Skip to content

Commit f89b168

Browse files
authored
fix(billing): keep billing a request whose period start moved forward before its first charge (#8480)
* 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. * 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.
1 parent b84828d commit f89b168

5 files changed

Lines changed: 67 additions & 8 deletions

File tree

‎apps/sim/app/api/billing/update-cost/route.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,23 @@ describe('POST /api/billing/update-cost — mid-run usage gate', () => {
10501050

10511051
expect(body.usageExceeded).toBe(false)
10521052
})
1053+
1054+
it('keeps the exceeded verdict with the plan-upgrade card when the card read outlasts the callback budget', async () => {
1055+
billingPlanMockFns.mockGetHighestPrioritySubscription.mockImplementation(async () => {
1056+
await sleep(1500)
1057+
return { plan: 'pro' }
1058+
})
1059+
const startedAt = Date.now()
1060+
1061+
const body = await (await POST(directCallback())).json()
1062+
1063+
expect(body).toMatchObject({
1064+
success: true,
1065+
usageExceeded: true,
1066+
usageUpgrade: { action: 'upgrade_plan' },
1067+
})
1068+
expect(Date.now() - startedAt).toBeLessThan(1400)
1069+
})
10531070
})
10541071

10551072
describe('a run that outlives its billing period', () => {

‎apps/sim/app/api/billing/update-cost/route.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ function invalidBillingProtocolResponse(requestId: string, span: Span): NextResp
8585
* steady-state steps cost no ledger read. The charge is
8686
* already recorded when this runs; a gate that cannot answer reports not-exceeded and leaves the
8787
* refusal to the next step or re-check rather than ending a paying run on a database blip,
88-
* and so does a read that outlasts {@link USAGE_STANDING_TIMEOUT_MS}.
88+
* and so does a verdict read that outlasts {@link USAGE_STANDING_TIMEOUT_MS}. An exceeded
89+
* verdict always pauses the run; a card read past that budget falls back to the plan-upgrade card.
8990
*/
9091
async function readUsageStanding(
9192
userId: string,
@@ -98,9 +99,10 @@ async function readUsageStanding(
9899
? () => readMidRunAccountUsageVerdict(accountDecision)
99100
: null
100101
if (!isHosted || !readVerdict) return { usageExceeded: false }
102+
const deadlineAt = Date.now() + USAGE_STANDING_TIMEOUT_MS
101103
let verdict: MidRunUsageVerdict
102104
try {
103-
verdict = await withinDeadline(readVerdict, Date.now() + USAGE_STANDING_TIMEOUT_MS)
105+
verdict = await withinDeadline(readVerdict, deadlineAt)
104106
} catch {
105107
logger.warn('Usage standing read outlasted the callback budget; answering not exceeded')
106108
return { usageExceeded: false }
@@ -110,7 +112,12 @@ async function readUsageStanding(
110112
if (verdict.status !== 'exceeded') return { usageExceeded: false }
111113
return {
112114
usageExceeded: true,
113-
usageUpgrade: await resolveUsageUpgradePayload(userId, billingAttribution, verdict.scope),
115+
usageUpgrade: await resolveUsageUpgradePayload(
116+
userId,
117+
billingAttribution,
118+
verdict.scope,
119+
deadlineAt
120+
),
114121
}
115122
}
116123

‎apps/sim/lib/billing/core/usage-log.integration.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,32 @@ describe('Cumulative billing with PostgreSQL', () => {
502502
expect(await stampedWindowTotal(resetStart, resetEnd)).toBeCloseTo(0.6, 9)
503503
})
504504

505+
it('keeps billing a request whose period start moved forward before its first charge', async () => {
506+
const resetStart = new Date('2025-09-15T00:00:00.000Z')
507+
const resetEnd = new Date('2025-10-15T00:00:00.000Z')
508+
await setSubscriptionWindow(resetStart, resetEnd)
509+
510+
expect(await charge(0.4)).toMatchObject({ billed: true, total: 0.4 })
511+
expect(await charge(1)).toMatchObject({
512+
billed: true,
513+
total: 1,
514+
billingPeriod: { start: resetStart, end: resetEnd },
515+
})
516+
expect(await ledgerRows()).toEqual([{ event_key: usage(0).eventKey, cost: '1' }])
517+
expect(await stampedWindowTotal(resetStart, resetEnd)).toBeCloseTo(1, 9)
518+
})
519+
520+
it('refuses a request admitted after the period its first charge was stamped with', async () => {
521+
await setSubscriptionPeriod(0)
522+
await charge(0.4)
523+
524+
await expect(charge(1, { start: periods[1], end: periods[2] })).rejects.toMatchObject({
525+
name: CumulativeUsageContextMismatchError.name,
526+
mismatchedFields: ['billing period'],
527+
})
528+
expect(await ledgerRows()).toEqual([{ event_key: usage(0).eventKey, cost: '0.4' }])
529+
})
530+
505531
it('holds an early period-start move until an in-flight top-up commits', async () => {
506532
const start = new Date(Date.now() - 24 * 60 * 60 * 1000)
507533
const end = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)

‎apps/sim/lib/billing/core/usage-log.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -696,7 +696,10 @@ function assertCumulativeUsageLedgerBinding(
696696
workspaceId?: string
697697
billingContext: BillingContext
698698
eventKey: string
699-
/** A request whose first charge landed after its period closed is stamped with a later one. */
699+
/**
700+
* A request whose first charge landed after its period closed, or after an anchor reset moved
701+
* its start forward, is stamped with a later one.
702+
*/
700703
allowLaterPeriod?: boolean
701704
}
702705
): void {
@@ -717,10 +720,11 @@ function assertCumulativeUsageLedgerBinding(
717720
const samePeriod =
718721
existing.billingPeriodStart?.getTime() === frozenPeriod.start.getTime() &&
719722
existing.billingPeriodEnd?.getTime() === frozenPeriod.end.getTime()
723+
// The same forward-only rule that rolls a charge into a new period row.
720724
const laterPeriod =
721725
expected.allowLaterPeriod === true &&
722726
existing.billingPeriodStart !== null &&
723-
existing.billingPeriodStart.getTime() >= frozenPeriod.end.getTime()
727+
existing.billingPeriodStart.getTime() > frozenPeriod.start.getTime()
724728
if (!samePeriod && !laterPeriod) {
725729
mismatchedFields.push('billing period')
726730
}

‎apps/sim/lib/billing/usage-upgrade.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
99
import { isEnterprise, isPaid } from '@/lib/billing/plan-helpers'
1010
import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
11+
import { withinDeadline } from '@/lib/core/utils/deadline'
1112

1213
const logger = createLogger('UsageUpgrade')
1314

@@ -22,12 +23,14 @@ const MEMBER_CAP_MESSAGE =
2223
* increase for a paid one, with copy naming who can raise an organization's limit. A member
2324
* over the cap their organization set gets copy naming who can raise that cap. An attributed
2425
* run reads the plan from its admission snapshot without a query; otherwise the actor's current
25-
* subscription decides, and a failed lookup falls back to the plan-upgrade card.
26+
* subscription decides, and a lookup that fails or outlasts `deadlineAt` falls back to the
27+
* plan-upgrade card.
2628
*/
2729
export async function resolveUsageUpgradePayload(
2830
userId: string,
2931
billingAttribution?: BillingAttributionSnapshot,
30-
scope?: AttributedUsageLimitsResult['scope']
32+
scope?: AttributedUsageLimitsResult['scope'],
33+
deadlineAt?: number
3134
): Promise<UsageUpgradePayload> {
3235
if (scope === 'member') {
3336
return { reason: 'usage_limit', action: 'increase_limit', message: MEMBER_CAP_MESSAGE }
@@ -39,7 +42,9 @@ export async function resolveUsageUpgradePayload(
3942
plan = billingAttribution.payerSubscription?.plan
4043
orgScoped = billingAttribution.billingEntity.type === 'organization'
4144
} else {
42-
const subscription = await getHighestPrioritySubscription(userId)
45+
const subscription = await (deadlineAt === undefined
46+
? getHighestPrioritySubscription(userId)
47+
: withinDeadline(() => getHighestPrioritySubscription(userId), deadlineAt))
4348
plan = subscription?.plan
4449
orgScoped = isOrgScopedSubscription(subscription, userId)
4550
}

0 commit comments

Comments
 (0)