@fantastic.dev/better-stripe 0.8.1 → 0.10.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 (175) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/MIGRATION.md +12 -0
  3. package/README.md +46 -2
  4. package/dist/client/billing/checkout.d.ts +1 -0
  5. package/dist/client/billing/checkout.d.ts.map +1 -1
  6. package/dist/client/billing/checkout.js +5 -1
  7. package/dist/client/billing/checkout.js.map +1 -1
  8. package/dist/client/billing/providerReads.d.ts +18 -0
  9. package/dist/client/billing/providerReads.d.ts.map +1 -0
  10. package/dist/client/billing/providerReads.js +15 -0
  11. package/dist/client/billing/providerReads.js.map +1 -0
  12. package/dist/client/billing/subscriptions.d.ts +1 -1
  13. package/dist/client/billing/subscriptions.d.ts.map +1 -1
  14. package/dist/client/billing/subscriptions.js.map +1 -1
  15. package/dist/client/connect/payoutSnapshot.d.ts +28 -0
  16. package/dist/client/connect/payoutSnapshot.d.ts.map +1 -0
  17. package/dist/client/connect/payoutSnapshot.js +81 -0
  18. package/dist/client/connect/payoutSnapshot.js.map +1 -0
  19. package/dist/client/connect/transfers.d.ts.map +1 -1
  20. package/dist/client/connect/transfers.js +13 -32
  21. package/dist/client/connect/transfers.js.map +1 -1
  22. package/dist/client/core/accounts.d.ts +12 -23
  23. package/dist/client/core/accounts.d.ts.map +1 -1
  24. package/dist/client/core/accounts.js +5 -1
  25. package/dist/client/core/accounts.js.map +1 -1
  26. package/dist/client/core/idempotency.d.ts +5 -0
  27. package/dist/client/core/idempotency.d.ts.map +1 -0
  28. package/dist/client/core/idempotency.js +10 -0
  29. package/dist/client/core/idempotency.js.map +1 -0
  30. package/dist/client/index.d.ts +31 -25
  31. package/dist/client/index.d.ts.map +1 -1
  32. package/dist/client/index.js +29 -52
  33. package/dist/client/index.js.map +1 -1
  34. package/dist/client/settlement/allocation.d.ts +30 -0
  35. package/dist/client/settlement/allocation.d.ts.map +1 -0
  36. package/dist/client/settlement/allocation.js +71 -0
  37. package/dist/client/settlement/allocation.js.map +1 -0
  38. package/dist/client/settlement/commissions.d.ts +27 -0
  39. package/dist/client/settlement/commissions.d.ts.map +1 -0
  40. package/dist/client/settlement/commissions.js +69 -0
  41. package/dist/client/settlement/commissions.js.map +1 -0
  42. package/dist/client/settlement/execution.d.ts +27 -0
  43. package/dist/client/settlement/execution.d.ts.map +1 -0
  44. package/dist/client/settlement/execution.js +70 -0
  45. package/dist/client/settlement/execution.js.map +1 -0
  46. package/dist/client/settlement/reconciliation.d.ts +10 -0
  47. package/dist/client/settlement/reconciliation.d.ts.map +1 -0
  48. package/dist/client/settlement/reconciliation.js +73 -0
  49. package/dist/client/settlement/reconciliation.js.map +1 -0
  50. package/dist/client/settlement/settleSale.d.ts +21 -0
  51. package/dist/client/settlement/settleSale.d.ts.map +1 -0
  52. package/dist/client/settlement/settleSale.js +27 -0
  53. package/dist/client/settlement/settleSale.js.map +1 -0
  54. package/dist/client/settlement/verifiedSale.d.ts +25 -0
  55. package/dist/client/settlement/verifiedSale.d.ts.map +1 -0
  56. package/dist/client/settlement/verifiedSale.js +100 -0
  57. package/dist/client/settlement/verifiedSale.js.map +1 -0
  58. package/dist/client/webhooks/asyncHooks.d.ts +5 -0
  59. package/dist/client/webhooks/asyncHooks.d.ts.map +1 -0
  60. package/dist/client/webhooks/asyncHooks.js +20 -0
  61. package/dist/client/webhooks/asyncHooks.js.map +1 -0
  62. package/dist/component/_generated/component.d.ts +115 -0
  63. package/dist/component/_generated/component.d.ts.map +1 -1
  64. package/dist/component/connect/queries.d.ts +30 -1
  65. package/dist/component/connect/queries.d.ts.map +1 -1
  66. package/dist/component/connect/queries.js +19 -2
  67. package/dist/component/connect/queries.js.map +1 -1
  68. package/dist/component/schema.d.ts +67 -0
  69. package/dist/component/schema.js +4 -0
  70. package/dist/component/schema.js.map +1 -1
  71. package/dist/component/settlement/mutations.d.ts +47 -0
  72. package/dist/component/settlement/mutations.d.ts.map +1 -0
  73. package/dist/component/settlement/mutations.js +104 -0
  74. package/dist/component/settlement/mutations.js.map +1 -0
  75. package/dist/component/settlement/queries.d.ts +29 -0
  76. package/dist/component/settlement/queries.d.ts.map +1 -0
  77. package/dist/component/settlement/queries.js +35 -0
  78. package/dist/component/settlement/queries.js.map +1 -0
  79. package/dist/component/settlement/schema.d.ts +99 -0
  80. package/dist/component/settlement/schema.d.ts.map +1 -0
  81. package/dist/component/settlement/schema.js +35 -0
  82. package/dist/component/settlement/schema.js.map +1 -0
  83. package/dist/react/workflows/AffiliatePayoutEditor.d.ts +41 -0
  84. package/dist/react/workflows/AffiliatePayoutEditor.d.ts.map +1 -0
  85. package/dist/react/workflows/AffiliatePayoutEditor.js +80 -0
  86. package/dist/react/workflows/AffiliatePayoutEditor.js.map +1 -0
  87. package/dist/react/workflows/AffiliateRateEditor.d.ts +26 -0
  88. package/dist/react/workflows/AffiliateRateEditor.d.ts.map +1 -0
  89. package/dist/react/workflows/AffiliateRateEditor.js +49 -0
  90. package/dist/react/workflows/AffiliateRateEditor.js.map +1 -0
  91. package/dist/react/workflows/Checkout.d.ts +3 -1
  92. package/dist/react/workflows/Checkout.d.ts.map +1 -1
  93. package/dist/react/workflows/Checkout.js +3 -2
  94. package/dist/react/workflows/Checkout.js.map +1 -1
  95. package/dist/react/workflows/CheckoutForms.d.ts +2 -0
  96. package/dist/react/workflows/CheckoutForms.d.ts.map +1 -1
  97. package/dist/react/workflows/CheckoutForms.js +22 -16
  98. package/dist/react/workflows/CheckoutForms.js.map +1 -1
  99. package/dist/react/workflows/IntegrationSwitch.js +2 -2
  100. package/dist/react/workflows/IntegrationSwitch.js.map +1 -1
  101. package/dist/react/workflows/Pagination.d.ts +32 -0
  102. package/dist/react/workflows/Pagination.d.ts.map +1 -0
  103. package/dist/react/workflows/Pagination.js +49 -0
  104. package/dist/react/workflows/Pagination.js.map +1 -0
  105. package/dist/react/workflows/Payouts.d.ts +3 -18
  106. package/dist/react/workflows/Payouts.d.ts.map +1 -1
  107. package/dist/react/workflows/Payouts.js +2 -2
  108. package/dist/react/workflows/Payouts.js.map +1 -1
  109. package/dist/react/workflows/PlanPricingEditor.d.ts +35 -0
  110. package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -0
  111. package/dist/react/workflows/PlanPricingEditor.js +64 -0
  112. package/dist/react/workflows/PlanPricingEditor.js.map +1 -0
  113. package/dist/react/workflows/index.d.ts +8 -0
  114. package/dist/react/workflows/index.d.ts.map +1 -1
  115. package/dist/react/workflows/index.js +4 -0
  116. package/dist/react/workflows/index.js.map +1 -1
  117. package/docs/react-workflows-audit.md +19 -0
  118. package/docs/react-workflows.md +94 -3
  119. package/docs/settlement.md +158 -0
  120. package/package.json +6 -4
  121. package/src/client/billing/checkout-idempotency.test.ts +82 -0
  122. package/src/client/billing/checkout.ts +6 -1
  123. package/src/client/billing/providerReads.test.ts +60 -0
  124. package/src/client/billing/providerReads.ts +30 -0
  125. package/src/client/billing/subscriptions.ts +1 -8
  126. package/src/client/connect/payoutSnapshot.test.ts +91 -0
  127. package/src/client/connect/payoutSnapshot.ts +130 -0
  128. package/src/client/connect/transfers.test.ts +15 -4
  129. package/src/client/connect/transfers.ts +13 -42
  130. package/src/client/core/account-idempotency.test.ts +78 -0
  131. package/src/client/core/accounts.ts +18 -25
  132. package/src/client/core/idempotency.test.ts +10 -0
  133. package/src/client/core/idempotency.ts +11 -0
  134. package/src/client/index.ts +84 -103
  135. package/src/client/settlement/allocation.test.ts +98 -0
  136. package/src/client/settlement/allocation.ts +110 -0
  137. package/src/client/settlement/commissions.test.ts +103 -0
  138. package/src/client/settlement/commissions.ts +125 -0
  139. package/src/client/settlement/execution.test.ts +186 -0
  140. package/src/client/settlement/execution.ts +134 -0
  141. package/src/client/settlement/reconciliation.ts +119 -0
  142. package/src/client/settlement/settleSale.ts +59 -0
  143. package/src/client/settlement/verifiedSale.test.ts +139 -0
  144. package/src/client/settlement/verifiedSale.ts +159 -0
  145. package/src/client/webhooks/asyncHooks.ts +38 -0
  146. package/src/client/webhooks/processors.test.ts +9 -9
  147. package/src/component/_generated/api.ts +4 -0
  148. package/src/component/_generated/component.ts +141 -0
  149. package/src/component/connect/queries.ts +26 -2
  150. package/src/component/earnings-totals.test.ts +13 -0
  151. package/src/component/schema.ts +8 -0
  152. package/src/component/settlement/mutations.ts +119 -0
  153. package/src/component/settlement/queries.ts +38 -0
  154. package/src/component/settlement/schema.ts +45 -0
  155. package/src/component/settlement-claims.test.ts +77 -0
  156. package/src/component/transfers.test.ts +48 -0
  157. package/src/react/components/EmbeddedDisputes.render.test.tsx +2 -2
  158. package/src/react/index.payment-element.test.ts +22 -37
  159. package/src/react/workflows/AffiliatePayoutEditor.test.tsx +90 -0
  160. package/src/react/workflows/AffiliatePayoutEditor.tsx +201 -0
  161. package/src/react/workflows/AffiliateRateEditor.test.tsx +36 -0
  162. package/src/react/workflows/AffiliateRateEditor.tsx +92 -0
  163. package/src/react/workflows/Checkout.test.tsx +30 -0
  164. package/src/react/workflows/Checkout.tsx +10 -3
  165. package/src/react/workflows/CheckoutForms.test.tsx +40 -0
  166. package/src/react/workflows/CheckoutForms.tsx +37 -16
  167. package/src/react/workflows/IntegrationSwitch.test.tsx +25 -0
  168. package/src/react/workflows/IntegrationSwitch.tsx +2 -2
  169. package/src/react/workflows/Pagination.test.tsx +53 -0
  170. package/src/react/workflows/Pagination.tsx +93 -0
  171. package/src/react/workflows/Payouts.test.tsx +33 -0
  172. package/src/react/workflows/Payouts.tsx +21 -15
  173. package/src/react/workflows/PlanPricingEditor.test.tsx +79 -0
  174. package/src/react/workflows/PlanPricingEditor.tsx +156 -0
  175. package/src/react/workflows/index.ts +22 -0
@@ -0,0 +1,93 @@
1
+ "use client";
2
+ import { useState, type ReactNode } from "react";
3
+
4
+ /** Cursor history for bounded server pages. Changing scope resets navigation. */
5
+ export function useCursorPagination(scopeKey: string) {
6
+ const initial = { scopeKey, cursors: [null] as (string | null)[], index: 0 };
7
+ const [state, setState] = useState(initial);
8
+ const current = state.scopeKey === scopeKey ? state : initial;
9
+ if (state.scopeKey !== scopeKey) setState(initial);
10
+ return {
11
+ cursor: current.cursors[current.index],
12
+ page: current.index + 1,
13
+ canPrevious: current.index > 0,
14
+ previous: () =>
15
+ setState((previous) => ({
16
+ ...previous,
17
+ index: Math.max(0, previous.index - 1),
18
+ })),
19
+ next: (cursor: string) =>
20
+ setState((previous) => ({
21
+ ...previous,
22
+ cursors: [...previous.cursors.slice(0, previous.index + 1), cursor],
23
+ index: previous.index + 1,
24
+ })),
25
+ };
26
+ }
27
+ export type PaginationProps = {
28
+ page: number;
29
+ canPrevious: boolean;
30
+ canNext: boolean;
31
+ isLoading?: boolean;
32
+ onPrevious: () => void;
33
+ onNext: () => void;
34
+ previousLabel?: string;
35
+ nextLabel?: string;
36
+ pageLabel?: string;
37
+ children?: (state: {
38
+ pageLabel: string;
39
+ previousLabel: string;
40
+ nextLabel: string;
41
+ previousDisabled: boolean;
42
+ nextDisabled: boolean;
43
+ previous: () => void;
44
+ next: () => void;
45
+ }) => ReactNode;
46
+ };
47
+ /** Unstyled navigation with render props for application design systems. */
48
+ export function Pagination({
49
+ page,
50
+ canPrevious,
51
+ canNext,
52
+ isLoading = false,
53
+ onPrevious,
54
+ onNext,
55
+ previousLabel = "Previous",
56
+ nextLabel = "Next",
57
+ pageLabel = `Page ${page}`,
58
+ children,
59
+ }: PaginationProps) {
60
+ const previousDisabled = isLoading || !canPrevious;
61
+ const nextDisabled = isLoading || !canNext;
62
+ const state = {
63
+ pageLabel,
64
+ previousLabel,
65
+ nextLabel,
66
+ previousDisabled,
67
+ nextDisabled,
68
+ previous: () => {
69
+ if (!previousDisabled) onPrevious();
70
+ },
71
+ next: () => {
72
+ if (!nextDisabled) onNext();
73
+ },
74
+ };
75
+ if (children) return <>{children(state)}</>;
76
+ return (
77
+ <nav aria-label="Pagination">
78
+ <button
79
+ type="button"
80
+ disabled={previousDisabled}
81
+ onClick={state.previous}
82
+ >
83
+ {previousLabel}
84
+ </button>
85
+ <span role="status" aria-live="polite">
86
+ {pageLabel}
87
+ </span>
88
+ <button type="button" disabled={nextDisabled} onClick={state.next}>
89
+ {nextLabel}
90
+ </button>
91
+ </nav>
92
+ );
93
+ }
@@ -110,3 +110,36 @@ it("keeps completed payouts out of upcoming and sorts the remaining arrivals", a
110
110
  );
111
111
  expect(screen.getAllByText("po_paid")).toHaveLength(1);
112
112
  });
113
+ it("distinguishes an unavailable account from an empty balance and labels upcoming previews", async () => {
114
+ const load = vi.fn().mockResolvedValue({
115
+ status: "not_ready",
116
+ reason: "account_unavailable",
117
+ balances: [],
118
+ upcoming: [],
119
+ payouts: [],
120
+ nextCursor: null,
121
+ upcomingComplete: true,
122
+ });
123
+ const { unmount } = render(<Payouts scopeKey="unavailable" load={load} />);
124
+ expect((await screen.findByRole("alert")).textContent).toContain(
125
+ "not available",
126
+ );
127
+ expect(screen.queryByText("Not scheduled")).toBeNull();
128
+ unmount();
129
+ render(
130
+ <Payouts
131
+ scopeKey="preview"
132
+ load={async () => ({
133
+ status: "ready",
134
+ balances: [],
135
+ upcoming: [],
136
+ payouts: [],
137
+ nextCursor: null,
138
+ upcomingComplete: false,
139
+ })}
140
+ />,
141
+ );
142
+ expect(
143
+ await screen.findByText(/Upcoming payouts are a bounded preview/),
144
+ ).toBeTruthy();
145
+ });
@@ -1,4 +1,8 @@
1
1
  "use client";
2
+ import type {
3
+ RecipientPayoutSnapshot,
4
+ RecipientPayoutRow,
5
+ } from "../../client/connect/payoutSnapshot.js";
2
6
  import { useState, type ReactNode } from "react";
3
7
  import { Connect, type ConnectProps } from "./Connect.js";
4
8
  import { useWorkflowAction } from "./useWorkflowAction.js";
@@ -9,20 +13,14 @@ import {
9
13
  type WorkflowColumn,
10
14
  } from "./Collection.js";
11
15
  import { formatPrice } from "../lib/price-helpers.js";
12
- export type PayoutRow = {
13
- id: string;
14
- amount: number;
15
- currency: string;
16
- status: string;
17
- /** Unix milliseconds. */ arrivalDate: number | null;
18
- created: number;
19
- };
20
- export type PayoutSnapshot = {
21
- balances: { currency: string; available: number; pending: number }[];
22
- payouts: PayoutRow[];
23
- upcoming: PayoutRow[];
24
- nextCursor: string | null;
25
- };
16
+ export type PayoutRow = RecipientPayoutRow;
17
+ export type PayoutSnapshot = Pick<
18
+ RecipientPayoutSnapshot,
19
+ "balances" | "payouts" | "upcoming" | "nextCursor"
20
+ > &
21
+ Partial<
22
+ Pick<RecipientPayoutSnapshot, "status" | "reason" | "upcomingComplete">
23
+ >;
26
24
  export type PayoutsRenderProps = {
27
25
  data: PayoutSnapshot | undefined;
28
26
  error: string | null;
@@ -169,7 +167,10 @@ function NativePayouts({
169
167
  </button>
170
168
  {error && <p role="alert">{error}</p>}
171
169
  {isLoading && !data && <p role="status">Loading payouts…</p>}
172
- {data && (
170
+ {data?.status === "not_ready" && (
171
+ <p role="alert">Stripe account data is not available yet.</p>
172
+ )}
173
+ {data && data.status !== "not_ready" && (
173
174
  <>
174
175
  <dl>
175
176
  {data.balances.map((balance) => (
@@ -188,6 +189,11 @@ function NativePayouts({
188
189
  </dd>
189
190
  </dl>
190
191
  <h3>Upcoming payouts</h3>
192
+ {data.upcomingComplete === false && (
193
+ <p>
194
+ Upcoming payouts are a bounded preview. More may be scheduled.
195
+ </p>
196
+ )}
191
197
  <PayoutHistory
192
198
  rows={upcomingPayouts}
193
199
  emptyLabel="No upcoming payouts scheduled."
@@ -0,0 +1,79 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { afterEach, expect, it, vi } from "vitest";
5
+ import { PlanPricingEditor } from "./PlanPricingEditor.js";
6
+ afterEach(cleanup);
7
+ it("validates money and percentages before saving minor units", async () => {
8
+ const save = vi.fn().mockResolvedValue(undefined);
9
+ render(
10
+ <PlanPricingEditor
11
+ plan={{
12
+ id: "starter",
13
+ name: "Starter",
14
+ monthlyAmount: 900,
15
+ feeBasisPoints: 900,
16
+ version: 0,
17
+ }}
18
+ onSave={save}
19
+ />,
20
+ );
21
+ const monthly = screen.getByLabelText("Monthly price (USD)");
22
+ await userEvent.clear(monthly);
23
+ await userEvent.type(monthly, "12.34");
24
+ const fee = screen.getByLabelText("Sale fee (%)");
25
+ await userEvent.clear(fee);
26
+ await userEvent.type(fee, "7.5");
27
+ await userEvent.click(screen.getByRole("button", { name: "Save pricing" }));
28
+ expect(save).toHaveBeenCalledWith({
29
+ monthlyAmount: 1234,
30
+ feeBasisPoints: 750,
31
+ affiliateBasisPoints: 0,
32
+ expectedVersion: 0,
33
+ });
34
+ expect(screen.getByRole("status").textContent).toContain("saved");
35
+ await userEvent.clear(fee);
36
+ await userEvent.type(fee, "101");
37
+ await userEvent.click(screen.getByRole("button", { name: "Save pricing" }));
38
+ expect(save).toHaveBeenCalledTimes(1);
39
+ expect(screen.getByRole("alert").textContent).toMatch(/fee/i);
40
+ await userEvent.clear(fee);
41
+ await userEvent.click(screen.getByRole("button", { name: "Save pricing" }));
42
+ expect(save).toHaveBeenLastCalledWith({
43
+ monthlyAmount: 1234,
44
+ feeBasisPoints: null,
45
+ affiliateBasisPoints: 0,
46
+ expectedVersion: 0,
47
+ });
48
+ });
49
+ it("validates affiliate payout percentages and saves basis points", async () => {
50
+ const save = vi.fn().mockResolvedValue(undefined);
51
+ render(
52
+ <PlanPricingEditor
53
+ plan={{
54
+ id: "growth",
55
+ name: "Growth",
56
+ monthlyAmount: 4900,
57
+ feeBasisPoints: 490,
58
+ affiliateBasisPoints: 2000,
59
+ version: 1,
60
+ }}
61
+ onSave={save}
62
+ />,
63
+ );
64
+ const share = screen.getByLabelText("Affiliate payout (%)");
65
+ await userEvent.clear(share);
66
+ await userEvent.type(share, "12.5");
67
+ await userEvent.click(screen.getByRole("button", { name: "Save pricing" }));
68
+ expect(save).toHaveBeenLastCalledWith({
69
+ monthlyAmount: 4900,
70
+ feeBasisPoints: 490,
71
+ affiliateBasisPoints: 1250,
72
+ expectedVersion: 1,
73
+ });
74
+ await userEvent.clear(share);
75
+ await userEvent.type(share, "100.001");
76
+ await userEvent.click(screen.getByRole("button", { name: "Save pricing" }));
77
+ expect(save).toHaveBeenCalledTimes(1);
78
+ expect(screen.getByRole("alert").textContent).toMatch(/affiliate/i);
79
+ });
@@ -0,0 +1,156 @@
1
+ "use client";
2
+ import { useId, useState, type ReactNode } from "react";
3
+ import { useWorkflowAction } from "./useWorkflowAction.js";
4
+ export type PlanPricing = {
5
+ id: string;
6
+ name: string;
7
+ monthlyAmount: number;
8
+ feeBasisPoints: number | null;
9
+ affiliateBasisPoints?: number | null;
10
+ version: number;
11
+ };
12
+ export type PlanPricingUpdate = {
13
+ monthlyAmount: number;
14
+ feeBasisPoints: number | null;
15
+ affiliateBasisPoints?: number | null;
16
+ expectedVersion: number;
17
+ };
18
+ export type PlanPricingEditorState = {
19
+ monthlyPrice: string;
20
+ saleFee: string;
21
+ affiliateShare: string;
22
+ setMonthlyPrice: (value: string) => void;
23
+ setSaleFee: (value: string) => void;
24
+ setAffiliateShare: (value: string) => void;
25
+ save: () => Promise<unknown>;
26
+ isPending: boolean;
27
+ error: string | null;
28
+ message: string | undefined;
29
+ };
30
+ export type PlanPricingEditorProps = {
31
+ plan: PlanPricing;
32
+ onSave: (update: PlanPricingUpdate) => Promise<unknown>;
33
+ children?: (state: PlanPricingEditorState) => ReactNode;
34
+ };
35
+ /** Unstyled store-plan pricing editor. Authorization and Stripe price creation belong on the server. */
36
+ export function PlanPricingEditor({
37
+ plan,
38
+ onSave,
39
+ children,
40
+ }: PlanPricingEditorProps) {
41
+ const id = useId();
42
+ const scope = `${plan.id}:${plan.version}`;
43
+ const initial = {
44
+ scope,
45
+ affiliateShare: String((plan.affiliateBasisPoints ?? 0) / 100),
46
+ monthlyPrice: (plan.monthlyAmount / 100).toFixed(2),
47
+ saleFee:
48
+ plan.feeBasisPoints === null ? "" : String(plan.feeBasisPoints / 100),
49
+ };
50
+ const [fields, setFields] = useState(initial);
51
+ if (fields.scope !== scope) setFields(initial);
52
+ const action = useWorkflowAction(async () => {
53
+ if (!/^\d+(\.\d{1,2})?$/.test(fields.monthlyPrice.trim()))
54
+ throw new Error(
55
+ "Enter a positive monthly price with at most two decimal places.",
56
+ );
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
+ const feeBasisPoints =
61
+ fields.saleFee.trim() === ""
62
+ ? null
63
+ : Math.round(Number(fields.saleFee) * 100);
64
+ if (
65
+ feeBasisPoints !== null &&
66
+ (!/^\d+(\.\d{1,2})?$/.test(fields.saleFee.trim()) ||
67
+ !Number.isSafeInteger(feeBasisPoints) ||
68
+ feeBasisPoints < 0 ||
69
+ feeBasisPoints > 10000)
70
+ )
71
+ throw new Error(
72
+ "Enter a sale fee from 0 to 100%, with at most two decimal places, or leave it blank for Stripe processing only.",
73
+ );
74
+ const affiliateBasisPoints = Math.round(
75
+ Number(fields.affiliateShare) * 100,
76
+ );
77
+ if (
78
+ !/^\d+(\.\d{1,2})?$/.test(fields.affiliateShare.trim()) ||
79
+ !Number.isSafeInteger(affiliateBasisPoints) ||
80
+ affiliateBasisPoints < 0 ||
81
+ affiliateBasisPoints > 10000
82
+ )
83
+ throw new Error(
84
+ "Enter an affiliate payout from 0 to 100%, with at most two decimal places.",
85
+ );
86
+ await onSave({
87
+ monthlyAmount,
88
+ feeBasisPoints,
89
+ affiliateBasisPoints,
90
+ expectedVersion: plan.version,
91
+ });
92
+ return "Pricing saved. Existing subscriptions keep their monthly price.";
93
+ }, plan.id);
94
+ const state: PlanPricingEditorState = {
95
+ ...fields,
96
+ setMonthlyPrice: (monthlyPrice) =>
97
+ setFields((previous) => ({ ...previous, monthlyPrice })),
98
+ setSaleFee: (saleFee) =>
99
+ setFields((previous) => ({ ...previous, saleFee })),
100
+ setAffiliateShare: (affiliateShare) =>
101
+ setFields((previous) => ({ ...previous, affiliateShare })),
102
+ save: action.run,
103
+ isPending: action.isPending,
104
+ error: action.error,
105
+ message: action.result,
106
+ };
107
+ if (children) return <>{children(state)}</>;
108
+ return (
109
+ <form
110
+ onSubmit={(event) => {
111
+ event.preventDefault();
112
+ void state.save();
113
+ }}
114
+ aria-label={`${plan.name} pricing`}
115
+ >
116
+ <h3>{plan.name}</h3>
117
+ <label htmlFor={`${id}-monthly`}>Monthly price (USD)</label>
118
+ <input
119
+ id={`${id}-monthly`}
120
+ inputMode="decimal"
121
+ value={state.monthlyPrice}
122
+ disabled={state.isPending}
123
+ onChange={(event) => state.setMonthlyPrice(event.target.value)}
124
+ />
125
+ <label htmlFor={`${id}-fee`}>Sale fee (%)</label>
126
+ <input
127
+ id={`${id}-fee`}
128
+ inputMode="decimal"
129
+ value={state.saleFee}
130
+ disabled={state.isPending}
131
+ onChange={(event) => state.setSaleFee(event.target.value)}
132
+ />
133
+ <p>
134
+ Leave the sale fee blank for Stripe processing only. The seller always
135
+ covers actual Stripe processing costs.
136
+ </p>
137
+ <label htmlFor={`${id}-affiliate`}>Affiliate payout (%)</label>
138
+ <input
139
+ id={`${id}-affiliate`}
140
+ inputMode="decimal"
141
+ value={state.affiliateShare}
142
+ disabled={state.isPending}
143
+ onChange={(event) => state.setAffiliateShare(event.target.value)}
144
+ />
145
+ <p>
146
+ Percentage of a referred sale after the sale fee. The seller receives
147
+ the remainder.
148
+ </p>
149
+ {state.error && <p role="alert">{state.error}</p>}
150
+ {state.message && <p role="status">{state.message}</p>}
151
+ <button type="submit" disabled={state.isPending}>
152
+ {state.isPending ? "Saving…" : "Save pricing"}
153
+ </button>
154
+ </form>
155
+ );
156
+ }
@@ -236,3 +236,25 @@ export type {
236
236
  PaymentDetailsPayment,
237
237
  PaymentDetailsState,
238
238
  } from "./Financials.js";
239
+ export { Pagination, useCursorPagination } from "./Pagination.js";
240
+ export type { PaginationProps } from "./Pagination.js";
241
+ export { PlanPricingEditor } from "./PlanPricingEditor.js";
242
+ export type {
243
+ PlanPricing,
244
+ PlanPricingUpdate,
245
+ PlanPricingEditorState,
246
+ PlanPricingEditorProps,
247
+ } from "./PlanPricingEditor.js";
248
+ export { AffiliateRateEditor } from "./AffiliateRateEditor.js";
249
+ export type {
250
+ AffiliateRateUpdate,
251
+ AffiliateRateEditorProps,
252
+ AffiliateRateEditorState,
253
+ } from "./AffiliateRateEditor.js";
254
+ export { AffiliatePayoutEditor } from "./AffiliatePayoutEditor.js";
255
+ export type {
256
+ AffiliateCommissionTerm,
257
+ AffiliatePayoutEditorProps,
258
+ AffiliatePayoutEditorState,
259
+ AffiliatePayoutUpdate,
260
+ } from "./AffiliatePayoutEditor.js";