From 303cc5da916a203630ac36768887de2bf502bd66 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 12 Aug 2026 13:02:15 -0700 Subject: [PATCH 1/3] fix(billing): checkout guard, admin panel case --- .../upgrade/hooks/use-upgrade-state.test.tsx | 16 +++ apps/sim/lib/auth/auth.ts | 6 +- .../sim/lib/auth/stripe-adapter-guard.test.ts | 87 +++++++++++++- apps/sim/lib/auth/stripe-adapter-guard.ts | 52 +++++++- apps/sim/lib/billing/authorization.test.ts | 56 ++++++++- apps/sim/lib/billing/authorization.ts | 56 ++++++++- .../lib/billing/webhooks/enterprise.test.ts | 103 ++++++++++++++++ apps/sim/lib/billing/webhooks/enterprise.ts | 112 +++++++++++++++++- 8 files changed, 470 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.test.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.test.tsx index 188e3a6523f..f2079037598 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/hooks/use-upgrade-state.test.tsx @@ -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() + }) + + 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() diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 50585c6403b..ecf9d40930f 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -1001,9 +1001,9 @@ 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) diff --git a/apps/sim/lib/auth/stripe-adapter-guard.test.ts b/apps/sim/lib/auth/stripe-adapter-guard.test.ts index 824921f0a71..80984b713d9 100644 --- a/apps/sim/lib/auth/stripe-adapter-guard.test.ts +++ b/apps/sim/lib/auth/stripe-adapter-guard.test.ts @@ -22,7 +22,12 @@ function createBaseAdapter() { const asAdapter = (base: ReturnType) => guardSubscriptionPlanWrites(base as unknown as Parameters[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,86 @@ 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) + const result = await 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', + }, + }) + + expect(result).toBeNull() + 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' }]) diff --git a/apps/sim/lib/auth/stripe-adapter-guard.ts b/apps/sim/lib/auth/stripe-adapter-guard.ts index 194af9cd6f3..04060379de8 100644 --- a/apps/sim/lib/auth/stripe-adapter-guard.ts +++ b/apps/sim/lib/auth/stripe-adapter-guard.ts @@ -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,28 @@ function guardWriteSurface( 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({ model: 'subscription', where: data.where, }) + + if (row && attemptsStripeSubscriptionRebind(row, data.update)) { + logger.error( + 'Blocked rebinding an existing subscription to a different Stripe subscription', + { + subscriptionId: row.id, + referenceId: row.referenceId, + currentStripeSubscriptionId: row.stripeSubscriptionId, + rejectedStripeSubscriptionId: (data.update as { stripeSubscriptionId: string }) + .stripeSubscriptionId, + } + ) + return null as never + } + + if (!hasNonOrgPlanWrite(data.update)) return adapter.update(data) + const sanitized = await stripPlanWhenOrgReferenced( row ? [row] : [], data.update as Record @@ -110,6 +129,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 { diff --git a/apps/sim/lib/billing/authorization.test.ts b/apps/sim/lib/billing/authorization.test.ts index e2843bd47a0..73eb71600c9 100644 --- a/apps/sim/lib/billing/authorization.test.ts +++ b/apps/sim/lib/billing/authorization.test.ts @@ -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', diff --git a/apps/sim/lib/billing/authorization.ts b/apps/sim/lib/billing/authorization.ts index b6edd341c26..0197b42ecfb 100644 --- a/apps/sim/lib/billing/authorization.ts +++ b/apps/sim/lib/billing/authorization.ts @@ -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 { + 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 { + await assertNoBoundIncompleteSubscription(userId) + const coverage = await getOrganizationCoverageForMember(userId) if (coverage.status === 'covered') { @@ -90,6 +130,8 @@ export async function assertPersonalCheckoutAllowed(userId: string): Promise ({ 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 + 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) diff --git a/apps/sim/lib/billing/webhooks/enterprise.ts b/apps/sim/lib/billing/webhooks/enterprise.ts index 59c81542064..aa7bc71681d 100644 --- a/apps/sim/lib/billing/webhooks/enterprise.ts +++ b/apps/sim/lib/billing/webhooks/enterprise.ts @@ -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 { + 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', }) From 485d1abaa3c9f4ffd7615769766b74c5d082d62a Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 12 Aug 2026 14:17:14 -0700 Subject: [PATCH 2/3] fix(billing): serialize checkout admission --- apps/sim/lib/auth/auth.ts | 37 +++++- .../sim/lib/auth/stripe-adapter-guard.test.ts | 23 ++-- apps/sim/lib/auth/stripe-adapter-guard.ts | 9 +- .../lib/billing/checkout-admission.test.ts | 109 ++++++++++++++++++ apps/sim/lib/billing/checkout-admission.ts | 86 ++++++++++++++ 5 files changed, 248 insertions(+), 16 deletions(-) create mode 100644 apps/sim/lib/billing/checkout-admission.test.ts create mode 100644 apps/sim/lib/billing/checkout-admission.ts diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index ecf9d40930f..e60ff745c5c 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -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, @@ -1008,13 +1015,39 @@ export const auth = betterAuth({ 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 context = ctx.context as typeof ctx.context & { + billingCheckoutAdmissionClaim?: CheckoutAdmissionClaim + } + if (context.billingCheckoutAdmissionClaim) { + await releaseCheckoutAdmission(context.billingCheckoutAdmissionClaim) + } + }), }, plugins: [ ...(env.TURNSTILE_SECRET_KEY diff --git a/apps/sim/lib/auth/stripe-adapter-guard.test.ts b/apps/sim/lib/auth/stripe-adapter-guard.test.ts index 80984b713d9..fea713ff40c 100644 --- a/apps/sim/lib/auth/stripe-adapter-guard.test.ts +++ b/apps/sim/lib/auth/stripe-adapter-guard.test.ts @@ -160,18 +160,19 @@ describe('guardSubscriptionPlanWrites', () => { base.findOne.mockResolvedValueOnce(personalPro) const guarded = asAdapter(base) - const result = await 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', - }, - }) + 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(result).toBeNull() expect(base.update).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/auth/stripe-adapter-guard.ts b/apps/sim/lib/auth/stripe-adapter-guard.ts index 04060379de8..13f20e4166c 100644 --- a/apps/sim/lib/auth/stripe-adapter-guard.ts +++ b/apps/sim/lib/auth/stripe-adapter-guard.ts @@ -86,17 +86,20 @@ function guardWriteSurface( }) 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: (data.update as { stripeSubscriptionId: string }) - .stripeSubscriptionId, + rejectedStripeSubscriptionId, } ) - return null as never + 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) diff --git a/apps/sim/lib/billing/checkout-admission.test.ts b/apps/sim/lib/billing/checkout-admission.test.ts new file mode 100644 index 00000000000..f7ddbf2892e --- /dev/null +++ b/apps/sim/lib/billing/checkout-admission.test.ts @@ -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() + }) +}) diff --git a/apps/sim/lib/billing/checkout-admission.ts b/apps/sim/lib/billing/checkout-admission.ts new file mode 100644 index 00000000000..dd1b41d43ef --- /dev/null +++ b/apps/sim/lib/billing/checkout-admission.ts @@ -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 { + 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 { + 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'), + }) + } +} From 89877b42f088dc05ed0301c11a7195cf629f1e75 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 12 Aug 2026 15:12:18 -0700 Subject: [PATCH 3/3] fix(billing): release checkout admission claim --- apps/sim/lib/auth/auth.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index e60ff745c5c..cdcbcd32903 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -1041,11 +1041,11 @@ export const auth = betterAuth({ }), after: createAuthMiddleware(async (ctx) => { if (!isBillingEnabled || ctx.path !== '/subscription/upgrade') return - const context = ctx.context as typeof ctx.context & { + const checkoutContext = ctx as typeof ctx & { billingCheckoutAdmissionClaim?: CheckoutAdmissionClaim } - if (context.billingCheckoutAdmissionClaim) { - await releaseCheckoutAdmission(context.billingCheckoutAdmissionClaim) + if (checkoutContext.billingCheckoutAdmissionClaim) { + await releaseCheckoutAdmission(checkoutContext.billingCheckoutAdmissionClaim) } }), },