From 98231197d6f679570ac09054dd2950f4b9a548fe Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Thu, 23 Apr 2026 13:20:36 -0700 Subject: [PATCH 01/10] feat(payments): collect 0.9% platform fee on every stripe money movement Charges the platform 0.9% on both legs of each transaction on non-internal projects. The charge leg rides along via Stripe's native `application_fee_amount` / `application_fee_percent` params on the PaymentIntent / Subscription. The refund leg cannot use Stripe's built-in fee handling (Stripe's default is to reverse our charge-leg fee on refund, netting us zero) so we disable that with `refund_application_fee: false` and collect the refund-leg fee via an inverse Connect transfer. The inverse-transfer path is fire-and-forget from the refund route so a fee-collection failure never blocks the end-customer's refund. Every attempt writes a durable `PlatformFeeEvent` ledger row first, upserting on `(sourceType, sourceId)` so replayed refunds cannot double-record. On retry beyond Stripe's 24h idempotency-key window we reconcile via a content-addressed `transfer_group` lookup so we don't double-debit the merchant. FAILED rows keep a Sentry-captured error string so ops can see what's stuck via the admin `/platform-fees` endpoint. Merchant Connect accounts now onboard with `debit_negative_balances: true` so Stripe can ACH-debit the merchant's linked bank when their balance goes negative from settlement events (payouts, chargebacks). Inverse transfers themselves still hard-fail on insufficient balance and land in the ledger as FAILED for manual reconciliation. --- apps/backend/.env | 1 + apps/backend/.env.development | 1 + .../migration.sql | 30 ++ apps/backend/prisma/schema.prisma | 34 ++ .../internal/payments/platform-fees/route.ts | 75 +++++ .../latest/internal/payments/setup/route.ts | 16 + .../payments/transactions/refund/route.tsx | 28 +- .../[customer_id]/switch/route.ts | 9 + .../purchases/purchase-session/route.tsx | 25 ++ .../backend/src/lib/payments/platform-fees.ts | 299 ++++++++++++++++++ .../api/v1/internal/platform-fees.test.ts | 220 +++++++++++++ .../v1/internal/transactions-refund.test.ts | 141 +-------- apps/e2e/tests/backend/helpers/payments.ts | 240 ++++++++++++++ 13 files changed, 982 insertions(+), 137 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260422000000_add_platform_fee_event/migration.sql create mode 100644 apps/backend/src/app/api/latest/internal/payments/platform-fees/route.ts create mode 100644 apps/backend/src/lib/payments/platform-fees.ts create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/internal/platform-fees.test.ts create mode 100644 apps/e2e/tests/backend/helpers/payments.ts diff --git a/apps/backend/.env b/apps/backend/.env index 228c065825..2e3dc21e4c 100644 --- a/apps/backend/.env +++ b/apps/backend/.env @@ -113,6 +113,7 @@ STACK_OPENAI_API_KEY=# enter your openai api key STACK_FEATUREBASE_API_KEY=# enter your featurebase api key STACK_STRIPE_SECRET_KEY=# enter your stripe api key STACK_STRIPE_WEBHOOK_SECRET=# enter your stripe webhook secret +STACK_STRIPE_PLATFORM_ACCOUNT_ID=# enter your platform stripe account id (acct_...), destination for Connect inverse transfers STACK_TELEGRAM_BOT_TOKEN= # enter you telegram bot token STACK_TELEGRAM_CHAT_ID=# enter your telegram chat id diff --git a/apps/backend/.env.development b/apps/backend/.env.development index f20581e078..0ea3dab048 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -76,6 +76,7 @@ STACK_VERCEL_SANDBOX_TOKEN=vercel_sandbox_disabled_for_local_development STACK_OPENAI_API_KEY=mock_openai_api_key STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret +STACK_STRIPE_PLATFORM_ACCOUNT_ID=acct_mock_platform STACK_OPENROUTER_API_KEY=FORWARD_TO_PRODUCTION STACK_FEEDBACK_MODE=FORWARD_TO_PRODUCTION STACK_MINTLIFY_MCP_URL=https://stackauth-e0affa27.mintlify.app/mcp diff --git a/apps/backend/prisma/migrations/20260422000000_add_platform_fee_event/migration.sql b/apps/backend/prisma/migrations/20260422000000_add_platform_fee_event/migration.sql new file mode 100644 index 0000000000..b0e4d7445e --- /dev/null +++ b/apps/backend/prisma/migrations/20260422000000_add_platform_fee_event/migration.sql @@ -0,0 +1,30 @@ +-- CreateTable +CREATE TABLE "PlatformFeeEvent" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "tenancyId" UUID NOT NULL, + "projectId" TEXT NOT NULL, + "sourceType" TEXT NOT NULL CHECK ("sourceType" IN ('REFUND')), + "sourceId" TEXT NOT NULL, + "amount" INTEGER NOT NULL CHECK ("amount" >= 0), + "currency" TEXT NOT NULL, + "status" TEXT NOT NULL CHECK ("status" IN ('PENDING', 'COLLECTED', 'FAILED')), + "stripeTransferId" TEXT, + "error" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "collectedAt" TIMESTAMP(3), + + CONSTRAINT "PlatformFeeEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "PlatformFeeEvent_sourceType_sourceId_key" ON "PlatformFeeEvent"("sourceType", "sourceId"); + +-- CreateIndex +CREATE INDEX "PlatformFeeEvent_tenancyId_idx" ON "PlatformFeeEvent"("tenancyId"); + +-- CreateIndex +CREATE INDEX "PlatformFeeEvent_projectId_idx" ON "PlatformFeeEvent"("projectId"); + +-- CreateIndex +CREATE INDEX "PlatformFeeEvent_status_idx" ON "PlatformFeeEvent"("status"); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 1b20c77b17..0898bbc18b 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -1312,6 +1312,40 @@ model SubscriptionInvoice { @@unique([tenancyId, stripeInvoiceId]) } +// Ledger of platform fees collected via inverse Connect transfers (e.g. our +// 0.9% cut of refund outflows). Each row is keyed by the originating Stripe +// event so collection is idempotent under webhook / handler retry. +model PlatformFeeEvent { + id String @id @default(uuid()) @db.Uuid + + tenancyId String @db.Uuid + projectId String + + // Discriminator for the source event. Currently only REFUND; additional + // sources (e.g. manual adjustments) may be added later. + sourceType String + // Stripe ID of the originating event — refund id for REFUND. Unique with + // sourceType to make fee collection idempotent. + sourceId String + + amount Int + currency String + + // PENDING | COLLECTED | FAILED + status String + stripeTransferId String? + error String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + collectedAt DateTime? + + @@unique([sourceType, sourceId]) + @@index([tenancyId]) + @@index([projectId]) + @@index([status]) +} + model OutgoingRequest { id String @id @default(uuid()) @db.Uuid diff --git a/apps/backend/src/app/api/latest/internal/payments/platform-fees/route.ts b/apps/backend/src/app/api/latest/internal/payments/platform-fees/route.ts new file mode 100644 index 0000000000..7acdd8db60 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/payments/platform-fees/route.ts @@ -0,0 +1,75 @@ +import { PlatformFeeStatus } from "@/lib/payments/platform-fees"; +import { globalPrismaClient } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; + +export const GET = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + project: adaptSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + // Sum of every platform fee event not yet marked COLLECTED, in + // USD stripe-units (cents). Expands to a keyed map when multi-currency + // support is introduced. + total_due_usd: yupNumber().defined(), + events: yupArray( + yupObject({ + id: yupString().defined(), + source_type: yupString().defined(), + source_id: yupString().defined(), + amount: yupNumber().defined(), + currency: yupString().defined(), + status: yupString().defined(), + stripe_transfer_id: yupString().nullable().defined(), + error: yupString().nullable().defined(), + created_at: yupString().defined(), + collected_at: yupString().nullable().defined(), + }).defined(), + ).defined(), + }).defined(), + }), + handler: async ({ auth }) => { + // TODO: pagination. Low-priority today (volume per tenancy is small), but + // merchants with high refund throughput will eventually accumulate + // thousands of rows here. Add cursor-based pagination before shipping a + // merchant-visible UI. + const events = await globalPrismaClient.platformFeeEvent.findMany({ + where: { tenancyId: auth.tenancy.id }, + orderBy: { createdAt: "desc" }, + }); + + const totalDueUsdUnits = events + .filter((e) => e.status !== PlatformFeeStatus.COLLECTED && e.currency === "usd") + .reduce((sum, e) => sum + e.amount, 0); + + return { + statusCode: 200, + bodyType: "json", + body: { + total_due_usd: totalDueUsdUnits, + events: events.map((e) => ({ + id: e.id, + source_type: e.sourceType, + source_id: e.sourceId, + amount: e.amount, + currency: e.currency, + status: e.status, + stripe_transfer_id: e.stripeTransferId, + error: e.error, + created_at: e.createdAt.toISOString(), + collected_at: e.collectedAt?.toISOString() ?? null, + })), + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/payments/setup/route.ts b/apps/backend/src/app/api/latest/internal/payments/setup/route.ts index 7d0159faa5..ddd20a4bb2 100644 --- a/apps/backend/src/app/api/latest/internal/payments/setup/route.ts +++ b/apps/backend/src/app/api/latest/internal/payments/setup/route.ts @@ -50,6 +50,22 @@ export const POST = createSmartRouteHandler({ transfers: { requested: true }, }, country: "US", + // `debit_negative_balances` lets Stripe ACH-debit the merchant's + // linked bank when their Stripe balance goes negative due to payouts, + // chargebacks, or other settlement events. It does NOT let our + // `stripe.transfers.create` push a connected account into a negative + // balance on its own — transfers hard-fail on insufficient balance + // and land in the PlatformFeeEvent ledger with status=FAILED for + // manual reconciliation. We still enable this setting so that merchant + // balances that *would* go negative for other reasons (e.g. their own + // refunds running ahead of incoming payments) are covered automatically. + // Refs: https://docs.stripe.com/connect/account-debits + // https://docs.stripe.com/connect/account-balances#negative-balances + settings: { + payouts: { + debit_negative_balances: true, + }, + }, metadata: { tenancyId: auth.tenancy.id, } 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..db7662f851 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 @@ -1,9 +1,11 @@ import { buildOneTimePurchaseTransaction, buildSubscriptionTransaction, resolveSelectedPriceFromProduct } from "@/app/api/latest/internal/payments/transactions/transaction-builder"; import { bulldozerWriteManualTransaction, bulldozerWriteOneTimePurchase, bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; +import { collectInverseFee, PlatformFeeSourceType } from "@/lib/payments/platform-fees"; import type { ManualTransactionRow } from "@/lib/payments/schema/types"; import { getStripeForAccount } from "@/lib/stripe"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import type { TransactionEntry } from "@stackframe/stack-shared/dist/interface/crud/transactions"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; import { adaptSchema, adminAuthTypeSchema, moneyAmountSchema, productSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; @@ -262,10 +264,24 @@ export const POST = createSmartRouteHandler({ if (refundAmountStripeUnits > totalStripeUnits) { throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount."); } - await stripe.refunds.create({ + const subscriptionRefund = await stripe.refunds.create({ payment_intent: paymentIntentId, amount: refundAmountStripeUnits, + // Keep the charge-leg application fee with the platform. Stripe's + // default would reverse it proportionally, cancelling out our 0.9% + // cut on the original charge. + refund_application_fee: false, }); + // Fee collection is best-effort and the originating refund has already + // succeeded — don't block the response on the inverse transfer. The + // helper has its own durable ledger + Sentry capture for failures. + runAsynchronously(collectInverseFee({ + tenancy: auth.tenancy, + amountStripeUnits: refundAmountStripeUnits, + currency: "usd", + sourceType: PlatformFeeSourceType.REFUND, + sourceId: subscriptionRefund.id, + })); const refundedAt = new Date(); if (refundedQuantity > 0) { if (!subscription.stripeSubscriptionId) { @@ -363,14 +379,22 @@ export const POST = createSmartRouteHandler({ if (refundAmountStripeUnits > totalStripeUnits) { throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount."); } - await stripe.refunds.create({ + const purchaseRefund = await stripe.refunds.create({ payment_intent: purchase.stripePaymentIntentId, amount: refundAmountStripeUnits, metadata: { tenancyId: auth.tenancy.id, purchaseId: purchase.id, }, + refund_application_fee: false, }); + runAsynchronously(collectInverseFee({ + tenancy: auth.tenancy, + amountStripeUnits: refundAmountStripeUnits, + currency: "usd", + sourceType: PlatformFeeSourceType.REFUND, + sourceId: purchaseRefund.id, + })); const refundedAt = new Date(); await prisma.oneTimePurchase.update({ where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } }, 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..fc15bae917 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,22 @@ export const POST = createSmartRouteHandler({ const existingItem = existingStripeSub.items.data[0]; const product = await stripe.products.create({ name: data.product.displayName ?? "Subscription" }); if (selectedPrice.interval) { + // TODO (platform-fees): this is a plan-switch mid-cycle that returns + // `latest_invoice.confirmation_secret`, so an upgrade/proration invoice + // is created synchronously. `application_fee_percent` is applied to + // invoices generated from the subscription's normal billing cycle, but + // per Stripe's subscription/proration docs the immediately-generated + // upgrade invoice may not inherit the newly-set fee percent. Our + // charge-leg guarantee for this specific invoice is therefore + // best-effort until we either (a) observe the behaviour against a real + // onboarded Connect account, or (b) listen for the resulting + // `invoice.created` webhook and stamp `application_fee_amount` on the + // invoice before it finalises. Refund-leg collection (via + // `collectInverseFee`) is unaffected and still works on the full + // refund amount regardless. + // Refs: https://docs.stripe.com/connect/subscriptions + // https://docs.stripe.com/billing/subscriptions/prorations + 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 +131,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 +163,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 +182,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 +195,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 +218,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..0a1cb063ec --- /dev/null +++ b/apps/backend/src/lib/payments/platform-fees.ts @@ -0,0 +1,299 @@ +import { getStackStripe } from "@/lib/stripe"; +import type { Tenancy } from "@/lib/tenancies"; +import { globalPrismaClient } from "@/prisma-client"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import Stripe from "stripe"; + +function stripeErrorContext(err: unknown): Record { + if (err instanceof Stripe.errors.StripeError) { + return { + stripeErrCode: err.code, + stripeErrType: err.type, + stripeRequestId: err.requestId, + stripeStatusCode: err.statusCode, + stripeErrMessage: err.message, + }; + } + return { error: err }; +} + +// 0.9% of every Stripe money movement on a non-internal project is collected +// as a platform fee. Charge-leg fees ride along via Stripe's native +// application_fee_* params; outflow-leg fees (e.g. refunds) are collected via +// inverse Connect transfers — see collectInverseFee below. +export const APPLICATION_FEE_BPS = 90; + +const INTERNAL_PROJECT_ID = "internal"; + +export const PlatformFeeStatus = { + PENDING: "PENDING", + COLLECTED: "COLLECTED", + FAILED: "FAILED", +} as const; +export type PlatformFeeStatus = typeof PlatformFeeStatus[keyof typeof PlatformFeeStatus]; + +export const PlatformFeeSourceType = { + REFUND: "REFUND", +} as const; +export type PlatformFeeSourceType = typeof PlatformFeeSourceType[keyof typeof PlatformFeeSourceType]; + +export function getApplicationFeeBps(projectId: string): number { + if (projectId === INTERNAL_PROJECT_ID) return 0; + return APPLICATION_FEE_BPS; +} + +export function computeApplicationFeeAmount(options: { amountStripeUnits: number, projectId: string }): number { + const bps = getApplicationFeeBps(options.projectId); + if (bps === 0) return 0; + return Math.round(options.amountStripeUnits * bps / 10000); +} + +export function getApplicationFeePercentOrUndefined(projectId: string): number | undefined { + const bps = getApplicationFeeBps(projectId); + if (bps === 0) return undefined; + return bps / 100; +} + +/** + * Collect an inverse platform fee for an outflow event (e.g. a refund). + * + * Contract: this function **never throws**. It is designed to be fire-and-forget + * from the callsite via `runAsynchronously(...)`. Any config / lookup / Stripe / + * DB error results in a durable PlatformFeeEvent row with `status = FAILED` and + * a descriptive error message, plus a Sentry event. Callers may treat the + * originating money movement as already-succeeded regardless of outcome here. + */ +export async function collectInverseFee(options: { + tenancy: Tenancy, + amountStripeUnits: number, + currency: string, + sourceType: PlatformFeeSourceType, + sourceId: string, +}): Promise { + try { + await collectInverseFeeInner(options); + } catch (err) { + // Last-resort catch: the inner function is engineered to always return + // normally, but if a DB lookup or other helper throws before we can write + // a ledger row, we still need to avoid surfacing the error to the caller. + captureError("collect-inverse-fee-unexpected", new StackAssertionError( + "Unexpected error in collectInverseFee — ledger state may be missing for this refund", + { + sourceType: options.sourceType, + sourceId: options.sourceId, + tenancyId: options.tenancy.id, + ...stripeErrorContext(err), + } + )); + } +} + +async function collectInverseFeeInner(options: { + tenancy: Tenancy, + amountStripeUnits: number, + currency: string, + sourceType: PlatformFeeSourceType, + sourceId: string, +}): Promise { + // Explicit invariant: multi-currency fee aggregation isn't built out yet, so + // we assert here rather than silently miscategorising a non-USD refund as a + // USD due in the ledger. + if (options.currency !== "usd") { + throw new StackAssertionError("collectInverseFee currently only supports usd", { + currency: options.currency, + sourceType: options.sourceType, + sourceId: options.sourceId, + }); + } + + const projectId = options.tenancy.project.id; + const feeAmount = computeApplicationFeeAmount({ amountStripeUnits: options.amountStripeUnits, projectId }); + if (feeAmount <= 0) return; + + // Write the ledger row FIRST, before any config / lookup / Stripe call. + // This guarantees durable state exists for every fee event — config failures + // and account-lookup failures are recorded as FAILED rows that ops can see + // and retry, rather than being silently dropped. + const ledgerKey = { sourceType_sourceId: { sourceType: options.sourceType, sourceId: options.sourceId } }; + const ledgerRow = await globalPrismaClient.platformFeeEvent.upsert({ + where: ledgerKey, + create: { + tenancyId: options.tenancy.id, + projectId, + sourceType: options.sourceType, + sourceId: options.sourceId, + amount: feeAmount, + currency: options.currency, + status: PlatformFeeStatus.PENDING, + }, + update: {}, + }); + if (ledgerRow.status === PlatformFeeStatus.COLLECTED) return; + + const platformAccountId = getEnvVariable("STACK_STRIPE_PLATFORM_ACCOUNT_ID", ""); + if (!platformAccountId) { + await markLedgerFailed(ledgerKey, "STACK_STRIPE_PLATFORM_ACCOUNT_ID not set"); + captureError("collect-inverse-fee", new StackAssertionError( + "STACK_STRIPE_PLATFORM_ACCOUNT_ID not set; inverse fee collection skipped", + { sourceType: options.sourceType, sourceId: options.sourceId, tenancyId: options.tenancy.id } + )); + return; + } + + const project = await globalPrismaClient.project.findUnique({ + where: { id: projectId }, + select: { stripeAccountId: true }, + }); + const stripeAccountId = project?.stripeAccountId; + if (!stripeAccountId) { + await markLedgerFailed(ledgerKey, "Project has no stripeAccountId"); + captureError("collect-inverse-fee", new StackAssertionError( + "Project has no stripeAccountId; cannot collect inverse fee", + { sourceType: options.sourceType, sourceId: options.sourceId, projectId } + )); + return; + } + + const platformStripe = getStackStripe(); + // `transfer_group` is our durable reconciliation key. Stripe's + // `idempotencyKey` only dedupes within ~24h, so a retry *after* the key + // expires (ledger-update-failure scenario) would otherwise create a second + // transfer and double-debit the merchant. By tagging every transfer with a + // stable, content-addressed `transfer_group` derived from `(sourceType, + // sourceId)` we can look the transfer up on Stripe on retry and reconcile + // instead of creating a new one. + const transferGroup = `platform-fee-${options.sourceType}-${options.sourceId}`; + + // Retry reconciliation: if a prior attempt on this sourceId left the ledger + // without a stripeTransferId (the transfer might have succeeded but our + // ledger-update crashed), list transfers on the merchant's account for this + // transfer_group and use the pre-existing transfer if we find one. + if (!ledgerRow.stripeTransferId) { + try { + const existing = await platformStripe.transfers.list( + { transfer_group: transferGroup, limit: 1 }, + { stripeAccount: stripeAccountId }, + ); + if (existing.data.length > 0) { + const pre = existing.data[0]; + await globalPrismaClient.platformFeeEvent.update({ + where: ledgerKey, + data: { + status: PlatformFeeStatus.COLLECTED, + stripeTransferId: pre.id, + collectedAt: new Date(pre.created * 1000), + error: null, + }, + }); + return; + } + } catch (searchErr) { + captureError("collect-inverse-fee-search", new StackAssertionError( + "Failed to search Stripe for existing platform fee transfer before retry — proceeding with idempotent create", + { sourceType: options.sourceType, sourceId: options.sourceId, ...stripeErrorContext(searchErr) } + )); + // Fall through: the idempotency key still gives us 24h of safety on the + // near-term retry path; the reconciliation only matters beyond that. + } + } + + let transferId: string; + try { + // Transfer from the connected account's Stripe balance back to the + // platform. Executed AS the connected account (stripeAccount header) with + // destination set to our platform account ID. + const transfer = await platformStripe.transfers.create( + { + amount: feeAmount, + currency: options.currency, + destination: platformAccountId, + transfer_group: transferGroup, + metadata: { + platformFeeSourceType: options.sourceType, + platformFeeSourceId: options.sourceId, + platformFeeTenancyId: options.tenancy.id, + }, + }, + { + stripeAccount: stripeAccountId, + idempotencyKey: transferGroup, + }, + ); + transferId = transfer.id; + } catch (stripeErr) { + captureError("collect-inverse-fee", new StackAssertionError( + "Failed to collect inverse platform fee", + { + sourceType: options.sourceType, + sourceId: options.sourceId, + tenancyId: options.tenancy.id, + ...stripeErrorContext(stripeErr), + } + )); + await markLedgerFailed(ledgerKey, stripeErr instanceof Error ? stripeErr.message : String(stripeErr)); + return; + } + + try { + await globalPrismaClient.platformFeeEvent.update({ + where: ledgerKey, + data: { + status: PlatformFeeStatus.COLLECTED, + stripeTransferId: transferId, + collectedAt: new Date(), + // Clear any error from a previous failed attempt so ops / the + // listing endpoint don't surface stale failure reasons. + error: null, + }, + }); + } catch (dbErr) { + // The money was collected but we couldn't record it. Log loudly — someone + // will need to reconcile the ledger row against the Stripe transfer id. + captureError("collect-inverse-fee-ledger-write", new StackAssertionError( + "Stripe transfer succeeded but ledger update failed — manual reconciliation needed", + { sourceType: options.sourceType, sourceId: options.sourceId, transferId, dbErr } + )); + } +} + +async function markLedgerFailed( + where: { sourceType_sourceId: { sourceType: string, sourceId: string } }, + error: string, +): Promise { + try { + await globalPrismaClient.platformFeeEvent.update({ + where, + data: { status: PlatformFeeStatus.FAILED, error }, + }); + } catch (dbErr) { + captureError("collect-inverse-fee-ledger-write", new StackAssertionError( + "Failed to record FAILED status on platform fee event", + { where, originalError: error, dbErr } + )); + } +} + +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", ({ 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 is 0 for internal project", ({ expect }) => { + expect(computeApplicationFeeAmount({ amountStripeUnits: 10000, projectId: "internal" })).toBe(0); + }); + 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/platform-fees.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/platform-fees.test.ts new file mode 100644 index 0000000000..e1ac7b0fcf --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/platform-fees.test.ts @@ -0,0 +1,220 @@ +import { expect } from "vitest"; +import { it } from "../../../../../helpers"; +import { Auth, Payments, Project, niceBackendFetch } from "../../../../backend-helpers"; +import { + createLiveModeOneTimePurchaseTransaction, + createLiveModeSubscriptionTransaction, + createPurchaseCode, +} from "../../../../helpers/payments"; + +// `amount_usd: "5000"` in refund_entries is parsed as 5000 stripe-units (= $50), +// so 0.9% = 45. Partial refund "1250" = 1250 stripe-units, 0.9% = round(11.25) = 11. +const EXPECTED_REFUND_FEE_STRIPE_UNITS = 45; +const EXPECTED_PARTIAL_REFUND_FEE_STRIPE_UNITS = 11; + +/** + * `collectInverseFee` is intentionally fire-and-forget via `runAsynchronously` + * in the refund route, so the refund response returns before the ledger row + * is written / reaches a terminal status. Tests must poll instead of asserting + * immediately after the refund response. + */ +async function waitForPlatformFeeEvent(options: { terminal?: boolean } = {}) { + const { terminal = true } = options; + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const res = await niceBackendFetch("/api/latest/internal/payments/platform-fees", { + accessType: "admin", + }); + expect(res.status).toBe(200); + const events = res.body.events as Array<{ status: string }>; + if (events.length > 0) { + if (!terminal) return res; + if (events.every((e) => e.status === "COLLECTED" || e.status === "FAILED")) { + return res; + } + } + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error("Timed out waiting for PlatformFeeEvent to reach a terminal status"); +} + +it("records a COLLECTED PlatformFeeEvent when a live-mode OTP is refunded", async () => { + const { purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction(); + + const beforeRes = await niceBackendFetch("/api/latest/internal/payments/platform-fees", { + accessType: "admin", + }); + expect(beforeRes.status).toBe(200); + expect(beforeRes.body.events).toHaveLength(0); + expect(beforeRes.body.total_due_usd).toBe(0); + + const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { + accessType: "admin", + method: "POST", + body: { + type: "one-time-purchase", + id: purchaseTransaction.id, + refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "5000" }], + }, + }); + expect(refundRes.status).toBe(200); + + const afterRes = await waitForPlatformFeeEvent(); + expect(afterRes.body.events).toHaveLength(1); + const event = afterRes.body.events[0]; + expect(event.source_type).toBe("REFUND"); + expect(event.amount).toBe(EXPECTED_REFUND_FEE_STRIPE_UNITS); + expect(event.currency).toBe("usd"); + expect(event.status).toBe("COLLECTED"); + expect(event.stripe_transfer_id).not.toBeNull(); + // total_due_usd excludes COLLECTED rows. + expect(afterRes.body.total_due_usd).toBe(0); +}); + +it("collects proportional fee on a partial refund", async () => { + const { purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction(); + + const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { + accessType: "admin", + method: "POST", + body: { + type: "one-time-purchase", + id: purchaseTransaction.id, + refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "1250" }], + }, + }); + expect(refundRes.status).toBe(200); + + const feesRes = await waitForPlatformFeeEvent(); + expect(feesRes.body.events).toHaveLength(1); + expect(feesRes.body.events[0].amount).toBe(EXPECTED_PARTIAL_REFUND_FEE_STRIPE_UNITS); + expect(feesRes.body.events[0].status).toBe("COLLECTED"); +}); + +it("does not record a fee on a test-mode refund attempt", async () => { + await Project.createAndSwitch(); + await Payments.setup(); + await Project.updateConfig({ + payments: { + testMode: true, + products: { + "otp-product": { + displayName: "One-Time Product", + customerType: "user", + serverOnly: false, + stackable: false, + prices: { single: { USD: "5000" } }, + includedItems: {}, + }, + }, + items: {}, + }, + }); + + const { userId } = await Auth.fastSignUp(); + const code = await createPurchaseCode({ userId, productId: "otp-product" }); + const sessionRes = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", { + accessType: "admin", + method: "POST", + body: { full_code: code, price_id: "single", quantity: 1 }, + }); + expect(sessionRes.status).toBe(200); + + const transactions = await niceBackendFetch("/api/latest/internal/payments/transactions", { + accessType: "admin", + }); + const transactionId = transactions.body.transactions[0].id; + + const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { + accessType: "admin", + method: "POST", + body: { + type: "one-time-purchase", + id: transactionId, + refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "5000" }], + }, + }); + // Test-mode OTP refunds are rejected upstream; no Stripe call, no fee row. + expect(refundRes.body.code).toBe("TEST_MODE_PURCHASE_NON_REFUNDABLE"); + + const feesRes = await niceBackendFetch("/api/latest/internal/payments/platform-fees", { + accessType: "admin", + }); + expect(feesRes.status).toBe(200); + expect(feesRes.body.events).toHaveLength(0); +}); + +// TODO(platform-fees): this test covers only the *refund endpoint's* already- +// refunded rejection; it does NOT exercise the helper's sourceId idempotency +// path (calling collectInverseFee twice with the same sourceId and asserting +// one row + one Stripe transfer). That requires either a direct helper-level +// test with a shared-context stripe client (not wired in this repo) or an +// admin retry endpoint. Tracking separately. +it("refund endpoint rejects a second refund for the same purchase", async () => { + const { purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction(); + + const firstRefund = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { + accessType: "admin", + method: "POST", + body: { + type: "one-time-purchase", + id: purchaseTransaction.id, + refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "5000" }], + }, + }); + expect(firstRefund.status).toBe(200); + // Wait for the first fee row to land before firing the second refund so the + // assertion below is unambiguous about "only one row exists". + await waitForPlatformFeeEvent(); + + const secondRefund = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { + accessType: "admin", + method: "POST", + body: { + type: "one-time-purchase", + id: purchaseTransaction.id, + refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "5000" }], + }, + }); + expect(secondRefund.body.code).toBe("ONE_TIME_PURCHASE_ALREADY_REFUNDED"); + + const feesRes = await niceBackendFetch("/api/latest/internal/payments/platform-fees", { + accessType: "admin", + }); + expect(feesRes.body.events).toHaveLength(1); +}); + +// Skipped against stripe-mock: the subscription refund path calls +// `stripe.invoices.retrieve(id, { expand: ["payments"] })` at refund time and +// expects `payments.data` with a paid payment carrying a `payment_intent`. +// stripe-mock returns its default invoice fixture which doesn't populate +// payments, and the mock-override plumbing (`stack_stripe_mock_data`) is +// webhook-time-only — it doesn't propagate to unrelated API calls made later. +// +// TODO(platform-fees): close this coverage gap via one of — +// (a) patch stripe-mock to echo a paid payment on invoices.retrieve when a +// sibling payment_intent.succeeded webhook was previously replayed for +// the same invoice, +// (b) thread `stack_stripe_mock_data` overrides through `getStripeForAccount` +// on the refund path so tests can stub invoices.retrieve, +// (c) run this under a real-Stripe CI job with an onboarded connected +// account (matches the manual-QA path in this PR's description). +it.skip("records a PlatformFeeEvent when a live-mode subscription is refunded", async () => { + const { subscriptionTransaction } = await createLiveModeSubscriptionTransaction(); + + const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { + accessType: "admin", + method: "POST", + body: { + type: "subscription", + id: subscriptionTransaction.id, + refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "1000" }], + }, + }); + expect(refundRes.status).toBe(200); + + const feesRes = await waitForPlatformFeeEvent(); + expect(feesRes.body.events).toHaveLength(1); + // 0.9% of 1000 stripe-units ($10) = 9 stripe-units. + expect(feesRes.body.events[0].amount).toBe(9); +}); 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..332b373773 --- /dev/null +++ b/apps/e2e/tests/backend/helpers/payments.ts @@ -0,0 +1,240 @@ +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: { + "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: {}, + }, + }; +} + +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_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 }; +} + +/** + * Sets up a live-mode subscription by injecting an invoice.paid webhook with + * billing_reason=subscription_create. After this, the tenancy DB has a + * Subscription row and a SubscriptionInvoice row marked as the creation + * invoice, which is what the refund endpoint's subscription path expects. + */ +export async function createLiveModeSubscriptionTransaction() { + const config = await setupProjectWithPaymentsConfig({ testMode: false }); + const { userId } = await Auth.fastSignUp(); + + 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: "sub-product" }); + const stackTestTenancyId = code.split("_")[0]; + const product = config.payments.products["sub-product"]; + + const idSuffix = randomUUID().replace(/-/g, ""); + const stripeSubscriptionId = `sub_live_refund_${idSuffix}`; + const stripeInvoiceId = `in_live_refund_${idSuffix}`; + const stripeCustomerId = `cus_live_refund_${idSuffix}`; + const nowSec = Math.floor(Date.now() / 1000); + + const subscription = { + id: stripeSubscriptionId, + status: "active", + items: { + data: [ + { + id: `si_live_refund_${idSuffix}`, + quantity: 1, + current_period_start: nowSec - 60, + current_period_end: nowSec + 60 * 60 * 24 * 30, + }, + ], + }, + metadata: { + productId: "sub-product", + product: JSON.stringify(product), + priceId: "monthly", + }, + cancel_at_period_end: false, + }; + + const invoice = { + id: stripeInvoiceId, + customer: stripeCustomerId, + billing_reason: "subscription_create", + status: "paid", + total: 100000, + hosted_invoice_url: `https://example.test/invoice/${stripeInvoiceId}`, + lines: { + data: [ + { + parent: { + subscription_item_details: { + subscription: stripeSubscriptionId, + }, + }, + }, + ], + }, + stack_stripe_mock_data: { + "accounts.retrieve": { metadata: { tenancyId: stackTestTenancyId } }, + "customers.retrieve": { metadata: { customerId: userId, customerType: "USER" } }, + "subscriptions.list": { data: [subscription] }, + }, + }; + + const webhookPayload = { + id: `evt_live_refund_${idSuffix}`, + type: "invoice.paid", + account: accountId, + data: { object: invoice }, + }; + + const webhookSecret = process.env.STACK_STRIPE_WEBHOOK_SECRET ?? "mock_stripe_webhook_secret"; + const webhookRes = await Payments.sendStripeWebhook(webhookPayload, { 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 subscriptionTransaction = transactionsRes.body.transactions.find( + (tx: any) => tx.type === "purchase" || tx.type === "subscription-start" + ); + expect(subscriptionTransaction).toBeDefined(); + + return { + userId, + stripeSubscriptionId, + stripeInvoiceId, + subscriptionTransaction, + transactionsRes, + }; +} From 321bafbaf2cbd54dd33951b6d8b9e49f2278af12 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Thu, 23 Apr 2026 13:21:40 -0700 Subject: [PATCH 02/10] fix(payments): don't fall through to transfers.create when reconciliation finds existing transfer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retry-reconciliation block in `collectInverseFeeInner` wraps two distinct Stripe/DB calls in one try/catch. When Stripe's `transfers.list` finds a pre-existing transfer for this `transfer_group` but the subsequent `platformFeeEvent.update` throws (transient DB error, etc.), the shared catch swallowed the error and fell through to `transfers.create`. That's fine in-window (the idempotency key dedupes), but after the 24h idempotency-key window expires the next retry would create a second transfer on the merchant's account — double-debit. Split into two try blocks with distinct error semantics: (a) `transfers.list` fails — safe to fall through. No transfer is known to exist yet, and the idempotency key still dedupes the near-term retry path. (b) `transfers.list` succeeds and returns a pre-existing transfer, but the ledger update then fails — bail with FAILED status and return. Creating another transfer here would double-debit once the idempotency key expires. The Sentry report and ledger row both capture the pre-existing transfer id so ops can reconcile manually. Refs: https://docs.stripe.com/api/idempotent_requests (24h key pruning) https://docs.stripe.com/api/transfers/list (transfer_group filter) --- .../backend/src/lib/payments/platform-fees.ts | 51 +++++++++++++++---- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/apps/backend/src/lib/payments/platform-fees.ts b/apps/backend/src/lib/payments/platform-fees.ts index 0a1cb063ec..741bb3dde0 100644 --- a/apps/backend/src/lib/payments/platform-fees.ts +++ b/apps/backend/src/lib/payments/platform-fees.ts @@ -169,14 +169,38 @@ async function collectInverseFeeInner(options: { // without a stripeTransferId (the transfer might have succeeded but our // ledger-update crashed), list transfers on the merchant's account for this // transfer_group and use the pre-existing transfer if we find one. + // + // Two error cases are handled explicitly below; the distinction matters + // because falling through to `transfers.create` is only safe when we've + // proven no transfer exists yet: + // (a) the `transfers.list` lookup itself fails — safe to fall through: + // we don't know if a transfer exists, but the idempotency key on the + // near-term retry (24h window) still dedupes, and worst case the NEXT + // retry's reconciliation will pick up whatever we create here. + // (b) the lookup succeeds AND returns a pre-existing transfer, but the + // ledger update then fails — we MUST NOT fall through. Creating a + // second transfer now (or after the idempotency key expires on a + // later retry) would double-debit the merchant. Bail with FAILED so + // ops sees the inconsistency and can reconcile manually using the + // captured transfer id. if (!ledgerRow.stripeTransferId) { + let existing: Stripe.ApiList | null = null; try { - const existing = await platformStripe.transfers.list( + existing = await platformStripe.transfers.list( { transfer_group: transferGroup, limit: 1 }, { stripeAccount: stripeAccountId }, ); - if (existing.data.length > 0) { - const pre = existing.data[0]; + } catch (searchErr) { + captureError("collect-inverse-fee-search", new StackAssertionError( + "Failed to search Stripe for existing platform fee transfer before retry — proceeding with idempotent create", + { sourceType: options.sourceType, sourceId: options.sourceId, ...stripeErrorContext(searchErr) } + )); + // Case (a): fall through to `transfers.create`. + } + + if (existing && existing.data.length > 0) { + const pre = existing.data[0]; + try { await globalPrismaClient.platformFeeEvent.update({ where: ledgerKey, data: { @@ -186,15 +210,22 @@ async function collectInverseFeeInner(options: { error: null, }, }); + } catch (dbErr) { + // Case (b): DO NOT fall through. We know a transfer exists on Stripe + // (id: pre.id) but we couldn't record it. Mark FAILED loudly and + // return; creating another transfer here would double-debit after + // the idempotency key expires. + captureError("collect-inverse-fee-ledger-reconcile", new StackAssertionError( + "Found pre-existing Stripe transfer during retry reconciliation but ledger update failed — manual reconciliation needed to avoid double-debit on next retry", + { sourceType: options.sourceType, sourceId: options.sourceId, preExistingTransferId: pre.id, dbErr } + )); + await markLedgerFailed( + ledgerKey, + `Pre-existing Stripe transfer ${pre.id} found but ledger update failed during reconciliation; manual intervention required to avoid double-debit`, + ); return; } - } catch (searchErr) { - captureError("collect-inverse-fee-search", new StackAssertionError( - "Failed to search Stripe for existing platform fee transfer before retry — proceeding with idempotent create", - { sourceType: options.sourceType, sourceId: options.sourceId, ...stripeErrorContext(searchErr) } - )); - // Fall through: the idempotency key still gives us 24h of safety on the - // near-term retry path; the reconciliation only matters beyond that. + return; } } From 18f85600ae85977f4b24faf566d561add3442b69 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Thu, 23 Apr 2026 13:49:20 -0700 Subject: [PATCH 03/10] fix(payments): align PlatformFeeEvent id default with migration & stringify dbErr in Sentry - schema.prisma: use dbgenerated("gen_random_uuid()") to match migration's DEFAULT and fix prisma migrate diff drift in CI - platform-fees.ts: serialize dbErr to string in the case-(b) reconciliation captureError so the primary double-debit signal is readable in Sentry --- apps/backend/prisma/schema.prisma | 2 +- apps/backend/src/lib/payments/platform-fees.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 0898bbc18b..9ec5e48ffb 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -1316,7 +1316,7 @@ model SubscriptionInvoice { // 0.9% cut of refund outflows). Each row is keyed by the originating Stripe // event so collection is idempotent under webhook / handler retry. model PlatformFeeEvent { - id String @id @default(uuid()) @db.Uuid + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid tenancyId String @db.Uuid projectId String diff --git a/apps/backend/src/lib/payments/platform-fees.ts b/apps/backend/src/lib/payments/platform-fees.ts index 741bb3dde0..2f4a9a4bf5 100644 --- a/apps/backend/src/lib/payments/platform-fees.ts +++ b/apps/backend/src/lib/payments/platform-fees.ts @@ -217,7 +217,7 @@ async function collectInverseFeeInner(options: { // the idempotency key expires. captureError("collect-inverse-fee-ledger-reconcile", new StackAssertionError( "Found pre-existing Stripe transfer during retry reconciliation but ledger update failed — manual reconciliation needed to avoid double-debit on next retry", - { sourceType: options.sourceType, sourceId: options.sourceId, preExistingTransferId: pre.id, dbErr } + { sourceType: options.sourceType, sourceId: options.sourceId, preExistingTransferId: pre.id, dbErr: dbErr instanceof Error ? dbErr.message : String(dbErr) } )); await markLedgerFailed( ledgerKey, From 74f3ea0768223237da95321e339af02baf3119a6 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Thu, 23 Apr 2026 14:45:52 -0700 Subject: [PATCH 04/10] fix: fail closed on platform fee reconciliation --- .../payments/transactions/refund/route.tsx | 10 +- .../src/lib/payments/platform-fees.test.ts | 128 ++++++++++++++++++ .../backend/src/lib/payments/platform-fees.ts | 42 +++--- .../api/v1/internal/platform-fees.test.ts | 8 +- 4 files changed, 155 insertions(+), 33 deletions(-) create mode 100644 apps/backend/src/lib/payments/platform-fees.test.ts 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 db7662f851..9e56e8b7ba 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 @@ -5,7 +5,7 @@ import type { ManualTransactionRow } from "@/lib/payments/schema/types"; import { getStripeForAccount } from "@/lib/stripe"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; +import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks"; import type { TransactionEntry } from "@stackframe/stack-shared/dist/interface/crud/transactions"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; import { adaptSchema, adminAuthTypeSchema, moneyAmountSchema, productSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; @@ -273,9 +273,9 @@ export const POST = createSmartRouteHandler({ refund_application_fee: false, }); // Fee collection is best-effort and the originating refund has already - // succeeded — don't block the response on the inverse transfer. The - // helper has its own durable ledger + Sentry capture for failures. - runAsynchronously(collectInverseFee({ + // succeeded, but we still need to keep the background ledger/transfer work + // alive after the response on serverless runtimes. + runAsynchronouslyAndWaitUntil(collectInverseFee({ tenancy: auth.tenancy, amountStripeUnits: refundAmountStripeUnits, currency: "usd", @@ -388,7 +388,7 @@ export const POST = createSmartRouteHandler({ }, refund_application_fee: false, }); - runAsynchronously(collectInverseFee({ + runAsynchronouslyAndWaitUntil(collectInverseFee({ tenancy: auth.tenancy, amountStripeUnits: refundAmountStripeUnits, currency: "usd", diff --git a/apps/backend/src/lib/payments/platform-fees.test.ts b/apps/backend/src/lib/payments/platform-fees.test.ts new file mode 100644 index 0000000000..619dc4b96c --- /dev/null +++ b/apps/backend/src/lib/payments/platform-fees.test.ts @@ -0,0 +1,128 @@ +import type { Tenancy } from "@/lib/tenancies"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { collectInverseFee, PlatformFeeSourceType, PlatformFeeStatus } from "./platform-fees"; + +type PlatformFeeRow = { + tenancyId: string, + projectId: string, + sourceType: string, + sourceId: string, + amount: number, + currency: string, + status: string, + stripeTransferId: string | null, + collectedAt: Date | null, + error: string | null, +}; + +const mocks = vi.hoisted(() => ({ + rows: new Map(), + captureError: vi.fn(), + findProject: vi.fn(), + transferList: vi.fn(), + transferCreate: vi.fn(), + platformFeeEventUpsert: vi.fn(), + platformFeeEventUpdate: vi.fn(), +})); + +function rowKey(sourceType: string, sourceId: string): string { + return `${sourceType}:${sourceId}`; +} + +vi.mock("@/prisma-client", () => ({ + globalPrismaClient: { + platformFeeEvent: { + upsert: mocks.platformFeeEventUpsert, + update: mocks.platformFeeEventUpdate, + }, + project: { + findUnique: mocks.findProject, + }, + }, +})); + +vi.mock("@/lib/stripe", () => ({ + getStackStripe: () => ({ + transfers: { + list: mocks.transferList, + create: mocks.transferCreate, + }, + }), +})); + +vi.mock("@stackframe/stack-shared/dist/utils/errors", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + captureError: mocks.captureError, + }; +}); + +const tenancy = { + id: "tenancy_1", + project: { id: "project_1" }, +} as Tenancy; + +describe("collectInverseFee", () => { + const originalPlatformAccountId = process.env.STACK_STRIPE_PLATFORM_ACCOUNT_ID; + + beforeEach(() => { + mocks.rows.clear(); + mocks.captureError.mockClear(); + mocks.findProject.mockReset().mockResolvedValue({ stripeAccountId: "acct_connected" }); + mocks.transferList.mockReset(); + mocks.transferCreate.mockReset(); + mocks.platformFeeEventUpsert.mockReset().mockImplementation(async ({ where, create }) => { + const key = rowKey(where.sourceType_sourceId.sourceType, where.sourceType_sourceId.sourceId); + const existing = mocks.rows.get(key); + if (existing) return existing; + const row = { + ...create, + stripeTransferId: null, + collectedAt: null, + error: null, + }; + mocks.rows.set(key, row); + return row; + }); + mocks.platformFeeEventUpdate.mockReset().mockImplementation(async ({ where, data }) => { + const key = rowKey(where.sourceType_sourceId.sourceType, where.sourceType_sourceId.sourceId); + const existing = mocks.rows.get(key); + if (!existing) throw new Error(`missing row ${key}`); + const row = { ...existing, ...data }; + mocks.rows.set(key, row); + return row; + }); + process.env.STACK_STRIPE_PLATFORM_ACCOUNT_ID = "acct_platform"; + }); + + afterEach(() => { + if (originalPlatformAccountId === undefined) { + delete process.env.STACK_STRIPE_PLATFORM_ACCOUNT_ID; + } else { + process.env.STACK_STRIPE_PLATFORM_ACCOUNT_ID = originalPlatformAccountId; + } + }); + + it("fails closed instead of creating a transfer when Stripe reconciliation lookup fails", async () => { + mocks.transferList.mockRejectedValue(new Error("stripe list unavailable")); + + await collectInverseFee({ + tenancy, + amountStripeUnits: 10_000, + currency: "usd", + sourceType: PlatformFeeSourceType.REFUND, + sourceId: "refund_1", + }); + + expect(mocks.transferList).toHaveBeenCalledWith( + { transfer_group: "platform-fee-REFUND-refund_1", limit: 1 }, + { stripeAccount: "acct_connected" }, + ); + expect(mocks.transferCreate).not.toHaveBeenCalled(); + expect(mocks.rows.get("REFUND:refund_1")).toMatchObject({ + status: PlatformFeeStatus.FAILED, + error: expect.stringContaining("rather than risking double-debit"), + }); + }); +}); diff --git a/apps/backend/src/lib/payments/platform-fees.ts b/apps/backend/src/lib/payments/platform-fees.ts index 2f4a9a4bf5..e4163f4366 100644 --- a/apps/backend/src/lib/payments/platform-fees.ts +++ b/apps/backend/src/lib/payments/platform-fees.ts @@ -58,11 +58,11 @@ export function getApplicationFeePercentOrUndefined(projectId: string): number | /** * Collect an inverse platform fee for an outflow event (e.g. a refund). * - * Contract: this function **never throws**. It is designed to be fire-and-forget - * from the callsite via `runAsynchronously(...)`. Any config / lookup / Stripe / - * DB error results in a durable PlatformFeeEvent row with `status = FAILED` and - * a descriptive error message, plus a Sentry event. Callers may treat the - * originating money movement as already-succeeded regardless of outcome here. + * Contract: this function **never throws**. It is designed to run after the + * originating refund succeeds. Any config / lookup / Stripe / DB error results + * in a durable PlatformFeeEvent row with `status = FAILED` and a descriptive + * error message, plus a Sentry event. Callers may treat the originating money + * movement as already-succeeded regardless of outcome here. */ export async function collectInverseFee(options: { tenancy: Tenancy, @@ -170,19 +170,10 @@ async function collectInverseFeeInner(options: { // ledger-update crashed), list transfers on the merchant's account for this // transfer_group and use the pre-existing transfer if we find one. // - // Two error cases are handled explicitly below; the distinction matters - // because falling through to `transfers.create` is only safe when we've - // proven no transfer exists yet: - // (a) the `transfers.list` lookup itself fails — safe to fall through: - // we don't know if a transfer exists, but the idempotency key on the - // near-term retry (24h window) still dedupes, and worst case the NEXT - // retry's reconciliation will pick up whatever we create here. - // (b) the lookup succeeds AND returns a pre-existing transfer, but the - // ledger update then fails — we MUST NOT fall through. Creating a - // second transfer now (or after the idempotency key expires on a - // later retry) would double-debit the merchant. Bail with FAILED so - // ops sees the inconsistency and can reconcile manually using the - // captured transfer id. + // Lookup failure must fail closed. We cannot prove whether a previous retry + // already created the transfer, and Stripe's idempotency keys are not durable + // forever. Falling through to `transfers.create` after a failed search can + // double-debit the merchant once the old key has expired. if (!ledgerRow.stripeTransferId) { let existing: Stripe.ApiList | null = null; try { @@ -192,10 +183,14 @@ async function collectInverseFeeInner(options: { ); } catch (searchErr) { captureError("collect-inverse-fee-search", new StackAssertionError( - "Failed to search Stripe for existing platform fee transfer before retry — proceeding with idempotent create", + "Failed to search Stripe for existing platform fee transfer before retry — failing closed to avoid double-debit", { sourceType: options.sourceType, sourceId: options.sourceId, ...stripeErrorContext(searchErr) } )); - // Case (a): fall through to `transfers.create`. + await markLedgerFailed( + ledgerKey, + `Failed to search Stripe for existing platform fee transfer for ${transferGroup}; retry later rather than risking double-debit`, + ); + return; } if (existing && existing.data.length > 0) { @@ -211,10 +206,9 @@ async function collectInverseFeeInner(options: { }, }); } catch (dbErr) { - // Case (b): DO NOT fall through. We know a transfer exists on Stripe - // (id: pre.id) but we couldn't record it. Mark FAILED loudly and - // return; creating another transfer here would double-debit after - // the idempotency key expires. + // We know a transfer exists on Stripe (id: pre.id) but we couldn't + // record it. Mark FAILED loudly and return; creating another transfer + // here would double-debit after the idempotency key expires. captureError("collect-inverse-fee-ledger-reconcile", new StackAssertionError( "Found pre-existing Stripe transfer during retry reconciliation but ledger update failed — manual reconciliation needed to avoid double-debit on next retry", { sourceType: options.sourceType, sourceId: options.sourceId, preExistingTransferId: pre.id, dbErr: dbErr instanceof Error ? dbErr.message : String(dbErr) } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/platform-fees.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/platform-fees.test.ts index e1ac7b0fcf..54c2cdeda9 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/platform-fees.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/platform-fees.test.ts @@ -13,10 +13,10 @@ const EXPECTED_REFUND_FEE_STRIPE_UNITS = 45; const EXPECTED_PARTIAL_REFUND_FEE_STRIPE_UNITS = 11; /** - * `collectInverseFee` is intentionally fire-and-forget via `runAsynchronously` - * in the refund route, so the refund response returns before the ledger row - * is written / reaches a terminal status. Tests must poll instead of asserting - * immediately after the refund response. + * `collectInverseFee` is intentionally backgrounded via `runAsynchronouslyAndWaitUntil` + * in the refund route, so the refund response can return before the ledger row + * reaches a terminal status. Tests must poll instead of asserting immediately + * after the refund response. */ async function waitForPlatformFeeEvent(options: { terminal?: boolean } = {}) { const { terminal = true } = options; From e66ae8a999a4d623c69c744eb24f6ac9d213a6f5 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Thu, 23 Apr 2026 16:00:42 -0700 Subject: [PATCH 05/10] Fix platform fee CI lint failures --- apps/backend/src/lib/payments/platform-fees.test.ts | 10 ++-------- apps/backend/src/lib/payments/platform-fees.ts | 2 +- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/backend/src/lib/payments/platform-fees.test.ts b/apps/backend/src/lib/payments/platform-fees.test.ts index 619dc4b96c..f3d4caf729 100644 --- a/apps/backend/src/lib/payments/platform-fees.test.ts +++ b/apps/backend/src/lib/payments/platform-fees.test.ts @@ -64,8 +64,6 @@ const tenancy = { } as Tenancy; describe("collectInverseFee", () => { - const originalPlatformAccountId = process.env.STACK_STRIPE_PLATFORM_ACCOUNT_ID; - beforeEach(() => { mocks.rows.clear(); mocks.captureError.mockClear(); @@ -93,15 +91,11 @@ describe("collectInverseFee", () => { mocks.rows.set(key, row); return row; }); - process.env.STACK_STRIPE_PLATFORM_ACCOUNT_ID = "acct_platform"; + vi.stubEnv("STACK_STRIPE_PLATFORM_ACCOUNT_ID", "acct_platform"); }); afterEach(() => { - if (originalPlatformAccountId === undefined) { - delete process.env.STACK_STRIPE_PLATFORM_ACCOUNT_ID; - } else { - process.env.STACK_STRIPE_PLATFORM_ACCOUNT_ID = originalPlatformAccountId; - } + vi.unstubAllEnvs(); }); it("fails closed instead of creating a transfer when Stripe reconciliation lookup fails", async () => { diff --git a/apps/backend/src/lib/payments/platform-fees.ts b/apps/backend/src/lib/payments/platform-fees.ts index e4163f4366..be3dd2f908 100644 --- a/apps/backend/src/lib/payments/platform-fees.ts +++ b/apps/backend/src/lib/payments/platform-fees.ts @@ -193,7 +193,7 @@ async function collectInverseFeeInner(options: { return; } - if (existing && existing.data.length > 0) { + if (existing.data.length > 0) { const pre = existing.data[0]; try { await globalPrismaClient.platformFeeEvent.update({ From 02a18c07ca9259600b93475afa7d467a2cbb2b4f Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 4 May 2026 13:13:00 -0700 Subject: [PATCH 06/10] refactor: nix charges on refund Rather than maintaining complex refund cut logic, lets not charge on refunds. Let's keep the logic where we dont refund the app fee. --- apps/backend/.env | 1 - apps/backend/.env.development | 1 - .../migration.sql | 30 -- apps/backend/prisma/schema.prisma | 34 --- .../internal/payments/platform-fees/route.ts | 75 ----- .../latest/internal/payments/setup/route.ts | 16 - .../payments/transactions/refund/route.tsx | 86 ++++-- .../purchases/purchase-session/route.tsx | 15 +- .../src/lib/payments/platform-fees.test.ts | 122 -------- .../backend/src/lib/payments/platform-fees.ts | 283 +----------------- .../api/v1/internal/platform-fees.test.ts | 220 -------------- 11 files changed, 68 insertions(+), 815 deletions(-) delete mode 100644 apps/backend/prisma/migrations/20260422000000_add_platform_fee_event/migration.sql delete mode 100644 apps/backend/src/app/api/latest/internal/payments/platform-fees/route.ts delete mode 100644 apps/backend/src/lib/payments/platform-fees.test.ts delete mode 100644 apps/e2e/tests/backend/endpoints/api/v1/internal/platform-fees.test.ts diff --git a/apps/backend/.env b/apps/backend/.env index 2e3dc21e4c..228c065825 100644 --- a/apps/backend/.env +++ b/apps/backend/.env @@ -113,7 +113,6 @@ STACK_OPENAI_API_KEY=# enter your openai api key STACK_FEATUREBASE_API_KEY=# enter your featurebase api key STACK_STRIPE_SECRET_KEY=# enter your stripe api key STACK_STRIPE_WEBHOOK_SECRET=# enter your stripe webhook secret -STACK_STRIPE_PLATFORM_ACCOUNT_ID=# enter your platform stripe account id (acct_...), destination for Connect inverse transfers STACK_TELEGRAM_BOT_TOKEN= # enter you telegram bot token STACK_TELEGRAM_CHAT_ID=# enter your telegram chat id diff --git a/apps/backend/.env.development b/apps/backend/.env.development index 0ea3dab048..f20581e078 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -76,7 +76,6 @@ STACK_VERCEL_SANDBOX_TOKEN=vercel_sandbox_disabled_for_local_development STACK_OPENAI_API_KEY=mock_openai_api_key STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret -STACK_STRIPE_PLATFORM_ACCOUNT_ID=acct_mock_platform STACK_OPENROUTER_API_KEY=FORWARD_TO_PRODUCTION STACK_FEEDBACK_MODE=FORWARD_TO_PRODUCTION STACK_MINTLIFY_MCP_URL=https://stackauth-e0affa27.mintlify.app/mcp diff --git a/apps/backend/prisma/migrations/20260422000000_add_platform_fee_event/migration.sql b/apps/backend/prisma/migrations/20260422000000_add_platform_fee_event/migration.sql deleted file mode 100644 index b0e4d7445e..0000000000 --- a/apps/backend/prisma/migrations/20260422000000_add_platform_fee_event/migration.sql +++ /dev/null @@ -1,30 +0,0 @@ --- CreateTable -CREATE TABLE "PlatformFeeEvent" ( - "id" UUID NOT NULL DEFAULT gen_random_uuid(), - "tenancyId" UUID NOT NULL, - "projectId" TEXT NOT NULL, - "sourceType" TEXT NOT NULL CHECK ("sourceType" IN ('REFUND')), - "sourceId" TEXT NOT NULL, - "amount" INTEGER NOT NULL CHECK ("amount" >= 0), - "currency" TEXT NOT NULL, - "status" TEXT NOT NULL CHECK ("status" IN ('PENDING', 'COLLECTED', 'FAILED')), - "stripeTransferId" TEXT, - "error" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - "collectedAt" TIMESTAMP(3), - - CONSTRAINT "PlatformFeeEvent_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "PlatformFeeEvent_sourceType_sourceId_key" ON "PlatformFeeEvent"("sourceType", "sourceId"); - --- CreateIndex -CREATE INDEX "PlatformFeeEvent_tenancyId_idx" ON "PlatformFeeEvent"("tenancyId"); - --- CreateIndex -CREATE INDEX "PlatformFeeEvent_projectId_idx" ON "PlatformFeeEvent"("projectId"); - --- CreateIndex -CREATE INDEX "PlatformFeeEvent_status_idx" ON "PlatformFeeEvent"("status"); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 9ec5e48ffb..1b20c77b17 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -1312,40 +1312,6 @@ model SubscriptionInvoice { @@unique([tenancyId, stripeInvoiceId]) } -// Ledger of platform fees collected via inverse Connect transfers (e.g. our -// 0.9% cut of refund outflows). Each row is keyed by the originating Stripe -// event so collection is idempotent under webhook / handler retry. -model PlatformFeeEvent { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - - tenancyId String @db.Uuid - projectId String - - // Discriminator for the source event. Currently only REFUND; additional - // sources (e.g. manual adjustments) may be added later. - sourceType String - // Stripe ID of the originating event — refund id for REFUND. Unique with - // sourceType to make fee collection idempotent. - sourceId String - - amount Int - currency String - - // PENDING | COLLECTED | FAILED - status String - stripeTransferId String? - error String? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - collectedAt DateTime? - - @@unique([sourceType, sourceId]) - @@index([tenancyId]) - @@index([projectId]) - @@index([status]) -} - model OutgoingRequest { id String @id @default(uuid()) @db.Uuid diff --git a/apps/backend/src/app/api/latest/internal/payments/platform-fees/route.ts b/apps/backend/src/app/api/latest/internal/payments/platform-fees/route.ts deleted file mode 100644 index 7acdd8db60..0000000000 --- a/apps/backend/src/app/api/latest/internal/payments/platform-fees/route.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { PlatformFeeStatus } from "@/lib/payments/platform-fees"; -import { globalPrismaClient } from "@/prisma-client"; -import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; - -export const GET = createSmartRouteHandler({ - metadata: { - hidden: true, - }, - request: yupObject({ - auth: yupObject({ - type: adminAuthTypeSchema.defined(), - project: adaptSchema.defined(), - tenancy: adaptSchema.defined(), - }).defined(), - }), - response: yupObject({ - statusCode: yupNumber().oneOf([200]).defined(), - bodyType: yupString().oneOf(["json"]).defined(), - body: yupObject({ - // Sum of every platform fee event not yet marked COLLECTED, in - // USD stripe-units (cents). Expands to a keyed map when multi-currency - // support is introduced. - total_due_usd: yupNumber().defined(), - events: yupArray( - yupObject({ - id: yupString().defined(), - source_type: yupString().defined(), - source_id: yupString().defined(), - amount: yupNumber().defined(), - currency: yupString().defined(), - status: yupString().defined(), - stripe_transfer_id: yupString().nullable().defined(), - error: yupString().nullable().defined(), - created_at: yupString().defined(), - collected_at: yupString().nullable().defined(), - }).defined(), - ).defined(), - }).defined(), - }), - handler: async ({ auth }) => { - // TODO: pagination. Low-priority today (volume per tenancy is small), but - // merchants with high refund throughput will eventually accumulate - // thousands of rows here. Add cursor-based pagination before shipping a - // merchant-visible UI. - const events = await globalPrismaClient.platformFeeEvent.findMany({ - where: { tenancyId: auth.tenancy.id }, - orderBy: { createdAt: "desc" }, - }); - - const totalDueUsdUnits = events - .filter((e) => e.status !== PlatformFeeStatus.COLLECTED && e.currency === "usd") - .reduce((sum, e) => sum + e.amount, 0); - - return { - statusCode: 200, - bodyType: "json", - body: { - total_due_usd: totalDueUsdUnits, - events: events.map((e) => ({ - id: e.id, - source_type: e.sourceType, - source_id: e.sourceId, - amount: e.amount, - currency: e.currency, - status: e.status, - stripe_transfer_id: e.stripeTransferId, - error: e.error, - created_at: e.createdAt.toISOString(), - collected_at: e.collectedAt?.toISOString() ?? null, - })), - }, - }; - }, -}); diff --git a/apps/backend/src/app/api/latest/internal/payments/setup/route.ts b/apps/backend/src/app/api/latest/internal/payments/setup/route.ts index ddd20a4bb2..7d0159faa5 100644 --- a/apps/backend/src/app/api/latest/internal/payments/setup/route.ts +++ b/apps/backend/src/app/api/latest/internal/payments/setup/route.ts @@ -50,22 +50,6 @@ export const POST = createSmartRouteHandler({ transfers: { requested: true }, }, country: "US", - // `debit_negative_balances` lets Stripe ACH-debit the merchant's - // linked bank when their Stripe balance goes negative due to payouts, - // chargebacks, or other settlement events. It does NOT let our - // `stripe.transfers.create` push a connected account into a negative - // balance on its own — transfers hard-fail on insufficient balance - // and land in the PlatformFeeEvent ledger with status=FAILED for - // manual reconciliation. We still enable this setting so that merchant - // balances that *would* go negative for other reasons (e.g. their own - // refunds running ahead of incoming payments) are covered automatically. - // Refs: https://docs.stripe.com/connect/account-debits - // https://docs.stripe.com/connect/account-balances#negative-balances - settings: { - payouts: { - debit_negative_balances: true, - }, - }, metadata: { tenancyId: auth.tenancy.id, } 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 9e56e8b7ba..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 @@ -1,22 +1,44 @@ import { buildOneTimePurchaseTransaction, buildSubscriptionTransaction, resolveSelectedPriceFromProduct } from "@/app/api/latest/internal/payments/transactions/transaction-builder"; import { bulldozerWriteManualTransaction, bulldozerWriteOneTimePurchase, bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; -import { collectInverseFee, PlatformFeeSourceType } from "@/lib/payments/platform-fees"; import type { ManualTransactionRow } from "@/lib/payments/schema/types"; import { getStripeForAccount } from "@/lib/stripe"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks"; import type { TransactionEntry } from "@stackframe/stack-shared/dist/interface/crud/transactions"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; import { adaptSchema, adminAuthTypeSchema, moneyAmountSchema, productSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; 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; @@ -264,23 +286,9 @@ export const POST = createSmartRouteHandler({ if (refundAmountStripeUnits > totalStripeUnits) { throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount."); } - const subscriptionRefund = await stripe.refunds.create({ - payment_intent: paymentIntentId, - amount: refundAmountStripeUnits, - // Keep the charge-leg application fee with the platform. Stripe's - // default would reverse it proportionally, cancelling out our 0.9% - // cut on the original charge. - refund_application_fee: false, - }); - // Fee collection is best-effort and the originating refund has already - // succeeded, but we still need to keep the background ledger/transfer work - // alive after the response on serverless runtimes. - runAsynchronouslyAndWaitUntil(collectInverseFee({ - tenancy: auth.tenancy, + await stripe.refunds.create(buildStripeRefundParams({ + paymentIntentId, amountStripeUnits: refundAmountStripeUnits, - currency: "usd", - sourceType: PlatformFeeSourceType.REFUND, - sourceId: subscriptionRefund.id, })); const refundedAt = new Date(); if (refundedQuantity > 0) { @@ -379,21 +387,13 @@ export const POST = createSmartRouteHandler({ if (refundAmountStripeUnits > totalStripeUnits) { throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount."); } - const purchaseRefund = 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, }, - refund_application_fee: false, - }); - runAsynchronouslyAndWaitUntil(collectInverseFee({ - tenancy: auth.tenancy, - amountStripeUnits: refundAmountStripeUnits, - currency: "usd", - sourceType: PlatformFeeSourceType.REFUND, - sourceId: purchaseRefund.id, })); const refundedAt = new Date(); await prisma.oneTimePurchase.update({ @@ -429,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/purchases/purchase-session/route.tsx b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx index fc15bae917..67d6e60f29 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 @@ -98,14 +98,13 @@ export const POST = createSmartRouteHandler({ // is created synchronously. `application_fee_percent` is applied to // invoices generated from the subscription's normal billing cycle, but // per Stripe's subscription/proration docs the immediately-generated - // upgrade invoice may not inherit the newly-set fee percent. Our - // charge-leg guarantee for this specific invoice is therefore - // best-effort until we either (a) observe the behaviour against a real - // onboarded Connect account, or (b) listen for the resulting - // `invoice.created` webhook and stamp `application_fee_amount` on the - // invoice before it finalises. Refund-leg collection (via - // `collectInverseFee`) is unaffected and still works on the full - // refund amount regardless. + // upgrade invoice may not inherit the newly-set fee percent — i.e. we + // may miss collecting our 0.9% on the proration invoice itself even + // though all later renewals of the new plan are covered. Best-effort + // until we either (a) observe the behaviour against a real onboarded + // Connect account, or (b) listen for the resulting `invoice.created` + // webhook and stamp `application_fee_amount` on the invoice before it + // finalises. // Refs: https://docs.stripe.com/connect/subscriptions // https://docs.stripe.com/billing/subscriptions/prorations const applicationFeePercent = getApplicationFeePercentOrUndefined(tenancy.project.id); diff --git a/apps/backend/src/lib/payments/platform-fees.test.ts b/apps/backend/src/lib/payments/platform-fees.test.ts deleted file mode 100644 index f3d4caf729..0000000000 --- a/apps/backend/src/lib/payments/platform-fees.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { Tenancy } from "@/lib/tenancies"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { collectInverseFee, PlatformFeeSourceType, PlatformFeeStatus } from "./platform-fees"; - -type PlatformFeeRow = { - tenancyId: string, - projectId: string, - sourceType: string, - sourceId: string, - amount: number, - currency: string, - status: string, - stripeTransferId: string | null, - collectedAt: Date | null, - error: string | null, -}; - -const mocks = vi.hoisted(() => ({ - rows: new Map(), - captureError: vi.fn(), - findProject: vi.fn(), - transferList: vi.fn(), - transferCreate: vi.fn(), - platformFeeEventUpsert: vi.fn(), - platformFeeEventUpdate: vi.fn(), -})); - -function rowKey(sourceType: string, sourceId: string): string { - return `${sourceType}:${sourceId}`; -} - -vi.mock("@/prisma-client", () => ({ - globalPrismaClient: { - platformFeeEvent: { - upsert: mocks.platformFeeEventUpsert, - update: mocks.platformFeeEventUpdate, - }, - project: { - findUnique: mocks.findProject, - }, - }, -})); - -vi.mock("@/lib/stripe", () => ({ - getStackStripe: () => ({ - transfers: { - list: mocks.transferList, - create: mocks.transferCreate, - }, - }), -})); - -vi.mock("@stackframe/stack-shared/dist/utils/errors", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - captureError: mocks.captureError, - }; -}); - -const tenancy = { - id: "tenancy_1", - project: { id: "project_1" }, -} as Tenancy; - -describe("collectInverseFee", () => { - beforeEach(() => { - mocks.rows.clear(); - mocks.captureError.mockClear(); - mocks.findProject.mockReset().mockResolvedValue({ stripeAccountId: "acct_connected" }); - mocks.transferList.mockReset(); - mocks.transferCreate.mockReset(); - mocks.platformFeeEventUpsert.mockReset().mockImplementation(async ({ where, create }) => { - const key = rowKey(where.sourceType_sourceId.sourceType, where.sourceType_sourceId.sourceId); - const existing = mocks.rows.get(key); - if (existing) return existing; - const row = { - ...create, - stripeTransferId: null, - collectedAt: null, - error: null, - }; - mocks.rows.set(key, row); - return row; - }); - mocks.platformFeeEventUpdate.mockReset().mockImplementation(async ({ where, data }) => { - const key = rowKey(where.sourceType_sourceId.sourceType, where.sourceType_sourceId.sourceId); - const existing = mocks.rows.get(key); - if (!existing) throw new Error(`missing row ${key}`); - const row = { ...existing, ...data }; - mocks.rows.set(key, row); - return row; - }); - vi.stubEnv("STACK_STRIPE_PLATFORM_ACCOUNT_ID", "acct_platform"); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it("fails closed instead of creating a transfer when Stripe reconciliation lookup fails", async () => { - mocks.transferList.mockRejectedValue(new Error("stripe list unavailable")); - - await collectInverseFee({ - tenancy, - amountStripeUnits: 10_000, - currency: "usd", - sourceType: PlatformFeeSourceType.REFUND, - sourceId: "refund_1", - }); - - expect(mocks.transferList).toHaveBeenCalledWith( - { transfer_group: "platform-fee-REFUND-refund_1", limit: 1 }, - { stripeAccount: "acct_connected" }, - ); - expect(mocks.transferCreate).not.toHaveBeenCalled(); - expect(mocks.rows.get("REFUND:refund_1")).toMatchObject({ - status: PlatformFeeStatus.FAILED, - error: expect.stringContaining("rather than risking double-debit"), - }); - }); -}); diff --git a/apps/backend/src/lib/payments/platform-fees.ts b/apps/backend/src/lib/payments/platform-fees.ts index be3dd2f908..f60f35431a 100644 --- a/apps/backend/src/lib/payments/platform-fees.ts +++ b/apps/backend/src/lib/payments/platform-fees.ts @@ -1,43 +1,12 @@ -import { getStackStripe } from "@/lib/stripe"; -import type { Tenancy } from "@/lib/tenancies"; -import { globalPrismaClient } from "@/prisma-client"; -import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; -import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; -import Stripe from "stripe"; - -function stripeErrorContext(err: unknown): Record { - if (err instanceof Stripe.errors.StripeError) { - return { - stripeErrCode: err.code, - stripeErrType: err.type, - stripeRequestId: err.requestId, - stripeStatusCode: err.statusCode, - stripeErrMessage: err.message, - }; - } - return { error: err }; -} - // 0.9% of every Stripe money movement on a non-internal project is collected -// as a platform fee. Charge-leg fees ride along via Stripe's native -// application_fee_* params; outflow-leg fees (e.g. refunds) are collected via -// inverse Connect transfers — see collectInverseFee below. +// 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. export const APPLICATION_FEE_BPS = 90; const INTERNAL_PROJECT_ID = "internal"; -export const PlatformFeeStatus = { - PENDING: "PENDING", - COLLECTED: "COLLECTED", - FAILED: "FAILED", -} as const; -export type PlatformFeeStatus = typeof PlatformFeeStatus[keyof typeof PlatformFeeStatus]; - -export const PlatformFeeSourceType = { - REFUND: "REFUND", -} as const; -export type PlatformFeeSourceType = typeof PlatformFeeSourceType[keyof typeof PlatformFeeSourceType]; - export function getApplicationFeeBps(projectId: string): number { if (projectId === INTERNAL_PROJECT_ID) return 0; return APPLICATION_FEE_BPS; @@ -55,250 +24,6 @@ export function getApplicationFeePercentOrUndefined(projectId: string): number | return bps / 100; } -/** - * Collect an inverse platform fee for an outflow event (e.g. a refund). - * - * Contract: this function **never throws**. It is designed to run after the - * originating refund succeeds. Any config / lookup / Stripe / DB error results - * in a durable PlatformFeeEvent row with `status = FAILED` and a descriptive - * error message, plus a Sentry event. Callers may treat the originating money - * movement as already-succeeded regardless of outcome here. - */ -export async function collectInverseFee(options: { - tenancy: Tenancy, - amountStripeUnits: number, - currency: string, - sourceType: PlatformFeeSourceType, - sourceId: string, -}): Promise { - try { - await collectInverseFeeInner(options); - } catch (err) { - // Last-resort catch: the inner function is engineered to always return - // normally, but if a DB lookup or other helper throws before we can write - // a ledger row, we still need to avoid surfacing the error to the caller. - captureError("collect-inverse-fee-unexpected", new StackAssertionError( - "Unexpected error in collectInverseFee — ledger state may be missing for this refund", - { - sourceType: options.sourceType, - sourceId: options.sourceId, - tenancyId: options.tenancy.id, - ...stripeErrorContext(err), - } - )); - } -} - -async function collectInverseFeeInner(options: { - tenancy: Tenancy, - amountStripeUnits: number, - currency: string, - sourceType: PlatformFeeSourceType, - sourceId: string, -}): Promise { - // Explicit invariant: multi-currency fee aggregation isn't built out yet, so - // we assert here rather than silently miscategorising a non-USD refund as a - // USD due in the ledger. - if (options.currency !== "usd") { - throw new StackAssertionError("collectInverseFee currently only supports usd", { - currency: options.currency, - sourceType: options.sourceType, - sourceId: options.sourceId, - }); - } - - const projectId = options.tenancy.project.id; - const feeAmount = computeApplicationFeeAmount({ amountStripeUnits: options.amountStripeUnits, projectId }); - if (feeAmount <= 0) return; - - // Write the ledger row FIRST, before any config / lookup / Stripe call. - // This guarantees durable state exists for every fee event — config failures - // and account-lookup failures are recorded as FAILED rows that ops can see - // and retry, rather than being silently dropped. - const ledgerKey = { sourceType_sourceId: { sourceType: options.sourceType, sourceId: options.sourceId } }; - const ledgerRow = await globalPrismaClient.platformFeeEvent.upsert({ - where: ledgerKey, - create: { - tenancyId: options.tenancy.id, - projectId, - sourceType: options.sourceType, - sourceId: options.sourceId, - amount: feeAmount, - currency: options.currency, - status: PlatformFeeStatus.PENDING, - }, - update: {}, - }); - if (ledgerRow.status === PlatformFeeStatus.COLLECTED) return; - - const platformAccountId = getEnvVariable("STACK_STRIPE_PLATFORM_ACCOUNT_ID", ""); - if (!platformAccountId) { - await markLedgerFailed(ledgerKey, "STACK_STRIPE_PLATFORM_ACCOUNT_ID not set"); - captureError("collect-inverse-fee", new StackAssertionError( - "STACK_STRIPE_PLATFORM_ACCOUNT_ID not set; inverse fee collection skipped", - { sourceType: options.sourceType, sourceId: options.sourceId, tenancyId: options.tenancy.id } - )); - return; - } - - const project = await globalPrismaClient.project.findUnique({ - where: { id: projectId }, - select: { stripeAccountId: true }, - }); - const stripeAccountId = project?.stripeAccountId; - if (!stripeAccountId) { - await markLedgerFailed(ledgerKey, "Project has no stripeAccountId"); - captureError("collect-inverse-fee", new StackAssertionError( - "Project has no stripeAccountId; cannot collect inverse fee", - { sourceType: options.sourceType, sourceId: options.sourceId, projectId } - )); - return; - } - - const platformStripe = getStackStripe(); - // `transfer_group` is our durable reconciliation key. Stripe's - // `idempotencyKey` only dedupes within ~24h, so a retry *after* the key - // expires (ledger-update-failure scenario) would otherwise create a second - // transfer and double-debit the merchant. By tagging every transfer with a - // stable, content-addressed `transfer_group` derived from `(sourceType, - // sourceId)` we can look the transfer up on Stripe on retry and reconcile - // instead of creating a new one. - const transferGroup = `platform-fee-${options.sourceType}-${options.sourceId}`; - - // Retry reconciliation: if a prior attempt on this sourceId left the ledger - // without a stripeTransferId (the transfer might have succeeded but our - // ledger-update crashed), list transfers on the merchant's account for this - // transfer_group and use the pre-existing transfer if we find one. - // - // Lookup failure must fail closed. We cannot prove whether a previous retry - // already created the transfer, and Stripe's idempotency keys are not durable - // forever. Falling through to `transfers.create` after a failed search can - // double-debit the merchant once the old key has expired. - if (!ledgerRow.stripeTransferId) { - let existing: Stripe.ApiList | null = null; - try { - existing = await platformStripe.transfers.list( - { transfer_group: transferGroup, limit: 1 }, - { stripeAccount: stripeAccountId }, - ); - } catch (searchErr) { - captureError("collect-inverse-fee-search", new StackAssertionError( - "Failed to search Stripe for existing platform fee transfer before retry — failing closed to avoid double-debit", - { sourceType: options.sourceType, sourceId: options.sourceId, ...stripeErrorContext(searchErr) } - )); - await markLedgerFailed( - ledgerKey, - `Failed to search Stripe for existing platform fee transfer for ${transferGroup}; retry later rather than risking double-debit`, - ); - return; - } - - if (existing.data.length > 0) { - const pre = existing.data[0]; - try { - await globalPrismaClient.platformFeeEvent.update({ - where: ledgerKey, - data: { - status: PlatformFeeStatus.COLLECTED, - stripeTransferId: pre.id, - collectedAt: new Date(pre.created * 1000), - error: null, - }, - }); - } catch (dbErr) { - // We know a transfer exists on Stripe (id: pre.id) but we couldn't - // record it. Mark FAILED loudly and return; creating another transfer - // here would double-debit after the idempotency key expires. - captureError("collect-inverse-fee-ledger-reconcile", new StackAssertionError( - "Found pre-existing Stripe transfer during retry reconciliation but ledger update failed — manual reconciliation needed to avoid double-debit on next retry", - { sourceType: options.sourceType, sourceId: options.sourceId, preExistingTransferId: pre.id, dbErr: dbErr instanceof Error ? dbErr.message : String(dbErr) } - )); - await markLedgerFailed( - ledgerKey, - `Pre-existing Stripe transfer ${pre.id} found but ledger update failed during reconciliation; manual intervention required to avoid double-debit`, - ); - return; - } - return; - } - } - - let transferId: string; - try { - // Transfer from the connected account's Stripe balance back to the - // platform. Executed AS the connected account (stripeAccount header) with - // destination set to our platform account ID. - const transfer = await platformStripe.transfers.create( - { - amount: feeAmount, - currency: options.currency, - destination: platformAccountId, - transfer_group: transferGroup, - metadata: { - platformFeeSourceType: options.sourceType, - platformFeeSourceId: options.sourceId, - platformFeeTenancyId: options.tenancy.id, - }, - }, - { - stripeAccount: stripeAccountId, - idempotencyKey: transferGroup, - }, - ); - transferId = transfer.id; - } catch (stripeErr) { - captureError("collect-inverse-fee", new StackAssertionError( - "Failed to collect inverse platform fee", - { - sourceType: options.sourceType, - sourceId: options.sourceId, - tenancyId: options.tenancy.id, - ...stripeErrorContext(stripeErr), - } - )); - await markLedgerFailed(ledgerKey, stripeErr instanceof Error ? stripeErr.message : String(stripeErr)); - return; - } - - try { - await globalPrismaClient.platformFeeEvent.update({ - where: ledgerKey, - data: { - status: PlatformFeeStatus.COLLECTED, - stripeTransferId: transferId, - collectedAt: new Date(), - // Clear any error from a previous failed attempt so ops / the - // listing endpoint don't surface stale failure reasons. - error: null, - }, - }); - } catch (dbErr) { - // The money was collected but we couldn't record it. Log loudly — someone - // will need to reconcile the ledger row against the Stripe transfer id. - captureError("collect-inverse-fee-ledger-write", new StackAssertionError( - "Stripe transfer succeeded but ledger update failed — manual reconciliation needed", - { sourceType: options.sourceType, sourceId: options.sourceId, transferId, dbErr } - )); - } -} - -async function markLedgerFailed( - where: { sourceType_sourceId: { sourceType: string, sourceId: string } }, - error: string, -): Promise { - try { - await globalPrismaClient.platformFeeEvent.update({ - where, - data: { status: PlatformFeeStatus.FAILED, error }, - }); - } catch (dbErr) { - captureError("collect-inverse-fee-ledger-write", new StackAssertionError( - "Failed to record FAILED status on platform fee event", - { where, originalError: error, dbErr } - )); - } -} - import.meta.vitest?.describe("platform fee helpers", (test) => { test("getApplicationFeeBps returns 0 for internal project", ({ expect }) => { expect(getApplicationFeeBps("internal")).toBe(0); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/platform-fees.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/platform-fees.test.ts deleted file mode 100644 index 54c2cdeda9..0000000000 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/platform-fees.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { expect } from "vitest"; -import { it } from "../../../../../helpers"; -import { Auth, Payments, Project, niceBackendFetch } from "../../../../backend-helpers"; -import { - createLiveModeOneTimePurchaseTransaction, - createLiveModeSubscriptionTransaction, - createPurchaseCode, -} from "../../../../helpers/payments"; - -// `amount_usd: "5000"` in refund_entries is parsed as 5000 stripe-units (= $50), -// so 0.9% = 45. Partial refund "1250" = 1250 stripe-units, 0.9% = round(11.25) = 11. -const EXPECTED_REFUND_FEE_STRIPE_UNITS = 45; -const EXPECTED_PARTIAL_REFUND_FEE_STRIPE_UNITS = 11; - -/** - * `collectInverseFee` is intentionally backgrounded via `runAsynchronouslyAndWaitUntil` - * in the refund route, so the refund response can return before the ledger row - * reaches a terminal status. Tests must poll instead of asserting immediately - * after the refund response. - */ -async function waitForPlatformFeeEvent(options: { terminal?: boolean } = {}) { - const { terminal = true } = options; - const deadline = Date.now() + 10_000; - while (Date.now() < deadline) { - const res = await niceBackendFetch("/api/latest/internal/payments/platform-fees", { - accessType: "admin", - }); - expect(res.status).toBe(200); - const events = res.body.events as Array<{ status: string }>; - if (events.length > 0) { - if (!terminal) return res; - if (events.every((e) => e.status === "COLLECTED" || e.status === "FAILED")) { - return res; - } - } - await new Promise((r) => setTimeout(r, 100)); - } - throw new Error("Timed out waiting for PlatformFeeEvent to reach a terminal status"); -} - -it("records a COLLECTED PlatformFeeEvent when a live-mode OTP is refunded", async () => { - const { purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction(); - - const beforeRes = await niceBackendFetch("/api/latest/internal/payments/platform-fees", { - accessType: "admin", - }); - expect(beforeRes.status).toBe(200); - expect(beforeRes.body.events).toHaveLength(0); - expect(beforeRes.body.total_due_usd).toBe(0); - - const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { - accessType: "admin", - method: "POST", - body: { - type: "one-time-purchase", - id: purchaseTransaction.id, - refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "5000" }], - }, - }); - expect(refundRes.status).toBe(200); - - const afterRes = await waitForPlatformFeeEvent(); - expect(afterRes.body.events).toHaveLength(1); - const event = afterRes.body.events[0]; - expect(event.source_type).toBe("REFUND"); - expect(event.amount).toBe(EXPECTED_REFUND_FEE_STRIPE_UNITS); - expect(event.currency).toBe("usd"); - expect(event.status).toBe("COLLECTED"); - expect(event.stripe_transfer_id).not.toBeNull(); - // total_due_usd excludes COLLECTED rows. - expect(afterRes.body.total_due_usd).toBe(0); -}); - -it("collects proportional fee on a partial refund", async () => { - const { purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction(); - - const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { - accessType: "admin", - method: "POST", - body: { - type: "one-time-purchase", - id: purchaseTransaction.id, - refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "1250" }], - }, - }); - expect(refundRes.status).toBe(200); - - const feesRes = await waitForPlatformFeeEvent(); - expect(feesRes.body.events).toHaveLength(1); - expect(feesRes.body.events[0].amount).toBe(EXPECTED_PARTIAL_REFUND_FEE_STRIPE_UNITS); - expect(feesRes.body.events[0].status).toBe("COLLECTED"); -}); - -it("does not record a fee on a test-mode refund attempt", async () => { - await Project.createAndSwitch(); - await Payments.setup(); - await Project.updateConfig({ - payments: { - testMode: true, - products: { - "otp-product": { - displayName: "One-Time Product", - customerType: "user", - serverOnly: false, - stackable: false, - prices: { single: { USD: "5000" } }, - includedItems: {}, - }, - }, - items: {}, - }, - }); - - const { userId } = await Auth.fastSignUp(); - const code = await createPurchaseCode({ userId, productId: "otp-product" }); - const sessionRes = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", { - accessType: "admin", - method: "POST", - body: { full_code: code, price_id: "single", quantity: 1 }, - }); - expect(sessionRes.status).toBe(200); - - const transactions = await niceBackendFetch("/api/latest/internal/payments/transactions", { - accessType: "admin", - }); - const transactionId = transactions.body.transactions[0].id; - - const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { - accessType: "admin", - method: "POST", - body: { - type: "one-time-purchase", - id: transactionId, - refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "5000" }], - }, - }); - // Test-mode OTP refunds are rejected upstream; no Stripe call, no fee row. - expect(refundRes.body.code).toBe("TEST_MODE_PURCHASE_NON_REFUNDABLE"); - - const feesRes = await niceBackendFetch("/api/latest/internal/payments/platform-fees", { - accessType: "admin", - }); - expect(feesRes.status).toBe(200); - expect(feesRes.body.events).toHaveLength(0); -}); - -// TODO(platform-fees): this test covers only the *refund endpoint's* already- -// refunded rejection; it does NOT exercise the helper's sourceId idempotency -// path (calling collectInverseFee twice with the same sourceId and asserting -// one row + one Stripe transfer). That requires either a direct helper-level -// test with a shared-context stripe client (not wired in this repo) or an -// admin retry endpoint. Tracking separately. -it("refund endpoint rejects a second refund for the same purchase", async () => { - const { purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction(); - - const firstRefund = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { - accessType: "admin", - method: "POST", - body: { - type: "one-time-purchase", - id: purchaseTransaction.id, - refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "5000" }], - }, - }); - expect(firstRefund.status).toBe(200); - // Wait for the first fee row to land before firing the second refund so the - // assertion below is unambiguous about "only one row exists". - await waitForPlatformFeeEvent(); - - const secondRefund = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { - accessType: "admin", - method: "POST", - body: { - type: "one-time-purchase", - id: purchaseTransaction.id, - refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "5000" }], - }, - }); - expect(secondRefund.body.code).toBe("ONE_TIME_PURCHASE_ALREADY_REFUNDED"); - - const feesRes = await niceBackendFetch("/api/latest/internal/payments/platform-fees", { - accessType: "admin", - }); - expect(feesRes.body.events).toHaveLength(1); -}); - -// Skipped against stripe-mock: the subscription refund path calls -// `stripe.invoices.retrieve(id, { expand: ["payments"] })` at refund time and -// expects `payments.data` with a paid payment carrying a `payment_intent`. -// stripe-mock returns its default invoice fixture which doesn't populate -// payments, and the mock-override plumbing (`stack_stripe_mock_data`) is -// webhook-time-only — it doesn't propagate to unrelated API calls made later. -// -// TODO(platform-fees): close this coverage gap via one of — -// (a) patch stripe-mock to echo a paid payment on invoices.retrieve when a -// sibling payment_intent.succeeded webhook was previously replayed for -// the same invoice, -// (b) thread `stack_stripe_mock_data` overrides through `getStripeForAccount` -// on the refund path so tests can stub invoices.retrieve, -// (c) run this under a real-Stripe CI job with an onboarded connected -// account (matches the manual-QA path in this PR's description). -it.skip("records a PlatformFeeEvent when a live-mode subscription is refunded", async () => { - const { subscriptionTransaction } = await createLiveModeSubscriptionTransaction(); - - const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { - accessType: "admin", - method: "POST", - body: { - type: "subscription", - id: subscriptionTransaction.id, - refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "1000" }], - }, - }); - expect(refundRes.status).toBe(200); - - const feesRes = await waitForPlatformFeeEvent(); - expect(feesRes.body.events).toHaveLength(1); - // 0.9% of 1000 stripe-units ($10) = 9 stripe-units. - expect(feesRes.body.events[0].amount).toBe(9); -}); From 54fa22efc92f66efc59227dd0d8d19738f3441b8 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 4 May 2026 13:31:38 -0700 Subject: [PATCH 07/10] chore: have tests describe limits of rounding behavior OTPs require application_fee_amounts so we calc them using a helper. However this means that for really small purchases, we charge nothing. We explicitly document this --- .../backend/src/lib/payments/platform-fees.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/lib/payments/platform-fees.ts b/apps/backend/src/lib/payments/platform-fees.ts index f60f35431a..69cc987791 100644 --- a/apps/backend/src/lib/payments/platform-fees.ts +++ b/apps/backend/src/lib/payments/platform-fees.ts @@ -12,6 +12,17 @@ export function getApplicationFeeBps(projectId: string): number { 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 { const bps = getApplicationFeeBps(options.projectId); if (bps === 0) return 0; @@ -32,12 +43,21 @@ import.meta.vitest?.describe("platform fee helpers", (test) => { 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", ({ expect }) => { + 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 is 0 for internal project", ({ expect }) => { + 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("getApplicationFeePercentOrUndefined returns 0.9 for non-internal", ({ expect }) => { From e166b7d2f5ce09a8fdf1fe9143edd3bf92d6a46a Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 4 May 2026 14:12:25 -0700 Subject: [PATCH 08/10] chore: cleanup unused code and comments --- .../purchases/purchase-session/route.tsx | 14 --- .../backend/src/lib/payments/platform-fees.ts | 16 ++- apps/e2e/tests/backend/helpers/payments.ts | 116 +----------------- 3 files changed, 15 insertions(+), 131 deletions(-) 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 67d6e60f29..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 @@ -93,20 +93,6 @@ export const POST = createSmartRouteHandler({ const existingItem = existingStripeSub.items.data[0]; const product = await stripe.products.create({ name: data.product.displayName ?? "Subscription" }); if (selectedPrice.interval) { - // TODO (platform-fees): this is a plan-switch mid-cycle that returns - // `latest_invoice.confirmation_secret`, so an upgrade/proration invoice - // is created synchronously. `application_fee_percent` is applied to - // invoices generated from the subscription's normal billing cycle, but - // per Stripe's subscription/proration docs the immediately-generated - // upgrade invoice may not inherit the newly-set fee percent — i.e. we - // may miss collecting our 0.9% on the proration invoice itself even - // though all later renewals of the new plan are covered. Best-effort - // until we either (a) observe the behaviour against a real onboarded - // Connect account, or (b) listen for the resulting `invoice.created` - // webhook and stamp `application_fee_amount` on the invoice before it - // finalises. - // Refs: https://docs.stripe.com/connect/subscriptions - // https://docs.stripe.com/billing/subscriptions/prorations const applicationFeePercent = getApplicationFeePercentOrUndefined(tenancy.project.id); const updated = await stripe.subscriptions.update(conflicting.stripeSubscriptionId, { payment_behavior: 'default_incomplete', diff --git a/apps/backend/src/lib/payments/platform-fees.ts b/apps/backend/src/lib/payments/platform-fees.ts index 69cc987791..ab7acf248c 100644 --- a/apps/backend/src/lib/payments/platform-fees.ts +++ b/apps/backend/src/lib/payments/platform-fees.ts @@ -1,14 +1,18 @@ +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; -const INTERNAL_PROJECT_ID = "internal"; - export function getApplicationFeeBps(projectId: string): number { - if (projectId === INTERNAL_PROJECT_ID) return 0; + if (projectId === "internal") return 0; return APPLICATION_FEE_BPS; } @@ -24,6 +28,9 @@ export function getApplicationFeeBps(projectId: string): number { * 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); @@ -60,6 +67,9 @@ import.meta.vitest?.describe("platform fee helpers", (test) => { 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); }); diff --git a/apps/e2e/tests/backend/helpers/payments.ts b/apps/e2e/tests/backend/helpers/payments.ts index 332b373773..32dc375070 100644 --- a/apps/e2e/tests/backend/helpers/payments.ts +++ b/apps/e2e/tests/backend/helpers/payments.ts @@ -1,3 +1,4 @@ +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"; @@ -7,16 +8,6 @@ export function createDefaultPaymentsConfig(testMode: boolean | undefined) { 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", @@ -120,7 +111,7 @@ export async function createLiveModeOneTimePurchaseTransaction(options: { quanti }, }; - const webhookSecret = process.env.STACK_STRIPE_WEBHOOK_SECRET ?? "mock_stripe_webhook_secret"; + 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 }); @@ -135,106 +126,3 @@ export async function createLiveModeOneTimePurchaseTransaction(options: { quanti return { userId, transactionsRes, purchaseTransaction }; } - -/** - * Sets up a live-mode subscription by injecting an invoice.paid webhook with - * billing_reason=subscription_create. After this, the tenancy DB has a - * Subscription row and a SubscriptionInvoice row marked as the creation - * invoice, which is what the refund endpoint's subscription path expects. - */ -export async function createLiveModeSubscriptionTransaction() { - const config = await setupProjectWithPaymentsConfig({ testMode: false }); - const { userId } = await Auth.fastSignUp(); - - 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: "sub-product" }); - const stackTestTenancyId = code.split("_")[0]; - const product = config.payments.products["sub-product"]; - - const idSuffix = randomUUID().replace(/-/g, ""); - const stripeSubscriptionId = `sub_live_refund_${idSuffix}`; - const stripeInvoiceId = `in_live_refund_${idSuffix}`; - const stripeCustomerId = `cus_live_refund_${idSuffix}`; - const nowSec = Math.floor(Date.now() / 1000); - - const subscription = { - id: stripeSubscriptionId, - status: "active", - items: { - data: [ - { - id: `si_live_refund_${idSuffix}`, - quantity: 1, - current_period_start: nowSec - 60, - current_period_end: nowSec + 60 * 60 * 24 * 30, - }, - ], - }, - metadata: { - productId: "sub-product", - product: JSON.stringify(product), - priceId: "monthly", - }, - cancel_at_period_end: false, - }; - - const invoice = { - id: stripeInvoiceId, - customer: stripeCustomerId, - billing_reason: "subscription_create", - status: "paid", - total: 100000, - hosted_invoice_url: `https://example.test/invoice/${stripeInvoiceId}`, - lines: { - data: [ - { - parent: { - subscription_item_details: { - subscription: stripeSubscriptionId, - }, - }, - }, - ], - }, - stack_stripe_mock_data: { - "accounts.retrieve": { metadata: { tenancyId: stackTestTenancyId } }, - "customers.retrieve": { metadata: { customerId: userId, customerType: "USER" } }, - "subscriptions.list": { data: [subscription] }, - }, - }; - - const webhookPayload = { - id: `evt_live_refund_${idSuffix}`, - type: "invoice.paid", - account: accountId, - data: { object: invoice }, - }; - - const webhookSecret = process.env.STACK_STRIPE_WEBHOOK_SECRET ?? "mock_stripe_webhook_secret"; - const webhookRes = await Payments.sendStripeWebhook(webhookPayload, { 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 subscriptionTransaction = transactionsRes.body.transactions.find( - (tx: any) => tx.type === "purchase" || tx.type === "subscription-start" - ); - expect(subscriptionTransaction).toBeDefined(); - - return { - userId, - stripeSubscriptionId, - stripeInvoiceId, - subscriptionTransaction, - transactionsRes, - }; -} From a2aaf207b67204de3a1e46a060f384d3fb4ad9fa Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 4 May 2026 14:30:31 -0700 Subject: [PATCH 09/10] chore: make name for eventId in e2e payments more intuitive --- apps/e2e/tests/backend/helpers/payments.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/e2e/tests/backend/helpers/payments.ts b/apps/e2e/tests/backend/helpers/payments.ts index 32dc375070..ec29698805 100644 --- a/apps/e2e/tests/backend/helpers/payments.ts +++ b/apps/e2e/tests/backend/helpers/payments.ts @@ -83,8 +83,8 @@ export async function createLiveModeOneTimePurchaseTransaction(options: { quanti 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 eventId = `evt_otp_purchase_${idSuffix}`; + const paymentIntentId = `pi_otp_purchase_${idSuffix}`; const paymentIntentPayload = { id: eventId, type: "payment_intent.succeeded", From c4e201b45f810d838ee3935b514dea1809339656 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 4 May 2026 15:22:20 -0700 Subject: [PATCH 10/10] chore: document float division --- apps/backend/src/lib/payments/platform-fees.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/backend/src/lib/payments/platform-fees.ts b/apps/backend/src/lib/payments/platform-fees.ts index ab7acf248c..c919621000 100644 --- a/apps/backend/src/lib/payments/platform-fees.ts +++ b/apps/backend/src/lib/payments/platform-fees.ts @@ -36,6 +36,18 @@ export function computeApplicationFeeAmount(options: { amountStripeUnits: number 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;