Skip to content

Commit 30a364e

Browse files
committed
fix(billing): judge mid-run usage against the payer's current period
- Mid-run checks always judge the admitted payer's current subscription period (cached for a minute), so an early anchor move or a rollover is judged against the period charges now land in; a straddling read is judged again against the next period. - A direct-v1 continuation checks the payer saved in its account decision, against that payer's current period, never a payer chosen from the actor's current memberships. - An unreadable usage read no longer reports a spent-limit message; new turns refused for it get neutral copy. - Dispatch-time refusals pass the verdict scope, so a member over the cap their organization set gets the member card.
1 parent b6f1fe0 commit 30a364e

12 files changed

Lines changed: 223 additions & 49 deletions

File tree

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ vi.mock('@/lib/billing/threshold-billing', () => ({
4949
}))
5050

5151
import { billingUpdateCostResponseSchema } from '@/lib/api/contracts/subscription'
52+
import { resetMidRunPeriodCache } from '@/lib/billing/core/mid-run-usage'
5253
import { resetUsageGateCache } from '@/lib/billing/core/usage-gate-cache'
5354
import {
5455
BillingCallbackBody,
@@ -896,11 +897,13 @@ describe('POST /api/billing/update-cost — mid-run usage gate', () => {
896897

897898
beforeEach(() => {
898899
resetUsageGateCache()
900+
resetMidRunPeriodCache()
899901
setEnvFlags({ isBillingEnabled: true, isHosted: true })
900902
mockCheckInternalApiKey.mockReturnValue({ success: true })
901903
mockRecordCumulativeUsage.mockResolvedValue({ billed: true, delta: 0.5, total: 0.5 })
902904
mockCheckAndBillPayerOverageThreshold.mockResolvedValue(undefined)
903905
mockRequireBillingAttributionHeader.mockReturnValue(CURRENT_ATTRIBUTION)
906+
mockRefreshAttributionPeriod.mockImplementation(async (attribution: unknown) => attribution)
904907
mockToBillingContext.mockReturnValue({
905908
billingEntity: { type: 'organization', id: 'org-1' },
906909
billingPeriod: {
@@ -1099,6 +1102,22 @@ describe('POST /api/billing/update-cost — mid-run usage gate', () => {
10991102
expect(body.usageExceeded).toBe(true)
11001103
})
11011104

1105+
it('judges a run whose payer period moved early against the moved period', async () => {
1106+
const moved = {
1107+
...CURRENT_ATTRIBUTION,
1108+
billingPeriod: { start: '2026-07-15T00:00:00.000Z', end: '2099-02-01T00:00:00.000Z' },
1109+
}
1110+
mockRefreshAttributionPeriod.mockResolvedValue(moved)
1111+
mockCheckAttributedUsageLimits.mockImplementation(async (attribution: typeof moved) => ({
1112+
isExceeded: attribution.billingPeriod.start === moved.billingPeriod.start,
1113+
scope: 'payer',
1114+
}))
1115+
1116+
const body = await (await POST(attributedCallback())).json()
1117+
1118+
expect(body.usageExceeded).toBe(true)
1119+
})
1120+
11021121
it('keeps a run going when its current period cannot be read', async () => {
11031122
mockRequireBillingAttributionHeader.mockReturnValue(ATTRIBUTION)
11041123
mockRefreshAttributionPeriod.mockRejectedValue(new Error('subscription read timed out'))

‎apps/sim/app/api/copilot/api-keys/validate/route.test.ts‎

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
schemaMock,
77
setEnvFlags,
88
} from '@sim/testing'
9+
import { billingCoreMock, billingCoreMockFns } from '@sim/testing/mocks/billing-core.mock'
910
import { billingPlanMock, billingPlanMockFns } from '@sim/testing/mocks/billing-plan.mock'
1011
import {
1112
billingSubscriptionMock,
@@ -118,6 +119,8 @@ vi.mock('@/lib/billing/calculations/usage-monitor', () => billingUsageMonitorMoc
118119

119120
vi.mock('@/lib/billing/core/plan', () => billingPlanMock)
120121

122+
vi.mock('@/lib/billing/core/billing', () => billingCoreMock)
123+
121124
vi.mock('@/lib/billing/core/subscription', () => billingSubscriptionMock)
122125

123126
vi.mock('@/lib/billing/core/usage-log', () => billingUsageLogMock)
@@ -138,6 +141,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
138141
vi.mock('@/lib/workspaces/utils', () => workspacesUtilsMock)
139142

140143
import { validateCopilotApiKeyBodySchema } from '@/lib/api/contracts/copilot'
144+
import { resetMidRunPeriodCache } from '@/lib/billing/core/mid-run-usage'
141145
import { resetUsageGateCache } from '@/lib/billing/core/usage-gate-cache'
142146
import { POST } from '@/app/api/copilot/api-keys/validate/route'
143147

@@ -147,6 +151,7 @@ const { mockAuthorizeOrganizationChatDelegation: mockAuthorizeOrganizationChat }
147151
mothershipOrganizationChatsMockFns
148152
const { mockDeriveBillingContext } = billingUsageLogMockFns
149153
const { mockGetHighestPrioritySubscription } = billingPlanMockFns
154+
const { mockGetOrganizationSubscription } = billingCoreMockFns
150155
const { mockCheckServerSideUsageLimits, mockCheckUsageStatus } = billingUsageMonitorMockFns
151156

152157
const mockIsEnterprisePlan = billingSubscriptionMockFns.mockIsEnterprisePlan
@@ -510,7 +515,16 @@ describe('validation lifecycle purposes', () => {
510515
mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false })
511516
mockCheckUsageStatus.mockResolvedValue({ isExceeded: false, currentUsage: 1, limit: 10 })
512517
mockIsEnterprisePlan.mockResolvedValue(false)
518+
mockGetOrganizationSubscription.mockResolvedValue({
519+
id: 'sub-org-1',
520+
referenceId: 'org-1',
521+
plan: 'enterprise',
522+
status: 'active',
523+
periodStart: new Date(ATTRIBUTION.billingPeriod.start),
524+
periodEnd: new Date(ATTRIBUTION.billingPeriod.end),
525+
})
513526
resetUsageGateCache()
527+
resetMidRunPeriodCache()
514528
})
515529

516530
it('defaults older callers to full admission and rejects unknown purposes', () => {
@@ -531,7 +545,6 @@ describe('validation lifecycle purposes', () => {
531545
expect(mockAuthorizeCallback.mock.invocationCallOrder[0]).toBeLessThan(
532546
mockCheckContinuationBilling.mock.invocationCallOrder[0]
533547
)
534-
expect(mockCheckAttributedUsageLimits).toHaveBeenCalledWith(ATTRIBUTION)
535548
expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled()
536549
expect(mockResolveLegacyV0BillingAttribution).not.toHaveBeenCalled()
537550
expect(mockGetHighestPrioritySubscription).not.toHaveBeenCalled()
@@ -729,6 +742,60 @@ describe('validation lifecycle purposes', () => {
729742
expect(mockCheckAttributedUsageLimits).toHaveBeenCalledTimes(3)
730743
})
731744

745+
it('refuses a new turn whose usage cannot be read with neutral copy', async () => {
746+
mockCheckAttributedUsageLimits.mockResolvedValueOnce({
747+
isExceeded: true,
748+
reason: 'usage_unavailable',
749+
message: 'Usage limit exceeded: $0.00 used of $0.00 limit.',
750+
scope: 'payer',
751+
})
752+
const refused = await POST(request({ ...body, purpose: 'new-turn' }, attributedHeaders))
753+
expect(refused.status).toBe(402)
754+
const refusal = await refused.json()
755+
expect(refusal.code).toBe('USAGE_UNAVAILABLE')
756+
expect(refusal.error).not.toMatch(/\$/)
757+
})
758+
759+
it('checks the payer saved at admission for a direct-v1 run whose actor changed orgs', async () => {
760+
const endedDecision = {
761+
...ACCOUNT_BILLING_DECISION,
762+
billingPeriod: { start: '2026-06-01T00:00:00.000Z', end: '2026-07-01T00:00:00.000Z' },
763+
}
764+
mockGetHighestPrioritySubscription.mockResolvedValue({
765+
id: 'sub-new-org',
766+
referenceId: 'new-org',
767+
plan: 'team',
768+
status: 'active',
769+
periodStart: new Date('2026-07-01T00:00:00.000Z'),
770+
periodEnd: new Date('2099-01-01T00:00:00.000Z'),
771+
})
772+
mockGetOrganizationSubscription.mockResolvedValue({
773+
id: 'sub-account-org',
774+
referenceId: 'account-org',
775+
plan: 'team',
776+
status: 'active',
777+
periodStart: new Date('2026-07-01T00:00:00.000Z'),
778+
periodEnd: new Date('2099-01-01T00:00:00.000Z'),
779+
})
780+
mockCheckUsageStatus.mockImplementation(
781+
async (
782+
_userId: string,
783+
_subscription: unknown,
784+
context?: { billingEntity: { id: string } }
785+
) => ({
786+
isExceeded: context?.billingEntity.id === 'account-org',
787+
currentUsage: 12,
788+
limit: 10,
789+
})
790+
)
791+
792+
const response = await POST(
793+
request(body, { ...directHeaders, 'x-sim-billing-account-decision': encode(endedDecision) })
794+
)
795+
796+
expect(response.status).toBe(402)
797+
})
798+
732799
it('refuses a blocked new turn with the blocked body the contract declares', async () => {
733800
mockCheckAttributedUsageLimits.mockResolvedValueOnce({
734801
isExceeded: true,

‎apps/sim/app/api/copilot/api-keys/validate/route.ts‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '@/lib/api/contracts/copilot'
1616
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
1717
import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor'
18+
import { USAGE_UNAVAILABLE_MESSAGE } from '@/lib/billing/constants'
1819
import {
1920
type AccountBillingDecision,
2021
type BillingAttributionSnapshot,
@@ -68,7 +69,6 @@ const logger = createLogger('CopilotApiKeysValidate')
6869

6970
const CONTINUATION_BLOCKED_MESSAGE = 'Continuation billing account is blocked'
7071
const BILLING_BLOCKED_MESSAGE = 'Billing account is blocked'
71-
const USAGE_UNAVAILABLE_MESSAGE = 'Usage could not be verified. Please try again.'
7272

7373
function invalidBillingProtocolResponse(): NextResponse {
7474
return NextResponse.json({ error: 'Invalid billing attribution protocol' }, { status: 400 })
@@ -269,10 +269,7 @@ async function admissionRefusal(
269269
return { code: COPILOT_BILLING_BLOCKED_CODE, error: usage.message ?? BILLING_BLOCKED_MESSAGE }
270270
}
271271
if (usage.reason === 'usage_unavailable') {
272-
return {
273-
code: COPILOT_USAGE_UNAVAILABLE_CODE,
274-
error: usage.message ?? USAGE_UNAVAILABLE_MESSAGE,
275-
}
272+
return { code: COPILOT_USAGE_UNAVAILABLE_CODE, error: USAGE_UNAVAILABLE_MESSAGE }
276273
}
277274
const usageUpgrade = await resolveUsageUpgradePayload(
278275
userId,

‎apps/sim/lib/billing/calculations/usage-monitor.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,23 @@ describe('checkServerSideUsageLimits', () => {
213213
mockGetBillingPeriodUsageCost.mockResolvedValue(125)
214214
})
215215

216+
it('does not describe an unreadable ledger as a spent limit', async () => {
217+
dbChainMockFns.limit.mockResolvedValueOnce([{ blocked: false }])
218+
mockGetBillingPeriodUsageCost.mockRejectedValueOnce(new Error('canceling statement'))
219+
220+
const result = await checkServerSideUsageLimits('user-1', {
221+
referenceId: 'user-1',
222+
plan: 'free',
223+
status: 'active',
224+
seats: 1,
225+
periodStart: new Date('2026-06-01T00:00:00.000Z'),
226+
periodEnd: new Date('2026-07-01T00:00:00.000Z'),
227+
})
228+
229+
expect(result).toMatchObject({ isExceeded: true, reason: 'usage_unavailable' })
230+
expect(result.message ?? '').not.toMatch(/\$/)
231+
})
232+
216233
it('keeps blocked accounts blocked while reporting their real ledger usage', async () => {
217234
dbChainMockFns.limit.mockResolvedValueOnce([{ blocked: true, blockedReason: 'payment_failed' }])
218235
const subscription = {

‎apps/sim/lib/billing/calculations/usage-monitor.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { userStats } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { toError } from '@sim/utils/errors'
55
import { eq } from 'drizzle-orm'
6+
import { USAGE_UNAVAILABLE_MESSAGE } from '@/lib/billing/constants'
67
import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
78
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
89
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
@@ -366,7 +367,11 @@ export async function checkServerSideUsageLimits(
366367
isExceeded: usageData.isExceeded,
367368
currentUsage: usageData.currentUsage,
368369
limit: usageData.limit,
369-
message: usageData.isExceeded ? exceededMessage : undefined,
370+
message: usageData.unavailable
371+
? USAGE_UNAVAILABLE_MESSAGE
372+
: usageData.isExceeded
373+
? exceededMessage
374+
: undefined,
370375
...(usageData.unavailable ? { reason: 'usage_unavailable' as const } : {}),
371376
}
372377
} catch (error) {

‎apps/sim/lib/billing/constants.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,7 @@ export const ANNUAL_DISCOUNT_RATE = 0.15
103103
* Effectively unlimited — any limit >= this threshold is treated as uncapped.
104104
*/
105105
export const ON_DEMAND_UNLIMITED = 999999
106+
107+
/** Shown when usage could not be read, instead of a limit the read never measured. */
108+
export const USAGE_UNAVAILABLE_MESSAGE =
109+
'Usage could not be verified right now. Please try again in a moment.'

‎apps/sim/lib/billing/core/billing-attribution.test.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -449,10 +449,9 @@ describe('checkAttributedUsageLimits', () => {
449449
scope: 'user',
450450
unavailable: true,
451451
})
452-
await expect(checkAttributedUsageLimits(attribution)).resolves.toMatchObject({
453-
isExceeded: true,
454-
reason: 'usage_unavailable',
455-
})
452+
const unavailable = await checkAttributedUsageLimits(attribution)
453+
expect(unavailable).toMatchObject({ isExceeded: true, reason: 'usage_unavailable' })
454+
expect(unavailable.message ?? '').not.toMatch(/\$/)
456455
})
457456

458457
it('returns payer exhaustion before checking the actor member cap', async () => {

‎apps/sim/lib/billing/core/billing-attribution.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
checkUsageStatus,
1111
} from '@/lib/billing/calculations/usage-monitor'
1212
import { parseBillingConcurrencyLimit } from '@/lib/billing/concurrency-defaults'
13+
import { USAGE_UNAVAILABLE_MESSAGE } from '@/lib/billing/constants'
1314
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
1415
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
1516
import { getHighestPriorityPersonalSubscription } from '@/lib/billing/core/plan'
@@ -970,8 +971,9 @@ export async function checkAttributedUsageLimits(
970971
if (payerUsage.isExceeded) {
971972
const formattedUsage = payerUsage.currentUsage.toFixed(2)
972973
const formattedLimit = payerUsage.limit.toFixed(2)
973-
const message =
974-
validatedAttribution.billingEntity.type === 'organization'
974+
const message = payerUsage.unavailable
975+
? USAGE_UNAVAILABLE_MESSAGE
976+
: validatedAttribution.billingEntity.type === 'organization'
975977
? `Organization usage limit exceeded: $${formattedUsage} pooled of $${formattedLimit} organization limit. Ask a team admin to raise the organization usage limit to continue.`
976978
: `Usage limit exceeded: $${formattedUsage} used of $${formattedLimit} limit. Please upgrade your plan or raise your usage limit to continue.`
977979

0 commit comments

Comments
 (0)