diff --git a/internal/tiger/api/client.go b/internal/tiger/api/client.go index 78918761..f944e2dc 100644 --- a/internal/tiger/api/client.go +++ b/internal/tiger/api/client.go @@ -102,6 +102,9 @@ type ClientInterface interface { // GetAuthInfo request GetAuthInfo(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetDraftInvoice request + GetDraftInvoice(ctx context.Context, projectId ProjectId, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetServices request GetServices(ctx context.Context, projectId ProjectId, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -287,6 +290,18 @@ func (c *Client) GetAuthInfo(ctx context.Context, reqEditors ...RequestEditorFn) return c.Client.Do(req) } +func (c *Client) GetDraftInvoice(ctx context.Context, projectId ProjectId, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetDraftInvoiceRequest(c.Server, projectId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) GetServices(ctx context.Context, projectId ProjectId, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetServicesRequest(c.Server, projectId) if err != nil { @@ -946,6 +961,40 @@ func NewGetAuthInfoRequest(server string) (*http.Request, error) { return req, nil } +// NewGetDraftInvoiceRequest generates requests for GetDraftInvoice +func NewGetDraftInvoiceRequest(server string, projectId ProjectId) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "project_id", runtime.ParamLocationPath, projectId) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/projects/%s/billing/draft-invoice", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewGetServicesRequest generates requests for GetServices func NewGetServicesRequest(server string, projectId ProjectId) (*http.Request, error) { var err error @@ -2603,6 +2652,9 @@ type ClientWithResponsesInterface interface { // GetAuthInfoWithResponse request GetAuthInfoWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetAuthInfoResponse, error) + // GetDraftInvoiceWithResponse request + GetDraftInvoiceWithResponse(ctx context.Context, projectId ProjectId, reqEditors ...RequestEditorFn) (*GetDraftInvoiceResponse, error) + // GetServicesWithResponse request GetServicesWithResponse(ctx context.Context, projectId ProjectId, reqEditors ...RequestEditorFn) (*GetServicesResponse, error) @@ -2797,6 +2849,29 @@ func (r GetAuthInfoResponse) StatusCode() int { return 0 } +type GetDraftInvoiceResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DraftInvoice + JSON4XX *ClientError +} + +// Status returns HTTPResponse.Status +func (r GetDraftInvoiceResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetDraftInvoiceResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type GetServicesResponse struct { Body []byte HTTPResponse *http.Response @@ -3570,6 +3645,15 @@ func (c *ClientWithResponses) GetAuthInfoWithResponse(ctx context.Context, reqEd return ParseGetAuthInfoResponse(rsp) } +// GetDraftInvoiceWithResponse request returning *GetDraftInvoiceResponse +func (c *ClientWithResponses) GetDraftInvoiceWithResponse(ctx context.Context, projectId ProjectId, reqEditors ...RequestEditorFn) (*GetDraftInvoiceResponse, error) { + rsp, err := c.GetDraftInvoice(ctx, projectId, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetDraftInvoiceResponse(rsp) +} + // GetServicesWithResponse request returning *GetServicesResponse func (c *ClientWithResponses) GetServicesWithResponse(ctx context.Context, projectId ProjectId, reqEditors ...RequestEditorFn) (*GetServicesResponse, error) { rsp, err := c.GetServices(ctx, projectId, reqEditors...) @@ -4069,6 +4153,39 @@ func ParseGetAuthInfoResponse(rsp *http.Response) (*GetAuthInfoResponse, error) return response, nil } +// ParseGetDraftInvoiceResponse parses an HTTP response from a GetDraftInvoiceWithResponse call +func ParseGetDraftInvoiceResponse(rsp *http.Response) (*GetDraftInvoiceResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetDraftInvoiceResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DraftInvoice + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode/100 == 4: + var dest ClientError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON4XX = &dest + + } + + return response, nil +} + // ParseGetServicesResponse parses an HTTP response from a GetServicesWithResponse call func ParseGetServicesResponse(rsp *http.Response) (*GetServicesResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/internal/tiger/api/mocks/mock_client.go b/internal/tiger/api/mocks/mock_client.go index 5edb0d88..2b65b071 100644 --- a/internal/tiger/api/mocks/mock_client.go +++ b/internal/tiger/api/mocks/mock_client.go @@ -542,6 +542,26 @@ func (mr *MockClientInterfaceMockRecorder) GetAuthInfo(ctx any, reqEditors ...an return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthInfo", reflect.TypeOf((*MockClientInterface)(nil).GetAuthInfo), varargs...) } +// GetDraftInvoice mocks base method. +func (m *MockClientInterface) GetDraftInvoice(ctx context.Context, projectId api.ProjectId, reqEditors ...api.RequestEditorFn) (*http.Response, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, projectId} + for _, a := range reqEditors { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "GetDraftInvoice", varargs...) + ret0, _ := ret[0].(*http.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetDraftInvoice indicates an expected call of GetDraftInvoice. +func (mr *MockClientInterfaceMockRecorder) GetDraftInvoice(ctx, projectId any, reqEditors ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, projectId}, reqEditors...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDraftInvoice", reflect.TypeOf((*MockClientInterface)(nil).GetDraftInvoice), varargs...) +} + // GetReplicaSets mocks base method. func (m *MockClientInterface) GetReplicaSets(ctx context.Context, projectId api.ProjectId, serviceId api.ServiceId, reqEditors ...api.RequestEditorFn) (*http.Response, error) { m.ctrl.T.Helper() @@ -1586,6 +1606,26 @@ func (mr *MockClientWithResponsesInterfaceMockRecorder) GetAuthInfoWithResponse( return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthInfoWithResponse", reflect.TypeOf((*MockClientWithResponsesInterface)(nil).GetAuthInfoWithResponse), varargs...) } +// GetDraftInvoiceWithResponse mocks base method. +func (m *MockClientWithResponsesInterface) GetDraftInvoiceWithResponse(ctx context.Context, projectId api.ProjectId, reqEditors ...api.RequestEditorFn) (*api.GetDraftInvoiceResponse, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, projectId} + for _, a := range reqEditors { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "GetDraftInvoiceWithResponse", varargs...) + ret0, _ := ret[0].(*api.GetDraftInvoiceResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetDraftInvoiceWithResponse indicates an expected call of GetDraftInvoiceWithResponse. +func (mr *MockClientWithResponsesInterfaceMockRecorder) GetDraftInvoiceWithResponse(ctx, projectId any, reqEditors ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, projectId}, reqEditors...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDraftInvoiceWithResponse", reflect.TypeOf((*MockClientWithResponsesInterface)(nil).GetDraftInvoiceWithResponse), varargs...) +} + // GetReplicaSetsWithResponse mocks base method. func (m *MockClientWithResponsesInterface) GetReplicaSetsWithResponse(ctx context.Context, projectId api.ProjectId, serviceId api.ServiceId, reqEditors ...api.RequestEditorFn) (*api.GetReplicaSetsResponse, error) { m.ctrl.T.Helper() diff --git a/internal/tiger/api/types.go b/internal/tiger/api/types.go index bcb0439a..e74da860 100644 --- a/internal/tiger/api/types.go +++ b/internal/tiger/api/types.go @@ -123,6 +123,28 @@ type ConnectionPooler struct { // DeployStatus defines model for DeployStatus. type DeployStatus string +// DraftInvoice defines model for DraftInvoice. +type DraftInvoice struct { + AmountDue *string `json:"amount_due,omitempty"` + BillingAccountId *string `json:"billing_account_id,omitempty"` + BillingPeriodEnd *time.Time `json:"billing_period_end,omitempty"` + BillingPeriodStart *time.Time `json:"billing_period_start,omitempty"` + Currency *string `json:"currency,omitempty"` + LineItems *[]DraftInvoiceLineItem `json:"line_items,omitempty"` + + // Status Invoice status; "none" when nothing is due. + Status *string `json:"status,omitempty"` + Subtotal *string `json:"subtotal,omitempty"` + Total *string `json:"total,omitempty"` +} + +// DraftInvoiceLineItem defines model for DraftInvoiceLineItem. +type DraftInvoiceLineItem struct { + Amount *string `json:"amount,omitempty"` + Label *string `json:"label,omitempty"` + ServiceId *string `json:"service_id,omitempty"` +} + // Endpoint defines model for Endpoint. type Endpoint struct { Host *string `json:"host,omitempty"` diff --git a/internal/tiger/cmd/billing.go b/internal/tiger/cmd/billing.go new file mode 100644 index 00000000..bd02249d --- /dev/null +++ b/internal/tiger/cmd/billing.go @@ -0,0 +1,147 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/olekukonko/tablewriter" + "github.com/spf13/cobra" + + "github.com/timescale/tiger-cli/internal/tiger/api" + "github.com/timescale/tiger-cli/internal/tiger/common" + "github.com/timescale/tiger-cli/internal/tiger/util" +) + +// buildBillingCmd creates the main billing command with all subcommands +func buildBillingCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "billing", + Aliases: []string{"invoice"}, + Short: "View billing information", + Long: `View billing information for the current Tiger Cloud project.`, + } + + cmd.AddCommand(buildBillingDraftInvoiceCmd()) + + return cmd +} + +// buildBillingDraftInvoiceCmd represents the draft-invoice command under billing +func buildBillingDraftInvoiceCmd() *cobra.Command { + var output string + + cmd := &cobra.Command{ + Use: "draft-invoice", + Aliases: []string{"current-invoice"}, + Short: "Show the current draft invoice", + Long: `Show the current draft (upcoming) invoice for the current project, +including a per-service cost breakdown. + +Examples: + # Show the current draft invoice + tiger billing draft-invoice + + # Show the draft invoice in JSON format + tiger billing draft-invoice --output json + + # Show the draft invoice in YAML format + tiger billing draft-invoice --output yaml`, + Args: cobra.NoArgs, + PreRunE: bindFlags("output"), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + + // Load config and API client + cfg, err := common.LoadConfig(cmd.Context()) + if err != nil { + return err + } + + // Make API call to get the draft invoice + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) + defer cancel() + + resp, err := cfg.Client.GetDraftInvoiceWithResponse(ctx, cfg.ProjectID) + if err != nil { + return fmt.Errorf("failed to get draft invoice: %w", err) + } + + // Handle API response + if resp.StatusCode() != http.StatusOK { + return common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + + if resp.JSON200 == nil { + return fmt.Errorf("empty response from API") + } + + // Output draft invoice in requested format + return outputDraftInvoice(cmd, *resp.JSON200, cfg.Output) + }, + } + + cmd.Flags().VarP((*outputFlag)(&output), "output", "o", "Output format (json, yaml, table)") + + return cmd +} + +// outputDraftInvoice formats and outputs the draft invoice based on the specified format +func outputDraftInvoice(cmd *cobra.Command, invoice api.DraftInvoice, format string) error { + outputWriter := cmd.OutOrStdout() + + switch strings.ToLower(format) { + case "json": + return util.SerializeToJSON(outputWriter, invoice) + case "yaml": + return util.SerializeToYAML(outputWriter, invoice) + default: // table format (default) + return outputDraftInvoiceTable(invoice, outputWriter) + } +} + +// outputDraftInvoiceTable outputs detailed draft invoice information in formatted tables +func outputDraftInvoiceTable(invoice api.DraftInvoice, output io.Writer) error { + // Summary table + summary := tablewriter.NewWriter(output) + summary.Header("PROPERTY", "VALUE") + + summary.Append("Status", util.Deref(invoice.Status)) + summary.Append("Currency", util.Deref(invoice.Currency)) + if invoice.BillingPeriodStart != nil { + summary.Append("Period Start", invoice.BillingPeriodStart.Format("2006-01-02")) + } + if invoice.BillingPeriodEnd != nil { + summary.Append("Period End", invoice.BillingPeriodEnd.Format("2006-01-02")) + } + summary.Append("Subtotal", util.Deref(invoice.Subtotal)) + summary.Append("Total", util.Deref(invoice.Total)) + summary.Append("Amount Due", util.Deref(invoice.AmountDue)) + + if err := summary.Render(); err != nil { + return err + } + + // Per-service line items table + if invoice.LineItems != nil && len(*invoice.LineItems) > 0 { + items := tablewriter.NewWriter(output) + items.Header("SERVICE ID", "PRODUCT", "AMOUNT") + + for _, item := range *invoice.LineItems { + items.Append( + util.Deref(item.ServiceId), + util.Deref(item.Label), + util.Deref(item.Amount), + ) + } + + if err := items.Render(); err != nil { + return err + } + } + + return nil +} diff --git a/internal/tiger/cmd/billing_test.go b/internal/tiger/cmd/billing_test.go new file mode 100644 index 00000000..2ca3872a --- /dev/null +++ b/internal/tiger/cmd/billing_test.go @@ -0,0 +1,126 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/timescale/tiger-cli/internal/tiger/api" + "github.com/timescale/tiger-cli/internal/tiger/config" +) + +func setupBillingTest(t *testing.T) string { + t.Helper() + + // Use a unique keyring service name for this test to avoid conflicts + config.SetTestServiceName(t) + + tmpDir, err := os.MkdirTemp("", "tiger-billing-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + os.Setenv("TIGER_CONFIG_DIR", tmpDir) + os.Setenv("TIGER_ANALYTICS", "false") + + config.ResetGlobalConfig() + + t.Cleanup(func() { + config.ResetGlobalConfig() + os.Unsetenv("TIGER_CONFIG_DIR") + os.Unsetenv("TIGER_ANALYTICS") + os.RemoveAll(tmpDir) + }) + + return tmpDir +} + +func executeBillingCommand(ctx context.Context, args ...string) (string, error, *cobra.Command) { + testRoot, err := buildRootCmd(ctx) + if err != nil { + return "", err, nil + } + + buf := new(bytes.Buffer) + testRoot.SetOut(buf) + testRoot.SetErr(buf) + testRoot.SetArgs(args) + + err = testRoot.Execute() + return buf.String(), err, testRoot +} + +func TestBillingDraftInvoice_JSON(t *testing.T) { + tmpDir := setupBillingTest(t) + + projectID := "test-project-789" + + // Mock server that serves the draft-invoice endpoint + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expectedPath := "/projects/" + projectID + "/billing/draft-invoice" + if r.URL.Path != expectedPath { + w.WriteHeader(http.StatusNotFound) + return + } + + serviceID := "svc-1" + label := "Compute" + amount := "15.00" + invoice := api.DraftInvoice{ + BillingAccountId: strPtr("42"), + Currency: strPtr("USD"), + Status: strPtr("draft"), + AmountDue: strPtr("15.00"), + Subtotal: strPtr("15.00"), + Total: strPtr("15.00"), + LineItems: &[]api.DraftInvoiceLineItem{ + {ServiceId: &serviceID, Label: &label, Amount: &amount}, + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(invoice) + })) + defer mockServer.Close() + + // Point config at the mock server + configFile := config.GetConfigFile(tmpDir) + configContent := "api_url: \"" + mockServer.URL + "\"\n" + if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Store credentials so the project ID is available without /auth/info + if err := config.StoreCredentials("test-api-key-789", projectID); err != nil { + t.Fatalf("Failed to store credentials: %v", err) + } + + output, err, _ := executeBillingCommand(t.Context(), "billing", "draft-invoice", "--output", "json") + if err != nil { + t.Fatalf("draft-invoice failed: %v", err) + } + + var result api.DraftInvoice + if err := json.Unmarshal([]byte(output), &result); err != nil { + t.Fatalf("Output should be valid JSON: %v\noutput: %s", err, output) + } + + if result.Status == nil || *result.Status != "draft" { + t.Errorf("Expected status 'draft', got: %v", result.Status) + } + + if !strings.Contains(output, "Compute") { + t.Errorf("Expected output to contain line item label 'Compute', got: %s", output) + } +} + +func strPtr(s string) *string { + return &s +} diff --git a/internal/tiger/cmd/root.go b/internal/tiger/cmd/root.go index c8512b3d..381088de 100644 --- a/internal/tiger/cmd/root.go +++ b/internal/tiger/cmd/root.go @@ -117,6 +117,7 @@ tiger auth login cmd.AddCommand(buildConfigCmd()) cmd.AddCommand(buildAuthCmd()) cmd.AddCommand(buildServiceCmd()) + cmd.AddCommand(buildBillingCmd()) cmd.AddCommand(buildDbCmd()) cmd.AddCommand(buildMCPCmd()) diff --git a/internal/tiger/mcp/billing_tools.go b/internal/tiger/mcp/billing_tools.go new file mode 100644 index 00000000..dc62bf61 --- /dev/null +++ b/internal/tiger/mcp/billing_tools.go @@ -0,0 +1,150 @@ +package mcp + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "go.uber.org/zap" + + "github.com/timescale/tiger-cli/internal/tiger/api" + "github.com/timescale/tiger-cli/internal/tiger/common" + "github.com/timescale/tiger-cli/internal/tiger/logging" + "github.com/timescale/tiger-cli/internal/tiger/util" +) + +// MCP tool name for billing. +const ( + toolBillingGetDraftInvoice = "billing_get_draft_invoice" +) + +// BillingGetDraftInvoiceInput represents input for billing_get_draft_invoice. +// It takes no parameters and operates on the current project. +type BillingGetDraftInvoiceInput struct{} + +func (BillingGetDraftInvoiceInput) Schema() *jsonschema.Schema { + return util.Must(jsonschema.For[BillingGetDraftInvoiceInput](nil)) +} + +// BillingGetDraftInvoiceOutput represents output for billing_get_draft_invoice. +type BillingGetDraftInvoiceOutput struct { + Invoice DraftInvoiceDetail `json:"invoice"` +} + +func (BillingGetDraftInvoiceOutput) Schema() *jsonschema.Schema { + return util.Must(jsonschema.For[BillingGetDraftInvoiceOutput](nil)) +} + +// DraftInvoiceDetail represents the current draft invoice for the project. +type DraftInvoiceDetail struct { + BillingAccountID string `json:"billing_account_id,omitempty"` + Currency string `json:"currency,omitempty"` + Status string `json:"status" jsonschema:"Invoice status; \"none\" when nothing is due"` + BillingPeriodStart string `json:"billing_period_start,omitempty" jsonschema:"Start of the billing period (RFC3339)"` + BillingPeriodEnd string `json:"billing_period_end,omitempty" jsonschema:"End of the billing period (RFC3339)"` + AmountDue string `json:"amount_due,omitempty"` + Subtotal string `json:"subtotal,omitempty"` + Total string `json:"total,omitempty"` + LineItems []DraftInvoiceLineItemDetail `json:"line_items,omitempty"` +} + +func (DraftInvoiceDetail) Schema() *jsonschema.Schema { + return util.Must(jsonschema.For[DraftInvoiceDetail](nil)) +} + +// DraftInvoiceLineItemDetail represents a per-service line item on the draft invoice. +type DraftInvoiceLineItemDetail struct { + ServiceID string `json:"service_id,omitempty"` + Label string `json:"label,omitempty"` + Amount string `json:"amount,omitempty"` +} + +// registerBillingTools registers billing tools with comprehensive schemas and descriptions +func (s *Server) registerBillingTools() { + // billing_get_draft_invoice + mcp.AddTool(s.mcpServer, &mcp.Tool{ + Name: toolBillingGetDraftInvoice, + Title: "Get Draft Invoice", + Description: "Get the current draft (upcoming) invoice for the current Tiger Cloud project. " + + "Returns billing period, totals, amount due, and a per-service cost breakdown. " + + "Status is \"none\" with zeroed totals when nothing is due yet.", + InputSchema: BillingGetDraftInvoiceInput{}.Schema(), + OutputSchema: BillingGetDraftInvoiceOutput{}.Schema(), + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: true, + OpenWorldHint: util.Ptr(true), + Title: "Get Draft Invoice", + }, + }, s.handleBillingGetDraftInvoice) +} + +// handleBillingGetDraftInvoice handles the billing_get_draft_invoice MCP tool +func (s *Server) handleBillingGetDraftInvoice(ctx context.Context, req *mcp.CallToolRequest, input BillingGetDraftInvoiceInput) (*mcp.CallToolResult, BillingGetDraftInvoiceOutput, error) { + // Load config and API client + cfg, err := common.LoadConfig(ctx) + if err != nil { + return nil, BillingGetDraftInvoiceOutput{}, err + } + + logging.Debug("MCP: Getting draft invoice", + zap.String("project_id", cfg.ProjectID)) + + // Make API call to get the draft invoice + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + resp, err := cfg.Client.GetDraftInvoiceWithResponse(ctx, cfg.ProjectID) + if err != nil { + return nil, BillingGetDraftInvoiceOutput{}, fmt.Errorf("failed to get draft invoice: %w", err) + } + + // Handle API response + if resp.StatusCode() != http.StatusOK { + return nil, BillingGetDraftInvoiceOutput{}, resp.JSON4XX + } + + if resp.JSON200 == nil { + return nil, BillingGetDraftInvoiceOutput{}, fmt.Errorf("empty response from API") + } + + output := BillingGetDraftInvoiceOutput{ + Invoice: convertToDraftInvoiceDetail(*resp.JSON200), + } + + return nil, output, nil +} + +// convertToDraftInvoiceDetail converts an api.DraftInvoice into the MCP output shape +func convertToDraftInvoiceDetail(invoice api.DraftInvoice) DraftInvoiceDetail { + detail := DraftInvoiceDetail{ + BillingAccountID: util.Deref(invoice.BillingAccountId), + Currency: util.Deref(invoice.Currency), + Status: util.Deref(invoice.Status), + AmountDue: util.Deref(invoice.AmountDue), + Subtotal: util.Deref(invoice.Subtotal), + Total: util.Deref(invoice.Total), + } + + if invoice.BillingPeriodStart != nil { + detail.BillingPeriodStart = invoice.BillingPeriodStart.Format(time.RFC3339) + } + if invoice.BillingPeriodEnd != nil { + detail.BillingPeriodEnd = invoice.BillingPeriodEnd.Format(time.RFC3339) + } + + if invoice.LineItems != nil { + detail.LineItems = make([]DraftInvoiceLineItemDetail, 0, len(*invoice.LineItems)) + for _, item := range *invoice.LineItems { + detail.LineItems = append(detail.LineItems, DraftInvoiceLineItemDetail{ + ServiceID: util.Deref(item.ServiceId), + Label: util.Deref(item.Label), + Amount: util.Deref(item.Amount), + }) + } + } + + return detail +} diff --git a/internal/tiger/mcp/billing_tools_test.go b/internal/tiger/mcp/billing_tools_test.go new file mode 100644 index 00000000..4f921f90 --- /dev/null +++ b/internal/tiger/mcp/billing_tools_test.go @@ -0,0 +1,107 @@ +package mcp + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/timescale/tiger-cli/internal/tiger/api" + "github.com/timescale/tiger-cli/internal/tiger/config" +) + +func setupBillingToolTest(t *testing.T) string { + t.Helper() + + // Use a unique keyring service name for this test to avoid conflicts + config.SetTestServiceName(t) + + tmpDir, err := os.MkdirTemp("", "tiger-mcp-billing-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + os.Setenv("TIGER_CONFIG_DIR", tmpDir) + os.Setenv("TIGER_ANALYTICS", "false") + + config.ResetGlobalConfig() + + t.Cleanup(func() { + config.ResetGlobalConfig() + os.Unsetenv("TIGER_CONFIG_DIR") + os.Unsetenv("TIGER_ANALYTICS") + os.RemoveAll(tmpDir) + }) + + return tmpDir +} + +func TestBillingGetDraftInvoice(t *testing.T) { + tmpDir := setupBillingToolTest(t) + + projectID := "test-project-789" + + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expectedPath := "/projects/" + projectID + "/billing/draft-invoice" + if r.URL.Path != expectedPath { + w.WriteHeader(http.StatusNotFound) + return + } + + serviceID := "svc-1" + label := "Compute" + amount := "15.00" + invoice := api.DraftInvoice{ + BillingAccountId: ptr("42"), + Currency: ptr("USD"), + Status: ptr("draft"), + AmountDue: ptr("15.00"), + Subtotal: ptr("15.00"), + Total: ptr("15.00"), + LineItems: &[]api.DraftInvoiceLineItem{ + {ServiceId: &serviceID, Label: &label, Amount: &amount}, + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(invoice) + })) + defer mockServer.Close() + + if _, err := config.UseTestConfig(tmpDir, map[string]any{ + "api_url": mockServer.URL, + }); err != nil { + t.Fatalf("Failed to save test config: %v", err) + } + + if err := config.StoreCredentials("test-api-key-789", projectID); err != nil { + t.Fatalf("Failed to store credentials: %v", err) + } + + s := &Server{} + _, output, err := s.handleBillingGetDraftInvoice(t.Context(), nil, BillingGetDraftInvoiceInput{}) + if err != nil { + t.Fatalf("handleBillingGetDraftInvoice failed: %v", err) + } + + if output.Invoice.Status != "draft" { + t.Errorf("Expected status 'draft', got: %q", output.Invoice.Status) + } + if output.Invoice.Currency != "USD" { + t.Errorf("Expected currency 'USD', got: %q", output.Invoice.Currency) + } + if output.Invoice.Total != "15.00" { + t.Errorf("Expected total '15.00', got: %q", output.Invoice.Total) + } + if len(output.Invoice.LineItems) != 1 { + t.Fatalf("Expected 1 line item, got: %d", len(output.Invoice.LineItems)) + } + if output.Invoice.LineItems[0].Label != "Compute" { + t.Errorf("Expected line item label 'Compute', got: %q", output.Invoice.LineItems[0].Label) + } +} + +func ptr(s string) *string { + return &s +} diff --git a/internal/tiger/mcp/server.go b/internal/tiger/mcp/server.go index 28f53c5f..b0b22187 100644 --- a/internal/tiger/mcp/server.go +++ b/internal/tiger/mcp/server.go @@ -89,6 +89,9 @@ func (s *Server) registerTools(ctx context.Context) { // Service management tools s.registerServiceTools() + // Billing tools + s.registerBillingTools() + // Database operation tools s.registerDatabaseTools() diff --git a/openapi.yaml b/openapi.yaml index 8fdce7b6..bb3dc58a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -365,6 +365,28 @@ paths: '4XX': $ref: '#/components/responses/ClientError' + /projects/{project_id}/billing/draft-invoice: + get: + operationId: getDraftInvoice + tags: + - Billing + summary: Get the current draft invoice + description: > + Retrieves the current draft (upcoming) invoice for the project, including a + per-service cost breakdown. Returns status "none" with zeroed totals when + nothing is due yet. + parameters: + - $ref: '#/components/parameters/ProjectId' + responses: + '200': + description: The current draft invoice. + content: + application/json: + schema: + $ref: '#/components/schemas/DraftInvoice' + '4XX': + $ref: '#/components/responses/ClientError' + /projects/{project_id}/services/{service_id}/start: post: operationId: startService @@ -1092,6 +1114,46 @@ components: type: array items: $ref: '#/components/schemas/ReadReplicaSet' + DraftInvoice: + type: object + properties: + billing_account_id: + type: string + currency: + type: string + example: "USD" + status: + type: string + description: 'Invoice status; "none" when nothing is due.' + example: "draft" + billing_period_start: + type: string + format: date-time + billing_period_end: + type: string + format: date-time + amount_due: + type: string + example: "123.45" + subtotal: + type: string + total: + type: string + line_items: + type: array + items: + $ref: '#/components/schemas/DraftInvoiceLineItem' + DraftInvoiceLineItem: + type: object + properties: + service_id: + type: string + label: + type: string + example: "Compute" + amount: + type: string + example: "12.34" ServiceType: type: string enum: