@fantastic.dev/better-stripe 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +64 -8
  3. package/dist/client/costRecovery-invoices.d.ts +38 -0
  4. package/dist/client/costRecovery-invoices.d.ts.map +1 -0
  5. package/dist/client/costRecovery-invoices.js +105 -0
  6. package/dist/client/costRecovery-invoices.js.map +1 -0
  7. package/dist/client/costRecovery.d.ts +43 -2
  8. package/dist/client/costRecovery.d.ts.map +1 -1
  9. package/dist/client/costRecovery.js +69 -2
  10. package/dist/client/costRecovery.js.map +1 -1
  11. package/dist/client/index.js +1 -1
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/component.d.ts +78 -0
  14. package/dist/component/_generated/component.d.ts.map +1 -1
  15. package/dist/component/costRecovery/claims.d.ts.map +1 -1
  16. package/dist/component/costRecovery/claims.js +15 -8
  17. package/dist/component/costRecovery/claims.js.map +1 -1
  18. package/dist/component/costRecovery/completion.d.ts +6 -0
  19. package/dist/component/costRecovery/completion.d.ts.map +1 -1
  20. package/dist/component/costRecovery/helpers.d.ts +6 -0
  21. package/dist/component/costRecovery/helpers.d.ts.map +1 -1
  22. package/dist/component/costRecovery/mutations.d.ts +72 -0
  23. package/dist/component/costRecovery/mutations.d.ts.map +1 -1
  24. package/dist/component/costRecovery/mutations.js +57 -0
  25. package/dist/component/costRecovery/mutations.js.map +1 -1
  26. package/dist/component/costRecovery/queries.d.ts +6 -0
  27. package/dist/component/costRecovery/queries.d.ts.map +1 -1
  28. package/dist/component/costRecovery/reservations.d.ts +12 -0
  29. package/dist/component/costRecovery/reservations.d.ts.map +1 -1
  30. package/dist/component/costRecovery/schema.d.ts +44 -2
  31. package/dist/component/costRecovery/schema.d.ts.map +1 -1
  32. package/dist/component/costRecovery/schema.js +6 -0
  33. package/dist/component/costRecovery/schema.js.map +1 -1
  34. package/dist/component/schema.d.ts +17 -1
  35. package/dist/react/workflows/Collections.d.ts.map +1 -1
  36. package/dist/react/workflows/Collections.js +2 -1
  37. package/dist/react/workflows/Collections.js.map +1 -1
  38. package/dist/react/workflows/Refunds.d.ts +14 -3
  39. package/dist/react/workflows/Refunds.d.ts.map +1 -1
  40. package/dist/react/workflows/Refunds.js +58 -16
  41. package/dist/react/workflows/Refunds.js.map +1 -1
  42. package/dist/react/workflows/index.d.ts +3 -1
  43. package/dist/react/workflows/index.d.ts.map +1 -1
  44. package/dist/react/workflows/index.js +2 -1
  45. package/dist/react/workflows/index.js.map +1 -1
  46. package/dist/react/workflows/refund-preview.d.ts +16 -0
  47. package/dist/react/workflows/refund-preview.d.ts.map +1 -0
  48. package/dist/react/workflows/refund-preview.js +38 -0
  49. package/dist/react/workflows/refund-preview.js.map +1 -0
  50. package/docs/react-workflows.md +79 -4
  51. package/package.json +1 -1
  52. package/src/client/costRecovery-invoices.test.ts +401 -0
  53. package/src/client/costRecovery-invoices.transport.test.ts +107 -0
  54. package/src/client/costRecovery-invoices.ts +144 -0
  55. package/src/client/costRecovery.ts +107 -2
  56. package/src/client/index.ts +1 -1
  57. package/src/component/_generated/component.ts +99 -0
  58. package/src/component/costRecovery/claims.ts +18 -8
  59. package/src/component/costRecovery/mutations.ts +72 -0
  60. package/src/component/costRecovery/schema.ts +8 -0
  61. package/src/react/workflows/Collections.test.tsx +36 -0
  62. package/src/react/workflows/Collections.tsx +3 -1
  63. package/src/react/workflows/Refunds.currency.test.tsx +290 -0
  64. package/src/react/workflows/Refunds.tsx +115 -25
  65. package/src/react/workflows/index.ts +8 -1
  66. package/src/react/workflows/refund-preview.test.ts +65 -0
  67. package/src/react/workflows/refund-preview.ts +56 -0
@@ -84,3 +84,39 @@ it("preserves store attribution in referred sales and refund statuses in history
84
84
  expect(screen.getByText("failed")).toBeTruthy();
85
85
  expect(screen.getByRole("columnheader", { name: "Created" })).toBeTruthy();
86
86
  });
87
+
88
+ it("renders observed refund units and unavailable data without throwing", () => {
89
+ render(
90
+ <RefundHistory
91
+ rows={[
92
+ {
93
+ stripeRefundId: "re_jpy",
94
+ amount: 500,
95
+ currency: "jpy",
96
+ status: "succeeded",
97
+ },
98
+ {
99
+ stripeRefundId: "re_ugx",
100
+ amount: 101,
101
+ currency: "ugx",
102
+ status: "pending",
103
+ },
104
+ {
105
+ stripeRefundId: "re_unknown",
106
+ amount: 100,
107
+ currency: "zzz",
108
+ status: "failed",
109
+ },
110
+ {
111
+ stripeRefundId: "re_invalid",
112
+ amount: -1,
113
+ currency: "usd",
114
+ status: "canceled",
115
+ },
116
+ ]}
117
+ />,
118
+ );
119
+ expect(screen.getByText("¥500")).toBeTruthy();
120
+ expect(screen.getByText("UGX 1.01")).toBeTruthy();
121
+ expect(screen.getAllByText("Amount unavailable")).toHaveLength(2);
122
+ });
@@ -2,6 +2,7 @@
2
2
  import type { CollectionProps, WorkflowColumn } from "./Collection.js";
3
3
  import { Collection } from "./Collection.js";
4
4
  import { formatPrice } from "../lib/price-helpers.js";
5
+ import { formatRefundAmount } from "./refund-preview.js";
5
6
  export type ResourceCollectionProps<Row> = Omit<
6
7
  CollectionProps<Row>,
7
8
  "columns" | "getKey"
@@ -111,7 +112,8 @@ export function RefundHistory(props: ResourceCollectionProps<RefundRow>) {
111
112
  id: "amount",
112
113
  label: "Amount",
113
114
  align: "end",
114
- cell: (row) => formatPrice(row.amount, row.currency),
115
+ cell: (row) =>
116
+ formatRefundAmount(row.amount, row.currency) ?? "Amount unavailable",
115
117
  },
116
118
  { id: "status", label: "Status", cell: (row) => row.status },
117
119
  {
@@ -0,0 +1,290 @@
1
+ // @vitest-environment jsdom
2
+ import "@testing-library/jest-dom/vitest";
3
+ import {
4
+ act,
5
+ cleanup,
6
+ render,
7
+ renderHook,
8
+ screen,
9
+ } from "@testing-library/react";
10
+ import userEvent from "@testing-library/user-event";
11
+ import { afterEach, expect, it, vi } from "vitest";
12
+ import { Refunds, useRefunds } from "./Refunds.js";
13
+ afterEach(cleanup);
14
+ it.each([
15
+ ["usd", "12.34", 1234],
16
+ ["jpy", "500", 500],
17
+ ["mga", "500", 500],
18
+ ["isk", "500", 50000],
19
+ ["ugx", "500", 50000],
20
+ ["huf", "10.45", 1045],
21
+ ["twd", "800.45", 80045],
22
+ ["usd", "90071992547409.91", Number.MAX_SAFE_INTEGER],
23
+ ])(
24
+ "preserves original %s partial refund units",
25
+ async (currency, input, expected) => {
26
+ const issueRefund = vi
27
+ .fn()
28
+ .mockResolvedValue({ stripeRefundId: "re_test" });
29
+ const { result } = renderHook(() =>
30
+ useRefunds({
31
+ scopeKey: "seller",
32
+ paymentIntentId: "pi_test",
33
+ paymentCurrency: currency,
34
+ issueRefund,
35
+ }),
36
+ );
37
+ act(() => result.current.setRefundAmount(input));
38
+ await act(async () => {
39
+ await result.current.submitRefund("partial");
40
+ });
41
+ expect(issueRefund).toHaveBeenCalledWith({
42
+ stripePaymentIntentId: "pi_test",
43
+ amountCents: expected,
44
+ reason: "requested_by_customer",
45
+ });
46
+ },
47
+ );
48
+ it.each([
49
+ ["jpy", "0.5"],
50
+ ["mga", "1.5"],
51
+ ["isk", "1.01"],
52
+ ["ugx", "1.01"],
53
+ ["usd", "0"],
54
+ ["usd", "1.005"],
55
+ ["usd", "1e2"],
56
+ ["usd", "90071992547409.92"],
57
+ ["ZZZ", "1"],
58
+ ["bhd", "1"],
59
+ [null, "1"],
60
+ ])("blocks invalid original %s amount %s", async (currency, input) => {
61
+ const issueRefund = vi.fn();
62
+ const { result } = renderHook(() =>
63
+ useRefunds({
64
+ scopeKey: "seller",
65
+ paymentIntentId: "pi_test",
66
+ paymentCurrency: currency,
67
+ issueRefund,
68
+ }),
69
+ );
70
+ act(() => result.current.setRefundAmount(input));
71
+ await act(async () => {
72
+ await result.current.submitRefund("partial");
73
+ });
74
+ expect(issueRefund).not.toHaveBeenCalled();
75
+ expect(result.current.error).toBeTruthy();
76
+ });
77
+ it("uses independent original charge units for transfer reversal", async () => {
78
+ const issueRefund = vi.fn().mockResolvedValue({ stripeRefundId: "re_test" });
79
+ const reverseTransfers = vi.fn().mockResolvedValue({
80
+ reversals: [],
81
+ summary: { reversalCount: 0, totalReversed: 0 },
82
+ });
83
+ const { result } = renderHook(() =>
84
+ useRefunds({
85
+ scopeKey: "seller",
86
+ paymentIntentId: "pi_test",
87
+ paymentCurrency: "jpy",
88
+ sourceChargeId: "ch_test",
89
+ sourceChargeCurrency: "isk",
90
+ issueRefund,
91
+ reverseTransfers,
92
+ }),
93
+ );
94
+ act(() => {
95
+ result.current.setRefundAmount("500");
96
+ result.current.setReversalAmount("500");
97
+ });
98
+ await act(async () => {
99
+ await result.current.submitRefund("partial");
100
+ await result.current.submitReversal("partial");
101
+ });
102
+ expect(issueRefund.mock.calls[0][0].amountCents).toBe(500);
103
+ expect(reverseTransfers).toHaveBeenCalledWith({
104
+ sourceChargeId: "ch_test",
105
+ amountCents: 50000,
106
+ });
107
+ });
108
+ it("omits amount for full operations even with unavailable currency", async () => {
109
+ const issueRefund = vi.fn().mockResolvedValue({ stripeRefundId: "re_full" });
110
+ const reverseTransfers = vi.fn().mockResolvedValue({
111
+ reversals: [],
112
+ summary: { reversalCount: 0, totalReversed: 0 },
113
+ });
114
+ const { result } = renderHook(() =>
115
+ useRefunds({
116
+ scopeKey: "seller",
117
+ paymentIntentId: "pi_test",
118
+ paymentCurrency: null,
119
+ sourceChargeId: "ch_test",
120
+ sourceChargeCurrency: null,
121
+ issueRefund,
122
+ reverseTransfers,
123
+ }),
124
+ );
125
+ await act(async () => {
126
+ await result.current.submitRefund("full");
127
+ await result.current.submitReversal("full");
128
+ });
129
+ expect(
130
+ Object.prototype.hasOwnProperty.call(
131
+ issueRefund.mock.calls[0][0],
132
+ "amountCents",
133
+ ),
134
+ ).toBe(false);
135
+ expect(
136
+ Object.prototype.hasOwnProperty.call(
137
+ reverseTransfers.mock.calls[0][0],
138
+ "amountCents",
139
+ ),
140
+ ).toBe(false);
141
+ });
142
+ it("does not apply a verified payment currency to another editable identifier", async () => {
143
+ const issueRefund = vi.fn();
144
+ const { result } = renderHook(() =>
145
+ useRefunds({
146
+ scopeKey: "seller",
147
+ paymentIntentId: "pi_verified",
148
+ paymentCurrency: "jpy",
149
+ issueRefund,
150
+ }),
151
+ );
152
+ act(() => {
153
+ result.current.setPaymentIntentId("pi_other");
154
+ result.current.setRefundAmount("500");
155
+ });
156
+ await act(async () => {
157
+ await result.current.submitRefund("partial");
158
+ });
159
+ expect(issueRefund).not.toHaveBeenCalled();
160
+ expect(result.current.error).toMatch(/currency|identifier/i);
161
+ });
162
+ it("requires an identified original payment when currency context is explicit", async () => {
163
+ const issueRefund = vi.fn();
164
+ const { result } = renderHook(() =>
165
+ useRefunds({ scopeKey: "seller", paymentCurrency: "jpy", issueRefund }),
166
+ );
167
+ act(() => {
168
+ result.current.setPaymentIntentId("pi_unverified");
169
+ result.current.setRefundAmount("500");
170
+ });
171
+ await act(async () => {
172
+ await result.current.submitRefund("partial");
173
+ });
174
+ expect(issueRefund).not.toHaveBeenCalled();
175
+ });
176
+ it("does not apply a verified charge currency to another reversal identifier", async () => {
177
+ const reverseTransfers = vi.fn();
178
+ const { result } = renderHook(() =>
179
+ useRefunds({
180
+ scopeKey: "seller",
181
+ sourceChargeId: "ch_verified",
182
+ sourceChargeCurrency: "jpy",
183
+ issueRefund: vi.fn(),
184
+ reverseTransfers,
185
+ }),
186
+ );
187
+ act(() => {
188
+ result.current.setSourceChargeId("ch_other");
189
+ result.current.setReversalAmount("500");
190
+ });
191
+ await act(async () => {
192
+ await result.current.submitReversal("partial");
193
+ });
194
+ expect(reverseTransfers).not.toHaveBeenCalled();
195
+ });
196
+ it("resets currency and payment scoped amounts/pending and ignores late completion", async () => {
197
+ let finish: ((value: { stripeRefundId: string }) => void) | undefined;
198
+ const issueRefund = vi
199
+ .fn()
200
+ .mockImplementationOnce(
201
+ () =>
202
+ new Promise((resolve) => {
203
+ finish = resolve;
204
+ }),
205
+ )
206
+ .mockResolvedValue({ stripeRefundId: "re_new" });
207
+ const { result, rerender } = renderHook(
208
+ ({ currency, id }) =>
209
+ useRefunds({
210
+ scopeKey: "seller",
211
+ paymentIntentId: id,
212
+ paymentCurrency: currency,
213
+ issueRefund,
214
+ }),
215
+ { initialProps: { currency: "usd", id: "pi_one" } },
216
+ );
217
+ act(() => result.current.setRefundAmount("10.00"));
218
+ let pending: Promise<unknown> | undefined;
219
+ act(() => {
220
+ pending = result.current.submitRefund("partial");
221
+ });
222
+ expect(result.current.isPending).toBe(true);
223
+ rerender({ currency: "jpy", id: "pi_two" });
224
+ expect(result.current.refundAmount).toBe("");
225
+ expect(result.current.isPending).toBe(false);
226
+ await act(async () => {
227
+ finish?.({ stripeRefundId: "re_old" });
228
+ await pending;
229
+ });
230
+ expect(result.current.refundResult).toBeNull();
231
+ act(() => result.current.setRefundAmount("500"));
232
+ await act(async () => {
233
+ await result.current.submitRefund("partial");
234
+ });
235
+ expect(issueRefund).toHaveBeenLastCalledWith({
236
+ stripePaymentIntentId: "pi_two",
237
+ amountCents: 500,
238
+ reason: "requested_by_customer",
239
+ });
240
+ });
241
+ it("retains exact currency units through a retry", async () => {
242
+ const issueRefund = vi
243
+ .fn()
244
+ .mockRejectedValueOnce(new Error("Retry"))
245
+ .mockResolvedValue({ stripeRefundId: "re_retry" });
246
+ const { result } = renderHook(() =>
247
+ useRefunds({
248
+ scopeKey: "seller",
249
+ paymentIntentId: "pi_test",
250
+ paymentCurrency: "jpy",
251
+ issueRefund,
252
+ }),
253
+ );
254
+ act(() => result.current.setRefundAmount("500"));
255
+ await act(async () => {
256
+ await result.current.submitRefund("partial");
257
+ });
258
+ expect(result.current.error).toBe("Retry");
259
+ await act(async () => {
260
+ await result.current.submitRefund("partial");
261
+ });
262
+ expect(issueRefund).toHaveBeenNthCalledWith(2, {
263
+ stripePaymentIntentId: "pi_test",
264
+ amountCents: 500,
265
+ reason: "requested_by_customer",
266
+ });
267
+ });
268
+ it("labels and validates native original-currency fields", async () => {
269
+ const issueRefund = vi
270
+ .fn()
271
+ .mockResolvedValue({ stripeRefundId: "re_native" });
272
+ render(
273
+ <Refunds
274
+ scopeKey="seller"
275
+ paymentIntentId="pi_test"
276
+ paymentCurrency="jpy"
277
+ issueRefund={issueRefund}
278
+ />,
279
+ );
280
+ const field = screen.getByLabelText("Partial refund amount (JPY)");
281
+ expect(field).toHaveAttribute("step", "1");
282
+ expect(field).toHaveAttribute("inputmode", "numeric");
283
+ await userEvent.type(field, "500");
284
+ await userEvent.click(screen.getByRole("button", { name: "Partial refund" }));
285
+ expect(issueRefund).toHaveBeenCalledWith({
286
+ stripePaymentIntentId: "pi_test",
287
+ amountCents: 500,
288
+ reason: "requested_by_customer",
289
+ });
290
+ });
@@ -2,12 +2,20 @@
2
2
  import { useState, useId, type ReactNode } from "react";
3
3
  import { useWorkflowAction } from "./useWorkflowAction.js";
4
4
  import { Connect, type ConnectProps } from "./Connect.js";
5
+ import {
6
+ getChargeCurrencyMetadata,
7
+ parseChargeAmount,
8
+ } from "../../client/charge-currency.js";
5
9
  export type RefundRequest = {
6
10
  stripePaymentIntentId: string;
11
+ /** Original payment currency's provider units. Legacy name, not universal cents. */
7
12
  amountCents?: number;
8
13
  reason: "requested_by_customer";
9
14
  };
10
- export type ReversalRequest = { sourceChargeId: string; amountCents?: number };
15
+ export type ReversalRequest = {
16
+ sourceChargeId: string;
17
+ /** Original charge provider units. */ amountCents?: number;
18
+ };
11
19
  export type RefundResult = { stripeRefundId: string };
12
20
  export type ReversalResult = {
13
21
  reversals: { stripeTransferId: string; amount: number }[];
@@ -17,26 +25,50 @@ export type RefundActionsProps = {
17
25
  scopeKey: string;
18
26
  paymentIntentId?: string;
19
27
  sourceChargeId?: string;
28
+ /** Verified currency for paymentIntentId. Omitted = legacy USD; null = unknown. */
29
+ paymentCurrency?: string | null;
30
+ /** Verified currency for sourceChargeId, independently of the refund payment. */
31
+ sourceChargeCurrency?: string | null;
20
32
  issueRefund: (args: RefundRequest) => Promise<RefundResult>;
21
33
  reverseTransfers?: (args: ReversalRequest) => Promise<ReversalResult>;
22
34
  };
23
- /** Currency input expressed in two decimal minor units. Rejects rounding and scientific notation. */
35
+ /** Exact positive refund amount in the original charge currency's provider units. */
36
+ export function parseRefundAmount(
37
+ value: string,
38
+ currency: string,
39
+ ): number | null {
40
+ const amount = parseChargeAmount(value.trim(), currency);
41
+ return amount !== null && amount > 0 ? amount : null;
42
+ }
43
+ /** Legacy USD-only parser. Use parseRefundAmount for original-currency inputs. */
24
44
  export function parseAmountCents(value: string): number | null {
25
- if (!/^\d+(?:\.\d{1,2})?$/.test(value.trim())) return null;
26
- const [whole, fraction = ""] = value.trim().split(".");
27
- const amount = Number(whole) * 100 + Number(fraction.padEnd(2, "0"));
28
- return Number.isSafeInteger(amount) && amount > 0 ? amount : null;
45
+ return parseRefundAmount(value, "usd");
29
46
  }
30
47
  export function useRefunds(props: RefundActionsProps) {
48
+ const paymentCurrency =
49
+ props.paymentCurrency === undefined
50
+ ? "usd"
51
+ : (props.paymentCurrency?.toLowerCase() ?? null);
52
+ const sourceChargeCurrency =
53
+ props.sourceChargeCurrency === undefined
54
+ ? "usd"
55
+ : (props.sourceChargeCurrency?.toLowerCase() ?? null);
56
+ const scope = JSON.stringify([
57
+ props.scopeKey,
58
+ props.paymentIntentId?.trim(),
59
+ props.sourceChargeId?.trim(),
60
+ paymentCurrency,
61
+ sourceChargeCurrency,
62
+ ]);
31
63
  const initial = {
32
- scope: props.scopeKey,
64
+ scope,
33
65
  paymentIntentId: props.paymentIntentId ?? "",
34
66
  sourceChargeId: props.sourceChargeId ?? "",
35
67
  refundAmount: "",
36
68
  reversalAmount: "",
37
69
  };
38
70
  const [fields, setFields] = useState(initial);
39
- if (fields.scope !== props.scopeKey) setFields(initial);
71
+ if (fields.scope !== scope) setFields(initial);
40
72
  const [operationType, setOperationType] = useState<"refund" | "reversal">(
41
73
  "refund",
42
74
  );
@@ -57,24 +89,43 @@ export function useRefunds(props: RefundActionsProps) {
57
89
  ? "Enter a PaymentIntent id before issuing a refund."
58
90
  : "Enter a source charge id before reversing transfers.",
59
91
  );
92
+ const currency =
93
+ operation.type === "refund" ? paymentCurrency : sourceChargeCurrency;
94
+ const boundId =
95
+ operation.type === "refund"
96
+ ? props.paymentIntentId
97
+ : props.sourceChargeId;
98
+ const hasExplicitCurrency =
99
+ operation.type === "refund"
100
+ ? props.paymentCurrency !== undefined
101
+ : props.sourceChargeCurrency !== undefined;
102
+ if (
103
+ operation.kind === "partial" &&
104
+ hasExplicitCurrency &&
105
+ id !== boundId?.trim()
106
+ )
107
+ throw new Error(
108
+ "Original currency is not verified for this identifier.",
109
+ );
60
110
  const amount =
61
111
  operation.kind === "partial"
62
- ? parseAmountCents(
112
+ ? parseRefundAmount(
63
113
  operation.type === "refund"
64
114
  ? fields.refundAmount
65
115
  : fields.reversalAmount,
116
+ currency ?? "",
66
117
  )
67
118
  : undefined;
68
119
  if (amount === null)
69
120
  throw new Error(
70
- `Enter a positive partial ${operation.type === "refund" ? "refund" : "reversal"} amount with at most two decimal places.`,
121
+ `Enter a positive partial ${operation.type === "refund" ? "refund" : "reversal"} amount in supported original currency units.`,
71
122
  );
72
123
  if (operation.type === "refund")
73
124
  return {
74
125
  type: "refund" as const,
75
126
  value: await props.issueRefund({
76
127
  stripePaymentIntentId: id,
77
- amountCents: amount,
128
+ ...(amount === undefined ? {} : { amountCents: amount }),
78
129
  reason: "requested_by_customer",
79
130
  }),
80
131
  };
@@ -84,11 +135,15 @@ export function useRefunds(props: RefundActionsProps) {
84
135
  type: "reversal" as const,
85
136
  value: await props.reverseTransfers({
86
137
  sourceChargeId: id,
87
- amountCents: amount,
138
+ ...(amount === undefined ? {} : { amountCents: amount }),
88
139
  }),
89
140
  };
90
141
  },
91
- props.scopeKey,
142
+ JSON.stringify([
143
+ scope,
144
+ fields.paymentIntentId.trim(),
145
+ fields.sourceChargeId.trim(),
146
+ ]),
92
147
  );
93
148
  const set = (
94
149
  name:
@@ -97,6 +152,14 @@ export function useRefunds(props: RefundActionsProps) {
97
152
  ) => setFields((previous) => ({ ...previous, [name]: value }));
98
153
  return {
99
154
  ...fields,
155
+ scope: props.scopeKey,
156
+ paymentCurrency,
157
+ sourceChargeCurrency,
158
+ refundInputDecimalPlaces:
159
+ getChargeCurrencyMetadata(paymentCurrency ?? "")?.displayDecimals ?? null,
160
+ reversalInputDecimalPlaces:
161
+ getChargeCurrencyMetadata(sourceChargeCurrency ?? "")?.displayDecimals ??
162
+ null,
100
163
  setPaymentIntentId: (value: string) => set("paymentIntentId", value),
101
164
  setSourceChargeId: (value: string) => set("sourceChargeId", value),
102
165
  setRefundAmount: (value: string) => set("refundAmount", value),
@@ -127,6 +190,35 @@ export function Refunds(props: RefundsProps) {
127
190
  <RefundsBody key={props.scopeKey} {...props} />
128
191
  );
129
192
  }
193
+ function RefundAmountInput(props: {
194
+ id: string;
195
+ label: string;
196
+ currency: string | null | undefined;
197
+ decimalPlaces: number | null;
198
+ value: string;
199
+ onChange: (value: string) => void;
200
+ }) {
201
+ const step = props.decimalPlaces === 0 ? "1" : "0.01";
202
+ return (
203
+ <>
204
+ <label htmlFor={props.id}>
205
+ {props.label}
206
+ {props.currency !== undefined
207
+ ? ` (${props.currency?.toUpperCase() ?? "currency unavailable"})`
208
+ : ""}
209
+ </label>
210
+ <input
211
+ id={props.id}
212
+ type="number"
213
+ min={step}
214
+ step={step}
215
+ inputMode={props.decimalPlaces === 0 ? "numeric" : "decimal"}
216
+ value={props.value}
217
+ onChange={(event) => props.onChange(event.target.value)}
218
+ />
219
+ </>
220
+ );
221
+ }
130
222
  function RefundsBody(
131
223
  props: RefundActionsProps & {
132
224
  children?: (state: RefundsRenderProps) => ReactNode;
@@ -145,14 +237,13 @@ function RefundsBody(
145
237
  value={state.paymentIntentId}
146
238
  onChange={(event) => state.setPaymentIntentId(event.target.value)}
147
239
  />
148
- <label htmlFor={`${id}-amount`}>Partial refund amount</label>
149
- <input
240
+ <RefundAmountInput
150
241
  id={`${id}-amount`}
151
- type="number"
152
- min="0.01"
153
- step="0.01"
242
+ label="Partial refund amount"
243
+ currency={props.paymentCurrency}
244
+ decimalPlaces={state.refundInputDecimalPlaces}
154
245
  value={state.refundAmount}
155
- onChange={(event) => state.setRefundAmount(event.target.value)}
246
+ onChange={state.setRefundAmount}
156
247
  />
157
248
  <button
158
249
  type="button"
@@ -184,14 +275,13 @@ function RefundsBody(
184
275
  value={state.sourceChargeId}
185
276
  onChange={(event) => state.setSourceChargeId(event.target.value)}
186
277
  />
187
- <label htmlFor={`${id}-reversal`}>Partial reversal amount</label>
188
- <input
278
+ <RefundAmountInput
189
279
  id={`${id}-reversal`}
190
- type="number"
191
- min="0.01"
192
- step="0.01"
280
+ label="Partial reversal amount"
281
+ currency={props.sourceChargeCurrency}
282
+ decimalPlaces={state.reversalInputDecimalPlaces}
193
283
  value={state.reversalAmount}
194
- onChange={(event) => state.setReversalAmount(event.target.value)}
284
+ onChange={state.setReversalAmount}
195
285
  />
196
286
  <button
197
287
  type="button"
@@ -34,7 +34,12 @@ export type {
34
34
  PayoutSnapshot,
35
35
  PayoutRow,
36
36
  } from "./Payouts.js";
37
- export { Refunds, useRefunds, parseAmountCents } from "./Refunds.js";
37
+ export {
38
+ Refunds,
39
+ useRefunds,
40
+ parseAmountCents,
41
+ parseRefundAmount,
42
+ } from "./Refunds.js";
38
43
  export type {
39
44
  RefundsProps,
40
45
  RefundsRenderProps,
@@ -45,6 +50,8 @@ export type {
45
50
  ReversalResult,
46
51
  } from "./Refunds.js";
47
52
  export { Collection } from "./Collection.js";
53
+ export { formatRefundAmount, groupRefundPreview } from "./refund-preview.js";
54
+ export type { RefundPreviewGroup } from "./refund-preview.js";
48
55
  export type {
49
56
  CollectionProps,
50
57
  CollectionRenderProps,
@@ -0,0 +1,65 @@
1
+ import { expect, it } from "vitest";
2
+ import { formatRefundAmount, groupRefundPreview } from "./refund-preview.js";
3
+
4
+ it("isolates loaded amounts by currency and provider status", () => {
5
+ expect(
6
+ groupRefundPreview([
7
+ { amount: 100, currency: "USD", status: "succeeded" },
8
+ { amount: 250, currency: "usd", status: "succeeded" },
9
+ { amount: 500, currency: "jpy", status: "succeeded" },
10
+ { amount: 200, currency: "eur", status: "pending" },
11
+ { amount: 300, currency: "jpy", status: "failed" },
12
+ { amount: 400, currency: "usd", status: "canceled" },
13
+ ]),
14
+ ).toEqual([
15
+ { currency: "usd", status: "succeeded", amount: 350, count: 2 },
16
+ { currency: "jpy", status: "succeeded", amount: 500, count: 1 },
17
+ { currency: "eur", status: "pending", amount: 200, count: 1 },
18
+ { currency: "jpy", status: "failed", amount: 300, count: 1 },
19
+ { currency: "usd", status: "canceled", amount: 400, count: 1 },
20
+ ]);
21
+ });
22
+
23
+ it("keeps empty separate from recorded zero and fails closed on invalid/unsafe groups", () => {
24
+ expect(groupRefundPreview([])).toEqual([]);
25
+ expect(
26
+ groupRefundPreview([{ amount: 0, currency: "usd", status: "succeeded" }])[0]
27
+ ?.amount,
28
+ ).toBe(0);
29
+ for (const amount of [-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
30
+ expect(
31
+ groupRefundPreview([{ amount, currency: "usd", status: "succeeded" }])[0]
32
+ ?.amount,
33
+ ).toBeNull();
34
+ }
35
+ expect(
36
+ groupRefundPreview([
37
+ { amount: Number.MAX_SAFE_INTEGER, currency: "usd", status: "succeeded" },
38
+ { amount: 1, currency: "usd", status: "succeeded" },
39
+ ])[0]?.amount,
40
+ ).toBeNull();
41
+ expect(
42
+ groupRefundPreview([{ amount: 100, currency: "zzz", status: "pending" }])[0]
43
+ ?.amount,
44
+ ).toBeNull();
45
+ });
46
+
47
+ it.each([
48
+ [500, "jpy", "¥500"],
49
+ [500, "mga", "MGA\u00a0500"],
50
+ [50000, "isk", "ISK\u00a0500"],
51
+ [101, "ugx", "UGX\u00a01.01"],
52
+ [1045, "huf", "HUF\u00a010.45"],
53
+ [0, "usd", "$0.00"],
54
+ ])(
55
+ "formats observed original refund units %s %s",
56
+ (amount, currency, expected) => {
57
+ expect(formatRefundAmount(amount, currency)).toBe(expected);
58
+ },
59
+ );
60
+
61
+ it("does not throw for unknown currency or invalid observed amounts", () => {
62
+ expect(formatRefundAmount(100, "zzz")).toBeNull();
63
+ expect(formatRefundAmount(-1, "usd")).toBeNull();
64
+ expect(formatRefundAmount(1.5, "jpy")).toBeNull();
65
+ });