Visitar URL original
fix(billing): settle a free payer's run after they subscribe mid-run · simstudioai/sim@51198df · GitHub
Skip to content

Commit 51198df

Browse files
committed
fix(billing): settle a free payer's run after they subscribe mid-run
1 parent e56bf3d commit 51198df

3 files changed

Lines changed: 68 additions & 3 deletions

File tree

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

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,8 @@ async function createUser(): Promise<string> {
7070
return userId
7171
}
7272

73-
/** A user on a pro subscription for `period`, whose close marker has caught up to it. */
74-
async function createPayer(period: { start: Date; end: Date }): Promise<Payer> {
75-
const userId = await createUser()
73+
/** Puts `userId` on a pro subscription for `period`, whose close marker has caught up to it. */
74+
async function subscribePro(userId: string, period: { start: Date; end: Date }): Promise<string> {
7675
const subscriptionId = generateId()
7776
await db.insert(subscription).values({
7877
id: subscriptionId,
@@ -83,6 +82,13 @@ async function createPayer(period: { start: Date; end: Date }): Promise<Payer> {
8382
periodEnd: period.end,
8483
lastClosedPeriodStart: period.start,
8584
})
85+
return subscriptionId
86+
}
87+
88+
/** A user on a pro subscription for `period`. */
89+
async function createPayer(period: { start: Date; end: Date }): Promise<Payer> {
90+
const userId = await createUser()
91+
const subscriptionId = await subscribePro(userId, period)
8692
const decision = serializeAccountBillingDecisionHeader({
8793
userId,
8894
billingEntity: { type: 'user', id: userId },
@@ -245,6 +251,52 @@ describe('direct-v1 cost callbacks in PostgreSQL', () => {
245251
})
246252
})
247253

254+
/**
255+
* A free payer who subscribes mid-run: the run's spend stays in the open default period it was
256+
* admitted in, which no subscription period ever matches, so each callback must still settle
257+
* rather than answer a retryable refusal on every attempt.
258+
*/
259+
describe('a free payer who subscribes mid-run', () => {
260+
it.each([
261+
{
262+
protocol: 'direct-v1',
263+
admit: async () => {
264+
const payer = await createFreePayer()
265+
return {
266+
userId: payer.userId,
267+
send: (key: string, cost: number) => callback(payer, key, cost),
268+
}
269+
},
270+
},
271+
{
272+
protocol: 'attribution-v1',
273+
admit: async () => {
274+
const attribution = await createFreeWorkspacePayer()
275+
return {
276+
userId: attribution.billedAccountUserId,
277+
send: (key: string, cost: number) => attributedCallback(attribution, key, cost),
278+
}
279+
},
280+
},
281+
])('settles $protocol spend in the period it was admitted in', async ({ admit }) => {
282+
const { userId, send } = await admit()
283+
const requestKey = generateId()
284+
const periodStart = new Date(Date.now() - 60 * 60 * 1000)
285+
286+
expect((await POST(send(requestKey, 0.5), {})).status).toBe(200)
287+
await subscribePro(userId, {
288+
start: periodStart,
289+
end: new Date(periodStart.getTime() + 30 * DAY_MS),
290+
})
291+
expect((await POST(send(requestKey, 0.8), {})).status).toBe(200)
292+
expect((await POST(send(requestKey, 0.8), {})).status).toBe(409)
293+
294+
const rows = await requestRows(requestKey)
295+
expect(rows.map((row) => Number(row.cost))).toEqual([0.8])
296+
expect(rows[0]?.billingPeriodStart?.getTime()).toBe(defaultBillingPeriod().start.getTime())
297+
})
298+
})
299+
248300
/**
249301
* Each payer's first callback caches an admission ($1 of a $5 limit), and its second, inside the
250302
* near-limit cache window, records spend that takes it to $6.

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,10 @@ export function defaultBillingPeriod(): { start: Date; end: Date } {
77
end: OPEN_BILLING_PERIOD_END,
88
}
99
}
10+
11+
export function isDefaultBillingPeriod(period: { start: Date; end: Date }): boolean {
12+
return (
13+
period.start.getTime() === OPEN_BILLING_PERIOD_START.getTime() &&
14+
period.end.getTime() === OPEN_BILLING_PERIOD_END.getTime()
15+
)
16+
}

‎apps/sim/lib/billing/threshold-billing.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { and, eq, sql } from 'drizzle-orm'
77
import { BILLING_LOCK_TIMEOUT_MS, DEFAULT_OVERAGE_THRESHOLD } from '@/lib/billing/constants'
88
import { getEffectiveBillingStatus, isOrganizationBillingBlocked } from '@/lib/billing/core/access'
99
import { calculateSubscriptionOverage, computeOrgOverageAmount } from '@/lib/billing/core/billing'
10+
import { isDefaultBillingPeriod } from '@/lib/billing/core/billing-period'
1011
import type { HighestPrioritySubscription } from '@/lib/billing/core/plan'
1112
import {
1213
getHighestPriorityPersonalSubscription,
@@ -216,6 +217,11 @@ export async function checkAndBillPayerOverageThreshold(
216217
billingEntity: BillingEntity,
217218
options: ThresholdBillingOptions = {}
218219
): Promise<ThresholdSettlementOutcome | undefined> {
220+
// Spend admitted without a subscription stays in the open period, which no subscription the
221+
// payer gains later ever spans, so it has no threshold to settle.
222+
if (options.expectedBillingPeriod && isDefaultBillingPeriod(options.expectedBillingPeriod)) {
223+
return noOp(options, 'no-subscription')
224+
}
219225
if (billingEntity.type === 'organization') {
220226
return checkAndBillOrganizationOverageThreshold(billingEntity.id, options)
221227
}

0 commit comments

Comments
 (0)