Skip to content

Commit 485d1ab

Browse files
committed
fix(billing): serialize checkout admission
1 parent 303cc5d commit 485d1ab

5 files changed

Lines changed: 248 additions & 16 deletions

File tree

apps/sim/lib/auth/auth.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,20 @@ import {
3838
} from '@/lib/auth/constants'
3939
import { getSessionCookieCacheVersion } from '@/lib/auth/security-policy'
4040
import { clampExpiryForSession } from '@/lib/auth/session-policy'
41+
import { getActiveOrganizationId } from '@/lib/auth/session-response'
4142
import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard'
4243
import { sendPlanWelcomeEmail } from '@/lib/billing'
4344
import {
4445
assertPersonalCheckoutAllowed,
4546
authorizeSubscriptionReference,
4647
isPersonalCheckoutRequest,
4748
} from '@/lib/billing/authorization'
49+
import {
50+
type CheckoutAdmissionClaim,
51+
claimCheckoutAdmission,
52+
releaseCheckoutAdmission,
53+
resolveCheckoutReferenceId,
54+
} from '@/lib/billing/checkout-admission'
4855
import {
4956
getOrganizationIdForSubscriptionReference,
5057
syncSubscriptionPlan,
@@ -1008,13 +1015,39 @@ export const auth = betterAuth({
10081015
if (isBillingEnabled && ctx.path === '/subscription/upgrade') {
10091016
const session = await getSessionFromCtx(ctx)
10101017
const sessionUserId = session?.user?.id
1011-
if (sessionUserId && isPersonalCheckoutRequest(ctx.body ?? {}, sessionUserId)) {
1012-
await assertPersonalCheckoutAllowed(sessionUserId)
1018+
if (sessionUserId) {
1019+
const requestBody = ctx.body ?? {}
1020+
const referenceId = resolveCheckoutReferenceId(
1021+
requestBody,
1022+
sessionUserId,
1023+
getActiveOrganizationId(session)
1024+
)
1025+
if (referenceId) {
1026+
const checkoutAdmissionClaim = await claimCheckoutAdmission(referenceId)
1027+
try {
1028+
if (isPersonalCheckoutRequest(requestBody, sessionUserId)) {
1029+
await assertPersonalCheckoutAllowed(sessionUserId)
1030+
}
1031+
} catch (error) {
1032+
await releaseCheckoutAdmission(checkoutAdmissionClaim)
1033+
throw error
1034+
}
1035+
return { context: { billingCheckoutAdmissionClaim: checkoutAdmissionClaim } }
1036+
}
10131037
}
10141038
}
10151039

10161040
return
10171041
}),
1042+
after: createAuthMiddleware(async (ctx) => {
1043+
if (!isBillingEnabled || ctx.path !== '/subscription/upgrade') return
1044+
const context = ctx.context as typeof ctx.context & {
1045+
billingCheckoutAdmissionClaim?: CheckoutAdmissionClaim
1046+
}
1047+
if (context.billingCheckoutAdmissionClaim) {
1048+
await releaseCheckoutAdmission(context.billingCheckoutAdmissionClaim)
1049+
}
1050+
}),
10181051
},
10191052
plugins: [
10201053
...(env.TURNSTILE_SECRET_KEY

apps/sim/lib/auth/stripe-adapter-guard.test.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -160,18 +160,19 @@ describe('guardSubscriptionPlanWrites', () => {
160160
base.findOne.mockResolvedValueOnce(personalPro)
161161

162162
const guarded = asAdapter(base)
163-
const result = await guarded.update({
164-
model: 'subscription',
165-
where: [{ field: 'id', value: personalPro.id }] as never,
166-
update: {
167-
stripeSubscriptionId: 'sub_enterprise',
168-
status: 'active',
169-
periodEnd: new Date('2026-09-11T18:36:09Z'),
170-
billingInterval: 'month',
171-
},
172-
})
163+
await expect(
164+
guarded.update({
165+
model: 'subscription',
166+
where: [{ field: 'id', value: personalPro.id }] as never,
167+
update: {
168+
stripeSubscriptionId: 'sub_enterprise',
169+
status: 'active',
170+
periodEnd: new Date('2026-09-11T18:36:09Z'),
171+
billingInterval: 'month',
172+
},
173+
})
174+
).rejects.toThrow(/already bound to Stripe subscription sub_personal_pro/)
173175

174-
expect(result).toBeNull()
175176
expect(base.update).not.toHaveBeenCalled()
176177
})
177178

apps/sim/lib/auth/stripe-adapter-guard.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,17 +86,20 @@ function guardWriteSurface<TAdapter extends SubscriptionWriteSurface>(
8686
})
8787

8888
if (row && attemptsStripeSubscriptionRebind(row, data.update)) {
89+
const rejectedStripeSubscriptionId = (data.update as { stripeSubscriptionId: string })
90+
.stripeSubscriptionId
8991
logger.error(
9092
'Blocked rebinding an existing subscription to a different Stripe subscription',
9193
{
9294
subscriptionId: row.id,
9395
referenceId: row.referenceId,
9496
currentStripeSubscriptionId: row.stripeSubscriptionId,
95-
rejectedStripeSubscriptionId: (data.update as { stripeSubscriptionId: string })
96-
.stripeSubscriptionId,
97+
rejectedStripeSubscriptionId,
9798
}
9899
)
99-
return null as never
100+
throw new Error(
101+
`Subscription ${row.id} is already bound to Stripe subscription ${row.stripeSubscriptionId}; refusing to bind ${rejectedStripeSubscriptionId}`
102+
)
100103
}
101104

102105
if (!hasNonOrgPlanWrite(data.update)) return adapter.update(data)
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockAtomicallyClaim, mockRelease, mockIdempotencyService } = vi.hoisted(() => ({
7+
mockAtomicallyClaim: vi.fn(),
8+
mockRelease: vi.fn(),
9+
mockIdempotencyService: vi.fn(),
10+
}))
11+
12+
vi.mock('@/lib/core/idempotency/service', () => ({
13+
IdempotencyService: class MockIdempotencyService {
14+
constructor(options: unknown) {
15+
mockIdempotencyService(options)
16+
}
17+
18+
atomicallyClaim(...args: unknown[]) {
19+
return mockAtomicallyClaim(...args)
20+
}
21+
22+
release(...args: unknown[]) {
23+
return mockRelease(...args)
24+
}
25+
},
26+
}))
27+
28+
import {
29+
claimCheckoutAdmission,
30+
releaseCheckoutAdmission,
31+
resolveCheckoutReferenceId,
32+
} from '@/lib/billing/checkout-admission'
33+
34+
describe('checkout admission', () => {
35+
beforeEach(() => {
36+
mockAtomicallyClaim.mockReset()
37+
mockRelease.mockReset()
38+
mockRelease.mockResolvedValue(undefined)
39+
})
40+
41+
it('uses durable, short-lived database claims', () => {
42+
expect(mockIdempotencyService).toHaveBeenCalledWith({
43+
namespace: 'billing-checkout-admission',
44+
ttlSeconds: 120,
45+
inProgressTtlSeconds: 120,
46+
retryFailures: true,
47+
storeResultBody: false,
48+
forceStorage: 'database',
49+
})
50+
})
51+
52+
it('resolves explicit, organization, and personal references like Better Auth', () => {
53+
expect(
54+
resolveCheckoutReferenceId({ referenceId: 'org-explicit' }, 'user-1', 'org-active')
55+
).toBe('org-explicit')
56+
expect(
57+
resolveCheckoutReferenceId({ customerType: 'organization' }, 'user-1', 'org-active')
58+
).toBe('org-active')
59+
expect(resolveCheckoutReferenceId({}, 'user-1', 'org-active')).toBe('user-1')
60+
})
61+
62+
it('admits only one overlapping checkout for a billing reference', async () => {
63+
mockAtomicallyClaim
64+
.mockResolvedValueOnce({
65+
claimed: true,
66+
normalizedKey: 'billing-checkout-admission:stripe:org-1',
67+
storageMethod: 'database',
68+
claimToken: 'claim-1',
69+
})
70+
.mockResolvedValueOnce({
71+
claimed: false,
72+
normalizedKey: 'billing-checkout-admission:stripe:org-1',
73+
storageMethod: 'database',
74+
existingResult: { status: 'in-progress' },
75+
})
76+
77+
const firstClaim = await claimCheckoutAdmission('org-1')
78+
await expect(claimCheckoutAdmission('org-1')).rejects.toThrow(
79+
/checkout is already being started/
80+
)
81+
expect(firstClaim.claimToken).toBe('claim-1')
82+
})
83+
84+
it('releases only the claim owned by this request', async () => {
85+
await releaseCheckoutAdmission({
86+
normalizedKey: 'billing-checkout-admission:stripe:org-1',
87+
storageMethod: 'database',
88+
claimToken: 'claim-1',
89+
})
90+
91+
expect(mockRelease).toHaveBeenCalledWith(
92+
'billing-checkout-admission:stripe:org-1',
93+
'database',
94+
'claim-1'
95+
)
96+
})
97+
98+
it('does not replace a successful checkout response when release fails', async () => {
99+
mockRelease.mockRejectedValueOnce(new Error('database unavailable'))
100+
101+
await expect(
102+
releaseCheckoutAdmission({
103+
normalizedKey: 'billing-checkout-admission:stripe:org-1',
104+
storageMethod: 'database',
105+
claimToken: 'claim-1',
106+
})
107+
).resolves.toBeUndefined()
108+
})
109+
})
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { APIError } from 'better-auth/api'
4+
import { type AtomicClaimResult, IdempotencyService } from '@/lib/core/idempotency/service'
5+
6+
const logger = createLogger('BillingCheckoutAdmission')
7+
8+
const CHECKOUT_ADMISSION_LEASE_SECONDS = 2 * 60
9+
10+
const checkoutAdmissionLeases = new IdempotencyService({
11+
namespace: 'billing-checkout-admission',
12+
ttlSeconds: CHECKOUT_ADMISSION_LEASE_SECONDS,
13+
inProgressTtlSeconds: CHECKOUT_ADMISSION_LEASE_SECONDS,
14+
retryFailures: true,
15+
storeResultBody: false,
16+
forceStorage: 'database',
17+
})
18+
19+
export interface CheckoutAdmissionClaim {
20+
normalizedKey: string
21+
storageMethod: AtomicClaimResult['storageMethod']
22+
claimToken: string
23+
}
24+
25+
/**
26+
* Resolves the billing reference using the same precedence as Better Auth's
27+
* Stripe `referenceMiddleware`: an explicit reference wins, otherwise an
28+
* organization checkout uses the active organization and a personal checkout
29+
* uses the session user.
30+
*/
31+
export function resolveCheckoutReferenceId(
32+
body: { referenceId?: unknown; customerType?: unknown },
33+
sessionUserId: string,
34+
activeOrganizationId: string | null
35+
): string | null {
36+
if (body.referenceId) {
37+
return typeof body.referenceId === 'string' ? body.referenceId : null
38+
}
39+
if (body.customerType === 'organization') return activeOrganizationId
40+
return sessionUserId
41+
}
42+
43+
/**
44+
* Atomically reserves one in-flight checkout request per billing reference.
45+
* The claim spans Better Auth's local-row preparation and Stripe Checkout
46+
* creation, closing the window where two requests could both pass a read-only
47+
* admission check before either one bound a subscription.
48+
*/
49+
export async function claimCheckoutAdmission(referenceId: string): Promise<CheckoutAdmissionClaim> {
50+
const claim = await checkoutAdmissionLeases.atomicallyClaim('stripe', referenceId)
51+
if (!claim.claimed) {
52+
logger.warn('Blocking concurrent subscription checkout', { referenceId })
53+
throw new APIError('CONFLICT', {
54+
message:
55+
'A subscription checkout is already being started. Please wait a moment and try again.',
56+
})
57+
}
58+
if (!claim.claimToken) {
59+
throw new Error('Checkout admission claim is missing its fencing token')
60+
}
61+
return {
62+
normalizedKey: claim.normalizedKey,
63+
storageMethod: claim.storageMethod,
64+
claimToken: claim.claimToken,
65+
}
66+
}
67+
68+
/**
69+
* Releases a checkout admission claim without masking the endpoint result.
70+
* A failed release is bounded by the claim's short lease and remains visible
71+
* in logs for operators.
72+
*/
73+
export async function releaseCheckoutAdmission(claim: CheckoutAdmissionClaim): Promise<void> {
74+
try {
75+
await checkoutAdmissionLeases.release(
76+
claim.normalizedKey,
77+
claim.storageMethod,
78+
claim.claimToken
79+
)
80+
} catch (error) {
81+
logger.warn('Failed to release checkout admission claim; lease will expire', {
82+
normalizedKey: claim.normalizedKey,
83+
error: getErrorMessage(error, 'Unknown error'),
84+
})
85+
}
86+
}

0 commit comments

Comments
 (0)