Skip to content

Commit efefa5d

Browse files
authored
Partial refunds frontend (#1123)
https://www.loom.com/share/bb7abfde507f40d386ee856f5ffbd506 <!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * USD-based refund system enabling partial and full refunds with explicit USD amounts * Per-entry refund selection with granular quantity controls in refund dialogs * **Bug Fixes** * Stronger refund validation and error handling to prevent invalid or out-of-bounds refunds * **Tests** * Expanded end-to-end coverage for refund edge cases and scenarios * **Style** * Improved refund dialog UI with contextual alerts and better controls <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent b32eb9e commit efefa5d

11 files changed

Lines changed: 894 additions & 86 deletions

File tree

apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx

Lines changed: 170 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,81 @@
1+
import { buildOneTimePurchaseTransaction, buildSubscriptionTransaction, resolveSelectedPriceFromProduct } from "@/app/api/latest/internal/payments/transactions/transaction-builder";
12
import { getStripeForAccount } from "@/lib/stripe";
23
import { getPrismaClientForTenancy } from "@/prisma-client";
34
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
5+
import type { TransactionEntry } from "@stackframe/stack-shared/dist/interface/crud/transactions";
46
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
5-
import { adaptSchema, adminAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
6-
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
7-
import { SubscriptionStatus } from "@/generated/prisma/client";
7+
import { adaptSchema, adminAuthTypeSchema, moneyAmountSchema, productSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
8+
import { moneyAmountToStripeUnits } from "@stackframe/stack-shared/dist/utils/currencies";
9+
import { SUPPORTED_CURRENCIES, type MoneyAmount } from "@stackframe/stack-shared/dist/utils/currency-constants";
10+
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
11+
import { InferType } from "yup";
12+
13+
const USD_CURRENCY = SUPPORTED_CURRENCIES.find((currency) => currency.code === "USD")
14+
?? throwErr("USD currency configuration missing in SUPPORTED_CURRENCIES");
15+
16+
function getTotalUsdStripeUnits(options: { product: InferType<typeof productSchema>, priceId: string | null, quantity: number }) {
17+
const selectedPrice = resolveSelectedPriceFromProduct(options.product, options.priceId ?? null);
18+
const usdPrice = selectedPrice?.USD;
19+
if (typeof usdPrice !== "string") {
20+
throw new KnownErrors.SchemaError("Refund amounts can only be specified for USD-priced purchases.");
21+
}
22+
if (!Number.isFinite(options.quantity) || Math.trunc(options.quantity) !== options.quantity) {
23+
throw new StackAssertionError("Purchase quantity is not an integer", { quantity: options.quantity });
24+
}
25+
return moneyAmountToStripeUnits(usdPrice as MoneyAmount, USD_CURRENCY) * options.quantity;
26+
}
27+
28+
type RefundEntrySelection = {
29+
entry_index: number,
30+
quantity: number,
31+
amount_usd: MoneyAmount,
32+
};
33+
34+
function validateRefundEntries(options: { entries: TransactionEntry[], refundEntries: RefundEntrySelection[] }) {
35+
const seenEntryIndexes = new Set<number>();
36+
const entryByIndex = new Map<number, TransactionEntry>(
37+
options.entries.map((entry, index) => [index, entry]),
38+
);
39+
40+
for (const refundEntry of options.refundEntries) {
41+
if (!Number.isFinite(refundEntry.quantity) || Math.trunc(refundEntry.quantity) !== refundEntry.quantity) {
42+
throw new KnownErrors.SchemaError("Refund quantity must be an integer.");
43+
}
44+
if (refundEntry.quantity < 0) {
45+
throw new KnownErrors.SchemaError("Refund quantity cannot be negative.");
46+
}
47+
if (seenEntryIndexes.has(refundEntry.entry_index)) {
48+
throw new KnownErrors.SchemaError("Refund entries cannot contain duplicate entry indexes.");
49+
}
50+
seenEntryIndexes.add(refundEntry.entry_index);
51+
const entry = entryByIndex.get(refundEntry.entry_index);
52+
if (!entry) {
53+
throw new KnownErrors.SchemaError("Refund entry index is invalid.");
54+
}
55+
if (entry.type !== "product_grant") {
56+
throw new KnownErrors.SchemaError("Refund entries must reference product grant entries.");
57+
}
58+
if (refundEntry.quantity > entry.quantity) {
59+
throw new KnownErrors.SchemaError("Refund quantity cannot exceed purchased quantity.");
60+
}
61+
}
62+
}
63+
64+
function getRefundedQuantity(refundEntries: RefundEntrySelection[]) {
65+
let total = 0;
66+
for (const refundEntry of refundEntries) {
67+
total += refundEntry.quantity;
68+
}
69+
return total;
70+
}
71+
72+
function getRefundAmountStripeUnits(refundEntries: RefundEntrySelection[]) {
73+
let total = 0;
74+
for (const refundEntry of refundEntries) {
75+
total += moneyAmountToStripeUnits(refundEntry.amount_usd, USD_CURRENCY);
76+
}
77+
return total;
78+
}
879

980
export const POST = createSmartRouteHandler({
1081
metadata: {
@@ -19,6 +90,13 @@ export const POST = createSmartRouteHandler({
1990
body: yupObject({
2091
type: yupString().oneOf(["subscription", "one-time-purchase"]).defined(),
2192
id: yupString().defined(),
93+
refund_entries: yupArray(
94+
yupObject({
95+
entry_index: yupNumber().integer().defined(),
96+
quantity: yupNumber().integer().defined(),
97+
amount_usd: moneyAmountSchema(USD_CURRENCY).defined(),
98+
}).defined(),
99+
).defined(),
22100
}).defined()
23101
}),
24102
response: yupObject({
@@ -30,10 +108,13 @@ export const POST = createSmartRouteHandler({
30108
}),
31109
handler: async ({ auth, body }) => {
32110
const prisma = await getPrismaClientForTenancy(auth.tenancy);
111+
const refundEntries = body.refund_entries.map((entry) => ({
112+
...entry,
113+
amount_usd: entry.amount_usd as MoneyAmount,
114+
}));
33115
if (body.type === "subscription") {
34116
const subscription = await prisma.subscription.findUnique({
35117
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } },
36-
select: { refundedAt: true },
37118
});
38119
if (!subscription) {
39120
throw new KnownErrors.SubscriptionInvoiceNotFound(body.id);
@@ -72,16 +153,73 @@ export const POST = createSmartRouteHandler({
72153
if (!paymentIntentId || typeof paymentIntentId !== "string") {
73154
throw new StackAssertionError("Payment has no payment intent", { invoiceId: subscriptionInvoice.stripeInvoiceId });
74155
}
75-
await stripe.refunds.create({ payment_intent: paymentIntentId });
76-
await prisma.subscription.update({
77-
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } },
78-
data: {
79-
status: SubscriptionStatus.canceled,
80-
cancelAtPeriodEnd: true,
81-
currentPeriodEnd: new Date(),
82-
refundedAt: new Date(),
83-
},
156+
let refundAmountStripeUnits: number | null = null;
157+
const transaction = buildSubscriptionTransaction({ subscription });
158+
validateRefundEntries({
159+
entries: transaction.entries,
160+
refundEntries,
84161
});
162+
const refundedQuantity = getRefundedQuantity(refundEntries);
163+
const totalStripeUnits = getTotalUsdStripeUnits({
164+
product: subscription.product as InferType<typeof productSchema>,
165+
priceId: subscription.priceId ?? null,
166+
quantity: subscription.quantity,
167+
});
168+
refundAmountStripeUnits = getRefundAmountStripeUnits(refundEntries);
169+
if (refundAmountStripeUnits < 0) {
170+
throw new KnownErrors.SchemaError("Refund amount cannot be negative.");
171+
}
172+
if (refundAmountStripeUnits > totalStripeUnits) {
173+
throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount.");
174+
}
175+
await stripe.refunds.create({
176+
payment_intent: paymentIntentId,
177+
amount: refundAmountStripeUnits,
178+
});
179+
if (refundedQuantity > 0) {
180+
if (!subscription.stripeSubscriptionId) {
181+
throw new StackAssertionError("Stripe subscription id missing for refund", { subscriptionId: subscription.id });
182+
}
183+
const stripeSubscription = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId);
184+
if (stripeSubscription.items.data.length === 0) {
185+
throw new StackAssertionError("Stripe subscription has no items", { subscriptionId: subscription.id });
186+
}
187+
const subscriptionItem = stripeSubscription.items.data[0];
188+
if (!Number.isFinite(subscriptionItem.quantity) || Math.trunc(subscriptionItem.quantity ?? 0) !== subscriptionItem.quantity) {
189+
throw new StackAssertionError("Stripe subscription item quantity is not an integer", {
190+
subscriptionId: subscription.id,
191+
itemQuantity: subscriptionItem.quantity,
192+
});
193+
}
194+
const currentQuantity = subscriptionItem.quantity ?? 0;
195+
const newQuantity = currentQuantity - refundedQuantity;
196+
if (newQuantity < 0) {
197+
throw new StackAssertionError("Refund quantity exceeds Stripe subscription item quantity", {
198+
subscriptionId: subscription.id,
199+
currentQuantity,
200+
refundedQuantity,
201+
});
202+
}
203+
await stripe.subscriptions.update(subscription.stripeSubscriptionId, {
204+
cancel_at_period_end: newQuantity === 0,
205+
items: [{
206+
id: subscriptionItem.id,
207+
quantity: newQuantity,
208+
}],
209+
});
210+
await prisma.subscription.update({
211+
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } },
212+
data: {
213+
cancelAtPeriodEnd: newQuantity === 0,
214+
refundedAt: new Date(),
215+
},
216+
});
217+
} else {
218+
await prisma.subscription.update({
219+
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } },
220+
data: { refundedAt: new Date() },
221+
});
222+
}
85223
} else {
86224
const purchase = await prisma.oneTimePurchase.findUnique({
87225
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } },
@@ -99,8 +237,27 @@ export const POST = createSmartRouteHandler({
99237
if (!purchase.stripePaymentIntentId) {
100238
throw new KnownErrors.OneTimePurchaseNotFound(body.id);
101239
}
240+
let refundAmountStripeUnits: number | null = null;
241+
const transaction = buildOneTimePurchaseTransaction({ purchase });
242+
validateRefundEntries({
243+
entries: transaction.entries,
244+
refundEntries,
245+
});
246+
const totalStripeUnits = getTotalUsdStripeUnits({
247+
product: purchase.product as InferType<typeof productSchema>,
248+
priceId: purchase.priceId ?? null,
249+
quantity: purchase.quantity,
250+
});
251+
refundAmountStripeUnits = getRefundAmountStripeUnits(refundEntries);
252+
if (refundAmountStripeUnits < 0) {
253+
throw new KnownErrors.SchemaError("Refund amount cannot be negative.");
254+
}
255+
if (refundAmountStripeUnits > totalStripeUnits) {
256+
throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount.");
257+
}
102258
await stripe.refunds.create({
103259
payment_intent: purchase.stripePaymentIntentId,
260+
amount: refundAmountStripeUnits,
104261
metadata: {
105262
tenancyId: auth.tenancy.id,
106263
purchaseId: purchase.id,

0 commit comments

Comments
 (0)