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..069f7960ab 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,10 +1,81 @@ +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, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; -import { SubscriptionStatus } from "@/generated/prisma/client"; +import { adaptSchema, adminAuthTypeSchema, moneyAmountSchema, productSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { moneyAmountToStripeUnits } from "@stackframe/stack-shared/dist/utils/currencies"; +import { SUPPORTED_CURRENCIES, type MoneyAmount } from "@stackframe/stack-shared/dist/utils/currency-constants"; +import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { 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; +} + +type RefundEntrySelection = { + entry_index: number, + quantity: number, + amount_usd: MoneyAmount, +}; + +function validateRefundEntries(options: { entries: TransactionEntry[], refundEntries: RefundEntrySelection[] }) { + const seenEntryIndexes = new Set(); + const entryByIndex = new Map( + options.entries.map((entry, index) => [index, entry]), + ); + + for (const refundEntry of options.refundEntries) { + if (!Number.isFinite(refundEntry.quantity) || Math.trunc(refundEntry.quantity) !== refundEntry.quantity) { + throw new KnownErrors.SchemaError("Refund quantity must be an integer."); + } + if (refundEntry.quantity < 0) { + throw new KnownErrors.SchemaError("Refund quantity cannot be negative."); + } + if (seenEntryIndexes.has(refundEntry.entry_index)) { + throw new KnownErrors.SchemaError("Refund entries cannot contain duplicate entry indexes."); + } + seenEntryIndexes.add(refundEntry.entry_index); + const entry = entryByIndex.get(refundEntry.entry_index); + if (!entry) { + throw new KnownErrors.SchemaError("Refund entry index is invalid."); + } + if (entry.type !== "product_grant") { + throw new KnownErrors.SchemaError("Refund entries must reference product grant entries."); + } + if (refundEntry.quantity > entry.quantity) { + throw new KnownErrors.SchemaError("Refund quantity cannot exceed purchased quantity."); + } + } +} + +function getRefundedQuantity(refundEntries: RefundEntrySelection[]) { + let total = 0; + for (const refundEntry of refundEntries) { + total += refundEntry.quantity; + } + return total; +} + +function getRefundAmountStripeUnits(refundEntries: RefundEntrySelection[]) { + let total = 0; + for (const refundEntry of refundEntries) { + total += moneyAmountToStripeUnits(refundEntry.amount_usd, USD_CURRENCY); + } + return total; +} export const POST = createSmartRouteHandler({ metadata: { @@ -19,6 +90,13 @@ 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() }), response: yupObject({ @@ -30,10 +108,13 @@ export const POST = createSmartRouteHandler({ }), 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 } }, - select: { refundedAt: true }, }); if (!subscription) { throw new KnownErrors.SubscriptionInvoiceNotFound(body.id); @@ -72,16 +153,73 @@ 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 }); - 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(), - }, + let refundAmountStripeUnits: number | null = null; + const transaction = buildSubscriptionTransaction({ subscription }); + validateRefundEntries({ + entries: transaction.entries, + refundEntries, }); + const refundedQuantity = getRefundedQuantity(refundEntries); + const totalStripeUnits = getTotalUsdStripeUnits({ + product: subscription.product as InferType, + priceId: subscription.priceId ?? null, + quantity: subscription.quantity, + }); + 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({ + payment_intent: paymentIntentId, + amount: refundAmountStripeUnits, + }); + 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 } }, @@ -99,8 +237,27 @@ export const POST = createSmartRouteHandler({ if (!purchase.stripePaymentIntentId) { throw new KnownErrors.OneTimePurchaseNotFound(body.id); } + let refundAmountStripeUnits: number | null = null; + 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 = 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({ payment_intent: purchase.stripePaymentIntentId, + 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..743919b986 100644 --- a/apps/dashboard/src/components/data-table/transaction-table.tsx +++ b/apps/dashboard/src/components/data-table/transaction-table.tsx @@ -1,11 +1,16 @@ '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 { 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'; @@ -34,6 +39,8 @@ 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 { return 'customer_type' in entry && 'customer_id' in entry; @@ -149,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})`; } @@ -191,10 +217,96 @@ 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, 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: false, error: "Refund amounts are only supported for USD charges.", refundEntries: undefined }; + } + 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); + if (refundUnits < 0) { + return { canSubmit: false, error: "Refund amount cannot be negative.", refundEntries: undefined }; + } + if (refundUnits > maxChargedUnits) { + return { canSubmit: false, error: `Refund amount cannot exceed $${chargedAmountUsd}.`, refundEntries: undefined }; + } + if (!canComputeRefundEntries) { + return { canSubmit: false, error: "Refund entries are only supported for USD-priced products.", refundEntries: undefined }; + } + 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; + 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 }; + } + 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 ( <> @@ -208,13 +320,80 @@ 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, + refundEntries: refundValidation.refundEntries ?? throwErr("Refund entries missing for refund"), + }); }, + 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?`} +
+

{`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. + + + )} +
) : null} { if (!open) { @@ -122,14 +125,14 @@ export function ActionDialog(props: ActionDialogProps) { )} {okButton && ( @@ -139,4 +142,3 @@ export function ActionDialog(props: ActionDialogProps) { ); } - 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 312350e0c1..17e2baf593 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,6 +77,66 @@ async function createTestModeTransaction(productId: string, priceId: string) { return { transactionId: transaction.id, userId }; } +async function createLiveModeOneTimePurchaseTransaction(options: { quantity?: number } = {}) { + const config = await setupProjectWithPaymentsConfig({ testMode: false }); + const { userId } = await Auth.fastSignUp(); + const quantity = options.quantity ?? 1; + + const accountInfo = await niceBackendFetch("/api/latest/internal/payments/stripe/account-info", { + accessType: "admin", + }); + expect(accountInfo.status).toBe(200); + const accountId: string = accountInfo.body.account_id; + + const code = await createPurchaseCode({ userId, productId: "otp-product" }); + const stackTestTenancyId = code.split("_")[0]; + const product = config.payments.products["otp-product"]; + + const idSuffix = randomUUID().replace(/-/g, ""); + const eventId = `evt_otp_refund_${idSuffix}`; + const paymentIntentId = `pi_otp_refund_${idSuffix}`; + const paymentIntentPayload = { + id: eventId, + type: "payment_intent.succeeded", + account: accountId, + data: { + object: { + id: paymentIntentId, + customer: userId, + stack_stripe_mock_data: { + "accounts.retrieve": { metadata: { tenancyId: stackTestTenancyId } }, + "customers.retrieve": { metadata: { customerId: userId, customerType: "USER" } }, + "subscriptions.list": { data: [] }, + }, + metadata: { + productId: "otp-product", + product: JSON.stringify(product), + customerId: userId, + customerType: "user", + purchaseQuantity: String(quantity), + purchaseKind: "ONE_TIME", + priceId: "single", + }, + }, + }, + }; + + const webhookSecret = process.env.STACK_STRIPE_WEBHOOK_SECRET ?? "mock_stripe_webhook_secret"; + const webhookRes = await Payments.sendStripeWebhook(paymentIntentPayload, { secret: webhookSecret }); + expect(webhookRes.status).toBe(200); + expect(webhookRes.body).toEqual({ received: true }); + + const transactionsRes = await niceBackendFetch("/api/latest/internal/payments/transactions", { + accessType: "admin", + }); + expect(transactionsRes.status).toBe(200); + + const purchaseTransaction = transactionsRes.body.transactions.find((tx: any) => tx.type === "purchase"); + expect(purchaseTransaction).toBeDefined(); + + return { userId, transactionsRes, purchaseTransaction }; +} + it("returns TestModePurchaseNonRefundable when refunding test mode one-time purchases", async () => { await setupProjectWithPaymentsConfig(); const { transactionId, userId } = await createTestModeTransaction("otp-product", "single"); @@ -91,7 +151,11 @@ 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, + refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "5000" }], + }, }); expect(refundRes).toMatchInlineSnapshot(` NiceResponse { @@ -116,7 +180,11 @@ 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, + refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "1000" }], + }, }); expect(refundRes).toMatchInlineSnapshot(` NiceResponse { @@ -135,49 +203,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 Auth.fastSignUp(); - - const accountInfo = await niceBackendFetch("/api/latest/internal/payments/stripe/account-info", { - accessType: "admin", - }); - expect(accountInfo.status).toBe(200); - const accountId: string = accountInfo.body.account_id; - - const code = await createPurchaseCode({ userId, productId: "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 +211,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,11 +260,14 @@ 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", - body: { type: "one-time-purchase", id: purchaseTransaction.id }, + body: { + type: "one-time-purchase", + id: purchaseTransaction.id, + refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "5000" }], + }, }); expect(refundRes.status).toBe(200); expect(refundRes.body).toEqual({ success: true }); @@ -260,7 +286,11 @@ 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, + refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "5000" }], + }, }); expect(secondRefundAttempt).toMatchInlineSnapshot(` NiceResponse { @@ -288,3 +318,413 @@ 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, + refund_entries: [{ entry_index: 0, quantity: 1, 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); + + const secondRefundAttempt = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", { + accessType: "admin", + method: "POST", + body: { + type: "one-time-purchase", + id: purchaseTransaction.id, + refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "1250" }], + }, + }); + expect(secondRefundAttempt.body.code).toBe("ONE_TIME_PURCHASE_ALREADY_REFUNDED"); +}); + +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, + refund_entries: [{ entry_index: 0, quantity: 2, amount_usd: "10000" }], + }, + }); + 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("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, + refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "-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", +