@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.
Files changed (168) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +267 -2
  3. package/dist/client/billing/balanceFunding.d.ts +36 -0
  4. package/dist/client/billing/balanceFunding.d.ts.map +1 -0
  5. package/dist/client/billing/balanceFunding.js +107 -0
  6. package/dist/client/billing/balanceFunding.js.map +1 -0
  7. package/dist/client/billing/invoicePreview.d.ts +28 -0
  8. package/dist/client/billing/invoicePreview.d.ts.map +1 -0
  9. package/dist/client/billing/invoicePreview.js +56 -0
  10. package/dist/client/billing/invoicePreview.js.map +1 -0
  11. package/dist/client/charge-currency-format.d.ts +3 -0
  12. package/dist/client/charge-currency-format.d.ts.map +1 -0
  13. package/dist/client/charge-currency-format.js +22 -0
  14. package/dist/client/charge-currency-format.js.map +1 -0
  15. package/dist/client/charge-currency.d.ts +14 -0
  16. package/dist/client/charge-currency.d.ts.map +1 -0
  17. package/dist/client/charge-currency.js +85 -0
  18. package/dist/client/charge-currency.js.map +1 -0
  19. package/dist/client/costRecovery-invoices.d.ts +38 -0
  20. package/dist/client/costRecovery-invoices.d.ts.map +1 -0
  21. package/dist/client/costRecovery-invoices.js +105 -0
  22. package/dist/client/costRecovery-invoices.js.map +1 -0
  23. package/dist/client/costRecovery.d.ts +156 -0
  24. package/dist/client/costRecovery.d.ts.map +1 -0
  25. package/dist/client/costRecovery.js +117 -0
  26. package/dist/client/costRecovery.js.map +1 -0
  27. package/dist/client/index.d.ts +28 -66
  28. package/dist/client/index.d.ts.map +1 -1
  29. package/dist/client/index.js +25 -0
  30. package/dist/client/index.js.map +1 -1
  31. package/dist/client/products/prices.d.ts +3 -2
  32. package/dist/client/products/prices.d.ts.map +1 -1
  33. package/dist/client/products/prices.js +0 -3
  34. package/dist/client/products/prices.js.map +1 -1
  35. package/dist/client/products/products.d.ts +3 -2
  36. package/dist/client/products/products.d.ts.map +1 -1
  37. package/dist/client/products/products.js +0 -3
  38. package/dist/client/products/products.js.map +1 -1
  39. package/dist/client/settlement/processingCosts.d.ts +56 -0
  40. package/dist/client/settlement/processingCosts.d.ts.map +1 -0
  41. package/dist/client/settlement/processingCosts.js +173 -0
  42. package/dist/client/settlement/processingCosts.js.map +1 -0
  43. package/dist/client/utils/webhookEndpoints.d.ts +1 -1
  44. package/dist/client/utils/webhookEndpoints.d.ts.map +1 -1
  45. package/dist/client/utils/webhookEndpoints.js +1 -0
  46. package/dist/client/utils/webhookEndpoints.js.map +1 -1
  47. package/dist/client/webhooks/processors.d.ts.map +1 -1
  48. package/dist/client/webhooks/processors.js +1 -0
  49. package/dist/client/webhooks/processors.js.map +1 -1
  50. package/dist/component/_generated/component.d.ts +322 -0
  51. package/dist/component/_generated/component.d.ts.map +1 -1
  52. package/dist/component/costRecovery/claims.d.ts +68 -0
  53. package/dist/component/costRecovery/claims.d.ts.map +1 -0
  54. package/dist/component/costRecovery/claims.js +92 -0
  55. package/dist/component/costRecovery/claims.js.map +1 -0
  56. package/dist/component/costRecovery/completion.d.ts +39 -0
  57. package/dist/component/costRecovery/completion.d.ts.map +1 -0
  58. package/dist/component/costRecovery/completion.js +69 -0
  59. package/dist/component/costRecovery/completion.js.map +1 -0
  60. package/dist/component/costRecovery/helpers.d.ts +68 -0
  61. package/dist/component/costRecovery/helpers.d.ts.map +1 -0
  62. package/dist/component/costRecovery/helpers.js +71 -0
  63. package/dist/component/costRecovery/helpers.js.map +1 -0
  64. package/dist/component/costRecovery/mutations.d.ts +210 -0
  65. package/dist/component/costRecovery/mutations.d.ts.map +1 -0
  66. package/dist/component/costRecovery/mutations.js +93 -0
  67. package/dist/component/costRecovery/mutations.js.map +1 -0
  68. package/dist/component/costRecovery/queries.d.ts +108 -0
  69. package/dist/component/costRecovery/queries.d.ts.map +1 -0
  70. package/dist/component/costRecovery/queries.js +73 -0
  71. package/dist/component/costRecovery/queries.js.map +1 -0
  72. package/dist/component/costRecovery/reservations.d.ts +95 -0
  73. package/dist/component/costRecovery/reservations.d.ts.map +1 -0
  74. package/dist/component/costRecovery/reservations.js +134 -0
  75. package/dist/component/costRecovery/reservations.js.map +1 -0
  76. package/dist/component/costRecovery/schema.d.ts +382 -0
  77. package/dist/component/costRecovery/schema.d.ts.map +1 -0
  78. package/dist/component/costRecovery/schema.js +115 -0
  79. package/dist/component/costRecovery/schema.js.map +1 -0
  80. package/dist/component/schema.d.ts +135 -0
  81. package/dist/component/schema.js +5 -0
  82. package/dist/component/schema.js.map +1 -1
  83. package/dist/react/components/PriceCard.d.ts +3 -1
  84. package/dist/react/components/PriceCard.d.ts.map +1 -1
  85. package/dist/react/components/PriceCard.js +2 -2
  86. package/dist/react/components/PriceCard.js.map +1 -1
  87. package/dist/react/index.d.ts +1 -0
  88. package/dist/react/index.d.ts.map +1 -1
  89. package/dist/react/index.js +1 -0
  90. package/dist/react/index.js.map +1 -1
  91. package/dist/react/lib/price-helpers.d.ts +4 -6
  92. package/dist/react/lib/price-helpers.d.ts.map +1 -1
  93. package/dist/react/lib/price-helpers.js +16 -9
  94. package/dist/react/lib/price-helpers.js.map +1 -1
  95. package/dist/react/workflows/Collections.d.ts.map +1 -1
  96. package/dist/react/workflows/Collections.js +2 -1
  97. package/dist/react/workflows/Collections.js.map +1 -1
  98. package/dist/react/workflows/PlanPricingEditor.d.ts +4 -0
  99. package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -1
  100. package/dist/react/workflows/PlanPricingEditor.js +12 -9
  101. package/dist/react/workflows/PlanPricingEditor.js.map +1 -1
  102. package/dist/react/workflows/Refunds.d.ts +14 -3
  103. package/dist/react/workflows/Refunds.d.ts.map +1 -1
  104. package/dist/react/workflows/Refunds.js +58 -16
  105. package/dist/react/workflows/Refunds.js.map +1 -1
  106. package/dist/react/workflows/index.d.ts +3 -1
  107. package/dist/react/workflows/index.d.ts.map +1 -1
  108. package/dist/react/workflows/index.js +2 -1
  109. package/dist/react/workflows/index.js.map +1 -1
  110. package/dist/react/workflows/refund-preview.d.ts +16 -0
  111. package/dist/react/workflows/refund-preview.d.ts.map +1 -0
  112. package/dist/react/workflows/refund-preview.js +38 -0
  113. package/dist/react/workflows/refund-preview.js.map +1 -0
  114. package/docs/react-workflows.md +167 -1
  115. package/package.json +2 -2
  116. package/src/client/billing/balanceFunding.test.ts +257 -0
  117. package/src/client/billing/balanceFunding.ts +183 -0
  118. package/src/client/billing/balanceFundingTransport.test.ts +89 -0
  119. package/src/client/billing/invoicePreview.test.ts +123 -0
  120. package/src/client/billing/invoicePreview.ts +94 -0
  121. package/src/client/billing/invoicePreviewTransport.test.ts +70 -0
  122. package/src/client/charge-currency-format.ts +29 -0
  123. package/src/client/charge-currency.test.ts +113 -0
  124. package/src/client/charge-currency.ts +119 -0
  125. package/src/client/costRecovery-invoices.test.ts +401 -0
  126. package/src/client/costRecovery-invoices.transport.test.ts +107 -0
  127. package/src/client/costRecovery-invoices.ts +144 -0
  128. package/src/client/costRecovery.test.ts +76 -0
  129. package/src/client/costRecovery.ts +254 -0
  130. package/src/client/index.ts +76 -75
  131. package/src/client/products/prices.ts +13 -11
  132. package/src/client/products/products.ts +10 -10
  133. package/src/client/settlement/processingCosts.test.ts +349 -0
  134. package/src/client/settlement/processingCosts.ts +315 -0
  135. package/src/client/settlement/processingCostsTransport.test.ts +135 -0
  136. package/src/client/utils/webhookEndpoints.test.ts +5 -0
  137. package/src/client/utils/webhookEndpoints.ts +1 -0
  138. package/src/client/webhooks/hooks.test.ts +9 -0
  139. package/src/client/webhooks/payout-canceled.integration.test.ts +192 -0
  140. package/src/client/webhooks/processors.ts +1 -0
  141. package/src/component/_generated/api.ts +12 -0
  142. package/src/component/_generated/component.ts +407 -0
  143. package/src/component/cost-recovery.test.ts +344 -0
  144. package/src/component/costRecovery/claims.ts +119 -0
  145. package/src/component/costRecovery/completion.ts +92 -0
  146. package/src/component/costRecovery/helpers.ts +83 -0
  147. package/src/component/costRecovery/mutations.ts +115 -0
  148. package/src/component/costRecovery/queries.ts +88 -0
  149. package/src/component/costRecovery/reservations.ts +164 -0
  150. package/src/component/costRecovery/schema.ts +135 -0
  151. package/src/component/schema.ts +10 -0
  152. package/src/react/components/PayoutSchedule.render.test.tsx +15 -0
  153. package/src/react/components/PriceCard.render.test.tsx +37 -0
  154. package/src/react/components/PriceCard.tsx +9 -1
  155. package/src/react/index.test.ts +5 -0
  156. package/src/react/index.ts +7 -0
  157. package/src/react/lib/price-helpers.test.ts +15 -2
  158. package/src/react/lib/price-helpers.ts +25 -8
  159. package/src/react/workflows/Billing.test.tsx +1 -1
  160. package/src/react/workflows/Collections.test.tsx +36 -0
  161. package/src/react/workflows/Collections.tsx +3 -1
  162. package/src/react/workflows/PlanPricingEditor.currency.test.tsx +164 -0
  163. package/src/react/workflows/PlanPricingEditor.tsx +26 -10
  164. package/src/react/workflows/Refunds.currency.test.tsx +290 -0
  165. package/src/react/workflows/Refunds.tsx +115 -25
  166. package/src/react/workflows/index.ts +8 -1
  167. package/src/react/workflows/refund-preview.test.ts +65 -0
  168. 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
- * Format a price amount in cents to a display string.
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
- amountInCents: number,
25
+ amount: number,
20
26
  currency: string,
21
27
  locale = "en-US",
22
28
  ): string {
23
- return new Intl.NumberFormat(locale, {
24
- style: "currency",
25
- currency: currency.toUpperCase(),
26
- minimumFractionDigits: 2,
27
- }).format(amountInCents / 100);
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
- const amount = formatPrice(price.unitAmount ?? 0, price.currency, locale);
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: 800 },
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) => formatPrice(row.amount, row.currency),
115
+ cell: (row) =>
116
+ formatRefundAmount(row.amount, row.currency) ?? "Amount unavailable",
115
117
  },
116
118
  { id: "status", label: "Status", cell: (row) => row.status },
117
119
  {
@@ -0,0 +1,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 scope = `${plan.id}:${plan.version}`;
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 / 100).toFixed(2),
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
- if (!/^\d+(\.\d{1,2})?$/.test(fields.monthlyPrice.trim()))
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 with at most two decimal places.",
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
- }, plan.id);
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`}>Monthly price (USD)</label>
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
+ });