Skip to content

Commit 074f4b8

Browse files
committed
test(billing): pin the Stripe-only rollover gate and the decision's subscription ID parsing
- A payer whose period is not a Stripe period is never rolled or share-locked. - An account decision refuses a payer subscription ID that is not a non-empty string. - Documents that a subscription replaced mid-run can only under-enforce the limit.
1 parent 19c3658 commit 074f4b8

3 files changed

Lines changed: 58 additions & 0 deletions

File tree

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,6 +1112,29 @@ describe('POST /api/billing/update-cost — mid-run usage gate', () => {
11121112
expect(res.status).toBe(200)
11131113
})
11141114

1115+
it('keeps a payer whose period is not a Stripe period on its frozen period', async () => {
1116+
mockToBillingContext.mockReturnValue({
1117+
billingEntity: { type: 'organization', id: 'org-1' },
1118+
billingPeriod: {
1119+
start: new Date('2026-07-01T00:00:00.000Z'),
1120+
end: new Date('2026-08-01T00:00:00.000Z'),
1121+
source: 'default',
1122+
},
1123+
})
1124+
mockRecordCumulativeUsage.mockResolvedValue({
1125+
billed: true,
1126+
delta: 0.5,
1127+
total: 0.5,
1128+
billingPeriod: CURRENT_PERIOD,
1129+
})
1130+
1131+
await POST(attributedCallback())
1132+
1133+
expect(mockRecordCumulativeUsage).toHaveBeenCalledWith(
1134+
expect.not.objectContaining({ payerSubscriptionId: expect.anything() })
1135+
)
1136+
})
1137+
11151138
it('keeps reporting-window payers on their frozen period', async () => {
11161139
mockToBillingContext.mockReturnValue({
11171140
billingEntity: { type: 'organization', id: 'org-1' },

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
checkAccountBillingBlocks,
2121
checkAttributedBillingBlocks,
2222
checkAttributedUsageLimits,
23+
requireAccountBillingDecisionHeader,
2324
requireBillingAttributionHeader,
2425
requireBillingCallbackAttribution,
2526
requireBillingRequestIdHeader,
@@ -196,6 +197,38 @@ describe('resolveBillingAttribution', () => {
196197
})
197198
})
198199

200+
describe('account billing decision header', () => {
201+
const decision = {
202+
userId: 'actor',
203+
billingEntity: { type: 'user', id: 'actor' },
204+
billingPeriod: {
205+
start: '2026-07-01T00:00:00.000Z',
206+
end: '2026-08-01T00:00:00.000Z',
207+
source: 'stripe',
208+
},
209+
}
210+
const header = (value: unknown) =>
211+
new Headers({ 'x-sim-billing-account-decision': encodeURIComponent(JSON.stringify(value)) })
212+
213+
it('restores the admitted payer subscription', () => {
214+
expect(
215+
requireAccountBillingDecisionHeader(header({ ...decision, payerSubscriptionId: 'sub-1' }))
216+
).toMatchObject({ payerSubscriptionId: 'sub-1' })
217+
expect(requireAccountBillingDecisionHeader(header(decision))).not.toHaveProperty(
218+
'payerSubscriptionId'
219+
)
220+
})
221+
222+
it.each([42, '', ' ', null, { id: 'sub-1' }])(
223+
'refuses a payer subscription of %j',
224+
(payerSubscriptionId) => {
225+
expect(() =>
226+
requireAccountBillingDecisionHeader(header({ ...decision, payerSubscriptionId }))
227+
).toThrow('Account billing decision header is malformed')
228+
}
229+
)
230+
})
231+
199232
describe('serialized attribution boundaries', () => {
200233
const attribution = {
201234
actorUserId: 'actor-a',

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ export interface AccountBillingDecision {
101101
* The payer's subscription at admission, so a run that outlives a Stripe period bills its
102102
* later spend to the period it was spent in, as an attributed run's `payerSubscription` does.
103103
* Absent for a payer without a subscription, and in decisions minted before it existed.
104+
* If that subscription is replaced mid-run, spend stays in its period while the mid-run
105+
* verdict judges the payer's current one, so the limit can only be under-enforced.
104106
*/
105107
readonly payerSubscriptionId?: string
106108
}

0 commit comments

Comments
 (0)