Skip to content

Commit 43fb34d

Browse files
committed
amount usd in entries
1 parent 7beaf10 commit 43fb34d

6 files changed

Lines changed: 60 additions & 65 deletions

File tree

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

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ function getTotalUsdStripeUnits(options: { product: InferType<typeof productSche
2828
type RefundEntrySelection = {
2929
entry_index: number,
3030
quantity: number,
31+
amount_usd: MoneyAmount,
3132
};
3233

3334
function validateRefundEntries(options: { entries: TransactionEntry[], refundEntries: RefundEntrySelection[] }) {
@@ -68,6 +69,14 @@ function getRefundedQuantity(refundEntries: RefundEntrySelection[]) {
6869
return total;
6970
}
7071

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+
}
79+
7180
export const POST = createSmartRouteHandler({
7281
metadata: {
7382
hidden: true,
@@ -81,11 +90,11 @@ export const POST = createSmartRouteHandler({
8190
body: yupObject({
8291
type: yupString().oneOf(["subscription", "one-time-purchase"]).defined(),
8392
id: yupString().defined(),
84-
amount_usd: moneyAmountSchema(USD_CURRENCY).defined(),
8593
refund_entries: yupArray(
8694
yupObject({
8795
entry_index: yupNumber().integer().defined(),
8896
quantity: yupNumber().integer().defined(),
97+
amount_usd: moneyAmountSchema(USD_CURRENCY).defined(),
8998
}).defined(),
9099
).defined(),
91100
}).defined()
@@ -99,8 +108,10 @@ export const POST = createSmartRouteHandler({
99108
}),
100109
handler: async ({ auth, body }) => {
101110
const prisma = await getPrismaClientForTenancy(auth.tenancy);
102-
const refundAmountUsd = body.amount_usd;
103-
const refundEntries = body.refund_entries;
111+
const refundEntries = body.refund_entries.map((entry) => ({
112+
...entry,
113+
amount_usd: entry.amount_usd as MoneyAmount,
114+
}));
104115
if (body.type === "subscription") {
105116
const subscription = await prisma.subscription.findUnique({
106117
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: body.id } },
@@ -154,7 +165,7 @@ export const POST = createSmartRouteHandler({
154165
priceId: subscription.priceId ?? null,
155166
quantity: subscription.quantity,
156167
});
157-
refundAmountStripeUnits = moneyAmountToStripeUnits(refundAmountUsd as MoneyAmount, USD_CURRENCY);
168+
refundAmountStripeUnits = getRefundAmountStripeUnits(refundEntries);
158169
if (refundAmountStripeUnits < 0) {
159170
throw new KnownErrors.SchemaError("Refund amount cannot be negative.");
160171
}
@@ -237,7 +248,7 @@ export const POST = createSmartRouteHandler({
237248
priceId: purchase.priceId ?? null,
238249
quantity: purchase.quantity,
239250
});
240-
refundAmountStripeUnits = moneyAmountToStripeUnits(refundAmountUsd as MoneyAmount, USD_CURRENCY);
251+
refundAmountStripeUnits = getRefundAmountStripeUnits(refundEntries);
241252
if (refundAmountStripeUnits < 0) {
242253
throw new KnownErrors.SchemaError("Refund amount cannot be negative.");
243254
}

apps/dashboard/src/components/data-table/transaction-table.tsx

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -259,28 +259,28 @@ function RefundActionCell({ transaction, refundTarget }: { transaction: Transact
259259

260260
const refundValidation = React.useMemo(() => {
261261
if (!chargedAmountUsd || !USD_CURRENCY) {
262-
return { canSubmit: false, error: "Refund amounts are only supported for USD charges.", refundEntries: undefined, amountUsd: undefined };
262+
return { canSubmit: false, error: "Refund amounts are only supported for USD charges.", refundEntries: undefined };
263263
}
264264
if (!refundAmountUsd) {
265-
return { canSubmit: false, error: "Enter a refund amount.", refundEntries: undefined, amountUsd: undefined };
265+
return { canSubmit: false, error: "Enter a refund amount.", refundEntries: undefined };
266266
}
267267
const isValid = moneyAmountSchema(USD_CURRENCY).defined().isValidSync(refundAmountUsd);
268268
if (!isValid) {
269-
return { canSubmit: false, error: "Refund amount must be a valid USD amount.", refundEntries: undefined, amountUsd: undefined };
269+
return { canSubmit: false, error: "Refund amount must be a valid USD amount.", refundEntries: undefined };
270270
}
271271
const refundUnits = moneyAmountToStripeUnits(refundAmountUsd as MoneyAmount, USD_CURRENCY);
272272
const maxChargedUnits = moneyAmountToStripeUnits(chargedAmountUsd as MoneyAmount, USD_CURRENCY);
273273
if (refundUnits < 0) {
274-
return { canSubmit: false, error: "Refund amount cannot be negative.", refundEntries: undefined, amountUsd: undefined };
274+
return { canSubmit: false, error: "Refund amount cannot be negative.", refundEntries: undefined };
275275
}
276276
if (refundUnits > maxChargedUnits) {
277-
return { canSubmit: false, error: `Refund amount cannot exceed $${chargedAmountUsd}.`, refundEntries: undefined, amountUsd: undefined };
277+
return { canSubmit: false, error: `Refund amount cannot exceed $${chargedAmountUsd}.`, refundEntries: undefined };
278278
}
279279
if (!canComputeRefundEntries) {
280-
return { canSubmit: false, error: "Refund entries are only supported for USD-priced products.", refundEntries: undefined, amountUsd: undefined };
280+
return { canSubmit: false, error: "Refund entries are only supported for USD-priced products.", refundEntries: undefined };
281281
}
282282
if (totalSelectedQuantity < 0) {
283-
return { canSubmit: false, error: "Quantity cannot be negative.", refundEntries: undefined, amountUsd: undefined };
283+
return { canSubmit: false, error: "Quantity cannot be negative.", refundEntries: undefined };
284284
}
285285
const maxUnits = maxChargedUnits;
286286
const selectedUnits = selectedEntries.reduce((sum, entry) => {
@@ -289,15 +289,23 @@ function RefundActionCell({ transaction, refundTarget }: { transaction: Transact
289289
return sum + entryUnits;
290290
}, 0);
291291
if (selectedUnits < 0) {
292-
return { canSubmit: false, error: "Quantity cannot be negative.", refundEntries: undefined, amountUsd: undefined };
292+
return { canSubmit: false, error: "Quantity cannot be negative.", refundEntries: undefined };
293293
}
294294
if (selectedUnits > maxUnits) {
295-
return { canSubmit: false, error: `Refund amount cannot exceed $${chargedAmountUsd}.`, refundEntries: undefined, amountUsd: undefined };
295+
return { canSubmit: false, error: `Refund amount cannot exceed $${chargedAmountUsd}.`, refundEntries: undefined };
296296
}
297-
const refundEntries = selectedEntries
297+
const entries = selectedEntries
298298
.filter((entry) => entry.selectedQuantity > 0)
299299
.map((entry) => ({ entryIndex: entry.entryIndex, quantity: entry.selectedQuantity }));
300-
return { canSubmit: true, error: null, refundEntries, amountUsd: refundAmountUsd as MoneyAmount };
300+
const fallbackEntry = selectedEntries[0] ?? throwErr("Refund entry missing for refund entries");
301+
const normalizedEntries = entries.length > 0
302+
? entries
303+
: [{ entryIndex: fallbackEntry.entryIndex, quantity: 0 }];
304+
const refundEntries = normalizedEntries.map((entry, index) => ({
305+
...entry,
306+
amountUsd: (index === 0 ? refundAmountUsd : "0") as MoneyAmount,
307+
}));
308+
return { canSubmit: true, error: null, refundEntries };
301309
}, [chargedAmountUsd, canComputeRefundEntries, refundAmountUsd, selectedEntries, totalSelectedQuantity]);
302310

303311
return (
@@ -318,7 +326,6 @@ function RefundActionCell({ transaction, refundTarget }: { transaction: Transact
318326
await app.refundTransaction({
319327
...target,
320328
refundEntries: refundValidation.refundEntries ?? throwErr("Refund entries missing for refund"),
321-
amountUsd: refundValidation.amountUsd ?? throwErr("Refund amount missing for refund"),
322329
});
323330
},
324331
props: chargedAmountUsd ? { disabled: !refundValidation.canSubmit } : undefined,

apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts

Lines changed: 21 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,7 @@ it("returns TestModePurchaseNonRefundable when refunding test mode one-time purc
154154
body: {
155155
type: "one-time-purchase",
156156
id: transactionId,
157-
amount_usd: "5000",
158-
refund_entries: [{ entry_index: 0, quantity: 1 }],
157+
refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "5000" }],
159158
},
160159
});
161160
expect(refundRes).toMatchInlineSnapshot(`
@@ -184,8 +183,7 @@ it("returns SubscriptionInvoiceNotFound when id does not exist", async () => {
184183
body: {
185184
type: "subscription",
186185
id: missingId,
187-
amount_usd: "1000",
188-
refund_entries: [{ entry_index: 0, quantity: 1 }],
186+
refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "1000" }],
189187
},
190188
});
191189
expect(refundRes).toMatchInlineSnapshot(`
@@ -268,8 +266,7 @@ it("refunds non-test mode one-time purchases created via Stripe webhooks", async
268266
body: {
269267
type: "one-time-purchase",
270268
id: purchaseTransaction.id,
271-
amount_usd: "5000",
272-
refund_entries: [{ entry_index: 0, quantity: 1 }],
269+
refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "5000" }],
273270
},
274271
});
275272
expect(refundRes.status).toBe(200);
@@ -292,8 +289,7 @@ it("refunds non-test mode one-time purchases created via Stripe webhooks", async
292289
body: {
293290
type: "one-time-purchase",
294291
id: purchaseTransaction.id,
295-
amount_usd: "5000",
296-
refund_entries: [{ entry_index: 0, quantity: 1 }],
292+
refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "5000" }],
297293
},
298294
});
299295
expect(secondRefundAttempt).toMatchInlineSnapshot(`
@@ -332,8 +328,7 @@ it("refunds partial amounts for non-test mode one-time purchases", async () => {
332328
body: {
333329
type: "one-time-purchase",
334330
id: purchaseTransaction.id,
335-
amount_usd: "1250",
336-
refund_entries: [{ entry_index: 0, quantity: 1 }],
331+
refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "1250" }],
337332
},
338333
});
339334
expect(refundRes.status).toBe(200);
@@ -361,8 +356,7 @@ it("refunds partial amounts for non-test mode one-time purchases", async () => {
361356
body: {
362357
type: "one-time-purchase",
363358
id: purchaseTransaction.id,
364-
amount_usd: "1250",
365-
refund_entries: [{ entry_index: 0, quantity: 1 }],
359+
refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "1250" }],
366360
},
367361
});
368362
expect(secondRefundAttempt.body.code).toBe("ONE_TIME_PURCHASE_ALREADY_REFUNDED");
@@ -377,8 +371,7 @@ it("refunds selected quantities for non-test mode one-time purchases", async ()
377371
body: {
378372
type: "one-time-purchase",
379373
id: purchaseTransaction.id,
380-
amount_usd: "10000",
381-
refund_entries: [{ entry_index: 0, quantity: 2 }],
374+
refund_entries: [{ entry_index: 0, quantity: 2, amount_usd: "10000" }],
382375
},
383376
});
384377
expect(refundRes.status).toBe(200);
@@ -410,8 +403,7 @@ it("returns SCHEMA_ERROR when amount_usd is negative", async () => {
410403
body: {
411404
type: "one-time-purchase",
412405
id: purchaseTransaction.id,
413-
amount_usd: "-1",
414-
refund_entries: [{ entry_index: 0, quantity: 1 }],
406+
refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "-1" }],
415407
},
416408
});
417409
expect(refundRes).toMatchInlineSnapshot(`
@@ -447,8 +439,7 @@ it("allows amount_usd of zero", async () => {
447439
body: {
448440
type: "one-time-purchase",
449441
id: purchaseTransaction.id,
450-
amount_usd: "0",
451-
refund_entries: [{ entry_index: 0, quantity: 1 }],
442+
refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "0" }],
452443
},
453444
});
454445
expect(refundRes).toMatchInlineSnapshot(`
@@ -460,7 +451,7 @@ it("allows amount_usd of zero", async () => {
460451
`);
461452
});
462453

463-
it("allows empty refund_entries (money-only refund)", async () => {
454+
it("allows zero-quantity refund entries (money-only refund)", async () => {
464455
const { userId, purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction();
465456

466457
const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", {
@@ -469,8 +460,7 @@ it("allows empty refund_entries (money-only refund)", async () => {
469460
body: {
470461
type: "one-time-purchase",
471462
id: purchaseTransaction.id,
472-
amount_usd: "5000",
473-
refund_entries: [],
463+
refund_entries: [{ entry_index: 0, quantity: 0, amount_usd: "5000" }],
474464
},
475465
});
476466
expect(refundRes.status).toBe(200);
@@ -502,8 +492,7 @@ it("returns SCHEMA_ERROR when refund_entries contains bad entry_index", async ()
502492
body: {
503493
type: "one-time-purchase",
504494
id: purchaseTransaction.id,
505-
amount_usd: "5000",
506-
refund_entries: [{ entry_index: 999, quantity: 1 }],
495+
refund_entries: [{ entry_index: 999, quantity: 1, amount_usd: "5000" }],
507496
},
508497
});
509498
expect(refundRes).toMatchInlineSnapshot(`
@@ -531,8 +520,7 @@ it("returns SCHEMA_ERROR when refund_entries contains negative quantity", async
531520
body: {
532521
type: "one-time-purchase",
533522
id: purchaseTransaction.id,
534-
amount_usd: "5000",
535-
refund_entries: [{ entry_index: 0, quantity: -1 }],
523+
refund_entries: [{ entry_index: 0, quantity: -1, amount_usd: "5000" }],
536524
},
537525
});
538526
expect(refundRes).toMatchInlineSnapshot(`
@@ -560,8 +548,7 @@ it("allows refund_entries with zero quantity", async () => {
560548
body: {
561549
type: "one-time-purchase",
562550
id: purchaseTransaction.id,
563-
amount_usd: "5000",
564-
refund_entries: [{ entry_index: 0, quantity: 0 }],
551+
refund_entries: [{ entry_index: 0, quantity: 0, amount_usd: "5000" }],
565552
},
566553
});
567554
expect(refundRes.status).toBe(200);
@@ -593,8 +580,7 @@ it("returns SCHEMA_ERROR when refund_entries contains quantity past limit", asyn
593580
body: {
594581
type: "one-time-purchase",
595582
id: purchaseTransaction.id,
596-
amount_usd: "5000",
597-
refund_entries: [{ entry_index: 0, quantity: 2 }],
583+
refund_entries: [{ entry_index: 0, quantity: 2, amount_usd: "5000" }],
598584
},
599585
});
600586
expect(refundRes).toMatchInlineSnapshot(`
@@ -622,8 +608,7 @@ it("returns SCHEMA_ERROR when amount_usd exceeds charged amount", async () => {
622608
body: {
623609
type: "one-time-purchase",
624610
id: purchaseTransaction.id,
625-
amount_usd: "5001",
626-
refund_entries: [{ entry_index: 0, quantity: 1 }],
611+
refund_entries: [{ entry_index: 0, quantity: 1, amount_usd: "5001" }],
627612
},
628613
});
629614
expect(refundRes).toMatchInlineSnapshot(`
@@ -651,8 +636,7 @@ it("returns SCHEMA_ERROR when refund_entries contains negative entry_index", asy
651636
body: {
652637
type: "one-time-purchase",
653638
id: purchaseTransaction.id,
654-
amount_usd: "5000",
655-
refund_entries: [{ entry_index: -1, quantity: 1 }],
639+
refund_entries: [{ entry_index: -1, quantity: 1, amount_usd: "5000" }],
656640
},
657641
});
658642
expect(refundRes).toMatchInlineSnapshot(`
@@ -680,8 +664,7 @@ it("returns SCHEMA_ERROR when refund_entries quantity is not an integer", async
680664
body: {
681665
type: "one-time-purchase",
682666
id: purchaseTransaction.id,
683-
amount_usd: "5000",
684-
refund_entries: [{ entry_index: 0, quantity: 1.5 }],
667+
refund_entries: [{ entry_index: 0, quantity: 1.5, amount_usd: "5000" }],
685668
},
686669
});
687670
expect(refundRes.body.code).toBe("SCHEMA_ERROR");
@@ -696,8 +679,7 @@ it("returns SCHEMA_ERROR when refund_entries references non-product_grant entrie
696679
body: {
697680
type: "one-time-purchase",
698681
id: purchaseTransaction.id,
699-
amount_usd: "5000",
700-
refund_entries: [{ entry_index: 1, quantity: 1 }],
682+
refund_entries: [{ entry_index: 1, quantity: 1, amount_usd: "5000" }],
701683
},
702684
});
703685
expect(refundRes).toMatchInlineSnapshot(`
@@ -725,10 +707,9 @@ it("returns SCHEMA_ERROR when refund_entries contains duplicate entry indexes",
725707
body: {
726708
type: "one-time-purchase",
727709
id: purchaseTransaction.id,
728-
amount_usd: "5000",
729710
refund_entries: [
730-
{ entry_index: 0, quantity: 1 },
731-
{ entry_index: 0, quantity: 1 },
711+
{ entry_index: 0, quantity: 1, amount_usd: "5000" },
712+
{ entry_index: 0, quantity: 1, amount_usd: "5000" },
732713
],
733714
},
734715
});

packages/stack-shared/src/interface/admin-interface.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -710,8 +710,7 @@ export class StackAdminInterface extends StackServerInterface {
710710
async refundTransaction(options: {
711711
type: "subscription" | "one-time-purchase",
712712
id: string,
713-
amountUsd: MoneyAmount,
714-
refundEntries: Array<{ entryIndex: number, quantity: number }>,
713+
refundEntries: Array<{ entryIndex: number, quantity: number, amountUsd: MoneyAmount }>,
715714
}): Promise<{ success: boolean }> {
716715
const response = await this.sendAdminRequest(
717716
"/internal/payments/transactions/refund",
@@ -723,10 +722,10 @@ export class StackAdminInterface extends StackServerInterface {
723722
body: JSON.stringify({
724723
type: options.type,
725724
id: options.id,
726-
amount_usd: options.amountUsd,
727725
refund_entries: options.refundEntries.map((entry) => ({
728726
entry_index: entry.entryIndex,
729727
quantity: entry.quantity,
728+
amount_usd: entry.amountUsd,
730729
})),
731730
}),
732731
},

packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -676,13 +676,11 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
676676
async refundTransaction(options: {
677677
type: "subscription" | "one-time-purchase",
678678
id: string,
679-
amountUsd: MoneyAmount,
680-
refundEntries: Array<{ entryIndex: number, quantity: number }>,
679+
refundEntries: Array<{ entryIndex: number, quantity: number, amountUsd: MoneyAmount }>,
681680
}): Promise<void> {
682681
await this._interface.refundTransaction({
683682
type: options.type,
684683
id: options.id,
685-
amountUsd: options.amountUsd,
686684
refundEntries: options.refundEntries,
687685
});
688686
await this._transactionsCache.invalidateWhere(() => true);

packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,7 @@ export type StackAdminApp<HasTokenStore extends boolean = boolean, ProjectId ext
114114
refundTransaction(options: {
115115
type: "subscription" | "one-time-purchase",
116116
id: string,
117-
amountUsd: MoneyAmount,
118-
refundEntries: Array<{ entryIndex: number, quantity: number }>,
117+
refundEntries: Array<{ entryIndex: number, quantity: number, amountUsd: MoneyAmount }>,
119118
}): Promise<void>,
120119
queryAnalytics(options: AnalyticsQueryOptions): Promise<AnalyticsQueryResponse>,
121120

0 commit comments

Comments
 (0)