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: {