From c2f285abed090af5b81b904b4bfbb2be8a3a9667 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Thu, 20 Aug 2026 09:06:10 +0300 Subject: [PATCH 1/2] fix(billing): read the one-number usage shape (AIT-436) /webhook/usage no longer ships totalMessages/totalForwards or the inbound/outbound split: they were three names for the same figure, two of them saying "messages" over an action count. The legacy status renderer reads `total`, and keeps its message wording because the actions branch returns before it. --- src/__tests__/billing.test.ts | 48 +++++++++++++++++------------------ src/commands/billing.ts | 13 ++++++++-- 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/__tests__/billing.test.ts b/src/__tests__/billing.test.ts index 539e7a8..8e7bd5d 100644 --- a/src/__tests__/billing.test.ts +++ b/src/__tests__/billing.test.ts @@ -88,7 +88,7 @@ async function advanceUntilSettled( } } -function mockSubAndUsage(sub: any, usage: { totalMessages: number; limit: number; percentage: number }) { +function mockSubAndUsage(sub: any, usage: { total: number; limit: number; percentage: number }) { mockedApiClient.mockImplementation(async (path: string) => { if (path === '/workspaces') return WORKSPACES; if (path === SUBSCRIPTION_PATH) return sub; @@ -122,7 +122,7 @@ describe('billing commands', () => { describe('billingStatus', () => { it('calls apiClient with GET the org subscription route and /webhook/usage', async () => { - mockSubAndUsage(activeSub, { totalMessages: 100, limit: 1200, percentage: 8 }); + mockSubAndUsage(activeSub, { total: 100, limit: 1200, percentage: 8 }); await billingStatus({ human: false }); @@ -135,7 +135,7 @@ describe('billing commands', () => { describe('billingStatus json', () => { it('emits structured { subscription, usage } when human=false', async () => { - const usage = { totalMessages: 100, limit: 1200, percentage: 8 }; + const usage = { total: 100, limit: 1200, percentage: 8 }; mockSubAndUsage(activeSub, usage); await billingStatus({ human: false }); @@ -151,7 +151,7 @@ describe('billing commands', () => { describe('billingStatus human', () => { it('renders plan/status/interval/renews/messages with no nudge under 80%', async () => { - mockSubAndUsage(activeSub, { totalMessages: 600, limit: 1200, percentage: 50 }); + mockSubAndUsage(activeSub, { total: 600, limit: 1200, percentage: 50 }); await billingStatus({ human: true }); @@ -168,7 +168,7 @@ describe('billing commands', () => { describe('billingStatus human — free tier', () => { it('renders interval/renews as "n/a" (not an em-dash) when the subscription has no billing fields', async () => { - mockSubAndUsage(freeSub, { totalMessages: 10, limit: 50, percentage: 20 }); + mockSubAndUsage(freeSub, { total: 10, limit: 50, percentage: 20 }); await billingStatus({ human: true }); @@ -182,7 +182,7 @@ describe('billing commands', () => { describe('billingStatus cancel warning', () => { it('prints cancel warning when cancelAtPeriodEnd is true', async () => { mockSubAndUsage({ ...activeSub, cancelAtPeriodEnd: true }, { - totalMessages: 100, + total: 100, limit: 1200, percentage: 8, }); @@ -196,7 +196,7 @@ describe('billing commands', () => { describe('billingStatus 80% nudge', () => { it('prints yellow nudge with billing upgrade reference at 85%', async () => { - mockSubAndUsage(activeSub, { totalMessages: 1020, limit: 1200, percentage: 85 }); + mockSubAndUsage(activeSub, { total: 1020, limit: 1200, percentage: 85 }); await billingStatus({ human: true }); @@ -208,7 +208,7 @@ describe('billing commands', () => { describe('billingStatus 100% over limit', () => { it('prints red exceeded line at 105%', async () => { - mockSubAndUsage(activeSub, { totalMessages: 1260, limit: 1200, percentage: 105 }); + mockSubAndUsage(activeSub, { total: 1260, limit: 1200, percentage: 105 }); await billingStatus({ human: true }); @@ -239,7 +239,7 @@ describe('billing commands', () => { unlimited: true, trial: { status: 'not_started', endsAt: null, daysLeft: null }, }, - { totalMessages: 0, limit: 0, percentage: 0 }, + { total: 0, limit: 0, percentage: 0 }, ); await billingStatus({ human: true }); @@ -260,7 +260,7 @@ describe('billing commands', () => { unlimited: false, trial: { status: 'active', endsAt: '2026-08-22T00:00:00.000Z', daysLeft: 4 }, }, - { totalMessages: 0, limit: 0, percentage: 0 }, + { total: 0, limit: 0, percentage: 0 }, ); await billingStatus({ human: true }); @@ -283,7 +283,7 @@ describe('billing commands', () => { actionsQuota: 0, unlimited: false, }, - { totalMessages: 1234, limit: 30000, percentage: 4 }, + { total: 1234, limit: 30000, percentage: 4 }, ); await billingStatus({ human: true }); @@ -307,7 +307,7 @@ describe('billing commands', () => { trial: null, cancelAtPeriodEnd: true, }, - { totalMessages: 0, limit: 0, percentage: 0 }, + { total: 0, limit: 0, percentage: 0 }, ); await billingStatus({ human: true }); @@ -329,7 +329,7 @@ describe('billing commands', () => { unlimited: false, trial: null, }, - { totalMessages: 0, limit: 0, percentage: 0 }, + { total: 0, limit: 0, percentage: 0 }, ); await billingStatus({ human: true }); @@ -350,7 +350,7 @@ describe('billing commands', () => { unlimited: false, trial: null, }, - { totalMessages: 0, limit: 0, percentage: 0 }, + { total: 0, limit: 0, percentage: 0 }, ); await billingStatus({ json: true }); @@ -371,7 +371,7 @@ describe('billing commands', () => { unlimited: false, trial: null, }, - { totalMessages: 0, limit: 0, percentage: 0 }, + { total: 0, limit: 0, percentage: 0 }, ); await billingStatus({ human: true }); @@ -392,7 +392,7 @@ describe('billing commands', () => { unlimited: false, trial: { status: 'expired', endsAt: '2026-08-10T00:00:00.000Z', daysLeft: 0 }, }, - { totalMessages: 0, limit: 0, percentage: 0 }, + { total: 0, limit: 0, percentage: 0 }, ); mockedGetBillingEligibility.mockResolvedValueOnce({ eligiblePlan: 'build', @@ -419,7 +419,7 @@ describe('billing commands', () => { unlimited: false, trial: null, }, - { totalMessages: 0, limit: 0, percentage: 0 }, + { total: 0, limit: 0, percentage: 0 }, ); await billingStatus({ human: true }); @@ -439,7 +439,7 @@ describe('billing commands', () => { unlimited: false, trial: null, }, - { totalMessages: 0, limit: 0, percentage: 0 }, + { total: 0, limit: 0, percentage: 0 }, ); await billingStatus({ human: true }); @@ -460,7 +460,7 @@ describe('billing commands', () => { unlimited: false, trial: null, }, - { totalMessages: 0, limit: 0, percentage: 0 }, + { total: 0, limit: 0, percentage: 0 }, ); await billingStatus({ human: true }); @@ -480,7 +480,7 @@ describe('billing commands', () => { unlimited: false, trial: null, }, - { totalMessages: 0, limit: 0, percentage: 0 }, + { total: 0, limit: 0, percentage: 0 }, ); await billingStatus({ human: true }); @@ -491,7 +491,7 @@ describe('billing commands', () => { }); it('legacy org (no usageUnit) is untouched: no eligibility call, existing output shape', async () => { - mockSubAndUsage(activeSub, { totalMessages: 600, limit: 1200, percentage: 50 }); + mockSubAndUsage(activeSub, { total: 600, limit: 1200, percentage: 50 }); await billingStatus({ human: true }); @@ -512,7 +512,7 @@ describe('billing commands', () => { unlimited: false, trial: null, }, - { totalMessages: 0, limit: 0, percentage: 0 }, + { total: 0, limit: 0, percentage: 0 }, ); await billingStatus({ human: false }); @@ -958,7 +958,7 @@ describe('billing commands — npx prefix roll-out (cliCommandPrefix)', () => { }); it('billingStatus 80% nudge prints "npx hookmyapp billing upgrade" under npm_command=exec', async () => { - mockSubAndUsage(activeSub, { totalMessages: 1020, limit: 1200, percentage: 85 }); + mockSubAndUsage(activeSub, { total: 1020, limit: 1200, percentage: 85 }); await billingStatus({ human: true }); @@ -967,7 +967,7 @@ describe('billing commands — npx prefix roll-out (cliCommandPrefix)', () => { }); it('billingStatus 100% over-limit prints "npx hookmyapp billing upgrade"', async () => { - mockSubAndUsage(activeSub, { totalMessages: 1260, limit: 1200, percentage: 105 }); + mockSubAndUsage(activeSub, { total: 1260, limit: 1200, percentage: 105 }); await billingStatus({ human: true }); diff --git a/src/commands/billing.ts b/src/commands/billing.ts index 87dbd92..f1c4953 100644 --- a/src/commands/billing.ts +++ b/src/commands/billing.ts @@ -251,7 +251,14 @@ export async function billingStatus(opts: { json?: boolean; human?: boolean } = const [sub, usage] = (await Promise.all([ apiClient(`/organizations/${orgPublicId}/billing/subscription`), apiClient('/webhook/usage', { workspaceId }), - ])) as [BillingSubscription, { totalMessages: number; limit: number; percentage: number }]; + // AIT-436: `/webhook/usage` reports ONE number, in `usageUnit`. The + // totalMessages/totalForwards aliases and the inbound/outbound split are + // gone -- they were three names for the same figure, two of them saying + // "messages" over an action count. + ])) as [ + BillingSubscription, + { total: number; limit: number; percentage: number; usageUnit: 'messages' | 'actions' }, + ]; // Accept either `json: true` or `human: false` (back-compat with callers // and tests that predate the phase-108 opts shape). @@ -306,7 +313,9 @@ export async function billingStatus(opts: { json?: boolean; human?: boolean } = const status = sub.status; const interval = sub.billingInterval ?? 'n/a'; const renews = sub.currentPeriodEnd ?? 'n/a'; - const messages = `${usage.totalMessages} / ${usage.limit} (${usage.percentage}%)`; + // This renderer is only reached for message-metered orgs (the actions + // branch returned above), so the label is literally true here. + const messages = `${usage.total} / ${usage.limit} (${usage.percentage}%)`; output({ plan, status, interval, renews, messages }, { json: false, kind: 'read' }); From d782dfe919aea6f1069f11a2ad924332b8e7225f Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Thu, 20 Aug 2026 11:11:43 +0300 Subject: [PATCH 2/2] test(billing): fixtures carry usageUnit, matching the contract A usage fixture without `usageUnit` is a payload the backend never sends, so the tests could pass against a shape that does not exist (CodeRabbit, PR #62). The mock helper now fills in the legacy meter, which is what every one of these cases is, and the json-output test states it explicitly because it compares the whole object. --- src/__tests__/billing.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/__tests__/billing.test.ts b/src/__tests__/billing.test.ts index 8e7bd5d..c4ed2ad 100644 --- a/src/__tests__/billing.test.ts +++ b/src/__tests__/billing.test.ts @@ -88,11 +88,18 @@ async function advanceUntilSettled( } } -function mockSubAndUsage(sub: any, usage: { total: number; limit: number; percentage: number }) { +// AIT-436: `usageUnit` is part of the /webhook/usage contract, so a fixture +// without it is a payload the backend never sends (CodeRabbit, PR #62). It +// defaults to the legacy meter, which is what every one of these cases is. +function mockSubAndUsage( + sub: any, + usage: { total: number; limit: number; percentage: number; usageUnit?: 'messages' | 'actions' }, +) { + const withUnit = { usageUnit: 'messages' as const, ...usage }; mockedApiClient.mockImplementation(async (path: string) => { if (path === '/workspaces') return WORKSPACES; if (path === SUBSCRIPTION_PATH) return sub; - if (path === '/webhook/usage') return usage; + if (path === '/webhook/usage') return withUnit; throw new Error(`unexpected path: ${path}`); }); } @@ -135,7 +142,7 @@ describe('billing commands', () => { describe('billingStatus json', () => { it('emits structured { subscription, usage } when human=false', async () => { - const usage = { total: 100, limit: 1200, percentage: 8 }; + const usage = { total: 100, limit: 1200, percentage: 8, usageUnit: 'messages' as const }; mockSubAndUsage(activeSub, usage); await billingStatus({ human: false });