@fantastic.dev/better-stripe 0.11.0 → 0.12.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 (139) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +211 -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.d.ts +115 -0
  20. package/dist/client/costRecovery.d.ts.map +1 -0
  21. package/dist/client/costRecovery.js +50 -0
  22. package/dist/client/costRecovery.js.map +1 -0
  23. package/dist/client/index.d.ts +28 -66
  24. package/dist/client/index.d.ts.map +1 -1
  25. package/dist/client/index.js +25 -0
  26. package/dist/client/index.js.map +1 -1
  27. package/dist/client/products/prices.d.ts +3 -2
  28. package/dist/client/products/prices.d.ts.map +1 -1
  29. package/dist/client/products/prices.js +0 -3
  30. package/dist/client/products/prices.js.map +1 -1
  31. package/dist/client/products/products.d.ts +3 -2
  32. package/dist/client/products/products.d.ts.map +1 -1
  33. package/dist/client/products/products.js +0 -3
  34. package/dist/client/products/products.js.map +1 -1
  35. package/dist/client/settlement/processingCosts.d.ts +56 -0
  36. package/dist/client/settlement/processingCosts.d.ts.map +1 -0
  37. package/dist/client/settlement/processingCosts.js +173 -0
  38. package/dist/client/settlement/processingCosts.js.map +1 -0
  39. package/dist/client/utils/webhookEndpoints.d.ts +1 -1
  40. package/dist/client/utils/webhookEndpoints.d.ts.map +1 -1
  41. package/dist/client/utils/webhookEndpoints.js +1 -0
  42. package/dist/client/utils/webhookEndpoints.js.map +1 -1
  43. package/dist/client/webhooks/processors.d.ts.map +1 -1
  44. package/dist/client/webhooks/processors.js +1 -0
  45. package/dist/client/webhooks/processors.js.map +1 -1
  46. package/dist/component/_generated/component.d.ts +244 -0
  47. package/dist/component/_generated/component.d.ts.map +1 -1
  48. package/dist/component/costRecovery/claims.d.ts +68 -0
  49. package/dist/component/costRecovery/claims.d.ts.map +1 -0
  50. package/dist/component/costRecovery/claims.js +85 -0
  51. package/dist/component/costRecovery/claims.js.map +1 -0
  52. package/dist/component/costRecovery/completion.d.ts +33 -0
  53. package/dist/component/costRecovery/completion.d.ts.map +1 -0
  54. package/dist/component/costRecovery/completion.js +69 -0
  55. package/dist/component/costRecovery/completion.js.map +1 -0
  56. package/dist/component/costRecovery/helpers.d.ts +62 -0
  57. package/dist/component/costRecovery/helpers.d.ts.map +1 -0
  58. package/dist/component/costRecovery/helpers.js +71 -0
  59. package/dist/component/costRecovery/helpers.js.map +1 -0
  60. package/dist/component/costRecovery/mutations.d.ts +138 -0
  61. package/dist/component/costRecovery/mutations.d.ts.map +1 -0
  62. package/dist/component/costRecovery/mutations.js +36 -0
  63. package/dist/component/costRecovery/mutations.js.map +1 -0
  64. package/dist/component/costRecovery/queries.d.ts +102 -0
  65. package/dist/component/costRecovery/queries.d.ts.map +1 -0
  66. package/dist/component/costRecovery/queries.js +73 -0
  67. package/dist/component/costRecovery/queries.js.map +1 -0
  68. package/dist/component/costRecovery/reservations.d.ts +83 -0
  69. package/dist/component/costRecovery/reservations.d.ts.map +1 -0
  70. package/dist/component/costRecovery/reservations.js +134 -0
  71. package/dist/component/costRecovery/reservations.js.map +1 -0
  72. package/dist/component/costRecovery/schema.d.ts +340 -0
  73. package/dist/component/costRecovery/schema.d.ts.map +1 -0
  74. package/dist/component/costRecovery/schema.js +109 -0
  75. package/dist/component/costRecovery/schema.js.map +1 -0
  76. package/dist/component/schema.d.ts +119 -0
  77. package/dist/component/schema.js +5 -0
  78. package/dist/component/schema.js.map +1 -1
  79. package/dist/react/components/PriceCard.d.ts +3 -1
  80. package/dist/react/components/PriceCard.d.ts.map +1 -1
  81. package/dist/react/components/PriceCard.js +2 -2
  82. package/dist/react/components/PriceCard.js.map +1 -1
  83. package/dist/react/index.d.ts +1 -0
  84. package/dist/react/index.d.ts.map +1 -1
  85. package/dist/react/index.js +1 -0
  86. package/dist/react/index.js.map +1 -1
  87. package/dist/react/lib/price-helpers.d.ts +4 -6
  88. package/dist/react/lib/price-helpers.d.ts.map +1 -1
  89. package/dist/react/lib/price-helpers.js +16 -9
  90. package/dist/react/lib/price-helpers.js.map +1 -1
  91. package/dist/react/workflows/PlanPricingEditor.d.ts +4 -0
  92. package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -1
  93. package/dist/react/workflows/PlanPricingEditor.js +12 -9
  94. package/dist/react/workflows/PlanPricingEditor.js.map +1 -1
  95. package/docs/react-workflows.md +91 -0
  96. package/package.json +2 -2
  97. package/src/client/billing/balanceFunding.test.ts +257 -0
  98. package/src/client/billing/balanceFunding.ts +183 -0
  99. package/src/client/billing/balanceFundingTransport.test.ts +89 -0
  100. package/src/client/billing/invoicePreview.test.ts +123 -0
  101. package/src/client/billing/invoicePreview.ts +94 -0
  102. package/src/client/billing/invoicePreviewTransport.test.ts +70 -0
  103. package/src/client/charge-currency-format.ts +29 -0
  104. package/src/client/charge-currency.test.ts +113 -0
  105. package/src/client/charge-currency.ts +119 -0
  106. package/src/client/costRecovery.test.ts +76 -0
  107. package/src/client/costRecovery.ts +149 -0
  108. package/src/client/index.ts +76 -75
  109. package/src/client/products/prices.ts +13 -11
  110. package/src/client/products/products.ts +10 -10
  111. package/src/client/settlement/processingCosts.test.ts +349 -0
  112. package/src/client/settlement/processingCosts.ts +315 -0
  113. package/src/client/settlement/processingCostsTransport.test.ts +135 -0
  114. package/src/client/utils/webhookEndpoints.test.ts +5 -0
  115. package/src/client/utils/webhookEndpoints.ts +1 -0
  116. package/src/client/webhooks/hooks.test.ts +9 -0
  117. package/src/client/webhooks/payout-canceled.integration.test.ts +192 -0
  118. package/src/client/webhooks/processors.ts +1 -0
  119. package/src/component/_generated/api.ts +12 -0
  120. package/src/component/_generated/component.ts +308 -0
  121. package/src/component/cost-recovery.test.ts +344 -0
  122. package/src/component/costRecovery/claims.ts +109 -0
  123. package/src/component/costRecovery/completion.ts +92 -0
  124. package/src/component/costRecovery/helpers.ts +83 -0
  125. package/src/component/costRecovery/mutations.ts +43 -0
  126. package/src/component/costRecovery/queries.ts +88 -0
  127. package/src/component/costRecovery/reservations.ts +164 -0
  128. package/src/component/costRecovery/schema.ts +127 -0
  129. package/src/component/schema.ts +10 -0
  130. package/src/react/components/PayoutSchedule.render.test.tsx +15 -0
  131. package/src/react/components/PriceCard.render.test.tsx +37 -0
  132. package/src/react/components/PriceCard.tsx +9 -1
  133. package/src/react/index.test.ts +5 -0
  134. package/src/react/index.ts +7 -0
  135. package/src/react/lib/price-helpers.test.ts +15 -2
  136. package/src/react/lib/price-helpers.ts +25 -8
  137. package/src/react/workflows/Billing.test.tsx +1 -1
  138. package/src/react/workflows/PlanPricingEditor.currency.test.tsx +164 -0
  139. package/src/react/workflows/PlanPricingEditor.tsx +26 -10
@@ -0,0 +1,127 @@
1
+ import { defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+ export const scopeFields = {
4
+ accountId: v.string(),
5
+ scopeKey: v.string(),
6
+ currency: v.string(),
7
+ };
8
+ export const totalsFields = {
9
+ amountDue: v.number(),
10
+ collectedAmount: v.number(),
11
+ reservedAmount: v.number(),
12
+ outstanding: v.number(),
13
+ };
14
+ export const signValidator = v.union(
15
+ v.literal("positive"),
16
+ v.literal("negative"),
17
+ v.literal("zero"),
18
+ );
19
+ export const observationFields = {
20
+ ...scopeFields,
21
+ claimKey: v.string(),
22
+ sourceChargeId: v.string(),
23
+ sourceGross: v.number(),
24
+ sourceProcessingFee: v.number(),
25
+ amountDue: v.number(),
26
+ revision: v.number(),
27
+ };
28
+ export const claimFields = {
29
+ ...observationFields,
30
+ collectedAmount: v.number(),
31
+ reservedAmount: v.number(),
32
+ outstanding: v.number(),
33
+ sign: signValidator,
34
+ };
35
+ export const claimDoc = v.object({
36
+ ...claimFields,
37
+ _id: v.id("recoveryClaims"),
38
+ _creationTime: v.number(),
39
+ });
40
+ export const balanceFields = {
41
+ ...scopeFields,
42
+ ...totalsFields,
43
+ activeGroupKey: v.optional(v.string()),
44
+ };
45
+ export const groupInput = {
46
+ ...scopeFields,
47
+ groupKey: v.string(),
48
+ sourceKind: v.union(v.literal("settlement"), v.literal("invoice")),
49
+ sourceId: v.string(),
50
+ sourceGross: v.optional(v.number()),
51
+ sourceProcessingFee: v.optional(v.number()),
52
+ maxDebit: v.number(),
53
+ maxCredit: v.number(),
54
+ };
55
+ export const groupFields = {
56
+ ...groupInput,
57
+ amount: v.number(),
58
+ pendingOperations: v.number(),
59
+ status: v.union(
60
+ v.literal("open"),
61
+ v.literal("sealed"),
62
+ v.literal("collecting"),
63
+ v.literal("releasing"),
64
+ v.literal("collected"),
65
+ v.literal("released"),
66
+ ),
67
+ };
68
+ export const groupDoc = v.object({
69
+ ...groupFields,
70
+ _id: v.id("recoveryGroups"),
71
+ _creationTime: v.number(),
72
+ });
73
+ export const reservationFields = {
74
+ operationKey: v.string(),
75
+ groupKey: v.string(),
76
+ amount: v.number(),
77
+ limit: v.number(),
78
+ allocations: v.array(
79
+ v.object({
80
+ claimId: v.id("recoveryClaims"),
81
+ claimKey: v.string(),
82
+ amount: v.number(),
83
+ }),
84
+ ),
85
+ status: v.union(
86
+ v.literal("reserved"),
87
+ v.literal("collected"),
88
+ v.literal("released"),
89
+ v.literal("empty"),
90
+ ),
91
+ };
92
+ export const reservationDoc = v.object({
93
+ ...reservationFields,
94
+ _id: v.id("recoveryReservations"),
95
+ _creationTime: v.number(),
96
+ });
97
+ export const claimsTable = defineTable(claimFields)
98
+ .index("by_key", ["claimKey"])
99
+ .index("by_scope", ["accountId", "scopeKey", "currency"])
100
+ .index("by_sign", ["accountId", "scopeKey", "currency", "sign"]);
101
+ export const balancesTable = defineTable(balanceFields).index("by_scope", [
102
+ "accountId",
103
+ "scopeKey",
104
+ "currency",
105
+ ]);
106
+ export const groupsTable = defineTable(groupFields).index("by_key", [
107
+ "groupKey",
108
+ ]);
109
+ export const reservationsTable = defineTable(reservationFields)
110
+ .index("by_key", ["operationKey"])
111
+ .index("by_group_status", ["groupKey", "status"]);
112
+
113
+ export const reserveArgs = {
114
+ ...groupInput,
115
+ operationKey: v.string(),
116
+ limit: v.optional(v.number()),
117
+ };
118
+ export const finishArgs = {
119
+ groupKey: v.string(),
120
+ outcome: v.union(v.literal("collect"), v.literal("release")),
121
+ limit: v.optional(v.number()),
122
+ };
123
+ export const rollbackArgs = {
124
+ groupKey: v.string(),
125
+ returnedPrincipal: v.number(),
126
+ revision: v.number(),
127
+ };
@@ -1,3 +1,9 @@
1
+ import {
2
+ claimsTable,
3
+ balancesTable,
4
+ groupsTable,
5
+ reservationsTable,
6
+ } from "./costRecovery/schema";
1
7
  import {
2
8
  allocationsTable,
3
9
  commissionTermsTable,
@@ -24,6 +30,10 @@ import { pricesTable, productsTable } from "./products/schema";
24
30
  import { webhookEventsTable } from "./webhooks/schema";
25
31
 
26
32
  export default defineSchema({
33
+ recoveryClaims: claimsTable,
34
+ recoveryBalances: balancesTable,
35
+ recoveryGroups: groupsTable,
36
+ recoveryReservations: reservationsTable,
27
37
  settlementAllocations: allocationsTable,
28
38
  commissionTerms: commissionTermsTable,
29
39
  commissionDecisions: commissionDecisionsTable,
@@ -176,3 +176,18 @@ describe("PayoutSchedule", () => {
176
176
  expect(BarrelExport).toBe(PayoutSchedule);
177
177
  });
178
178
  });
179
+ it.each(["ugx", "isk"])(
180
+ "preserves observed fractional %s balances",
181
+ (currency) => {
182
+ const html = renderToStaticMarkup(
183
+ createElement(PayoutSchedule, {
184
+ balance: { currency, available: 101, pending: -1 },
185
+ children: ({ availableLabel, pendingLabel }) =>
186
+ createElement("p", null, `${availableLabel}|${pendingLabel}`),
187
+ }),
188
+ );
189
+ expect(html).toBe(
190
+ `<p>${currency.toUpperCase()} 1.01|-${currency.toUpperCase()} 0.01</p>`,
191
+ );
192
+ },
193
+ );
@@ -30,6 +30,43 @@ const basePrice: PriceCardPrice = {
30
30
  };
31
31
 
32
32
  describe("PriceCard", () => {
33
+ it("accepts a translated custom price label and locale", () => {
34
+ const html = renderToStaticMarkup(
35
+ createElement(PriceCard, {
36
+ price: { ...basePrice, unitAmount: undefined },
37
+ customPriceLabel: "Preis auf Anfrage",
38
+ locale: "de-DE",
39
+ }),
40
+ );
41
+ expect(html).toContain("<p>Preis auf Anfrage</p>");
42
+ expect(
43
+ renderToStaticMarkup(
44
+ createElement(PriceCard, {
45
+ price: { ...basePrice, currency: "eur" },
46
+ locale: "de-DE",
47
+ }),
48
+ ),
49
+ ).toContain("20,00");
50
+ });
51
+ it("shows a zero-decimal charge without dividing by 100", () => {
52
+ const html = renderToStaticMarkup(
53
+ createElement(PriceCard, {
54
+ price: { ...basePrice, currency: "jpy", unitAmount: 500 },
55
+ }),
56
+ );
57
+ expect(html).toContain("<p>¥500/month</p>");
58
+ });
59
+
60
+ it("does not present a custom price as free in render props", () => {
61
+ const html = renderToStaticMarkup(
62
+ createElement(PriceCard, {
63
+ price: { ...basePrice, unitAmount: null },
64
+ children: ({ formattedPrice }) =>
65
+ createElement("p", null, formattedPrice),
66
+ }),
67
+ );
68
+ expect(html).toBe("<p>Custom price</p>");
69
+ });
33
70
  it("renders the formatted price inside the default card with role=option", () => {
34
71
  const html = renderToStaticMarkup(
35
72
  createElement(PriceCard, { price: basePrice }),
@@ -23,6 +23,8 @@ export type PriceCardProps = {
23
23
  /** i18n overrides */
24
24
  currentPlanLabel?: string;
25
25
  selectLabel?: string;
26
+ customPriceLabel?: string;
27
+ locale?: string;
26
28
  /** Render prop for full customization */
27
29
  children?: (props: {
28
30
  price: PriceCardPrice;
@@ -45,10 +47,16 @@ export function PriceCard({
45
47
  onSelect,
46
48
  currentPlanLabel = "Current plan",
47
49
  selectLabel = "Select",
50
+ customPriceLabel = "Custom price",
51
+ locale = "en-US",
48
52
  children,
49
53
  className,
50
54
  }: PriceCardProps) {
51
- const formattedPrice = formatPriceWithInterval(price);
55
+ const formattedPrice = formatPriceWithInterval(
56
+ price,
57
+ locale,
58
+ customPriceLabel,
59
+ );
52
60
 
53
61
  if (children) {
54
62
  return (
@@ -7,6 +7,11 @@ import { describe, expect, it } from "vitest";
7
7
  import * as ReactExports from "./index.js";
8
8
 
9
9
  describe("better-stripe/react barrel export", () => {
10
+ it("exports shared charge helpers with actual zero-decimal behavior", () => {
11
+ expect(ReactExports.parseChargeAmount("500", "jpy")).toBe(500);
12
+ expect(ReactExports.formatChargeAmount(500, "jpy")).toBe("¥500");
13
+ expect(ReactExports.getChargeCurrencyMetadata("isk")?.apiDecimals).toBe(2);
14
+ });
10
15
  it("exports all documented hook factories", () => {
11
16
  const hookFactories = [
12
17
  "createUseAccount",
@@ -283,3 +283,10 @@ export {
283
283
 
284
284
  // Complete unstyled workflows
285
285
  export * from "./workflows/index.js";
286
+ export {
287
+ chargeAmountToDecimal,
288
+ getChargeCurrencyMetadata,
289
+ parseChargeAmount,
290
+ formatChargeAmount,
291
+ type ChargeCurrencyMetadata,
292
+ } from "../client/charge-currency.js";
@@ -8,6 +8,10 @@ import {
8
8
  } from "./price-helpers.js";
9
9
 
10
10
  describe("formatPrice", () => {
11
+ it("keeps fractional compatibility units from observed balances and costs", () => {
12
+ expect(formatPrice(101, "ugx")).toContain("1.01");
13
+ expect(formatPrice(-1, "isk")).toBe("-ISK 0.01");
14
+ });
11
15
  it("formats USD cents to dollars", () => {
12
16
  expect(formatPrice(2000, "usd")).toBe("$20.00");
13
17
  });
@@ -32,6 +36,15 @@ describe("formatPrice", () => {
32
36
  });
33
37
 
34
38
  describe("formatPriceWithInterval", () => {
39
+ it("supports translated custom prices without fabricating an interval", () => {
40
+ expect(
41
+ formatPriceWithInterval(
42
+ { currency: "usd", type: "recurring", interval: "month", active: true },
43
+ "en-US",
44
+ "Contact us",
45
+ ),
46
+ ).toBe("Contact us");
47
+ });
35
48
  it("shows amount only for one_time prices", () => {
36
49
  const result = formatPriceWithInterval({
37
50
  unitAmount: 2900,
@@ -80,7 +93,7 @@ describe("formatPriceWithInterval", () => {
80
93
  expect(result).toBe("$50.00 every 3 months");
81
94
  });
82
95
 
83
- it("handles null unitAmount as zero", () => {
96
+ it("keeps a custom/null amount distinct from a zero price", () => {
84
97
  const result = formatPriceWithInterval({
85
98
  unitAmount: null,
86
99
  currency: "usd",
@@ -89,7 +102,7 @@ describe("formatPriceWithInterval", () => {
89
102
  intervalCount: null,
90
103
  active: true,
91
104
  });
92
- expect(result).toBe("$0.00");
105
+ expect(result).toBe("Custom price");
93
106
  });
94
107
  });
95
108
 
@@ -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} />);
@@ -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)}