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..d69abad26b3 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,23 @@ describe('POST /api/billing/update-cost — mid-run usage gate', () => { expect(body.usageExceeded).toBe(false) }) + + 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 { plan: 'pro' } + }) + const startedAt = Date.now() + + const body = await (await POST(directCallback())).json() + + expect(body).toMatchObject({ + success: true, + usageExceeded: true, + usageUpgrade: { action: 'upgrade_plan' }, + }) + 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..2a7bdfda6cd 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -85,7 +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 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,9 +99,10 @@ async function readUsageStanding( ? () => readMidRunAccountUsageVerdict(accountDecision) : null if (!isHosted || !readVerdict) return { usageExceeded: false } + const deadlineAt = Date.now() + USAGE_STANDING_TIMEOUT_MS let verdict: MidRunUsageVerdict try { - verdict = await withinDeadline(readVerdict, 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 } @@ -110,7 +112,12 @@ async function readUsageStanding( if (verdict.status !== 'exceeded') return { usageExceeded: false } return { usageExceeded: true, - usageUpgrade: await resolveUsageUpgradePayload(userId, billingAttribution, verdict.scope), + usageUpgrade: await resolveUsageUpgradePayload( + userId, + billingAttribution, + verdict.scope, + deadlineAt + ), } } 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') } 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) }