From 4dc6f9e520bd1dd0407794a5962fb665eb34b699 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 12 Nov 2026 18:58:48 -0800 Subject: [PATCH] fix(aws-marketplace): cancel a Stripe subscription that binds to a customer AWS Marketplace already bills A Stripe hosted checkout left open while the same organization links an AWS Marketplace license passed both the checkout guard and the link-time check, so Stripe and AWS could both bill it. When Stripe binds the subscription, Sim now rechecks under the organization mutation lock, which both link paths hold from their Stripe check to commit, and queues an immediate Stripe cancellation plus a team notice to refund the charge. --- apps/sim/lib/auth/auth.ts | 6 + .../aws-marketplace/application/admin.ts | 2 + .../aws-marketplace/application/use-cases.ts | 7 +- .../billing/aws-marketplace/notice-events.ts | 6 + .../billing/aws-marketplace/notifications.ts | 8 + .../lib/billing/aws-marketplace/repository.ts | 140 ++++++- .../stripe-fence.integration.ts | 347 ++++++++++++++++++ .../billing/aws-marketplace/stripe-fence.ts | 85 +++++ .../lib/billing/webhooks/outbox-handlers.ts | 9 +- 9 files changed, 591 insertions(+), 19 deletions(-) create mode 100644 apps/sim/lib/billing/aws-marketplace/stripe-fence.integration.ts create mode 100644 apps/sim/lib/billing/aws-marketplace/stripe-fence.ts diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 0b492da2045..a34a3e176b5 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -84,6 +84,7 @@ import { authorizeSubscriptionReference, isPersonalCheckoutRequest, } from '@/lib/billing/authorization' +import { cancelStripeSubscriptionBilledThroughMarketplace } from '@/lib/billing/aws-marketplace/stripe-fence' import { type CheckoutAdmissionClaim, claimCheckoutAdmission, @@ -1615,6 +1616,11 @@ export const auth = betterAuth({ stripeSubscription: Stripe.Subscription subscription: any }) => { + // A checkout left open across an AWS Marketplace link would bill twice. + if (await cancelStripeSubscriptionBilledThroughMarketplace(subscription.id)) { + return + } + const { priceId, planFromStripe, isAnnual } = resolvePlanFromStripeSubscription(stripeSubscription) diff --git a/apps/sim/lib/billing/aws-marketplace/application/admin.ts b/apps/sim/lib/billing/aws-marketplace/application/admin.ts index ad373adbb8f..3d5fb0dab57 100644 --- a/apps/sim/lib/billing/aws-marketplace/application/admin.ts +++ b/apps/sim/lib/billing/aws-marketplace/application/admin.ts @@ -13,6 +13,7 @@ import { lockLicense, organizationExists, } from '@/lib/billing/aws-marketplace/repository' +import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' import { OrchestrationError } from '@/lib/core/orchestration/types' /** The licenses no organization holds yet, the ones the team's notices ask to link. */ @@ -49,6 +50,7 @@ export async function linkAwsMarketplaceLicenseAsAdmin( actor: AdminMutationActor ): Promise<{ id: string; organizationId: string }> { const { license, newlyLinked } = await db.transaction(async (tx) => { + await acquireOrganizationMutationLock(tx, input.organizationId) const locked = await lockLicense(tx, input.licenseId) if (!locked) throw new OrchestrationError('not_found', 'License') requireLinkableTo(locked, input.organizationId) diff --git a/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts b/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts index be2f5774365..249afd8e37a 100644 --- a/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts +++ b/apps/sim/lib/billing/aws-marketplace/application/use-cases.ts @@ -22,6 +22,7 @@ import { recordLicenseVerification, saveLicenseContact, } from '@/lib/billing/aws-marketplace/repository' +import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' import { defineAuthorizedOrganizationUseCase, type OrganizationUseCaseContext, @@ -98,7 +99,9 @@ export function requireLinkableTo( /** * AWS bills a linked organization for the listing, so one already paying Stripe for a plan would - * pay twice. Its subscription is cancelled first, by its owner or by support. + * pay twice. Its subscription is cancelled first, by its owner or by support. The caller holds the + * organization mutation lock until commit, which a subscription binding to Stripe also takes (see + * `cancelStripeSubscriptionBilledThroughMarketplace`). */ export async function requireNotBilledThroughStripe( tx: DbTransaction, @@ -260,6 +263,8 @@ export const linkAwsMarketplaceLicense = defineAuthorizedOrganizationUseCase({ }: OrganizationUseCaseContext): Promise { const { claimHash, verification } = await verifyClaimedLicense(input, input.organizationId) return db.transaction(async (tx) => { + // Held from the Stripe check to commit; a Stripe subscription binding meanwhile waits for it. + await acquireOrganizationMutationLock(tx, input.organizationId) const locked = await lockOpenClaim(tx, input, claimHash) requireLinkableTo(locked, input.organizationId) requireNotOverruled(locked, verification.verifiedAt) diff --git a/apps/sim/lib/billing/aws-marketplace/notice-events.ts b/apps/sim/lib/billing/aws-marketplace/notice-events.ts index 789d7461a31..74671fc94fd 100644 --- a/apps/sim/lib/billing/aws-marketplace/notice-events.ts +++ b/apps/sim/lib/billing/aws-marketplace/notice-events.ts @@ -28,6 +28,12 @@ export type AwsMarketplaceTeamNotice = state: 'issued' | 'resolved' detail: string } + | { + kind: 'stripe-cancelled' + licenseIds: string[] + subscriptionId: string + stripeSubscriptionId: string + } export interface AwsMarketplaceBuyerNotice { licenseId: string diff --git a/apps/sim/lib/billing/aws-marketplace/notifications.ts b/apps/sim/lib/billing/aws-marketplace/notifications.ts index ee9baac9d4e..d8dafecf749 100644 --- a/apps/sim/lib/billing/aws-marketplace/notifications.ts +++ b/apps/sim/lib/billing/aws-marketplace/notifications.ts @@ -184,6 +184,14 @@ New agreement: ${notice.agreementId}`, Event detail: ${notice.detail}`, next: 'Review the agreement in the AWS Marketplace Management Portal.', } + case 'stripe-cancelled': + return { + subject: `Stripe subscription cancelled: ${account}`, + opening: `A Stripe checkout completed for a customer AWS Marketplace already bills, so Sim cancelled the Stripe subscription at once. +Sim subscription: ${notice.subscriptionId} +Stripe subscription: ${notice.stripeSubscriptionId}`, + next: 'Refund the Stripe charge: from the admin dashboard for an organization, in Stripe for a personal plan. Cancelling credits only the unused time to the Stripe balance. Keep the customer on the AWS Marketplace terms below.', + } } } diff --git a/apps/sim/lib/billing/aws-marketplace/repository.ts b/apps/sim/lib/billing/aws-marketplace/repository.ts index 15636142926..0076f81d3a7 100644 --- a/apps/sim/lib/billing/aws-marketplace/repository.ts +++ b/apps/sim/lib/billing/aws-marketplace/repository.ts @@ -35,6 +35,7 @@ import type { AwsMarketplaceProducts, } from '@/lib/billing/aws-marketplace/products' import { TERMINAL_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' +import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-events' import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import type { DbTransaction } from '@/lib/db/types' @@ -61,6 +62,9 @@ interface AgreementObservation { seenAt: Date | null } +/** Why the cancellation was queued; the outbox handler logs it. */ +const STRIPE_CANCELLATION_REASON = 'billed-through-aws-marketplace' + function enqueueTeamNotice(tx: DbTransaction, notice: AwsMarketplaceTeamNotice, id?: string) { return enqueueOutboxEvent(tx, AWS_MARKETPLACE_TEAM_NOTICE_EVENT, notice, { id }) } @@ -675,10 +679,24 @@ export async function listAccountLicenses(userId: string) { } /** - * Whether AWS Marketplace bills this billing reference: a license of a listing this deployment - * sells that is not inactive, linked to the organization, or registered to the user's account with - * no organization yet. + * A license of a listing this deployment sells that is not inactive and bills `referenceId`: linked + * to the organization, or registered to the user's account with no organization yet. */ +function billsReference(products: AwsMarketplaceProducts, referenceId: string) { + return and( + ofProducts(products), + ne(awsMarketplaceLicense.status, 'inactive'), + or( + eq(awsMarketplaceLicense.organizationId, referenceId), + and( + isNull(awsMarketplaceLicense.organizationId), + eq(awsMarketplaceLicense.linkedByUserId, referenceId) + ) + ) + ) +} + +/** Whether AWS Marketplace bills this billing reference; see {@link billsReference}. */ export async function holdsMarketplaceLicense( products: AwsMarketplaceProducts, referenceId: string @@ -686,23 +704,26 @@ export async function holdsMarketplaceLicense( const [row] = await db .select({ id: awsMarketplaceLicense.id }) .from(awsMarketplaceLicense) - .where( - and( - ofProducts(products), - ne(awsMarketplaceLicense.status, 'inactive'), - or( - eq(awsMarketplaceLicense.organizationId, referenceId), - and( - isNull(awsMarketplaceLicense.organizationId), - eq(awsMarketplaceLicense.linkedByUserId, referenceId) - ) - ) - ) - ) + .where(billsReference(products, referenceId)) .limit(1) return Boolean(row) } +/** The licenses that bill `referenceId`, read in the caller's transaction. */ +export async function listLicensesBillingReference( + tx: DbTransaction, + products: AwsMarketplaceProducts, + referenceId: string +): Promise { + const rows = await tx + .select({ id: awsMarketplaceLicense.id }) + .from(awsMarketplaceLicense) + .where(billsReference(products, referenceId)) + .orderBy(asc(awsMarketplaceLicense.createdAt)) + .limit(MAX_LICENSES_PER_AGREEMENT) + return rows.map((row) => row.id) +} + /** * Licenses with no organization, newest first, for the admin dashboard: those waiting for the team * to link them, and inactive ones that never will be. @@ -768,6 +789,93 @@ export async function paysForSelfServePlan( return Boolean(row) } +/** The billing reference a subscription row names, read without a lock. */ +export async function findSubscriptionReference( + tx: DbTransaction, + subscriptionId: string +): Promise { + const [row] = await tx + .select({ referenceId: subscription.referenceId }) + .from(subscription) + .where(eq(subscription.id, subscriptionId)) + .limit(1) + return row?.referenceId ?? null +} + +/** + * Locks a subscription that pays Stripe for a self-serve plan, the kind {@link paysForSelfServePlan} + * refuses to link: bound to a Stripe subscription, not finished, and not Enterprise. Null when the + * row is not, or no longer, one. + */ +export async function lockSelfServeStripeSubscription(tx: DbTransaction, subscriptionId: string) { + const [row] = await tx + .select({ + id: subscription.id, + referenceId: subscription.referenceId, + plan: subscription.plan, + stripeSubscriptionId: subscription.stripeSubscriptionId, + }) + .from(subscription) + .where( + and( + eq(subscription.id, subscriptionId), + isNotNull(subscription.stripeSubscriptionId), + notInArray(subscription.status, [...TERMINAL_SUBSCRIPTION_STATUSES]), + ne(subscription.plan, 'enterprise') + ) + ) + .for('update') + .limit(1) + if (!row?.stripeSubscriptionId) return null + return { ...row, stripeSubscriptionId: row.stripeSubscriptionId } +} + +/** + * Queues the immediate Stripe cancellation of a subscription AWS Marketplace also bills, and tells + * the team, at most once per subscription: a redelivered webhook finds both already queued. The + * caller holds the subscription row lock. + */ +export async function enqueueStripeCancellationForMarketplace( + tx: DbTransaction, + input: { + subscriptionId: string + stripeSubscriptionId: string + organizationId: string | null + licenseIds: string[] + } +): Promise { + const cancellationId = `aws-marketplace-stripe-cancel-${input.subscriptionId}` + const [queued] = await tx + .select({ id: outboxEvent.id }) + .from(outboxEvent) + .where(eq(outboxEvent.id, cancellationId)) + .limit(1) + if (queued) return false + await enqueueOutboxEvent( + tx, + OUTBOX_EVENT_TYPES.STRIPE_CANCEL_SUBSCRIPTION_IMMEDIATELY, + { + stripeSubscriptionId: input.stripeSubscriptionId, + subscriptionId: input.subscriptionId, + ...(input.organizationId && { organizationId: input.organizationId }), + operationId: cancellationId, + reason: STRIPE_CANCELLATION_REASON, + }, + { id: cancellationId } + ) + await enqueueTeamNotice( + tx, + { + kind: 'stripe-cancelled', + licenseIds: input.licenseIds, + subscriptionId: input.subscriptionId, + stripeSubscriptionId: input.stripeSubscriptionId, + }, + `aws-marketplace-stripe-cancel-notice-${input.subscriptionId}` + ) + return true +} + export async function organizationExists(tx: DbTransaction, organizationId: string) { const [row] = await tx .select({ id: organization.id }) diff --git a/apps/sim/lib/billing/aws-marketplace/stripe-fence.integration.ts b/apps/sim/lib/billing/aws-marketplace/stripe-fence.integration.ts new file mode 100644 index 00000000000..7242550fbbc --- /dev/null +++ b/apps/sim/lib/billing/aws-marketplace/stripe-fence.integration.ts @@ -0,0 +1,347 @@ +import { db } from '@sim/db' +import { + awsMarketplaceLicense, + member, + organization, + outboxEvent, + subscription, + user, +} from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, sql } from 'drizzle-orm' +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' + +const ACCOUNT = '000000000000' +const TEAM_PLAN = 'team_6000' + +const marketplace = vi.hoisted(() => ({ enabled: true, product: 'product-placeholder' })) + +vi.mock('@/lib/billing/aws-marketplace/config', async () => { + const actual = await vi.importActual( + '@/lib/billing/aws-marketplace/config' + ) + const { parseAwsMarketplaceProducts } = await vi.importActual< + typeof import('@/lib/billing/aws-marketplace/products') + >('@/lib/billing/aws-marketplace/products') + const sold = parseAwsMarketplaceProducts(`${marketplace.product}=contract`) + return { + ...actual, + getAwsMarketplaceProducts: () => (marketplace.enabled ? sold : new Map()), + } +}) + +import { linkAwsMarketplaceLicenseAsAdmin } from '@/lib/billing/aws-marketplace/application/admin' +import { cancelStripeSubscriptionBilledThroughMarketplace } from '@/lib/billing/aws-marketplace/stripe-fence' +import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' +import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-events' +import type { DbTransaction } from '@/lib/db/types' + +type LicenseStatus = (typeof awsMarketplaceLicense.$inferInsert)['status'] + +const ADMIN = { id: null, name: 'Fence fixture admin', email: null } +/** Long enough that a statement not waiting on a lock has finished. */ +const LOCK_WAIT_PROBE_MS = 300 + +const userIds: string[] = [] +const organizationIds: string[] = [] +const subscriptionIds: string[] = [] + +async function createUser(): Promise { + const id = generateId() + userIds.push(id) + await db.insert(user).values({ + id, + name: 'Stripe fence fixture', + email: `${id}@example.test`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + return id +} + +async function createOrganization(ownerId: string): Promise { + const id = generateId() + organizationIds.push(id) + await db + .insert(organization) + .values({ id, name: 'Stripe fence org', slug: id, createdAt: new Date() }) + await db.insert(member).values({ + id: generateId(), + organizationId: id, + userId: ownerId, + role: 'owner', + createdAt: new Date(), + }) + return id +} + +async function insertLicense(link: { + organizationId: string | null + linkedByUserId: string | null + status?: LicenseStatus +}): Promise { + const id = generateId() + const now = new Date() + await db.insert(awsMarketplaceLicense).values({ + id, + licenseArn: `license-${generateId()}`, + productCode: marketplace.product, + customerAwsAccountId: ACCOUNT, + dimension: 'enterprise', + quantity: 25, + lastVerifiedAt: now, + lastVerificationAttemptAt: now, + status: link.status ?? 'active', + organizationId: link.organizationId, + linkedByUserId: link.linkedByUserId, + }) + return id +} + +/** The row Better Auth's Stripe plugin has just bound when `onSubscriptionComplete` runs. */ +async function bindSubscription( + referenceId: string, + overrides: { plan?: string; bound?: boolean } = {} +): Promise { + const id = generateId() + subscriptionIds.push(id) + const plan = overrides.plan ?? TEAM_PLAN + await db.insert(subscription).values({ + id, + plan, + referenceId, + status: 'active', + stripeSubscriptionId: overrides.bound === false ? null : `sub_${generateId()}`, + metadata: { plan, referenceId, seats: 5, monthlyPrice: 100 }, + }) + return id +} + +async function queuedCancellations(subscriptionId: string) { + return db + .select({ id: outboxEvent.id, payload: outboxEvent.payload }) + .from(outboxEvent) + .where( + and( + eq(outboxEvent.eventType, OUTBOX_EVENT_TYPES.STRIPE_CANCEL_SUBSCRIPTION_IMMEDIATELY), + sql`${outboxEvent.payload} ->> 'subscriptionId' = ${subscriptionId}` + ) + ) +} + +async function queuedTeamNotices(subscriptionId: string) { + return db + .select({ id: outboxEvent.id }) + .from(outboxEvent) + .where(eq(outboxEvent.id, `aws-marketplace-stripe-cancel-notice-${subscriptionId}`)) +} + +/** Settles `promise` into whether it finished within the probe window, without awaiting it. */ +async function finishesWithinProbe(promise: Promise): Promise { + return Promise.race([ + promise.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), LOCK_WAIT_PROBE_MS)), + ]) +} + +/** A transaction left open until `release` is called, holding what `hold` takes. */ +function openTransaction(hold: (tx: DbTransaction) => Promise) { + let release: () => void = () => {} + const released = new Promise((resolve) => { + release = resolve + }) + let holding: () => void = () => {} + const held = new Promise((resolve) => { + holding = resolve + }) + const done = db.transaction(async (tx) => { + await hold(tx) + holding() + await released + }) + return { held, release, done } +} + +afterEach(() => { + marketplace.enabled = true +}) + +afterAll(async () => { + if (subscriptionIds.length) { + await db.delete(outboxEvent).where( + inArray( + outboxEvent.id, + subscriptionIds.flatMap((id) => [ + `aws-marketplace-stripe-cancel-${id}`, + `aws-marketplace-stripe-cancel-notice-${id}`, + ]) + ) + ) + await db.delete(subscription).where(inArray(subscription.id, subscriptionIds)) + } + if (organizationIds.length) { + await db + .delete(awsMarketplaceLicense) + .where(inArray(awsMarketplaceLicense.organizationId, organizationIds)) + await db.delete(organization).where(inArray(organization.id, organizationIds)) + } + if (userIds.length) { + await db + .delete(awsMarketplaceLicense) + .where(inArray(awsMarketplaceLicense.linkedByUserId, userIds)) + await db.delete(user).where(inArray(user.id, userIds)) + } +}) + +describe('a Stripe subscription binding to a customer AWS Marketplace bills', () => { + it('cancels it at once and tells the team, once however often the webhook is redelivered', async () => { + const ownerId = await createUser() + const organizationId = await createOrganization(ownerId) + const licenseId = await insertLicense({ organizationId, linkedByUserId: ownerId }) + const subscriptionId = await bindSubscription(organizationId) + + await expect(cancelStripeSubscriptionBilledThroughMarketplace(subscriptionId)).resolves.toBe( + true + ) + await expect(cancelStripeSubscriptionBilledThroughMarketplace(subscriptionId)).resolves.toBe( + true + ) + + const cancellations = await queuedCancellations(subscriptionId) + expect(cancellations).toHaveLength(1) + expect(cancellations[0]?.payload).toMatchObject({ + subscriptionId, + organizationId, + reason: 'billed-through-aws-marketplace', + }) + expect(await queuedTeamNotices(subscriptionId)).toHaveLength(1) + const [notice] = await db + .select({ payload: outboxEvent.payload }) + .from(outboxEvent) + .where(eq(outboxEvent.id, `aws-marketplace-stripe-cancel-notice-${subscriptionId}`)) + expect(notice?.payload).toMatchObject({ kind: 'stripe-cancelled', licenseIds: [licenseId] }) + }) + + it('cancels a personal plan for an account holding a license with no organization yet', async () => { + const userId = await createUser() + await insertLicense({ organizationId: null, linkedByUserId: userId }) + const subscriptionId = await bindSubscription(userId, { plan: 'pro_6000' }) + + await expect(cancelStripeSubscriptionBilledThroughMarketplace(subscriptionId)).resolves.toBe( + true + ) + const [cancellation] = await queuedCancellations(subscriptionId) + expect(cancellation?.payload).not.toHaveProperty('organizationId') + }) + + it.each([ + ['no license', { license: null, plan: TEAM_PLAN, bound: true }], + ['only an inactive license', { license: 'inactive', plan: TEAM_PLAN, bound: true }], + [ + 'an Enterprise subscription the team issued', + { license: 'active', plan: 'enterprise', bound: true }, + ], + ['no Stripe subscription bound yet', { license: 'active', plan: TEAM_PLAN, bound: false }], + ] as const)('leaves it alone with %s', async (_, setup) => { + const ownerId = await createUser() + const organizationId = await createOrganization(ownerId) + if (setup.license) { + await insertLicense({ organizationId, linkedByUserId: ownerId, status: setup.license }) + } + const subscriptionId = await bindSubscription(organizationId, { + plan: setup.plan, + bound: setup.bound, + }) + + await expect(cancelStripeSubscriptionBilledThroughMarketplace(subscriptionId)).resolves.toBe( + false + ) + expect(await queuedCancellations(subscriptionId)).toHaveLength(0) + }) + + it('leaves it alone where Sim does not sell on AWS Marketplace', async () => { + const ownerId = await createUser() + const organizationId = await createOrganization(ownerId) + await insertLicense({ organizationId, linkedByUserId: ownerId }) + const subscriptionId = await bindSubscription(organizationId) + marketplace.enabled = false + + await expect(cancelStripeSubscriptionBilledThroughMarketplace(subscriptionId)).resolves.toBe( + false + ) + expect(await queuedCancellations(subscriptionId)).toHaveLength(0) + }) +}) + +describe('a Stripe checkout racing an AWS Marketplace link', () => { + it('cancels the subscription when the link commits first', async () => { + const ownerId = await createUser() + const organizationId = await createOrganization(ownerId) + const licenseId = await insertLicense({ organizationId: null, linkedByUserId: null }) + + await linkAwsMarketplaceLicenseAsAdmin({ licenseId, organizationId }, ADMIN) + const subscriptionId = await bindSubscription(organizationId) + + await expect(cancelStripeSubscriptionBilledThroughMarketplace(subscriptionId)).resolves.toBe( + true + ) + expect(await queuedCancellations(subscriptionId)).toHaveLength(1) + }) + + it('refuses the link when the subscription binds first, and keeps the subscription', async () => { + const ownerId = await createUser() + const organizationId = await createOrganization(ownerId) + const licenseId = await insertLicense({ organizationId: null, linkedByUserId: null }) + const subscriptionId = await bindSubscription(organizationId) + + await expect( + linkAwsMarketplaceLicenseAsAdmin({ licenseId, organizationId }, ADMIN) + ).rejects.toMatchObject({ code: 'conflict' }) + await expect(cancelStripeSubscriptionBilledThroughMarketplace(subscriptionId)).resolves.toBe( + false + ) + expect(await queuedCancellations(subscriptionId)).toHaveLength(0) + }) + + it('waits for a link that passed its Stripe check, then sees the license it commits', async () => { + const ownerId = await createUser() + const organizationId = await createOrganization(ownerId) + const licenseId = await insertLicense({ organizationId: null, linkedByUserId: null }) + // The link's state between its Stripe check and its commit: lock held, license taken. + const link = openTransaction(async (tx) => { + await acquireOrganizationMutationLock(tx, organizationId) + await tx + .update(awsMarketplaceLicense) + .set({ organizationId }) + .where(eq(awsMarketplaceLicense.id, licenseId)) + }) + await link.held + const subscriptionId = await bindSubscription(organizationId) + + const fence = cancelStripeSubscriptionBilledThroughMarketplace(subscriptionId) + const finishedEarly = await finishesWithinProbe(fence) + link.release() + await link.done + + expect(finishedEarly).toBe(false) + await expect(fence).resolves.toBe(true) + expect(await queuedCancellations(subscriptionId)).toHaveLength(1) + }) + + it('makes a link wait for a binding that is checking the organization', async () => { + const ownerId = await createUser() + const organizationId = await createOrganization(ownerId) + const licenseId = await insertLicense({ organizationId: null, linkedByUserId: null }) + const binding = openTransaction((tx) => acquireOrganizationMutationLock(tx, organizationId)) + await binding.held + + const linking = linkAwsMarketplaceLicenseAsAdmin({ licenseId, organizationId }, ADMIN) + const finishedEarly = await finishesWithinProbe(linking) + binding.release() + await binding.done + + expect(finishedEarly).toBe(false) + await expect(linking).resolves.toMatchObject({ id: licenseId, organizationId }) + }) +}) diff --git a/apps/sim/lib/billing/aws-marketplace/stripe-fence.ts b/apps/sim/lib/billing/aws-marketplace/stripe-fence.ts new file mode 100644 index 00000000000..7bbe3c6b1f7 --- /dev/null +++ b/apps/sim/lib/billing/aws-marketplace/stripe-fence.ts @@ -0,0 +1,85 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { db } from '@sim/db' +import { createLogger } from '@sim/logger' +import { getAwsMarketplaceProducts } from '@/lib/billing/aws-marketplace/config' +import { + enqueueStripeCancellationForMarketplace, + findSubscriptionReference, + listLicensesBillingReference, + lockSelfServeStripeSubscription, + organizationExists, +} from '@/lib/billing/aws-marketplace/repository' +import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' + +const logger = createLogger('AwsMarketplaceStripeFence') + +/** + * Cancels a Stripe subscription that has just bound to a customer AWS Marketplace already bills, + * so the customer is never charged twice. Returns true when the subscription is cancelled, and the + * caller then skips the paid-plan setup. + * + * The checkout guard refuses such a checkout before it starts, and linking a license refuses an + * organization already bound to Stripe, but a hosted checkout left open across a link passes both: + * no lock can span it. This closes that window once Stripe binds the subscription. It takes the + * organization mutation lock the link holds from its Stripe check to its commit, so it runs either + * before that check (which then sees the bound subscription and refuses) or after the link commits + * (and this sees the license). + * + * Stripe stays the authority on entitlement: the cancellation is queued, and its verified deletion + * webhook ends the subscription row. The team is told to refund the charge, which a cancellation + * only credits to the Stripe balance. + */ +export async function cancelStripeSubscriptionBilledThroughMarketplace( + subscriptionId: string +): Promise { + const products = getAwsMarketplaceProducts() + if (products.size === 0) return false + + const cancelled = await db.transaction(async (tx) => { + const referenceId = await findSubscriptionReference(tx, subscriptionId) + if (!referenceId) return null + // Lock order for a subscription's billing writers: organization mutation lock, then the row. + const organizationId = (await organizationExists(tx, referenceId)) ? referenceId : null + if (organizationId) await acquireOrganizationMutationLock(tx, organizationId) + const bound = await lockSelfServeStripeSubscription(tx, subscriptionId) + if (!bound) return null + if (bound.referenceId !== referenceId) { + throw new Error('Subscription changed billing reference while checking AWS Marketplace') + } + const licenseIds = await listLicensesBillingReference(tx, products, bound.referenceId) + if (licenseIds.length === 0) return null + const queued = await enqueueStripeCancellationForMarketplace(tx, { + subscriptionId: bound.id, + stripeSubscriptionId: bound.stripeSubscriptionId, + organizationId, + licenseIds, + }) + return { ...bound, organizationId, licenseIds, queued } + }) + if (!cancelled) return false + + if (cancelled.queued) { + logger.error('Cancelled a Stripe subscription for a customer AWS Marketplace already bills', { + subscriptionId: cancelled.id, + referenceId: cancelled.referenceId, + plan: cancelled.plan, + licenseIds: cancelled.licenseIds, + }) + recordAudit({ + actorId: null, + actorName: 'AWS Marketplace billing', + action: AuditAction.SUBSCRIPTION_CANCELLED, + resourceType: AuditResourceType.SUBSCRIPTION, + resourceId: cancelled.id, + description: + 'Cancelled a Stripe subscription immediately because AWS Marketplace already bills this customer', + metadata: { + referenceId: cancelled.referenceId, + organizationId: cancelled.organizationId, + plan: cancelled.plan, + awsMarketplaceLicenseIds: cancelled.licenseIds, + }, + }) + } + return true +} diff --git a/apps/sim/lib/billing/webhooks/outbox-handlers.ts b/apps/sim/lib/billing/webhooks/outbox-handlers.ts index 6c71a48589a..693f4630a8e 100644 --- a/apps/sim/lib/billing/webhooks/outbox-handlers.ts +++ b/apps/sim/lib/billing/webhooks/outbox-handlers.ts @@ -35,13 +35,18 @@ function syncWriteIdempotencyKey(eventId: string): string { return `outbox:${eventId}:${generateShortId()}` } +/** + * An admin's request carries the organization and who asked, and is audited here. A cancellation + * Sim queues on its own (an AWS Marketplace customer who also paid Stripe) carries no requester and + * is audited where it was decided. + */ interface StripeCancelSubscriptionImmediatelyPayload { stripeSubscriptionId: string subscriptionId: string - organizationId: string + organizationId?: string operationId: string reason?: string - requestedBy: { id: string | null; name: string; email: string | null } + requestedBy?: { id: string | null; name: string; email: string | null } } async function recordAdminCancellationAudit(params: {