@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
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Price formatting and filtering utilities.
|
|
3
3
|
*/
|
|
4
|
+
import {
|
|
5
|
+
formatChargeAmount,
|
|
6
|
+
getChargeCurrencyMetadata,
|
|
7
|
+
} from "../../client/charge-currency.js";
|
|
8
|
+
import { renderCurrencyUnits } from "../../client/charge-currency-format.js";
|
|
4
9
|
|
|
5
10
|
type PriceLike = {
|
|
6
11
|
unitAmount?: number | null;
|
|
@@ -12,19 +17,29 @@ type PriceLike = {
|
|
|
12
17
|
};
|
|
13
18
|
|
|
14
19
|
/**
|
|
15
|
-
*
|
|
20
|
+
* Legacy display helper for observed ordinary account/charge units.
|
|
21
|
+
* Preserves fractional ISK/UGX balances; does not validate charge eligibility.
|
|
16
22
|
* e.g. 2000, 'usd' → '$20.00'
|
|
17
23
|
*/
|
|
18
24
|
export function formatPrice(
|
|
19
|
-
|
|
25
|
+
amount: number,
|
|
20
26
|
currency: string,
|
|
21
27
|
locale = "en-US",
|
|
22
28
|
): string {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
const metadata = getChargeCurrencyMetadata(currency);
|
|
30
|
+
if (!metadata || !Number.isSafeInteger(amount))
|
|
31
|
+
throw new RangeError("Invalid amount or unsupported currency");
|
|
32
|
+
const displayDecimals =
|
|
33
|
+
amount % 10 ** metadata.apiDecimals === 0
|
|
34
|
+
? metadata.displayDecimals
|
|
35
|
+
: metadata.apiDecimals;
|
|
36
|
+
return renderCurrencyUnits(
|
|
37
|
+
amount,
|
|
38
|
+
metadata.currency,
|
|
39
|
+
metadata.apiDecimals,
|
|
40
|
+
displayDecimals,
|
|
41
|
+
locale,
|
|
42
|
+
);
|
|
28
43
|
}
|
|
29
44
|
|
|
30
45
|
/**
|
|
@@ -34,8 +49,10 @@ export function formatPrice(
|
|
|
34
49
|
export function formatPriceWithInterval(
|
|
35
50
|
price: PriceLike,
|
|
36
51
|
locale = "en-US",
|
|
52
|
+
customPriceLabel = "Custom price",
|
|
37
53
|
): string {
|
|
38
|
-
|
|
54
|
+
if (price.unitAmount == null) return customPriceLabel;
|
|
55
|
+
const amount = formatChargeAmount(price.unitAmount, price.currency, locale);
|
|
39
56
|
if (price.type === "one_time" || !price.interval) {
|
|
40
57
|
return amount;
|
|
41
58
|
}
|
|
@@ -34,7 +34,7 @@ it("loads an itemized estimate only when requested and displays the server total
|
|
|
34
34
|
nextBillingAt: 1800000000000,
|
|
35
35
|
lines: [
|
|
36
36
|
{ id: "plan", description: "Store plan", amount: 2900 },
|
|
37
|
-
{ id: "cost", description: "Refund costs", amount:
|
|
37
|
+
{ id: "cost", description: "Refund costs", amount: 700 },
|
|
38
38
|
],
|
|
39
39
|
});
|
|
40
40
|
render(<InvoiceEstimate scopeKey="seller" load={load} />);
|
|
@@ -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) =>
|
|
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,164 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import "@testing-library/jest-dom/vitest";
|
|
3
|
+
import type { ComponentProps } from "react";
|
|
4
|
+
import { act, cleanup, render, screen } from "@testing-library/react";
|
|
5
|
+
import userEvent from "@testing-library/user-event";
|
|
6
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
7
|
+
import { PlanPricingEditor } from "./PlanPricingEditor.js";
|
|
8
|
+
afterEach(cleanup);
|
|
9
|
+
const plan = {
|
|
10
|
+
id: "plan",
|
|
11
|
+
name: "Plan",
|
|
12
|
+
monthlyAmount: 500,
|
|
13
|
+
feeBasisPoints: 500,
|
|
14
|
+
affiliateBasisPoints: 4000,
|
|
15
|
+
version: 1,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
it.each([
|
|
19
|
+
["usd", 500, "5.00", "12.34", 1234, "decimal"],
|
|
20
|
+
["jpy", 500, "500", "600", 600, "numeric"],
|
|
21
|
+
["mga", 500, "500", "600", 600, "numeric"],
|
|
22
|
+
["isk", 50000, "500", "600", 60000, "numeric"],
|
|
23
|
+
["ugx", 50000, "500", "600", 60000, "numeric"],
|
|
24
|
+
["huf", 1045, "10.45", "12.34", 1234, "decimal"],
|
|
25
|
+
["twd", 1045, "10.45", "12.34", 1234, "decimal"],
|
|
26
|
+
[
|
|
27
|
+
"usd",
|
|
28
|
+
Number.MAX_SAFE_INTEGER,
|
|
29
|
+
"90071992547409.91",
|
|
30
|
+
"90071992547409.91",
|
|
31
|
+
Number.MAX_SAFE_INTEGER,
|
|
32
|
+
"decimal",
|
|
33
|
+
],
|
|
34
|
+
])(
|
|
35
|
+
"initializes and saves exact %s provider price units",
|
|
36
|
+
async (currency, monthlyAmount, initial, input, expected, inputMode) => {
|
|
37
|
+
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
38
|
+
render(
|
|
39
|
+
<PlanPricingEditor
|
|
40
|
+
plan={{ ...plan, currency, monthlyAmount }}
|
|
41
|
+
onSave={onSave}
|
|
42
|
+
/>,
|
|
43
|
+
);
|
|
44
|
+
const field = screen.getByLabelText(
|
|
45
|
+
`Monthly price (${currency.toUpperCase()})`,
|
|
46
|
+
);
|
|
47
|
+
expect(field).toHaveValue(initial);
|
|
48
|
+
expect(field).toHaveAttribute("inputmode", inputMode);
|
|
49
|
+
await userEvent.clear(field);
|
|
50
|
+
await userEvent.type(field, input);
|
|
51
|
+
await userEvent.click(screen.getByRole("button", { name: "Save pricing" }));
|
|
52
|
+
expect(onSave).toHaveBeenCalledWith({
|
|
53
|
+
monthlyAmount: expected,
|
|
54
|
+
feeBasisPoints: 500,
|
|
55
|
+
affiliateBasisPoints: 4000,
|
|
56
|
+
expectedVersion: 1,
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
it.each([
|
|
62
|
+
["jpy", "0.5"],
|
|
63
|
+
["mga", "1.5"],
|
|
64
|
+
["isk", "1.01"],
|
|
65
|
+
["ugx", "1.01"],
|
|
66
|
+
["usd", "1.005"],
|
|
67
|
+
["usd", "90071992547409.92"],
|
|
68
|
+
["usd", "0"],
|
|
69
|
+
["usd", "1e2"],
|
|
70
|
+
["ZZZ", "1"],
|
|
71
|
+
["bhd", "1"],
|
|
72
|
+
])(
|
|
73
|
+
"rejects invalid %s price %s before the callback",
|
|
74
|
+
async (currency, value) => {
|
|
75
|
+
const onSave = vi.fn();
|
|
76
|
+
render(<PlanPricingEditor plan={{ ...plan, currency }} onSave={onSave} />);
|
|
77
|
+
const field = screen.getByLabelText(
|
|
78
|
+
`Monthly price (${currency.toUpperCase()})`,
|
|
79
|
+
);
|
|
80
|
+
await userEvent.clear(field);
|
|
81
|
+
await userEvent.type(field, value);
|
|
82
|
+
await userEvent.click(screen.getByRole("button", { name: "Save pricing" }));
|
|
83
|
+
expect(screen.getByRole("alert")).toBeTruthy();
|
|
84
|
+
expect(onSave).not.toHaveBeenCalled();
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
it("resets currency-scoped headless fields and ignores a late previous save", async () => {
|
|
89
|
+
let resolveOld: (() => void) | undefined;
|
|
90
|
+
const onSave = vi
|
|
91
|
+
.fn()
|
|
92
|
+
.mockImplementationOnce(
|
|
93
|
+
() =>
|
|
94
|
+
new Promise<void>((resolve) => {
|
|
95
|
+
resolveOld = resolve;
|
|
96
|
+
}),
|
|
97
|
+
)
|
|
98
|
+
.mockResolvedValue(undefined);
|
|
99
|
+
const children = (
|
|
100
|
+
state: Parameters<
|
|
101
|
+
NonNullable<ComponentProps<typeof PlanPricingEditor>["children"]>
|
|
102
|
+
>[0],
|
|
103
|
+
) => (
|
|
104
|
+
<>
|
|
105
|
+
<output>
|
|
106
|
+
{state.monthlyPrice}|{state.isPending ? "pending" : "idle"}|
|
|
107
|
+
{state.message ?? ""}
|
|
108
|
+
</output>
|
|
109
|
+
<button onClick={() => void state.save()}>Save</button>
|
|
110
|
+
<button onClick={() => state.setMonthlyPrice("777")}>Edit</button>
|
|
111
|
+
</>
|
|
112
|
+
);
|
|
113
|
+
const { rerender } = render(
|
|
114
|
+
<PlanPricingEditor plan={{ ...plan, currency: "usd" }} onSave={onSave}>
|
|
115
|
+
{children}
|
|
116
|
+
</PlanPricingEditor>,
|
|
117
|
+
);
|
|
118
|
+
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
119
|
+
expect(screen.getByRole("status")).toHaveTextContent("pending");
|
|
120
|
+
rerender(
|
|
121
|
+
<PlanPricingEditor plan={{ ...plan, currency: "jpy" }} onSave={onSave}>
|
|
122
|
+
{children}
|
|
123
|
+
</PlanPricingEditor>,
|
|
124
|
+
);
|
|
125
|
+
expect(screen.getByRole("status")).toHaveTextContent("500|idle|");
|
|
126
|
+
await act(async () => resolveOld?.());
|
|
127
|
+
expect(screen.getByRole("status")).toHaveTextContent("500|idle|");
|
|
128
|
+
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
129
|
+
expect(onSave).toHaveBeenLastCalledWith({
|
|
130
|
+
monthlyAmount: 500,
|
|
131
|
+
feeBasisPoints: 500,
|
|
132
|
+
affiliateBasisPoints: 4000,
|
|
133
|
+
expectedVersion: 1,
|
|
134
|
+
});
|
|
135
|
+
await userEvent.click(screen.getByRole("button", { name: "Edit" }));
|
|
136
|
+
rerender(
|
|
137
|
+
<PlanPricingEditor
|
|
138
|
+
plan={{ ...plan, currency: "jpy", version: 2, monthlyAmount: 600 }}
|
|
139
|
+
onSave={onSave}
|
|
140
|
+
>
|
|
141
|
+
{children}
|
|
142
|
+
</PlanPricingEditor>,
|
|
143
|
+
);
|
|
144
|
+
expect(screen.getByRole("status")).toHaveTextContent("600|idle|");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("keeps currency-aware retries on the same plan version", async () => {
|
|
148
|
+
const onSave = vi
|
|
149
|
+
.fn()
|
|
150
|
+
.mockRejectedValueOnce(new Error("Retry"))
|
|
151
|
+
.mockResolvedValue(undefined);
|
|
152
|
+
render(
|
|
153
|
+
<PlanPricingEditor plan={{ ...plan, currency: "jpy" }} onSave={onSave} />,
|
|
154
|
+
);
|
|
155
|
+
await userEvent.click(screen.getByRole("button", { name: "Save pricing" }));
|
|
156
|
+
expect(screen.getByRole("alert")).toHaveTextContent("Retry");
|
|
157
|
+
await userEvent.click(screen.getByRole("button", { name: "Save pricing" }));
|
|
158
|
+
expect(onSave).toHaveBeenNthCalledWith(2, {
|
|
159
|
+
monthlyAmount: 500,
|
|
160
|
+
feeBasisPoints: 500,
|
|
161
|
+
affiliateBasisPoints: 4000,
|
|
162
|
+
expectedVersion: 1,
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { useId, useState, type ReactNode } from "react";
|
|
3
|
+
import {
|
|
4
|
+
chargeAmountToDecimal,
|
|
5
|
+
getChargeCurrencyMetadata,
|
|
6
|
+
parseChargeAmount,
|
|
7
|
+
} from "../../client/charge-currency.js";
|
|
3
8
|
import { useWorkflowAction } from "./useWorkflowAction.js";
|
|
4
9
|
export type PlanPricing = {
|
|
5
10
|
id: string;
|
|
6
11
|
name: string;
|
|
7
12
|
monthlyAmount: number;
|
|
13
|
+
/** Defaults to USD for existing consumers. Charge/Price currency, not payout units. */
|
|
14
|
+
currency?: string;
|
|
8
15
|
feeBasisPoints: number | null;
|
|
9
16
|
affiliateBasisPoints?: number | null;
|
|
10
17
|
version: number;
|
|
@@ -16,6 +23,8 @@ export type PlanPricingUpdate = {
|
|
|
16
23
|
expectedVersion: number;
|
|
17
24
|
};
|
|
18
25
|
export type PlanPricingEditorState = {
|
|
26
|
+
currency: string;
|
|
27
|
+
inputDecimalPlaces: 0 | 2 | null;
|
|
19
28
|
monthlyPrice: string;
|
|
20
29
|
saleFee: string;
|
|
21
30
|
affiliateShare: string;
|
|
@@ -39,24 +48,27 @@ export function PlanPricingEditor({
|
|
|
39
48
|
children,
|
|
40
49
|
}: PlanPricingEditorProps) {
|
|
41
50
|
const id = useId();
|
|
42
|
-
const
|
|
51
|
+
const currency = (plan.currency ?? "usd").toLowerCase();
|
|
52
|
+
const metadata = getChargeCurrencyMetadata(currency);
|
|
53
|
+
const scope = `${plan.id}:${plan.version}:${currency}`;
|
|
43
54
|
const initial = {
|
|
44
55
|
scope,
|
|
45
56
|
affiliateShare: String((plan.affiliateBasisPoints ?? 0) / 100),
|
|
46
|
-
monthlyPrice: (plan.monthlyAmount
|
|
57
|
+
monthlyPrice: chargeAmountToDecimal(plan.monthlyAmount, currency) ?? "",
|
|
47
58
|
saleFee:
|
|
48
59
|
plan.feeBasisPoints === null ? "" : String(plan.feeBasisPoints / 100),
|
|
49
60
|
};
|
|
50
61
|
const [fields, setFields] = useState(initial);
|
|
51
62
|
if (fields.scope !== scope) setFields(initial);
|
|
52
63
|
const action = useWorkflowAction(async () => {
|
|
53
|
-
|
|
64
|
+
const monthlyAmount = parseChargeAmount(
|
|
65
|
+
fields.monthlyPrice.trim(),
|
|
66
|
+
currency,
|
|
67
|
+
);
|
|
68
|
+
if (monthlyAmount === null || monthlyAmount <= 0)
|
|
54
69
|
throw new Error(
|
|
55
|
-
"Enter a positive monthly price
|
|
70
|
+
"Enter a positive monthly price in supported currency units.",
|
|
56
71
|
);
|
|
57
|
-
const monthlyAmount = Math.round(Number(fields.monthlyPrice) * 100);
|
|
58
|
-
if (!Number.isSafeInteger(monthlyAmount) || monthlyAmount <= 0)
|
|
59
|
-
throw new Error("Enter a positive monthly price.");
|
|
60
72
|
const feeBasisPoints =
|
|
61
73
|
fields.saleFee.trim() === ""
|
|
62
74
|
? null
|
|
@@ -90,8 +102,10 @@ export function PlanPricingEditor({
|
|
|
90
102
|
expectedVersion: plan.version,
|
|
91
103
|
});
|
|
92
104
|
return "Pricing saved. Existing subscriptions keep their monthly price.";
|
|
93
|
-
},
|
|
105
|
+
}, scope);
|
|
94
106
|
const state: PlanPricingEditorState = {
|
|
107
|
+
currency,
|
|
108
|
+
inputDecimalPlaces: metadata?.displayDecimals ?? null,
|
|
95
109
|
...fields,
|
|
96
110
|
setMonthlyPrice: (monthlyPrice) =>
|
|
97
111
|
setFields((previous) => ({ ...previous, monthlyPrice })),
|
|
@@ -114,10 +128,12 @@ export function PlanPricingEditor({
|
|
|
114
128
|
aria-label={`${plan.name} pricing`}
|
|
115
129
|
>
|
|
116
130
|
<h3>{plan.name}</h3>
|
|
117
|
-
<label htmlFor={`${id}-monthly`}>
|
|
131
|
+
<label htmlFor={`${id}-monthly`}>
|
|
132
|
+
Monthly price ({currency.toUpperCase()})
|
|
133
|
+
</label>
|
|
118
134
|
<input
|
|
119
135
|
id={`${id}-monthly`}
|
|
120
|
-
inputMode="decimal"
|
|
136
|
+
inputMode={state.inputDecimalPlaces === 0 ? "numeric" : "decimal"}
|
|
121
137
|
value={state.monthlyPrice}
|
|
122
138
|
disabled={state.isPending}
|
|
123
139
|
onChange={(event) => state.setMonthlyPrice(event.target.value)}
|
|
@@ -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
|
+
});
|