From ca299a35bf371361138f5e8d132f4ad882e2c679 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 8 May 2026 15:43:19 -0700 Subject: [PATCH] payments: rework refund flow to three-knob API (amount, revoke, end-sub) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the per-entry `refund_entries: [{ entry_index, quantity, amount_usd }]` schema with a flat `{ amount_usd, revoke_product, end_subscription? }` shape on the admin refund endpoint. Refund state is now derived from the bulldozer ledger (`refund::` rows) rather than the legacy `refundedAt` Prisma column, so multiple partial refunds can run against a single purchase up to the remaining cap. Adds support for refunding any subscription invoice via `invoice_id` (start or renewal). Refund rows surface in the listing endpoint as `type: "refund"` with adjusted_by linkage that handles both new and legacy formats. Stripe idempotency keys are now derived from `(tenancyId, sourceTxnId, amount, prior_refunded)` so network retries dedupe at Stripe while intentional partials still get distinct keys. Dashboard refund dialog rebuilt around the three toggles. The `transaction-builder.ts` helpers that the old listing path used are gone — the listing reads bulldozer directly. Known follow-ups (documented in code): cap-check race window under concurrent refunds (a Postgres advisory lock would help, but bulldozer's embedded BEGIN/COMMIT prevents an outer Prisma tx from scoping the writes), and Stripe vs. DB non-atomicity if a write fails after a successful Stripe refund. --- .../payments/transactions/refund/route.tsx | 960 +++++++++++------- .../internal/payments/transactions/route.tsx | 118 ++- .../transactions/transaction-builder.ts | 300 +----- .../backend/src/lib/payments/refund-txn-id.ts | 59 ++ .../payments/schema/phase-1/transactions.ts | 10 + .../data-table/transaction-table.tsx | 257 ++--- .../v1/internal/transactions-refund.test.ts | 631 ++++-------- .../src/interface/admin-interface.ts | 19 +- .../src/interface/crud/transactions.ts | 1 + packages/stack-shared/src/known-errors.tsx | 5 + .../apps/implementations/admin-app-impl.ts | 15 +- .../stack-app/apps/interfaces/admin-app.ts | 7 +- 12 files changed, 1104 insertions(+), 1278 deletions(-) create mode 100644 apps/backend/src/lib/payments/refund-txn-id.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 023dc4ffa0..c2ebeca0dd 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,12 +1,19 @@ -import { buildOneTimePurchaseTransaction, buildSubscriptionTransaction, resolveSelectedPriceFromProduct } from "@/app/api/latest/internal/payments/transactions/transaction-builder"; +import { createHash, randomUUID } from "node:crypto"; +import { Prisma } from "@/generated/prisma/client"; +import { createBulldozerExecutionContext, toQueryableSqlQuery } from "@/lib/bulldozer/db/index"; +import { quoteSqlStringLiteral } from "@/lib/bulldozer/db/utilities"; import { bulldozerWriteManualTransaction, bulldozerWriteOneTimePurchase, bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; -import type { ManualTransactionRow } from "@/lib/payments/schema/types"; +import { REFUND_TXN_PREFIX } from "@/lib/payments/refund-txn-id"; +import { resolveSelectedPriceFromProduct } from "@/app/api/latest/internal/payments/transactions/transaction-builder"; +import { ONE_TIME_PURCHASE_PRODUCT_GRANT_ENTRY_INDEX, SUBSCRIPTION_START_PRODUCT_GRANT_ENTRY_INDEX } from "@/lib/payments/schema/phase-1/transactions"; +import { paymentsSchema } from "@/lib/payments/schema/singleton"; +import type { ManualTransactionRow, TransactionEntryData } from "@/lib/payments/schema/types"; import { getStripeForAccount } from "@/lib/stripe"; -import { getPrismaClientForTenancy } from "@/prisma-client"; +import type { Tenancy } from "@/lib/tenancies"; +import { getPrismaClientForTenancy, type PrismaClientTransaction } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -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 { adaptSchema, adminAuthTypeSchema, moneyAmountSchema, productSchema, 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"; @@ -17,14 +24,9 @@ const USD_CURRENCY = SUPPORTED_CURRENCIES.find((currency) => currency.code === " ?? 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. + * Builds parameters for `stripe.refunds.create`. The platform-fee invariant — + * we never let Stripe reverse our charge-leg 0.9% application fee on refund — + * lives here so it has exactly one source of truth. */ export function buildStripeRefundParams(args: { paymentIntentId: string, @@ -39,11 +41,38 @@ export function buildStripeRefundParams(args: { }; } -function getTotalUsdStripeUnits(options: { product: InferType, priceId: string | null, quantity: number }) { - const selectedPrice = resolveSelectedPriceFromProduct(options.product, options.priceId ?? null); +/** + * Formats stripe units as a decimal money string with the currency's full + * decimal places — this is the shape that round-trips through + * `moneyAmountToStripeUnits` (which strips the dot and parseInts the result). + * E.g. for USD: 5000 → "50.00", 1 → "0.01", 100 → "1.00". + */ +function stripeUnitsToMoneyAmount(stripeUnits: number): string { + if (!Number.isFinite(stripeUnits) || Math.trunc(stripeUnits) !== stripeUnits) { + throw new StackAssertionError("Stripe units must be an integer", { stripeUnits }); + } + const absolute = Math.abs(stripeUnits); + const decimals = USD_CURRENCY.decimals; + const units = absolute.toString().padStart(decimals + 1, "0"); + const integerPart = units.slice(0, -decimals) || "0"; + const fractionalPart = units.slice(-decimals); + return `${integerPart}.${fractionalPart}`; +} + +function readProductLineId(product: InferType): string | null { + const productLineId = Reflect.get(product, "productLineId"); + return typeof productLineId === "string" ? productLineId : null; +} + +function getTotalUsdStripeUnits(options: { + product: InferType, + priceId: string | null, + quantity: number, +}): number { + const selectedPrice = resolveSelectedPriceFromProduct(options.product, options.priceId); const usdPrice = selectedPrice?.USD; if (typeof usdPrice !== "string") { - throw new KnownErrors.SchemaError("Refund amounts can only be specified for USD-priced purchases."); + throw new KnownErrors.SchemaError("Refunds are only supported for USD-priced purchases."); } if (!Number.isFinite(options.quantity) || Math.trunc(options.quantity) !== options.quantity) { throw new StackAssertionError("Purchase quantity is not an integer", { quantity: options.quantity }); @@ -51,151 +80,163 @@ function getTotalUsdStripeUnits(options: { product: InferType(); - const entryByIndex = new Map( - options.entries.map((entry, index) => [index, entry]), - ); - - for (const refundEntry of options.refundEntries) { - if (!Number.isFinite(refundEntry.quantity) || Math.trunc(refundEntry.quantity) !== refundEntry.quantity) { - throw new KnownErrors.SchemaError("Refund quantity must be an integer."); - } - if (refundEntry.quantity < 0) { - throw new KnownErrors.SchemaError("Refund quantity cannot be negative."); - } - if (seenEntryIndexes.has(refundEntry.entry_index)) { - throw new KnownErrors.SchemaError("Refund entries cannot contain duplicate entry indexes."); - } - seenEntryIndexes.add(refundEntry.entry_index); - const entry = entryByIndex.get(refundEntry.entry_index); - if (!entry) { - throw new KnownErrors.SchemaError("Refund entry index is invalid."); - } - if (entry.type !== "product_grant") { - throw new KnownErrors.SchemaError("Refund entries must reference product grant entries."); - } - if (refundEntry.quantity > entry.quantity) { - throw new KnownErrors.SchemaError("Refund quantity cannot exceed purchased quantity."); - } - } +function makeRefundTxnId(sourceTxnId: string): string { + return `${REFUND_TXN_PREFIX}${sourceTxnId}:${randomUUID()}`; } -function getRefundedQuantity(refundEntries: RefundEntrySelection[]) { - let total = 0; - for (const refundEntry of refundEntries) { - total += refundEntry.quantity; - } - return total; +/** + * Derive a deterministic Stripe idempotency key from the tenancy, source + * transaction, refund amount, and the cumulative amount already refunded + * before this call. A network-level retry of the same admin click hits all + * three identical inputs and dedupes at Stripe. Two intentional partials of + * the same amount get distinct keys because `priorRefundedStripeUnits` + * advances after the first one commits. + */ +function makeStripeIdempotencyKey(args: { + tenancyId: string, + sourceTxnId: string, + amountStripeUnits: number, + priorRefundedStripeUnits: number, +}): string { + const fingerprint = `${args.tenancyId}:${args.sourceTxnId}:${args.amountStripeUnits}:${args.priorRefundedStripeUnits}`; + return `refund:${createHash("sha256").update(fingerprint).digest("hex").slice(0, 32)}`; } -function getRefundAmountStripeUnits(refundEntries: RefundEntrySelection[]) { - let total = 0; - for (const refundEntry of refundEntries) { - total += moneyAmountToStripeUnits(refundEntry.amount_usd, USD_CURRENCY); - } - return total; +function buildProductRevocationEntry(options: { + customerType: "user" | "team" | "custom", + customerId: string, + sourceTxnId: string, + productGrantEntryIndex: number, + productId: string | null, + productLineId: string | null, + quantity: number, +}): Extract { + return { + type: "product-revocation", + customerType: options.customerType, + customerId: options.customerId, + adjustedTransactionId: options.sourceTxnId, + adjustedEntryIndex: options.productGrantEntryIndex, + quantity: options.quantity, + productId: options.productId, + productLineId: options.productLineId, + }; } -function stripeUnitsToMoneyAmount(stripeUnits: number): string { - if (!Number.isFinite(stripeUnits) || Math.trunc(stripeUnits) !== stripeUnits) { - throw new StackAssertionError("Stripe units must be an integer", { stripeUnits }); - } - const absolute = Math.abs(stripeUnits); - const decimals = USD_CURRENCY.decimals; - const units = absolute.toString().padStart(decimals + 1, "0"); - const integerPart = units.slice(0, -decimals) || "0"; - const fractionalPart = units.slice(-decimals).replace(/0+$/, ""); - return fractionalPart.length > 0 ? `${integerPart}.${fractionalPart}` : integerPart; +/** + * Money-transfer entry on a refund row. The amount is stored as a positive + * decimal money string; the parent `type: "refund"` is the semantic + * discriminator that tells consumers this is money flowing back to the + * customer. (Storing a literal negative would break `moneyAmountSchema`, + * which requires non-negative values.) + */ +function buildMoneyTransferEntry(options: { + customerType: "user" | "team" | "custom", + customerId: string, + refundAmountStripeUnits: number, +}): Extract { + return { + type: "money-transfer", + customerType: options.customerType, + customerId: options.customerId, + chargedAmount: { + USD: stripeUnitsToMoneyAmount(options.refundAmountStripeUnits), + }, + }; } -function negateMoneyAmount(amount: string): string { - if (amount === "0") { - return "0"; +// ── Bulldozer reads: prior refund summary for a source txn ───────────────── + +type PriorRefundSummary = { + refundedStripeUnits: number, + productRevoked: boolean, +}; + +async function readPriorRefundSummary(options: { + prisma: PrismaClientTransaction, + tenancyId: string, + customerType: "user" | "team" | "custom", + customerId: string, + sourceTxnId: string, +}): Promise { + const executionContext = createBulldozerExecutionContext(); + const baseSql = toQueryableSqlQuery(paymentsSchema.transactions.listRowsInGroup(executionContext, { + start: "start", + end: "end", + startInclusive: true, + endInclusive: true, + })); + const sql = ` + SELECT "__rows"."rowdata" AS "rowData" + FROM (${baseSql}) AS "__rows" + WHERE "__rows"."rowdata"->>'tenancyId' = ${quoteSqlStringLiteral(options.tenancyId).sql} + AND "__rows"."rowdata"->>'type' = 'refund' + AND "__rows"."rowdata"->>'customerType' = ${quoteSqlStringLiteral(options.customerType).sql} + AND "__rows"."rowdata"->>'customerId' = ${quoteSqlStringLiteral(options.customerId).sql} + -- LIKE pattern is safe today because source txnIds are + -- 'sub-start:' / 'sub-renewal:' / 'otp:' — none of + -- which contain LIKE metacharacters (percent / underscore / backslash). + -- If a future source format introduces those, escape them before + -- interpolation. + AND ("__rows"."rowdata"->>'txnId') LIKE ${quoteSqlStringLiteral(`${REFUND_TXN_PREFIX}${options.sourceTxnId}:%`).sql} + `; + const rows = await options.prisma.$queryRaw>`${Prisma.raw(sql)}`; + let refundedStripeUnits = 0; + let productRevoked = false; + for (const row of rows) { + const rowData = row.rowData; + if (typeof rowData !== "object" || rowData === null) continue; + const entries = Reflect.get(rowData, "entries"); + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + if (typeof entry !== "object" || entry === null) continue; + const type = Reflect.get(entry, "type"); + if (type === "product-revocation") { + const adjustedTxnId = Reflect.get(entry, "adjustedTransactionId"); + if (adjustedTxnId === options.sourceTxnId) { + productRevoked = true; + } + } else if (type === "money-transfer") { + const chargedAmount = Reflect.get(entry, "chargedAmount"); + if (typeof chargedAmount !== "object" || chargedAmount === null) continue; + const usd = Reflect.get(chargedAmount, "USD"); + if (typeof usd !== "string") continue; + // Refund money-transfer entries store positive amounts (the refund + // row's `type: "refund"` carries the sign); guard against legacy data + // that may have a leading minus. + const absolute = usd.startsWith("-") ? usd.slice(1) : usd; + refundedStripeUnits += moneyAmountToStripeUnits(absolute as MoneyAmount, USD_CURRENCY); + } + } } - return `-${amount}`; + return { refundedStripeUnits, productRevoked }; } -function readProductLineId(product: InferType): string | null { - const productLineId = Reflect.get(product, "productLineId"); - return typeof productLineId === "string" ? productLineId : null; -} +// ── Stripe payment-intent resolution for invoice refunds ─────────────────── -function getProductGrantEntry(options: { entries: TransactionEntry[], entryIndex: number }): Extract { - const entry = options.entries[options.entryIndex]; - if (entry.type !== "product_grant") { - throw new StackAssertionError("Refund entry must reference a product grant entry", { entryIndex: options.entryIndex, entry }); +async function resolveInvoicePaymentIntentId(stripe: Stripe, stripeInvoiceId: string): Promise { + const invoice = await stripe.invoices.retrieve(stripeInvoiceId, { expand: ["payments"] }); + const payments = invoice.payments?.data; + if (!payments || payments.length === 0) { + throw new StackAssertionError("Invoice has no payments", { stripeInvoiceId }); + } + const paidPayment = payments.find((payment) => payment.status === "paid"); + if (!paidPayment) { + throw new StackAssertionError("Invoice has no paid payment", { stripeInvoiceId }); } - return entry; + const paymentIntentId = paidPayment.payment.payment_intent; + if (!paymentIntentId || typeof paymentIntentId !== "string") { + throw new StackAssertionError("Payment has no payment intent", { stripeInvoiceId }); + } + return paymentIntentId; } -function buildRefundManualTransaction(options: { - sourceKind: "subscription" | "one-time-purchase", - sourceId: string, - sourceTransactionId: string, - tenancyId: string, - sourceEntries: TransactionEntry[], - refundEntries: RefundEntrySelection[], - refundAmountStripeUnits: number, - productLineId: string | null, - paymentProvider: "test_mode" | "stripe", - refundedAt: Date, -}): { rowId: string, rowData: ManualTransactionRow } { - const productGrantEntry = getProductGrantEntry({ entries: options.sourceEntries, entryIndex: 0 }); - const revocationEntries = options.refundEntries.map((refundEntry) => { - const adjustedEntry = getProductGrantEntry({ - entries: options.sourceEntries, - entryIndex: refundEntry.entry_index, - }); - return { - type: "product-revocation" as const, - customerType: adjustedEntry.customer_type, - customerId: adjustedEntry.customer_id, - adjustedTransactionId: options.sourceTransactionId, - adjustedEntryIndex: refundEntry.entry_index, - quantity: refundEntry.quantity, - productId: adjustedEntry.product_id, - productLineId: options.productLineId, - }; - }); - const refundAmount = negateMoneyAmount(stripeUnitsToMoneyAmount(options.refundAmountStripeUnits)); - const createdAtMillis = options.refundedAt.getTime(); - return { - rowId: `refund:${options.sourceKind}:${options.sourceId}`, - rowData: { - txnId: `${options.sourceId}:refund`, - tenancyId: options.tenancyId, - effectiveAtMillis: createdAtMillis, - type: "refund", - entries: [ - ...revocationEntries, - { - type: "money-transfer", - customerType: productGrantEntry.customer_type, - customerId: productGrantEntry.customer_id, - chargedAmount: { - USD: refundAmount, - }, - }, - ], - customerType: productGrantEntry.customer_type, - customerId: productGrantEntry.customer_id, - paymentProvider: options.paymentProvider, - createdAtMillis, - }, - }; -} +// ── Route ───────────────────────────────────────────────────────────────── export const POST = createSmartRouteHandler({ - metadata: { - hidden: true, - }, + metadata: { hidden: true }, request: yupObject({ auth: yupObject({ type: adminAuthTypeSchema.defined(), @@ -205,230 +246,453 @@ export const POST = createSmartRouteHandler({ body: yupObject({ type: yupString().oneOf(["subscription", "one-time-purchase"]).defined(), id: yupString().defined(), - refund_entries: yupArray( - yupObject({ - entry_index: yupNumber().integer().defined(), - quantity: yupNumber().integer().defined(), - amount_usd: moneyAmountSchema(USD_CURRENCY).defined(), - }).defined(), - ).defined(), - }).defined() + invoice_id: yupString().optional(), + amount_usd: moneyAmountSchema(USD_CURRENCY).defined(), + revoke_product: yupBoolean().defined(), + end_subscription: yupBoolean().optional(), + }).defined(), }), response: yupObject({ statusCode: yupNumber().oneOf([200]).defined(), bodyType: yupString().oneOf(["json"]).defined(), body: yupObject({ success: yupBoolean().defined(), + refund_transaction_id: yupString().defined(), }).defined(), }), handler: async ({ auth, body }) => { const prisma = await getPrismaClientForTenancy(auth.tenancy); - const refundEntries = body.refund_entries.map((entry) => ({ - ...entry, - amount_usd: entry.amount_usd as MoneyAmount, - })); - if (body.type === "subscription") { - const subscription = await prisma.subscription.findUnique({ - where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } }, - }); - if (!subscription) { - throw new KnownErrors.SubscriptionInvoiceNotFound(body.id); - } - if (subscription.refundedAt) { - throw new KnownErrors.SubscriptionAlreadyRefunded(body.id); - } - const subscriptionInvoices = await prisma.subscriptionInvoice.findMany({ - where: { - tenancyId: auth.tenancy.id, - isSubscriptionCreationInvoice: true, - subscription: { - tenancyId: auth.tenancy.id, - id: body.id, - } - } - }); - if (subscriptionInvoices.length === 0) { - throw new KnownErrors.SubscriptionInvoiceNotFound(body.id); - } - if (subscriptionInvoices.length > 1) { - throw new StackAssertionError("Multiple subscription creation invoices found for subscription", { subscriptionId: body.id }); - } - const subscriptionInvoice = subscriptionInvoices[0]; - const stripe = await getStripeForAccount({ tenancy: auth.tenancy }); - const invoice = await stripe.invoices.retrieve(subscriptionInvoice.stripeInvoiceId, { expand: ["payments"] }); - const payments = invoice.payments?.data; - if (!payments || payments.length === 0) { - throw new StackAssertionError("Invoice has no payments", { invoiceId: subscriptionInvoice.stripeInvoiceId }); + const amountUsd = body.amount_usd as MoneyAmount; + const amountStripeUnits = moneyAmountToStripeUnits(amountUsd, USD_CURRENCY); + const revokeProduct = body.revoke_product; + const endSubscription = body.end_subscription ?? false; + + if (amountStripeUnits < 0) { + throw new KnownErrors.SchemaError("Refund amount cannot be negative."); + } + + if (body.type === "one-time-purchase") { + if (body.invoice_id !== undefined) { + throw new KnownErrors.SchemaError("invoice_id is not applicable to one-time purchases."); } - const paidPayment = payments.find((payment) => payment.status === "paid"); - if (!paidPayment) { - throw new StackAssertionError("Invoice has no paid payment", { invoiceId: subscriptionInvoice.stripeInvoiceId }); + if (endSubscription) { + throw new KnownErrors.SchemaError("end_subscription is not applicable to one-time purchases."); } - const paymentIntentId = paidPayment.payment.payment_intent; - if (!paymentIntentId || typeof paymentIntentId !== "string") { - throw new StackAssertionError("Payment has no payment intent", { invoiceId: subscriptionInvoice.stripeInvoiceId }); + if (amountStripeUnits === 0 && !revokeProduct) { + throw new KnownErrors.SchemaError("Refund must do something: specify a non-zero amount or revoke the product."); } - const transaction = buildSubscriptionTransaction({ subscription }); - validateRefundEntries({ - entries: transaction.entries, - refundEntries, - }); - const refundedQuantity = getRefundedQuantity(refundEntries); - const totalStripeUnits = getTotalUsdStripeUnits({ - product: subscription.product as InferType, - priceId: subscription.priceId ?? null, - quantity: subscription.quantity, + return await handleOneTimePurchaseRefund({ + prisma, + tenancy: auth.tenancy, + purchaseId: body.id, + amountUsd, + amountStripeUnits, + revokeProduct, }); - const refundAmountStripeUnits = getRefundAmountStripeUnits(refundEntries); - if (refundAmountStripeUnits < 0) { - throw new KnownErrors.SchemaError("Refund amount cannot be negative."); - } - if (refundAmountStripeUnits > totalStripeUnits) { - throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount."); - } - await stripe.refunds.create(buildStripeRefundParams({ + } + + // subscription path + if (revokeProduct && !endSubscription) { + throw new KnownErrors.SchemaError("Revoking a subscription's product also requires ending the subscription. Set end_subscription to true."); + } + if (amountStripeUnits === 0 && !revokeProduct && !endSubscription) { + throw new KnownErrors.SchemaError("Refund must do something: specify a non-zero amount, revoke the product, or end the subscription."); + } + return await handleSubscriptionRefund({ + prisma, + tenancy: auth.tenancy, + subscriptionId: body.id, + invoiceId: body.invoice_id, + amountUsd, + amountStripeUnits, + revokeProduct, + endSubscription, + }); + }, +}); + +// ── Subscription refund handler ──────────────────────────────────────────── +// +// Known concurrency / atomicity gaps (deferred to a follow-up): +// +// 1. **Race on cap check.** Two concurrent refund requests for the same +// source can both call `readPriorRefundSummary` before either commits its +// refund row, so both pass the cap check and over-refund. Wrapping this +// flow in a Prisma `$transaction` does NOT fix it — `bulldozerWriteManualTransaction` +// embeds its own `BEGIN; ... COMMIT;` (see `lib/bulldozer/db/index.ts:162`), +// so its writes commit independently of any outer Prisma tx. A real fix +// needs either a bulldozer-aware mutex (writes-table sentinel row, advisory +// lock taken on a long-lived dedicated connection, etc.) or a "pending +// refund intent" pattern that participates in the cap calc before Stripe is +// called. In practice, refunds are admin-only and rare, so the race window +// is small. +// +// 2. **Stripe + DB are not atomic.** A successful `stripe.refunds.create` +// followed by a write failure leaves the customer refunded with no ledger +// row. The Stripe idempotency key matches `refundTxnId`, but each call +// generates a fresh uuid, so a caller-side retry would issue a second +// real Stripe refund. No out-of-band reconciliation today. Tracked as a +// follow-up alongside (1). +async function handleSubscriptionRefund(options: { + prisma: Awaited>, + tenancy: Tenancy, + subscriptionId: string, + invoiceId: string | undefined, + amountUsd: MoneyAmount, + amountStripeUnits: number, + revokeProduct: boolean, + endSubscription: boolean, +}) { + const { prisma, tenancy } = options; + const subscription = await prisma.subscription.findUnique({ + where: { tenancyId_id: { tenancyId: tenancy.id, id: options.subscriptionId } }, + }); + if (!subscription) { + throw new KnownErrors.SubscriptionInvoiceNotFound(options.subscriptionId); + } + // Legacy refund backstop: the pre-three-knob flow set `refundedAt` and + // gated all further refunds on it. The new bulldozer-derived prior-refund + // summary doesn't see those legacy refunds, so without this gate an admin + // could double-refund through Stripe on a previously-refunded purchase. + if (subscription.refundedAt) { + throw new KnownErrors.SchemaError("This subscription has already been refunded under a previous version of the refund flow; further refunds are not permitted from this endpoint."); + } + + const customerType = subscription.customerType.toLowerCase() as "user" | "team" | "custom"; + const isTestMode = subscription.creationSource === "TEST_MODE"; + const product = subscription.product as InferType; + const productLineId = readProductLineId(product); + + if (isTestMode && options.amountStripeUnits > 0) { + throw new KnownErrors.SchemaError("Test-mode subscriptions have no money to refund. Set amount_usd to \"0\"."); + } + + // Determine which invoice this refund targets — defaults to the start invoice. + let invoice: { id: string, stripeInvoiceId: string, amountTotal: number | null } | null = null; + let sourceTxnId: string; + if (options.invoiceId !== undefined) { + const found = await prisma.subscriptionInvoice.findUnique({ + where: { tenancyId_id: { tenancyId: tenancy.id, id: options.invoiceId } }, + }); + if (!found || found.stripeSubscriptionId !== subscription.stripeSubscriptionId) { + throw new KnownErrors.SubscriptionInvoiceNotFound(options.invoiceId); + } + invoice = { id: found.id, stripeInvoiceId: found.stripeInvoiceId, amountTotal: found.amountTotal }; + sourceTxnId = found.isSubscriptionCreationInvoice + ? `sub-start:${subscription.id}` + : `sub-renewal:${found.id}`; + } else if (!isTestMode) { + const startInvoices = await prisma.subscriptionInvoice.findMany({ + where: { + tenancyId: tenancy.id, + isSubscriptionCreationInvoice: true, + subscription: { tenancyId: tenancy.id, id: subscription.id }, + }, + }); + if (startInvoices.length === 0) { + throw new KnownErrors.SubscriptionInvoiceNotFound(subscription.id); + } + if (startInvoices.length > 1) { + throw new StackAssertionError("Multiple subscription creation invoices found for subscription", { subscriptionId: subscription.id }); + } + const startInvoice = startInvoices[0]; + invoice = { id: startInvoice.id, stripeInvoiceId: startInvoice.stripeInvoiceId, amountTotal: startInvoice.amountTotal }; + sourceTxnId = `sub-start:${subscription.id}`; + } else { + // test-mode sub has no invoice; refund references the synthetic start txn. + sourceTxnId = `sub-start:${subscription.id}`; + } + + // Cap = original − sum(prior refunds for this source txn). Test-mode subs + // have no money flow (amount must be 0 anyway, see check above), so the cap + // is irrelevant — short-circuit to 0 to avoid a USD-only throw on non-USD + // test-mode products. Live mode reads the invoice's amountTotal. + const totalStripeUnits = isTestMode + ? 0 + : (invoice?.amountTotal ?? getTotalUsdStripeUnits({ + product, + priceId: subscription.priceId ?? null, + quantity: subscription.quantity, + })); + + const prior = await readPriorRefundSummary({ + prisma, + tenancyId: tenancy.id, + customerType, + customerId: subscription.customerId, + sourceTxnId, + }); + const remainingStripeUnits = Math.max(0, totalStripeUnits - prior.refundedStripeUnits); + if (options.amountStripeUnits > remainingStripeUnits) { + throw new KnownErrors.SchemaError(`Refund amount cannot exceed the remaining refundable amount ($${stripeUnitsToMoneyAmount(remainingStripeUnits)}).`); + } + if (options.revokeProduct && prior.productRevoked) { + throw new KnownErrors.SchemaError("This subscription's product has already been revoked."); + } + + const refundTxnId = makeRefundTxnId(sourceTxnId); + + // ── Stripe side ─────────────────────────────────────────────────────── + if (options.amountStripeUnits > 0 && !isTestMode) { + const stripe = await getStripeForAccount({ tenancy }); + const paymentIntentId = await resolveInvoicePaymentIntentId(stripe, invoice!.stripeInvoiceId); + await stripe.refunds.create( + buildStripeRefundParams({ paymentIntentId, - amountStripeUnits: refundAmountStripeUnits, - })); - const refundedAt = new Date(); - if (refundedQuantity > 0) { - if (!subscription.stripeSubscriptionId) { - throw new StackAssertionError("Stripe subscription id missing for refund", { subscriptionId: subscription.id }); - } - const stripeSubscription = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId); - if (stripeSubscription.items.data.length === 0) { - throw new StackAssertionError("Stripe subscription has no items", { subscriptionId: subscription.id }); - } - const subscriptionItem = stripeSubscription.items.data[0]; - if (!Number.isFinite(subscriptionItem.quantity) || Math.trunc(subscriptionItem.quantity ?? 0) !== subscriptionItem.quantity) { - throw new StackAssertionError("Stripe subscription item quantity is not an integer", { - subscriptionId: subscription.id, - itemQuantity: subscriptionItem.quantity, - }); - } - const currentQuantity = subscriptionItem.quantity ?? 0; - const newQuantity = currentQuantity - refundedQuantity; - if (newQuantity < 0) { - throw new StackAssertionError("Refund quantity exceeds Stripe subscription item quantity", { - subscriptionId: subscription.id, - currentQuantity, - refundedQuantity, - }); - } - await stripe.subscriptions.update(subscription.stripeSubscriptionId, { - cancel_at_period_end: newQuantity === 0, - items: [{ - id: subscriptionItem.id, - quantity: newQuantity, - }], - }); - await prisma.subscription.update({ - where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } }, - data: { - cancelAtPeriodEnd: newQuantity === 0, - refundedAt, - }, - }); - } else { - await prisma.subscription.update({ - where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } }, - data: { refundedAt }, - }); - } - // dual write - prisma and bulldozer - const updatedSub = await prisma.subscription.findUniqueOrThrow({ - where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } }, - }); - await bulldozerWriteSubscription(prisma, updatedSub); - const manualRefund = buildRefundManualTransaction({ - sourceKind: "subscription", - sourceId: subscription.id, - sourceTransactionId: `sub-start:${subscription.id}`, - tenancyId: auth.tenancy.id, - sourceEntries: transaction.entries, - refundEntries, - refundAmountStripeUnits, - productLineId: readProductLineId(subscription.product as InferType), - paymentProvider: subscription.creationSource === "TEST_MODE" ? "test_mode" : "stripe", - refundedAt, - }); - await bulldozerWriteManualTransaction(prisma, manualRefund.rowId, manualRefund.rowData); - } else { - const purchase = await prisma.oneTimePurchase.findUnique({ - where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } }, - }); - if (!purchase) { - throw new KnownErrors.OneTimePurchaseNotFound(body.id); - } - if (purchase.refundedAt) { - throw new KnownErrors.OneTimePurchaseAlreadyRefunded(body.id); - } - if (purchase.creationSource === "TEST_MODE") { - throw new KnownErrors.TestModePurchaseNonRefundable(); - } - const stripe = await getStripeForAccount({ tenancy: auth.tenancy }); - if (!purchase.stripePaymentIntentId) { - throw new KnownErrors.OneTimePurchaseNotFound(body.id); - } - const transaction = buildOneTimePurchaseTransaction({ purchase }); - validateRefundEntries({ - entries: transaction.entries, - refundEntries, - }); - const totalStripeUnits = getTotalUsdStripeUnits({ - product: purchase.product as InferType, - priceId: purchase.priceId ?? null, - quantity: purchase.quantity, - }); - const refundAmountStripeUnits = getRefundAmountStripeUnits(refundEntries); - if (refundAmountStripeUnits < 0) { - throw new KnownErrors.SchemaError("Refund amount cannot be negative."); - } - if (refundAmountStripeUnits > totalStripeUnits) { - throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount."); - } - await stripe.refunds.create(buildStripeRefundParams({ - paymentIntentId: purchase.stripePaymentIntentId, - amountStripeUnits: refundAmountStripeUnits, + amountStripeUnits: options.amountStripeUnits, metadata: { - tenancyId: auth.tenancy.id, - purchaseId: purchase.id, + tenancyId: tenancy.id, + subscriptionId: subscription.id, + ...(invoice ? { invoiceId: invoice.id } : {}), }, - })); - const refundedAt = new Date(); - await prisma.oneTimePurchase.update({ - where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } }, - data: { refundedAt }, - }); - // dual write - prisma and bulldozer - const updatedPurchase = await prisma.oneTimePurchase.findUniqueOrThrow({ - where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } }, - }); - await bulldozerWriteOneTimePurchase(prisma, updatedPurchase); - const manualRefund = buildRefundManualTransaction({ - sourceKind: "one-time-purchase", - sourceId: purchase.id, - sourceTransactionId: `otp:${purchase.id}`, - tenancyId: auth.tenancy.id, - sourceEntries: transaction.entries, - refundEntries, - refundAmountStripeUnits, - productLineId: readProductLineId(purchase.product as InferType), - paymentProvider: "stripe", - refundedAt, + }), + { + idempotencyKey: makeStripeIdempotencyKey({ + tenancyId: tenancy.id, + sourceTxnId, + amountStripeUnits: options.amountStripeUnits, + priorRefundedStripeUnits: prior.refundedStripeUnits, + }), + }, + ); + } + + // ── Lifecycle: Prisma + Stripe ──────────────────────────────────────── + const now = new Date(); + let updatedSub: typeof subscription | null = null; + if (options.revokeProduct) { + // Immediate end. Stripe sub canceled, Prisma endedAt=now → timefold + // auto-emits subscription-end with item-quantity-expire entries. Preserve + // an existing `endedAt` if the sub already ended naturally — clobbering + // it with a later `now` would re-trigger the sub-end event with stale + // outstandingGrants state. + if (!isTestMode && subscription.stripeSubscriptionId) { + const stripe = await getStripeForAccount({ tenancy }); + // Idempotent cancel: the Stripe sub may already be canceled (natural + // end before this refund). resource_missing / "already canceled" is + // not an error from our perspective. + try { + await stripe.subscriptions.cancel(subscription.stripeSubscriptionId); + } catch (e: unknown) { + const code = (e as { code?: string }).code; + if (code !== "resource_missing" && code !== "subscription_canceled") { + throw e; + } + } + } + updatedSub = await prisma.subscription.update({ + where: { tenancyId_id: { tenancyId: tenancy.id, id: subscription.id } }, + data: { + // Don't touch `cancelAtPeriodEnd` — it's meaningless once `endedAt` + // is in the past, and writing `true` alongside an immediate `endedAt` + // creates inconsistent state for any reader that consults the flag + // without joining `endedAt`. + status: "canceled", + canceledAt: subscription.canceledAt ?? now, + endedAt: subscription.endedAt ?? now, + }, + }); + } else if (options.endSubscription) { + // End at period end. Items follow natural lifecycle when sub-end fires + // at period boundary. + if (!isTestMode && subscription.stripeSubscriptionId) { + const stripe = await getStripeForAccount({ tenancy }); + await stripe.subscriptions.update(subscription.stripeSubscriptionId, { + cancel_at_period_end: true, }); - await bulldozerWriteManualTransaction(prisma, manualRefund.rowId, manualRefund.rowData); } + updatedSub = await prisma.subscription.update({ + where: { tenancyId_id: { tenancyId: tenancy.id, id: subscription.id } }, + data: { + cancelAtPeriodEnd: true, + canceledAt: subscription.canceledAt ?? now, + endedAt: subscription.endedAt ?? subscription.currentPeriodEnd, + }, + }); + } + + if (updatedSub) { + await bulldozerWriteSubscription(prisma, updatedSub); + } + + // ── Refund row ──────────────────────────────────────────────────────── + const refundEntries: TransactionEntryData[] = []; + if (options.amountStripeUnits > 0 && !isTestMode) { + refundEntries.push(buildMoneyTransferEntry({ + customerType, + customerId: subscription.customerId, + refundAmountStripeUnits: options.amountStripeUnits, + })); + } + if (options.revokeProduct) { + refundEntries.push(buildProductRevocationEntry({ + customerType, + customerId: subscription.customerId, + sourceTxnId, + productGrantEntryIndex: SUBSCRIPTION_START_PRODUCT_GRANT_ENTRY_INDEX, + productId: subscription.productId ?? null, + productLineId, + quantity: subscription.quantity, + })); + } + + const nowMillis = now.getTime(); + const refundRow: ManualTransactionRow = { + txnId: refundTxnId, + tenancyId: tenancy.id, + effectiveAtMillis: nowMillis, + type: "refund", + entries: refundEntries, + customerType, + customerId: subscription.customerId, + paymentProvider: isTestMode ? "test_mode" : "stripe", + createdAtMillis: nowMillis, + }; + await bulldozerWriteManualTransaction(prisma, refundTxnId, refundRow); + + return { + statusCode: 200 as const, + bodyType: "json" as const, + body: { success: true, refund_transaction_id: refundTxnId }, + }; +} + +// ── One-time-purchase refund handler ─────────────────────────────────────── +// +// See the concurrency / atomicity caveats on `handleSubscriptionRefund` +// above — the cap-check race and Stripe-vs-DB non-atomicity apply equally +// to OTPs. +async function handleOneTimePurchaseRefund(options: { + prisma: Awaited>, + tenancy: Tenancy, + purchaseId: string, + amountUsd: MoneyAmount, + amountStripeUnits: number, + revokeProduct: boolean, +}) { + const { prisma, tenancy } = options; + const purchase = await prisma.oneTimePurchase.findUnique({ + where: { tenancyId_id: { tenancyId: tenancy.id, id: options.purchaseId } }, + }); + if (!purchase) { + throw new KnownErrors.OneTimePurchaseNotFound(options.purchaseId); + } + // Legacy refund backstop — see handleSubscriptionRefund above. + if (purchase.refundedAt) { + throw new KnownErrors.SchemaError("This purchase has already been refunded under a previous version of the refund flow; further refunds are not permitted from this endpoint."); + } + + const customerType = purchase.customerType.toLowerCase() as "user" | "team" | "custom"; + const isTestMode = purchase.creationSource === "TEST_MODE"; + const product = purchase.product as InferType; + const productLineId = readProductLineId(product); + + if (isTestMode && options.amountStripeUnits > 0) { + throw new KnownErrors.SchemaError("Test-mode purchases have no money to refund. Set amount_usd to \"0\"."); + } + + const sourceTxnId = `otp:${purchase.id}`; + const totalStripeUnits = isTestMode + ? 0 + : getTotalUsdStripeUnits({ + product, + priceId: purchase.priceId ?? null, + quantity: purchase.quantity, + }); + + const prior = await readPriorRefundSummary({ + prisma, + tenancyId: tenancy.id, + customerType, + customerId: purchase.customerId, + sourceTxnId, + }); + const remainingStripeUnits = Math.max(0, totalStripeUnits - prior.refundedStripeUnits); + if (options.amountStripeUnits > remainingStripeUnits) { + throw new KnownErrors.SchemaError(`Refund amount cannot exceed the remaining refundable amount ($${stripeUnitsToMoneyAmount(remainingStripeUnits)}).`); + } + if (options.revokeProduct && prior.productRevoked) { + throw new KnownErrors.SchemaError("This purchase's product has already been revoked."); + } + + const refundTxnId = makeRefundTxnId(sourceTxnId); - return { - statusCode: 200, - bodyType: "json", - body: { - success: true, + // ── Stripe side ─────────────────────────────────────────────────────── + if (options.amountStripeUnits > 0 && !isTestMode) { + if (!purchase.stripePaymentIntentId) { + throw new StackAssertionError("Live-mode one-time purchase missing stripePaymentIntentId", { purchaseId: purchase.id }); + } + const stripe = await getStripeForAccount({ tenancy }); + await stripe.refunds.create( + buildStripeRefundParams({ + paymentIntentId: purchase.stripePaymentIntentId, + amountStripeUnits: options.amountStripeUnits, + metadata: { tenancyId: tenancy.id, purchaseId: purchase.id }, + }), + { + idempotencyKey: makeStripeIdempotencyKey({ + tenancyId: tenancy.id, + sourceTxnId, + amountStripeUnits: options.amountStripeUnits, + priorRefundedStripeUnits: prior.refundedStripeUnits, + }), }, - }; - }, -}); + ); + } + + // ── Lifecycle: Prisma ───────────────────────────────────────────────── + const now = new Date(); + if (options.revokeProduct) { + const updatedPurchase = await prisma.oneTimePurchase.update({ + where: { tenancyId_id: { tenancyId: tenancy.id, id: purchase.id } }, + data: { revokedAt: now }, + }); + await bulldozerWriteOneTimePurchase(prisma, updatedPurchase); + } + + // ── Refund row ──────────────────────────────────────────────────────── + const refundEntries: TransactionEntryData[] = []; + if (options.amountStripeUnits > 0 && !isTestMode) { + refundEntries.push(buildMoneyTransferEntry({ + customerType, + customerId: purchase.customerId, + refundAmountStripeUnits: options.amountStripeUnits, + })); + } + if (options.revokeProduct) { + refundEntries.push(buildProductRevocationEntry({ + customerType, + customerId: purchase.customerId, + sourceTxnId, + productGrantEntryIndex: ONE_TIME_PURCHASE_PRODUCT_GRANT_ENTRY_INDEX, + productId: purchase.productId ?? null, + productLineId, + quantity: purchase.quantity, + })); + } + + const nowMillis = now.getTime(); + const refundRow: ManualTransactionRow = { + txnId: refundTxnId, + tenancyId: tenancy.id, + effectiveAtMillis: nowMillis, + type: "refund", + entries: refundEntries, + customerType, + customerId: purchase.customerId, + paymentProvider: isTestMode ? "test_mode" : "stripe", + createdAtMillis: nowMillis, + }; + await bulldozerWriteManualTransaction(prisma, refundTxnId, refundRow); + + return { + statusCode: 200 as const, + bodyType: "json" as const, + body: { success: true, refund_transaction_id: refundTxnId }, + }; +} + +// ── Inline tests for the Stripe params builder ───────────────────────────── import.meta.vitest?.describe("buildStripeRefundParams", (test) => { test("always sets refund_application_fee: false to keep our 0.9% with the platform", ({ expect }) => { @@ -447,9 +711,6 @@ import.meta.vitest?.describe("buildStripeRefundParams", (test) => { 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 }); @@ -457,3 +718,4 @@ import.meta.vitest?.describe("buildStripeRefundParams", (test) => { expect(withoutMeta.refund_application_fee).toBe(false); }); }); + diff --git a/apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx b/apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx index b75dd2836a..0ec4ec9cf4 100644 --- a/apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx +++ b/apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx @@ -2,6 +2,7 @@ import { Prisma } from "@/generated/prisma/client"; import { createBulldozerExecutionContext, toQueryableSqlQuery } from "@/lib/bulldozer/db/index"; import { quoteSqlStringLiteral } from "@/lib/bulldozer/db/utilities"; import { paymentsSchema } from "@/lib/payments/schema/singleton"; +import { REFUND_TXN_PREFIX, parseRefundTxnId } from "@/lib/payments/refund-txn-id"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { TRANSACTION_TYPES, transactionSchema, type Transaction, type TransactionEntry, type TransactionType } from "@stackframe/stack-shared/dist/interface/crud/transactions"; @@ -15,7 +16,8 @@ type LedgerTransactionType = | "subscription-start" | "one-time-purchase" | "manual-item-quantity-change" - | "subscription-renewal"; + | "subscription-renewal" + | "refund"; type LedgerCursor = { createdAtMillis: number, @@ -41,6 +43,7 @@ const DEFAULT_LEDGER_TRANSACTION_TYPES: readonly LedgerTransactionType[] = [ "one-time-purchase", "manual-item-quantity-change", "subscription-renewal", + "refund", ]; function parseCursor(cursor: string): LedgerCursor { @@ -89,6 +92,9 @@ function getLedgerTypesForFilter(type: string | undefined): readonly LedgerTrans case "subscription-renewal": { return ["subscription-renewal"]; } + case "refund": { + return ["refund"]; + } case "subscription-cancellation": case "chargeback": case "product-change": { @@ -124,7 +130,8 @@ function readLedgerTransactionRow(rowData: unknown): LedgerTransactionRow { type !== "subscription-start" && type !== "one-time-purchase" && type !== "manual-item-quantity-change" && - type !== "subscription-renewal" + type !== "subscription-renewal" && + type !== "refund" ) { throw new StackAssertionError("Unexpected ledger transaction type", { rowData }); } @@ -174,6 +181,14 @@ function parseSourceId(row: LedgerTransactionRow): string { } return row.txnId.slice("miqc:".length); } + if (row.type === "refund") { + const parsed = parseRefundTxnId(row.txnId); + if (parsed) return parsed.uuid; + // Legacy refund format `:refund` (pre-three-knob refund flow). + // No structured uuid to extract; the txnId itself is unique, so use it + // directly. Avoids 500ing the listing for tenancies with prior refunds. + return row.txnId; + } if (!row.txnId.startsWith("sub-renewal:")) { throw new StackAssertionError("subscription-renewal transaction id has invalid prefix", { txnId: row.txnId }); } @@ -527,6 +542,9 @@ function mapLedgerTransactionTypeToApiType(type: LedgerTransactionType): Transac if (type === "subscription-renewal") { return "subscription-renewal"; } + if (type === "refund") { + return "refund"; + } return "purchase"; } @@ -538,50 +556,49 @@ function buildAdjustedByFromRefunds(options: { return adjustedByFromRefunds ?? []; } +/** + * Builds the source-txn → refunds lookup. New-format refunds are linked by + * parsing the txnId (`refund::`). Legacy refund rows + * (`:refund`, written by the pre-three-knob flow) don't have a + * parseable txnId, so we fall back to scanning their `product-revocation` + * entries for `adjustedTransactionId`. This keeps the "refunded" badge + * accurate across both formats. + */ function buildAdjustedByLookupFromRefundRows(rows: unknown[]): Map { const lookup = new Map(); + // Note on `entry_index`: for new-format refunds we always emit `0`. The + // SDK contract still exposes this field, but with the three-knob refund + // model there is no longer a per-source-entry refund concept — a refund + // is "amount + revoke + end-sub" against the whole source. The dashboard + // doesn't render based on this value. Legacy refund rows keep their + // original entry index for back-compat with any external readers. + const addLink = (sourceTxnId: string, refundTxnId: string, entryIndex: number) => { + const existing = lookup.get(sourceTxnId) ?? []; + lookup.set(sourceTxnId, [...existing, { transaction_id: refundTxnId, entry_index: entryIndex }]); + }; for (const rowData of rows) { if (!isRecord(rowData)) { throw new StackAssertionError("Refund transaction rowData is not an object", { rowData }); } const refundTxnId = Reflect.get(rowData, "txnId"); - const entries = Reflect.get(rowData, "entries"); if (typeof refundTxnId !== "string" || refundTxnId.length === 0) { throw new StackAssertionError("Refund transaction row is missing txnId", { rowData }); } - if (!Array.isArray(entries)) { - throw new StackAssertionError("Refund transaction row has invalid entries", { rowData }); + const parsed = parseRefundTxnId(refundTxnId); + if (parsed) { + addLink(parsed.sourceTxnId, refundTxnId, 0); + continue; } - for (let entryIdx = 0; entryIdx < entries.length; entryIdx++) { - const entry = entries[entryIdx]; - if (!isRecord(entry)) { - throw new StackAssertionError("Refund transaction entry is not an object", { entry, rowData }); - } - if (entry.type !== "product-revocation") { - continue; - } - const adjustedTransactionId = Reflect.get(entry, "adjustedTransactionId"); - const adjustedEntryIndex = Reflect.get(entry, "adjustedEntryIndex"); - if ( - typeof adjustedTransactionId !== "string" || - adjustedTransactionId.length === 0 || - typeof adjustedEntryIndex !== "number" || - !Number.isInteger(adjustedEntryIndex) || - adjustedEntryIndex < 0 - ) { - throw new StackAssertionError("Refund transaction has invalid product-revocation back reference", { - entry, - rowData, - }); - } - const existing = lookup.get(adjustedTransactionId) ?? []; - lookup.set(adjustedTransactionId, [ - ...existing, - { - transaction_id: refundTxnId, - entry_index: entryIdx, - }, - ]); + // Legacy fallback: extract source txns from product-revocation entries. + const entries = Reflect.get(rowData, "entries"); + if (!Array.isArray(entries)) continue; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (!isRecord(entry)) continue; + if (entry.type !== "product-revocation") continue; + const adjustedTxnId = Reflect.get(entry, "adjustedTransactionId"); + if (typeof adjustedTxnId !== "string" || adjustedTxnId.length === 0) continue; + addLink(adjustedTxnId, refundTxnId, i); } } return lookup; @@ -661,18 +678,33 @@ async function getTransactions(options: { const hasMore = parsedRows.length > options.limit; const pageRows = hasMore ? parsedRows.slice(0, options.limit) : parsedRows; + // Source rows are anything that could be refunded — refund rows themselves + // can't be the target of another refund. We only look up refunds for these. + const pageSourceRows = pageRows.filter((row) => row.type !== "refund"); let refundRows: Array<{ rowData: unknown }> = []; - if (pageRows.length > 0) { - const adjustedTransactionIdsSql = pageRows.map((row) => quoteSqlStringLiteral(row.txnId).sql).join(", "); + if (pageSourceRows.length > 0) { + // New-format refunds: txnId starts with 'refund::'. + // LIKE pattern is safe today because source txnIds (sub-start:, + // sub-renewal:, otp:, etc.) contain no LIKE metacharacters + // (percent / underscore / backslash). Escape if a future source-id format + // includes them. + const refundLikeClauses = pageSourceRows + .map((row) => `"__rows"."rowdata"->>'txnId' LIKE ${quoteSqlStringLiteral(`${REFUND_TXN_PREFIX}${row.txnId}:%`).sql}`) + .join(" OR "); + // Legacy refunds (`:refund`) link via product-revocation entries. + const adjustedTransactionIdsSql = pageSourceRows + .map((row) => quoteSqlStringLiteral(row.txnId).sql) + .join(", "); + const legacyRefundClause = `EXISTS ( + SELECT 1 + FROM jsonb_array_elements("__rows"."rowdata"->'entries') AS "__entry" + WHERE "__entry"->>'type' = 'product-revocation' + AND "__entry"->>'adjustedTransactionId' IN (${adjustedTransactionIdsSql}) + )`; const refundWhereClauses = [ `"__rows"."rowdata"->>'tenancyId' = ${quoteSqlStringLiteral(options.tenancyId).sql}`, `"__rows"."rowdata"->>'type' = 'refund'`, - `EXISTS ( - SELECT 1 - FROM jsonb_array_elements("__rows"."rowdata"->'entries') AS "__entry" - WHERE "__entry"->>'type' = 'product-revocation' - AND "__entry"->>'adjustedTransactionId' IN (${adjustedTransactionIdsSql}) - )`, + `((${refundLikeClauses}) OR ${legacyRefundClause})`, ]; if (options.customerType) { refundWhereClauses.push(`"__rows"."rowdata"->>'customerType' = ${quoteSqlStringLiteral(options.customerType).sql}`); diff --git a/apps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.ts b/apps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.ts index eee81038b5..c84c7652b9 100644 --- a/apps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.ts +++ b/apps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.ts @@ -1,10 +1,11 @@ -import type { ItemQuantityChange, OneTimePurchase, Subscription, SubscriptionInvoice } from "@/generated/prisma/client"; -import type { Transaction, TransactionEntry } from "@stackframe/stack-shared/dist/interface/crud/transactions"; -import { SUPPORTED_CURRENCIES, type Currency } from "@stackframe/stack-shared/dist/utils/currency-constants"; -import { typedToLowercase } from "@stackframe/stack-shared/dist/utils/strings"; -import { productSchema } from "@stackframe/stack-shared/dist/schema-fields"; -import { InferType } from "yup"; -import { productToInlineProduct } from "@/lib/payments"; +/** + * Helpers for resolving a single price from a product snapshot. Originally + * this file held a family of `build*Transaction` constructors that the old + * refund/listing endpoints used to hand-roll API `Transaction` shapes from + * Prisma rows. The three-knob refund rework moved both flows onto the + * bulldozer-derived listing path, leaving only `resolveSelectedPriceFromProduct` + * still in use (called by `refund/route.tsx` to compute the USD cap). + */ type SelectedPriceMetadata = { interval?: unknown, @@ -24,10 +25,6 @@ export type ProductWithPrices = { prices?: Record | "include-by-default", } | null | undefined; -type ProductSnapshot = (TransactionEntry & { type: "product_grant" })["product"]; - -const REFUND_TRANSACTION_SUFFIX = ":refund"; - export function resolveSelectedPriceFromProduct(product: ProductWithPrices, priceId?: string | null): SelectedPrice | null { if (!product) return null; if (!priceId) return null; @@ -38,284 +35,3 @@ export function resolveSelectedPriceFromProduct(product: ProductWithPrices, pric const { serverOnly: _serverOnly, freeTrial: _freeTrial, ...rest } = selected as any; return rest as SelectedPrice; } - -function multiplyMoneyAmount(amount: string, quantity: number, currency: Currency): string { - if (!Number.isFinite(quantity) || Math.trunc(quantity) !== quantity) { - throw new Error("Quantity must be an integer when multiplying money amounts"); - } - if (quantity === 0) return "0"; - - const multiplierNegative = quantity < 0; - const safeQuantity = BigInt(Math.abs(quantity)); - - const isNegative = amount.startsWith("-"); - const normalized = isNegative ? amount.slice(1) : amount; - const [wholePart, fractionalPart = ""] = normalized.split("."); - const paddedFractional = fractionalPart.padEnd(currency.decimals, "0"); - const smallestUnit = BigInt(`${wholePart || "0"}${paddedFractional.padEnd(currency.decimals, "0")}`); - const multiplied = smallestUnit * safeQuantity; - - const totalDecimals = currency.decimals; - let multipliedStr = multiplied.toString(); - if (totalDecimals > 0) { - if (multipliedStr.length <= totalDecimals) { - multipliedStr = multipliedStr.padStart(totalDecimals + 1, "0"); - } - } - - let integerPart: string; - let fractionalResult: string | null = null; - if (totalDecimals === 0) { - integerPart = multipliedStr; - } else { - integerPart = multipliedStr.slice(0, -totalDecimals) || "0"; - const rawFraction = multipliedStr.slice(-totalDecimals); - const trimmedFraction = rawFraction.replace(/0+$/, ""); - fractionalResult = trimmedFraction.length > 0 ? trimmedFraction : null; - } - - integerPart = integerPart.replace(/^0+(?=\d)/, "") || "0"; - - let result = fractionalResult ? `${integerPart}.${fractionalResult}` : integerPart; - const shouldBeNegative = (isNegative ? -1 : 1) * (multiplierNegative ? -1 : 1) === -1; - if (shouldBeNegative && result !== "0") { - result = `-${result}`; - } - - return result; -} - -function buildChargedAmount(price: SelectedPrice | null, quantity: number): Record { - if (!price) return {}; - const result: Record = {}; - for (const currency of SUPPORTED_CURRENCIES) { - const rawAmount = price[currency.code as keyof typeof price]; - if (typeof rawAmount !== "string") continue; - const multiplied = multiplyMoneyAmount(rawAmount, quantity, currency); - if (multiplied === "0") continue; - result[currency.code] = multiplied; - } - return result; -} - -function createMoneyTransferEntry(options: { - customerType: "user" | "team" | "custom", - customerId: string, - chargedAmount: Record, - skip: boolean, -}): TransactionEntry | null { - if (options.skip) return null; - const chargedCurrencies = Object.keys(options.chargedAmount); - if (chargedCurrencies.length === 0) return null; - const netUsd = options.chargedAmount.USD ?? "0"; - return { - type: "money_transfer", - adjusted_transaction_id: null, - adjusted_entry_index: null, - customer_type: options.customerType, - customer_id: options.customerId, - charged_amount: options.chargedAmount, - net_amount: { - USD: netUsd, - }, - }; -} - -function createProductGrantEntry(options: { - customerType: "user" | "team" | "custom", - customerId: string, - productId: string | null, - product: ProductSnapshot, - priceId: string | null, - quantity: number, - subscriptionId?: string, - oneTimePurchaseId?: string, -}): TransactionEntry { - return { - type: "product_grant", - adjusted_transaction_id: null, - adjusted_entry_index: null, - customer_type: options.customerType, - customer_id: options.customerId, - product_id: options.productId, - product: options.product, - price_id: options.priceId, - quantity: options.quantity, - subscription_id: options.subscriptionId, - one_time_purchase_id: options.oneTimePurchaseId, - }; -} - -function buildRefundAdjustments(options: { refundedAt?: Date | null, entries: TransactionEntry[], transactionId: string }): Transaction["adjusted_by"] { - if (!options.refundedAt) { - return []; - } - const productGrantIndex = options.entries.findIndex((entry) => entry.type === "product_grant"); - const entryIndex = productGrantIndex >= 0 ? productGrantIndex : 0; - return [{ - transaction_id: `${options.transactionId}${REFUND_TRANSACTION_SUFFIX}`, - entry_index: entryIndex, - }]; -} - -export function buildSubscriptionTransaction(options: { - subscription: Subscription, -}): Transaction { - const { subscription } = options; - const customerType = typedToLowercase(subscription.customerType); - const product = subscription.product as InferType; - const inlineProduct = productToInlineProduct(product); - const selectedPrice = resolveSelectedPriceFromProduct(product, subscription.priceId ?? null); - const quantity = subscription.quantity; - const chargedAmount = buildChargedAmount(selectedPrice, quantity); - const testMode = subscription.creationSource === "TEST_MODE"; - - const entries: TransactionEntry[] = [ - createProductGrantEntry({ - customerType, - customerId: subscription.customerId, - productId: subscription.productId ?? null, - product: inlineProduct, - priceId: subscription.priceId ?? null, - quantity, - subscriptionId: subscription.id, - }), - ]; - - const moneyTransfer = createMoneyTransferEntry({ - customerType, - customerId: subscription.customerId, - chargedAmount, - skip: testMode, - }); - if (moneyTransfer) { - entries.push(moneyTransfer); - } - - const adjustedBy = buildRefundAdjustments({ - refundedAt: subscription.refundedAt, - entries, - transactionId: subscription.id, - }); - - return { - id: subscription.id, - created_at_millis: subscription.createdAt.getTime(), - effective_at_millis: subscription.createdAt.getTime(), - type: "purchase", - entries, - adjusted_by: adjustedBy, - test_mode: testMode, - }; -} - -export function buildOneTimePurchaseTransaction(options: { - purchase: OneTimePurchase, -}): Transaction { - const { purchase } = options; - const customerType = typedToLowercase(purchase.customerType); - const product = purchase.product as InferType; - const inlineProduct = productToInlineProduct(product); - const selectedPrice = resolveSelectedPriceFromProduct(product, purchase.priceId ?? null); - const quantity = purchase.quantity; - const chargedAmount = buildChargedAmount(selectedPrice, quantity); - const testMode = purchase.creationSource === "TEST_MODE"; - - const entries: TransactionEntry[] = [ - createProductGrantEntry({ - customerType, - customerId: purchase.customerId, - productId: purchase.productId ?? null, - product: inlineProduct, - priceId: purchase.priceId ?? null, - quantity, - oneTimePurchaseId: purchase.id, - }), - ]; - - const moneyTransfer = createMoneyTransferEntry({ - customerType, - customerId: purchase.customerId, - chargedAmount, - skip: testMode, - }); - if (moneyTransfer) { - entries.push(moneyTransfer); - } - - const adjustedBy = buildRefundAdjustments({ - refundedAt: purchase.refundedAt, - entries, - transactionId: purchase.id, - }); - - return { - id: purchase.id, - created_at_millis: purchase.createdAt.getTime(), - effective_at_millis: purchase.createdAt.getTime(), - type: "purchase", - entries, - adjusted_by: adjustedBy, - test_mode: testMode, - }; -} - -export function buildItemQuantityChangeTransaction(options: { - change: ItemQuantityChange, -}): Transaction { - const { change } = options; - const customerType = typedToLowercase(change.customerType); - - const entries: TransactionEntry[] = [ - { - type: "item_quantity_change", - adjusted_transaction_id: null, - adjusted_entry_index: null, - customer_type: customerType, - customer_id: change.customerId, - item_id: change.itemId, - quantity: change.quantity, - }, - ]; - - return { - id: change.id, - created_at_millis: change.createdAt.getTime(), - effective_at_millis: change.createdAt.getTime(), - type: "manual-item-quantity-change", - entries, - adjusted_by: [], - test_mode: false, - }; -} - -export function buildSubscriptionRenewalTransaction(options: { - subscription: Subscription, - subscriptionInvoice: SubscriptionInvoice, -}): Transaction { - const { subscription, subscriptionInvoice } = options; - const product = subscription.product as InferType; - const selectedPrice = resolveSelectedPriceFromProduct(product, subscription.priceId ?? null); - const chargedAmount = buildChargedAmount(selectedPrice, subscription.quantity); - - const entries: TransactionEntry[] = []; - const moneyTransfer = createMoneyTransferEntry({ - customerType: typedToLowercase(subscription.customerType), - customerId: subscription.customerId, - chargedAmount, - skip: false, - }); - if (moneyTransfer) { - entries.push(moneyTransfer); - } - - return { - type: "subscription-renewal", - id: subscriptionInvoice.id, - test_mode: false, - entries, - adjusted_by: [], - created_at_millis: subscriptionInvoice.createdAt.getTime(), - effective_at_millis: subscriptionInvoice.createdAt.getTime(), - }; -} diff --git a/apps/backend/src/lib/payments/refund-txn-id.ts b/apps/backend/src/lib/payments/refund-txn-id.ts new file mode 100644 index 0000000000..3f0615b84d --- /dev/null +++ b/apps/backend/src/lib/payments/refund-txn-id.ts @@ -0,0 +1,59 @@ +export const REFUND_TXN_PREFIX = "refund:"; + +/** + * The set of source-transaction id prefixes that the refund flow can target. + * Pinned here so the LIKE-pattern safety invariant in `readPriorRefundSummary` + * and the listing route is testable: none of these may contain LIKE + * metacharacters (% / _ / \). If a future source format is added, the test + * below will fail loud rather than silently producing false-positive matches. + */ +export const REFUND_SOURCE_TXN_PREFIXES = [ + "sub-start:", + "sub-renewal:", + "otp:", +] as const; + +/** + * Parse a refund txnId of shape `refund::`. The sourceTxnId + * itself may contain colons (e.g. `sub-start:abc`), so we strip the leading + * `refund:` and the trailing `:`. Returns null for non-refund ids. + */ +export function parseRefundTxnId(txnId: string): { sourceTxnId: string, uuid: string } | null { + if (!txnId.startsWith(REFUND_TXN_PREFIX)) return null; + const rest = txnId.slice(REFUND_TXN_PREFIX.length); + const lastColon = rest.lastIndexOf(":"); + if (lastColon < 0) return null; + const sourceTxnId = rest.slice(0, lastColon); + const uuid = rest.slice(lastColon + 1); + if (sourceTxnId.length === 0 || uuid.length === 0) return null; + return { sourceTxnId, uuid }; +} + +import.meta.vitest?.describe("parseRefundTxnId", (test) => { + test("parses a refund txn id with a colon-containing source", ({ expect }) => { + const parsed = parseRefundTxnId("refund:sub-start:abc-123:550e8400-e29b-41d4-a716-446655440000"); + expect(parsed).toEqual({ + sourceTxnId: "sub-start:abc-123", + uuid: "550e8400-e29b-41d4-a716-446655440000", + }); + }); + test("parses an OTP refund txn id", ({ expect }) => { + const parsed = parseRefundTxnId("refund:otp:abc:550e8400-e29b-41d4-a716-446655440000"); + expect(parsed).toEqual({ + sourceTxnId: "otp:abc", + uuid: "550e8400-e29b-41d4-a716-446655440000", + }); + }); + test("returns null for non-refund txn ids", ({ expect }) => { + expect(parseRefundTxnId("sub-start:abc")).toBeNull(); + expect(parseRefundTxnId("otp:abc")).toBeNull(); + }); +}); + +import.meta.vitest?.describe("REFUND_SOURCE_TXN_PREFIXES", (test) => { + test("contains no SQL LIKE metacharacters (the LIKE-safety invariant for readPriorRefundSummary)", ({ expect }) => { + for (const prefix of REFUND_SOURCE_TXN_PREFIXES) { + expect(prefix).not.toMatch(/[%_\\]/); + } + }); +}); diff --git a/apps/backend/src/lib/payments/schema/phase-1/transactions.ts b/apps/backend/src/lib/payments/schema/phase-1/transactions.ts index b7e93021b5..e9d7147789 100644 --- a/apps/backend/src/lib/payments/schema/phase-1/transactions.ts +++ b/apps/backend/src/lib/payments/schema/phase-1/transactions.ts @@ -27,6 +27,16 @@ import type { SeedEventsStoredTables } from "./stored-tables"; const mapper = (sql: string) => ({ type: "mapper" as const, sql }); const predicate = (sql: string) => ({ type: "predicate" as const, sql }); +// ── Entry-index constants ────────────────────────────────────────────── +// These pin the position of the product-grant entry within source +// transactions. The refund flow uses them for `adjustedEntryIndex` on +// product-revocation entries; if the layouts above are reordered, both +// these constants and any persisted refund rows need to be reconciled. +// subscription-start: [active-subscription-start, product-grant, money-transfer?, ...] +// one-time-purchase: [product-grant, money-transfer?, ...] +export const SUBSCRIPTION_START_PRODUCT_GRANT_ENTRY_INDEX = 1; +export const ONE_TIME_PURCHASE_PRODUCT_GRANT_ENTRY_INDEX = 0; + export function createTransactionsTable(events: EventTables, manualTransactions: SeedEventsStoredTables['manualTransactions']) { diff --git a/apps/dashboard/src/components/data-table/transaction-table.tsx b/apps/dashboard/src/components/data-table/transaction-table.tsx index 6818d462cb..65bbfde4eb 100644 --- a/apps/dashboard/src/components/data-table/transaction-table.tsx +++ b/apps/dashboard/src/components/data-table/transaction-table.tsx @@ -5,9 +5,9 @@ 'use client'; import { useAdminApp } from '@/app/(main)/(protected)/projects/[projectId]/use-admin-app'; -import { ActionCell, ActionDialog, Alert, AlertDescription, AvatarCell, Badge, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'; +import { ActionCell, ActionDialog, Alert, AlertDescription, AvatarCell, Badge, Checkbox, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'; import type { Icon as PhosphorIcon } from '@phosphor-icons/react'; -import { ArrowClockwiseIcon, ArrowCounterClockwiseIcon, GearIcon, ProhibitIcon, QuestionIcon, ShoppingCartIcon, ShuffleIcon } from '@phosphor-icons/react'; +import { ArrowClockwiseIcon, ArrowCounterClockwiseIcon, GearIcon, ProhibitIcon, QuestionIcon, ReceiptXIcon, ShoppingCartIcon, ShuffleIcon } from '@phosphor-icons/react'; import { createDefaultDataGridState, DataGrid, DataGridToolbar, useDataSource, type DataGridColumnDef, type DataGridDataSource, type DataGridState } from '@stackframe/dashboard-ui-components'; import type { Transaction, TransactionEntry, TransactionType } from '@stackframe/stack-shared/dist/interface/crud/transactions'; import { TRANSACTION_TYPES } from '@stackframe/stack-shared/dist/interface/crud/transactions'; @@ -15,9 +15,7 @@ import { moneyAmountSchema } from '@stackframe/stack-shared/dist/schema-fields'; import { moneyAmountToStripeUnits } from '@stackframe/stack-shared/dist/utils/currencies'; import type { MoneyAmount } from '@stackframe/stack-shared/dist/utils/currency-constants'; import { SUPPORTED_CURRENCIES } from '@stackframe/stack-shared/dist/utils/currency-constants'; -import { throwErr } from '@stackframe/stack-shared/dist/utils/errors'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import { Link } from '../link'; type SourceType = 'subscription' | 'one_time' | 'item_quantity_change' | 'other'; @@ -43,7 +41,6 @@ type MoneyTransferEntry = Extract; type ProductGrantEntry = Extract; type ItemQuantityChangeEntry = Extract; type RefundTarget = { type: 'subscription' | 'one-time-purchase', id: string }; -type RefundEntrySelection = { entryIndex: number, quantity: number }; const USD_CURRENCY = SUPPORTED_CURRENCIES.find((currency) => currency.code === 'USD'); function isEntryWithCustomer(entry: TransactionEntry): entry is EntryWithCustomer { @@ -107,6 +104,9 @@ function formatTransactionTypeLabel(transactionType: TransactionType | null): Tr case 'chargeback': { return { label: 'Chargeback', Icon: ArrowCounterClockwiseIcon }; } + case 'refund': { + return { label: 'Refund', Icon: ReceiptXIcon }; + } case 'manual-item-quantity-change': { return { label: 'Manual Item Quantity Change', Icon: GearIcon }; } @@ -169,30 +169,11 @@ function pickChargedAmountDisplay(entry: MoneyTransferEntry | undefined): string return 'Non USD amount'; } -function getRefundableProductEntries(transaction: Transaction): Array<{ entryIndex: number, entry: ProductGrantEntry }> { - return transaction.entries.flatMap((entry, entryIndex) => ( - isProductGrantEntry(entry) ? [{ entryIndex, entry }] : [] - )); -} - function getProductDisplayName(entry: ProductGrantEntry): string { const product = entry.product as { display_name?: string } | null | undefined; return product?.display_name ?? entry.product_id ?? 'Product'; } -function getUsdUnitPrice(entry: ProductGrantEntry): MoneyAmount | null { - if (!entry.price_id) { - return null; - } - const product = entry.product as { prices?: Record | "include-by-default" } | null | undefined; - if (!product || !product.prices || product.prices === "include-by-default") { - return null; - } - const price = product.prices[entry.price_id]; - const usd = price?.USD; - return typeof usd === 'string' ? (usd as MoneyAmount) : null; -} - function describeDetail(transaction: Transaction, sourceType: SourceType): string { const productGrant = transaction.entries.find(isProductGrantEntry); if (productGrant) { @@ -234,184 +215,126 @@ function getTransactionSummary(transaction: Transaction): TransactionSummary { function RefundActionCell({ transaction, refundTarget }: { transaction: Transaction, refundTarget: RefundTarget | null }) { const app = useAdminApp(); const [isDialogOpen, setIsDialogOpen] = useState(false); - const [refundSelections, setRefundSelections] = useState([]); - const [refundAmountUsd, setRefundAmountUsd] = useState(''); + const [amountUsd, setAmountUsd] = useState('0'); + const [revokeProduct, setRevokeProduct] = useState(true); + const [endSubscription, setEndSubscription] = useState(true); const target = transaction.type === 'purchase' ? refundTarget : null; - const alreadyRefunded = transaction.adjusted_by.length > 0; - const productEntries = useMemo(() => getRefundableProductEntries(transaction), [transaction]); - const canRefund = !!target && !transaction.test_mode && !alreadyRefunded && productEntries.length > 0; + // Don't gate on `adjusted_by.length` here: the backend supports multiple + // partial refunds (and a separate revoke) until both caps are hit, and + // computes the actual remaining state from the bulldozer ledger. The button + // stays available; the backend rejects if there's nothing left to do. + const canRefund = !!target; const moneyTransferEntry = transaction.entries.find(isMoneyTransferEntry); const chargedAmountUsd = moneyTransferEntry ? (moneyTransferEntry.charged_amount.USD ?? null) : null; + const isSubscription = target?.type === 'subscription'; - useEffect(() => { - if (isDialogOpen) { - setRefundSelections(productEntries.map(({ entryIndex, entry }) => ({ - entryIndex, - quantity: entry.quantity, - }))); - setRefundAmountUsd(chargedAmountUsd ?? ''); - } - }, [chargedAmountUsd, isDialogOpen, productEntries]); - - const refundCandidates = useMemo(() => { - return productEntries.map(({ entryIndex, entry }) => ({ - entryIndex, - entry, - productName: getProductDisplayName(entry), - maxQuantity: entry.quantity, - unitPriceUsd: getUsdUnitPrice(entry), - })); - }, [productEntries]); - - const selectionByIndex = useMemo(() => { - return new Map(refundSelections.map((selection) => [selection.entryIndex, selection.quantity])); - }, [refundSelections]); - - const canComputeRefundEntries = refundCandidates.length > 0 && refundCandidates.every((candidate) => candidate.unitPriceUsd); - const selectedEntries = refundCandidates.map((candidate) => { - const selectedQuantity = selectionByIndex.get(candidate.entryIndex) ?? candidate.maxQuantity; - return { ...candidate, selectedQuantity }; - }); - const totalSelectedQuantity = selectedEntries.reduce((sum, entry) => sum + entry.selectedQuantity, 0); - - const refundValidation = useMemo(() => { - if (!chargedAmountUsd || !USD_CURRENCY) { - return { canSubmit: false, error: "Refund amounts are only supported for USD charges.", refundEntries: undefined }; + const validation = useMemo(() => { + if (!target || !USD_CURRENCY) { + return { canSubmit: false, error: null as string | null }; } - if (!refundAmountUsd) { - return { canSubmit: false, error: "Enter a refund amount.", refundEntries: undefined }; + if (!moneyAmountSchema(USD_CURRENCY).defined().isValidSync(amountUsd)) { + return { canSubmit: false, error: "Refund amount must be a valid USD amount." }; } - const isValid = moneyAmountSchema(USD_CURRENCY).defined().isValidSync(refundAmountUsd); - if (!isValid) { - return { canSubmit: false, error: "Refund amount must be a valid USD amount.", refundEntries: undefined }; - } - const refundUnits = moneyAmountToStripeUnits(refundAmountUsd as MoneyAmount, USD_CURRENCY); - const maxChargedUnits = moneyAmountToStripeUnits(chargedAmountUsd as MoneyAmount, USD_CURRENCY); + const refundUnits = moneyAmountToStripeUnits(amountUsd as MoneyAmount, USD_CURRENCY); if (refundUnits < 0) { - return { canSubmit: false, error: "Refund amount cannot be negative.", refundEntries: undefined }; - } - if (refundUnits > maxChargedUnits) { - return { canSubmit: false, error: `Refund amount cannot exceed $${chargedAmountUsd}.`, refundEntries: undefined }; - } - if (!canComputeRefundEntries) { - return { canSubmit: false, error: "Refund entries are only supported for USD-priced products.", refundEntries: undefined }; + return { canSubmit: false, error: "Refund amount cannot be negative." }; } - if (totalSelectedQuantity < 0) { - return { canSubmit: false, error: "Quantity cannot be negative.", refundEntries: undefined }; + if (refundUnits > 0 && !chargedAmountUsd) { + return { canSubmit: false, error: "This transaction has no money to refund (test mode or non-USD)." }; } - const maxUnits = maxChargedUnits; - const selectedUnits = selectedEntries.reduce((sum, entry) => { - if (!entry.unitPriceUsd) { - return sum; + if (chargedAmountUsd) { + const maxUnits = moneyAmountToStripeUnits(chargedAmountUsd as MoneyAmount, USD_CURRENCY); + if (refundUnits > maxUnits) { + return { canSubmit: false, error: `Refund amount cannot exceed $${chargedAmountUsd}.` }; } - const entryUnits = moneyAmountToStripeUnits(entry.unitPriceUsd, USD_CURRENCY) * entry.selectedQuantity; - return sum + entryUnits; - }, 0); - if (selectedUnits < 0) { - return { canSubmit: false, error: "Quantity cannot be negative.", refundEntries: undefined }; } - if (selectedUnits > maxUnits) { - return { canSubmit: false, error: `Refund amount cannot exceed $${chargedAmountUsd}.`, refundEntries: undefined }; + if (refundUnits === 0 && !revokeProduct && !endSubscription) { + return { canSubmit: false, error: "Refund must do something: enter an amount, revoke product, or end subscription." }; } - const entries = selectedEntries - .filter((entry) => entry.selectedQuantity > 0) - .map((entry) => ({ entryIndex: entry.entryIndex, quantity: entry.selectedQuantity })); - const fallbackEntry = selectedEntries[0] ?? throwErr("Refund entry missing for refund entries"); - const normalizedEntries = entries.length > 0 - ? entries - : [{ entryIndex: fallbackEntry.entryIndex, quantity: 0 }]; - const refundEntries = normalizedEntries.map((entry, index) => ({ - ...entry, - amountUsd: (index === 0 ? refundAmountUsd : "0") as MoneyAmount, - })); - return { canSubmit: true, error: null, refundEntries }; - }, [chargedAmountUsd, canComputeRefundEntries, refundAmountUsd, selectedEntries, totalSelectedQuantity]); + return { canSubmit: true, error: null }; + }, [target, amountUsd, chargedAmountUsd, revokeProduct, endSubscription]); return ( <> {target ? ( { + if (open) { + setAmountUsd(chargedAmountUsd ?? '0'); + setRevokeProduct(true); + setEndSubscription(isSubscription); + } + setIsDialogOpen(open); + }} title="Refund Transaction" danger cancelButton okButton={{ label: "Refund", onClick: async () => { - if (chargedAmountUsd && !refundValidation.canSubmit) { + if (!validation.canSubmit) { return "prevent-close"; } await app.refundTransaction({ ...target, - refundEntries: refundValidation.refundEntries ?? throwErr("Refund entries missing for refund"), + amountUsd: amountUsd as MoneyAmount, + revokeProduct, + ...(isSubscription ? { endSubscription } : {}), }); }, - props: chargedAmountUsd ? { disabled: !refundValidation.canSubmit } : undefined, + props: { disabled: !validation.canSubmit }, }} confirmText="Refunds cannot be undone" >
-

{`Refund this ${target.type === 'subscription' ? 'subscription' : 'one-time purchase'} transaction?`}

- {chargedAmountUsd ? ( -
-
- - setRefundAmountUsd(event.target.value)} - /> -
- {canComputeRefundEntries ? ( -
- - {selectedEntries.map((entry) => ( -
-
-
{entry.productName}
-
Purchased: {entry.maxQuantity}
-
- { - const raw = Number.parseInt(event.target.value, 10); - const clamped = Number.isNaN(raw) ? 0 : Math.min(Math.max(raw, 0), entry.maxQuantity); - setRefundSelections((prev) => prev.map((selection) => ( - selection.entryIndex === entry.entryIndex ? { ...selection, quantity: clamped } : selection - ))); - }} - className="w-24" - /> -
- ))} -
- ) : ( - - - Partial refunds are only available for USD-priced products. This will issue a full refund. - - - )} - {refundValidation.error ? ( - - {refundValidation.error} - - ) : null} +

{`Refund this ${isSubscription ? 'subscription' : 'one-time purchase'}?`}

+
+ + setAmountUsd(event.target.value)} + disabled={!chargedAmountUsd} + /> + {!chargedAmountUsd ? ( +

No money to refund (test mode or non-USD).

+ ) : null} +
+
+ { + const next = value === true; + setRevokeProduct(next); + if (next && isSubscription) setEndSubscription(true); + }} + /> + +
+ {isSubscription ? ( +
+ setEndSubscription(value === true)} + disabled={revokeProduct} + /> +
- ) : ( - - - Partial refunds are only available for USD charges. This will issue a full refund. - + ) : null} + {validation.error ? ( + + {validation.error} - )} + ) : null}
) : null} 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 3b110da09a..bd0c777e92 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,50 +1,58 @@ import { randomUUID } from "node:crypto"; import { expect } from "vitest"; import { it } from "../../../../../helpers"; -import { niceBackendFetch } from "../../../../backend-helpers"; +import { Auth, Payments, Project, niceBackendFetch } from "../../../../backend-helpers"; import { createLiveModeOneTimePurchaseTransaction, + createPurchaseCode, createTestModeTransaction, setupProjectWithPaymentsConfig, } from "../../../../helpers/payments"; -it("returns TestModePurchaseNonRefundable when refunding test mode one-time purchases", async () => { - await setupProjectWithPaymentsConfig(); - const { transactionId, userId } = await createTestModeTransaction("otp-product", "single"); - - const productsRes = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { - accessType: "client", +/** + * Spin up a project that has a subscription product configured, sign up a + * user, and create a test-mode subscription via the test-mode-purchase-session + * endpoint. Returns the new subscription's id. + */ +async function createTestModeSubscription(): Promise<{ subscriptionId: string, userId: string }> { + await Project.createAndSwitch(); + await Payments.setup(); + await Project.updateConfig({ + payments: { + testMode: true, + products: { + "sub-product": { + displayName: "Sub Product", + customerType: "user", + serverOnly: false, + stackable: false, + prices: { + monthly: { USD: "5000", interval: [1, "month"] }, + }, + includedItems: {}, + }, + }, + items: {}, + }, }); - expect(productsRes.status).toBe(200); - expect(productsRes.body.items).toHaveLength(1); - expect(productsRes.body.items[0].id).toBe("otp-product"); - - const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { + const { userId } = await Auth.fastSignUp(); + const code = await createPurchaseCode({ userId, productId: "sub-product" }); + const sessionRes = await niceBackendFetch("/api/latest/internal/payments/test-mode-purchase-session", { accessType: "admin", method: "POST", - body: { - type: "one-time-purchase", - id: transactionId, - refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "5000" }], - }, + body: { full_code: code, price_id: "monthly", quantity: 1 }, }); - expect(refundRes).toMatchInlineSnapshot(` - NiceResponse { - "status": 400, - "body": { - "code": "TEST_MODE_PURCHASE_NON_REFUNDABLE", - "error": "Test mode purchases are not refundable.", - }, - "headers": Headers { - "x-stack-known-error": "TEST_MODE_PURCHASE_NON_REFUNDABLE", -