@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +19 -3
  3. package/dist/client/connect/disputes.d.ts +1 -1
  4. package/dist/client/connect/disputes.d.ts.map +1 -1
  5. package/dist/client/connect/disputes.js +12 -6
  6. package/dist/client/connect/disputes.js.map +1 -1
  7. package/dist/component/_generated/component.d.ts +35 -0
  8. package/dist/component/_generated/component.d.ts.map +1 -1
  9. package/dist/component/connect/queries.d.ts +30 -1
  10. package/dist/component/connect/queries.d.ts.map +1 -1
  11. package/dist/component/connect/queries.js +19 -2
  12. package/dist/component/connect/queries.js.map +1 -1
  13. package/dist/react/components/DisputeDetail.d.ts +4 -1
  14. package/dist/react/components/DisputeDetail.d.ts.map +1 -1
  15. package/dist/react/components/DisputeDetail.js +12 -6
  16. package/dist/react/components/DisputeDetail.js.map +1 -1
  17. package/dist/react/components/DisputesList.d.ts.map +1 -1
  18. package/dist/react/components/DisputesList.js +2 -1
  19. package/dist/react/components/DisputesList.js.map +1 -1
  20. package/dist/react/index.d.ts +1 -1
  21. package/dist/react/index.d.ts.map +1 -1
  22. package/dist/react/index.js +1 -1
  23. package/dist/react/index.js.map +1 -1
  24. package/dist/react/workflows/AffiliatePayoutEditor.d.ts +41 -0
  25. package/dist/react/workflows/AffiliatePayoutEditor.d.ts.map +1 -0
  26. package/dist/react/workflows/AffiliatePayoutEditor.js +80 -0
  27. package/dist/react/workflows/AffiliatePayoutEditor.js.map +1 -0
  28. package/dist/react/workflows/AffiliateRateEditor.d.ts +26 -0
  29. package/dist/react/workflows/AffiliateRateEditor.d.ts.map +1 -0
  30. package/dist/react/workflows/AffiliateRateEditor.js +49 -0
  31. package/dist/react/workflows/AffiliateRateEditor.js.map +1 -0
  32. package/dist/react/workflows/CheckoutForms.d.ts +2 -0
  33. package/dist/react/workflows/CheckoutForms.d.ts.map +1 -1
  34. package/dist/react/workflows/CheckoutForms.js +22 -16
  35. package/dist/react/workflows/CheckoutForms.js.map +1 -1
  36. package/dist/react/workflows/Disputes.d.ts.map +1 -1
  37. package/dist/react/workflows/Disputes.js +9 -9
  38. package/dist/react/workflows/Disputes.js.map +1 -1
  39. package/dist/react/workflows/IntegrationSwitch.js +2 -2
  40. package/dist/react/workflows/IntegrationSwitch.js.map +1 -1
  41. package/dist/react/workflows/Pagination.d.ts +32 -0
  42. package/dist/react/workflows/Pagination.d.ts.map +1 -0
  43. package/dist/react/workflows/Pagination.js +49 -0
  44. package/dist/react/workflows/Pagination.js.map +1 -0
  45. package/dist/react/workflows/Payouts.d.ts +1 -0
  46. package/dist/react/workflows/Payouts.d.ts.map +1 -1
  47. package/dist/react/workflows/Payouts.js +3 -2
  48. package/dist/react/workflows/Payouts.js.map +1 -1
  49. package/dist/react/workflows/PlanPricingEditor.d.ts +35 -0
  50. package/dist/react/workflows/PlanPricingEditor.d.ts.map +1 -0
  51. package/dist/react/workflows/PlanPricingEditor.js +64 -0
  52. package/dist/react/workflows/PlanPricingEditor.js.map +1 -0
  53. package/dist/react/workflows/index.d.ts +8 -0
  54. package/dist/react/workflows/index.d.ts.map +1 -1
  55. package/dist/react/workflows/index.js +4 -0
  56. package/dist/react/workflows/index.js.map +1 -1
  57. package/docs/react-workflows-audit.md +41 -0
  58. package/docs/react-workflows.md +92 -1
  59. package/package.json +5 -4
  60. package/src/client/connect/disputes.test.ts +15 -0
  61. package/src/client/connect/disputes.ts +13 -7
  62. package/src/component/_generated/component.ts +41 -0
  63. package/src/component/connect/queries.ts +26 -2
  64. package/src/component/earnings-totals.test.ts +13 -0
  65. package/src/component/transfers.test.ts +48 -0
  66. package/src/react/components/DisputeDetail.tsx +14 -5
  67. package/src/react/components/DisputesList.render.test.tsx +21 -0
  68. package/src/react/components/DisputesList.tsx +2 -1
  69. package/src/react/components/EvidenceForm.render.test.tsx +1 -2
  70. package/src/react/index.ts +4 -1
  71. package/src/react/workflows/AffiliatePayoutEditor.test.tsx +90 -0
  72. package/src/react/workflows/AffiliatePayoutEditor.tsx +201 -0
  73. package/src/react/workflows/AffiliateRateEditor.test.tsx +36 -0
  74. package/src/react/workflows/AffiliateRateEditor.tsx +92 -0
  75. package/src/react/workflows/CheckoutForms.test.tsx +40 -0
  76. package/src/react/workflows/CheckoutForms.tsx +37 -16
  77. package/src/react/workflows/Disputes.test.tsx +41 -2
  78. package/src/react/workflows/Disputes.tsx +49 -40
  79. package/src/react/workflows/IntegrationSwitch.test.tsx +25 -0
  80. package/src/react/workflows/IntegrationSwitch.tsx +2 -2
  81. package/src/react/workflows/Pagination.test.tsx +53 -0
  82. package/src/react/workflows/Pagination.tsx +93 -0
  83. package/src/react/workflows/Payouts.test.tsx +30 -0
  84. package/src/react/workflows/Payouts.tsx +4 -1
  85. package/src/react/workflows/PlanPricingEditor.test.tsx +79 -0
  86. package/src/react/workflows/PlanPricingEditor.tsx +156 -0
  87. 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";