@fantastic.dev/better-stripe 0.12.0 → 0.14.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.
- package/CHANGELOG.md +24 -0
- package/README.md +93 -8
- package/dist/client/connect/disputes.d.ts +20 -0
- package/dist/client/connect/disputes.d.ts.map +1 -1
- package/dist/client/connect/disputes.js +55 -0
- package/dist/client/connect/disputes.js.map +1 -1
- package/dist/client/costRecovery-invoices.d.ts +44 -0
- package/dist/client/costRecovery-invoices.d.ts.map +1 -0
- package/dist/client/costRecovery-invoices.js +109 -0
- package/dist/client/costRecovery-invoices.js.map +1 -0
- package/dist/client/costRecovery.d.ts +59 -2
- package/dist/client/costRecovery.d.ts.map +1 -1
- package/dist/client/costRecovery.js +73 -2
- package/dist/client/costRecovery.js.map +1 -1
- package/dist/client/errors.d.ts +1 -1
- package/dist/client/errors.d.ts.map +1 -1
- package/dist/client/errors.js.map +1 -1
- package/dist/client/index.d.ts +5 -14
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +4 -8
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +137 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/costRecovery/claims.d.ts +12 -0
- package/dist/component/costRecovery/claims.d.ts.map +1 -1
- package/dist/component/costRecovery/claims.js +15 -8
- package/dist/component/costRecovery/claims.js.map +1 -1
- package/dist/component/costRecovery/completion.d.ts +6 -0
- package/dist/component/costRecovery/completion.d.ts.map +1 -1
- package/dist/component/costRecovery/helpers.d.ts +6 -0
- package/dist/component/costRecovery/helpers.d.ts.map +1 -1
- package/dist/component/costRecovery/imports.d.ts +53 -0
- package/dist/component/costRecovery/imports.d.ts.map +1 -0
- package/dist/component/costRecovery/imports.js +67 -0
- package/dist/component/costRecovery/imports.js.map +1 -0
- package/dist/component/costRecovery/mutations.d.ts +119 -0
- package/dist/component/costRecovery/mutations.d.ts.map +1 -1
- package/dist/component/costRecovery/mutations.js +64 -1
- package/dist/component/costRecovery/mutations.js.map +1 -1
- package/dist/component/costRecovery/queries.d.ts +18 -0
- package/dist/component/costRecovery/queries.d.ts.map +1 -1
- package/dist/component/costRecovery/reservations.d.ts +12 -0
- package/dist/component/costRecovery/reservations.d.ts.map +1 -1
- package/dist/component/costRecovery/schema.d.ts +116 -4
- package/dist/component/costRecovery/schema.d.ts.map +1 -1
- package/dist/component/costRecovery/schema.js +19 -0
- package/dist/component/costRecovery/schema.js.map +1 -1
- package/dist/component/schema.d.ts +36 -2
- package/dist/react/workflows/Collections.d.ts.map +1 -1
- package/dist/react/workflows/Collections.js +2 -1
- package/dist/react/workflows/Collections.js.map +1 -1
- package/dist/react/workflows/EvidenceUpload.d.ts +18 -0
- package/dist/react/workflows/EvidenceUpload.d.ts.map +1 -0
- package/dist/react/workflows/EvidenceUpload.js +35 -0
- package/dist/react/workflows/EvidenceUpload.js.map +1 -0
- package/dist/react/workflows/Onboarding.d.ts +3 -3
- package/dist/react/workflows/Onboarding.d.ts.map +1 -1
- package/dist/react/workflows/Onboarding.js +8 -6
- package/dist/react/workflows/Onboarding.js.map +1 -1
- package/dist/react/workflows/Refunds.d.ts +14 -3
- package/dist/react/workflows/Refunds.d.ts.map +1 -1
- package/dist/react/workflows/Refunds.js +58 -16
- package/dist/react/workflows/Refunds.js.map +1 -1
- package/dist/react/workflows/index.d.ts +5 -1
- package/dist/react/workflows/index.d.ts.map +1 -1
- package/dist/react/workflows/index.js +3 -1
- package/dist/react/workflows/index.js.map +1 -1
- package/dist/react/workflows/refund-preview.d.ts +16 -0
- package/dist/react/workflows/refund-preview.d.ts.map +1 -0
- package/dist/react/workflows/refund-preview.js +38 -0
- package/dist/react/workflows/refund-preview.js.map +1 -0
- package/dist/react/workflows/useWorkflowAction.d.ts +1 -1
- package/dist/react/workflows/useWorkflowAction.d.ts.map +1 -1
- package/dist/react/workflows/useWorkflowAction.js +6 -2
- package/dist/react/workflows/useWorkflowAction.js.map +1 -1
- package/docs/react-workflows.md +121 -4
- package/package.json +1 -1
- package/src/client/connect/dispute-upload.test.ts +208 -0
- package/src/client/connect/disputes.ts +103 -0
- package/src/client/costRecovery-import.test.ts +174 -0
- package/src/client/costRecovery-invoices.test.ts +470 -0
- package/src/client/costRecovery-invoices.transport.test.ts +107 -0
- package/src/client/costRecovery-invoices.ts +176 -0
- package/src/client/costRecovery.ts +123 -2
- package/src/client/errors.ts +1 -0
- package/src/client/index.ts +15 -14
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +164 -0
- package/src/component/costRecovery/claims.ts +18 -8
- package/src/component/costRecovery/imports.ts +74 -0
- package/src/component/costRecovery/mutations.ts +80 -0
- package/src/component/costRecovery/schema.ts +21 -0
- package/src/react/workflows/Collections.test.tsx +36 -0
- package/src/react/workflows/Collections.tsx +3 -1
- package/src/react/workflows/EvidenceUpload.test.tsx +56 -0
- package/src/react/workflows/EvidenceUpload.tsx +71 -0
- package/src/react/workflows/Onboarding.test.tsx +73 -0
- package/src/react/workflows/Onboarding.tsx +8 -5
- package/src/react/workflows/Refunds.currency.test.tsx +290 -0
- package/src/react/workflows/Refunds.tsx +115 -25
- package/src/react/workflows/index.ts +11 -1
- package/src/react/workflows/refund-preview.test.ts +65 -0
- package/src/react/workflows/refund-preview.ts +56 -0
- package/src/react/workflows/useWorkflowAction.ts +6 -1
|
@@ -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 = {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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 !==
|
|
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
|
-
?
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
149
|
-
<input
|
|
240
|
+
<RefundAmountInput
|
|
150
241
|
id={`${id}-amount`}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
242
|
+
label="Partial refund amount"
|
|
243
|
+
currency={props.paymentCurrency}
|
|
244
|
+
decimalPlaces={state.refundInputDecimalPlaces}
|
|
154
245
|
value={state.refundAmount}
|
|
155
|
-
onChange={
|
|
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
|
-
<
|
|
188
|
-
<input
|
|
278
|
+
<RefundAmountInput
|
|
189
279
|
id={`${id}-reversal`}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
280
|
+
label="Partial reversal amount"
|
|
281
|
+
currency={props.sourceChargeCurrency}
|
|
282
|
+
decimalPlaces={state.reversalInputDecimalPlaces}
|
|
193
283
|
value={state.reversalAmount}
|
|
194
|
-
onChange={
|
|
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 {
|
|
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,
|
|
@@ -258,3 +265,6 @@ export type {
|
|
|
258
265
|
AffiliatePayoutEditorState,
|
|
259
266
|
AffiliatePayoutUpdate,
|
|
260
267
|
} from "./AffiliatePayoutEditor.js";
|
|
268
|
+
|
|
269
|
+
export { EvidenceUpload } from "./EvidenceUpload.js";
|
|
270
|
+
export type { EvidenceUploadProps } from "./EvidenceUpload.js";
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { getChargeCurrencyMetadata } from "../../client/charge-currency.js";
|
|
2
|
+
import { formatPrice } from "../lib/price-helpers.js";
|
|
3
|
+
|
|
4
|
+
export type RefundPreviewGroup = {
|
|
5
|
+
currency: string;
|
|
6
|
+
status: string;
|
|
7
|
+
/** Loaded rows only. Null means unknown/invalid units or an unsafe sum. */
|
|
8
|
+
amount: number | null;
|
|
9
|
+
count: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function isRefundAmount(amount: number, currency: string): boolean {
|
|
13
|
+
return (
|
|
14
|
+
Number.isSafeInteger(amount) &&
|
|
15
|
+
amount >= 0 &&
|
|
16
|
+
getChargeCurrencyMetadata(currency) !== null
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Observed refund units, including fractional ISK/UGX amounts; not input eligibility. */
|
|
21
|
+
export function formatRefundAmount(
|
|
22
|
+
amount: number,
|
|
23
|
+
currency: string,
|
|
24
|
+
locale = "en-US",
|
|
25
|
+
): string | null {
|
|
26
|
+
return isRefundAmount(amount, currency)
|
|
27
|
+
? formatPrice(amount, currency, locale)
|
|
28
|
+
: null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Group only the supplied preview, preserving provider status and currency. */
|
|
32
|
+
export function groupRefundPreview(
|
|
33
|
+
rows: readonly { amount: number; currency: string; status: string }[],
|
|
34
|
+
): RefundPreviewGroup[] {
|
|
35
|
+
const groups = new Map<string, RefundPreviewGroup>();
|
|
36
|
+
for (const row of rows) {
|
|
37
|
+
const currency = row.currency.toLowerCase();
|
|
38
|
+
const key = JSON.stringify([currency, row.status]);
|
|
39
|
+
const group = groups.get(key) ?? {
|
|
40
|
+
currency,
|
|
41
|
+
status: row.status,
|
|
42
|
+
amount: 0,
|
|
43
|
+
count: 0,
|
|
44
|
+
};
|
|
45
|
+
group.count++;
|
|
46
|
+
if (group.amount === null || !isRefundAmount(row.amount, currency))
|
|
47
|
+
group.amount = null;
|
|
48
|
+
else {
|
|
49
|
+
const sum = BigInt(group.amount) + BigInt(row.amount);
|
|
50
|
+
group.amount =
|
|
51
|
+
sum <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(sum) : null;
|
|
52
|
+
}
|
|
53
|
+
groups.set(key, group);
|
|
54
|
+
}
|
|
55
|
+
return [...groups.values()];
|
|
56
|
+
}
|