@fantastic.dev/better-stripe 0.11.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.
- package/CHANGELOG.md +30 -0
- package/README.md +267 -2
- package/dist/client/billing/balanceFunding.d.ts +36 -0
- package/dist/client/billing/balanceFunding.d.ts.map +1 -0
- package/dist/client/billing/balanceFunding.js +107 -0
- package/dist/client/billing/balanceFunding.js.map +1 -0
- package/dist/client/billing/invoicePreview.d.ts +28 -0
- package/dist/client/billing/invoicePreview.d.ts.map +1 -0
- package/dist/client/billing/invoicePreview.js +56 -0
- package/dist/client/billing/invoicePreview.js.map +1 -0
- package/dist/client/charge-currency-format.d.ts +3 -0
- package/dist/client/charge-currency-format.d.ts.map +1 -0
- package/dist/client/charge-currency-format.js +22 -0
- package/dist/client/charge-currency-format.js.map +1 -0
- package/dist/client/charge-currency.d.ts +14 -0
- package/dist/client/charge-currency.d.ts.map +1 -0
- package/dist/client/charge-currency.js +85 -0
- package/dist/client/charge-currency.js.map +1 -0
- package/dist/client/costRecovery-invoices.d.ts +38 -0
- package/dist/client/costRecovery-invoices.d.ts.map +1 -0
- package/dist/client/costRecovery-invoices.js +105 -0
- package/dist/client/costRecovery-invoices.js.map +1 -0
- package/dist/client/costRecovery.d.ts +156 -0
- package/dist/client/costRecovery.d.ts.map +1 -0
- package/dist/client/costRecovery.js +117 -0
- package/dist/client/costRecovery.js.map +1 -0
- package/dist/client/index.d.ts +28 -66
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +25 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/products/prices.d.ts +3 -2
- package/dist/client/products/prices.d.ts.map +1 -1
- package/dist/client/products/prices.js +0 -3
- package/dist/client/products/prices.js.map +1 -1
- package/dist/client/products/products.d.ts +3 -2
- package/dist/client/products/products.d.ts.map +1 -1
- package/dist/client/products/products.js +0 -3
- package/dist/client/products/products.js.map +1 -1
- package/dist/client/settlement/processingCosts.d.ts +56 -0
- package/dist/client/settlement/processingCosts.d.ts.map +1 -0
- package/dist/client/settlement/processingCosts.js +173 -0
- package/dist/client/settlement/processingCosts.js.map +1 -0
- package/dist/client/utils/webhookEndpoints.d.ts +1 -1
- package/dist/client/utils/webhookEndpoints.d.ts.map +1 -1
- package/dist/client/utils/webhookEndpoints.js +1 -0
- package/dist/client/utils/webhookEndpoints.js.map +1 -1
- package/dist/client/webhooks/processors.d.ts.map +1 -1
- package/dist/client/webhooks/processors.js +1 -0
- package/dist/client/webhooks/processors.js.map +1 -1
- package/dist/component/_generated/component.d.ts +322 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/costRecovery/claims.d.ts +68 -0
- package/dist/component/costRecovery/claims.d.ts.map +1 -0
- package/dist/component/costRecovery/claims.js +92 -0
- package/dist/component/costRecovery/claims.js.map +1 -0
- package/dist/component/costRecovery/completion.d.ts +39 -0
- package/dist/component/costRecovery/completion.d.ts.map +1 -0
- package/dist/component/costRecovery/completion.js +69 -0
- package/dist/component/costRecovery/completion.js.map +1 -0
- package/dist/component/costRecovery/helpers.d.ts +68 -0
- package/dist/component/costRecovery/helpers.d.ts.map +1 -0
- package/dist/component/costRecovery/helpers.js +71 -0
- package/dist/component/costRecovery/helpers.js.map +1 -0
- package/dist/component/costRecovery/mutations.d.ts +210 -0
- package/dist/component/costRecovery/mutations.d.ts.map +1 -0
- package/dist/component/costRecovery/mutations.js +93 -0
- package/dist/component/costRecovery/mutations.js.map +1 -0
- package/dist/component/costRecovery/queries.d.ts +108 -0
- package/dist/component/costRecovery/queries.d.ts.map +1 -0
- package/dist/component/costRecovery/queries.js +73 -0
- package/dist/component/costRecovery/queries.js.map +1 -0
- package/dist/component/costRecovery/reservations.d.ts +95 -0
- package/dist/component/costRecovery/reservations.d.ts.map +1 -0
- package/dist/component/costRecovery/reservations.js +134 -0
- package/dist/component/costRecovery/reservations.js.map +1 -0
- package/dist/component/costRecovery/schema.d.ts +382 -0
- package/dist/component/costRecovery/schema.d.ts.map +1 -0
- package/dist/component/costRecovery/schema.js +115 -0
- package/dist/component/costRecovery/schema.js.map +1 -0
- package/dist/component/schema.d.ts +135 -0
- package/dist/component/schema.js +5 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/react/components/PriceCard.d.ts +3 -1
- package/dist/react/components/PriceCard.d.ts.map +1 -1
- package/dist/react/components/PriceCard.js +2 -2
- package/dist/react/components/PriceCard.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/price-helpers.d.ts +4 -6
- package/dist/react/lib/price-helpers.d.ts.map +1 -1
- package/dist/react/lib/price-helpers.js +16 -9
- package/dist/react/lib/price-helpers.js.map +1 -1
- 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/PlanPricingEditor.d.ts +4 -0
- package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -1
- package/dist/react/workflows/PlanPricingEditor.js +12 -9
- package/dist/react/workflows/PlanPricingEditor.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 +3 -1
- package/dist/react/workflows/index.d.ts.map +1 -1
- package/dist/react/workflows/index.js +2 -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/docs/react-workflows.md +167 -1
- package/package.json +2 -2
- package/src/client/billing/balanceFunding.test.ts +257 -0
- package/src/client/billing/balanceFunding.ts +183 -0
- package/src/client/billing/balanceFundingTransport.test.ts +89 -0
- package/src/client/billing/invoicePreview.test.ts +123 -0
- package/src/client/billing/invoicePreview.ts +94 -0
- package/src/client/billing/invoicePreviewTransport.test.ts +70 -0
- package/src/client/charge-currency-format.ts +29 -0
- package/src/client/charge-currency.test.ts +113 -0
- package/src/client/charge-currency.ts +119 -0
- package/src/client/costRecovery-invoices.test.ts +401 -0
- package/src/client/costRecovery-invoices.transport.test.ts +107 -0
- package/src/client/costRecovery-invoices.ts +144 -0
- package/src/client/costRecovery.test.ts +76 -0
- package/src/client/costRecovery.ts +254 -0
- package/src/client/index.ts +76 -75
- package/src/client/products/prices.ts +13 -11
- package/src/client/products/products.ts +10 -10
- package/src/client/settlement/processingCosts.test.ts +349 -0
- package/src/client/settlement/processingCosts.ts +315 -0
- package/src/client/settlement/processingCostsTransport.test.ts +135 -0
- package/src/client/utils/webhookEndpoints.test.ts +5 -0
- package/src/client/utils/webhookEndpoints.ts +1 -0
- package/src/client/webhooks/hooks.test.ts +9 -0
- package/src/client/webhooks/payout-canceled.integration.test.ts +192 -0
- package/src/client/webhooks/processors.ts +1 -0
- package/src/component/_generated/api.ts +12 -0
- package/src/component/_generated/component.ts +407 -0
- package/src/component/cost-recovery.test.ts +344 -0
- package/src/component/costRecovery/claims.ts +119 -0
- package/src/component/costRecovery/completion.ts +92 -0
- package/src/component/costRecovery/helpers.ts +83 -0
- package/src/component/costRecovery/mutations.ts +115 -0
- package/src/component/costRecovery/queries.ts +88 -0
- package/src/component/costRecovery/reservations.ts +164 -0
- package/src/component/costRecovery/schema.ts +135 -0
- package/src/component/schema.ts +10 -0
- package/src/react/components/PayoutSchedule.render.test.tsx +15 -0
- package/src/react/components/PriceCard.render.test.tsx +37 -0
- package/src/react/components/PriceCard.tsx +9 -1
- package/src/react/index.test.ts +5 -0
- package/src/react/index.ts +7 -0
- package/src/react/lib/price-helpers.test.ts +15 -2
- package/src/react/lib/price-helpers.ts +25 -8
- package/src/react/workflows/Billing.test.tsx +1 -1
- package/src/react/workflows/Collections.test.tsx +36 -0
- package/src/react/workflows/Collections.tsx +3 -1
- package/src/react/workflows/PlanPricingEditor.currency.test.tsx +164 -0
- package/src/react/workflows/PlanPricingEditor.tsx +26 -10
- package/src/react/workflows/Refunds.currency.test.tsx +290 -0
- package/src/react/workflows/Refunds.tsx +115 -25
- package/src/react/workflows/index.ts +8 -1
- package/src/react/workflows/refund-preview.test.ts +65 -0
- package/src/react/workflows/refund-preview.ts +56 -0
|
@@ -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,
|
|
@@ -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
|
+
}
|