diff --git a/README.md b/README.md index 54d5e7a..36171b9 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ accepted. | Command | Operations | | -------------------------- | ----------------------------------------------------------------------------------------------- | | `kaiten customers` | `list`, `get`, `create`, `update`, `delete` | -| `kaiten instances` | `list`, `get`, `create`, `update`, `delete`, `audit-trails`, `usage list\|get\|report` | +| `kaiten instances` | `list`, `get`, `create`, `update`, `delete`, `audit-trails`, `usage list\|get\|report\|history\|export` | | `kaiten licenses` | `list`, `get`, `create`, `update`, `delete`, `entitlements list\|get\|associate\|update\|delete` | | `kaiten entitlements` | `list`, `get`, `create`, `update`, `delete` | | `kaiten entitlement-groups`| `list`, `get`, `create`, `update`, `delete`, `add-entitlement`, `remove-entitlement`, `usage` | @@ -99,6 +99,7 @@ accepted. | `kaiten releases` | `list`, `get`, `create`, `delete` | | `kaiten service-accounts` | `list`, `get`, `create`, `update`, `tokens list\|create\|delete` | | `kaiten deployment-zones` | `list`, `get`, `create`, `update`, `delete` | +| `kaiten billing` | `settings`, `capabilities`, `health`, `sync`, `close-periods`, `handoff`, `invoices`, `subscriptions`, `prices`, `addons`, `instance-addons`, `vouchers`, `publishable-keys`, `customer-sessions`, `customers` | | `kaiten config` | `set`, `unset`, `view` | | `kaiten doctor` | Validate configuration and API connectivity | | `kaiten version` | Print version, commit and build date | @@ -116,7 +117,8 @@ kaiten licenses entitlements list team-v1 kaiten instances audit-trails acme-production --event-name INSTANCE_UPDATED --after 2026-01-01T00:00:00Z --limit 100 ``` -`--output` (or `KAITEN_OUTPUT`) selects `table`, `json` or `yaml`. +`--output` (or `KAITEN_OUTPUT`) selects `table`, `json`, `yaml` or `ndjson` (one compact +JSON document per line: one per row of a list). Without it, the format follows what the command returns. A list prints a table. A single resource (`get`, `create`, a usage report, a new token) prints YAML, and so does @@ -210,6 +212,62 @@ kaiten instances usage list acme-production kaiten entitlement-groups usage compute acme-production ``` +A report without a key is sent once and never retried: if it fails in flight, it may +or may not have been counted. Give it a `--transaction-id` and Kaiten applies it at +most once per key, so the CLI retries it on network errors and on 500, 502, 503 and +504, and running the same command again is safe. A report the server had already +counted is answered from the first time, with a note on stderr; stdout is unchanged. + +```shell +kaiten instances usage report acme-production tokens --value 1200 \ + --transaction-id llm-call:9f2c:tokens +``` + +A key already used for a different report exits `5`: send a correction as a new +report under a new key. + +The usage history lists every accepted report, with the counter before and after it +and the limit it was gated on. The range defaults to the last 30 days. + +```shell +kaiten instances usage history acme-production tokens --from 2026-10-01T00:00:00Z + +# Stream it as CSV (default) or NDJSON: one pair, up to 366 days per run... +kaiten instances usage export acme-production tokens > tokens.csv +# ...or the whole organization, up to 31 days per run, narrowed if needed +kaiten instances usage export --from 2026-09-01T00:00:00Z --to 2026-10-01T00:00:00Z \ + --format json --output-file usage-september.ndjson +``` + +`--instance-id` and `--entitlement-id` reach deleted instances and entitlements, +whose reports are kept. A `--from` before the start of the organization's history +exits `5`. Exports are written as they arrive and are not bound by the CLI's request +timeout; `--output-file` only takes its final name once the export is complete. + +### Billing + +```shell +# Invoices: filter, export, settle +kaiten billing invoices list --overdue --customer acme +kaiten billing invoices list --updated-since 2027-01-01T00:00:00Z --output json +kaiten billing invoices export --from 2027-01-01T00:00:00Z --to 2027-02-01T00:00:00Z \ + --granularity invoice --output-file january.csv +kaiten billing invoices mark-paid inv_123 --external-reference WIRE-9 +kaiten billing invoices void inv_123 --reason "duplicate" --yes + +# The accounting handoff: lease a batch, book it, acknowledge each invoice +kaiten billing handoff claim --limit 50 --lease 30m --output ndjson > batch.ndjson +kaiten billing handoff ack inv_123 --lease-id lease_456 --external-reference ACC-2027-0001 + +# Subscriptions, prices, add-ons and vouchers take their body from --file or --payload +kaiten billing subscriptions subscribe acme-prod --payload '{"basePriceId":"price_1"}' +kaiten billing prices preview team-v1 --file scenario.yaml +kaiten billing instance-addons set-quantity acme-prod extra-seats --quantity 5 +``` + +Amounts are printed in the currency's minor units, as the API carries them. An +acknowledgement with `--lease-id` for an invoice another claim took since exits `5`. + ### Service account tokens ```shell @@ -222,7 +280,9 @@ The plaintext token is printed once; the API stores a hash. ### Destructive commands -`delete`, `remove-entitlement` and `config unset` echo their target and ask for +`delete`, `remove-entitlement`, `config unset` and the billing commands that +cannot be undone (`invoices void`, `invoices write-off`, `subscriptions cancel`, +`instance-addons detach`, the `revoke`s, ...) echo their target and ask for confirmation. When stdin is not an interactive terminal they refuse to run instead of prompting, so scripts and CI pipelines pass `--yes`: diff --git a/go.mod b/go.mod index eb47993..d74ad17 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/kaitencloud/cli go 1.25.0 require ( - github.com/kaitencloud/sdk-go v0.1.0 + github.com/kaitencloud/sdk-go v0.3.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 22bc578..83760b2 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= -github.com/kaitencloud/sdk-go v0.1.0 h1:r75JTSnJAxrzGsMRTTt2qxJ3wWpKrAgV5wLZ2CnZycU= -github.com/kaitencloud/sdk-go v0.1.0/go.mod h1:KE77iZ8i+SfjjhUf+qPaQrLHq3/EpFuz4+UPtZ7WFeM= +github.com/kaitencloud/sdk-go v0.3.0 h1:jggYxW7xgI5TgwYIOCSfIF55uqmqN1lRCtpAJl9SrFo= +github.com/kaitencloud/sdk-go v0.3.0/go.mod h1:KE77iZ8i+SfjjhUf+qPaQrLHq3/EpFuz4+UPtZ7WFeM= github.com/oapi-codegen/runtime v1.3.1 h1:RgDY6J4OGQLbRXhG/Xpt3vSVqYpHQS7hN4m85+5xB9g= github.com/oapi-codegen/runtime v1.3.1/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/internal/cmd/billing.go b/internal/cmd/billing.go new file mode 100644 index 0000000..3a73f7d --- /dev/null +++ b/internal/cmd/billing.go @@ -0,0 +1,1098 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/kaitencloud/cli/internal/output" + "github.com/kaitencloud/sdk-go" + "github.com/spf13/cobra" +) + +func newBillingCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "billing", + Short: "Manage billing: invoices, the accounting handoff, subscriptions and the catalogue", + Args: cobra.NoArgs, + RunE: runHelp, + } + + cmd.AddCommand( + newBillingSettingsCommand(), + billingCall{ + use: "capabilities", short: "Show what the configured billing provider can do", + run: func(ctx context.Context, c *sdk.Client, _ []string) (any, error) { return c.Billing.Capabilities(ctx) }, + }.command(), + billingCall{ + use: "health", short: "Show the billing provider's health and the work waiting on it", + run: func(ctx context.Context, c *sdk.Client, _ []string) (any, error) { return c.Billing.Health(ctx) }, + }.command(), + billingCall{ + use: "sync", short: "Reconcile with the billing provider, in the background", + run: func(ctx context.Context, c *sdk.Client, _ []string) (any, error) { return c.Billing.Sync(ctx) }, + }.command(), + billingBodyCall[sdk.ClosePeriodsScope]{ + use: "close-periods", short: "Close the billing periods whose boundary has passed", + optional: true, + run: func(ctx context.Context, c *sdk.Client, _ []string, in sdk.ClosePeriodsScope) (any, error) { + return c.Billing.ClosePeriods(ctx, in) + }, + }.command(), + newBillingHandoffCommand(), + newBillingInvoicesCommand(), + newBillingSubscriptionsCommand(), + newBillingPricesCommand(), + newBillingAddonsCommand(), + newBillingInstanceAddonsCommand(), + newBillingVouchersCommand(), + newBillingKeysCommand(), + newBillingSessionsCommand(), + newBillingCustomersCommand(), + ) + + return cmd +} + +func groupCommand(use, short string, children ...*cobra.Command) *cobra.Command { + cmd := &cobra.Command{Use: use, Short: short, Args: cobra.NoArgs, RunE: runHelp} + cmd.AddCommand(children...) + return cmd +} + +// billingCall is a billing command with positional arguments and no body. A +// run that answers nil reports message instead. +type billingCall struct { + use, short, long, message string + destructive string + run func(ctx context.Context, c *sdk.Client, args []string) (any, error) +} + +func (b billingCall) command() *cobra.Command { + cmd := &cobra.Command{ + Use: b.use, + Short: b.short, + Long: b.long, + Args: cobra.ExactArgs(positionalCount(b.use)), + RunE: func(cmd *cobra.Command, args []string) error { + if b.destructive != "" { + if err := confirmDestructive(cmd, b.destructive, strings.Join(args, " ")); err != nil { + return err + } + } + client, _, err := newClient(cmd) + if err != nil { + return err + } + ctx, cancel := commandContext(cmd) + defer cancel() + value, err := b.run(ctx, client, args) + if err != nil { + return err + } + return writeResult(cmd, value, b.message) + }, + } + if b.destructive != "" { + addConfirmFlag(cmd) + } + return cmd +} + +// billingBodyCall is a billing command whose request body comes from --file or +// --payload. optional lets the body be left out, as an empty document. +type billingBodyCall[T any] struct { + use, short, long, message string + destructive string + optional bool + run func(ctx context.Context, c *sdk.Client, args []string, in T) (any, error) +} + +func (b billingBodyCall[T]) command() *cobra.Command { + var file, payload string + cmd := &cobra.Command{ + Use: b.use, + Short: b.short, + Long: b.long, + Args: cobra.ExactArgs(positionalCount(b.use)), + RunE: func(cmd *cobra.Command, args []string) error { + var in T + if !b.optional || file != "" || payload != "" { + var err error + if in, err = resolveFileOrPayload[T](file, payload); err != nil { + return err + } + } + if b.destructive != "" { + if err := confirmDestructive(cmd, b.destructive, strings.Join(args, " ")); err != nil { + return err + } + } + client, _, err := newClient(cmd) + if err != nil { + return err + } + ctx, cancel := commandContext(cmd) + defer cancel() + value, err := b.run(ctx, client, args, in) + if err != nil { + return err + } + return writeResult(cmd, value, b.message) + }, + } + addInputSourceFlags(cmd, &file, &payload, "request") + if b.destructive != "" { + addConfirmFlag(cmd) + } + return cmd +} + +func positionalCount(use string) int { + count := 0 + for _, field := range strings.Fields(use)[1:] { + if strings.HasPrefix(field, "<") { + count++ + } + } + return count +} + +func writeResult(cmd *cobra.Command, value any, message string) error { + if value == nil { + return writeMessage(cmd, message) + } + return writeStructured(cmd, output.FormatYAML, value, output.Table{}) +} + +func newBillingSettingsCommand() *cobra.Command { + return groupCommand("settings", "Show or change the organization's billing settings", + billingCall{ + use: "get", short: "Show the billing settings", + run: func(ctx context.Context, c *sdk.Client, _ []string) (any, error) { return c.Billing.Settings(ctx) }, + }.command(), + billingBodyCall[sdk.BillingSettings]{ + use: "update", short: "Change the billing settings (a member left out is left alone)", + run: func(ctx context.Context, c *sdk.Client, _ []string, in sdk.BillingSettings) (any, error) { + return c.Billing.UpdateSettings(ctx, in) + }, + }.command(), + ) +} + +// --- Handoff --------------------------------------------------------------- + +func newBillingHandoffCommand() *cobra.Command { + var status string + list := &cobra.Command{ + Use: "list", + Short: "List the invoices waiting for, or leased to, the accounting handoff", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + client, _, err := newClient(cmd) + if err != nil { + return err + } + ctx, cancel := commandContext(cmd) + defer cancel() + items, err := client.Billing.Handoff.List(ctx, &sdk.ListHandoffParams{Status: optionalEnum[sdk.ListHandoffParamsStatus](cmd, "status", status)}) + if err != nil { + return err + } + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, []string{item.Id, item.CustomerSlug, formatMoney(item.Total, item.Currency), formatTime(item.IssuedAt), string(item.HandoffStatus), formatTime(item.Handoff.LeasedUntil)}) + } + return writeStructured(cmd, output.FormatTable, items, output.Table{ + Columns: []string{"INVOICE", "CUSTOMER", "TOTAL", "ISSUED", "HANDOFF", "LEASED UNTIL"}, + Rows: rows, + }) + }, + } + list.Flags().StringVar(&status, "status", "", "Only PENDING or ACKNOWLEDGED invoices") + + return groupCommand("handoff", "Hand invoices off to an accounting system: claim a leased batch, acknowledge each one", + list, newBillingHandoffClaimCommand(), newBillingHandoffAckCommand()) +} + +func newBillingHandoffClaimCommand() *cobra.Command { + var limit int32 + var lease time.Duration + + cmd := &cobra.Command{ + Use: "claim", + Short: "Lease a batch of invoices to hand off", + Long: `Lease up to --limit invoices to hand off, oldest issue first. Each stays +leased to this claim for --lease: acknowledge it with "kaiten billing handoff ack" +before then, or it is offered to the next claim. + +--output ndjson prints one invoice per line, each carrying its lease in +handoff.leaseId: the form a booking script reads line by line.`, + Args: cobra.NoArgs, + Example: ` kaiten billing handoff claim --limit 50 --lease 30m --output ndjson | + while read -r invoice; do book "$invoice"; done`, + RunE: func(cmd *cobra.Command, _ []string) error { + if lease < time.Minute || lease > time.Hour { + return errUsagef("--lease must be between 1m and 1h") + } + if limit < 1 || limit > 100 { + return errUsagef("--limit must be between 1 and 100") + } + client, cfg, err := newClient(cmd) + if err != nil { + return err + } + ctx, cancel := commandContext(cmd) + defer cancel() + seconds := int32(lease / time.Second) + claim, err := client.Billing.Handoff.Claim(ctx, sdk.HandoffClaimSize{Limit: &limit, LeaseSeconds: &seconds}) + if err != nil { + return err + } + if cfg.Output == output.FormatNDJSON { + return writeStructured(cmd, output.FormatNDJSON, claim.Invoices, output.Table{}) + } + fmt.Fprintf(cmd.ErrOrStderr(), "lease %s, until %s\n", claim.LeaseId, formatTimeValue(claim.LeasedUntil)) + rows := make([][]string, 0, len(claim.Invoices)) + for _, invoice := range claim.Invoices { + rows = append(rows, []string{invoice.Id, invoice.CustomerSlug, deref(invoice.BillingEmail), formatMoney(invoice.Total, invoice.Currency), formatTime(invoice.IssuedAt)}) + } + return writeStructured(cmd, output.FormatTable, claim, output.Table{ + Columns: []string{"INVOICE", "CUSTOMER", "BILLING EMAIL", "TOTAL", "ISSUED"}, + Rows: rows, + }) + }, + } + cmd.Flags().Int32Var(&limit, "limit", 25, "Invoices to lease, 1 to 100") + cmd.Flags().DurationVar(&lease, "lease", 15*time.Minute, "How long the invoices stay leased to this claim, 1m to 1h") + return cmd +} + +func newBillingHandoffAckCommand() *cobra.Command { + var leaseID, reference string + + cmd := &cobra.Command{ + Use: "ack ", + Short: "Acknowledge the handoff of an invoice", + Long: `Acknowledge that the accounting system booked an invoice. With --lease-id, an +invoice another claim took since is refused (exit code 5) rather than +acknowledged twice.`, + Args: cobra.ExactArgs(1), + Example: ` kaiten billing handoff ack inv_123 --lease-id lease_456 --external-reference ACC-2027-0001`, + RunE: func(cmd *cobra.Command, args []string) error { + client, _, err := newClient(cmd) + if err != nil { + return err + } + ctx, cancel := commandContext(cmd) + defer cancel() + invoice, err := client.Billing.Handoff.Ack(ctx, args[0], sdk.HandoffBooking{ + LeaseId: optionalString(cmd, "lease-id", leaseID), + ExternalReference: optionalString(cmd, "external-reference", reference), + }) + if err != nil { + return err + } + return writeInvoice(cmd, invoice) + }, + } + cmd.Flags().StringVar(&leaseID, "lease-id", "", "The claim's lease: refuse the acknowledgement if another claim took the invoice") + cmd.Flags().StringVar(&reference, "external-reference", "", "The invoice's number in the accounting system") + return cmd +} + +// --- Invoices -------------------------------------------------------------- + +func newBillingInvoicesCommand() *cobra.Command { + return groupCommand("invoices", "List, export and settle invoices", + newBillingInvoicesListCommand(), + billingCall{ + use: "get ", short: "Show an invoice with its lines", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Invoices.Get(ctx, args[0]) + }, + }.command(), + newBillingInvoicesExportCommand(), + newBillingInvoicesMarkPaidCommand(), + newBillingInvoicesReasonCommand("void", "Void an invoice", "void invoice", true, + func(ctx context.Context, c *sdk.Client, id, reason string) (sdk.Invoice, error) { + return c.Billing.Invoices.Void(ctx, id, sdk.InvoiceVoid{Reason: reason}) + }), + newBillingInvoicesReasonCommand("write-off", "Write an invoice off as uncollectable", "write off invoice", true, + func(ctx context.Context, c *sdk.Client, id, reason string) (sdk.Invoice, error) { + return c.Billing.Invoices.WriteOff(ctx, id, sdk.InvoiceWriteOff{Reason: reason}) + }), + newBillingInvoicesReasonCommand("release-hold", "Release an invoice held for review", "", false, + func(ctx context.Context, c *sdk.Client, id, reason string) (sdk.Invoice, error) { + return c.Billing.Invoices.ReleaseHold(ctx, id, sdk.HoldRelease{Reason: reason}) + }), + billingCall{ + use: "recompose ", short: "Compose a draft invoice again from what is known now", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Invoices.Recompose(ctx, args[0]) + }, + }.command(), + billingCall{ + use: "retry-push ", short: "Push an invoice to the billing provider again, in the background", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Invoices.RetryPush(ctx, args[0]) + }, + }.command(), + billingCall{ + use: "sync ", short: "Read an invoice's state back from the billing provider", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Invoices.Sync(ctx, args[0]) + }, + }.command(), + newBillingInvoicesLineReportsCommand(), + ) +} + +func writeInvoice(cmd *cobra.Command, invoice sdk.Invoice) error { + return writeStructured(cmd, output.FormatTable, invoice, output.Table{ + Columns: []string{"INVOICE", "STATUS", "KIND", "CUSTOMER", "INSTANCE", "TOTAL", "ISSUED", "DUE", "HANDOFF"}, + Rows: [][]string{{ + invoice.Id, string(invoice.Status), string(invoice.Kind), invoice.CustomerSlug, invoice.InstanceSlug, + formatMoney(invoice.Total, invoice.Currency), formatTime(invoice.IssuedAt), formatTime(invoice.DueAt), string(invoice.HandoffStatus), + }}, + }) +} + +// formatMoney renders an amount in the currency's minor units, as the API +// carries it: the CLI does not guess a currency's exponent. +func formatMoney(minor int64, currency string) string { + return fmt.Sprintf("%d %s", minor, strings.ToUpper(currency)) +} + +func newBillingInvoicesListCommand() *cobra.Command { + var statuses []string + var overdue, held bool + var customer, instance, from, to, updatedSince, kind, handoff string + var limit int32 + + cmd := &cobra.Command{ + Use: "list", + Short: "List the organization's invoices", + Long: `List invoices, newest first. --from and --to bound the issue date; +--updated-since lists what changed since a previous run, for a sync.`, + Args: cobra.NoArgs, + Example: ` kaiten billing invoices list --overdue + kaiten billing invoices list --status PUSH_FAILED --status PAYMENT_FAILED --customer acme + kaiten billing invoices list --updated-since 2027-01-01T00:00:00Z --output json`, + RunE: func(cmd *cobra.Command, _ []string) error { + params := sdk.ListInvoicesParams{ + CustomerSlug: optionalString(cmd, "customer", customer), + InstanceSlug: optionalString(cmd, "instance", instance), + Kind: optionalEnum[sdk.ListInvoicesParamsKind](cmd, "kind", kind), + HandoffStatus: optionalEnum[sdk.ListInvoicesParamsHandoffStatus](cmd, "handoff-status", handoff), + } + if flagChanged(cmd, "overdue") { + params.Overdue = &overdue + } + if flagChanged(cmd, "held") { + params.Held = &held + } + if len(statuses) > 0 { + values := make([]sdk.ListInvoicesParamsStatus, 0, len(statuses)) + for _, status := range statuses { + values = append(values, sdk.ListInvoicesParamsStatus(strings.ToUpper(status))) + } + params.Status = &values + } + var err error + if params.IssuedFrom, err = parseTimeFlag(from, "from"); err != nil { + return err + } + if params.IssuedTo, err = parseTimeFlag(to, "to"); err != nil { + return err + } + if params.UpdatedSince, err = parseTimeFlag(updatedSince, "updated-since"); err != nil { + return err + } + + client, _, err := newClient(cmd) + if err != nil { + return err + } + ctx, cancel := commandContext(cmd) + defer cancel() + var items []sdk.InvoiceSummary + if limit > 0 { + params.Limit = &limit + page, err := client.Billing.Invoices.ListPage(ctx, ¶ms) + if err != nil { + return err + } + items = page.Items + } else if items, err = client.Billing.Invoices.List(ctx, ¶ms); err != nil { + return err + } + + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, []string{ + item.Id, string(item.Status), string(item.Kind), item.CustomerSlug, item.InstanceSlug, + formatMoney(item.Total, item.Currency), formatTime(item.IssuedAt), formatTime(item.DueAt), string(item.HandoffStatus), + }) + } + return writeStructured(cmd, output.FormatTable, items, output.Table{ + Columns: []string{"INVOICE", "STATUS", "KIND", "CUSTOMER", "INSTANCE", "TOTAL", "ISSUED", "DUE", "HANDOFF"}, + Rows: rows, + }) + }, + } + cmd.Flags().StringArrayVar(&statuses, "status", nil, "Only invoices in this status (repeatable): DRAFT, MANUAL, PUSHED, PUSH_FAILED, PAID, PAYMENT_FAILED, UNCOLLECTIBLE, VOID") + cmd.Flags().BoolVar(&overdue, "overdue", false, "Only invoices past their due date and unpaid") + cmd.Flags().BoolVar(&held, "held", false, "Only invoices held for review") + cmd.Flags().StringVar(&customer, "customer", "", "Only this customer's invoices (slug)") + cmd.Flags().StringVar(&instance, "instance", "", "Only this instance's invoices (slug)") + cmd.Flags().StringVar(&kind, "kind", "", "Only invoices of this kind") + cmd.Flags().StringVar(&handoff, "handoff-status", "", "Only invoices in this handoff status: NOT_REQUIRED, PENDING, ACKNOWLEDGED") + cmd.Flags().StringVar(&from, "from", "", "Only invoices issued at or after this RFC3339 timestamp") + cmd.Flags().StringVar(&to, "to", "", "Only invoices issued before this RFC3339 timestamp") + cmd.Flags().StringVar(&updatedSince, "updated-since", "", "Only invoices changed at or after this RFC3339 timestamp") + cmd.Flags().Int32Var(&limit, "limit", 0, "Fetch one page of at most this many invoices (default: all of them)") + return cmd +} + +func newBillingInvoicesExportCommand() *cobra.Command { + var from, to, format, granularity, outputFile, customer string + + cmd := &cobra.Command{ + Use: "export", + Short: "Export the invoices issued in a range as CSV or JSON", + Long: `Export the invoices issued in [--from, --to), one row per line or per invoice, +to stdout or to --output-file. The --output flag does not apply: the document is +written as the API sends it, in --format.`, + Args: cobra.NoArgs, + Example: ` kaiten billing invoices export --from 2027-01-01T00:00:00Z --to 2027-02-01T00:00:00Z --output-file january.csv`, + RunE: func(cmd *cobra.Command, _ []string) error { + if format != "csv" && format != "json" { + return errUsagef("--format must be csv or json") + } + if granularity != "line" && granularity != "invoice" { + return errUsagef("--granularity must be line or invoice") + } + if from == "" || to == "" { + return errUsagef("--from and --to are required") + } + fromTime, err := parseTimeFlag(from, "from") + if err != nil { + return err + } + toTime, err := parseTimeFlag(to, "to") + if err != nil { + return err + } + client, _, err := newClient(cmd) + if err != nil { + return err + } + ctx, cancel := commandContext(cmd) + defer cancel() + document, err := client.Billing.Invoices.Export(ctx, &sdk.ExportInvoicesParams{ + IssuedFrom: fromTime, IssuedTo: toTime, Format: &format, Granularity: &granularity, + CustomerSlug: optionalString(cmd, "customer", customer), + }) + if err != nil { + return err + } + if outputFile == "" { + _, err := cmd.OutOrStdout().Write(document) + return err + } + partial := outputFile + ".partial" + if err := os.WriteFile(partial, document, 0o600); err != nil { + return fmt.Errorf("write %s: %w", outputFile, err) + } + if err := os.Rename(partial, outputFile); err != nil { + _ = os.Remove(partial) + return fmt.Errorf("write %s: %w", outputFile, err) + } + fmt.Fprintf(cmd.ErrOrStderr(), "wrote %d bytes to %s\n", len(document), outputFile) + return nil + }, + } + cmd.Flags().StringVar(&from, "from", "", "Invoices issued at or after this RFC3339 timestamp (required)") + cmd.Flags().StringVar(&to, "to", "", "Invoices issued before this RFC3339 timestamp (required)") + cmd.Flags().StringVar(&format, "format", "csv", "Export format: csv or json") + cmd.Flags().StringVar(&granularity, "granularity", "line", "One row per line or per invoice") + cmd.Flags().StringVar(&customer, "customer", "", "Only this customer's invoices (slug)") + cmd.Flags().StringVar(&outputFile, "output-file", "", "Write the export to this file instead of stdout") + return cmd +} + +func newBillingInvoicesMarkPaidCommand() *cobra.Command { + var paidAt, reference, note string + + cmd := &cobra.Command{ + Use: "mark-paid ", + Short: "Record an invoice as paid outside the billing provider", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + at, err := parseTimeFlag(paidAt, "paid-at") + if err != nil { + return err + } + client, _, err := newClient(cmd) + if err != nil { + return err + } + ctx, cancel := commandContext(cmd) + defer cancel() + invoice, err := client.Billing.Invoices.MarkPaid(ctx, args[0], sdk.InvoicePayment{ + PaidAt: at, + ExternalReference: optionalString(cmd, "external-reference", reference), + Note: optionalString(cmd, "note", note), + }) + if err != nil { + return err + } + return writeInvoice(cmd, invoice) + }, + } + cmd.Flags().StringVar(&paidAt, "paid-at", "", "When it was paid, RFC3339 (default: now)") + cmd.Flags().StringVar(&reference, "external-reference", "", "The payment's reference, e.g. a bank transfer's") + cmd.Flags().StringVar(¬e, "note", "", "A note kept with the payment") + return cmd +} + +func newBillingInvoicesReasonCommand(use, short, destructive string, confirm bool, run func(ctx context.Context, c *sdk.Client, id, reason string) (sdk.Invoice, error)) *cobra.Command { + var reason string + + cmd := &cobra.Command{ + Use: use + " ", + Short: short, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(reason) == "" { + return errUsagef("--reason is required") + } + if confirm { + if err := confirmDestructive(cmd, destructive, args[0]); err != nil { + return err + } + } + client, _, err := newClient(cmd) + if err != nil { + return err + } + ctx, cancel := commandContext(cmd) + defer cancel() + invoice, err := run(ctx, client, args[0], reason) + if err != nil { + return err + } + return writeInvoice(cmd, invoice) + }, + } + cmd.Flags().StringVar(&reason, "reason", "", "Why, kept on the invoice (required)") + if confirm { + addConfirmFlag(cmd) + } + return cmd +} + +func newBillingInvoicesLineReportsCommand() *cobra.Command { + var afterSeq int64 + var limit int32 + + cmd := &cobra.Command{ + Use: "line-reports ", + Short: "List the usage reports a metered invoice line was rated from", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client, _, err := newClient(cmd) + if err != nil { + return err + } + ctx, cancel := commandContext(cmd) + defer cancel() + params := sdk.ListInvoiceLineReportsParams{} + if flagChanged(cmd, "after-seq") { + params.AfterSeq = &afterSeq + } + params.Limit = optionalInt32(limit) + page, err := client.Billing.Invoices.ListLineReports(ctx, args[0], args[1], ¶ms) + if err != nil { + return err + } + return writeStructured(cmd, output.FormatYAML, page, output.Table{}) + }, + } + cmd.Flags().Int64Var(&afterSeq, "after-seq", 0, "Only reports after this sequence number: the previous page's last") + cmd.Flags().Int32Var(&limit, "limit", 0, "At most this many reports") + return cmd +} + +// --- Subscriptions --------------------------------------------------------- + +func newBillingSubscriptionsCommand() *cobra.Command { + return groupCommand("subscriptions", "Manage instances' subscriptions", + billingCall{ + use: "get ", short: "Show an instance's billing and subscription", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Subscriptions.Get(ctx, args[0]) + }, + }.command(), + billingBodyCall[sdk.NewSubscription]{ + use: "subscribe ", short: "Subscribe an instance to a price of its licence", + run: func(ctx context.Context, c *sdk.Client, args []string, in sdk.NewSubscription) (any, error) { + return c.Billing.Subscriptions.Subscribe(ctx, args[0], in) + }, + }.command(), + billingBodyCall[sdk.SubscriptionTerms]{ + use: "update ", short: "Change an instance's subscription terms", + run: func(ctx context.Context, c *sdk.Client, args []string, in sdk.SubscriptionTerms) (any, error) { + return c.Billing.Subscriptions.Update(ctx, args[0], in) + }, + }.command(), + billingBodyCall[sdk.SubscriptionCancellation]{ + use: "cancel ", short: "Cancel an instance's subscription, now or at the end of its period", + optional: true, destructive: "cancel the subscription of", + run: func(ctx context.Context, c *sdk.Client, args []string, in sdk.SubscriptionCancellation) (any, error) { + return c.Billing.Subscriptions.Cancel(ctx, args[0], in) + }, + }.command(), + billingCall{ + use: "reactivate ", short: "Withdraw a cancellation scheduled for the end of the period", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Subscriptions.Reactivate(ctx, args[0]) + }, + }.command(), + billingBodyCall[sdk.PlanChangeTarget]{ + use: "schedule-change ", short: "Schedule a change of price for the next period", + run: func(ctx context.Context, c *sdk.Client, args []string, in sdk.PlanChangeTarget) (any, error) { + return c.Billing.Subscriptions.SchedulePlanChange(ctx, args[0], in) + }, + }.command(), + billingCall{ + use: "cancel-change ", short: "Withdraw the scheduled change of price", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Subscriptions.CancelPlanChange(ctx, args[0]) + }, + }.command(), + billingCall{ + use: "upcoming-invoice ", short: "Show the instance's next invoice, measured so far", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Subscriptions.UpcomingInvoice(ctx, args[0]) + }, + }.command(), + billingCall{ + use: "invoices ", short: "List an instance's invoices", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Subscriptions.ListInvoices(ctx, args[0], nil) + }, + }.command(), + ) +} + +// --- Licence prices -------------------------------------------------------- + +func newBillingPricesCommand() *cobra.Command { + return groupCommand("prices", "Manage the prices of licence versions", + billingCall{ + use: "list ", short: "List a licence version's prices", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.LicensePrices.List(ctx, args[0], nil) + }, + }.command(), + billingCall{ + use: "get ", short: "Show a price", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.LicensePrices.Get(ctx, args[0], args[1]) + }, + }.command(), + billingBodyCall[sdk.NewLicensePrice]{ + use: "create ", short: "Create a price on a licence version", + run: func(ctx context.Context, c *sdk.Client, args []string, in sdk.NewLicensePrice) (any, error) { + return c.Billing.LicensePrices.Create(ctx, args[0], in) + }, + }.command(), + billingBodyCall[sdk.LicensePriceChanges]{ + use: "update ", short: "Change a price", + run: func(ctx context.Context, c *sdk.Client, args []string, in sdk.LicensePriceChanges) (any, error) { + return c.Billing.LicensePrices.Update(ctx, args[0], args[1], in) + }, + }.command(), + billingCall{ + use: "deprecate ", short: "Deprecate a price: subscriptions keep it, new ones cannot take it", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.LicensePrices.Deprecate(ctx, args[0], args[1]) + }, + }.command(), + billingBodyCall[sdk.InvoicePreviewScenario]{ + use: "preview ", short: "Rate a scenario against a licence version's prices, billing nothing", + run: func(ctx context.Context, c *sdk.Client, args []string, in sdk.InvoicePreviewScenario) (any, error) { + return c.Billing.LicensePrices.Preview(ctx, args[0], in) + }, + }.command(), + ) +} + +// --- Add-ons --------------------------------------------------------------- + +func newBillingAddonsCommand() *cobra.Command { + return groupCommand("addons", "Manage the add-on catalogue", + billingCall{ + use: "list", short: "List the add-ons", + run: func(ctx context.Context, c *sdk.Client, _ []string) (any, error) { + return c.Billing.Addons.List(ctx, nil) + }, + }.command(), + billingCall{ + use: "get ", short: "Show an add-on", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Addons.Get(ctx, args[0]) + }, + }.command(), + billingBodyCall[sdk.NewAddon]{ + use: "create", short: "Create an add-on", + run: func(ctx context.Context, c *sdk.Client, _ []string, in sdk.NewAddon) (any, error) { + return c.Billing.Addons.Create(ctx, in) + }, + }.command(), + billingBodyCall[sdk.AddonChanges]{ + use: "update ", short: "Change an add-on", + run: func(ctx context.Context, c *sdk.Client, args []string, in sdk.AddonChanges) (any, error) { + return c.Billing.Addons.Update(ctx, args[0], in) + }, + }.command(), + billingCall{ + use: "delete ", short: "Delete a draft add-on", message: "deleted", destructive: "delete add-on", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return nil, c.Billing.Addons.Delete(ctx, args[0]) + }, + }.command(), + billingCall{ + use: "publish ", short: "Publish a draft add-on", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Addons.Publish(ctx, args[0]) + }, + }.command(), + billingCall{ + use: "archive ", short: "Archive an add-on: attachments keep it, new ones cannot take it", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Addons.Archive(ctx, args[0]) + }, + }.command(), + billingCall{ + use: "unarchive ", short: "Bring an archived add-on back", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Addons.Unarchive(ctx, args[0]) + }, + }.command(), + groupCommand("compatibility", "Choose the licence families an add-on attaches under", + billingCall{ + use: "list ", short: "List the licence families an add-on attaches under", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Addons.ListCompatibility(ctx, args[0]) + }, + }.command(), + billingCall{ + use: "add ", short: "Make an add-on attachable under a licence family", message: "added", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return nil, c.Billing.Addons.SetCompatibility(ctx, args[0], args[1]) + }, + }.command(), + billingCall{ + use: "remove ", short: "Stop an add-on attaching under a licence family", message: "removed", + destructive: "remove the compatibility of", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return nil, c.Billing.Addons.RemoveCompatibility(ctx, args[0], args[1]) + }, + }.command(), + ), + groupCommand("entitlements", "Manage what an add-on grants", + billingCall{ + use: "list ", short: "List what an add-on grants", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Addons.ListEntitlements(ctx, args[0]) + }, + }.command(), + billingCall{ + use: "get ", short: "Show an add-on's grant", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Addons.GetEntitlement(ctx, args[0], args[1]) + }, + }.command(), + billingBodyCall[sdk.NewAddonGrant]{ + use: "assign ", short: "Make an add-on grant an entitlement", + run: func(ctx context.Context, c *sdk.Client, args []string, in sdk.NewAddonGrant) (any, error) { + return c.Billing.Addons.AssignEntitlement(ctx, args[0], in) + }, + }.command(), + billingBodyCall[sdk.AddonGrant]{ + use: "update ", short: "Change an add-on's grant", + run: func(ctx context.Context, c *sdk.Client, args []string, in sdk.AddonGrant) (any, error) { + return c.Billing.Addons.UpdateEntitlement(ctx, args[0], args[1], in) + }, + }.command(), + billingCall{ + use: "unassign ", short: "Remove an add-on's grant", message: "unassigned", + destructive: "remove the grant of", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return nil, c.Billing.Addons.UnassignEntitlement(ctx, args[0], args[1]) + }, + }.command(), + ), + groupCommand("prices", "Manage an add-on's prices", + billingCall{ + use: "list ", short: "List an add-on's prices", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Addons.ListPrices(ctx, args[0], nil) + }, + }.command(), + billingBodyCall[sdk.NewAddonPrice]{ + use: "create ", short: "Create a price on an add-on", + run: func(ctx context.Context, c *sdk.Client, args []string, in sdk.NewAddonPrice) (any, error) { + return c.Billing.Addons.CreatePrice(ctx, args[0], in) + }, + }.command(), + billingCall{ + use: "deprecate ", short: "Deprecate a price of an add-on", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Addons.DeprecatePrice(ctx, args[0], args[1]) + }, + }.command(), + ), + groupCommand("families", "Show and change add-on families", + billingCall{ + use: "list", short: "List the add-on families", + run: func(ctx context.Context, c *sdk.Client, _ []string) (any, error) { + return c.Billing.Addons.ListFamilies(ctx) + }, + }.command(), + billingCall{ + use: "get ", short: "Show an add-on family", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Addons.GetFamily(ctx, args[0]) + }, + }.command(), + billingBodyCall[sdk.AddonFamilyVisibility]{ + use: "update ", short: "Change an add-on family", + run: func(ctx context.Context, c *sdk.Client, args []string, in sdk.AddonFamilyVisibility) (any, error) { + return c.Billing.Addons.UpdateFamily(ctx, args[0], in) + }, + }.command(), + ), + ) +} + +func newBillingInstanceAddonsCommand() *cobra.Command { + return groupCommand("instance-addons", "Attach add-ons to instances", + billingCall{ + use: "list ", short: "List the add-ons attached to an instance", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.InstanceAddons.List(ctx, args[0], nil) + }, + }.command(), + billingBodyCall[sdk.NewInstanceAddon]{ + use: "attach ", short: "Attach an add-on to an instance", + run: func(ctx context.Context, c *sdk.Client, args []string, in sdk.NewInstanceAddon) (any, error) { + return c.Billing.InstanceAddons.Attach(ctx, args[0], in) + }, + }.command(), + newBillingSetQuantityCommand(), + billingCall{ + use: "detach ", short: "Remove an add-on from an instance", message: "detached", + destructive: "detach", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return nil, c.Billing.InstanceAddons.Detach(ctx, args[0], args[1]) + }, + }.command(), + ) +} + +func newBillingSetQuantityCommand() *cobra.Command { + var quantity int32 + cmd := &cobra.Command{ + Use: "set-quantity ", + Short: "Change the quantity of an attached add-on", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if quantity < 1 { + return errUsagef("--quantity must be at least 1; detach the add-on to remove it") + } + client, _, err := newClient(cmd) + if err != nil { + return err + } + ctx, cancel := commandContext(cmd) + defer cancel() + attachment, err := client.Billing.InstanceAddons.SetQuantity(ctx, args[0], args[1], sdk.AddonQuantity{Quantity: quantity}) + if err != nil { + return err + } + return writeStructured(cmd, output.FormatYAML, attachment, output.Table{}) + }, + } + cmd.Flags().Int32Var(&quantity, "quantity", 0, "The new quantity (required)") + return cmd +} + +// --- Vouchers -------------------------------------------------------------- + +func newBillingVouchersCommand() *cobra.Command { + return groupCommand("vouchers", "Manage vouchers and their redemptions", + billingCall{ + use: "list", short: "List the vouchers", + run: func(ctx context.Context, c *sdk.Client, _ []string) (any, error) { + return c.Billing.Vouchers.List(ctx, nil) + }, + }.command(), + billingCall{ + use: "get ", short: "Show a voucher", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Vouchers.Get(ctx, args[0]) + }, + }.command(), + billingBodyCall[sdk.VoucherDraft]{ + use: "create", short: "Create a draft voucher", + run: func(ctx context.Context, c *sdk.Client, _ []string, in sdk.VoucherDraft) (any, error) { + return c.Billing.Vouchers.Create(ctx, in) + }, + }.command(), + billingBodyCall[sdk.VoucherDraft]{ + use: "update ", short: "Change a voucher", + run: func(ctx context.Context, c *sdk.Client, args []string, in sdk.VoucherDraft) (any, error) { + return c.Billing.Vouchers.Update(ctx, args[0], in) + }, + }.command(), + billingCall{ + use: "publish ", short: "Publish a draft voucher: its code can be redeemed from now on", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Vouchers.Publish(ctx, args[0]) + }, + }.command(), + billingCall{ + use: "archive ", short: "Archive a voucher: its code is redeemed no more", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Vouchers.Archive(ctx, args[0]) + }, + }.command(), + billingCall{ + use: "redemptions ", short: "List the redemptions of a voucher", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Vouchers.ListRedemptions(ctx, args[0], nil) + }, + }.command(), + billingCall{ + use: "lookup ", short: "Find a voucher by its code", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Vouchers.Lookup(ctx, sdk.VoucherCode{Code: args[0]}) + }, + }.command(), + billingBodyCall[sdk.VoucherCheck]{ + use: "validate", short: "Check whether a code could be redeemed on an instance, redeeming nothing", + run: func(ctx context.Context, c *sdk.Client, _ []string, in sdk.VoucherCheck) (any, error) { + return c.Billing.Vouchers.Validate(ctx, in) + }, + }.command(), + billingCall{ + use: "instance-list ", short: "List the vouchers redeemed on an instance", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.InstanceVouchers.List(ctx, args[0], nil) + }, + }.command(), + billingCall{ + use: "redeem ", short: "Redeem a voucher on an instance", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.InstanceVouchers.Redeem(ctx, args[0], sdk.RedeemableCode{Code: args[1]}) + }, + }.command(), + billingBodyCall[sdk.RevocationReason]{ + use: "revoke ", short: "Revoke a redemption", + optional: true, destructive: "revoke redemption", + run: func(ctx context.Context, c *sdk.Client, args []string, in sdk.RevocationReason) (any, error) { + return c.Billing.InstanceVouchers.Revoke(ctx, args[0], args[1], in) + }, + }.command(), + ) +} + +// --- Keys, sessions, customers --------------------------------------------- + +func newBillingKeysCommand() *cobra.Command { + return groupCommand("publishable-keys", "Manage the pk_ keys a public page reads the catalogue with", + billingCall{ + use: "list", short: "List the publishable keys", + run: func(ctx context.Context, c *sdk.Client, _ []string) (any, error) { + return c.Billing.PublishableKeys.List(ctx, nil) + }, + }.command(), + billingBodyCall[sdk.PublishableKeyDraft]{ + use: "create", short: "Create a publishable key; its secret is shown this once", + run: func(ctx context.Context, c *sdk.Client, _ []string, in sdk.PublishableKeyDraft) (any, error) { + return c.Billing.PublishableKeys.Create(ctx, in) + }, + }.command(), + billingBodyCall[sdk.PublishableKeyPatch]{ + use: "update ", short: "Change a publishable key", + run: func(ctx context.Context, c *sdk.Client, args []string, in sdk.PublishableKeyPatch) (any, error) { + return c.Billing.PublishableKeys.Update(ctx, args[0], in) + }, + }.command(), + billingCall{ + use: "revoke ", short: "Revoke a publishable key", destructive: "revoke publishable key", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.PublishableKeys.Revoke(ctx, args[0]) + }, + }.command(), + ) +} + +func newBillingSessionsCommand() *cobra.Command { + return groupCommand("customer-sessions", "Manage the kst_ sessions a customer's page acts with", + billingBodyCall[sdk.CustomerSessionDraft]{ + use: "create", short: "Create a customer session; its secret is shown this once", + run: func(ctx context.Context, c *sdk.Client, _ []string, in sdk.CustomerSessionDraft) (any, error) { + return c.Billing.CustomerSessions.Create(ctx, in) + }, + }.command(), + billingCall{ + use: "revoke ", short: "Revoke a customer session", message: "revoked", destructive: "revoke customer session", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return nil, c.Billing.CustomerSessions.Revoke(ctx, args[0]) + }, + }.command(), + ) +} + +func newBillingCustomersCommand() *cobra.Command { + return groupCommand("customers", "Show a customer's billing, payment method and portal", + billingCall{ + use: "get ", short: "Show a customer's billing", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Customers.Get(ctx, args[0]) + }, + }.command(), + billingCall{ + use: "detach-payment-method ", short: "Remove the customer's default payment method", message: "detached", + destructive: "detach the payment method of", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return nil, c.Billing.Customers.DetachPaymentMethod(ctx, args[0]) + }, + }.command(), + billingBodyCall[sdk.NewPaymentMethodSession]{ + use: "payment-method-session ", short: "Start a hosted session where the customer sets a payment method", + run: func(ctx context.Context, c *sdk.Client, args []string, in sdk.NewPaymentMethodSession) (any, error) { + return c.Billing.Customers.CreatePaymentMethodSession(ctx, args[0], in) + }, + }.command(), + billingCall{ + use: "complete-payment-method-session ", short: "Read back the payment method a session set", + run: func(ctx context.Context, c *sdk.Client, args []string) (any, error) { + return c.Billing.Customers.CompletePaymentMethodSession(ctx, args[0], args[1]) + }, + }.command(), + billingBodyCall[sdk.NewPortalSession]{ + use: "portal-session ", short: "Open the billing provider's portal for the customer", + run: func(ctx context.Context, c *sdk.Client, args []string, in sdk.NewPortalSession) (any, error) { + return c.Billing.Customers.CreatePortalSession(ctx, args[0], in) + }, + }.command(), + ) +} diff --git a/internal/cmd/billing_test.go b/internal/cmd/billing_test.go new file mode 100644 index 0000000..c93d624 --- /dev/null +++ b/internal/cmd/billing_test.go @@ -0,0 +1,207 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +const claimBody = `{"leaseId":"lease-1","leasedUntil":"2027-01-01T00:15:00Z","invoices":[ + {"id":"inv-1","customerSlug":"acme","total":1200,"currency":"eur","handoff":{"status":"PENDING","claimCount":1,"leaseId":"lease-1"}}, + {"id":"inv-2","customerSlug":"globex","total":800,"currency":"eur","handoff":{"status":"PENDING","claimCount":1,"leaseId":"lease-1"}}]}` + +func TestBillingHandoffClaimPrintsOneInvoicePerLineInNDJSON(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + writeTestJSON(w, http.StatusOK, claimBody) + }) + + stdout, stderr, code := runCapturing(t, "--base-url", server.URL, "--output", "ndjson", "billing", "handoff", "claim", "--limit", "50", "--lease", "30m") + if code != exitOK { + t.Fatalf("exit %d: %s", code, stderr) + } + + request := api.only(t) + if request.method != http.MethodPost || request.path != "/billing/handoff/claim" { + t.Fatalf("request = %s %s", request.method, request.path) + } + var body map[string]any + if err := json.Unmarshal(request.body, &body); err != nil || body["limit"] != 50.0 || body["leaseSeconds"] != 1800.0 { + t.Fatalf("body = %s", request.body) + } + + lines := strings.Split(strings.TrimSpace(stdout), "\n") + if len(lines) != 2 { + t.Fatalf("stdout = %q, want two lines", stdout) + } + var invoice struct { + ID string `json:"id"` + Handoff struct { + LeaseID string `json:"leaseId"` + } `json:"handoff"` + } + if err := json.Unmarshal([]byte(lines[1]), &invoice); err != nil || invoice.ID != "inv-2" || invoice.Handoff.LeaseID != "lease-1" { + t.Fatalf("line = %q", lines[1]) + } +} + +func TestBillingHandoffClaimRefusesALeaseOutOfRange(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + writeTestJSON(w, http.StatusOK, claimBody) + }) + + for _, args := range [][]string{{"--lease", "30s"}, {"--lease", "2h"}, {"--limit", "0"}, {"--limit", "101"}} { + _, _, code := runCapturing(t, append([]string{"--base-url", server.URL, "billing", "handoff", "claim"}, args...)...) + if code != exitUsage { + t.Fatalf("%v: exit %d, want %d", args, code, exitUsage) + } + } + if api.count() != 0 { + t.Fatalf("requests = %d, want none", api.count()) + } +} + +func TestBillingHandoffAckSendsTheLease(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + writeTestJSON(w, http.StatusOK, `{"id":"inv-1","status":"PAID","handoffStatus":"ACKNOWLEDGED","total":1200,"currency":"eur"}`) + }) + + stdout, stderr, code := runCapturing(t, "--base-url", server.URL, "billing", "handoff", "ack", "inv-1", "--lease-id", "lease-1", "--external-reference", "ACC-1") + if code != exitOK { + t.Fatalf("exit %d: %s", code, stderr) + } + request := api.only(t) + if request.path != "/billing/handoff/inv-1/ack" || string(request.body) != `{"externalReference":"ACC-1","leaseId":"lease-1"}` { + t.Fatalf("request = %s %s", request.path, request.body) + } + expectOutputContains(t, stdout, "ACKNOWLEDGED") +} + +// A lease another claim took answers 409: the script is told the API refused. +func TestBillingHandoffAckOfAStolenLeaseExitsRejected(t *testing.T) { + server, _ := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + writeTestJSON(w, http.StatusConflict, `{"title":"Conflict","status":409,"code":"AckHandoff.LeaseMismatch","detail":"another claim holds it"}`) + }) + + if _, _, code := runCapturing(t, "--base-url", server.URL, "billing", "handoff", "ack", "inv-1", "--lease-id", "old"); code != exitRejected { + t.Fatalf("exit %d, want %d", code, exitRejected) + } +} + +func TestBillingInvoicesListSendsTheFilters(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + writeTestJSON(w, http.StatusOK, `{"items":[{"id":"inv-1","status":"PUSH_FAILED","total":1200,"currency":"eur","customerSlug":"acme"}],"hasMore":false}`) + }) + + stdout, stderr, code := runCapturing(t, "--base-url", server.URL, "billing", "invoices", "list", + "--status", "push_failed", "--status", "PAYMENT_FAILED", "--overdue", "--customer", "acme", "--updated-since", "2027-01-01T00:00:00Z") + if code != exitOK { + t.Fatalf("exit %d: %s", code, stderr) + } + query := api.only(t).query + if strings.Join(query["status"], ",") != "PUSH_FAILED,PAYMENT_FAILED" || query.Get("overdue") != "true" || + query.Get("customerSlug") != "acme" || query.Get("updatedSince") != "2027-01-01T00:00:00Z" || query.Get("held") != "" { + t.Fatalf("query = %s", query.Encode()) + } + expectOutputContains(t, stdout, "1200 EUR") +} + +func TestBillingInvoicesVoidNeedsAReasonAndConfirmation(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + writeTestJSON(w, http.StatusOK, `{"id":"inv-1","status":"VOID"}`) + }) + + if _, _, code := runCapturing(t, "--base-url", server.URL, "billing", "invoices", "void", "inv-1", "--yes"); code != exitUsage { + t.Fatalf("without --reason: exit %d, want %d", code, exitUsage) + } + if _, _, code := runCapturing(t, "--base-url", server.URL, "billing", "invoices", "void", "inv-1", "--reason", "duplicate"); code != exitUsage { + t.Fatalf("without --yes on a non-interactive stdin: exit %d, want %d", code, exitUsage) + } + if api.count() != 0 { + t.Fatalf("requests = %d before confirmation, want none", api.count()) + } + + _, stderr, code := runCapturing(t, "--base-url", server.URL, "billing", "invoices", "void", "inv-1", "--reason", "duplicate", "--yes") + if code != exitOK { + t.Fatalf("exit %d: %s", code, stderr) + } + if request := api.only(t); request.path != "/invoices/inv-1/void" || string(request.body) != `{"reason":"duplicate"}` { + t.Fatalf("request = %s %s", request.path, request.body) + } +} + +func TestBillingInvoicesMarkPaidOmitsWhatWasNotGiven(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + writeTestJSON(w, http.StatusOK, `{"id":"inv-1","status":"PAID"}`) + }) + + _, stderr, code := runCapturing(t, "--base-url", server.URL, "billing", "invoices", "mark-paid", "inv-1", "--external-reference", "WIRE-9") + if code != exitOK { + t.Fatalf("exit %d: %s", code, stderr) + } + if body := string(api.only(t).body); body != `{"externalReference":"WIRE-9"}` { + t.Fatalf("body = %s", body) + } +} + +func TestBillingInvoicesExportWritesTheFile(t *testing.T) { + const csv = "invoiceId,total\ninv-1,1200\n" + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + w.Header().Set("Content-Type", "text/csv") + _, _ = w.Write([]byte(csv)) + }) + file := filepath.Join(t.TempDir(), "january.csv") + + if _, _, code := runCapturing(t, "--base-url", server.URL, "billing", "invoices", "export", "--from", "2027-01-01T00:00:00Z"); code != exitUsage { + t.Fatalf("without --to: exit %d, want %d", code, exitUsage) + } + _, stderr, code := runCapturing(t, "--base-url", server.URL, "billing", "invoices", "export", + "--from", "2027-01-01T00:00:00Z", "--to", "2027-02-01T00:00:00Z", "--granularity", "invoice", "--output-file", file) + if code != exitOK { + t.Fatalf("exit %d: %s", code, stderr) + } + query := api.only(t).query + if query.Get("format") != "csv" || query.Get("granularity") != "invoice" || query.Get("issuedTo") != "2027-02-01T00:00:00Z" { + t.Fatalf("query = %s", query.Encode()) + } + written, err := os.ReadFile(file) //nolint:gosec // the test's own temporary file + if err != nil || string(written) != csv { + t.Fatalf("file = %q, %v", written, err) + } + if _, err := os.Stat(file + ".partial"); !os.IsNotExist(err) { + t.Fatalf("the partial file was left behind: %v", err) + } +} + +func TestBillingSubscribeReadsThePayload(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + writeTestJSON(w, http.StatusCreated, `{"subscription":{"status":"ACTIVE"}}`) + }) + + _, stderr, code := runCapturing(t, "--base-url", server.URL, "billing", "subscriptions", "subscribe", "acme-prod", + "--payload", `{"basePriceId":"price-1","voucherCode":"LAUNCH"}`) + if code != exitOK { + t.Fatalf("exit %d: %s", code, stderr) + } + request := api.only(t) + if request.method != http.MethodPost || request.path != "/instances/acme-prod/billing" || string(request.body) != `{"basePriceId":"price-1","voucherCode":"LAUNCH"}` { + t.Fatalf("request = %s %s %s", request.method, request.path, request.body) + } +} + +// Cancelling at the end of the period needs no body: the API's default mode. +func TestBillingSubscriptionCancelTakesNoPayload(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + writeTestJSON(w, http.StatusOK, `{"status":"ACTIVE"}`) + }) + + _, stderr, code := runCapturing(t, "--base-url", server.URL, "billing", "subscriptions", "cancel", "acme-prod", "--yes") + if code != exitOK { + t.Fatalf("exit %d: %s", code, stderr) + } + if request := api.only(t); request.path != "/instances/acme-prod/billing/cancel" || string(request.body) != `{}` { + t.Fatalf("request = %s %s", request.path, request.body) + } +} diff --git a/internal/cmd/common.go b/internal/cmd/common.go index 85cb679..da2368c 100644 --- a/internal/cmd/common.go +++ b/internal/cmd/common.go @@ -67,7 +67,9 @@ func runtimeConfig(cmd *cobra.Command) (config.Runtime, error) { return config.Resolve(baseURL, authToken, format) } -func newClient(cmd *cobra.Command) (*sdk.Client, config.Runtime, error) { +// newClient builds an SDK client from the resolved configuration. extra options +// are applied after the CLI's own, so a command can replace the HTTP client. +func newClient(cmd *cobra.Command, extra ...sdk.Option) (*sdk.Client, config.Runtime, error) { cfg, err := runtimeConfig(cmd) if err != nil { return nil, config.Runtime{}, err @@ -81,7 +83,7 @@ func newClient(cmd *cobra.Command) (*sdk.Client, config.Runtime, error) { opts = append(opts, sdk.WithBearerToken(cfg.AuthToken)) } - client, err := sdk.NewClient(cfg.BaseURL, opts...) + client, err := sdk.NewClient(cfg.BaseURL, append(opts, extra...)...) if err != nil { return nil, cfg, err } diff --git a/internal/cmd/confirm_test.go b/internal/cmd/confirm_test.go index dc60c4c..fdad1e6 100644 --- a/internal/cmd/confirm_test.go +++ b/internal/cmd/confirm_test.go @@ -154,6 +154,17 @@ func TestEveryDestructiveCommandCarriesConfirmFlag(t *testing.T) { "releases delete", "service-accounts tokens delete", "config unset", + "billing invoices void", + "billing invoices write-off", + "billing subscriptions cancel", + "billing addons delete", + "billing addons compatibility remove", + "billing addons entitlements unassign", + "billing instance-addons detach", + "billing vouchers revoke", + "billing publishable-keys revoke", + "billing customer-sessions revoke", + "billing customers detach-payment-method", } root := NewRootCommand() diff --git a/internal/cmd/exit.go b/internal/cmd/exit.go index 21ee531..6fdfeda 100644 --- a/internal/cmd/exit.go +++ b/internal/cmd/exit.go @@ -102,6 +102,12 @@ func exitCode(cmd *cobra.Command, err error) int { return exitRejected } + // The SDK refuses a malformed --transaction-id before sending anything: the + // invocation was wrong, not the API's answer. + if errors.Is(err, sdk.ErrInvalidTransactionID) { + return exitUsage + } + var netErr net.Error if errors.As(err, &netErr) || errors.Is(err, context.DeadlineExceeded) { return exitUnavailable diff --git a/internal/cmd/instances.go b/internal/cmd/instances.go index ec15a9f..937c84f 100644 --- a/internal/cmd/instances.go +++ b/internal/cmd/instances.go @@ -254,6 +254,8 @@ func newInstancesUsageCommand() *cobra.Command { cmd.AddCommand(newInstancesUsageListCommand()) cmd.AddCommand(newInstancesUsageGetCommand()) cmd.AddCommand(newInstancesUsageReportCommand()) + cmd.AddCommand(newInstancesUsageHistoryCommand()) + cmd.AddCommand(newInstancesUsageExportCommand()) return cmd } @@ -317,17 +319,32 @@ func newInstancesUsageReportCommand() *cobra.Command { var behavior string var metadataFile string var metadataPayload string + var transactionID string cmd := &cobra.Command{ Use: "report --value ", Short: "Report entitlement usage for an instance", - Args: cobra.ExactArgs(2), + Long: `Report entitlement usage for an instance. + +Without --transaction-id a report is sent once: if it fails in flight it may or +may not have been counted, so it is not sent again. With one, Kaiten applies the +report at most once per key, so it is retried on network errors and on 500, 502, +503 and 504, and running the same command again is safe -- a report the server +had already counted is answered from the first time, noted on stderr. + +A key already used for a different report is refused (exit code 5): send a +correction as a new report under a new key.`, + Args: cobra.ExactArgs(2), Example: ` # Add to the recorded usage kaiten instances usage report acme-prod seats --value 3 # Overwrite it instead, and attach metadata kaiten instances usage report acme-prod seats --value 12 --behavior set \ - --metadata-payload '{"source":"nightly-sync"}'`, + --metadata-payload '{"source":"nightly-sync"}' + + # Make it safe to retry: one key per measurement + kaiten instances usage report acme-prod tokens --value 1200 \ + --transaction-id llm-call:9f2c:tokens`, RunE: func(cmd *cobra.Command, args []string) error { hasInline := anyFlagChanged(cmd, "value", "behavior", "metadata-file", "metadata-payload") inputValue, err := resolveInput(file, payload, hasInline, func() (sdk.UsageReportInput, error) { @@ -348,6 +365,11 @@ func newInstancesUsageReportCommand() *cobra.Command { if err != nil { return err } + // The flag wins over a key in --file or --payload, so one payload file + // can be replayed under per-run keys. + if transactionID != "" { + inputValue.TransactionID = transactionID + } client, _, err := newClient(cmd) if err != nil { @@ -355,11 +377,18 @@ func newInstancesUsageReportCommand() *cobra.Command { } ctx, cancel := commandContext(cmd) defer cancel() - item, err := client.Instances.ReportEntitlementUsageMetric(ctx, args[0], args[1], inputValue) + result, err := client.Instances.ReportEntitlementUsage(ctx, args[0], args[1], inputValue) if err != nil { return err } - return writeStructured(cmd, output.FormatYAML, item, output.Table{}) + // Notes go to stderr, so stdout stays the usage document scripts read. + if result.Replayed { + fmt.Fprintf(cmd.ErrOrStderr(), "note: transaction %s was already counted; this is its original result, not a new report\n", inputValue.TransactionID) + } + if result.MetadataDropped { + fmt.Fprintln(cmd.ErrOrStderr(), "note: the metadata was larger than 4 KiB and was not stored; the report was counted") + } + return writeStructured(cmd, output.FormatYAML, result.Usage, output.Table{}) }, } addInputSourceFlags(cmd, &file, &payload, "usage report") @@ -367,6 +396,7 @@ func newInstancesUsageReportCommand() *cobra.Command { cmd.Flags().StringVar(&behavior, "behavior", string(sdk.Append), "Usage behavior: append or set") cmd.Flags().StringVar(&metadataFile, "metadata-file", "", "Optional JSON or YAML metadata file") cmd.Flags().StringVar(&metadataPayload, "metadata-payload", "", "Optional inline JSON or YAML metadata object") + cmd.Flags().StringVar(&transactionID, "transaction-id", "", "Idempotency key: 1 to 128 characters of letters, digits, '.', '_', ':' and '-'") return cmd } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 069d3e4..5f4bfab 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -84,11 +84,12 @@ interrupted by a signal. README.md carries the full table.`, rootCmd.PersistentFlags().String("base-url", "", "Kaiten API base URL") rootCmd.PersistentFlags().String("auth-token", "", "Kaiten API bearer token") - rootCmd.PersistentFlags().String("output", "", "Output format: table, json, yaml") + rootCmd.PersistentFlags().String("output", "", "Output format: table, json, yaml, ndjson") rootCmd.AddCommand(newVersionCommand()) rootCmd.AddCommand(newConfigCommand()) rootCmd.AddCommand(newDoctorCommand()) + rootCmd.AddCommand(newBillingCommand()) rootCmd.AddCommand(newComponentsCommand()) rootCmd.AddCommand(newCustomersCommand()) rootCmd.AddCommand(newDeploymentZonesCommand()) diff --git a/internal/cmd/usage.go b/internal/cmd/usage.go new file mode 100644 index 0000000..5f5a4d4 --- /dev/null +++ b/internal/cmd/usage.go @@ -0,0 +1,230 @@ +package cmd + +import ( + "fmt" + "io" + "net/http" + "os" + + "github.com/kaitencloud/cli/internal/output" + "github.com/kaitencloud/sdk-go" + "github.com/spf13/cobra" +) + +func newInstancesUsageHistoryCommand() *cobra.Command { + var from, to, transactionID string + var limit int32 + + cmd := &cobra.Command{ + Use: "history ", + Short: "List the usage reports of an instance's entitlement", + Long: `List the usage reports Kaiten accepted for one instance and entitlement, in +the order it accepted them, with the counter before and after each one and the +limit it was gated on. + +The range defaults to the last 30 days. A --from earlier than the start of the +organization's usage history is refused (exit code 5).`, + Args: cobra.ExactArgs(2), + Example: ` kaiten instances usage history acme-prod tokens + kaiten instances usage history acme-prod tokens --from 2026-10-01T00:00:00Z --limit 100 + kaiten instances usage history acme-prod tokens --transaction-id llm-call:9f2c:tokens`, + RunE: func(cmd *cobra.Command, args []string) error { + fromTime, err := parseTimeFlag(from, "from") + if err != nil { + return err + } + toTime, err := parseTimeFlag(to, "to") + if err != nil { + return err + } + + client, _, err := newClient(cmd) + if err != nil { + return err + } + ctx, cancel := commandContext(cmd) + defer cancel() + items, err := client.Instances.ListUsageReports(ctx, args[0], args[1], &sdk.UsageReportsOptions{ + From: fromTime, + To: toTime, + TransactionID: transactionID, + Limit: optionalInt32(limit), + }) + if err != nil { + return err + } + + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, []string{ + fmt.Sprint(item.ReportSeq), + formatTimeValue(item.ReportedAt), + string(item.Behavior), + item.ReportedValue, + item.ValueBefore, + item.ValueAfter, + item.Delta, + stringOrEmpty(item.LimitValue), + stringOrEmpty(item.TransactionId), + }) + } + return writeStructured(cmd, output.FormatTable, items, output.Table{ + Columns: []string{"SEQ", "REPORTED AT", "BEHAVIOR", "VALUE", "BEFORE", "AFTER", "DELTA", "LIMIT", "TRANSACTION ID"}, + Rows: rows, + }) + }, + } + + cmd.Flags().StringVar(&from, "from", "", "Only reports accepted at or after this RFC3339 timestamp (default: 30 days before --to)") + cmd.Flags().StringVar(&to, "to", "", "Only reports accepted before this RFC3339 timestamp (default: now)") + cmd.Flags().StringVar(&transactionID, "transaction-id", "", "Only the report sent with this idempotency key") + cmd.Flags().Int32Var(&limit, "limit", 0, "Fetch at most this many reports") + + return cmd +} + +func newInstancesUsageExportCommand() *cobra.Command { + var from, to, format, outputFile string + var instance, instanceID, entitlement, entitlementID string + + cmd := &cobra.Command{ + Use: "export [ ]", + Short: "Export usage reports as CSV or NDJSON", + Long: `Stream usage reports as CSV (with a header row) or NDJSON (one report per +line), to stdout or to --output-file. + +With an instance and an entitlement, the export covers that pair, up to 366 days +per run. Without them, it covers the whole organization, up to 31 days per run, +and --instance, --instance-id, --entitlement and --entitlement-id narrow it; the +ID filters reach deleted instances and entitlements, whose reports are kept. + +The range defaults to the last 30 days. A --from earlier than the start of the +organization's usage history is refused (exit code 5). The --output flag does not +apply: the export is written as it arrives, in --format.`, + Args: func(_ *cobra.Command, args []string) error { + if len(args) != 0 && len(args) != 2 { + return fmt.Errorf("accepts either no arguments or , received %d", len(args)) + } + return nil + }, + Example: ` # One instance's tokens, last 30 days, as CSV on stdout + kaiten instances usage export acme-prod tokens > tokens.csv + + # The whole organization for September, as NDJSON in a file + kaiten instances usage export --from 2026-09-01T00:00:00Z --to 2026-10-01T00:00:00Z \ + --format json --output-file usage-september.ndjson + + # A deleted instance's reports, by ID + kaiten instances usage export --instance-id 3f1c... --output-file old-instance.csv`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 2 && anyFlagChanged(cmd, "instance", "instance-id", "entitlement", "entitlement-id") { + return errUsagef("--instance, --instance-id, --entitlement and --entitlement-id narrow an organization export; drop them, or drop the two arguments") + } + if format != string(sdk.UsageExportCSV) && format != string(sdk.UsageExportJSON) { + return errUsagef("--format must be csv or json") + } + fromTime, err := parseTimeFlag(from, "from") + if err != nil { + return err + } + toTime, err := parseTimeFlag(to, "to") + if err != nil { + return err + } + + client, err := newStreamingClient(cmd) + if err != nil { + return err + } + ctx := cmd.Context() + + var export *sdk.UsageExport + if len(args) == 2 { + export, err = client.Instances.ExportUsageReports(ctx, args[0], args[1], &sdk.UsageExportOptions{ + From: fromTime, To: toTime, Format: sdk.UsageExportFormat(format), + }) + } else { + export, err = client.Instances.ExportOrganizationUsageReports(ctx, &sdk.OrganizationUsageExportOptions{ + From: fromTime, To: toTime, Format: sdk.UsageExportFormat(format), + InstanceSlug: instance, InstanceID: instanceID, + EntitlementSlug: entitlement, EntitlementID: entitlementID, + }) + } + if err != nil { + return err + } + defer func() { _ = export.Body.Close() }() + + return writeExport(cmd, export, outputFile) + }, + } + + cmd.Flags().StringVar(&from, "from", "", "Only reports accepted at or after this RFC3339 timestamp (default: 30 days before --to)") + cmd.Flags().StringVar(&to, "to", "", "Only reports accepted before this RFC3339 timestamp (default: now)") + cmd.Flags().StringVar(&format, "format", string(sdk.UsageExportCSV), "Export format: csv or json (NDJSON)") + cmd.Flags().StringVar(&outputFile, "output-file", "", "Write the export to this file instead of stdout") + cmd.Flags().StringVar(&instance, "instance", "", "Organization export: only this instance (slug)") + cmd.Flags().StringVar(&instanceID, "instance-id", "", "Organization export: only this instance (ID; reaches a deleted one)") + cmd.Flags().StringVar(&entitlement, "entitlement", "", "Organization export: only this entitlement (slug)") + cmd.Flags().StringVar(&entitlementID, "entitlement-id", "", "Organization export: only this entitlement (ID; reaches a deleted one)") + + return cmd +} + +// writeExport copies an export to outputFile, or to stdout when it is empty, as +// it arrives. A file is written beside its final name and renamed into place +// once complete, so an interrupted export never leaves a truncated file that +// looks finished. +func writeExport(cmd *cobra.Command, export *sdk.UsageExport, outputFile string) error { + if outputFile == "" { + _, err := io.Copy(cmd.OutOrStdout(), export.Body) + return err + } + + partial := outputFile + ".partial" + file, err := os.Create(partial) //nolint:gosec // the path is the caller's own --output-file + if err != nil { + return fmt.Errorf("create %s: %w", partial, err) + } + written, copyErr := io.Copy(file, export.Body) + closeErr := file.Close() + if err := firstError(copyErr, closeErr); err != nil { + _ = os.Remove(partial) + return fmt.Errorf("write %s: %w", outputFile, err) + } + if err := os.Rename(partial, outputFile); err != nil { + return fmt.Errorf("write %s: %w", outputFile, err) + } + + fmt.Fprintf(cmd.ErrOrStderr(), "wrote %d bytes to %s\n", written, outputFile) + return nil +} + +// newStreamingClient is newClient for a command whose response body can take +// longer to read than any fixed deadline: an export. Its HTTP client has no +// overall timeout -- the server must still start answering within +// defaultTimeout -- and the command runs on its own context, which a signal +// still cancels, rather than commandContext's. +func newStreamingClient(cmd *cobra.Command) (*sdk.Client, error) { + transport := http.DefaultTransport.(*http.Transport).Clone() //nolint:forcetypeassert // the standard library's own type + transport.ResponseHeaderTimeout = defaultTimeout + + client, _, err := newClient(cmd, sdk.WithHTTPClient(&http.Client{Transport: transport})) + return client, err +} + +func stringOrEmpty(value *string) string { + if value == nil { + return "" + } + return *value +} + +func firstError(errs ...error) error { + for _, err := range errs { + if err != nil { + return err + } + } + return nil +} diff --git a/internal/cmd/usage_test.go b/internal/cmd/usage_test.go new file mode 100644 index 0000000..a94e39c --- /dev/null +++ b/internal/cmd/usage_test.go @@ -0,0 +1,254 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +const usageBody = `{"entitlementId":"e","entitlementSlug":"tokens","licenseId":"l","value":{"type":"number","value":1200,"event_count":1},"limit":{"type":"number","value":5000}}` + +// usageAPI is a fake Core API for the usage commands. It records each request +// and answers with what the test configured. +type usageAPI struct { + mu sync.Mutex + requests []recordedUsageRequest + answer func(w http.ResponseWriter, r *http.Request, body []byte) +} + +type recordedUsageRequest struct { + method, path string + query url.Values + body []byte + header http.Header +} + +func givenUsageAPI(t *testing.T, answer func(w http.ResponseWriter, r *http.Request, body []byte)) (*httptest.Server, *usageAPI) { + t.Helper() + givenIsolatedConfig(t) + api := &usageAPI{answer: answer} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + api.mu.Lock() + api.requests = append(api.requests, recordedUsageRequest{r.Method, r.URL.Path, r.URL.Query(), body, r.Header.Clone()}) + api.mu.Unlock() + api.answer(w, r, body) + })) + t.Cleanup(server.Close) + return server, api +} + +func (a *usageAPI) only(t *testing.T) recordedUsageRequest { + t.Helper() + a.mu.Lock() + defer a.mu.Unlock() + if len(a.requests) != 1 { + t.Fatalf("requests = %d, want 1", len(a.requests)) + } + return a.requests[0] +} + +func (a *usageAPI) count() int { + a.mu.Lock() + defer a.mu.Unlock() + return len(a.requests) +} + +// runCapturing runs the CLI and returns stdout, stderr and the exit code. +func runCapturing(t *testing.T, args ...string) (string, string, int) { + t.Helper() + var stdout, stderr bytes.Buffer + root := NewRootCommand() + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs(args) + cmd, err := root.ExecuteC() + return stdout.String(), stderr.String(), exitCode(cmd, err) +} + +func TestUsageReportSendsTheTransactionID(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + w.Header().Set("Idempotent-Replayed", "true") + writeTestJSON(w, http.StatusOK, usageBody) + }) + + stdout, stderr, code := runCapturing(t, "--base-url", server.URL, "--output", "json", + "instances", "usage", "report", "acme-prod", "tokens", "--value", "1200", "--transaction-id", "llm-call:9f2c:tokens") + expectExitCode(t, code, exitOK) + + if got := api.only(t).header.Get("Idempotency-Key"); got != "llm-call:9f2c:tokens" { + t.Errorf("Idempotency-Key = %q, want the key", got) + } + expectStructuredDocument(t, stdout, json.Unmarshal) + if strings.Contains(stdout, "already counted") { + t.Error("the replay note belongs on stderr, not in the document scripts read") + } + if !strings.Contains(stderr, "transaction llm-call:9f2c:tokens was already counted") { + t.Errorf("stderr = %q, want the replay note", stderr) + } +} + +func TestUsageReportWithoutAKeySendsNone(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + writeTestJSON(w, http.StatusOK, usageBody) + }) + + _, stderr, code := runCapturing(t, "--base-url", server.URL, "instances", "usage", "report", "acme-prod", "tokens", "--value", "1") + expectExitCode(t, code, exitOK) + if got := api.only(t).header.Get("Idempotency-Key"); got != "" { + t.Errorf("Idempotency-Key = %q, want no key", got) + } + if stderr != "" { + t.Errorf("stderr = %q, want nothing for a plain report", stderr) + } +} + +func TestUsageReportKeyFailures(t *testing.T) { + t.Run("a malformed key is a usage error and sends nothing", func(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + writeTestJSON(w, http.StatusOK, usageBody) + }) + _, _, code := runCapturing(t, "--base-url", server.URL, "instances", "usage", "report", "acme-prod", "tokens", "--value", "1", "--transaction-id", "has space") + expectExitCode(t, code, exitUsage) + if api.count() != 0 { + t.Errorf("requests = %d, want none", api.count()) + } + }) + + t.Run("a reused key is rejected", func(t *testing.T) { + server, _ := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"title":"Conflict","status":409,"code":"ReportEntitlementUsageMetric.TransactionIdReused","detail":"transactionId evt-1 was already used for another report"}`)) + }) + _, _, code := runCapturing(t, "--base-url", server.URL, "instances", "usage", "report", "acme-prod", "tokens", "--value", "1", "--transaction-id", "evt-1") + expectExitCode(t, code, exitRejected) + }) +} + +func TestUsageHistoryListsTheReports(t *testing.T) { + server, api := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + writeTestJSON(w, http.StatusOK, `{"items":[ + {"reportSeq":1,"reportedAt":"2026-10-05T08:00:00Z","behavior":"append","aggregationMethod":"SUM","reportedValue":"600","valueBefore":"0","valueAfter":"600","delta":"600","overageDelta":"0","eventCountAfter":1,"limitValue":"1000","overagePercent":50,"transactionId":"evt-1","instanceId":"11111111-1111-1111-1111-111111111111","entitlementId":"22222222-2222-2222-2222-222222222222","licenseId":"33333333-3333-3333-3333-333333333333"}, + {"reportSeq":2,"reportedAt":"2026-10-05T09:00:00Z","behavior":"set","aggregationMethod":"SUM","reportedValue":"450","valueBefore":"600","valueAfter":"450","delta":"-150","overageDelta":"0","eventCountAfter":2,"instanceId":"11111111-1111-1111-1111-111111111111","entitlementId":"22222222-2222-2222-2222-222222222222","licenseId":"33333333-3333-3333-3333-333333333333"}]}`) + }) + + stdout, _, code := runCapturing(t, "--base-url", server.URL, "instances", "usage", "history", "acme-prod", "tokens", "--from", "2026-10-01T00:00:00Z") + expectExitCode(t, code, exitOK) + + request := api.only(t) + if request.path != "/instances/acme-prod/entitlements/tokens/usage/reports" || request.query.Get("from") == "" { + t.Errorf("request = %s?%s", request.path, request.query.Encode()) + } + for _, want := range []string{"SEQ", "TRANSACTION ID", "evt-1", "1000", "-150", "set"} { + expectOutputContains(t, stdout, want) + } +} + +const exportCSV = "organization_id,instance_id,report_seq\no,i,1\no,i,2\n" + +func givenExportAPI(t *testing.T) (*httptest.Server, *usageAPI) { + t.Helper() + return givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", `attachment; filename="usage-20261001-20261005.csv"`) + _, _ = w.Write([]byte(exportCSV)) + }) +} + +func TestUsageExportStreamsAPairToStdout(t *testing.T) { + server, api := givenExportAPI(t) + + stdout, _, code := runCapturing(t, "--base-url", server.URL, "instances", "usage", "export", "acme-prod", "tokens") + expectExitCode(t, code, exitOK) + if stdout != exportCSV { + t.Errorf("stdout = %q, want the export byte for byte", stdout) + } + request := api.only(t) + if request.path != "/instances/acme-prod/entitlements/tokens/usage/reports/export" || request.query.Get("format") != "csv" { + t.Errorf("request = %s?%s", request.path, request.query.Encode()) + } +} + +func TestUsageExportWritesTheOrganizationToAFile(t *testing.T) { + server, api := givenExportAPI(t) + target := filepath.Join(t.TempDir(), "usage.ndjson") + + stdout, stderr, code := runCapturing(t, "--base-url", server.URL, "instances", "usage", "export", + "--format", "json", "--instance-id", "11111111-1111-1111-1111-111111111111", "--entitlement", "tokens", "--output-file", target) + expectExitCode(t, code, exitOK) + + written, err := os.ReadFile(target) //nolint:gosec // a path under t.TempDir() + if err != nil { + t.Fatal(err) + } + if string(written) != exportCSV { + t.Errorf("file = %q", written) + } + if _, err := os.Stat(target + ".partial"); !os.IsNotExist(err) { + t.Error("the partial file was left behind") + } + if stdout != "" || !strings.Contains(stderr, fmt.Sprintf("wrote %d bytes to %s", len(exportCSV), target)) { + t.Errorf("stdout = %q, stderr = %q", stdout, stderr) + } + + query := api.only(t).query + if api.only(t).path != "/usage/reports/export" || query.Get("format") != "json" || + query.Get("instanceId") != "11111111-1111-1111-1111-111111111111" || query.Get("entitlementSlug") != "tokens" { + t.Errorf("request = %s?%s", api.only(t).path, query.Encode()) + } +} + +func TestUsageExportInvocationMistakes(t *testing.T) { + server, api := givenExportAPI(t) + + for name, args := range map[string][]string{ + "one argument": {"acme-prod"}, + "organization filters on a pair": {"acme-prod", "tokens", "--instance", "acme-prod"}, + "an unknown format": {"--format", "xml"}, + "an unparsable from": {"--from", "yesterday"}, + "a malformed instance ID": {"--instance-id", "not-a-uuid"}, + "organization filter ID on a pair": {"acme-prod", "tokens", "--entitlement-id", "22222222-2222-2222-2222-222222222222"}, + "three arguments": {"a", "b", "c"}, + "an entitlement filter with one slug": {"acme-prod", "--entitlement", "tokens"}, + } { + t.Run(name, func(t *testing.T) { + _, _, code := runCapturing(t, append([]string{"--base-url", server.URL, "instances", "usage", "export"}, args...)...) + if name == "a malformed instance ID" { + // Caught by the SDK before any request, as an ordinary error. + if code == exitOK { + t.Fatalf("exit code = 0, want a failure") + } + return + } + expectExitCode(t, code, exitUsage) + }) + } + if api.count() != 0 { + t.Errorf("requests = %d, want none", api.count()) + } +} + +func TestUsageExportReportsTheAPIRefusal(t *testing.T) { + server, _ := givenUsageAPI(t, func(w http.ResponseWriter, _ *http.Request, _ []byte) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"title":"Unprocessable Entity","status":422,"code":"ExportUsageReports.OutsideRetention","detail":"the usage history is kept from 2026-07-05T12:00:00.000Z"}`)) + }) + target := filepath.Join(t.TempDir(), "usage.csv") + + _, _, code := runCapturing(t, "--base-url", server.URL, "instances", "usage", "export", "acme-prod", "tokens", "--from", "2025-01-01T00:00:00Z", "--output-file", target) + expectExitCode(t, code, exitRejected) + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Error("a refused export created the output file") + } +} diff --git a/internal/output/output.go b/internal/output/output.go index ad0cac3..38fdfe0 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "reflect" "strings" "text/tabwriter" @@ -16,6 +17,9 @@ const ( FormatTable = "table" FormatJSON = "json" FormatYAML = "yaml" + // FormatNDJSON writes one compact JSON document per line: one per element + // of a list, else the value on one line. + FormatNDJSON = "ndjson" ) // Table is a tabular result with column headers and rows, used for table-formatted output. @@ -27,7 +31,7 @@ type Table struct { // ValidateFormat returns an error if format is not a supported output format. func ValidateFormat(format string) error { switch strings.TrimSpace(format) { - case "", FormatTable, FormatJSON, FormatYAML: + case "", FormatTable, FormatJSON, FormatYAML, FormatNDJSON: return nil default: return fmt.Errorf("unsupported output format %q", format) @@ -48,6 +52,8 @@ func Write(w io.Writer, format string, value any, table Table) error { return writeJSON(w, value) case FormatYAML: return writeYAML(w, value) + case FormatNDJSON: + return writeNDJSON(w, value) default: return fmt.Errorf("unsupported output format %q", format) } @@ -94,3 +100,25 @@ func writeYAML(w io.Writer, value any) error { _, err = fmt.Fprint(w, string(data)) return err } + +func writeNDJSON(w io.Writer, value any) error { + items := reflect.ValueOf(value) + if items.Kind() != reflect.Slice && items.Kind() != reflect.Array { + return writeLine(w, value) + } + for i := range items.Len() { + if err := writeLine(w, items.Index(i).Interface()); err != nil { + return err + } + } + return nil +} + +func writeLine(w io.Writer, value any) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + _, err = fmt.Fprintln(w, string(data)) + return err +} diff --git a/internal/output/output_test.go b/internal/output/output_test.go index e2cbdc2..db0904f 100644 --- a/internal/output/output_test.go +++ b/internal/output/output_test.go @@ -174,3 +174,19 @@ func TestWriteTableRendersAnEmptyResultAsHeadersOnly(t *testing.T) { t.Fatalf("WriteTable() output = %q, want the header alone", got) } } + +func TestWriteNDJSONPrintsOneDocumentPerElement(t *testing.T) { + var list, single strings.Builder + if err := Write(&list, FormatNDJSON, []map[string]int{{"a": 1}, {"b": 2}}, Table{}); err != nil { + t.Fatalf("Write(list) error = %v", err) + } + if list.String() != "{\"a\":1}\n{\"b\":2}\n" { + t.Fatalf("list = %q", list.String()) + } + if err := Write(&single, FormatNDJSON, map[string]int{"a": 1}, Table{}); err != nil { + t.Fatalf("Write(single) error = %v", err) + } + if single.String() != "{\"a\":1}\n" { + t.Fatalf("single = %q", single.String()) + } +}