@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.
- package/CHANGELOG.md +12 -0
- package/MIGRATION.md +12 -0
- package/README.md +46 -2
- package/dist/client/billing/checkout.d.ts +1 -0
- package/dist/client/billing/checkout.d.ts.map +1 -1
- package/dist/client/billing/checkout.js +5 -1
- package/dist/client/billing/checkout.js.map +1 -1
- package/dist/client/billing/providerReads.d.ts +18 -0
- package/dist/client/billing/providerReads.d.ts.map +1 -0
- package/dist/client/billing/providerReads.js +15 -0
- package/dist/client/billing/providerReads.js.map +1 -0
- package/dist/client/billing/subscriptions.d.ts +1 -1
- package/dist/client/billing/subscriptions.d.ts.map +1 -1
- package/dist/client/billing/subscriptions.js.map +1 -1
- package/dist/client/connect/payoutSnapshot.d.ts +28 -0
- package/dist/client/connect/payoutSnapshot.d.ts.map +1 -0
- package/dist/client/connect/payoutSnapshot.js +81 -0
- package/dist/client/connect/payoutSnapshot.js.map +1 -0
- package/dist/client/connect/transfers.d.ts.map +1 -1
- package/dist/client/connect/transfers.js +13 -32
- package/dist/client/connect/transfers.js.map +1 -1
- package/dist/client/core/accounts.d.ts +12 -23
- package/dist/client/core/accounts.d.ts.map +1 -1
- package/dist/client/core/accounts.js +5 -1
- package/dist/client/core/accounts.js.map +1 -1
- package/dist/client/core/idempotency.d.ts +5 -0
- package/dist/client/core/idempotency.d.ts.map +1 -0
- package/dist/client/core/idempotency.js +10 -0
- package/dist/client/core/idempotency.js.map +1 -0
- package/dist/client/index.d.ts +31 -25
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +29 -52
- package/dist/client/index.js.map +1 -1
- package/dist/client/settlement/allocation.d.ts +30 -0
- package/dist/client/settlement/allocation.d.ts.map +1 -0
- package/dist/client/settlement/allocation.js +71 -0
- package/dist/client/settlement/allocation.js.map +1 -0
- package/dist/client/settlement/commissions.d.ts +27 -0
- package/dist/client/settlement/commissions.d.ts.map +1 -0
- package/dist/client/settlement/commissions.js +69 -0
- package/dist/client/settlement/commissions.js.map +1 -0
- package/dist/client/settlement/execution.d.ts +27 -0
- package/dist/client/settlement/execution.d.ts.map +1 -0
- package/dist/client/settlement/execution.js +70 -0
- package/dist/client/settlement/execution.js.map +1 -0
- package/dist/client/settlement/reconciliation.d.ts +10 -0
- package/dist/client/settlement/reconciliation.d.ts.map +1 -0
- package/dist/client/settlement/reconciliation.js +73 -0
- package/dist/client/settlement/reconciliation.js.map +1 -0
- package/dist/client/settlement/settleSale.d.ts +21 -0
- package/dist/client/settlement/settleSale.d.ts.map +1 -0
- package/dist/client/settlement/settleSale.js +27 -0
- package/dist/client/settlement/settleSale.js.map +1 -0
- package/dist/client/settlement/verifiedSale.d.ts +25 -0
- package/dist/client/settlement/verifiedSale.d.ts.map +1 -0
- package/dist/client/settlement/verifiedSale.js +100 -0
- package/dist/client/settlement/verifiedSale.js.map +1 -0
- package/dist/client/webhooks/asyncHooks.d.ts +5 -0
- package/dist/client/webhooks/asyncHooks.d.ts.map +1 -0
- package/dist/client/webhooks/asyncHooks.js +20 -0
- package/dist/client/webhooks/asyncHooks.js.map +1 -0
- package/dist/component/_generated/component.d.ts +115 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/connect/queries.d.ts +30 -1
- package/dist/component/connect/queries.d.ts.map +1 -1
- package/dist/component/connect/queries.js +19 -2
- package/dist/component/connect/queries.js.map +1 -1
- package/dist/component/schema.d.ts +67 -0
- package/dist/component/schema.js +4 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/component/settlement/mutations.d.ts +47 -0
- package/dist/component/settlement/mutations.d.ts.map +1 -0
- package/dist/component/settlement/mutations.js +104 -0
- package/dist/component/settlement/mutations.js.map +1 -0
- package/dist/component/settlement/queries.d.ts +29 -0
- package/dist/component/settlement/queries.d.ts.map +1 -0
- package/dist/component/settlement/queries.js +35 -0
- package/dist/component/settlement/queries.js.map +1 -0
- package/dist/component/settlement/schema.d.ts +99 -0
- package/dist/component/settlement/schema.d.ts.map +1 -0
- package/dist/component/settlement/schema.js +35 -0
- package/dist/component/settlement/schema.js.map +1 -0
- package/dist/react/workflows/AffiliatePayoutEditor.d.ts +41 -0
- package/dist/react/workflows/AffiliatePayoutEditor.d.ts.map +1 -0
- package/dist/react/workflows/AffiliatePayoutEditor.js +80 -0
- package/dist/react/workflows/AffiliatePayoutEditor.js.map +1 -0
- package/dist/react/workflows/AffiliateRateEditor.d.ts +26 -0
- package/dist/react/workflows/AffiliateRateEditor.d.ts.map +1 -0
- package/dist/react/workflows/AffiliateRateEditor.js +49 -0
- package/dist/react/workflows/AffiliateRateEditor.js.map +1 -0
- package/dist/react/workflows/Checkout.d.ts +3 -1
- package/dist/react/workflows/Checkout.d.ts.map +1 -1
- package/dist/react/workflows/Checkout.js +3 -2
- package/dist/react/workflows/Checkout.js.map +1 -1
- package/dist/react/workflows/CheckoutForms.d.ts +2 -0
- package/dist/react/workflows/CheckoutForms.d.ts.map +1 -1
- package/dist/react/workflows/CheckoutForms.js +22 -16
- package/dist/react/workflows/CheckoutForms.js.map +1 -1
- package/dist/react/workflows/IntegrationSwitch.js +2 -2
- package/dist/react/workflows/IntegrationSwitch.js.map +1 -1
- package/dist/react/workflows/Pagination.d.ts +32 -0
- package/dist/react/workflows/Pagination.d.ts.map +1 -0
- package/dist/react/workflows/Pagination.js +49 -0
- package/dist/react/workflows/Pagination.js.map +1 -0
- package/dist/react/workflows/Payouts.d.ts +3 -18
- package/dist/react/workflows/Payouts.d.ts.map +1 -1
- package/dist/react/workflows/Payouts.js +2 -2
- package/dist/react/workflows/Payouts.js.map +1 -1
- package/dist/react/workflows/PlanPricingEditor.d.ts +35 -0
- package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -0
- package/dist/react/workflows/PlanPricingEditor.js +64 -0
- package/dist/react/workflows/PlanPricingEditor.js.map +1 -0
- package/dist/react/workflows/index.d.ts +8 -0
- package/dist/react/workflows/index.d.ts.map +1 -1
- package/dist/react/workflows/index.js +4 -0
- package/dist/react/workflows/index.js.map +1 -1
- package/docs/react-workflows-audit.md +19 -0
- package/docs/react-workflows.md +94 -3
- package/docs/settlement.md +158 -0
- package/package.json +6 -4
- package/src/client/billing/checkout-idempotency.test.ts +82 -0
- package/src/client/billing/checkout.ts +6 -1
- package/src/client/billing/providerReads.test.ts +60 -0
- package/src/client/billing/providerReads.ts +30 -0
- package/src/client/billing/subscriptions.ts +1 -8
- package/src/client/connect/payoutSnapshot.test.ts +91 -0
- package/src/client/connect/payoutSnapshot.ts +130 -0
- package/src/client/connect/transfers.test.ts +15 -4
- package/src/client/connect/transfers.ts +13 -42
- package/src/client/core/account-idempotency.test.ts +78 -0
- package/src/client/core/accounts.ts +18 -25
- package/src/client/core/idempotency.test.ts +10 -0
- package/src/client/core/idempotency.ts +11 -0
- package/src/client/index.ts +84 -103
- package/src/client/settlement/allocation.test.ts +98 -0
- package/src/client/settlement/allocation.ts +110 -0
- package/src/client/settlement/commissions.test.ts +103 -0
- package/src/client/settlement/commissions.ts +125 -0
- package/src/client/settlement/execution.test.ts +186 -0
- package/src/client/settlement/execution.ts +134 -0
- package/src/client/settlement/reconciliation.ts +119 -0
- package/src/client/settlement/settleSale.ts +59 -0
- package/src/client/settlement/verifiedSale.test.ts +139 -0
- package/src/client/settlement/verifiedSale.ts +159 -0
- package/src/client/webhooks/asyncHooks.ts +38 -0
- package/src/client/webhooks/processors.test.ts +9 -9
- package/src/component/_generated/api.ts +4 -0
- package/src/component/_generated/component.ts +141 -0
- package/src/component/connect/queries.ts +26 -2
- package/src/component/earnings-totals.test.ts +13 -0
- package/src/component/schema.ts +8 -0
- package/src/component/settlement/mutations.ts +119 -0
- package/src/component/settlement/queries.ts +38 -0
- package/src/component/settlement/schema.ts +45 -0
- package/src/component/settlement-claims.test.ts +77 -0
- package/src/component/transfers.test.ts +48 -0
- package/src/react/components/EmbeddedDisputes.render.test.tsx +2 -2
- package/src/react/index.payment-element.test.ts +22 -37
- package/src/react/workflows/AffiliatePayoutEditor.test.tsx +90 -0
- package/src/react/workflows/AffiliatePayoutEditor.tsx +201 -0
- package/src/react/workflows/AffiliateRateEditor.test.tsx +36 -0
- package/src/react/workflows/AffiliateRateEditor.tsx +92 -0
- package/src/react/workflows/Checkout.test.tsx +30 -0
- package/src/react/workflows/Checkout.tsx +10 -3
- package/src/react/workflows/CheckoutForms.test.tsx +40 -0
- package/src/react/workflows/CheckoutForms.tsx +37 -16
- package/src/react/workflows/IntegrationSwitch.test.tsx +25 -0
- package/src/react/workflows/IntegrationSwitch.tsx +2 -2
- package/src/react/workflows/Pagination.test.tsx +53 -0
- package/src/react/workflows/Pagination.tsx +93 -0
- package/src/react/workflows/Payouts.test.tsx +33 -0
- package/src/react/workflows/Payouts.tsx +21 -15
- package/src/react/workflows/PlanPricingEditor.test.tsx +79 -0
- package/src/react/workflows/PlanPricingEditor.tsx +156 -0
- 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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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";
|