From 5d96ea9c4640c98564247158bf78edc930d7c852 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 19 Jan 2026 15:58:57 -0800 Subject: [PATCH 01/12] partial refunds --- .../payments/transactions/refund/route.tsx | 68 ++++++++- .../data-table/transaction-table.tsx | 72 +++++++++- .../v1/internal/transactions-refund.test.ts | 135 ++++++++++++------ .../src/interface/admin-interface.ts | 9 +- .../apps/implementations/admin-app-impl.ts | 5 +- .../stack-app/apps/interfaces/admin-app.ts | 3 +- 6 files changed, 232 insertions(+), 60 deletions(-) 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 ad7a1474c7..70659c3c0d 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 @@ -2,9 +2,28 @@ import { getStripeForAccount } from "@/lib/stripe"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; -import { adaptSchema, adminAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { adaptSchema, adminAuthTypeSchema, moneyAmountSchema, productSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { SubscriptionStatus } from "@/generated/prisma/client"; +import { SUPPORTED_CURRENCIES, type MoneyAmount } from "@stackframe/stack-shared/dist/utils/currency-constants"; +import { moneyAmountToStripeUnits } from "@stackframe/stack-shared/dist/utils/currencies"; +import { resolveSelectedPriceFromProduct } from "@/app/api/latest/internal/payments/transactions/transaction-builder"; +import { InferType } from "yup"; + +const USD_CURRENCY = SUPPORTED_CURRENCIES.find((currency) => currency.code === "USD") + ?? throwErr("USD currency configuration missing in SUPPORTED_CURRENCIES"); + +function getTotalUsdStripeUnits(options: { product: InferType, priceId: string | null, quantity: number }) { + const selectedPrice = resolveSelectedPriceFromProduct(options.product, options.priceId ?? null); + const usdPrice = selectedPrice?.USD; + if (typeof usdPrice !== "string") { + throw new KnownErrors.SchemaError("Refund amounts can only be specified 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 }); + } + return moneyAmountToStripeUnits(usdPrice as MoneyAmount, USD_CURRENCY) * options.quantity; +} export const POST = createSmartRouteHandler({ metadata: { @@ -19,6 +38,7 @@ export const POST = createSmartRouteHandler({ body: yupObject({ type: yupString().oneOf(["subscription", "one-time-purchase"]).defined(), id: yupString().defined(), + amount_usd: moneyAmountSchema(USD_CURRENCY).optional(), }).defined() }), response: yupObject({ @@ -30,10 +50,16 @@ export const POST = createSmartRouteHandler({ }), handler: async ({ auth, body }) => { const prisma = await getPrismaClientForTenancy(auth.tenancy); + const refundAmountUsd = body.amount_usd ?? null; if (body.type === "subscription") { const subscription = await prisma.subscription.findUnique({ where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } }, - select: { refundedAt: true }, + select: { + refundedAt: true, + product: true, + priceId: true, + quantity: true, + }, }); if (!subscription) { throw new KnownErrors.SubscriptionInvoiceNotFound(body.id); @@ -72,7 +98,25 @@ export const POST = createSmartRouteHandler({ if (!paymentIntentId || typeof paymentIntentId !== "string") { throw new StackAssertionError("Payment has no payment intent", { invoiceId: subscriptionInvoice.stripeInvoiceId }); } - await stripe.refunds.create({ payment_intent: paymentIntentId }); + let refundAmountStripeUnits: number | null = null; + if (refundAmountUsd) { + const totalStripeUnits = getTotalUsdStripeUnits({ + product: subscription.product as InferType, + priceId: subscription.priceId ?? null, + quantity: subscription.quantity, + }); + refundAmountStripeUnits = moneyAmountToStripeUnits(refundAmountUsd as MoneyAmount, USD_CURRENCY); + if (refundAmountStripeUnits <= 0) { + throw new KnownErrors.SchemaError("Refund amount must be greater than zero."); + } + if (refundAmountStripeUnits > totalStripeUnits) { + throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount."); + } + } + await stripe.refunds.create({ + payment_intent: paymentIntentId, + ...(refundAmountStripeUnits !== null ? { amount: refundAmountStripeUnits } : {}), + }); await prisma.subscription.update({ where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } }, data: { @@ -99,8 +143,24 @@ export const POST = createSmartRouteHandler({ if (!purchase.stripePaymentIntentId) { throw new KnownErrors.OneTimePurchaseNotFound(body.id); } + let refundAmountStripeUnits: number | null = null; + if (refundAmountUsd) { + const totalStripeUnits = getTotalUsdStripeUnits({ + product: purchase.product as InferType, + priceId: purchase.priceId ?? null, + quantity: purchase.quantity, + }); + refundAmountStripeUnits = moneyAmountToStripeUnits(refundAmountUsd as MoneyAmount, USD_CURRENCY); + if (refundAmountStripeUnits <= 0) { + throw new KnownErrors.SchemaError("Refund amount must be greater than zero."); + } + if (refundAmountStripeUnits > totalStripeUnits) { + throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount."); + } + } await stripe.refunds.create({ payment_intent: purchase.stripePaymentIntentId, + ...(refundAmountStripeUnits !== null ? { amount: refundAmountStripeUnits } : {}), metadata: { tenancyId: auth.tenancy.id, purchaseId: purchase.id, diff --git a/apps/dashboard/src/components/data-table/transaction-table.tsx b/apps/dashboard/src/components/data-table/transaction-table.tsx index c7dd550272..1f2fcf2cbc 100644 --- a/apps/dashboard/src/components/data-table/transaction-table.tsx +++ b/apps/dashboard/src/components/data-table/transaction-table.tsx @@ -1,11 +1,15 @@ 'use client'; import { useAdminApp } from '@/app/(main)/(protected)/projects/[projectId]/use-admin-app'; -import { ActionCell, ActionDialog, AvatarCell, Badge, DataTableColumnHeader, DataTableManualPagination, DateCell, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, TextCell, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'; +import { ActionCell, ActionDialog, Alert, AlertDescription, AvatarCell, Badge, DataTableColumnHeader, DataTableManualPagination, DateCell, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, TextCell, 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 type { Transaction, TransactionEntry, TransactionType } from '@stackframe/stack-shared/dist/interface/crud/transactions'; import { TRANSACTION_TYPES } from '@stackframe/stack-shared/dist/interface/crud/transactions'; +import type { MoneyAmount } from '@stackframe/stack-shared/dist/utils/currency-constants'; +import { SUPPORTED_CURRENCIES } from '@stackframe/stack-shared/dist/utils/currency-constants'; +import { moneyAmountToStripeUnits } from '@stackframe/stack-shared/dist/utils/currencies'; +import { moneyAmountSchema } from '@stackframe/stack-shared/dist/schema-fields'; import { deepPlainEquals } from '@stackframe/stack-shared/dist/utils/objects'; import type { ColumnDef, ColumnFiltersState, SortingState } from '@tanstack/react-table'; import React, { useCallback } from 'react'; @@ -34,6 +38,7 @@ type MoneyTransferEntry = Extract; type ProductGrantEntry = Extract; type ItemQuantityChangeEntry = Extract; type RefundTarget = { type: 'subscription' | 'one-time-purchase', id: string }; +const USD_CURRENCY = SUPPORTED_CURRENCIES.find((currency) => currency.code === 'USD'); function isEntryWithCustomer(entry: TransactionEntry): entry is EntryWithCustomer { return 'customer_type' in entry && 'customer_id' in entry; @@ -191,10 +196,41 @@ function getTransactionSummary(transaction: Transaction): TransactionSummary { function RefundActionCell({ transaction, refundTarget }: { transaction: Transaction, refundTarget: RefundTarget | null }) { const app = useAdminApp(); const [isDialogOpen, setIsDialogOpen] = React.useState(false); + const [refundAmountUsd, setRefundAmountUsd] = React.useState(''); const target = transaction.type === 'purchase' ? refundTarget : null; const alreadyRefunded = transaction.adjusted_by.length > 0; const productEntry = transaction.entries.find(isProductGrantEntry); const canRefund = !!target && !transaction.test_mode && !alreadyRefunded && productEntry?.price_id; + const moneyTransferEntry = transaction.entries.find(isMoneyTransferEntry); + const chargedAmountUsd = moneyTransferEntry ? (moneyTransferEntry.charged_amount.USD ?? null) : null; + + React.useEffect(() => { + if (isDialogOpen) { + setRefundAmountUsd(chargedAmountUsd ?? ''); + } + }, [chargedAmountUsd, isDialogOpen]); + + const refundValidation = React.useMemo(() => { + if (!chargedAmountUsd || !USD_CURRENCY) { + return { canSubmit: true, error: null, amountUsd: undefined }; + } + if (!refundAmountUsd) { + return { canSubmit: false, error: "Enter a refund amount.", amountUsd: undefined }; + } + const isValid = moneyAmountSchema(USD_CURRENCY).defined().isValidSync(refundAmountUsd); + if (!isValid) { + return { canSubmit: false, error: "Refund amount must be a valid USD amount.", amountUsd: undefined }; + } + const refundUnits = moneyAmountToStripeUnits(refundAmountUsd as MoneyAmount, USD_CURRENCY); + const maxUnits = moneyAmountToStripeUnits(chargedAmountUsd as MoneyAmount, USD_CURRENCY); + if (refundUnits <= 0) { + return { canSubmit: false, error: "Refund amount must be greater than zero.", amountUsd: undefined }; + } + if (refundUnits > maxUnits) { + return { canSubmit: false, error: `Refund amount cannot exceed $${chargedAmountUsd}.`, amountUsd: undefined }; + } + return { canSubmit: true, error: null, amountUsd: refundAmountUsd as MoneyAmount }; + }, [chargedAmountUsd, refundAmountUsd]); return ( <> @@ -208,13 +244,41 @@ function RefundActionCell({ transaction, refundTarget }: { transaction: Transact okButton={{ label: "Refund", onClick: async () => { - await app.refundTransaction(target); - setIsDialogOpen(false); + if (chargedAmountUsd && !refundValidation.canSubmit) { + return "prevent-close"; + } + await app.refundTransaction({ ...target, amountUsd: refundValidation.amountUsd }); }, + props: chargedAmountUsd ? { disabled: !refundValidation.canSubmit } : undefined, }} confirmText="Refunds cannot be undone and will revoke access to the purchased product." > - {`Refund this ${target.type === 'subscription' ? 'subscription' : 'one-time purchase'} transaction?`} +
+

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

+ {chargedAmountUsd ? ( +
+ + setRefundAmountUsd(event.target.value)} + /> + {refundValidation.error ? ( + + {refundValidation.error} + + ) : null} +
+ ) : ( + + + Partial refunds are only available for USD charges. This will issue a full refund. + + + )} +
) : null} tx.type === "purchase"); + expect(purchaseTransaction).toBeDefined(); + + return { userId, transactionsRes, purchaseTransaction }; +} + it("returns TestModePurchaseNonRefundable when refunding test mode one-time purchases", async () => { await setupProjectWithPaymentsConfig(); const { transactionId, userId } = await createTestModeTransaction("otp-product", "single"); @@ -135,49 +194,7 @@ it("returns SubscriptionInvoiceNotFound when id does not exist", async () => { }); it("refunds non-test mode one-time purchases created via Stripe webhooks", async () => { - const config = await setupProjectWithPaymentsConfig({ testMode: false }); - const { userId } = await User.create(); - - const accountInfo = await niceBackendFetch("/api/latest/internal/payments/stripe/account-info", { - accessType: "admin", - }); - expect(accountInfo.status).toBe(200); - const accountId: string = accountInfo.body.account_id; - - const code = await createPurchaseCode({ userId, productId: "otp-product" }); - const stackTestTenancyId = code.split("_")[0]; - const product = config.payments.products["otp-product"]; - - const paymentIntentPayload = { - id: "evt_otp_refund_success", - type: "payment_intent.succeeded", - account: accountId, - data: { - object: { - id: "pi_otp_refund_success", - customer: userId, - stack_stripe_mock_data: { - "accounts.retrieve": { metadata: { tenancyId: stackTestTenancyId } }, - "customers.retrieve": { metadata: { customerId: userId, customerType: "USER" } }, - "subscriptions.list": { data: [] }, - }, - metadata: { - productId: "otp-product", - product: JSON.stringify(product), - customerId: userId, - customerType: "user", - purchaseQuantity: "1", - purchaseKind: "ONE_TIME", - priceId: "single", - }, - }, - }, - }; - - const webhookRes = await Payments.sendStripeWebhook(paymentIntentPayload); - expect(webhookRes.status).toBe(200); - expect(webhookRes.body).toEqual({ received: true }); - + const { userId, transactionsRes, purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction(); const productsRes = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { accessType: "client", }); @@ -185,9 +202,6 @@ it("refunds non-test mode one-time purchases created via Stripe webhooks", async expect(productsRes.body.items).toHaveLength(1); expect(productsRes.body.items[0].id).toBe("otp-product"); - const transactionsRes = await niceBackendFetch("/api/latest/internal/payments/transactions", { - accessType: "admin", - }); expect(transactionsRes.body).toMatchInlineSnapshot(` { "next_cursor": null, @@ -237,7 +251,6 @@ it("refunds non-test mode one-time purchases created via Stripe webhooks", async } `); - const purchaseTransaction = transactionsRes.body.transactions.find((tx: any) => tx.type === "purchase"); const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { accessType: "admin", method: "POST", @@ -288,3 +301,31 @@ it("refunds non-test mode one-time purchases created via Stripe webhooks", async } `); }); + +it("refunds partial amounts for non-test mode one-time purchases", async () => { + const { userId, purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction(); + + const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { + accessType: "admin", + method: "POST", + body: { type: "one-time-purchase", id: purchaseTransaction.id, amount_usd: "1250" }, + }); + expect(refundRes.status).toBe(200); + expect(refundRes.body).toEqual({ success: true }); + + const transactionsAfterRefund = await niceBackendFetch("/api/latest/internal/payments/transactions", { + accessType: "admin", + }); + const refundedTransaction = transactionsAfterRefund.body.transactions.find((tx: any) => tx.id === purchaseTransaction.id); + expect(refundedTransaction?.adjusted_by).toEqual([ + { + entry_index: 0, + transaction_id: expect.stringContaining(`${purchaseTransaction.id}:refund`), + }, + ]); + + const productsAfterRes = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { + accessType: "client", + }); + expect(productsAfterRes.body.items).toHaveLength(0); +}); diff --git a/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index fd95ec525b..ca8747f247 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -10,6 +10,7 @@ import { SvixTokenCrud } from "./crud/svix-token"; import { TeamPermissionDefinitionsCrud } from "./crud/team-permissions"; import type { Transaction, TransactionType } from "./crud/transactions"; import { ServerAuthApplicationOptions, StackServerInterface } from "./server-interface"; +import type { MoneyAmount } from "../utils/currency-constants"; export type ChatContent = Array< @@ -609,7 +610,7 @@ export class StackAdminInterface extends StackServerInterface { return { transactions: json.transactions, nextCursor: json.next_cursor }; } - async refundTransaction(options: { type: "subscription" | "one-time-purchase", id: string }): Promise<{ success: boolean }> { + async refundTransaction(options: { type: "subscription" | "one-time-purchase", id: string, amountUsd?: MoneyAmount }): Promise<{ success: boolean }> { const response = await this.sendAdminRequest( "/internal/payments/transactions/refund", { @@ -617,7 +618,11 @@ export class StackAdminInterface extends StackServerInterface { headers: { "content-type": "application/json", }, - body: JSON.stringify(options), + body: JSON.stringify({ + type: options.type, + id: options.id, + amount_usd: options.amountUsd, + }), }, null, ); diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index 617ab99422..38326b8560 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -8,6 +8,7 @@ import type { Transaction, TransactionType } from "@stackframe/stack-shared/dist import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { pick } from "@stackframe/stack-shared/dist/utils/objects"; import { Result } from "@stackframe/stack-shared/dist/utils/results"; +import type { MoneyAmount } from "@stackframe/stack-shared/dist/utils/currency-constants"; import { useMemo } from "react"; // THIS_LINE_PLATFORM react-like import { AdminEmailOutbox, AdminSentEmail } from "../.."; import { EmailConfig, stackAppInternalsSymbol } from "../../common"; @@ -600,8 +601,8 @@ export class _StackAdminAppImplIncomplete { - await this._interface.refundTransaction({ type: options.type, id: options.id }); + async refundTransaction(options: { type: "subscription" | "one-time-purchase", id: string, amountUsd?: MoneyAmount }): Promise { + await this._interface.refundTransaction({ type: options.type, id: options.id, amountUsd: options.amountUsd }); await this._transactionsCache.invalidateWhere(() => true); } diff --git a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts index f721e4eca1..7a8419bd8f 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts @@ -1,6 +1,7 @@ import { ChatContent } from "@stackframe/stack-shared/dist/interface/admin-interface"; import type { Transaction, TransactionType } from "@stackframe/stack-shared/dist/interface/crud/transactions"; import { InternalSession } from "@stackframe/stack-shared/dist/sessions"; +import type { MoneyAmount } from "@stackframe/stack-shared/dist/utils/currency-constants"; import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { AsyncStoreProperty, EmailConfig } from "../../common"; import { AdminEmailOutbox, AdminSentEmail } from "../../email"; @@ -107,7 +108,7 @@ export type StackAdminApp, - refundTransaction(options: { type: "subscription" | "one-time-purchase", id: string }): Promise, + refundTransaction(options: { type: "subscription" | "one-time-purchase", id: string, amountUsd?: MoneyAmount }): Promise, // Email Outbox methods listOutboxEmails(options?: EmailOutboxListOptions): Promise, From 3cacdde1b8b98d2bd01c07b0ee9fba7ac8f7897b Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 19 Jan 2026 17:42:13 -0800 Subject: [PATCH 02/12] fix action dialog --- apps/dashboard/src/components/ui/action-dialog.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/src/components/ui/action-dialog.tsx b/apps/dashboard/src/components/ui/action-dialog.tsx index 50d5bcfcef..2da0605b2a 100644 --- a/apps/dashboard/src/components/ui/action-dialog.tsx +++ b/apps/dashboard/src/components/ui/action-dialog.tsx @@ -45,6 +45,8 @@ export function ActionDialog(props: ActionDialogProps) { const [confirmed, setConfirmed] = React.useState(false); const confirmId = useId(); const [invalidationCount, setInvalidationCount] = React.useState(0); + const { disabled: okButtonDisabledProp, ...okButtonProps } = okButton?.props ?? {}; + const okButtonDisabled = (!!props.confirmText && !confirmed) || !!okButtonDisabledProp; const onOpenChange = (open: boolean) => { if (!open) { @@ -122,14 +124,14 @@ export function ActionDialog(props: ActionDialogProps) { )} {okButton && ( @@ -139,4 +141,3 @@ export function ActionDialog(props: ActionDialogProps) { ); } - From 12d9147a7b2277da197c2a4bb284e070ff629492 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 19 Jan 2026 17:47:38 -0800 Subject: [PATCH 03/12] fix typecheck --- apps/dashboard/src/components/ui/action-dialog.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/src/components/ui/action-dialog.tsx b/apps/dashboard/src/components/ui/action-dialog.tsx index 2da0605b2a..7a437818e8 100644 --- a/apps/dashboard/src/components/ui/action-dialog.tsx +++ b/apps/dashboard/src/components/ui/action-dialog.tsx @@ -45,7 +45,8 @@ export function ActionDialog(props: ActionDialogProps) { const [confirmed, setConfirmed] = React.useState(false); const confirmId = useId(); const [invalidationCount, setInvalidationCount] = React.useState(0); - const { disabled: okButtonDisabledProp, ...okButtonProps } = okButton?.props ?? {}; + const okButtonExtraProps = okButton && typeof okButton === "object" ? okButton.props : undefined; + const { disabled: okButtonDisabledProp, ...okButtonProps } = okButtonExtraProps ?? {}; const okButtonDisabled = (!!props.confirmText && !confirmed) || !!okButtonDisabledProp; const onOpenChange = (open: boolean) => { From f1eb6389dfd9d96a99c35d910ee5a5b771f93ef6 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 26 Jan 2026 16:48:07 -0800 Subject: [PATCH 04/12] partial refund product quantity --- .../payments/transactions/refund/route.tsx | 122 +++++++++----- .../data-table/transaction-table.tsx | 154 +++++++++++++++--- .../v1/internal/transactions-refund.test.ts | 73 ++++++++- .../src/interface/admin-interface.ts | 11 +- .../apps/implementations/admin-app-impl.ts | 14 +- .../stack-app/apps/interfaces/admin-app.ts | 7 +- 6 files changed, 309 insertions(+), 72 deletions(-) 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 70659c3c0d..b2418ea8cb 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 @@ -2,13 +2,14 @@ import { getStripeForAccount } from "@/lib/stripe"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; -import { adaptSchema, adminAuthTypeSchema, moneyAmountSchema, productSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { adaptSchema, adminAuthTypeSchema, moneyAmountSchema, productSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { SubscriptionStatus } from "@/generated/prisma/client"; import { SUPPORTED_CURRENCIES, type MoneyAmount } from "@stackframe/stack-shared/dist/utils/currency-constants"; import { moneyAmountToStripeUnits } from "@stackframe/stack-shared/dist/utils/currencies"; -import { resolveSelectedPriceFromProduct } from "@/app/api/latest/internal/payments/transactions/transaction-builder"; +import { buildOneTimePurchaseTransaction, buildSubscriptionTransaction, resolveSelectedPriceFromProduct } from "@/app/api/latest/internal/payments/transactions/transaction-builder"; import { InferType } from "yup"; +import type { TransactionEntry } from "@stackframe/stack-shared/dist/interface/crud/transactions"; const USD_CURRENCY = SUPPORTED_CURRENCIES.find((currency) => currency.code === "USD") ?? throwErr("USD currency configuration missing in SUPPORTED_CURRENCIES"); @@ -25,6 +26,44 @@ function getTotalUsdStripeUnits(options: { product: InferType(); + const entryByIndex = new Map( + options.entries.map((entry, index) => [index, entry]), + ); + + for (const refundEntry of options.refundEntries) { + if (!Number.isFinite(refundEntry.quantity) || Math.trunc(refundEntry.quantity) !== refundEntry.quantity) { + throw new KnownErrors.SchemaError("Refund quantity must be an integer."); + } + if (refundEntry.quantity <= 0) { + throw new KnownErrors.SchemaError("Refund quantity must be greater than zero."); + } + 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."); + } + } +} + export const POST = createSmartRouteHandler({ metadata: { hidden: true, @@ -38,7 +77,13 @@ export const POST = createSmartRouteHandler({ body: yupObject({ type: yupString().oneOf(["subscription", "one-time-purchase"]).defined(), id: yupString().defined(), - amount_usd: moneyAmountSchema(USD_CURRENCY).optional(), + amount_usd: moneyAmountSchema(USD_CURRENCY).defined(), + refund_entries: yupArray( + yupObject({ + entry_index: yupNumber().integer().defined(), + quantity: yupNumber().integer().defined(), + }).defined(), + ).defined(), }).defined() }), response: yupObject({ @@ -50,16 +95,11 @@ export const POST = createSmartRouteHandler({ }), handler: async ({ auth, body }) => { const prisma = await getPrismaClientForTenancy(auth.tenancy); - const refundAmountUsd = body.amount_usd ?? null; + const refundAmountUsd = body.amount_usd; + const refundEntries = body.refund_entries; if (body.type === "subscription") { const subscription = await prisma.subscription.findUnique({ where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } }, - select: { - refundedAt: true, - product: true, - priceId: true, - quantity: true, - }, }); if (!subscription) { throw new KnownErrors.SubscriptionInvoiceNotFound(body.id); @@ -99,23 +139,26 @@ export const POST = createSmartRouteHandler({ throw new StackAssertionError("Payment has no payment intent", { invoiceId: subscriptionInvoice.stripeInvoiceId }); } let refundAmountStripeUnits: number | null = null; - if (refundAmountUsd) { - const totalStripeUnits = getTotalUsdStripeUnits({ - product: subscription.product as InferType, - priceId: subscription.priceId ?? null, - quantity: subscription.quantity, - }); - refundAmountStripeUnits = moneyAmountToStripeUnits(refundAmountUsd as MoneyAmount, USD_CURRENCY); - if (refundAmountStripeUnits <= 0) { - throw new KnownErrors.SchemaError("Refund amount must be greater than zero."); - } - if (refundAmountStripeUnits > totalStripeUnits) { - throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount."); - } + const transaction = buildSubscriptionTransaction({ subscription }); + validateRefundEntries({ + entries: transaction.entries, + refundEntries, + }); + const totalStripeUnits = getTotalUsdStripeUnits({ + product: subscription.product as InferType, + priceId: subscription.priceId ?? null, + quantity: subscription.quantity, + }); + refundAmountStripeUnits = moneyAmountToStripeUnits(refundAmountUsd as MoneyAmount, USD_CURRENCY); + if (refundAmountStripeUnits <= 0) { + throw new KnownErrors.SchemaError("Refund amount must be greater than zero."); + } + if (refundAmountStripeUnits > totalStripeUnits) { + throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount."); } await stripe.refunds.create({ payment_intent: paymentIntentId, - ...(refundAmountStripeUnits !== null ? { amount: refundAmountStripeUnits } : {}), + amount: refundAmountStripeUnits, }); await prisma.subscription.update({ where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } }, @@ -144,23 +187,26 @@ export const POST = createSmartRouteHandler({ throw new KnownErrors.OneTimePurchaseNotFound(body.id); } let refundAmountStripeUnits: number | null = null; - if (refundAmountUsd) { - const totalStripeUnits = getTotalUsdStripeUnits({ - product: purchase.product as InferType, - priceId: purchase.priceId ?? null, - quantity: purchase.quantity, - }); - refundAmountStripeUnits = moneyAmountToStripeUnits(refundAmountUsd as MoneyAmount, USD_CURRENCY); - if (refundAmountStripeUnits <= 0) { - throw new KnownErrors.SchemaError("Refund amount must be greater than zero."); - } - if (refundAmountStripeUnits > totalStripeUnits) { - throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount."); - } + const transaction = buildOneTimePurchaseTransaction({ purchase }); + validateRefundEntries({ + entries: transaction.entries, + refundEntries, + }); + const totalStripeUnits = getTotalUsdStripeUnits({ + product: purchase.product as InferType, + priceId: purchase.priceId ?? null, + quantity: purchase.quantity, + }); + refundAmountStripeUnits = moneyAmountToStripeUnits(refundAmountUsd as MoneyAmount, USD_CURRENCY); + if (refundAmountStripeUnits <= 0) { + throw new KnownErrors.SchemaError("Refund amount must be greater than zero."); + } + if (refundAmountStripeUnits > totalStripeUnits) { + throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount."); } await stripe.refunds.create({ payment_intent: purchase.stripePaymentIntentId, - ...(refundAmountStripeUnits !== null ? { amount: refundAmountStripeUnits } : {}), + amount: refundAmountStripeUnits, metadata: { tenancyId: auth.tenancy.id, purchaseId: purchase.id, diff --git a/apps/dashboard/src/components/data-table/transaction-table.tsx b/apps/dashboard/src/components/data-table/transaction-table.tsx index 1f2fcf2cbc..57aca370a2 100644 --- a/apps/dashboard/src/components/data-table/transaction-table.tsx +++ b/apps/dashboard/src/components/data-table/transaction-table.tsx @@ -10,6 +10,7 @@ import type { MoneyAmount } from '@stackframe/stack-shared/dist/utils/currency-c import { SUPPORTED_CURRENCIES } from '@stackframe/stack-shared/dist/utils/currency-constants'; import { moneyAmountToStripeUnits } from '@stackframe/stack-shared/dist/utils/currencies'; import { moneyAmountSchema } from '@stackframe/stack-shared/dist/schema-fields'; +import { throwErr } from '@stackframe/stack-shared/dist/utils/errors'; import { deepPlainEquals } from '@stackframe/stack-shared/dist/utils/objects'; import type { ColumnDef, ColumnFiltersState, SortingState } from '@tanstack/react-table'; import React, { useCallback } from 'react'; @@ -38,6 +39,7 @@ 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 { @@ -154,11 +156,30 @@ 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) { - const product = productGrant.product as { displayName?: string } | null | undefined; - const name = product?.displayName ?? productGrant.product_id ?? 'Product'; + const name = getProductDisplayName(productGrant); const quantity = productGrant.quantity; return `${name} (×${quantity})`; } @@ -196,41 +217,88 @@ function getTransactionSummary(transaction: Transaction): TransactionSummary { function RefundActionCell({ transaction, refundTarget }: { transaction: Transaction, refundTarget: RefundTarget | null }) { const app = useAdminApp(); const [isDialogOpen, setIsDialogOpen] = React.useState(false); + const [refundSelections, setRefundSelections] = React.useState([]); const [refundAmountUsd, setRefundAmountUsd] = React.useState(''); const target = transaction.type === 'purchase' ? refundTarget : null; const alreadyRefunded = transaction.adjusted_by.length > 0; - const productEntry = transaction.entries.find(isProductGrantEntry); - const canRefund = !!target && !transaction.test_mode && !alreadyRefunded && productEntry?.price_id; + const productEntries = React.useMemo(() => getRefundableProductEntries(transaction), [transaction]); + const canRefund = !!target && !transaction.test_mode && !alreadyRefunded && productEntries.length > 0; const moneyTransferEntry = transaction.entries.find(isMoneyTransferEntry); const chargedAmountUsd = moneyTransferEntry ? (moneyTransferEntry.charged_amount.USD ?? null) : null; React.useEffect(() => { if (isDialogOpen) { + setRefundSelections(productEntries.map(({ entryIndex, entry }) => ({ + entryIndex, + quantity: entry.quantity, + }))); setRefundAmountUsd(chargedAmountUsd ?? ''); } - }, [chargedAmountUsd, isDialogOpen]); + }, [chargedAmountUsd, isDialogOpen, productEntries]); + + const refundCandidates = React.useMemo(() => { + return productEntries.map(({ entryIndex, entry }) => ({ + entryIndex, + entry, + productName: getProductDisplayName(entry), + maxQuantity: entry.quantity, + unitPriceUsd: getUsdUnitPrice(entry), + })); + }, [productEntries]); + + const selectionByIndex = React.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 = React.useMemo(() => { if (!chargedAmountUsd || !USD_CURRENCY) { - return { canSubmit: true, error: null, amountUsd: undefined }; + return { canSubmit: false, error: "Refund amounts are only supported for USD charges.", refundEntries: undefined, amountUsd: undefined }; } if (!refundAmountUsd) { - return { canSubmit: false, error: "Enter a refund amount.", amountUsd: undefined }; + return { canSubmit: false, error: "Enter a refund amount.", refundEntries: undefined, amountUsd: undefined }; } const isValid = moneyAmountSchema(USD_CURRENCY).defined().isValidSync(refundAmountUsd); if (!isValid) { - return { canSubmit: false, error: "Refund amount must be a valid USD amount.", amountUsd: undefined }; + return { canSubmit: false, error: "Refund amount must be a valid USD amount.", refundEntries: undefined, amountUsd: undefined }; } const refundUnits = moneyAmountToStripeUnits(refundAmountUsd as MoneyAmount, USD_CURRENCY); - const maxUnits = moneyAmountToStripeUnits(chargedAmountUsd as MoneyAmount, USD_CURRENCY); + const maxChargedUnits = moneyAmountToStripeUnits(chargedAmountUsd as MoneyAmount, USD_CURRENCY); if (refundUnits <= 0) { - return { canSubmit: false, error: "Refund amount must be greater than zero.", amountUsd: undefined }; + return { canSubmit: false, error: "Refund amount must be greater than zero.", refundEntries: undefined, amountUsd: undefined }; + } + if (refundUnits > maxChargedUnits) { + return { canSubmit: false, error: `Refund amount cannot exceed $${chargedAmountUsd}.`, refundEntries: undefined, amountUsd: undefined }; } - if (refundUnits > maxUnits) { - return { canSubmit: false, error: `Refund amount cannot exceed $${chargedAmountUsd}.`, amountUsd: undefined }; + if (!canComputeRefundEntries) { + return { canSubmit: false, error: "Refund entries are only supported for USD-priced products.", refundEntries: undefined, amountUsd: undefined }; } - return { canSubmit: true, error: null, amountUsd: refundAmountUsd as MoneyAmount }; - }, [chargedAmountUsd, refundAmountUsd]); + if (totalSelectedQuantity <= 0) { + return { canSubmit: false, error: "Select at least one product to refund.", refundEntries: undefined, amountUsd: undefined }; + } + const maxUnits = maxChargedUnits; + const selectedUnits = selectedEntries.reduce((sum, entry) => { + if (!entry.unitPriceUsd) return sum; + const entryUnits = moneyAmountToStripeUnits(entry.unitPriceUsd, USD_CURRENCY) * entry.selectedQuantity; + return sum + entryUnits; + }, 0); + if (selectedUnits <= 0) { + return { canSubmit: false, error: "Refund amount must be greater than zero.", refundEntries: undefined, amountUsd: undefined }; + } + if (selectedUnits > maxUnits) { + return { canSubmit: false, error: `Refund amount cannot exceed $${chargedAmountUsd}.`, refundEntries: undefined, amountUsd: undefined }; + } + const refundEntries = selectedEntries + .filter((entry) => entry.selectedQuantity > 0) + .map((entry) => ({ entryIndex: entry.entryIndex, quantity: entry.selectedQuantity })); + return { canSubmit: true, error: null, refundEntries, amountUsd: refundAmountUsd as MoneyAmount }; + }, [chargedAmountUsd, canComputeRefundEntries, refundAmountUsd, selectedEntries, totalSelectedQuantity]); return ( <> @@ -247,7 +315,11 @@ function RefundActionCell({ transaction, refundTarget }: { transaction: Transact if (chargedAmountUsd && !refundValidation.canSubmit) { return "prevent-close"; } - await app.refundTransaction({ ...target, amountUsd: refundValidation.amountUsd }); + await app.refundTransaction({ + ...target, + refundEntries: refundValidation.refundEntries ?? throwErr("Refund entries missing for refund"), + amountUsd: refundValidation.amountUsd ?? throwErr("Refund amount missing for refund"), + }); }, props: chargedAmountUsd ? { disabled: !refundValidation.canSubmit } : undefined, }} @@ -257,14 +329,50 @@ function RefundActionCell({ transaction, refundTarget }: { transaction: Transact

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

{chargedAmountUsd ? (
- - setRefundAmountUsd(event.target.value)} - /> +
+ + 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} 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 16b4e1475b..130f80c7f1 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 @@ -77,9 +77,10 @@ async function createTestModeTransaction(productId: string, priceId: string) { return { transactionId: transaction.id, userId }; } -async function createLiveModeOneTimePurchaseTransaction() { +async function createLiveModeOneTimePurchaseTransaction(options: { quantity?: number } = {}) { const config = await setupProjectWithPaymentsConfig({ testMode: false }); const { userId } = await User.create(); + const quantity = options.quantity ?? 1; const accountInfo = await niceBackendFetch("/api/latest/internal/payments/stripe/account-info", { accessType: "admin", @@ -112,7 +113,7 @@ async function createLiveModeOneTimePurchaseTransaction() { product: JSON.stringify(product), customerId: userId, customerType: "user", - purchaseQuantity: "1", + purchaseQuantity: String(quantity), purchaseKind: "ONE_TIME", priceId: "single", }, @@ -150,7 +151,12 @@ it("returns TestModePurchaseNonRefundable when refunding test mode one-time purc const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { accessType: "admin", method: "POST", - body: { type: "one-time-purchase", id: transactionId }, + body: { + type: "one-time-purchase", + id: transactionId, + amount_usd: "5000", + refund_entries: [{ entry_index: 0, quantity: 1 }], + }, }); expect(refundRes).toMatchInlineSnapshot(` NiceResponse { @@ -175,7 +181,12 @@ it("returns SubscriptionInvoiceNotFound when id does not exist", async () => { const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { accessType: "admin", method: "POST", - body: { type: "subscription", id: missingId }, + body: { + type: "subscription", + id: missingId, + amount_usd: "1000", + refund_entries: [{ entry_index: 0, quantity: 1 }], + }, }); expect(refundRes).toMatchInlineSnapshot(` NiceResponse { @@ -254,7 +265,12 @@ it("refunds non-test mode one-time purchases created via Stripe webhooks", async const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { accessType: "admin", method: "POST", - body: { type: "one-time-purchase", id: purchaseTransaction.id }, + body: { + type: "one-time-purchase", + id: purchaseTransaction.id, + amount_usd: "5000", + refund_entries: [{ entry_index: 0, quantity: 1 }], + }, }); expect(refundRes.status).toBe(200); expect(refundRes.body).toEqual({ success: true }); @@ -273,7 +289,12 @@ it("refunds non-test mode one-time purchases created via Stripe webhooks", async const secondRefundAttempt = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { accessType: "admin", method: "POST", - body: { type: "one-time-purchase", id: purchaseTransaction.id }, + body: { + type: "one-time-purchase", + id: purchaseTransaction.id, + amount_usd: "5000", + refund_entries: [{ entry_index: 0, quantity: 1 }], + }, }); expect(secondRefundAttempt).toMatchInlineSnapshot(` NiceResponse { @@ -308,7 +329,45 @@ it("refunds partial amounts for non-test mode one-time purchases", async () => { const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { accessType: "admin", method: "POST", - body: { type: "one-time-purchase", id: purchaseTransaction.id, amount_usd: "1250" }, + body: { + type: "one-time-purchase", + id: purchaseTransaction.id, + amount_usd: "1250", + refund_entries: [{ entry_index: 0, quantity: 1 }], + }, + }); + expect(refundRes.status).toBe(200); + expect(refundRes.body).toEqual({ success: true }); + + const transactionsAfterRefund = await niceBackendFetch("/api/latest/internal/payments/transactions", { + accessType: "admin", + }); + const refundedTransaction = transactionsAfterRefund.body.transactions.find((tx: any) => tx.id === purchaseTransaction.id); + expect(refundedTransaction?.adjusted_by).toEqual([ + { + entry_index: 0, + transaction_id: expect.stringContaining(`${purchaseTransaction.id}:refund`), + }, + ]); + + const productsAfterRes = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, { + accessType: "client", + }); + expect(productsAfterRes.body.items).toHaveLength(0); +}); + +it("refunds selected quantities for non-test mode one-time purchases", async () => { + const { userId, purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction({ quantity: 3 }); + + const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { + accessType: "admin", + method: "POST", + body: { + type: "one-time-purchase", + id: purchaseTransaction.id, + amount_usd: "10000", + refund_entries: [{ entry_index: 0, quantity: 2 }], + }, }); expect(refundRes.status).toBe(200); expect(refundRes.body).toEqual({ success: true }); diff --git a/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index ca8747f247..33103866dd 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -610,7 +610,12 @@ export class StackAdminInterface extends StackServerInterface { return { transactions: json.transactions, nextCursor: json.next_cursor }; } - async refundTransaction(options: { type: "subscription" | "one-time-purchase", id: string, amountUsd?: MoneyAmount }): Promise<{ success: boolean }> { + async refundTransaction(options: { + type: "subscription" | "one-time-purchase", + id: string, + amountUsd: MoneyAmount, + refundEntries: Array<{ entryIndex: number, quantity: number }>, + }): Promise<{ success: boolean }> { const response = await this.sendAdminRequest( "/internal/payments/transactions/refund", { @@ -622,6 +627,10 @@ export class StackAdminInterface extends StackServerInterface { type: options.type, id: options.id, amount_usd: options.amountUsd, + refund_entries: options.refundEntries.map((entry) => ({ + entry_index: entry.entryIndex, + quantity: entry.quantity, + })), }), }, null, diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index 38326b8560..1cf2438e16 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -601,8 +601,18 @@ export class _StackAdminAppImplIncomplete { - await this._interface.refundTransaction({ type: options.type, id: options.id, amountUsd: options.amountUsd }); + async refundTransaction(options: { + type: "subscription" | "one-time-purchase", + id: string, + amountUsd: MoneyAmount, + refundEntries: Array<{ entryIndex: number, quantity: number }>, + }): Promise { + await this._interface.refundTransaction({ + type: options.type, + id: options.id, + amountUsd: options.amountUsd, + refundEntries: options.refundEntries, + }); await this._transactionsCache.invalidateWhere(() => true); } diff --git a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts index 7a8419bd8f..0069fd19db 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts @@ -108,7 +108,12 @@ export type StackAdminApp, - refundTransaction(options: { type: "subscription" | "one-time-purchase", id: string, amountUsd?: MoneyAmount }): Promise, + refundTransaction(options: { + type: "subscription" | "one-time-purchase", + id: string, + amountUsd: MoneyAmount, + refundEntries: Array<{ entryIndex: number, quantity: number }>, + }): Promise, // Email Outbox methods listOutboxEmails(options?: EmailOutboxListOptions): Promise, From 9d40dd73d00553e5d564abd8734fd853d963de55 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 26 Jan 2026 17:43:21 -0800 Subject: [PATCH 05/12] fixes --- .../payments/transactions/refund/route.tsx | 7 ++----- .../components/data-table/transaction-table.tsx | 14 +++++++------- .../backend/endpoints/api/v1/email-themes.test.ts | 2 +- 3 files changed, 10 insertions(+), 13 deletions(-) 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 b2418ea8cb..3c2cc0dd8d 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 @@ -32,9 +32,6 @@ type RefundEntrySelection = { }; function validateRefundEntries(options: { entries: TransactionEntry[], refundEntries: RefundEntrySelection[] }) { - if (options.refundEntries.length === 0) { - throw new KnownErrors.SchemaError("Refund entries cannot be empty."); - } const seenEntryIndexes = new Set(); const entryByIndex = new Map( options.entries.map((entry, index) => [index, entry]), @@ -44,8 +41,8 @@ function validateRefundEntries(options: { entries: TransactionEntry[], refundEnt 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 must be greater than zero."); + 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."); diff --git a/apps/dashboard/src/components/data-table/transaction-table.tsx b/apps/dashboard/src/components/data-table/transaction-table.tsx index 57aca370a2..9626ea7f5a 100644 --- a/apps/dashboard/src/components/data-table/transaction-table.tsx +++ b/apps/dashboard/src/components/data-table/transaction-table.tsx @@ -270,8 +270,8 @@ function RefundActionCell({ transaction, refundTarget }: { transaction: Transact } const refundUnits = moneyAmountToStripeUnits(refundAmountUsd as MoneyAmount, USD_CURRENCY); const maxChargedUnits = moneyAmountToStripeUnits(chargedAmountUsd as MoneyAmount, USD_CURRENCY); - if (refundUnits <= 0) { - return { canSubmit: false, error: "Refund amount must be greater than zero.", refundEntries: undefined, amountUsd: undefined }; + if (refundUnits < 0) { + return { canSubmit: false, error: "Refund amount cannot be negative.", refundEntries: undefined, amountUsd: undefined }; } if (refundUnits > maxChargedUnits) { return { canSubmit: false, error: `Refund amount cannot exceed $${chargedAmountUsd}.`, refundEntries: undefined, amountUsd: undefined }; @@ -279,8 +279,8 @@ function RefundActionCell({ transaction, refundTarget }: { transaction: Transact if (!canComputeRefundEntries) { return { canSubmit: false, error: "Refund entries are only supported for USD-priced products.", refundEntries: undefined, amountUsd: undefined }; } - if (totalSelectedQuantity <= 0) { - return { canSubmit: false, error: "Select at least one product to refund.", refundEntries: undefined, amountUsd: undefined }; + if (totalSelectedQuantity < 0) { + return { canSubmit: false, error: "Quantity cannot be negative.", refundEntries: undefined, amountUsd: undefined }; } const maxUnits = maxChargedUnits; const selectedUnits = selectedEntries.reduce((sum, entry) => { @@ -288,8 +288,8 @@ function RefundActionCell({ transaction, refundTarget }: { transaction: Transact const entryUnits = moneyAmountToStripeUnits(entry.unitPriceUsd, USD_CURRENCY) * entry.selectedQuantity; return sum + entryUnits; }, 0); - if (selectedUnits <= 0) { - return { canSubmit: false, error: "Refund amount must be greater than zero.", refundEntries: undefined, amountUsd: undefined }; + if (selectedUnits < 0) { + return { canSubmit: false, error: "Quantity cannot be negative.", refundEntries: undefined, amountUsd: undefined }; } if (selectedUnits > maxUnits) { return { canSubmit: false, error: `Refund amount cannot exceed $${chargedAmountUsd}.`, refundEntries: undefined, amountUsd: undefined }; @@ -323,7 +323,7 @@ function RefundActionCell({ transaction, refundTarget }: { transaction: Transact }, props: chargedAmountUsd ? { disabled: !refundValidation.canSubmit } : undefined, }} - confirmText="Refunds cannot be undone and will revoke access to the purchased product." + confirmText="Refunds cannot be undone" >

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

diff --git a/apps/e2e/tests/backend/endpoints/api/v1/email-themes.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/email-themes.test.ts index c9003e0c75..2c573df399 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/email-themes.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/email-themes.test.ts @@ -233,7 +233,7 @@ describe("update email theme", () => { NiceResponse { "status": 200, "body": { - "display_name": "Default Light", + "display_name": "Unnamed Theme", "id": "", "tsx_source": deindent\` import { Html, Tailwind, Body } from '@react-email/components'; From 2ad4a7d4e260de6b5253e6d3500b888269b9063c Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 26 Jan 2026 18:12:52 -0800 Subject: [PATCH 06/12] fix refund route --- .../payments/transactions/refund/route.tsx | 62 ++++++++++++++++--- 1 file changed, 53 insertions(+), 9 deletions(-) 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 3c2cc0dd8d..80d4e03694 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 @@ -61,6 +61,14 @@ function validateRefundEntries(options: { entries: TransactionEntry[], refundEnt } } +function getRefundedQuantity(refundEntries: RefundEntrySelection[]) { + let total = 0; + for (const refundEntry of refundEntries) { + total += refundEntry.quantity; + } + return total; +} + export const POST = createSmartRouteHandler({ metadata: { hidden: true, @@ -141,6 +149,7 @@ export const POST = createSmartRouteHandler({ entries: transaction.entries, refundEntries, }); + const refundedQuantity = getRefundedQuantity(refundEntries); const totalStripeUnits = getTotalUsdStripeUnits({ product: subscription.product as InferType, priceId: subscription.priceId ?? null, @@ -157,15 +166,50 @@ export const POST = createSmartRouteHandler({ payment_intent: paymentIntentId, amount: refundAmountStripeUnits, }); - await prisma.subscription.update({ - where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } }, - data: { - status: SubscriptionStatus.canceled, - cancelAtPeriodEnd: true, - currentPeriodEnd: new Date(), - refundedAt: new Date(), - }, - }); + if (refundedQuantity > 0) { + if (!subscription.stripeSubscriptionId) { + throw new StackAssertionError("Stripe subscription id missing for refund", { subscriptionId: subscription.id }); + } + const stripeSubscription = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId); + if (stripeSubscription.items.data.length === 0) { + throw new StackAssertionError("Stripe subscription has no items", { subscriptionId: subscription.id }); + } + const subscriptionItem = stripeSubscription.items.data[0]; + if (!Number.isFinite(subscriptionItem.quantity) || Math.trunc(subscriptionItem.quantity ?? 0) !== subscriptionItem.quantity) { + throw new StackAssertionError("Stripe subscription item quantity is not an integer", { + subscriptionId: subscription.id, + itemQuantity: subscriptionItem.quantity, + }); + } + const currentQuantity = subscriptionItem.quantity ?? 0; + const newQuantity = currentQuantity - refundedQuantity; + if (newQuantity < 0) { + throw new StackAssertionError("Refund quantity exceeds Stripe subscription item quantity", { + subscriptionId: subscription.id, + currentQuantity, + refundedQuantity, + }); + } + await stripe.subscriptions.update(subscription.stripeSubscriptionId, { + cancel_at_period_end: newQuantity === 0, + items: [{ + id: subscriptionItem.id, + quantity: newQuantity, + }], + }); + await prisma.subscription.update({ + where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } }, + data: { + cancelAtPeriodEnd: newQuantity === 0, + refundedAt: new Date(), + }, + }); + } else { + await prisma.subscription.update({ + where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } }, + data: { refundedAt: new Date() }, + }); + } } else { const purchase = await prisma.oneTimePurchase.findUnique({ where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } }, From 375271b9cb47e6125355bcf60387d0e99b933009 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 26 Jan 2026 18:26:21 -0800 Subject: [PATCH 07/12] allow zero refund amount --- .../internal/payments/transactions/refund/route.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 80d4e03694..d181d7e7cb 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 @@ -156,8 +156,8 @@ export const POST = createSmartRouteHandler({ quantity: subscription.quantity, }); refundAmountStripeUnits = moneyAmountToStripeUnits(refundAmountUsd as MoneyAmount, USD_CURRENCY); - if (refundAmountStripeUnits <= 0) { - throw new KnownErrors.SchemaError("Refund amount must be greater than zero."); + 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."); @@ -239,8 +239,8 @@ export const POST = createSmartRouteHandler({ quantity: purchase.quantity, }); refundAmountStripeUnits = moneyAmountToStripeUnits(refundAmountUsd as MoneyAmount, USD_CURRENCY); - if (refundAmountStripeUnits <= 0) { - throw new KnownErrors.SchemaError("Refund amount must be greater than zero."); + 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."); From 9743e5857067716fcfeee7333c6fd20b0cdae677 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 26 Jan 2026 19:02:31 -0800 Subject: [PATCH 08/12] more transaction refund tests --- .../payments/transactions/refund/route.tsx | 9 +- .../v1/internal/transactions-refund.test.ts | 359 ++++++++++++++++++ apps/e2e/tests/general/typecheck.test.ts | 2 +- 3 files changed, 364 insertions(+), 6 deletions(-) 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 d181d7e7cb..6df29a715a 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,15 +1,14 @@ +import { buildOneTimePurchaseTransaction, buildSubscriptionTransaction, resolveSelectedPriceFromProduct } from "@/app/api/latest/internal/payments/transactions/transaction-builder"; import { getStripeForAccount } from "@/lib/stripe"; import { getPrismaClientForTenancy } 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 { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; -import { SubscriptionStatus } from "@/generated/prisma/client"; -import { SUPPORTED_CURRENCIES, type MoneyAmount } from "@stackframe/stack-shared/dist/utils/currency-constants"; import { moneyAmountToStripeUnits } from "@stackframe/stack-shared/dist/utils/currencies"; -import { buildOneTimePurchaseTransaction, buildSubscriptionTransaction, resolveSelectedPriceFromProduct } from "@/app/api/latest/internal/payments/transactions/transaction-builder"; +import { SUPPORTED_CURRENCIES, type MoneyAmount } from "@stackframe/stack-shared/dist/utils/currency-constants"; +import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { InferType } from "yup"; -import type { TransactionEntry } from "@stackframe/stack-shared/dist/interface/crud/transactions"; const USD_CURRENCY = SUPPORTED_CURRENCIES.find((currency) => currency.code === "USD") ?? throwErr("USD currency configuration missing in SUPPORTED_CURRENCIES"); 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 130f80c7f1..058b49b14f 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 @@ -354,6 +354,18 @@ it("refunds partial amounts for non-test mode one-time purchases", async () => { accessType: "client", }); expect(productsAfterRes.body.items).toHaveLength(0); + + const secondRefundAttempt = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { + accessType: "admin", + method: "POST", + body: { + type: "one-time-purchase", + id: purchaseTransaction.id, + amount_usd: "1250", + refund_entries: [{ entry_index: 0, quantity: 1 }], + }, + }); + expect(secondRefundAttempt.body.code).toBe("ONE_TIME_PURCHASE_ALREADY_REFUNDED"); }); it("refunds selected quantities for non-test mode one-time purchases", async () => { @@ -388,3 +400,350 @@ it("refunds selected quantities for non-test mode one-time purchases", async () }); expect(productsAfterRes.body.items).toHaveLength(0); }); + +it("returns SCHEMA_ERROR when amount_usd is negative", async () => { + const { purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction(); + + const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { + accessType: "admin", + method: "POST", + body: { + type: "one-time-purchase", + id: purchaseTransaction.id, + amount_usd: "-1", + refund_entries: [{ entry_index: 0, quantity: 1 }], + }, + }); + expect(refundRes).toMatchInlineSnapshot(` + NiceResponse { + "status": 400, + "body": { + "code": "SCHEMA_ERROR", + "details": { + "message": deindent\` + Request validation failed on POST /api/latest/internal/payments/transactions/refund: + - Money amount must be in the format of or . + \`, + }, + "error": deindent\` + Request validation failed on POST /api/latest/internal/payments/transactions/refund: + - Money amount must be in the format of or . + \`, + }, + "headers": Headers { + "x-stack-known-error": "SCHEMA_ERROR", +