@fantastic.dev/better-stripe 0.8.0 → 0.9.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/README.md +19 -3
- package/dist/client/connect/disputes.d.ts +1 -1
- package/dist/client/connect/disputes.d.ts.map +1 -1
- package/dist/client/connect/disputes.js +12 -6
- package/dist/client/connect/disputes.js.map +1 -1
- package/dist/component/_generated/component.d.ts +35 -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/react/components/DisputeDetail.d.ts +4 -1
- package/dist/react/components/DisputeDetail.d.ts.map +1 -1
- package/dist/react/components/DisputeDetail.js +12 -6
- package/dist/react/components/DisputeDetail.js.map +1 -1
- package/dist/react/components/DisputesList.d.ts.map +1 -1
- package/dist/react/components/DisputesList.js +2 -1
- package/dist/react/components/DisputesList.js.map +1 -1
- package/dist/react/index.d.ts +1 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -1
- package/dist/react/index.js.map +1 -1
- 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/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/Disputes.d.ts.map +1 -1
- package/dist/react/workflows/Disputes.js +9 -9
- package/dist/react/workflows/Disputes.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 +1 -0
- package/dist/react/workflows/Payouts.d.ts.map +1 -1
- package/dist/react/workflows/Payouts.js +3 -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 +41 -0
- package/docs/react-workflows.md +92 -1
- package/package.json +5 -4
- package/src/client/connect/disputes.test.ts +15 -0
- package/src/client/connect/disputes.ts +13 -7
- package/src/component/_generated/component.ts +41 -0
- package/src/component/connect/queries.ts +26 -2
- package/src/component/earnings-totals.test.ts +13 -0
- package/src/component/transfers.test.ts +48 -0
- package/src/react/components/DisputeDetail.tsx +14 -5
- package/src/react/components/DisputesList.render.test.tsx +21 -0
- package/src/react/components/DisputesList.tsx +2 -1
- package/src/react/components/EvidenceForm.render.test.tsx +1 -2
- package/src/react/index.ts +4 -1
- 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/CheckoutForms.test.tsx +40 -0
- package/src/react/workflows/CheckoutForms.tsx +37 -16
- package/src/react/workflows/Disputes.test.tsx +41 -2
- package/src/react/workflows/Disputes.tsx +49 -40
- 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 +30 -0
- package/src/react/workflows/Payouts.tsx +4 -1
- 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,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";
|