fix(billing): checkout guard, admin panel case (#6641)

* fix(billing): checkout guard, admin panel case

* fix(billing): serialize checkout admission

* fix(billing): release checkout admission claim
This commit is contained in:
Vikhyath Mondreti
2026-08-12 15:32:54 -07:00
committed by GitHub
parent e56b28857c
commit 9f8d4d1310
10 changed files with 704 additions and 20 deletions
@@ -120,6 +120,22 @@ describe('useUpgradeState', () => {
})
})
it('shows checkout admission failures through the standard error toast', async () => {
mockHandleUpgrade.mockRejectedValueOnce(
new Error('Your subscription payment is still processing.')
)
await act(async () => {
root.render(<Harness />)
})
await act(async () => {
await currentState?.doUpgrade('team', 25000)
})
expect(mockToastError).toHaveBeenCalledWith('Your subscription payment is still processing.')
})
it('includes the routed workspace when switching the host billing interval', async () => {
await act(async () => {
root.render(<Harness />)
+38 -5
View File
@@ -38,6 +38,7 @@ import {
} from '@/lib/auth/constants'
import { getSessionCookieCacheVersion } from '@/lib/auth/security-policy'
import { clampExpiryForSession } from '@/lib/auth/session-policy'
import { getActiveOrganizationId } from '@/lib/auth/session-response'
import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard'
import { sendPlanWelcomeEmail } from '@/lib/billing'
import {
@@ -45,6 +46,12 @@ import {
authorizeSubscriptionReference,
isPersonalCheckoutRequest,
} from '@/lib/billing/authorization'
import {
type CheckoutAdmissionClaim,
claimCheckoutAdmission,
releaseCheckoutAdmission,
resolveCheckoutReferenceId,
} from '@/lib/billing/checkout-admission'
import {
getOrganizationIdForSubscriptionReference,
syncSubscriptionPlan,
@@ -1001,20 +1008,46 @@ export const auth = betterAuth({
/**
* Personal checkout guard. The Stripe plugin's `authorizeReference`
* only runs for organization references (it skips references equal to
* the session user), so duplicate-coverage enforcement for personal
* checkouts lives here: a member of an org with an entitled paid
* subscription must not buy a personal plan on top of it.
* the session user), so personal checkout admission lives here. It
* prevents both a duplicate checkout while Stripe payment is pending
* and a personal plan for someone already covered by an organization.
*/
if (isBillingEnabled && ctx.path === '/subscription/upgrade') {
const session = await getSessionFromCtx(ctx)
const sessionUserId = session?.user?.id
if (sessionUserId && isPersonalCheckoutRequest(ctx.body ?? {}, sessionUserId)) {
await assertPersonalCheckoutAllowed(sessionUserId)
if (sessionUserId) {
const requestBody = ctx.body ?? {}
const referenceId = resolveCheckoutReferenceId(
requestBody,
sessionUserId,
getActiveOrganizationId(session)
)
if (referenceId) {
const checkoutAdmissionClaim = await claimCheckoutAdmission(referenceId)
try {
if (isPersonalCheckoutRequest(requestBody, sessionUserId)) {
await assertPersonalCheckoutAllowed(sessionUserId)
}
} catch (error) {
await releaseCheckoutAdmission(checkoutAdmissionClaim)
throw error
}
return { context: { billingCheckoutAdmissionClaim: checkoutAdmissionClaim } }
}
}
}
return
}),
after: createAuthMiddleware(async (ctx) => {
if (!isBillingEnabled || ctx.path !== '/subscription/upgrade') return
const checkoutContext = ctx as typeof ctx & {
billingCheckoutAdmissionClaim?: CheckoutAdmissionClaim
}
if (checkoutContext.billingCheckoutAdmissionClaim) {
await releaseCheckoutAdmission(checkoutContext.billingCheckoutAdmissionClaim)
}
}),
},
plugins: [
...(env.TURNSTILE_SECRET_KEY
+87 -1
View File
@@ -22,7 +22,12 @@ function createBaseAdapter() {
const asAdapter = (base: ReturnType<typeof createBaseAdapter>) =>
guardSubscriptionPlanWrites(base as unknown as Parameters<typeof guardSubscriptionPlanWrites>[0])
const ORG_ROW = { id: 'sub-1', referenceId: 'org-1', plan: 'team_6000' }
const ORG_ROW = {
id: 'sub-1',
referenceId: 'org-1',
plan: 'team_6000',
stripeSubscriptionId: 'stripe-sub-1',
}
const WHERE = [{ field: 'id', value: 'sub-1' }]
describe('guardSubscriptionPlanWrites', () => {
@@ -144,6 +149,87 @@ describe('guardSubscriptionPlanWrites', () => {
expect(base.update).toHaveBeenCalled()
})
it('blocks rebinding a personal subscription to a different Stripe subscription', async () => {
const base = createBaseAdapter()
const personalPro = {
id: 'personal-pro',
referenceId: 'user-1',
plan: 'pro',
stripeSubscriptionId: 'sub_personal_pro',
}
base.findOne.mockResolvedValueOnce(personalPro)
const guarded = asAdapter(base)
await expect(
guarded.update({
model: 'subscription',
where: [{ field: 'id', value: personalPro.id }] as never,
update: {
stripeSubscriptionId: 'sub_enterprise',
status: 'active',
periodEnd: new Date('2026-09-11T18:36:09Z'),
billingInterval: 'month',
},
})
).rejects.toThrow(/already bound to Stripe subscription sub_personal_pro/)
expect(base.update).not.toHaveBeenCalled()
})
it('allows Stripe state updates when the subscription ID is unchanged', async () => {
const base = createBaseAdapter()
base.findOne.mockResolvedValueOnce({
id: 'personal-pro',
referenceId: 'user-1',
plan: 'pro',
stripeSubscriptionId: 'sub_personal_pro',
})
const guarded = asAdapter(base)
await guarded.update({
model: 'subscription',
where: WHERE as never,
update: {
stripeSubscriptionId: 'sub_personal_pro',
status: 'active',
cancelAtPeriodEnd: true,
},
})
expect(base.update).toHaveBeenCalledWith(
expect.objectContaining({
update: {
stripeSubscriptionId: 'sub_personal_pro',
status: 'active',
cancelAtPeriodEnd: true,
},
})
)
})
it('allows binding an unbound local subscription to Stripe', async () => {
const base = createBaseAdapter()
base.findOne.mockResolvedValueOnce({
id: 'new-subscription',
referenceId: 'user-1',
plan: 'pro',
stripeSubscriptionId: null,
})
const guarded = asAdapter(base)
await guarded.update({
model: 'subscription',
where: WHERE as never,
update: { stripeSubscriptionId: 'sub_new', status: 'incomplete' },
})
expect(base.update).toHaveBeenCalledWith(
expect.objectContaining({
update: { stripeSubscriptionId: 'sub_new', status: 'incomplete' },
})
)
})
it('rejects creating an org-referenced subscription with a non-org plan', async () => {
const base = createBaseAdapter()
dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'org-1' }])
+49 -6
View File
@@ -17,11 +17,13 @@ type SubscriptionWriteSurface = Pick<
/**
* The Better Auth Stripe plugin persists webhook state through the raw
* database adapter BEFORE invoking our subscription callbacks — including the
* `plan` column resolved from the Stripe price. That makes the adapter the
* only in-process seam that can enforce the billing invariant that
* organization-referenced subscriptions hold Team/Enterprise plans: by the
* time `syncSubscriptionPlan` runs in a callback, the plugin's write has
* already landed.
* Stripe subscription ID and `plan` column resolved from the Stripe price.
* That makes the adapter the only in-process seam that can enforce two billing
* invariants before the write lands:
*
* - an existing subscription cannot be rebound to a different Stripe
* subscription merely because both subscriptions share a customer;
* - organization-referenced subscriptions hold Team/Enterprise plans.
*
* Checkout admission blocks user-driven violations; this guard blocks the
* remaining vector — an operator swapping an org subscription onto a personal
@@ -77,11 +79,31 @@ function guardWriteSurface<TAdapter extends SubscriptionWriteSurface>(
return adapter.create(data)
},
update: async (data) => {
if (data.model === 'subscription' && hasNonOrgPlanWrite(data.update)) {
if (data.model === 'subscription' && needsSubscriptionRowInspection(data.update)) {
const row = await adapter.findOne<SubscriptionRowSlice>({
model: 'subscription',
where: data.where,
})
if (row && attemptsStripeSubscriptionRebind(row, data.update)) {
const rejectedStripeSubscriptionId = (data.update as { stripeSubscriptionId: string })
.stripeSubscriptionId
logger.error(
'Blocked rebinding an existing subscription to a different Stripe subscription',
{
subscriptionId: row.id,
referenceId: row.referenceId,
currentStripeSubscriptionId: row.stripeSubscriptionId,
rejectedStripeSubscriptionId,
}
)
throw new Error(
`Subscription ${row.id} is already bound to Stripe subscription ${row.stripeSubscriptionId}; refusing to bind ${rejectedStripeSubscriptionId}`
)
}
if (!hasNonOrgPlanWrite(data.update)) return adapter.update(data)
const sanitized = await stripPlanWhenOrgReferenced(
row ? [row] : [],
data.update as Record<string, unknown>
@@ -110,6 +132,27 @@ interface SubscriptionRowSlice {
id: string
referenceId: string
plan: string
stripeSubscriptionId: string | null
}
function needsSubscriptionRowInspection(update: unknown): boolean {
return hasStripeSubscriptionIdWrite(update) || hasNonOrgPlanWrite(update)
}
function hasStripeSubscriptionIdWrite(
update: unknown
): update is { stripeSubscriptionId: unknown } {
return Boolean(update && typeof update === 'object' && 'stripeSubscriptionId' in update)
}
function attemptsStripeSubscriptionRebind(row: SubscriptionRowSlice, update: unknown): boolean {
if (!hasStripeSubscriptionIdWrite(update)) return false
const incomingStripeSubscriptionId = update.stripeSubscriptionId
return (
typeof row.stripeSubscriptionId === 'string' &&
typeof incomingStripeSubscriptionId === 'string' &&
incomingStripeSubscriptionId !== row.stripeSubscriptionId
)
}
function hasNonOrgPlanWrite(update: unknown): boolean {
+55 -1
View File
@@ -1,7 +1,7 @@
/**
* @vitest-environment node
*/
import { resetDbChainMock } from '@sim/testing'
import { dbChainMockFns, hasMockCondition, resetDbChainMock, schemaMock } from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const {
@@ -96,6 +96,20 @@ describe('authorizeSubscriptionReference', () => {
expect(mockIsOwnerOrAdmin).toHaveBeenCalledWith('owner-1', 'org-1')
})
it('blocks an organization checkout while its bound Stripe subscription is incomplete', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{ id: 'subscription-1', stripeSubscriptionId: 'sub_pending' },
])
await expect(
authorizeSubscriptionReference('owner-1', 'org-1', 'upgrade-subscription', 'team_6000')
).rejects.toThrow(/subscription payment is still processing/)
expect(mockHasPaidSubscription).not.toHaveBeenCalled()
expect(mockAssertNoUnresolved).not.toHaveBeenCalled()
expect(mockIsOwnerOrAdmin).not.toHaveBeenCalled()
})
it('rejects an organization checkout for a pro plan — org references only hold Team/Enterprise', async () => {
await expect(
authorizeSubscriptionReference('owner-1', 'org-1', 'upgrade-subscription', 'pro_6000')
@@ -148,6 +162,46 @@ describe('assertPersonalCheckoutAllowed', () => {
await expect(assertPersonalCheckoutAllowed('user-1')).resolves.toBeUndefined()
})
it('keeps abandoned, unbound checkout placeholders retryable', async () => {
await assertPersonalCheckoutAllowed('user-1')
const predicate = dbChainMockFns.where.mock.calls[0]?.[0]
expect(
hasMockCondition(
predicate,
(node) => node.type === 'eq' && node.left === schemaMock.subscription.referenceId
)
).toBe(true)
expect(
hasMockCondition(
predicate,
(node) =>
node.type === 'eq' &&
node.left === schemaMock.subscription.status &&
node.right === 'incomplete'
)
).toBe(true)
expect(
hasMockCondition(
predicate,
(node) =>
node.type === 'isNotNull' && node.column === schemaMock.subscription.stripeSubscriptionId
)
).toBe(true)
})
it('blocks a personal checkout while its bound Stripe subscription is incomplete', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{ id: 'subscription-1', stripeSubscriptionId: 'sub_pending' },
])
await expect(assertPersonalCheckoutAllowed('user-1')).rejects.toThrow(
/subscription payment is still processing/
)
expect(mockGetOrganizationCoverageForMember).not.toHaveBeenCalled()
})
it('rejects checkout when an organization subscription already covers the user', async () => {
mockGetOrganizationCoverageForMember.mockResolvedValueOnce({
status: 'covered',
+51 -5
View File
@@ -1,6 +1,8 @@
import { db } from '@sim/db'
import { subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { APIError } from 'better-auth/api'
import { and, eq, isNotNull } from 'drizzle-orm'
import { hasPaidSubscription } from '@/lib/billing'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import { getOrganizationCoverageForMember } from '@/lib/billing/core/subscription'
@@ -13,6 +15,44 @@ import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils'
const logger = createLogger('BillingAuthorization')
/**
* Prevent a billing reference from starting a second Stripe Checkout while a
* previously completed Checkout still has a payment in flight.
*
* Better Auth intentionally reuses an unbound `incomplete` placeholder when a
* customer retries an abandoned Checkout. Once Stripe has bound that row to a
* subscription, however, reusing it would create another Stripe subscription
* and make later webhooks race to own the same local row.
*/
async function assertNoBoundIncompleteSubscription(referenceId: string): Promise<void> {
const [pendingSubscription] = await db
.select({
id: subscription.id,
stripeSubscriptionId: subscription.stripeSubscriptionId,
})
.from(subscription)
.where(
and(
eq(subscription.referenceId, referenceId),
eq(subscription.status, 'incomplete'),
isNotNull(subscription.stripeSubscriptionId)
)
)
.limit(1)
if (!pendingSubscription) return
logger.warn('Blocking checkout - Stripe subscription payment is still pending', {
referenceId,
subscriptionId: pendingSubscription.id,
stripeSubscriptionId: pendingSubscription.stripeSubscriptionId,
})
throw new APIError('CONFLICT', {
message:
'Your subscription payment is still processing. Wait for it to finish before starting another checkout. If this takes longer than expected, contact support.',
})
}
/**
* Classify a `/subscription/upgrade` request as a personal checkout using
* the same reference resolution as the Better Auth Stripe plugin
@@ -39,11 +79,9 @@ export function isPersonalCheckoutRequest(
/**
* Guard for personal (user-referenced) checkouts on `/subscription/upgrade`.
*
* A member of an organization with an entitled paid subscription is already
* covered by that org — their usage pools to it and personal Pro
* subscriptions are paused on join — so a personal checkout would bill the
* same human twice. Throws {@link APIError} with a user-facing message; the
* checkout UI surfaces it as-is.
* Blocks both a bound, incomplete Stripe subscription and a member already
* covered by an entitled organization subscription. Throws {@link APIError}
* with a user-facing message; the checkout UI surfaces it as-is.
*
* Called from the Better Auth `hooks.before` middleware, NOT from
* `authorizeReference`: the Stripe plugin skips `authorizeReference`
@@ -54,6 +92,8 @@ export function isPersonalCheckoutRequest(
* rejected rather than risking a duplicate subscription.
*/
export async function assertPersonalCheckoutAllowed(userId: string): Promise<void> {
await assertNoBoundIncompleteSubscription(userId)
const coverage = await getOrganizationCoverageForMember(userId)
if (coverage.status === 'covered') {
@@ -90,6 +130,8 @@ export async function assertPersonalCheckoutAllowed(userId: string): Promise<voi
* reason instead of a generic "Unauthorized":
* - Organizations can only check out Team or Enterprise plans — a `pro_*`
* plan can never become org-referenced.
* - A bound, incomplete Stripe subscription must finish before another
* checkout can start.
* - Organizations cannot start a checkout while they already have an
* active subscription (prevents duplicates).
* - Checkout is deferred while an Enterprise issuance is unresolved.
@@ -115,6 +157,10 @@ export async function authorizeSubscriptionReference(
})
}
if (action === 'upgrade-subscription') {
await assertNoBoundIncompleteSubscription(referenceId)
}
if (action === 'upgrade-subscription' && (await hasPaidSubscription(referenceId))) {
logger.warn('Blocking checkout - active subscription already exists for organization', {
userId,
@@ -0,0 +1,109 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockAtomicallyClaim, mockRelease, mockIdempotencyService } = vi.hoisted(() => ({
mockAtomicallyClaim: vi.fn(),
mockRelease: vi.fn(),
mockIdempotencyService: vi.fn(),
}))
vi.mock('@/lib/core/idempotency/service', () => ({
IdempotencyService: class MockIdempotencyService {
constructor(options: unknown) {
mockIdempotencyService(options)
}
atomicallyClaim(...args: unknown[]) {
return mockAtomicallyClaim(...args)
}
release(...args: unknown[]) {
return mockRelease(...args)
}
},
}))
import {
claimCheckoutAdmission,
releaseCheckoutAdmission,
resolveCheckoutReferenceId,
} from '@/lib/billing/checkout-admission'
describe('checkout admission', () => {
beforeEach(() => {
mockAtomicallyClaim.mockReset()
mockRelease.mockReset()
mockRelease.mockResolvedValue(undefined)
})
it('uses durable, short-lived database claims', () => {
expect(mockIdempotencyService).toHaveBeenCalledWith({
namespace: 'billing-checkout-admission',
ttlSeconds: 120,
inProgressTtlSeconds: 120,
retryFailures: true,
storeResultBody: false,
forceStorage: 'database',
})
})
it('resolves explicit, organization, and personal references like Better Auth', () => {
expect(
resolveCheckoutReferenceId({ referenceId: 'org-explicit' }, 'user-1', 'org-active')
).toBe('org-explicit')
expect(
resolveCheckoutReferenceId({ customerType: 'organization' }, 'user-1', 'org-active')
).toBe('org-active')
expect(resolveCheckoutReferenceId({}, 'user-1', 'org-active')).toBe('user-1')
})
it('admits only one overlapping checkout for a billing reference', async () => {
mockAtomicallyClaim
.mockResolvedValueOnce({
claimed: true,
normalizedKey: 'billing-checkout-admission:stripe:org-1',
storageMethod: 'database',
claimToken: 'claim-1',
})
.mockResolvedValueOnce({
claimed: false,
normalizedKey: 'billing-checkout-admission:stripe:org-1',
storageMethod: 'database',
existingResult: { status: 'in-progress' },
})
const firstClaim = await claimCheckoutAdmission('org-1')
await expect(claimCheckoutAdmission('org-1')).rejects.toThrow(
/checkout is already being started/
)
expect(firstClaim.claimToken).toBe('claim-1')
})
it('releases only the claim owned by this request', async () => {
await releaseCheckoutAdmission({
normalizedKey: 'billing-checkout-admission:stripe:org-1',
storageMethod: 'database',
claimToken: 'claim-1',
})
expect(mockRelease).toHaveBeenCalledWith(
'billing-checkout-admission:stripe:org-1',
'database',
'claim-1'
)
})
it('does not replace a successful checkout response when release fails', async () => {
mockRelease.mockRejectedValueOnce(new Error('database unavailable'))
await expect(
releaseCheckoutAdmission({
normalizedKey: 'billing-checkout-admission:stripe:org-1',
storageMethod: 'database',
claimToken: 'claim-1',
})
).resolves.toBeUndefined()
})
})
@@ -0,0 +1,86 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { APIError } from 'better-auth/api'
import { type AtomicClaimResult, IdempotencyService } from '@/lib/core/idempotency/service'
const logger = createLogger('BillingCheckoutAdmission')
const CHECKOUT_ADMISSION_LEASE_SECONDS = 2 * 60
const checkoutAdmissionLeases = new IdempotencyService({
namespace: 'billing-checkout-admission',
ttlSeconds: CHECKOUT_ADMISSION_LEASE_SECONDS,
inProgressTtlSeconds: CHECKOUT_ADMISSION_LEASE_SECONDS,
retryFailures: true,
storeResultBody: false,
forceStorage: 'database',
})
export interface CheckoutAdmissionClaim {
normalizedKey: string
storageMethod: AtomicClaimResult['storageMethod']
claimToken: string
}
/**
* Resolves the billing reference using the same precedence as Better Auth's
* Stripe `referenceMiddleware`: an explicit reference wins, otherwise an
* organization checkout uses the active organization and a personal checkout
* uses the session user.
*/
export function resolveCheckoutReferenceId(
body: { referenceId?: unknown; customerType?: unknown },
sessionUserId: string,
activeOrganizationId: string | null
): string | null {
if (body.referenceId) {
return typeof body.referenceId === 'string' ? body.referenceId : null
}
if (body.customerType === 'organization') return activeOrganizationId
return sessionUserId
}
/**
* Atomically reserves one in-flight checkout request per billing reference.
* The claim spans Better Auth's local-row preparation and Stripe Checkout
* creation, closing the window where two requests could both pass a read-only
* admission check before either one bound a subscription.
*/
export async function claimCheckoutAdmission(referenceId: string): Promise<CheckoutAdmissionClaim> {
const claim = await checkoutAdmissionLeases.atomicallyClaim('stripe', referenceId)
if (!claim.claimed) {
logger.warn('Blocking concurrent subscription checkout', { referenceId })
throw new APIError('CONFLICT', {
message:
'A subscription checkout is already being started. Please wait a moment and try again.',
})
}
if (!claim.claimToken) {
throw new Error('Checkout admission claim is missing its fencing token')
}
return {
normalizedKey: claim.normalizedKey,
storageMethod: claim.storageMethod,
claimToken: claim.claimToken,
}
}
/**
* Releases a checkout admission claim without masking the endpoint result.
* A failed release is bounded by the claim's short lease and remains visible
* in logs for operators.
*/
export async function releaseCheckoutAdmission(claim: CheckoutAdmissionClaim): Promise<void> {
try {
await checkoutAdmissionLeases.release(
claim.normalizedKey,
claim.storageMethod,
claim.claimToken
)
} catch (error) {
logger.warn('Failed to release checkout admission claim; lease will expire', {
normalizedKey: claim.normalizedKey,
error: getErrorMessage(error, 'Unknown error'),
})
}
}
@@ -15,6 +15,10 @@ const mocks = vi.hoisted(() => ({
subscriptionsRetrieve: vi.fn(),
patchOutboxEventPayload: vi.fn(),
reapplyPaidOrgJoinBillingForExistingMemberTx: vi.fn(),
acquireUserBillingIdentityLock: vi.fn(),
acquireInvitationMutationLocks: vi.fn(),
attachOwnedWorkspacesToOrganizationTx: vi.fn(),
invalidateWorkspaceTableLimitsCache: vi.fn(),
}))
vi.mock('@sim/audit', () => ({
@@ -35,6 +39,10 @@ vi.mock('@/lib/billing/organizations/membership', () => ({
reapplyPaidOrgJoinBillingForExistingMemberTx: mocks.reapplyPaidOrgJoinBillingForExistingMemberTx,
}))
vi.mock('@/lib/billing/organizations/billing-identity-lock', () => ({
acquireUserBillingIdentityLock: mocks.acquireUserBillingIdentityLock,
}))
vi.mock('@/lib/billing/stripe-client', () => ({
requireStripeClient: () => ({
subscriptions: { retrieve: mocks.subscriptionsRetrieve },
@@ -64,6 +72,10 @@ vi.mock('@/lib/core/outbox/service', () => ({
patchOutboxEventPayload: mocks.patchOutboxEventPayload,
}))
vi.mock('@/lib/invitations/locks', () => ({
acquireInvitationMutationLocks: mocks.acquireInvitationMutationLocks,
}))
vi.mock('@/lib/messaging/email/mailer', () => ({
sendEmail: vi.fn(),
}))
@@ -76,6 +88,15 @@ vi.mock('@/lib/posthog/server', () => ({
captureServerEvent: vi.fn(),
}))
vi.mock('@/lib/table/billing', () => ({
invalidateWorkspaceTableLimitsCache: mocks.invalidateWorkspaceTableLimitsCache,
}))
vi.mock('@/lib/workspaces/organization-workspaces', () => ({
attachOwnedWorkspacesToOrganizationTx: mocks.attachOwnedWorkspacesToOrganizationTx,
ownedAttachableWorkspacesWhere: vi.fn(),
}))
import { handleManualEnterpriseSubscription } from '@/lib/billing/webhooks/enterprise'
const ENTERPRISE_PROVISION_EVENT_TYPE = 'stripe.provision-enterprise'
@@ -159,9 +180,18 @@ function eventFor(subscription: Stripe.Subscription): Stripe.Event {
function queueSuccessfulExistingSubscriptionReconciliation(options: {
operation?: ReturnType<typeof operationPayload>
workspaceIds?: string[]
}) {
queueTableRows(schemaMock.organization, [{ creditBalance: '0' }])
if (options.operation) {
queueTableRows(schemaMock.outboxEvent, [
{ eventType: ENTERPRISE_PROVISION_EVENT_TYPE, payload: options.operation },
])
if (!('applicationResult' in options.operation)) {
const workspaceRows = (options.workspaceIds ?? []).map((id) => ({ id }))
queueTableRows(schemaMock.workspace, workspaceRows)
queueTableRows(schemaMock.workspace, workspaceRows)
}
queueTableRows(schemaMock.outboxEvent, [
{ eventType: ENTERPRISE_PROVISION_EVENT_TYPE, payload: options.operation },
])
@@ -180,6 +210,12 @@ describe('Enterprise webhook issuance correlation', () => {
resetDbChainMock()
mocks.patchOutboxEventPayload.mockResolvedValue(true)
mocks.reapplyPaidOrgJoinBillingForExistingMemberTx.mockResolvedValue(undefined)
mocks.attachOwnedWorkspacesToOrganizationTx.mockResolvedValue({
attachedWorkspaceIds: [],
addedMemberIds: [],
skippedMembers: [],
usageLimitUserIds: [],
})
})
afterAll(() => {
@@ -189,6 +225,13 @@ describe('Enterprise webhook issuance correlation', () => {
it('retries when the create webhook races ahead of paused-collection provisioning', async () => {
const subscription = stripeSubscription({ operationId: 'operation-1', paused: false })
mocks.subscriptionsRetrieve.mockResolvedValue(subscription)
queueTableRows(schemaMock.outboxEvent, [
{
eventType: ENTERPRISE_PROVISION_EVENT_TYPE,
payload: operationPayload({ pausePaymentCollection: true }),
},
])
queueTableRows(schemaMock.workspace, [])
queueTableRows(schemaMock.organization, [{ creditBalance: '0' }])
queueTableRows(schemaMock.outboxEvent, [
{
@@ -207,6 +250,66 @@ describe('Enterprise webhook issuance correlation', () => {
expect(mocks.reapplyPaidOrgJoinBillingForExistingMemberTx).not.toHaveBeenCalled()
})
it('sweeps the Enterprise owner personal workspaces when issuance is applied', async () => {
const subscription = stripeSubscription({ operationId: 'operation-1', paused: false })
mocks.subscriptionsRetrieve.mockResolvedValue(subscription)
queueSuccessfulExistingSubscriptionReconciliation({
operation: operationPayload(),
workspaceIds: ['workspace-1', 'workspace-archived'],
})
mocks.attachOwnedWorkspacesToOrganizationTx.mockResolvedValueOnce({
attachedWorkspaceIds: ['workspace-1', 'workspace-archived'],
addedMemberIds: [],
skippedMembers: [],
usageLimitUserIds: [],
})
await expect(
handleManualEnterpriseSubscription(eventFor(subscription))
).resolves.toBeUndefined()
expect(mocks.acquireInvitationMutationLocks).toHaveBeenCalledWith(expect.anything(), {
invitationIds: [],
workspaceIds: ['workspace-1', 'workspace-archived'],
})
expect(mocks.acquireUserBillingIdentityLock).toHaveBeenCalledWith(expect.anything(), 'owner-1')
expect(mocks.attachOwnedWorkspacesToOrganizationTx).toHaveBeenCalledWith(expect.anything(), {
ownerUserId: 'owner-1',
organizationId: 'org-1',
workspaceIds: ['workspace-1', 'workspace-archived'],
externalMemberPolicy: 'external-all',
ownerMatch: 'owner',
includeArchived: true,
})
expect(mocks.invalidateWorkspaceTableLimitsCache).toHaveBeenCalledTimes(2)
expect(mocks.patchOutboxEventPayload).toHaveBeenCalled()
})
it('retries without applying when the Enterprise owner workspace set changes', async () => {
const subscription = stripeSubscription({ operationId: 'operation-1', paused: false })
mocks.subscriptionsRetrieve.mockResolvedValue(subscription)
queueTableRows(schemaMock.outboxEvent, [
{ eventType: ENTERPRISE_PROVISION_EVENT_TYPE, payload: operationPayload() },
])
queueTableRows(schemaMock.workspace, [{ id: 'workspace-1' }])
queueTableRows(schemaMock.organization, [{ creditBalance: '0' }])
queueTableRows(schemaMock.outboxEvent, [
{ eventType: ENTERPRISE_PROVISION_EVENT_TYPE, payload: operationPayload() },
])
queueTableRows(schemaMock.user, [{ stripeCustomerId: 'cus_1' }])
queueTableRows(schemaMock.member, [{ value: 1 }])
queueTableRows(schemaMock.subscription, [])
queueTableRows(schemaMock.subscription, [{ id: 'local-sub-1', referenceId: 'org-1' }])
queueTableRows(schemaMock.workspace, [{ id: 'workspace-1' }, { id: 'workspace-2' }])
await expect(handleManualEnterpriseSubscription(eventFor(subscription))).rejects.toThrow(
'personal workspaces changed during reconciliation'
)
expect(mocks.attachOwnedWorkspacesToOrganizationTx).not.toHaveBeenCalled()
expect(mocks.patchOutboxEventPayload).not.toHaveBeenCalled()
})
it('allows later Stripe metadata edits after the issuance was already applied', async () => {
const subscription = stripeSubscription({ operationId: 'operation-1', paused: false })
mocks.subscriptionsRetrieve.mockResolvedValue(subscription)
+110 -2
View File
@@ -1,6 +1,6 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { member, organization, outboxEvent, subscription, user } from '@sim/db/schema'
import { member, organization, outboxEvent, subscription, user, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, asc, count, eq, inArray, sql } from 'drizzle-orm'
@@ -13,6 +13,7 @@ import {
enterpriseOperationMatchesStripeSubscription,
parseEnterpriseProvisionPayload,
} from '@/lib/billing/enterprise-outbox'
import { acquireUserBillingIdentityLock } from '@/lib/billing/organizations/billing-identity-lock'
import {
acquireOrganizationMutationLock,
reapplyPaidOrgJoinBillingForExistingMemberTx,
@@ -26,13 +27,67 @@ import {
} from '@/lib/billing/webhooks/enterprise-reconciliation-lease'
import { stripeWebhookIdempotency } from '@/lib/billing/webhooks/idempotency'
import { patchOutboxEventPayload } from '@/lib/core/outbox/service'
import { acquireInvitationMutationLocks } from '@/lib/invitations/locks'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
import { captureServerEvent } from '@/lib/posthog/server'
import { invalidateWorkspaceTableLimitsCache } from '@/lib/table/billing'
import {
attachOwnedWorkspacesToOrganizationTx,
ownedAttachableWorkspacesWhere,
} from '@/lib/workspaces/organization-workspaces'
import { parseEnterpriseSubscriptionMetadata } from '../types'
const logger = createLogger('BillingEnterprise')
interface EnterpriseWorkspaceSweepPlan {
operationId: string
ownerUserId: string
workspaceIds: string[]
}
function sameOrderedIds(left: string[], right: string[]): boolean {
return left.length === right.length && left.every((id, index) => id === right[index])
}
async function planEnterpriseOwnerWorkspaceSweep(
metadata: Stripe.Metadata,
organizationId: string
): Promise<EnterpriseWorkspaceSweepPlan | null> {
const operationId = metadata.enterpriseOperationId
if (!operationId) return null
const [operationRow] = await db
.select({ eventType: outboxEvent.eventType, payload: outboxEvent.payload })
.from(outboxEvent)
.where(eq(outboxEvent.id, operationId))
.limit(1)
const payload = operationRow ? parseEnterpriseProvisionPayload(operationRow.payload) : null
if (
operationRow?.eventType !== ENTERPRISE_PROVISION_EVENT_TYPE ||
!payload ||
payload.applicationResult ||
payload.request.organizationId !== organizationId
) {
return null
}
const workspaceIds = (
await db
.select({ id: workspace.id })
.from(workspace)
.where(
ownedAttachableWorkspacesWhere({
userId: payload.request.ownerUserId,
includeArchived: true,
})
)
.orderBy(workspace.id)
).map((row) => row.id)
return { operationId, ownerUserId: payload.request.ownerUserId, workspaceIds }
}
export async function handleManualEnterpriseSubscription(event: Stripe.Event) {
return stripeWebhookIdempotency.executeWithIdempotency(
'manual-enterprise-subscription',
@@ -115,6 +170,7 @@ async function reconcileManualEnterpriseSubscription(
}
const { seats, monthlyPrice } = enterpriseMetadata
const workspaceSweepPlan = await planEnterpriseOwnerWorkspaceSweep(metadata, referenceId)
// Get the first subscription item which contains the period information
const referenceItem = stripeSubscription.items?.data?.[0]
@@ -148,6 +204,12 @@ async function reconcileManualEnterpriseSubscription(
}
const coreResult = await db.transaction(async (tx) => {
if (workspaceSweepPlan && workspaceSweepPlan.workspaceIds.length > 0) {
await acquireInvitationMutationLocks(tx, {
invitationIds: [],
workspaceIds: workspaceSweepPlan.workspaceIds,
})
}
await acquireOrganizationMutationLock(tx, referenceId)
await tx.execute(
sql`select pg_advisory_xact_lock(hashtextextended(${`stripe-subscription:${stripeSubscription.id}`}, 0))`
@@ -273,7 +335,7 @@ async function reconcileManualEnterpriseSubscription(
if (existing && existing.referenceId !== referenceId) {
throw new Error(
`Stripe subscription ${stripeSubscription.id} is already bound to organization ${existing.referenceId}`
`Stripe subscription ${stripeSubscription.id} is already bound to reference ${existing.referenceId}, not organization ${referenceId}`
)
}
@@ -315,6 +377,46 @@ async function reconcileManualEnterpriseSubscription(
})
.where(eq(organization.id, referenceId))
let attachedWorkspaceIds: string[] = []
if (operationNewlyApplied && correlatedOperation) {
if (
!workspaceSweepPlan ||
workspaceSweepPlan.operationId !== operationId ||
workspaceSweepPlan.ownerUserId !== correlatedOperation.request.ownerUserId
) {
throw new Error('Unable to establish the Enterprise owner workspace sweep')
}
await acquireUserBillingIdentityLock(tx, workspaceSweepPlan.ownerUserId)
const currentWorkspaceIds = (
await tx
.select({ id: workspace.id })
.from(workspace)
.where(
ownedAttachableWorkspacesWhere({
userId: workspaceSweepPlan.ownerUserId,
includeArchived: true,
})
)
.orderBy(workspace.id)
).map((row) => row.id)
if (!sameOrderedIds(workspaceSweepPlan.workspaceIds, currentWorkspaceIds)) {
throw new Error(
'Enterprise owner personal workspaces changed during reconciliation; retry the webhook'
)
}
const attached = await attachOwnedWorkspacesToOrganizationTx(tx, {
ownerUserId: workspaceSweepPlan.ownerUserId,
organizationId: referenceId,
workspaceIds: currentWorkspaceIds,
externalMemberPolicy: 'external-all',
ownerMatch: 'owner',
includeArchived: true,
})
attachedWorkspaceIds = attached.attachedWorkspaceIds
}
// The organization lock is held across the census and all member billing
// transitions. Add/remove/accept paths take the same lock, so a departing
// member cannot be re-paused after their removal restores personal Pro.
@@ -346,6 +448,7 @@ async function reconcileManualEnterpriseSubscription(
operationNewlyApplied,
hasCorrelatedOperation: Boolean(correlatedOperation),
subscriptionNewlyInserted: !existing,
attachedWorkspaceIds,
...creditLimits,
}
})
@@ -357,10 +460,14 @@ async function reconcileManualEnterpriseSubscription(
operationNewlyApplied,
hasCorrelatedOperation,
subscriptionNewlyInserted,
attachedWorkspaceIds,
configuredUsageLimitCredits,
prepaidCredits,
effectiveUsageLimitCredits,
} = coreResult
for (const workspaceId of attachedWorkspaceIds) {
invalidateWorkspaceTableLimitsCache(workspaceId)
}
const shouldAnnounce = hasCorrelatedOperation ? operationNewlyApplied : subscriptionNewlyInserted
logger.info('[subscription.created] Upserted enterprise subscription', {
@@ -372,6 +479,7 @@ async function reconcileManualEnterpriseSubscription(
effectiveUsageLimitCredits,
prepaidCredits,
seats,
attachedWorkspaceCount: attachedWorkspaceIds.length,
note: 'Seats from metadata, Stripe quantity set to 1',
})