Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions cmd/billing/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,40 @@ 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 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 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 {
if c.ProviderInvoiceID == nil && c.Status == "pending" {
cents += c.TotalChargeCents
}
}
return float64(cents) / 100
}

func NewStatusCmd(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "status",
Expand Down Expand Up @@ -82,7 +109,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
},
}
Expand Down
85 changes: 82 additions & 3 deletions cmd/billing/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,20 @@ func makeSubscriptionResponse() map[string]interface{} {
"status": "active",
},
"usage": map[string]interface{}{
"executions": 450,
"limit": 1000,
"executionsUsed": 450,
"executionLimit": 1000,
},
"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,
},
},
"overageCharges": 0.0,
"limits": map[string]interface{}{
"maxWorkflows": 50,
},
Expand Down Expand Up @@ -73,6 +83,75 @@ 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)

// 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) {
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")
_, _ = 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: $3.50")
}

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)
Expand Down
2 changes: 1 addition & 1 deletion cmd/billing/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
}
Expand Down