diff --git a/apps/backend/prisma/migrations/20260514000000_add_subscription_product_revoked_at/migration.sql b/apps/backend/prisma/migrations/20260514000000_add_subscription_product_revoked_at/migration.sql new file mode 100644 index 0000000000..17c2d6fc2f --- /dev/null +++ b/apps/backend/prisma/migrations/20260514000000_add_subscription_product_revoked_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Subscription" ADD COLUMN "productRevokedAt" TIMESTAMP(3); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 485d89da91..103491fc3c 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -1300,6 +1300,14 @@ model Subscription { refundedAt DateTime? + // Set when a refund explicitly ends product access via end_action="now". + // Distinct from `endedAt` (which fires for natural expiry, webhook cancel, + // etc.) so phase-1's subscription-end mapper can tell refund-driven ends + // apart and avoid emitting a second product-revocation entry — the refund + // row already carries one. See refund/route.tsx and phase-1/transactions.ts + // for the consumer side. + productRevokedAt DateTime? + creationSource PurchaseCreationSource createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.test.ts b/apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.test.ts new file mode 100644 index 0000000000..d35938c551 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + getRefundDrivenImmediateEndedAt, + shouldRejectSubscriptionProductRevocationReplay, +} from "./route"; + +describe("subscription refund replay guard", () => { + it("allows retry repair when subscription marker exists but refund revocation row is missing", () => { + expect(shouldRejectSubscriptionProductRevocationReplay({ + endNow: true, + productRevokedAt: new Date("2026-01-01T00:00:00Z"), + priorProductRevoked: false, + })).toBe(false); + }); + + it("rejects replay only after both subscription marker and refund revocation row exist", () => { + expect(shouldRejectSubscriptionProductRevocationReplay({ + endNow: true, + productRevokedAt: new Date("2026-01-01T00:00:00Z"), + priorProductRevoked: true, + })).toBe(true); + }); + + it("does not reject non-immediate refunds", () => { + expect(shouldRejectSubscriptionProductRevocationReplay({ + endNow: false, + productRevokedAt: new Date("2026-01-01T00:00:00Z"), + priorProductRevoked: true, + })).toBe(false); + }); +}); + +describe("refund-driven immediate end timestamp", () => { + it("preserves an existing past endedAt", () => { + const existingEndedAt = new Date("2026-01-01T00:00:00Z"); + const now = new Date("2026-01-02T00:00:00Z"); + + expect(getRefundDrivenImmediateEndedAt({ existingEndedAt, now })).toBe(existingEndedAt); + }); + + it("pulls a scheduled future endedAt forward to now", () => { + const now = new Date("2026-01-02T00:00:00Z"); + const existingEndedAt = new Date("2026-02-01T00:00:00Z"); + + expect(getRefundDrivenImmediateEndedAt({ existingEndedAt, now })).toBe(now); + }); + + it("uses now when no endedAt exists", () => { + const now = new Date("2026-01-02T00:00:00Z"); + + expect(getRefundDrivenImmediateEndedAt({ existingEndedAt: null, now })).toBe(now); + }); +}); 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..a5b57918ed 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,39 @@ 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 canonical shape for `moneyAmountToStripeUnits` + * (which right-pads the fractional part to currency.decimals before + * stripping the dot, so shorter inputs like "5" also round-trip correctly). + * 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 +81,298 @@ function getTotalUsdStripeUnits(options: { product: InferType(); - const entryByIndex = new Map( - options.entries.map((entry, index) => [index, entry]), - ); +function makeRefundTxnId(sourceTxnId: string): string { + return `${REFUND_TXN_PREFIX}${sourceTxnId}:${randomUUID()}`; +} - 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."); - } - } +/** + * 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 getRefundedQuantity(refundEntries: RefundEntrySelection[]) { - let total = 0; - for (const refundEntry of refundEntries) { - total += refundEntry.quantity; - } - 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 getRefundAmountStripeUnits(refundEntries: RefundEntrySelection[]) { - let total = 0; - for (const refundEntry of refundEntries) { - total += moneyAmountToStripeUnits(refundEntry.amount_usd, USD_CURRENCY); - } - return total; +/** + * 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 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; +export function shouldRejectSubscriptionProductRevocationReplay(options: { + endNow: boolean, + productRevokedAt: Date | null, + priorProductRevoked: boolean, +}): boolean { + // The subscription marker alone is not enough to reject. If the Prisma / + // subscription bulldozer write succeeded but the refund manual transaction + // failed, the retry must still be allowed to write the canonical refund + // product-revocation row. + return options.endNow && options.productRevokedAt != null && options.priorProductRevoked; } -function negateMoneyAmount(amount: string): string { - if (amount === "0") { - return "0"; +export function getRefundDrivenImmediateEndedAt(options: { + existingEndedAt: Date | null, + now: Date, +}): Date { + if (options.existingEndedAt != null && options.existingEndedAt <= options.now) { + return options.existingEndedAt; } - return `-${amount}`; + return options.now; } -function readProductLineId(product: InferType): string | null { - const productLineId = Reflect.get(product, "productLineId"); - return typeof productLineId === "string" ? productLineId : null; +// ── 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 { refundedStripeUnits, productRevoked }; } -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 }); +// ── Bulldozer reads: outstanding item grants for an OTP ──────────────────── +// +// Subscriptions get their item grants expired automatically: setting +// `endedAt` triggers the subscription timefold to emit a `subscription-end` +// event whose `itemQuantityChangesToExpire` covers every still-outstanding +// grant (initial + item-grant-repeats). OTPs have no equivalent end event — +// `revokedAt` only stops the OTP timefold from scheduling future repeats — +// so any items granted by prior `item-grant-repeat` txns (and by the OTP +// itself with `expiresWhen: "when-purchase-expires"`) would remain valid +// forever after a revoke. To close that gap, the refund row emits explicit +// `item-quantity-expire` entries for every outstanding grant whose +// `expiresWhen` is `"when-purchase-expires"` or `"when-repeated"`. Permanent +// grants (`expiresWhen: null`) are intentionally left alone, matching the +// subscription-end filter — see `subscription-timefold-algo.ts:430-441`. + +type OutstandingItemGrant = { + txnId: string, + entryIndex: number, + itemId: string, + quantity: number, +}; + +/** + * Pure dedup logic: given the OTP txn and all its item-grant-repeat txns, + * collect every `item-quantity-change` entry then subtract any grant already + * referenced by a later `item-quantity-expire` entry (which is how + * "when-repeated" grants get retired by subsequent IGRs). Entries with + * `expiresWhen: null` are excluded — they're permanent by design. + */ +export function computeOutstandingItemGrantsForOtp( + rows: Array<{ txnId: unknown, entries: unknown }>, +): OutstandingItemGrant[] { + const grants: OutstandingItemGrant[] = []; + const expiredKeys = new Set(); + const grantKey = (txnId: string, entryIndex: number) => `${txnId}:${entryIndex}`; + + for (const row of rows) { + const txnId = row.txnId; + if (typeof txnId !== "string") continue; + const entries = row.entries; + if (!Array.isArray(entries)) continue; + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]; + if (typeof entry !== "object" || entry === null) continue; + const type = Reflect.get(entry, "type"); + if (type === "item-quantity-change") { + const expiresWhen = Reflect.get(entry, "expiresWhen"); + if (expiresWhen !== "when-purchase-expires" && expiresWhen !== "when-repeated") { + // Permanent grants survive revocation (matches sub-end semantics). + continue; + } + const itemId = Reflect.get(entry, "itemId"); + const quantity = Reflect.get(entry, "quantity"); + if (typeof itemId !== "string" || typeof quantity !== "number") continue; + grants.push({ txnId, entryIndex: index, itemId, quantity }); + } else if (type === "item-quantity-expire") { + const adjustedTxnId = Reflect.get(entry, "adjustedTransactionId"); + const adjustedIdx = Reflect.get(entry, "adjustedEntryIndex"); + if (typeof adjustedTxnId !== "string" || typeof adjustedIdx !== "number") continue; + expiredKeys.add(grantKey(adjustedTxnId, adjustedIdx)); + } + } } - return entry; + + return grants.filter((g) => !expiredKeys.has(grantKey(g.txnId, g.entryIndex))); } -function buildRefundManualTransaction(options: { - sourceKind: "subscription" | "one-time-purchase", - sourceId: string, - sourceTransactionId: string, +async function readOutstandingItemGrantsForOtp(options: { + prisma: PrismaClientTransaction, 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, - }); + customerType: "user" | "team" | "custom", + customerId: string, + purchaseId: string, +}): Promise { + const executionContext = createBulldozerExecutionContext(); + const baseSql = toQueryableSqlQuery(paymentsSchema.transactions.listRowsInGroup(executionContext, { + start: "start", + end: "end", + startInclusive: true, + endInclusive: true, + })); + const otpTxnId = `otp:${options.purchaseId}`; + const igrPrefix = `igr:${options.purchaseId}:`; + // LIKE pattern safety: purchaseId is a UUID and the igr txnId format is + // `igr::` — neither contains LIKE + // metacharacters today. Same caveat as `readPriorRefundSummary` above. + const sql = ` + SELECT "__rows"."rowdata" AS "rowData" + FROM (${baseSql}) AS "__rows" + WHERE "__rows"."rowdata"->>'tenancyId' = ${quoteSqlStringLiteral(options.tenancyId).sql} + AND "__rows"."rowdata"->>'customerType' = ${quoteSqlStringLiteral(options.customerType).sql} + AND "__rows"."rowdata"->>'customerId' = ${quoteSqlStringLiteral(options.customerId).sql} + AND ( + ("__rows"."rowdata"->>'txnId') = ${quoteSqlStringLiteral(otpTxnId).sql} + OR ( + ("__rows"."rowdata"->>'type') = 'item-grant-repeat' + AND ("__rows"."rowdata"->>'txnId') LIKE ${quoteSqlStringLiteral(`${igrPrefix}%`).sql} + ) + ) + `; + const rows = await options.prisma.$queryRaw>`${Prisma.raw(sql)}`; + return computeOutstandingItemGrantsForOtp(rows.map((row) => { + const rowData = row.rowData; + if (typeof rowData !== "object" || rowData === null) { + return { txnId: null, entries: null }; + } 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, + txnId: Reflect.get(rowData, "txnId"), + entries: Reflect.get(rowData, "entries"), }; - }); - 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, - }, - }; + })); +} + +// ── Stripe payment-intent resolution for invoice refunds ─────────────────── + +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 }); + } + const paymentIntentId = paidPayment.payment.payment_intent; + if (!paymentIntentId || typeof paymentIntentId !== "string") { + throw new StackAssertionError("Payment has no payment intent", { stripeInvoiceId }); + } + return paymentIntentId; } +// ── Route ───────────────────────────────────────────────────────────────── + export const POST = createSmartRouteHandler({ - metadata: { - hidden: true, - }, + metadata: { hidden: true }, request: yupObject({ auth: yupObject({ type: adminAuthTypeSchema.defined(), @@ -205,230 +382,564 @@ 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(), + // `end_action` collapses the previous two-flag API (`revoke_product`, + // `end_subscription`) into a single tri-state matching the dashboard's + // section-2 lifecycle picker: + // "now" → end product access immediately. For subs: + // cancel Stripe immediately + set endedAt=now + + // write a product-revocation entry on the refund + // row (which expires outstanding item grants via + // subscription-end). For OTPs: set revokedAt=now + + // write product-revocation + emit explicit + // item-quantity-expire entries (no sub-end exists + // for OTPs). + // "at-period-end" → cancel-at-period-end on the Stripe sub + set + // endedAt=currentPeriodEnd. Subscriptions only — + // OTPs have no period. + // undefined → no lifecycle change; refund money only. + end_action: yupString().oneOf(["now", "at-period-end"]).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 endAction = body.end_action; + + 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 (endAction === "at-period-end") { + throw new KnownErrors.SchemaError("end_action='at-period-end' is only valid for subscriptions; one-time purchases have no period."); } - 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 && endAction === undefined) { + throw new KnownErrors.SchemaError("Refund must do something: specify a non-zero amount or set end_action='now'."); } - const transaction = buildSubscriptionTransaction({ subscription }); - validateRefundEntries({ - entries: transaction.entries, - refundEntries, + return await handleOneTimePurchaseRefund({ + prisma, + tenancy: auth.tenancy, + purchaseId: body.id, + amountUsd, + amountStripeUnits, + endNow: endAction === "now", }); - const refundedQuantity = getRefundedQuantity(refundEntries); - const totalStripeUnits = getTotalUsdStripeUnits({ - product: subscription.product as InferType, - priceId: subscription.priceId ?? null, - quantity: subscription.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({ + } + + // subscription path + if (amountStripeUnits === 0 && endAction === undefined) { + throw new KnownErrors.SchemaError("Refund must do something: specify a non-zero amount or set end_action."); + } + return await handleSubscriptionRefund({ + prisma, + tenancy: auth.tenancy, + subscriptionId: body.id, + invoiceId: body.invoice_id, + amountUsd, + amountStripeUnits, + endAction, + }); + }, +}); + +// ── 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 is derived from +// `(tenancyId, sourceTxnId, amountStripeUnits, priorRefundedStripeUnits)` +// — *not* from `refundTxnId` — so: +// - Stripe-success → DB-fail → caller retries: `prior` is unchanged +// (no row committed), the key matches, Stripe dedupes, and the +// second attempt's bulldozer write recovers the state. Self-heals. +// - DB-success → response lost → caller retries: `prior` now includes +// the just-committed amount, so a fresh key is generated and Stripe +// issues a second real refund. This is the open hole — no +// out-of-band reconciliation today. Tracked alongside (1). +async function handleSubscriptionRefund(options: { + prisma: Awaited>, + tenancy: Tenancy, + subscriptionId: string, + invoiceId: string | undefined, + amountUsd: MoneyAmount, + amountStripeUnits: number, + endAction: "now" | "at-period-end" | undefined, +}) { + const { prisma, tenancy } = options; + const endNow = options.endAction === "now"; + const endAtPeriodEnd = options.endAction === "at-period-end"; + 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-rework 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. + // Preserve the legacy `SubscriptionAlreadyRefunded` known-error code so + // callers catching by code still work. + if (subscription.refundedAt) { + throw new KnownErrors.SubscriptionAlreadyRefunded(subscription.id); + } + + // End-at-period-end replay guard. The empty-entries refund row written by + // `amount=0, end_action="at-period-end"` is not visible to + // `readPriorRefundSummary` (which only tracks money + product-revocation), + // so without this gate the call is a forever-no-op that accumulates phantom + // rows on each replay. The sub's lifecycle state is authoritative here. + if (options.amountStripeUnits === 0 && endAtPeriodEnd) { + if (subscription.cancelAtPeriodEnd || subscription.endedAt) { + throw new KnownErrors.SchemaError("Subscription is already scheduled to end."); + } + } + + 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.TestModePurchaseNonRefundable(); + } + + // 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); + } + // `end_action="now"` is a sub-wide action (the product grant lives on + // the sub-start txn, not on renewal txns), so it can only meaningfully + // be paired with a refund targeting the creation invoice — or the default + // no-invoice-id call which already implies start. Targeting a renewal + // invoice with immediate end would write a product-revocation entry + // pointing at a non-existent entry on the renewal txn. + if (endNow && !found.isSubscriptionCreationInvoice) { + throw new KnownErrors.SchemaError("Cannot end product access immediately when refunding a renewal invoice — product revocation applies to the subscription as a whole. Omit invoice_id or pass the creation invoice id."); + } + 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. In live mode, `getTotalUsdStripeUnits` enforces + // USD-only pricing (throws otherwise). The invoice's `amountTotal` is the + // more accurate cap (reflects proration, quantity changes, discounts), but + // SubscriptionInvoice doesn't persist the invoice currency — so we only + // trust `amountTotal` after the USD pre-flight has succeeded. + const productCapStripeUnits = isTestMode + ? 0 + : getTotalUsdStripeUnits({ + product, + priceId: subscription.priceId ?? null, + quantity: subscription.quantity, + }); + const totalStripeUnits = isTestMode + ? 0 + : (invoice?.amountTotal ?? productCapStripeUnits); + + 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)}).`); + } + // Replay gate for endNow on subs. Require both the durable subscription + // marker and the refund ledger entry before rejecting; if a prior attempt + // failed after marking the subscription but before writing the refund row, + // the retry must still be able to repair the missing canonical revocation. + if (shouldRejectSubscriptionProductRevocationReplay({ + endNow, + productRevokedAt: subscription.productRevokedAt, + priorProductRevoked: 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, - }); + amountStripeUnits: options.amountStripeUnits, + metadata: { + tenancyId: tenancy.id, + subscriptionId: subscription.id, + refundTxnId, + ...(invoice ? { invoiceId: invoice.id } : {}), + }, + }), + { + 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 (endNow) { + // Immediate end. Stripe sub canceled, Prisma endedAt=now → timefold + // auto-emits subscription-end with item-quantity-expire entries. Preserve + // an existing past `endedAt` if the sub already ended naturally; a future + // scheduled end must be pulled forward to now. + const endedAt = getRefundDrivenImmediateEndedAt({ + existingEndedAt: subscription.endedAt, + now, + }); + 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` is what Stripe returns + // when the sub no longer exists; `subscription_already_canceled` is + // the documented code for re-cancel on an existing-but-canceled sub. + // Neither is 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_already_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, + // Signal to phase-1 that this end was refund-driven, so its sub-end + // mapper skips the auto-emitted `product-revocation` entry (the + // refund row below already carries one — see the comment on the + // entry push for why we need to avoid double-revocation). + productRevokedAt: subscription.productRevokedAt ?? now, + }, + }); + } else if (endAtPeriodEnd) { + // End at period end. Items follow natural lifecycle when sub-end fires + // at period boundary. + if (!isTestMode && subscription.stripeSubscriptionId) { + const stripe = await getStripeForAccount({ tenancy }); + // Same idempotent-cancel guard as the endNow branch above. The Stripe + // money refund has already been issued at this point (lines ~585-608), + // so an unhandled `subscription_already_canceled` / `resource_missing` + // error here would propagate up before `bulldozerWriteManualTransaction` + // commits the ledger row, leaving the customer refunded with no record. + // Reachable when an admin pairs `amount > 0` with `end_action="at-period-end"` + // on a sub that's already terminal (the empty-amount case is caught + // earlier by the replay guard). + try { 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, - }, + cancel_at_period_end: true, }); - } 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."); + } catch (e: unknown) { + const code = (e as { code?: string }).code; + if (code !== "resource_missing" && code !== "subscription_already_canceled") { + throw e; + } } - await stripe.refunds.create(buildStripeRefundParams({ + } + 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 (endNow) { + // Canonical product-revocation for refund-driven ends. Paired with the + // `productRevokedAt=now` write on the subscription row above: that flag + // tells the subscription-end timefold mapper (phase-1/transactions.ts) + // to skip its auto-emitted product-revocation, so we get exactly one + // entry instead of two. Without that coordination the phase-3 + // owned-products LFold would subtract `quantity` twice — fine for + // single-sub customers thanks to the GREATEST(..., 0) clamp, but + // broken for stackable subs where the second subtraction eats into a + // sibling sub's still-active grant. + 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, + endNow: 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. Preserves + // the legacy `OneTimePurchaseAlreadyRefunded` known-error code for callers + // catching by code. + if (purchase.refundedAt) { + throw new KnownErrors.OneTimePurchaseAlreadyRefunded(purchase.id); + } + + 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.TestModePurchaseNonRefundable(); + } + + 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.endNow && prior.productRevoked) { + throw new KnownErrors.SchemaError("This purchase's product has already been revoked."); + } + + const refundTxnId = makeRefundTxnId(sourceTxnId); + + // ── 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: refundAmountStripeUnits, - metadata: { - tenancyId: auth.tenancy.id, - purchaseId: purchase.id, - }, - })); - const refundedAt = new Date(); - await prisma.oneTimePurchase.update({ - where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } }, - 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, + amountStripeUnits: options.amountStripeUnits, + metadata: { tenancyId: tenancy.id, purchaseId: purchase.id, refundTxnId }, + }), + { + idempotencyKey: makeStripeIdempotencyKey({ + tenancyId: tenancy.id, + sourceTxnId, + amountStripeUnits: options.amountStripeUnits, + priorRefundedStripeUnits: prior.refundedStripeUnits, + }), + }, + ); + } + + // ── Lifecycle: Prisma ───────────────────────────────────────────────── + const now = new Date(); + if (options.endNow) { + 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.endNow) { + refundEntries.push(buildProductRevocationEntry({ + customerType, + customerId: purchase.customerId, + sourceTxnId, + productGrantEntryIndex: ONE_TIME_PURCHASE_PRODUCT_GRANT_ENTRY_INDEX, + productId: purchase.productId ?? null, + productLineId, + quantity: purchase.quantity, + })); + // Expire outstanding item grants from the OTP txn and any + // item-grant-repeat txns. See the helper docs above for the rationale — + // OTPs have no equivalent of the subscription-end cascade. + const outstandingGrants = await readOutstandingItemGrantsForOtp({ + prisma, + tenancyId: tenancy.id, + customerType, + customerId: purchase.customerId, + purchaseId: purchase.id, + }); + for (const grant of outstandingGrants) { + refundEntries.push({ + type: "item-quantity-expire", + customerType, + customerId: purchase.customerId, + adjustedTransactionId: grant.txnId, + adjustedEntryIndex: grant.entryIndex, + itemId: grant.itemId, + quantity: grant.quantity, }); - await bulldozerWriteManualTransaction(prisma, manualRefund.rowId, manualRefund.rowData); } + } - return { - statusCode: 200, - bodyType: "json", - body: { - success: true, - }, - }; - }, -}); + 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,13 +958,115 @@ 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 }); expect("metadata" in withoutMeta).toBe(false); expect(withoutMeta.refund_application_fee).toBe(false); }); + test("includes refundTxnId in metadata when threaded through", ({ expect }) => { + const params = buildStripeRefundParams({ + paymentIntentId: "pi_x", + amountStripeUnits: 1, + metadata: { tenancyId: "t1", subscriptionId: "s1", refundTxnId: "refund:sub-start:abc:uuid" }, + }); + // Stripe types `metadata` as `MetadataParam | "" | undefined`; we know + // we passed an object, so narrow before reading. + const metadata = params.metadata; + if (typeof metadata !== "object" || metadata === null) { + throw new Error("expected metadata object"); + } + expect(metadata.refundTxnId).toBe("refund:sub-start:abc:uuid"); + }); +}); + +import.meta.vitest?.describe("computeOutstandingItemGrantsForOtp", (test) => { + test("returns when-purchase-expires and when-repeated grants from the OTP txn", ({ expect }) => { + const otp = { + txnId: "otp:p1", + entries: [ + { type: "product-grant", customerType: "user", customerId: "u" }, + { type: "money-transfer", customerType: "user", customerId: "u" }, + { type: "item-quantity-change", customerType: "user", customerId: "u", itemId: "tokens", quantity: 50, expiresWhen: "when-purchase-expires" }, + { type: "item-quantity-change", customerType: "user", customerId: "u", itemId: "credits", quantity: 100, expiresWhen: "when-repeated" }, + ], + }; + const out = computeOutstandingItemGrantsForOtp([otp]); + expect(out).toEqual([ + { txnId: "otp:p1", entryIndex: 2, itemId: "tokens", quantity: 50 }, + { txnId: "otp:p1", entryIndex: 3, itemId: "credits", quantity: 100 }, + ]); + }); + + test("excludes permanent (expiresWhen=null) grants — matches sub-end semantics", ({ expect }) => { + const otp = { + txnId: "otp:p1", + entries: [ + { type: "product-grant", customerType: "user", customerId: "u" }, + { type: "item-quantity-change", customerType: "user", customerId: "u", itemId: "perma", quantity: 10, expiresWhen: null }, + { type: "item-quantity-change", customerType: "user", customerId: "u", itemId: "temp", quantity: 5, expiresWhen: "when-purchase-expires" }, + ], + }; + const out = computeOutstandingItemGrantsForOtp([otp]); + expect(out).toEqual([ + { txnId: "otp:p1", entryIndex: 2, itemId: "temp", quantity: 5 }, + ]); + }); + + test("subtracts grants already retired by a later IGR's item-quantity-expire", ({ expect }) => { + // OTP grants 100 credits (when-repeated). Then an IGR expires those and grants 100 fresh. + // Only the latest 100 remain outstanding. + const otp = { + txnId: "otp:p1", + entries: [ + { type: "product-grant", customerType: "user", customerId: "u" }, + { type: "item-quantity-change", customerType: "user", customerId: "u", itemId: "credits", quantity: 100, expiresWhen: "when-repeated" }, + ], + }; + const igr = { + txnId: "igr:p1:1000", + entries: [ + { type: "item-quantity-expire", customerType: "user", customerId: "u", adjustedTransactionId: "otp:p1", adjustedEntryIndex: 1, itemId: "credits", quantity: 100 }, + { type: "item-quantity-change", customerType: "user", customerId: "u", itemId: "credits", quantity: 100, expiresWhen: "when-repeated" }, + ], + }; + const out = computeOutstandingItemGrantsForOtp([otp, igr]); + expect(out).toEqual([ + { txnId: "igr:p1:1000", entryIndex: 1, itemId: "credits", quantity: 100 }, + ]); + }); + + test("accumulates when-purchase-expires grants across multiple IGRs (no auto-expiry)", ({ expect }) => { + // Three monthly IGRs each granting 100 bonus tokens that expire only when the purchase does. + const otp = { + txnId: "otp:p1", + entries: [{ type: "product-grant", customerType: "user", customerId: "u" }], + }; + const igrs = [1000, 2000, 3000].map((t) => ({ + txnId: `igr:p1:${t}`, + entries: [ + { type: "item-quantity-change", customerType: "user", customerId: "u", itemId: "bonus", quantity: 100, expiresWhen: "when-purchase-expires" }, + ], + })); + const out = computeOutstandingItemGrantsForOtp([otp, ...igrs]); + expect(out).toHaveLength(3); + expect(out.map((g) => g.txnId)).toEqual(["igr:p1:1000", "igr:p1:2000", "igr:p1:3000"]); + }); + + test("ignores non-item entries and malformed rows", ({ expect }) => { + const out = computeOutstandingItemGrantsForOtp([ + { txnId: "otp:p1", entries: [ + { type: "product-grant" }, + { type: "money-transfer" }, + { type: "item-quantity-change", itemId: "x", quantity: 1, expiresWhen: "when-purchase-expires" }, + ] }, + // malformed: missing entries array + { txnId: "otp:bad", entries: null }, + // malformed: non-string txnId + { txnId: 42, entries: [] }, + ]); + expect(out).toEqual([ + { txnId: "otp:p1", entryIndex: 2, itemId: "x", quantity: 1 }, + ]); + }); }); 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..9e51ba7681 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,13 @@ function parseSourceId(row: LedgerTransactionRow): string { } return row.txnId.slice("miqc:".length); } + if (row.type === "refund") { + // Return the full ledger txnId. Source rows link to refunds via + // `adjusted_by.transaction_id`, which carries the full refund txnId + // (matching what the refund route returns as `refund_transaction_id`). + // The listing's `id` field must match for the dashboard to join the two. + return row.txnId; + } if (!row.txnId.startsWith("sub-renewal:")) { throw new StackAssertionError("subscription-renewal transaction id has invalid prefix", { txnId: row.txnId }); } @@ -527,6 +541,9 @@ function mapLedgerTransactionTypeToApiType(type: LedgerTransactionType): Transac if (type === "subscription-renewal") { return "subscription-renewal"; } + if (type === "refund") { + return "refund"; + } return "purchase"; } @@ -538,50 +555,52 @@ 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"); + // Legacy fallback: extract source txns from product-revocation entries. + const entries = Reflect.get(rowData, "entries"); + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + if (!isRecord(entry)) continue; + if (entry.type !== "product-revocation") continue; + const adjustedTxnId = Reflect.get(entry, "adjustedTransactionId"); + if (typeof adjustedTxnId !== "string" || adjustedTxnId.length === 0) continue; 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, - }, - ]); + const entryIndex = typeof adjustedEntryIndex === "number" && Number.isInteger(adjustedEntryIndex) && adjustedEntryIndex >= 0 + ? adjustedEntryIndex + : 0; + addLink(adjustedTxnId, refundTxnId, entryIndex); } } return lookup; @@ -661,18 +680,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.test.tsx b/apps/backend/src/lib/payments.test.tsx index e62037978e..078fda83b7 100644 --- a/apps/backend/src/lib/payments.test.tsx +++ b/apps/backend/src/lib/payments.test.tsx @@ -40,7 +40,7 @@ describe.sequential('validatePurchaseSession - purchase guards (real DB)', () => productId, priceId: null, product: product as any, quantity: 1, stripeSubscriptionId: `stripe-${id}`, status: 'active', currentPeriodStart: new Date(), currentPeriodEnd: new Date(Date.now() + 86400000), - cancelAtPeriodEnd: false, canceledAt: null, endedAt: null, refundedAt: null, + cancelAtPeriodEnd: false, canceledAt: null, endedAt: null, refundedAt: null, productRevokedAt: null, creationSource: 'TEST_MODE', createdAt: new Date(), }); }; diff --git a/apps/backend/src/lib/payments/bulldozer-dual-write.ts b/apps/backend/src/lib/payments/bulldozer-dual-write.ts index 0b5ab83596..b6ce07477f 100644 --- a/apps/backend/src/lib/payments/bulldozer-dual-write.ts +++ b/apps/backend/src/lib/payments/bulldozer-dual-write.ts @@ -40,6 +40,7 @@ export function subscriptionToStoredRow(sub: { canceledAt: Date | null, endedAt: Date | null, refundedAt: Date | null, + productRevokedAt: Date | null, creationSource: string, createdAt: Date, }): Record { @@ -60,6 +61,7 @@ export function subscriptionToStoredRow(sub: { canceledAtMillis: dateToMillis(sub.canceledAt), endedAtMillis: dateToMillis(sub.endedAt), refundedAtMillis: dateToMillis(sub.refundedAt), + productRevokedAtMillis: dateToMillis(sub.productRevokedAt), creationSource: sub.creationSource, createdAtMillis: dateToMillis(sub.createdAt), }; diff --git a/apps/backend/src/lib/payments/ensure-free-plan.test.ts b/apps/backend/src/lib/payments/ensure-free-plan.test.ts index 91470f0686..9b88396212 100644 --- a/apps/backend/src/lib/payments/ensure-free-plan.test.ts +++ b/apps/backend/src/lib/payments/ensure-free-plan.test.ts @@ -64,6 +64,7 @@ describe.sequential("ensureFreePlanForBillingTeam (real DB)", () => { canceledAt: null, endedAt: null, refundedAt: null, + productRevokedAt: null, creationSource: "PURCHASE_PAGE", createdAt: now, }); @@ -175,6 +176,7 @@ describe.sequential("ensureFreePlanForBillingTeam (real DB)", () => { canceledAt: yesterday, endedAt: yesterday, refundedAt: null, + productRevokedAt: null, creationSource: "PURCHASE_PAGE", createdAt: yesterday, }); 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/__tests__/dual-write.test.ts b/apps/backend/src/lib/payments/schema/__tests__/dual-write.test.ts index ad368991e2..3850c07e44 100644 --- a/apps/backend/src/lib/payments/schema/__tests__/dual-write.test.ts +++ b/apps/backend/src/lib/payments/schema/__tests__/dual-write.test.ts @@ -92,6 +92,7 @@ describe("conversion functions", () => { canceledAt: null, endedAt: null, refundedAt: null, + productRevokedAt: null, creationSource: "TEST_MODE", createdAt: new Date("2024-01-01T00:00:00Z"), }); @@ -165,6 +166,7 @@ describe("setRow via dual-write conversion", () => { canceledAt: null, endedAt: null, refundedAt: null, + productRevokedAt: null, creationSource: "TEST_MODE", createdAt: new Date("2024-01-01T00:00:00Z"), }); @@ -197,6 +199,7 @@ describe("setRow via dual-write conversion", () => { canceledAt: null, endedAt: null, refundedAt: null, + productRevokedAt: null, creationSource: "TEST_MODE", createdAt: new Date("2024-01-01T00:00:00Z"), }); @@ -220,6 +223,7 @@ describe("setRow via dual-write conversion", () => { canceledAt: new Date("2024-01-10T00:00:00Z"), endedAt: new Date("2024-01-15T00:00:00Z"), refundedAt: null, + productRevokedAt: null, creationSource: "TEST_MODE", createdAt: new Date("2024-01-01T00:00:00Z"), }); diff --git a/apps/backend/src/lib/payments/schema/__tests__/integration-1-3-queue-drained.test.ts b/apps/backend/src/lib/payments/schema/__tests__/integration-1-3-queue-drained.test.ts index bab86f3d6a..26ceef417f 100644 --- a/apps/backend/src/lib/payments/schema/__tests__/integration-1-3-queue-drained.test.ts +++ b/apps/backend/src/lib/payments/schema/__tests__/integration-1-3-queue-drained.test.ts @@ -100,6 +100,7 @@ describe.sequential("payments schema integration phase 1→3, queue-drained path canceledAtMillis: 10 * DAY_MS, endedAtMillis: 10 * DAY_MS, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "TEST_MODE", createdAtMillis: 0, }))); @@ -142,6 +143,7 @@ describe.sequential("payments schema integration phase 1→3, queue-drained path canceledAtMillis: 20 * DAY_MS, endedAtMillis: 20 * DAY_MS, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "TEST_MODE", createdAtMillis: 11 * DAY_MS, }))); @@ -212,6 +214,7 @@ describe.sequential("payments schema integration phase 1→3, queue-drained path canceledAtMillis: null, endedAtMillis: 45 * DAY_MS, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "TEST_MODE", createdAtMillis: 0, }))); diff --git a/apps/backend/src/lib/payments/schema/__tests__/integration-1-3.test.ts b/apps/backend/src/lib/payments/schema/__tests__/integration-1-3.test.ts index e24efa861a..ea689758b6 100644 --- a/apps/backend/src/lib/payments/schema/__tests__/integration-1-3.test.ts +++ b/apps/backend/src/lib/payments/schema/__tests__/integration-1-3.test.ts @@ -68,6 +68,7 @@ describe.sequential("payments schema integration phase 1→3 (real postgres)", ( stripePaymentIntentId: "pi-int-1", revokedAtMillis: null, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "PURCHASE_PAGE", createdAtMillis: 1000, }))); @@ -149,6 +150,7 @@ describe.sequential("payments schema integration phase 1→3 (real postgres)", ( canceledAtMillis: null, endedAtMillis: null, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "PURCHASE_PAGE", createdAtMillis: 2000, }))); @@ -222,6 +224,7 @@ describe.sequential("payments schema integration phase 1→3 (real postgres)", ( stripePaymentIntentId: "pi-int-u2", revokedAtMillis: null, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "PURCHASE_PAGE", createdAtMillis: 3000, }))); @@ -287,6 +290,7 @@ describe.sequential("payments schema integration phase 1→3 (real postgres)", ( canceledAtMillis: 4000, endedAtMillis: 5000, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "PURCHASE_PAGE", createdAtMillis: 0, }))); @@ -400,6 +404,7 @@ describe.sequential("payments schema integration phase 1→3 (real postgres)", ( canceledAtMillis: null, endedAtMillis: 25 * DAY_MS, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "TEST_MODE", createdAtMillis: 0, }))); @@ -464,6 +469,7 @@ describe.sequential("payments schema integration phase 1→3 (real postgres)", ( stripePaymentIntentId: "pi-refundable", revokedAtMillis: null, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "PURCHASE_PAGE", createdAtMillis: 6000, }))); @@ -556,6 +562,7 @@ describe.sequential("payments schema integration phase 1→3 (real postgres)", ( canceledAtMillis: null, endedAtMillis: null, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "PURCHASE_PAGE", createdAtMillis: 0, }))); @@ -628,6 +635,7 @@ describe.sequential("payments schema integration phase 1→3 (real postgres)", ( stripePaymentIntentId: null, revokedAtMillis: null, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "TEST_MODE", createdAtMillis: 10000, }))); @@ -650,6 +658,7 @@ describe.sequential("payments schema integration phase 1→3 (real postgres)", ( stripePaymentIntentId: null, revokedAtMillis: null, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "TEST_MODE", createdAtMillis: 10000, }))); @@ -707,6 +716,7 @@ describe.sequential("payments schema integration phase 1→3 (real postgres)", ( stripePaymentIntentId: null, revokedAtMillis: null, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "TEST_MODE", createdAtMillis: 20000, }))); @@ -729,6 +739,7 @@ describe.sequential("payments schema integration phase 1→3 (real postgres)", ( stripePaymentIntentId: null, revokedAtMillis: null, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "TEST_MODE", createdAtMillis: 21000, }))); @@ -805,6 +816,7 @@ describe.sequential("payments schema integration phase 1→3 (real postgres)", ( canceledAtMillis: 5 * DAY_MS, endedAtMillis: 10 * DAY_MS, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "TEST_MODE", createdAtMillis: 0, }))); @@ -836,6 +848,7 @@ describe.sequential("payments schema integration phase 1→3 (real postgres)", ( canceledAtMillis: 15 * DAY_MS, endedAtMillis: 30 * DAY_MS, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "TEST_MODE", createdAtMillis: 1000, }))); @@ -941,6 +954,7 @@ describe.sequential("payments schema integration phase 1→3 (real postgres)", ( canceledAtMillis: 14 * DAY_MS, endedAtMillis: 14 * DAY_MS, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "TEST_MODE", createdAtMillis: 0, }))); @@ -1050,6 +1064,7 @@ describe.sequential("payments schema integration phase 1→3 (real postgres)", ( stripePaymentIntentId: null, revokedAtMillis: null, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "TEST_MODE", createdAtMillis: 0, }))); @@ -1153,6 +1168,7 @@ describe.sequential("payments schema integration phase 1→3 (real postgres)", ( canceledAtMillis: 2 * DAY_MS, endedAtMillis: 5 * DAY_MS, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "TEST_MODE", createdAtMillis: 0, }))); @@ -1216,6 +1232,7 @@ describe.sequential("payments schema integration phase 1→3 (real postgres)", ( canceledAtMillis: 10 * DAY_MS, endedAtMillis: 10 * DAY_MS, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "TEST_MODE", createdAtMillis: 0, }))); @@ -1245,6 +1262,7 @@ describe.sequential("payments schema integration phase 1→3 (real postgres)", ( canceledAtMillis: 20 * DAY_MS, endedAtMillis: 20 * DAY_MS, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "TEST_MODE", createdAtMillis: 11 * DAY_MS, }))); @@ -1328,6 +1346,7 @@ describe.sequential("payments schema integration phase 1→3 (real postgres)", ( canceledAtMillis: null, endedAtMillis: null, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "TEST_MODE", createdAtMillis: 1000, }))); @@ -1360,6 +1379,7 @@ describe.sequential("payments schema integration phase 1→3 (real postgres)", ( canceledAtMillis: 1500, endedAtMillis: 2000, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "TEST_MODE", createdAtMillis: 1000, }))); diff --git a/apps/backend/src/lib/payments/schema/__tests__/integration-2-3.test.ts b/apps/backend/src/lib/payments/schema/__tests__/integration-2-3.test.ts index ad70e40d44..642a32f141 100644 --- a/apps/backend/src/lib/payments/schema/__tests__/integration-2-3.test.ts +++ b/apps/backend/src/lib/payments/schema/__tests__/integration-2-3.test.ts @@ -50,6 +50,7 @@ describe.sequential("payments schema integration phase 2→3 (real postgres)", ( stripePaymentIntentId: `pi-${id}`, revokedAtMillis: null, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "PURCHASE_PAGE", createdAtMillis: opts.createdAtMillis, })); @@ -156,6 +157,7 @@ describe.sequential("payments schema integration phase 2→3 (real postgres)", ( stripePaymentIntentId: null, revokedAtMillis: null, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "TEST_MODE", createdAtMillis: 500, }))); diff --git a/apps/backend/src/lib/payments/schema/__tests__/phase-1.test.ts b/apps/backend/src/lib/payments/schema/__tests__/phase-1.test.ts index 34c42d5e51..f19eecdc4a 100644 --- a/apps/backend/src/lib/payments/schema/__tests__/phase-1.test.ts +++ b/apps/backend/src/lib/payments/schema/__tests__/phase-1.test.ts @@ -55,6 +55,7 @@ describe.sequential("payments schema phase 1 (real postgres)", () => { canceledAtMillis: null, endedAtMillis: null, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "TEST_MODE", createdAtMillis: 0, ...overrides, @@ -175,6 +176,7 @@ describe.sequential("payments schema phase 1 (real postgres)", () => { stripePaymentIntentId: "pi-ev-1", revokedAtMillis: null, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "PURCHASE_PAGE", createdAtMillis: 3000, }))); @@ -511,6 +513,7 @@ describe.sequential("payments schema phase 1 (real postgres)", () => { stripePaymentIntentId: null, revokedAtMillis: 30 * DAY_MS, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "TEST_MODE", createdAtMillis: 0, }))); diff --git a/apps/backend/src/lib/payments/schema/__tests__/phase-2.test.ts b/apps/backend/src/lib/payments/schema/__tests__/phase-2.test.ts index ca4f0e658c..718fd25d6e 100644 --- a/apps/backend/src/lib/payments/schema/__tests__/phase-2.test.ts +++ b/apps/backend/src/lib/payments/schema/__tests__/phase-2.test.ts @@ -70,6 +70,7 @@ describe.sequential("payments schema phase 2 (real postgres)", () => { canceledAtMillis: null, endedAtMillis: 4000, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "PURCHASE_PAGE", createdAtMillis: 1000, }))); diff --git a/apps/backend/src/lib/payments/schema/__tests__/phase-3.test.ts b/apps/backend/src/lib/payments/schema/__tests__/phase-3.test.ts index 1ef713d85c..15fdb2ac6a 100644 --- a/apps/backend/src/lib/payments/schema/__tests__/phase-3.test.ts +++ b/apps/backend/src/lib/payments/schema/__tests__/phase-3.test.ts @@ -68,6 +68,7 @@ describe.sequential("payments schema phase 3 (real postgres)", () => { canceledAtMillis: null, endedAtMillis: 3000, refundedAtMillis: null, + productRevokedAtMillis: null, creationSource: "PURCHASE_PAGE", createdAtMillis: 1000, }))); diff --git a/apps/backend/src/lib/payments/schema/phase-1/subscription-timefold-algo.ts b/apps/backend/src/lib/payments/schema/phase-1/subscription-timefold-algo.ts index febec76039..cc207129ad 100644 --- a/apps/backend/src/lib/payments/schema/phase-1/subscription-timefold-algo.ts +++ b/apps/backend/src/lib/payments/schema/phase-1/subscription-timefold-algo.ts @@ -8,7 +8,7 @@ * { * subscriptionId, tenancyId, customerId, customerType, * productId, product, productLineId, priceId, quantity, - * paymentProvider, endedAtMillis, + * paymentProvider, endedAtMillis, productRevokedAtMillis, * chargedAmount, * startTxnId, // e.g. "sub-start:" * startProductGrantEntryIndex, // always 1 (after active-subscription-start) @@ -169,6 +169,7 @@ export function getSubscriptionTimeFoldReducerSql(): string { 'quantity', ${R}->'quantity', 'paymentProvider', to_jsonb(${provider}), 'endedAtMillis', ${R}->'endedAtMillis', + 'productRevokedAtMillis', ${R}->'productRevokedAtMillis', 'chargedAmount', ${charged}, 'startTxnId', to_jsonb(${startTxnId}), 'startProductGrantEntryIndex', to_jsonb(1), @@ -454,6 +455,7 @@ export function getSubscriptionTimeFoldReducerSql(): string { 'entryIndex', ${stateSql}->'startProductGrantEntryIndex' ), 'itemQuantityChangesToExpire', ${endItemQuantityChangesToExpire(stateSql)}, + 'productRevokedAtMillis', ${stateSql}->'productRevokedAtMillis', 'paymentProvider', ${stateSql}->'paymentProvider', 'effectiveAtMillis', ${stateSql}->'endedAtMillis', 'createdAtMillis', ${stateSql}->'endedAtMillis' 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..c855214578 100644 --- a/apps/backend/src/lib/payments/schema/phase-1/transactions.ts +++ b/apps/backend/src/lib/payments/schema/phase-1/transactions.ts @@ -8,7 +8,9 @@ * Entry ordering follows the spec: * subscription-renewal: [money-transfer] * subscription-cancel: [active-subscription-change] - * subscription-end: [active-subscription-end, product-revocation, ...item-quantity-expire] + * subscription-end: [active-subscription-end, product-revocation?, ...item-quantity-expire] + * (product-revocation is skipped for refund-driven ends — + * see the subscription-end mapper for the rationale) * subscription-start: [active-subscription-start, product-grant, money-transfer?, ...item-quantity-change] * item-grant-repeat: [...item-quantity-expire?, ...item-quantity-change] * one-time-purchase: [product-grant, money-transfer?, ...item-quantity-change] @@ -27,6 +29,20 @@ 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 ────────────────────────────────────────────── +// Position of the product-grant entry as exposed by the public transactions +// API. Refund product-revocation rows persist `adjustedEntryIndex` purely +// as a back-reference read by SDK consumers — the value is copied through +// `mapLedgerEntry` verbatim. That mapper drops the hidden +// `active-subscription-start` entry, so the public-API layout is: +// subscription-start: [product_grant, money_transfer?, ...] +// one-time-purchase: [product_grant, money_transfer?, ...] +// Both product grants land at index 0 publicly. If the public mapping +// changes (e.g. `active-subscription-start` becomes visible), these need +// to move in lockstep and any persisted refund rows reconciled. +export const SUBSCRIPTION_START_PRODUCT_GRANT_ENTRY_INDEX = 0; +export const ONE_TIME_PURCHASE_PRODUCT_GRANT_ENTRY_INDEX = 0; + export function createTransactionsTable(events: EventTables, manualTransactions: SeedEventsStoredTables['manualTransactions']) { @@ -146,6 +162,19 @@ export function createTransactionsTable(events: EventTables, manualTransactions: // ── subscription-end → transaction ───────────────────── + // Emits `active-subscription-end`, optionally `product-revocation`, and + // any `item-quantity-expire` entries from the carried-through outstanding + // grants. + // + // The `product-revocation` entry is suppressed when the source sub row + // has `productRevokedAtMillis` set — that signal means a refund row has + // already written its own `product-revocation` against the same source + // (sub-start, entry index, quantity). Emitting another one here would + // double-subtract from the phase-3 owned-products LFold, which is masked + // by the `GREATEST(..., 0)` clamp for single-sub customers but corrupts + // the count for stackable subs (refunding one of N drops the count to 0 + // instead of N-1). The refund row is the canonical source of revocation + // for refund-driven ends; sub-end remains canonical for natural ends. const subscriptionEndTxns = declareMapTable({ tableId: "payments-txn-subscription-end", fromTable: events.subscriptionEndEvents, @@ -161,18 +190,25 @@ export function createTransactionsTable(events: EventTables, manualTransactions: 'customerType', "rowData"->'customerType', 'customerId', "rowData"->'customerId', 'subscriptionId', "rowData"->'subscriptionId' - ), - jsonb_build_object( - 'type', '"product-revocation"'::jsonb, - 'customerType', "rowData"->'customerType', - 'customerId', "rowData"->'customerId', - 'adjustedTransactionId', "rowData"->'startProductGrantRef'->'transactionId', - 'adjustedEntryIndex', "rowData"->'startProductGrantRef'->'entryIndex', - 'quantity', "rowData"->'quantity', - 'productId', "rowData"->'productId', - 'productLineId', "rowData"->'productLineId' ) ) + || CASE + WHEN "rowData"->>'productRevokedAtMillis' IS NULL + OR "rowData"->'productRevokedAtMillis' = 'null'::jsonb + THEN jsonb_build_array( + jsonb_build_object( + 'type', '"product-revocation"'::jsonb, + 'customerType', "rowData"->'customerType', + 'customerId', "rowData"->'customerId', + 'adjustedTransactionId', "rowData"->'startProductGrantRef'->'transactionId', + 'adjustedEntryIndex', "rowData"->'startProductGrantRef'->'entryIndex', + 'quantity', "rowData"->'quantity', + 'productId', "rowData"->'productId', + 'productLineId', "rowData"->'productLineId' + ) + ) + ELSE '[]'::jsonb + END || ( SELECT COALESCE(jsonb_agg( jsonb_build_object( diff --git a/apps/backend/src/lib/payments/schema/types.ts b/apps/backend/src/lib/payments/schema/types.ts index 2f6cd14da1..4144632bcd 100644 --- a/apps/backend/src/lib/payments/schema/types.ts +++ b/apps/backend/src/lib/payments/schema/types.ts @@ -91,6 +91,14 @@ export type SubscriptionRow = { canceledAtMillis: number | null, endedAtMillis: number | null, refundedAtMillis: number | null, + /** + * Set when a refund explicitly revoked product access (end_action="now"). + * Distinct from `endedAtMillis` so phase-1's subscription-end mapper can + * tell refund-driven ends from natural ends and skip emitting its own + * `product-revocation` entry — the refund row already has one. Null for + * subscriptions that ended naturally / via webhook cancel. + */ + productRevokedAtMillis: number | null, creationSource: PurchaseCreationSource, createdAtMillis: number, }; @@ -380,6 +388,13 @@ export type SubscriptionEndEventRow = { itemId: string, quantity: number, }>, + /** + * Mirrors `SubscriptionRow.productRevokedAtMillis`. When non-null, the + * subscription-end → transaction mapper omits its `product-revocation` + * entry because the refund row that drove this end already wrote one. + * See refund/route.tsx and phase-1/transactions.ts. + */ + productRevokedAtMillis: number | null, paymentProvider: PaymentProvider, effectiveAtMillis: number, createdAtMillis: number, diff --git a/apps/dashboard/src/components/data-table/transaction-table.tsx b/apps/dashboard/src/components/data-table/transaction-table.tsx index 6818d462cb..627c8e67c3 100644 --- a/apps/dashboard/src/components/data-table/transaction-table.tsx +++ b/apps/dashboard/src/components/data-table/transaction-table.tsx @@ -7,7 +7,7 @@ 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 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) { @@ -231,101 +212,80 @@ function getTransactionSummary(transaction: Transaction): TransactionSummary { }; } +// Sentinel string for the Select component when the admin chooses to leave +// the source purchase active (no lifecycle change). The API expects either +// `"now" | "at-period-end"` or the field omitted entirely; we map "none" +// → omitted at request time. +type EndActionChoice = "now" | "at-period-end" | "none"; + 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 [endAction, setEndAction] = useState("now"); + const [submitError, setSubmitError] = useState(null); 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. + // + // Known UI gap: refund actions are only enabled on `purchase` rows, and the + // submit call never passes `invoice_id`. The backend supports refunding a + // specific renewal invoice (POST body `invoice_id`), but the dashboard + // currently can't reach that path — admins refunding a renewal must use + // the API directly. Follow-up: enable the action on `subscription-renewal` + // rows and thread `invoice_id` through. + 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 ?? ''); + const validation = useMemo(() => { + if (!target || !USD_CURRENCY) { + return { canSubmit: false, error: null as string | null }; } - }, [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 }; + if (!moneyAmountSchema(USD_CURRENCY).defined().isValidSync(amountUsd)) { + return { canSubmit: false, error: "Refund amount must be a valid USD amount." }; } - if (!refundAmountUsd) { - return { canSubmit: false, error: "Enter a refund amount.", refundEntries: undefined }; - } - 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 }; + return { canSubmit: false, error: "Refund amount cannot be negative." }; } - if (!canComputeRefundEntries) { - return { canSubmit: false, error: "Refund entries are only supported for USD-priced products.", refundEntries: undefined }; + if (refundUnits > 0 && !chargedAmountUsd) { + return { canSubmit: false, error: "This transaction has no money to refund (test mode or non-USD)." }; } - if (totalSelectedQuantity < 0) { - return { canSubmit: false, error: "Quantity cannot be negative.", refundEntries: undefined }; - } - 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 && endAction === "none") { + return { + canSubmit: false, + error: "Refund must do something: enter an amount or change Subscription / Product.", + }; } - 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, endAction]); + + // Seed dialog state from the current transaction. Called from the menu + // click before opening, because ActionDialog's onOpenChange doesn't fire on + // the open transition for a controlled dialog — so without this an admin + // opening from the menu would see the initial useState defaults + // (`amountUsd: '0'`) and submitting unchanged on a paid purchase would + // revoke/end at $0 instead of refunding the charged amount. + const seedFromTransaction = () => { + // After a prior partial refund the remaining refundable balance is + // smaller than the original charge; we don't have it on the transaction + // payload, so default to 0 and let the admin enter an amount explicitly + // rather than preloading a value that will hit the backend cap. + const alreadyAdjusted = transaction.adjusted_by.length > 0; + setAmountUsd(alreadyAdjusted ? '0' : (chargedAmountUsd ?? '0')); + setEndAction("now"); + setSubmitError(null); + }; return ( <> @@ -338,80 +298,69 @@ function RefundActionCell({ transaction, refundTarget }: { transaction: Transact cancelButton okButton={{ label: "Refund", + // Awaiting directly (rather than wrapping in + // `runAsynchronouslyWithAlert`) lets ActionDialog drive the + // button's loading + disabled state during the request and + // keep the dialog open until the network call resolves — + // important for a destructive, non-idempotent action where a + // double-click would otherwise fire two refunds. onClick: async () => { - if (chargedAmountUsd && !refundValidation.canSubmit) { + if (!validation.canSubmit) { + return "prevent-close"; + } + setSubmitError(null); + const apiEndAction = endAction === "none" ? undefined : endAction; + try { + await app.refundTransaction({ + ...target, + amountUsd: amountUsd as MoneyAmount, + ...(apiEndAction !== undefined ? { endAction: apiEndAction } : {}), + }); + } catch (e: unknown) { + setSubmitError(e instanceof Error ? e.message : "Refund failed. Please try again."); return "prevent-close"; } - await app.refundTransaction({ - ...target, - refundEntries: refundValidation.refundEntries ?? throwErr("Refund entries missing for refund"), - }); }, - 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} -
- ) : ( - - - Partial refunds are only available for USD charges. This will issue a full refund. - +
+ + setAmountUsd(event.target.value)} + disabled={!chargedAmountUsd} + /> + {!chargedAmountUsd ? ( +

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

+ ) : null} +
+
+ + +
+ {validation.error || submitError ? ( + + {validation.error ?? submitError} - )} + ) : null}
) : null} @@ -425,6 +374,7 @@ function RefundActionCell({ transaction, refundTarget }: { transaction: Transact if (!target) { return; } + seedFromTransaction(); setIsDialogOpen(true); }, }]} 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..e2d14f3b99 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,76 @@ 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: "50.00", 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 { 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: { full_code: code, price_id: "monthly", quantity: 1 }, + }); + expect(sessionRes.status).toBe(200); + // The created subscription's id is on the resulting transaction's id. + const txnsRes = await niceBackendFetch("/api/latest/internal/payments/transactions", { + accessType: "admin", + }); + const purchaseTxn = txnsRes.body.transactions.find((tx: any) => tx.type === "purchase"); + expect(purchaseTxn).toBeDefined(); + return { subscriptionId: purchaseTxn.id, userId }; +} +it("rejects refund when target subscription does not exist", async () => { + await setupProjectWithPaymentsConfig(); + + const missingId = randomUUID(); 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" }], + type: "subscription", + id: missingId, + amount_usd: "0", + end_action: "now", }, }); - 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", -