Skip to content

Commit ac744d7

Browse files
committed
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 5a41b18 commit ac744d7

3 files changed

Lines changed: 33 additions & 19 deletions

File tree

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,16 +1051,20 @@ describe('POST /api/billing/update-cost — mid-run usage gate', () => {
10511051
expect(body.usageExceeded).toBe(false)
10521052
})
10531053

1054-
it('answers not exceeded when the upgrade-card read outlasts the callback budget', async () => {
1054+
it('keeps the exceeded verdict with the plan-upgrade card when the card read outlasts the callback budget', async () => {
10551055
billingPlanMockFns.mockGetHighestPrioritySubscription.mockImplementation(async () => {
10561056
await sleep(1500)
1057-
return null
1057+
return { plan: 'pro' }
10581058
})
10591059
const startedAt = Date.now()
10601060

10611061
const body = await (await POST(directCallback())).json()
10621062

1063-
expect(body).toMatchObject({ success: true, usageExceeded: false })
1063+
expect(body).toMatchObject({
1064+
success: true,
1065+
usageExceeded: true,
1066+
usageUpgrade: { action: 'upgrade_plan' },
1067+
})
10641068
expect(Date.now() - startedAt).toBeLessThan(1400)
10651069
})
10661070
})

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

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
toBillingContext,
2424
} from '@/lib/billing/core/billing-attribution'
2525
import {
26+
type MidRunUsageVerdict,
2627
readMidRunAccountUsageVerdict,
2728
readMidRunUsageVerdict,
2829
} from '@/lib/billing/core/mid-run-usage'
@@ -84,8 +85,8 @@ function invalidBillingProtocolResponse(requestId: string, span: Span): NextResp
8485
* steady-state steps cost no ledger read. The charge is
8586
* already recorded when this runs; a gate that cannot answer reports not-exceeded and leaves the
8687
* refusal to the next step or re-check rather than ending a paying run on a database blip,
87-
* and so does a read, verdict and upgrade card together, that outlasts
88-
* {@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,22 +99,26 @@ async function readUsageStanding(
9899
? () => readMidRunAccountUsageVerdict(accountDecision)
99100
: null
100101
if (!isHosted || !readVerdict) return { usageExceeded: false }
101-
const readStanding = async (): Promise<BillingUsageVerdict> => {
102-
const verdict = await readVerdict()
103-
// Only a spent limit pauses the run. A blocked account is refused at the run's next
104-
// continuation or re-check, with blocked-account copy rather than the upgrade card.
105-
if (verdict.status !== 'exceeded') return { usageExceeded: false }
106-
return {
107-
usageExceeded: true,
108-
usageUpgrade: await resolveUsageUpgradePayload(userId, billingAttribution, verdict.scope),
109-
}
110-
}
102+
const deadlineAt = Date.now() + USAGE_STANDING_TIMEOUT_MS
103+
let verdict: MidRunUsageVerdict
111104
try {
112-
return await withinDeadline(readStanding, Date.now() + USAGE_STANDING_TIMEOUT_MS)
105+
verdict = await withinDeadline(readVerdict, deadlineAt)
113106
} catch {
114107
logger.warn('Usage standing read outlasted the callback budget; answering not exceeded')
115108
return { usageExceeded: false }
116109
}
110+
// Only a spent limit pauses the run. A blocked account is refused at the run's next
111+
// continuation or re-check, with blocked-account copy rather than the upgrade card.
112+
if (verdict.status !== 'exceeded') return { usageExceeded: false }
113+
return {
114+
usageExceeded: true,
115+
usageUpgrade: await resolveUsageUpgradePayload(
116+
userId,
117+
billingAttribution,
118+
verdict.scope,
119+
deadlineAt
120+
),
121+
}
117122
}
118123

119124
function getBillingResolution(

‎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)