From 150ff8ef51261297c43a632b13526301d1c6e26d Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Tue, 8 Sep 2026 12:55:06 +0800 Subject: [PATCH 1/3] fix: KEEP-1331 decode billing overageCharges as an array The server returns overageCharges as an array of recent overage billing line-items, but the CLI typed it as float64, so kh billing status and kh billing usage crashed on response decoding. Type it as a slice of OverageCharge and render the summed line-item total as the dollar figure. --- cmd/billing/status.go | 25 +++++++++++++++-- cmd/billing/status_test.go | 55 +++++++++++++++++++++++++++++++++++++- cmd/billing/usage.go | 2 +- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/cmd/billing/status.go b/cmd/billing/status.go index 2d9276e..d8073e5 100644 --- a/cmd/billing/status.go +++ b/cmd/billing/status.go @@ -21,10 +21,31 @@ type SubscriptionResponse struct { Executions int `json:"executions"` Limit int `json:"limit"` } `json:"usage"` - OverageCharges float64 `json:"overageCharges"` + OverageCharges []OverageCharge `json:"overageCharges"` Limits map[string]interface{} `json:"limits"` } +// OverageCharge is one recent overage billing line-item returned in the +// overageCharges array of GET /api/billing/subscription. +type OverageCharge struct { + PeriodStart string `json:"periodStart"` + PeriodEnd string `json:"periodEnd"` + OverageCount int `json:"overageCount"` + TotalChargeCents int `json:"totalChargeCents"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + ProviderInvoiceID *string `json:"providerInvoiceId"` +} + +// TotalOverageDollars sums the recent overage line-item charges, in dollars. +func (s SubscriptionResponse) TotalOverageDollars() float64 { + cents := 0 + for _, c := range s.OverageCharges { + cents += c.TotalChargeCents + } + return float64(cents) / 100 +} + func NewStatusCmd(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "status", @@ -82,7 +103,7 @@ func NewStatusCmd(f *cmdutil.Factory) *cobra.Command { fmt.Fprintf(f.IOStreams.Out, "Plan: %s\n", sub.Subscription.Plan) fmt.Fprintf(f.IOStreams.Out, "Status: %s\n", sub.Subscription.Status) fmt.Fprintf(f.IOStreams.Out, "Executions: %d / %d\n", sub.Usage.Executions, sub.Usage.Limit) - fmt.Fprintf(f.IOStreams.Out, "Overage: $%.2f\n", sub.OverageCharges) + fmt.Fprintf(f.IOStreams.Out, "Overage: $%.2f\n", sub.TotalOverageDollars()) return nil }, } diff --git a/cmd/billing/status_test.go b/cmd/billing/status_test.go index 73ea728..c93b5f5 100644 --- a/cmd/billing/status_test.go +++ b/cmd/billing/status_test.go @@ -41,7 +41,17 @@ func makeSubscriptionResponse() map[string]interface{} { "executions": 450, "limit": 1000, }, - "overageCharges": 0.0, + "overageCharges": []map[string]interface{}{ + { + "periodStart": "2026-08-01T00:00:00.000Z", + "periodEnd": "2026-09-01T00:00:00.000Z", + "overageCount": 120, + "totalChargeCents": 350, + "status": "pending", + "createdAt": "2026-09-01T00:00:00.000Z", + "providerInvoiceId": nil, + }, + }, "limits": map[string]interface{}{ "maxWorkflows": 50, }, @@ -73,6 +83,49 @@ func TestStatusCmd(t *testing.T) { assert.Contains(t, out, "active") } +// realServerSubscriptionPayload mirrors GET /api/billing/subscription, where +// overageCharges is an array of recent billing line-items (not a scalar). +const realServerSubscriptionPayload = `{ + "subscription": {"plan": "Pro", "status": "active"}, + "usage": {"executionsUsed": 450, "executionLimit": 1000}, + "overageCharges": [ + {"periodStart": "2026-08-01T00:00:00.000Z", "periodEnd": "2026-09-01T00:00:00.000Z", "overageCount": 120, "totalChargeCents": 350, "status": "pending", "createdAt": "2026-09-01T00:00:00.000Z", "providerInvoiceId": null}, + {"periodStart": "2026-07-01T00:00:00.000Z", "periodEnd": "2026-08-01T00:00:00.000Z", "overageCount": 40, "totalChargeCents": 125, "status": "paid", "createdAt": "2026-08-01T00:00:00.000Z", "providerInvoiceId": "in_123"} + ], + "limits": {"maxWorkflows": 50} +}` + +func TestSubscriptionResponse_DecodesOverageChargesArray(t *testing.T) { + var sub billing.SubscriptionResponse + err := json.Unmarshal([]byte(realServerSubscriptionPayload), &sub) + require.NoError(t, err, "real server payload with overageCharges array must decode without error") + + require.Len(t, sub.OverageCharges, 2) + assert.Equal(t, 350, sub.OverageCharges[0].TotalChargeCents) + assert.Equal(t, "pending", sub.OverageCharges[0].Status) + assert.Nil(t, sub.OverageCharges[0].ProviderInvoiceID) + require.NotNil(t, sub.OverageCharges[1].ProviderInvoiceID) + assert.Equal(t, "in_123", *sub.OverageCharges[1].ProviderInvoiceID) + + // 350 + 125 cents = $4.75 + assert.InDelta(t, 4.75, sub.TotalOverageDollars(), 1e-9) +} + +func TestStatusCmd_OverageChargesArray(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(realServerSubscriptionPayload)) + })) + defer server.Close() + + ios, outBuf, _, _ := iostreams.Test() + f := newBillingFactory(server, ios) + + err := runBillingViaParent(f, []string{"st"}) + require.NoError(t, err, "overageCharges array must not crash response decoding") + assert.Contains(t, outBuf.String(), "Overage: $4.75") +} + func TestStatusCmd_NotEnabled(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "not found", http.StatusNotFound) diff --git a/cmd/billing/usage.go b/cmd/billing/usage.go index dfe9856..1166e1e 100644 --- a/cmd/billing/usage.go +++ b/cmd/billing/usage.go @@ -78,7 +78,7 @@ func NewUsageCmd(f *cmdutil.Factory) *cobra.Command { pct = (sub.Usage.Executions * 100) / sub.Usage.Limit } fmt.Fprintf(f.IOStreams.Out, "Executions: %d / %d (%d%% used)\n", sub.Usage.Executions, sub.Usage.Limit, pct) - fmt.Fprintf(f.IOStreams.Out, "Overage: $%.2f\n", sub.OverageCharges) + fmt.Fprintf(f.IOStreams.Out, "Overage: $%.2f\n", sub.TotalOverageDollars()) return nil }, } From 5f6cd82b1d9ba378c052d6019c8f356aa10198db Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Tue, 8 Sep 2026 13:31:10 +0800 Subject: [PATCH 2/3] fix: KEEP-1331 decode usage with server executionsUsed/executionLimit keys The CLI struct tagged usage as executions/limit, but the server sends executionsUsed/executionLimit, so Go silently decoded both to zero and kh billing status/usage reported "0 / 0" regardless of real usage. Align the JSON tags with the server and guard it with a decode test. --- cmd/billing/status.go | 4 ++-- cmd/billing/status_test.go | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/cmd/billing/status.go b/cmd/billing/status.go index d8073e5..1d8de61 100644 --- a/cmd/billing/status.go +++ b/cmd/billing/status.go @@ -18,8 +18,8 @@ type SubscriptionResponse struct { Status string `json:"status"` } `json:"subscription"` Usage struct { - Executions int `json:"executions"` - Limit int `json:"limit"` + Executions int `json:"executionsUsed"` + Limit int `json:"executionLimit"` } `json:"usage"` OverageCharges []OverageCharge `json:"overageCharges"` Limits map[string]interface{} `json:"limits"` diff --git a/cmd/billing/status_test.go b/cmd/billing/status_test.go index c93b5f5..f27e9c1 100644 --- a/cmd/billing/status_test.go +++ b/cmd/billing/status_test.go @@ -38,8 +38,8 @@ func makeSubscriptionResponse() map[string]interface{} { "status": "active", }, "usage": map[string]interface{}{ - "executions": 450, - "limit": 1000, + "executionsUsed": 450, + "executionLimit": 1000, }, "overageCharges": []map[string]interface{}{ { @@ -111,6 +111,18 @@ func TestSubscriptionResponse_DecodesOverageChargesArray(t *testing.T) { assert.InDelta(t, 4.75, sub.TotalOverageDollars(), 1e-9) } +func TestSubscriptionResponse_DecodesUsage(t *testing.T) { + var sub billing.SubscriptionResponse + err := json.Unmarshal([]byte(realServerSubscriptionPayload), &sub) + require.NoError(t, err) + + // The server sends usage.executionsUsed / usage.executionLimit; a struct + // tagged executions/limit silently decodes these to zero, so the command + // reports "0 / 0" regardless of real usage. + assert.Equal(t, 450, sub.Usage.Executions) + assert.Equal(t, 1000, sub.Usage.Limit) +} + func TestStatusCmd_OverageChargesArray(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") From fb767984b778258251b5d11ed4600fa340fbc415 Mon Sep 17 00:00:00 2001 From: Jacob Sussmilch Date: Tue, 8 Sep 2026 14:01:27 +0800 Subject: [PATCH 3/3] fix: KEEP-1331 count only pending, un-invoiced overage toward amount owed TotalOverageDollars summed every recent overage record, including ones already pushed to a provider invoice or in a non-pending status, so the CLI overstated the bill (re-billing settled charges). Match the billing UI: sum only records with providerInvoiceId==nil and status=="pending". --- cmd/billing/status.go | 10 ++++++++-- cmd/billing/status_test.go | 20 +++++++++++++++++--- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/cmd/billing/status.go b/cmd/billing/status.go index 1d8de61..513bc16 100644 --- a/cmd/billing/status.go +++ b/cmd/billing/status.go @@ -37,11 +37,17 @@ type OverageCharge struct { ProviderInvoiceID *string `json:"providerInvoiceId"` } -// TotalOverageDollars sums the recent overage line-item charges, in dollars. +// TotalOverageDollars sums the overage charges that will be added to the next +// invoice, in dollars: pending line-items not yet pushed to a provider invoice. +// Records already invoiced (providerInvoiceId set) or in any non-pending status +// are excluded, matching the billing UI, so the figure reflects what is +// currently owed rather than a rolling sum that re-bills settled charges. func (s SubscriptionResponse) TotalOverageDollars() float64 { cents := 0 for _, c := range s.OverageCharges { - cents += c.TotalChargeCents + if c.ProviderInvoiceID == nil && c.Status == "pending" { + cents += c.TotalChargeCents + } } return float64(cents) / 100 } diff --git a/cmd/billing/status_test.go b/cmd/billing/status_test.go index f27e9c1..865644e 100644 --- a/cmd/billing/status_test.go +++ b/cmd/billing/status_test.go @@ -107,8 +107,22 @@ func TestSubscriptionResponse_DecodesOverageChargesArray(t *testing.T) { require.NotNil(t, sub.OverageCharges[1].ProviderInvoiceID) assert.Equal(t, "in_123", *sub.OverageCharges[1].ProviderInvoiceID) - // 350 + 125 cents = $4.75 - assert.InDelta(t, 4.75, sub.TotalOverageDollars(), 1e-9) + // Only the pending, not-yet-invoiced record (350c) counts toward what is + // owed; the paid+invoiced record (125c) is excluded -> $3.50. + assert.InDelta(t, 3.50, sub.TotalOverageDollars(), 1e-9) +} + +func TestTotalOverageDollars_ExcludesInvoicedAndNonPending(t *testing.T) { + invoiceID := "in_1" + sub := billing.SubscriptionResponse{ + OverageCharges: []billing.OverageCharge{ + {TotalChargeCents: 350, Status: "pending", ProviderInvoiceID: nil}, // owed + {TotalChargeCents: 125, Status: "paid", ProviderInvoiceID: &invoiceID}, // already invoiced + {TotalChargeCents: 200, Status: "billed", ProviderInvoiceID: nil}, // not pending + }, + } + // Matches the billing UI: only providerInvoiceId==nil AND status=="pending". + assert.InDelta(t, 3.50, sub.TotalOverageDollars(), 1e-9) } func TestSubscriptionResponse_DecodesUsage(t *testing.T) { @@ -135,7 +149,7 @@ func TestStatusCmd_OverageChargesArray(t *testing.T) { err := runBillingViaParent(f, []string{"st"}) require.NoError(t, err, "overageCharges array must not crash response decoding") - assert.Contains(t, outBuf.String(), "Overage: $4.75") + assert.Contains(t, outBuf.String(), "Overage: $3.50") } func TestStatusCmd_NotEnabled(t *testing.T) {