From 5a41b182a319ce4174a47f2f6878750ff46ab3f9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 30 Sep 2026 14:47:39 -0700 Subject: [PATCH 1/2] fix(billing): keep billing a request whose period start moved forward before its first charge A Stripe anchor reset between admission and a request's first cost callback stamps row 0 with the reset period. The ledger binding only accepted a later period starting at or after the admitted period's end, so every later callback was refused with a billing-context mismatch and its spend went unbilled. The binding now accepts the same forward-only rule the roll uses: a start later than the admitted one. An earlier period is still refused. Also bound the upgrade-card subscription read in update-cost under the same 1 s standing deadline as the verdict read. --- .../app/api/billing/update-cost/route.test.ts | 13 ++++++++++ apps/sim/app/api/billing/update-cost/route.ts | 24 +++++++++-------- .../lib/billing/core/usage-log.integration.ts | 26 +++++++++++++++++++ apps/sim/lib/billing/core/usage-log.ts | 8 ++++-- 4 files changed, 58 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/api/billing/update-cost/route.test.ts b/apps/sim/app/api/billing/update-cost/route.test.ts index 60407452e78..c6babec977f 100644 --- a/apps/sim/app/api/billing/update-cost/route.test.ts +++ b/apps/sim/app/api/billing/update-cost/route.test.ts @@ -1050,6 +1050,19 @@ describe('POST /api/billing/update-cost — mid-run usage gate', () => { expect(body.usageExceeded).toBe(false) }) + + it('answers not exceeded when the upgrade-card read outlasts the callback budget', async () => { + billingPlanMockFns.mockGetHighestPrioritySubscription.mockImplementation(async () => { + await sleep(1500) + return null + }) + const startedAt = Date.now() + + const body = await (await POST(directCallback())).json() + + expect(body).toMatchObject({ success: true, usageExceeded: false }) + expect(Date.now() - startedAt).toBeLessThan(1400) + }) }) describe('a run that outlives its billing period', () => { diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts index 8f4be83be39..889d9260ddd 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -23,7 +23,6 @@ import { toBillingContext, } from '@/lib/billing/core/billing-attribution' import { - type MidRunUsageVerdict, readMidRunAccountUsageVerdict, readMidRunUsageVerdict, } from '@/lib/billing/core/mid-run-usage' @@ -85,7 +84,8 @@ function invalidBillingProtocolResponse(requestId: string, span: Span): NextResp * steady-state steps cost no ledger read. The charge is * already recorded when this runs; a gate that cannot answer reports not-exceeded and leaves the * refusal to the next step or re-check rather than ending a paying run on a database blip, - * and so does a read that outlasts {@link USAGE_STANDING_TIMEOUT_MS}. + * and so does a read, verdict and upgrade card together, that outlasts + * {@link USAGE_STANDING_TIMEOUT_MS}. */ async function readUsageStanding( userId: string, @@ -98,20 +98,22 @@ async function readUsageStanding( ? () => readMidRunAccountUsageVerdict(accountDecision) : null if (!isHosted || !readVerdict) return { usageExceeded: false } - let verdict: MidRunUsageVerdict + const readStanding = async (): Promise => { + const verdict = await readVerdict() + // Only a spent limit pauses the run. A blocked account is refused at the run's next + // continuation or re-check, with blocked-account copy rather than the upgrade card. + if (verdict.status !== 'exceeded') return { usageExceeded: false } + return { + usageExceeded: true, + usageUpgrade: await resolveUsageUpgradePayload(userId, billingAttribution, verdict.scope), + } + } try { - verdict = await withinDeadline(readVerdict, Date.now() + USAGE_STANDING_TIMEOUT_MS) + return await withinDeadline(readStanding, Date.now() + USAGE_STANDING_TIMEOUT_MS) } catch { logger.warn('Usage standing read outlasted the callback budget; answering not exceeded') return { usageExceeded: false } } - // Only a spent limit pauses the run. A blocked account is refused at the run's next - // continuation or re-check, with blocked-account copy rather than the upgrade card. - if (verdict.status !== 'exceeded') return { usageExceeded: false } - return { - usageExceeded: true, - usageUpgrade: await resolveUsageUpgradePayload(userId, billingAttribution, verdict.scope), - } } function getBillingResolution( diff --git a/apps/sim/lib/billing/core/usage-log.integration.ts b/apps/sim/lib/billing/core/usage-log.integration.ts index e03401fb602..f7acdaaffed 100644 --- a/apps/sim/lib/billing/core/usage-log.integration.ts +++ b/apps/sim/lib/billing/core/usage-log.integration.ts @@ -502,6 +502,32 @@ describe('Cumulative billing with PostgreSQL', () => { expect(await stampedWindowTotal(resetStart, resetEnd)).toBeCloseTo(0.6, 9) }) + it('keeps billing a request whose period start moved forward before its first charge', async () => { + const resetStart = new Date('2025-09-15T00:00:00.000Z') + const resetEnd = new Date('2025-10-15T00:00:00.000Z') + await setSubscriptionWindow(resetStart, resetEnd) + + expect(await charge(0.4)).toMatchObject({ billed: true, total: 0.4 }) + expect(await charge(1)).toMatchObject({ + billed: true, + total: 1, + billingPeriod: { start: resetStart, end: resetEnd }, + }) + expect(await ledgerRows()).toEqual([{ event_key: usage(0).eventKey, cost: '1' }]) + expect(await stampedWindowTotal(resetStart, resetEnd)).toBeCloseTo(1, 9) + }) + + it('refuses a request admitted after the period its first charge was stamped with', async () => { + await setSubscriptionPeriod(0) + await charge(0.4) + + await expect(charge(1, { start: periods[1], end: periods[2] })).rejects.toMatchObject({ + name: CumulativeUsageContextMismatchError.name, + mismatchedFields: ['billing period'], + }) + expect(await ledgerRows()).toEqual([{ event_key: usage(0).eventKey, cost: '0.4' }]) + }) + it('holds an early period-start move until an in-flight top-up commits', async () => { const start = new Date(Date.now() - 24 * 60 * 60 * 1000) const end = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) diff --git a/apps/sim/lib/billing/core/usage-log.ts b/apps/sim/lib/billing/core/usage-log.ts index 40276626118..1989f215322 100644 --- a/apps/sim/lib/billing/core/usage-log.ts +++ b/apps/sim/lib/billing/core/usage-log.ts @@ -696,7 +696,10 @@ function assertCumulativeUsageLedgerBinding( workspaceId?: string billingContext: BillingContext eventKey: string - /** A request whose first charge landed after its period closed is stamped with a later one. */ + /** + * A request whose first charge landed after its period closed, or after an anchor reset moved + * its start forward, is stamped with a later one. + */ allowLaterPeriod?: boolean } ): void { @@ -717,10 +720,11 @@ function assertCumulativeUsageLedgerBinding( const samePeriod = existing.billingPeriodStart?.getTime() === frozenPeriod.start.getTime() && existing.billingPeriodEnd?.getTime() === frozenPeriod.end.getTime() + // The same forward-only rule that rolls a charge into a new period row. const laterPeriod = expected.allowLaterPeriod === true && existing.billingPeriodStart !== null && - existing.billingPeriodStart.getTime() >= frozenPeriod.end.getTime() + existing.billingPeriodStart.getTime() > frozenPeriod.start.getTime() if (!samePeriod && !laterPeriod) { mismatchedFields.push('billing period') } From ac744d7f57f31c0127170789d415ec87ed4f65bb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 30 Sep 2026 15:34:38 -0700 Subject: [PATCH 2/2] fix(billing): keep an exceeded verdict when the upgrade-card read is slow The shared deadline discarded an exceeded verdict when the card lookup ran past the budget. The verdict read keeps the deadline; the card lookup now falls back to the plan-upgrade card past the same deadline. --- .../app/api/billing/update-cost/route.test.ts | 10 ++++-- apps/sim/app/api/billing/update-cost/route.ts | 31 +++++++++++-------- apps/sim/lib/billing/usage-upgrade.ts | 11 +++++-- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/apps/sim/app/api/billing/update-cost/route.test.ts b/apps/sim/app/api/billing/update-cost/route.test.ts index c6babec977f..d69abad26b3 100644 --- a/apps/sim/app/api/billing/update-cost/route.test.ts +++ b/apps/sim/app/api/billing/update-cost/route.test.ts @@ -1051,16 +1051,20 @@ describe('POST /api/billing/update-cost — mid-run usage gate', () => { expect(body.usageExceeded).toBe(false) }) - it('answers not exceeded when the upgrade-card read outlasts the callback budget', async () => { + it('keeps the exceeded verdict with the plan-upgrade card when the card read outlasts the callback budget', async () => { billingPlanMockFns.mockGetHighestPrioritySubscription.mockImplementation(async () => { await sleep(1500) - return null + return { plan: 'pro' } }) const startedAt = Date.now() const body = await (await POST(directCallback())).json() - expect(body).toMatchObject({ success: true, usageExceeded: false }) + expect(body).toMatchObject({ + success: true, + usageExceeded: true, + usageUpgrade: { action: 'upgrade_plan' }, + }) expect(Date.now() - startedAt).toBeLessThan(1400) }) }) diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts index 889d9260ddd..2a7bdfda6cd 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -23,6 +23,7 @@ import { toBillingContext, } from '@/lib/billing/core/billing-attribution' import { + type MidRunUsageVerdict, readMidRunAccountUsageVerdict, readMidRunUsageVerdict, } from '@/lib/billing/core/mid-run-usage' @@ -84,8 +85,8 @@ function invalidBillingProtocolResponse(requestId: string, span: Span): NextResp * steady-state steps cost no ledger read. The charge is * already recorded when this runs; a gate that cannot answer reports not-exceeded and leaves the * refusal to the next step or re-check rather than ending a paying run on a database blip, - * and so does a read, verdict and upgrade card together, that outlasts - * {@link USAGE_STANDING_TIMEOUT_MS}. + * and so does a verdict read that outlasts {@link USAGE_STANDING_TIMEOUT_MS}. An exceeded + * verdict always pauses the run; a card read past that budget falls back to the plan-upgrade card. */ async function readUsageStanding( userId: string, @@ -98,22 +99,26 @@ async function readUsageStanding( ? () => readMidRunAccountUsageVerdict(accountDecision) : null if (!isHosted || !readVerdict) return { usageExceeded: false } - const readStanding = async (): Promise => { - const verdict = await readVerdict() - // Only a spent limit pauses the run. A blocked account is refused at the run's next - // continuation or re-check, with blocked-account copy rather than the upgrade card. - if (verdict.status !== 'exceeded') return { usageExceeded: false } - return { - usageExceeded: true, - usageUpgrade: await resolveUsageUpgradePayload(userId, billingAttribution, verdict.scope), - } - } + const deadlineAt = Date.now() + USAGE_STANDING_TIMEOUT_MS + let verdict: MidRunUsageVerdict try { - return await withinDeadline(readStanding, Date.now() + USAGE_STANDING_TIMEOUT_MS) + verdict = await withinDeadline(readVerdict, deadlineAt) } catch { logger.warn('Usage standing read outlasted the callback budget; answering not exceeded') return { usageExceeded: false } } + // Only a spent limit pauses the run. A blocked account is refused at the run's next + // continuation or re-check, with blocked-account copy rather than the upgrade card. + if (verdict.status !== 'exceeded') return { usageExceeded: false } + return { + usageExceeded: true, + usageUpgrade: await resolveUsageUpgradePayload( + userId, + billingAttribution, + verdict.scope, + deadlineAt + ), + } } function getBillingResolution( diff --git a/apps/sim/lib/billing/usage-upgrade.ts b/apps/sim/lib/billing/usage-upgrade.ts index e72713a8d8c..95cf5813539 100644 --- a/apps/sim/lib/billing/usage-upgrade.ts +++ b/apps/sim/lib/billing/usage-upgrade.ts @@ -8,6 +8,7 @@ import type { import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { isEnterprise, isPaid } from '@/lib/billing/plan-helpers' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' +import { withinDeadline } from '@/lib/core/utils/deadline' const logger = createLogger('UsageUpgrade') @@ -22,12 +23,14 @@ const MEMBER_CAP_MESSAGE = * increase for a paid one, with copy naming who can raise an organization's limit. A member * over the cap their organization set gets copy naming who can raise that cap. An attributed * run reads the plan from its admission snapshot without a query; otherwise the actor's current - * subscription decides, and a failed lookup falls back to the plan-upgrade card. + * subscription decides, and a lookup that fails or outlasts `deadlineAt` falls back to the + * plan-upgrade card. */ export async function resolveUsageUpgradePayload( userId: string, billingAttribution?: BillingAttributionSnapshot, - scope?: AttributedUsageLimitsResult['scope'] + scope?: AttributedUsageLimitsResult['scope'], + deadlineAt?: number ): Promise { if (scope === 'member') { return { reason: 'usage_limit', action: 'increase_limit', message: MEMBER_CAP_MESSAGE } @@ -39,7 +42,9 @@ export async function resolveUsageUpgradePayload( plan = billingAttribution.payerSubscription?.plan orgScoped = billingAttribution.billingEntity.type === 'organization' } else { - const subscription = await getHighestPrioritySubscription(userId) + const subscription = await (deadlineAt === undefined + ? getHighestPrioritySubscription(userId) + : withinDeadline(() => getHighestPrioritySubscription(userId), deadlineAt)) plan = subscription?.plan orgScoped = isOrgScopedSubscription(subscription, userId) }