diff --git a/apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx b/apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx index 207671e63d..023dc4ffa0 100644 --- a/apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx +++ b/apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx @@ -10,11 +10,35 @@ import { adaptSchema, adminAuthTypeSchema, moneyAmountSchema, productSchema, yup import { moneyAmountToStripeUnits } from "@stackframe/stack-shared/dist/utils/currencies"; import { SUPPORTED_CURRENCIES, type MoneyAmount } from "@stackframe/stack-shared/dist/utils/currency-constants"; import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import type Stripe from "stripe"; import { InferType } from "yup"; const USD_CURRENCY = SUPPORTED_CURRENCIES.find((currency) => currency.code === "USD") ?? throwErr("USD currency configuration missing in SUPPORTED_CURRENCIES"); +/** + * Builds the parameters object for `stripe.refunds.create`. Centralised so the + * platform-fee invariant — that we never let Stripe reverse our charge-leg + * 0.9% application fee on refund — has exactly one source of truth and one + * place to test. + * + * Stripe's default for `refund_application_fee` on a Connect direct charge is + * `true`, which proportionally reverses the application fee along with the + * refund. We always set it to `false` so the platform retains its cut. + */ +export function buildStripeRefundParams(args: { + paymentIntentId: string, + amountStripeUnits: number, + metadata?: Record, +}): Stripe.RefundCreateParams { + return { + payment_intent: args.paymentIntentId, + amount: args.amountStripeUnits, + ...(args.metadata ? { metadata: args.metadata } : {}), + refund_application_fee: false, + }; +} + function getTotalUsdStripeUnits(options: { product: InferType, priceId: string | null, quantity: number }) { const selectedPrice = resolveSelectedPriceFromProduct(options.product, options.priceId ?? null); const usdPrice = selectedPrice?.USD; @@ -262,10 +286,10 @@ export const POST = createSmartRouteHandler({ if (refundAmountStripeUnits > totalStripeUnits) { throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount."); } - await stripe.refunds.create({ - payment_intent: paymentIntentId, - amount: refundAmountStripeUnits, - }); + await stripe.refunds.create(buildStripeRefundParams({ + paymentIntentId, + amountStripeUnits: refundAmountStripeUnits, + })); const refundedAt = new Date(); if (refundedQuantity > 0) { if (!subscription.stripeSubscriptionId) { @@ -363,14 +387,14 @@ export const POST = createSmartRouteHandler({ if (refundAmountStripeUnits > totalStripeUnits) { throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount."); } - await stripe.refunds.create({ - payment_intent: purchase.stripePaymentIntentId, - amount: refundAmountStripeUnits, + await stripe.refunds.create(buildStripeRefundParams({ + paymentIntentId: purchase.stripePaymentIntentId, + amountStripeUnits: refundAmountStripeUnits, metadata: { tenancyId: auth.tenancy.id, purchaseId: purchase.id, }, - }); + })); const refundedAt = new Date(); await prisma.oneTimePurchase.update({ where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } }, @@ -405,3 +429,31 @@ export const POST = createSmartRouteHandler({ }; }, }); + +import.meta.vitest?.describe("buildStripeRefundParams", (test) => { + test("always sets refund_application_fee: false to keep our 0.9% with the platform", ({ expect }) => { + const params = buildStripeRefundParams({ paymentIntentId: "pi_test", amountStripeUnits: 5000 }); + expect(params.refund_application_fee).toBe(false); + }); + test("propagates payment_intent and amount as-is", ({ expect }) => { + const params = buildStripeRefundParams({ paymentIntentId: "pi_abc", amountStripeUnits: 1234 }); + expect(params.payment_intent).toBe("pi_abc"); + expect(params.amount).toBe(1234); + }); + test("propagates metadata when provided and omits the key when not", ({ expect }) => { + const withMeta = buildStripeRefundParams({ + paymentIntentId: "pi_x", + amountStripeUnits: 1, + metadata: { tenancyId: "t1", purchaseId: "p1" }, + }); + expect(withMeta.metadata).toEqual({ tenancyId: "t1", purchaseId: "p1" }); + // refund_application_fee invariant must hold even when metadata is set — + // pin this explicitly so a future change to the metadata branch can't + // accidentally strip the fee flag. + expect(withMeta.refund_application_fee).toBe(false); + + const withoutMeta = buildStripeRefundParams({ paymentIntentId: "pi_x", amountStripeUnits: 1 }); + expect("metadata" in withoutMeta).toBe(false); + expect(withoutMeta.refund_application_fee).toBe(false); + }); +}); diff --git a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts index aebf658fde..ed5150a965 100644 --- a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts +++ b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts @@ -2,6 +2,7 @@ import { SubscriptionStatus } from "@/generated/prisma/client"; import { ensureClientCanAccessCustomer, ensureCustomerExists, getDefaultCardPaymentMethodSummary, getStripeCustomerForCustomerOrNull, isActiveSubscription, isAddOnProduct } from "@/lib/payments"; import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; import { getOwnedProductsForCustomer, getSubscriptionMapForCustomer } from "@/lib/payments/customer-data"; +import { getApplicationFeePercentOrUndefined } from "@/lib/payments/platform-fees"; import { upsertProductVersion } from "@/lib/product-versions"; import { getStripeForAccount, sanitizeStripePeriodDates } from "@/lib/stripe"; import { getPrismaClientForTenancy } from "@/prisma-client"; @@ -204,6 +205,11 @@ export const POST = createSmartRouteHandler({ throw new StackAssertionError("Stripe subscription has no items", { subscriptionId: existingSub.id }); } const existingItem = existingStripeSub.items.data[0]; + // Intentional: switching an existing (possibly pre-platform-fee) + // subscription to a new plan attaches the 0.9% application fee from + // this point forward. Subscriptions that never switch plans stay + // fee-less until a separate migration applies fees retroactively. + const applicationFeePercent = getApplicationFeePercentOrUndefined(auth.tenancy.project.id); const updated = await stripe.subscriptions.update(existingSub.stripeSubscriptionId, { payment_behavior: "error_if_incomplete", payment_settings: { save_default_payment_method: "on_subscription" }, @@ -226,6 +232,7 @@ export const POST = createSmartRouteHandler({ productVersionId, priceId: selectedPriceId, }, + ...(applicationFeePercent !== undefined ? { application_fee_percent: applicationFeePercent } : {}), }); const updatedSubscription = updated as Stripe.Subscription; const sanitizedUpdateDates = sanitizeStripePeriodDates( @@ -261,6 +268,7 @@ export const POST = createSmartRouteHandler({ // DEPRECATED: this path handles switching from include-by-default (free) products // to paid subscriptions. Default products are being removed; this code is kept // for backward compatibility only. + const applicationFeePercent = getApplicationFeePercentOrUndefined(auth.tenancy.project.id); const created = await stripe.subscriptions.create({ customer: stripeCustomer.id, payment_behavior: "error_if_incomplete", @@ -283,6 +291,7 @@ export const POST = createSmartRouteHandler({ productVersionId, priceId: selectedPriceId, }, + ...(applicationFeePercent !== undefined ? { application_fee_percent: applicationFeePercent } : {}), }); const createdSubscription = created as Stripe.Subscription; if (createdSubscription.items.data.length === 0) { diff --git a/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx index fd053e70f9..6eb7605a3a 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx +++ b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx @@ -1,6 +1,7 @@ import { SubscriptionStatus } from "@/generated/prisma/client"; import { getClientSecretFromStripeSubscription, validatePurchaseSession } from "@/lib/payments"; import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; +import { computeApplicationFeeAmount, getApplicationFeePercentOrUndefined } from "@/lib/payments/platform-fees"; import { upsertProductVersion } from "@/lib/product-versions"; import { getStripeForAccount } from "@/lib/stripe"; import { getTenancy } from "@/lib/tenancies"; @@ -92,6 +93,7 @@ export const POST = createSmartRouteHandler({ const existingItem = existingStripeSub.items.data[0]; const product = await stripe.products.create({ name: data.product.displayName ?? "Subscription" }); if (selectedPrice.interval) { + const applicationFeePercent = getApplicationFeePercentOrUndefined(tenancy.project.id); const updated = await stripe.subscriptions.update(conflicting.stripeSubscriptionId, { payment_behavior: 'default_incomplete', payment_settings: { save_default_payment_method: 'on_subscription' }, @@ -114,6 +116,7 @@ export const POST = createSmartRouteHandler({ productVersionId, priceId: price_id, }, + ...(applicationFeePercent !== undefined ? { application_fee_percent: applicationFeePercent } : {}), }); const clientSecretUpdated = getClientSecretFromStripeSubscription(updated); await purchaseUrlVerificationCodeHandler.revokeCode({ tenancy, id: codeId }); @@ -145,6 +148,10 @@ export const POST = createSmartRouteHandler({ // One-time payment path after conflicts handled if (!selectedPrice.interval) { const amountCents = Number(selectedPrice.USD) * 100 * Math.max(1, quantity); + const applicationFeeAmount = computeApplicationFeeAmount({ + amountStripeUnits: amountCents, + projectId: tenancy.project.id, + }); const paymentIntent = await stripe.paymentIntents.create({ amount: amountCents, currency: "usd", @@ -160,6 +167,7 @@ export const POST = createSmartRouteHandler({ tenancyId: data.tenancyId, priceId: price_id, }, + ...(applicationFeeAmount > 0 ? { application_fee_amount: applicationFeeAmount } : {}), }); const clientSecret = paymentIntent.client_secret; if (typeof clientSecret !== "string") { @@ -172,6 +180,7 @@ export const POST = createSmartRouteHandler({ const product = await stripe.products.create({ name: data.product.displayName ?? "Subscription", }); + const applicationFeePercent = getApplicationFeePercentOrUndefined(tenancy.project.id); const created = await stripe.subscriptions.create({ customer: data.stripeCustomerId, payment_behavior: 'default_incomplete', @@ -194,6 +203,7 @@ export const POST = createSmartRouteHandler({ productVersionId, priceId: price_id, }, + ...(applicationFeePercent !== undefined ? { application_fee_percent: applicationFeePercent } : {}), }); const clientSecret = getClientSecretFromStripeSubscription(created); if (typeof clientSecret !== "string") { diff --git a/apps/backend/src/lib/payments/platform-fees.ts b/apps/backend/src/lib/payments/platform-fees.ts new file mode 100644 index 0000000000..c919621000 --- /dev/null +++ b/apps/backend/src/lib/payments/platform-fees.ts @@ -0,0 +1,91 @@ +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; + +// 0.9% of every Stripe money movement on a non-internal project is collected +// as a platform fee, ridden along via Stripe's native application_fee_* +// params on the PaymentIntent / Subscription. Refunds keep our charge-leg +// fee with the platform via `refund_application_fee: false` at the refund +// site — there is no separate refund-leg collection. +// +// Stored as basis points (1 bps = 1/10000 = 0.01%) instead of a decimal +// percentage so all fee math is integer arithmetic — `0.9 * 5000 / 100` is +// `45.000000000000004` in IEEE-754, but `90 * 5000 / 10000` is exactly `45`. +export const APPLICATION_FEE_BPS = 90; + +export function getApplicationFeeBps(projectId: string): number { + if (projectId === "internal") return 0; + return APPLICATION_FEE_BPS; +} + +/** + * Half-to-nearest rounding. Stripe's `application_fee_amount` is an integer + * in stripe-units, so we can't represent 0.9% exactly when the charge isn't + * a multiple of $10. Round-nearest is unbiased on average — over many + * charges the over- and under-rounding cancel — at the cost of producing a + * 0 fee on charges in Stripe's min-charge band ($0.50–$0.55) where 0.9% + * falls below half a cent. That clip-to-zero band is small enough to be + * acceptable lost revenue; the alternative (ceil) over-collects on every + * non-multiple-of-$10 charge, and a fractional-cents ledger is more + * complexity than the precision is worth here. + */ +export function computeApplicationFeeAmount(options: { amountStripeUnits: number, projectId: string }): number { + if (options.amountStripeUnits < 0) { + throwErr("computeApplicationFeeAmount received negative amount", { amountStripeUnits: options.amountStripeUnits }); + } + const bps = getApplicationFeeBps(options.projectId); + if (bps === 0) return 0; + return Math.round(options.amountStripeUnits * bps / 10000); +} + +/** + * Returns the fee as a decimal percent for Stripe's `application_fee_percent` + * (subscription) parameter, or `undefined` for projects that aren't billed. + * + * `bps / 100` is intentional float division — the rest of the module uses + * integer arithmetic to avoid IEEE-754 noise on charge-amount math, but the + * subscription path requires a decimal because that's the shape Stripe's API + * accepts. This is safe for the current 90 bps (→ 0.9, which serialises + * cleanly), and any future bps value must produce a number with at most 4 + * decimal places after IEEE-754 rounding — that's the maximum precision + * Stripe documents for `application_fee_percent`. + */ +export function getApplicationFeePercentOrUndefined(projectId: string): number | undefined { + const bps = getApplicationFeeBps(projectId); + if (bps === 0) return undefined; + return bps / 100; +} + +import.meta.vitest?.describe("platform fee helpers", (test) => { + test("getApplicationFeeBps returns 0 for internal project", ({ expect }) => { + expect(getApplicationFeeBps("internal")).toBe(0); + }); + test("getApplicationFeeBps returns APPLICATION_FEE_BPS for any other project", ({ expect }) => { + expect(getApplicationFeeBps("proj_abc123")).toBe(APPLICATION_FEE_BPS); + expect(getApplicationFeeBps("some-uuid")).toBe(APPLICATION_FEE_BPS); + }); + test("computeApplicationFeeAmount is 0.9% of the charge, rounded half-to-nearest", ({ expect }) => { + expect(computeApplicationFeeAmount({ amountStripeUnits: 10000, projectId: "p" })).toBe(90); + expect(computeApplicationFeeAmount({ amountStripeUnits: 12345, projectId: "p" })).toBe(111); + expect(computeApplicationFeeAmount({ amountStripeUnits: 500000, projectId: "p" })).toBe(4500); + }); + test("computeApplicationFeeAmount clips to 0 below the half-cent threshold (~$0.56)", ({ expect }) => { + // Documented tradeoff: charges in Stripe's min-charge band whose 0.9% + // is under half a cent round to a 0 fee. Pinned here so a future reader + // doesn't accidentally "fix" the clipping without weighing the + // alternatives (see the JSDoc on computeApplicationFeeAmount). + expect(computeApplicationFeeAmount({ amountStripeUnits: 50, projectId: "p" })).toBe(0); + expect(computeApplicationFeeAmount({ amountStripeUnits: 55, projectId: "p" })).toBe(0); + expect(computeApplicationFeeAmount({ amountStripeUnits: 56, projectId: "p" })).toBe(1); + }); + test("computeApplicationFeeAmount is 0 for internal project even on large charges", ({ expect }) => { + expect(computeApplicationFeeAmount({ amountStripeUnits: 10000, projectId: "internal" })).toBe(0); + }); + test("computeApplicationFeeAmount throws on negative amounts", ({ expect }) => { + expect(() => computeApplicationFeeAmount({ amountStripeUnits: -1, projectId: "p" })).toThrow(/negative amount/); + }); + test("getApplicationFeePercentOrUndefined returns 0.9 for non-internal", ({ expect }) => { + expect(getApplicationFeePercentOrUndefined("proj_abc")).toBe(0.9); + }); + test("getApplicationFeePercentOrUndefined returns undefined for internal", ({ expect }) => { + expect(getApplicationFeePercentOrUndefined("internal")).toBeUndefined(); + }); +}); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts index 5efa123b4b..3b110da09a 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts @@ -1,141 +1,12 @@ import { randomUUID } from "node:crypto"; import { expect } from "vitest"; import { it } from "../../../../../helpers"; -import { Auth, Payments, Project, niceBackendFetch } from "../../../../backend-helpers"; - -function createDefaultPaymentsConfig(testMode: boolean | undefined) { - return { - payments: { - testMode: testMode ?? true, - products: { - "sub-product": { - displayName: "Sub Product", - customerType: "user", - serverOnly: false, - stackable: false, - prices: { - monthly: { USD: "1000", interval: [1, "month"] }, - }, - includedItems: {}, - }, - "otp-product": { - displayName: "One-Time Product", - customerType: "user", - serverOnly: false, - stackable: false, - prices: { - single: { USD: "5000" }, - }, - includedItems: {}, - }, - }, - items: {}, - }, - }; -} - -async function setupProjectWithPaymentsConfig(options: { testMode?: boolean } = {}) { - await Project.createAndSwitch(); - await Payments.setup(); - const config = createDefaultPaymentsConfig(options.testMode); - await Project.updateConfig(config); - return config; -} - -async function createPurchaseCode(options: { userId: string, productId: string }) { - const res = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { - method: "POST", - accessType: "client", - body: { - customer_type: "user", - customer_id: options.userId, - product_id: options.productId, - }, - }); - expect(res.status).toBe(200); - const codeMatch = (res.body.url as string).match(/\/purchase\/([a-z0-9-_]+)/); - const code = codeMatch ? codeMatch[1] : undefined; - expect(code).toBeDefined(); - return code as string; -} - -async function createTestModeTransaction(productId: string, priceId: string) { - const { userId } = await Auth.fastSignUp(); - const code = await createPurchaseCode({ userId, productId }); - const response = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", { - accessType: "admin", - method: "POST", - body: { full_code: code, price_id: priceId, quantity: 1 }, - }); - expect(response.status).toBe(200); - const transactions = await niceBackendFetch("/api/latest/internal/payments/transactions", { - accessType: "admin", - }); - expect(transactions.status).toBe(200); - expect(transactions.body.transactions.length).toBeGreaterThan(0); - const transaction = transactions.body.transactions[0]; - return { transactionId: transaction.id, userId }; -} - -async function createLiveModeOneTimePurchaseTransaction(options: { quantity?: number } = {}) { - const config = await setupProjectWithPaymentsConfig({ testMode: false }); - const { userId } = await Auth.fastSignUp(); - const quantity = options.quantity ?? 1; - - const accountInfo = await niceBackendFetch("/api/latest/internal/payments/stripe/account-info", { - accessType: "admin", - }); - expect(accountInfo.status).toBe(200); - const accountId: string = accountInfo.body.account_id; - - const code = await createPurchaseCode({ userId, productId: "otp-product" }); - const stackTestTenancyId = code.split("_")[0]; - const product = config.payments.products["otp-product"]; - - const idSuffix = randomUUID().replace(/-/g, ""); - const eventId = `evt_otp_refund_${idSuffix}`; - const paymentIntentId = `pi_otp_refund_${idSuffix}`; - const paymentIntentPayload = { - id: eventId, - type: "payment_intent.succeeded", - account: accountId, - data: { - object: { - id: paymentIntentId, - customer: userId, - stack_stripe_mock_data: { - "accounts.retrieve": { metadata: { tenancyId: stackTestTenancyId } }, - "customers.retrieve": { metadata: { customerId: userId, customerType: "USER" } }, - "subscriptions.list": { data: [] }, - }, - metadata: { - productId: "otp-product", - product: JSON.stringify(product), - customerId: userId, - customerType: "user", - purchaseQuantity: String(quantity), - purchaseKind: "ONE_TIME", - priceId: "single", - }, - }, - }, - }; - - const webhookSecret = process.env.STACK_STRIPE_WEBHOOK_SECRET ?? "mock_stripe_webhook_secret"; - const webhookRes = await Payments.sendStripeWebhook(paymentIntentPayload, { secret: webhookSecret }); - expect(webhookRes.status).toBe(200); - expect(webhookRes.body).toEqual({ received: true }); - - const transactionsRes = await niceBackendFetch("/api/latest/internal/payments/transactions", { - accessType: "admin", - }); - expect(transactionsRes.status).toBe(200); - - const purchaseTransaction = transactionsRes.body.transactions.find((tx: any) => tx.type === "purchase"); - expect(purchaseTransaction).toBeDefined(); - - return { userId, transactionsRes, purchaseTransaction }; -} +import { niceBackendFetch } from "../../../../backend-helpers"; +import { + createLiveModeOneTimePurchaseTransaction, + createTestModeTransaction, + setupProjectWithPaymentsConfig, +} from "../../../../helpers/payments"; it("returns TestModePurchaseNonRefundable when refunding test mode one-time purchases", async () => { await setupProjectWithPaymentsConfig(); diff --git a/apps/e2e/tests/backend/helpers/payments.ts b/apps/e2e/tests/backend/helpers/payments.ts new file mode 100644 index 0000000000..ec29698805 --- /dev/null +++ b/apps/e2e/tests/backend/helpers/payments.ts @@ -0,0 +1,128 @@ +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { randomUUID } from "node:crypto"; +import { expect } from "vitest"; +import { Auth, Payments, Project, niceBackendFetch } from "../backend-helpers"; + +export function createDefaultPaymentsConfig(testMode: boolean | undefined) { + return { + payments: { + testMode: testMode ?? true, + products: { + "otp-product": { + displayName: "One-Time Product", + customerType: "user", + serverOnly: false, + stackable: false, + prices: { + single: { USD: "5000" }, + }, + includedItems: {}, + }, + }, + items: {}, + }, + }; +} + +export async function setupProjectWithPaymentsConfig(options: { testMode?: boolean } = {}) { + await Project.createAndSwitch(); + await Payments.setup(); + const config = createDefaultPaymentsConfig(options.testMode); + await Project.updateConfig(config); + return config; +} + +export async function createPurchaseCode(options: { userId: string, productId: string }) { + const res = await niceBackendFetch("/api/latest/payments/purchases/create-purchase-url", { + method: "POST", + accessType: "client", + body: { + customer_type: "user", + customer_id: options.userId, + product_id: options.productId, + }, + }); + expect(res.status).toBe(200); + const codeMatch = (res.body.url as string).match(/\/purchase\/([a-z0-9-_]+)/); + const code = codeMatch ? codeMatch[1] : undefined; + expect(code).toBeDefined(); + return code as string; +} + +export async function createTestModeTransaction(productId: string, priceId: string) { + const { userId } = await Auth.fastSignUp(); + const code = await createPurchaseCode({ userId, productId }); + const response = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", { + accessType: "admin", + method: "POST", + body: { full_code: code, price_id: priceId, quantity: 1 }, + }); + expect(response.status).toBe(200); + const transactions = await niceBackendFetch("/api/latest/internal/payments/transactions", { + accessType: "admin", + }); + expect(transactions.status).toBe(200); + expect(transactions.body.transactions.length).toBeGreaterThan(0); + const transaction = transactions.body.transactions[0]; + return { transactionId: transaction.id, userId }; +} + +export async function createLiveModeOneTimePurchaseTransaction(options: { quantity?: number } = {}) { + const config = await setupProjectWithPaymentsConfig({ testMode: false }); + const { userId } = await Auth.fastSignUp(); + const quantity = options.quantity ?? 1; + + const accountInfo = await niceBackendFetch("/api/latest/internal/payments/stripe/account-info", { + accessType: "admin", + }); + expect(accountInfo.status).toBe(200); + const accountId: string = accountInfo.body.account_id; + + const code = await createPurchaseCode({ userId, productId: "otp-product" }); + const stackTestTenancyId = code.split("_")[0]; + const product = config.payments.products["otp-product"]; + + const idSuffix = randomUUID().replace(/-/g, ""); + const eventId = `evt_otp_purchase_${idSuffix}`; + const paymentIntentId = `pi_otp_purchase_${idSuffix}`; + const paymentIntentPayload = { + id: eventId, + type: "payment_intent.succeeded", + account: accountId, + data: { + object: { + id: paymentIntentId, + customer: userId, + stack_stripe_mock_data: { + "accounts.retrieve": { metadata: { tenancyId: stackTestTenancyId } }, + "customers.retrieve": { metadata: { customerId: userId, customerType: "USER" } }, + "subscriptions.list": { data: [] }, + }, + metadata: { + productId: "otp-product", + product: JSON.stringify(product), + customerId: userId, + customerType: "user", + purchaseQuantity: String(quantity), + purchaseKind: "ONE_TIME", + priceId: "single", + }, + }, + }, + }; + + const webhookSecret = getEnvVariable("STACK_STRIPE_WEBHOOK_SECRET", "mock_stripe_webhook_secret"); + const webhookRes = await Payments.sendStripeWebhook(paymentIntentPayload, { secret: webhookSecret }); + expect(webhookRes.status).toBe(200); + expect(webhookRes.body).toEqual({ received: true }); + + const transactionsRes = await niceBackendFetch("/api/latest/internal/payments/transactions", { + accessType: "admin", + }); + expect(transactionsRes.status).toBe(200); + + const purchaseTransaction = transactionsRes.body.transactions.find((tx: any) => tx.type === "purchase"); + expect(purchaseTransaction).toBeDefined(); + + return { userId, transactionsRes, purchaseTransaction }; +}