@fantastic.dev/better-stripe 0.13.0 → 0.15.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 (111) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +63 -1
  3. package/dist/client/billing/checkoutOptions.d.ts +27 -0
  4. package/dist/client/billing/checkoutOptions.d.ts.map +1 -0
  5. package/dist/client/billing/checkoutOptions.js +2 -0
  6. package/dist/client/billing/checkoutOptions.js.map +1 -0
  7. package/dist/client/billing/intents.d.ts +32 -0
  8. package/dist/client/billing/intents.d.ts.map +1 -0
  9. package/dist/client/billing/intents.js +71 -0
  10. package/dist/client/billing/intents.js.map +1 -0
  11. package/dist/client/connect/disputes.d.ts +20 -0
  12. package/dist/client/connect/disputes.d.ts.map +1 -1
  13. package/dist/client/connect/disputes.js +55 -0
  14. package/dist/client/connect/disputes.js.map +1 -1
  15. package/dist/client/core/fees.d.ts +2 -0
  16. package/dist/client/core/fees.d.ts.map +1 -1
  17. package/dist/client/core/fees.js +7 -0
  18. package/dist/client/core/fees.js.map +1 -1
  19. package/dist/client/costRecovery-invoices.d.ts +10 -4
  20. package/dist/client/costRecovery-invoices.d.ts.map +1 -1
  21. package/dist/client/costRecovery-invoices.js +4 -0
  22. package/dist/client/costRecovery-invoices.js.map +1 -1
  23. package/dist/client/costRecovery.d.ts +18 -2
  24. package/dist/client/costRecovery.d.ts.map +1 -1
  25. package/dist/client/costRecovery.js +4 -0
  26. package/dist/client/costRecovery.js.map +1 -1
  27. package/dist/client/errors.d.ts +1 -1
  28. package/dist/client/errors.d.ts.map +1 -1
  29. package/dist/client/errors.js.map +1 -1
  30. package/dist/client/index.d.ts +20 -37
  31. package/dist/client/index.d.ts.map +1 -1
  32. package/dist/client/index.js +18 -20
  33. package/dist/client/index.js.map +1 -1
  34. package/dist/component/_generated/component.d.ts +59 -0
  35. package/dist/component/_generated/component.d.ts.map +1 -1
  36. package/dist/component/costRecovery/claims.d.ts +12 -0
  37. package/dist/component/costRecovery/claims.d.ts.map +1 -1
  38. package/dist/component/costRecovery/imports.d.ts +53 -0
  39. package/dist/component/costRecovery/imports.d.ts.map +1 -0
  40. package/dist/component/costRecovery/imports.js +67 -0
  41. package/dist/component/costRecovery/imports.js.map +1 -0
  42. package/dist/component/costRecovery/mutations.d.ts +47 -0
  43. package/dist/component/costRecovery/mutations.d.ts.map +1 -1
  44. package/dist/component/costRecovery/mutations.js +7 -1
  45. package/dist/component/costRecovery/mutations.js.map +1 -1
  46. package/dist/component/costRecovery/queries.d.ts +12 -0
  47. package/dist/component/costRecovery/queries.d.ts.map +1 -1
  48. package/dist/component/costRecovery/schema.d.ts +72 -2
  49. package/dist/component/costRecovery/schema.d.ts.map +1 -1
  50. package/dist/component/costRecovery/schema.js +13 -0
  51. package/dist/component/costRecovery/schema.js.map +1 -1
  52. package/dist/component/schema.d.ts +19 -1
  53. package/dist/react/hooks/useConfirmPayment.d.ts +1 -1
  54. package/dist/react/hooks/useConfirmPayment.d.ts.map +1 -1
  55. package/dist/react/hooks/useConfirmPayment.js +13 -2
  56. package/dist/react/hooks/useConfirmPayment.js.map +1 -1
  57. package/dist/react/index.d.ts +2 -0
  58. package/dist/react/index.d.ts.map +1 -1
  59. package/dist/react/index.js +1 -0
  60. package/dist/react/index.js.map +1 -1
  61. package/dist/react/workflows/EvidenceUpload.d.ts +18 -0
  62. package/dist/react/workflows/EvidenceUpload.d.ts.map +1 -0
  63. package/dist/react/workflows/EvidenceUpload.js +35 -0
  64. package/dist/react/workflows/EvidenceUpload.js.map +1 -0
  65. package/dist/react/workflows/Onboarding.d.ts +3 -3
  66. package/dist/react/workflows/Onboarding.d.ts.map +1 -1
  67. package/dist/react/workflows/Onboarding.js +8 -6
  68. package/dist/react/workflows/Onboarding.js.map +1 -1
  69. package/dist/react/workflows/SaveCard.d.ts +27 -0
  70. package/dist/react/workflows/SaveCard.d.ts.map +1 -0
  71. package/dist/react/workflows/SaveCard.js +56 -0
  72. package/dist/react/workflows/SaveCard.js.map +1 -0
  73. package/dist/react/workflows/index.d.ts +2 -0
  74. package/dist/react/workflows/index.d.ts.map +1 -1
  75. package/dist/react/workflows/index.js +1 -0
  76. package/dist/react/workflows/index.js.map +1 -1
  77. package/dist/react/workflows/useWorkflowAction.d.ts +1 -1
  78. package/dist/react/workflows/useWorkflowAction.d.ts.map +1 -1
  79. package/dist/react/workflows/useWorkflowAction.js +6 -2
  80. package/dist/react/workflows/useWorkflowAction.js.map +1 -1
  81. package/docs/react-workflows.md +83 -0
  82. package/package.json +1 -1
  83. package/src/client/billing/checkoutOptions.ts +27 -0
  84. package/src/client/billing/intents.test.ts +216 -0
  85. package/src/client/billing/intents.ts +120 -0
  86. package/src/client/billing/intentsTransport.test.ts +94 -0
  87. package/src/client/connect/dispute-upload.test.ts +208 -0
  88. package/src/client/connect/disputes.ts +103 -0
  89. package/src/client/core/fees.ts +8 -0
  90. package/src/client/costRecovery-import.test.ts +174 -0
  91. package/src/client/costRecovery-invoices.test.ts +69 -0
  92. package/src/client/costRecovery-invoices.ts +32 -0
  93. package/src/client/costRecovery.ts +16 -0
  94. package/src/client/errors.ts +1 -0
  95. package/src/client/index.ts +50 -52
  96. package/src/component/_generated/api.ts +2 -0
  97. package/src/component/_generated/component.ts +65 -0
  98. package/src/component/costRecovery/imports.ts +74 -0
  99. package/src/component/costRecovery/mutations.ts +8 -0
  100. package/src/component/costRecovery/schema.ts +13 -0
  101. package/src/react/hooks/useConfirmPayment.test.tsx +47 -0
  102. package/src/react/hooks/useConfirmPayment.ts +16 -2
  103. package/src/react/index.ts +3 -0
  104. package/src/react/workflows/EvidenceUpload.test.tsx +56 -0
  105. package/src/react/workflows/EvidenceUpload.tsx +71 -0
  106. package/src/react/workflows/Onboarding.test.tsx +73 -0
  107. package/src/react/workflows/Onboarding.tsx +8 -5
  108. package/src/react/workflows/SaveCard.test.tsx +127 -0
  109. package/src/react/workflows/SaveCard.tsx +117 -0
  110. package/src/react/workflows/index.ts +3 -0
  111. package/src/react/workflows/useWorkflowAction.ts +6 -1
@@ -73,3 +73,76 @@ it("offers the shared example countries in the native onboarding UI", async () =
73
73
  );
74
74
  expect(screen.getByRole("option", { name: "United Kingdom" })).toBeTruthy();
75
75
  });
76
+
77
+ for (const flow of ["create", "continue"] as const) {
78
+ for (const transition of ["unmount", "scope", "current"] as const) {
79
+ it(`${flow} navigates only while current after ${transition}`, async () => {
80
+ let resolve!: (value: { url: string; onboardingUrl: string }) => void;
81
+ const response = new Promise<{ url: string; onboardingUrl: string }>(
82
+ (done) => {
83
+ resolve = done;
84
+ },
85
+ );
86
+ const navigate = vi.fn();
87
+ const { result, rerender, unmount } = renderHook(
88
+ ({ scope }) =>
89
+ useOnboarding({
90
+ scopeKey: scope,
91
+ createAccount: () => response,
92
+ continueOnboarding: () => response,
93
+ onNavigate: navigate,
94
+ }),
95
+ { initialProps: { scope: "seller-a" } },
96
+ );
97
+ let pending!: Promise<unknown>;
98
+ act(() => {
99
+ pending = result.current[flow]();
100
+ });
101
+ if (transition === "unmount") unmount();
102
+ if (transition === "scope") rerender({ scope: "seller-b" });
103
+ await act(async () => {
104
+ resolve({
105
+ url: "https://connect.stripe.com/setup",
106
+ onboardingUrl: "https://connect.stripe.com/setup",
107
+ });
108
+ await pending;
109
+ });
110
+ expect(navigate).toHaveBeenCalledTimes(transition === "current" ? 1 : 0);
111
+ });
112
+ }
113
+ }
114
+
115
+ for (const flow of ["create", "continue"] as const) {
116
+ it(`${flow} suppresses the default location assignment after unmount`, async () => {
117
+ let resolve!: (value: { url: string; onboardingUrl: string }) => void;
118
+ const response = new Promise<{ url: string; onboardingUrl: string }>(
119
+ (done) => {
120
+ resolve = done;
121
+ },
122
+ );
123
+ const assign = vi.fn();
124
+ const { result, unmount } = renderHook(() =>
125
+ useOnboarding({
126
+ scopeKey: "seller",
127
+ createAccount: () => response,
128
+ continueOnboarding: () => response,
129
+ }),
130
+ );
131
+ let pending!: Promise<unknown>;
132
+ act(() => {
133
+ pending = result.current[flow]();
134
+ });
135
+ unmount();
136
+ vi.stubGlobal("window", { location: { assign } });
137
+ try {
138
+ resolve({
139
+ url: "https://connect.stripe.com/setup",
140
+ onboardingUrl: "https://connect.stripe.com/setup",
141
+ });
142
+ await pending;
143
+ expect(assign).not.toHaveBeenCalled();
144
+ } finally {
145
+ vi.unstubAllGlobals();
146
+ }
147
+ });
148
+ }
@@ -51,10 +51,13 @@ export function useOnboarding(props: OnboardingActions) {
51
51
  ? result.url
52
52
  : undefined;
53
53
  if (!url) throw new Error("Stripe did not return an onboarding URL.");
54
- (props.onNavigate ?? ((url) => window.location.assign(url)))(url);
55
- return true;
54
+ return url;
56
55
  },
57
56
  props.scopeKey,
57
+ (url) => {
58
+ if (typeof url === "string")
59
+ (props.onNavigate ?? ((url) => window.location.assign(url)))(url);
60
+ },
58
61
  );
59
62
  const run = action.run;
60
63
  return {
@@ -69,9 +72,9 @@ export function useOnboarding(props: OnboardingActions) {
69
72
  createError: operation === "create" ? action.error : null,
70
73
  linkError: operation === "link" ? action.error : null,
71
74
  restartError: operation === "close" ? action.error : null,
72
- create: () => run("create"),
73
- continue: () => run("link"),
74
- close: () => run("close"),
75
+ create: async () => ((await run("create")) ? true : undefined),
76
+ continue: async () => ((await run("link")) ? true : undefined),
77
+ close: async () => ((await run("close")) ? true : undefined),
75
78
  };
76
79
  }
77
80
  export type OnboardingAccount = {
@@ -0,0 +1,127 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { afterEach, expect, it, vi } from "vitest";
5
+ const confirm = vi.hoisted(() => vi.fn());
6
+ vi.mock("../components/StripeProvider.js", () => ({
7
+ StripeProvider: ({ children }: { children: React.ReactNode }) => children,
8
+ }));
9
+ vi.mock("@stripe/react-stripe-js", () => ({
10
+ PaymentElement: () => <div>Card details</div>,
11
+ }));
12
+ vi.mock("../hooks/useConfirmPayment.js", () => ({
13
+ useConfirmPayment: () => ({
14
+ confirmSetup: confirm,
15
+ isReady: true,
16
+ hasElements: true,
17
+ }),
18
+ }));
19
+ import { SaveCard } from "./SaveCard.js";
20
+ afterEach(() => {
21
+ cleanup();
22
+ vi.clearAllMocks();
23
+ });
24
+ it("retries provisioning with the same logical request and requires explicit consent before confirmation", async () => {
25
+ const create = vi
26
+ .fn()
27
+ .mockRejectedValueOnce(new Error("offline"))
28
+ .mockResolvedValue({ setupIntentId: "seti_one", clientSecret: "secret" });
29
+ const success = vi.fn();
30
+ confirm.mockResolvedValue({ success: true });
31
+ render(
32
+ <SaveCard
33
+ publishableKey="pk_test"
34
+ scopeKey="buyer"
35
+ requestId="save-one"
36
+ returnUrl="https://app.test/cards"
37
+ createSetupIntent={create}
38
+ onSuccess={success}
39
+ />,
40
+ );
41
+ await screen.findByText("offline");
42
+ await userEvent.click(screen.getByRole("button", { name: "Retry" }));
43
+ await screen.findByText("Card details");
44
+ expect(create.mock.calls).toEqual([
45
+ [{ requestId: "save-one" }],
46
+ [{ requestId: "save-one" }],
47
+ ]);
48
+ await userEvent.click(screen.getByRole("button", { name: "Save card" }));
49
+ expect(confirm).not.toHaveBeenCalled();
50
+ await userEvent.click(screen.getByRole("checkbox"));
51
+ await userEvent.click(screen.getByRole("button", { name: "Save card" }));
52
+ await waitFor(() => expect(success).toHaveBeenCalledWith("seti_one"));
53
+ expect(confirm).toHaveBeenCalledWith("https://app.test/cards", "always");
54
+ });
55
+ it("keeps failed authentication retryable and does not report completion", async () => {
56
+ confirm
57
+ .mockResolvedValueOnce({ success: false, error: "Authentication failed" })
58
+ .mockResolvedValueOnce({ success: true });
59
+ const success = vi.fn();
60
+ render(
61
+ <SaveCard
62
+ publishableKey="pk_test"
63
+ scopeKey="buyer"
64
+ returnUrl="https://app.test/cards"
65
+ createSetupIntent={async () => ({
66
+ setupIntentId: "seti_one",
67
+ clientSecret: "secret",
68
+ })}
69
+ onSuccess={success}
70
+ />,
71
+ );
72
+ await screen.findByText("Card details");
73
+ await userEvent.click(screen.getByRole("checkbox"));
74
+ await userEvent.click(screen.getByRole("button", { name: "Save card" }));
75
+ await screen.findByRole("alert");
76
+ expect(success).not.toHaveBeenCalled();
77
+ await userEvent.click(screen.getByRole("button", { name: "Save card" }));
78
+ await waitFor(() => expect(success).toHaveBeenCalledOnce());
79
+ });
80
+ it("does not confirm the same completed setup twice", async () => {
81
+ confirm.mockResolvedValue({ success: true });
82
+ const success = vi.fn();
83
+ render(
84
+ <SaveCard
85
+ publishableKey="pk_test"
86
+ scopeKey="buyer"
87
+ returnUrl="https://app.test/cards"
88
+ createSetupIntent={async () => ({
89
+ setupIntentId: "seti_one",
90
+ clientSecret: "secret",
91
+ })}
92
+ onSuccess={success}
93
+ />,
94
+ );
95
+ await screen.findByText("Card details");
96
+ await userEvent.click(screen.getByRole("checkbox"));
97
+ await userEvent.click(screen.getByRole("button", { name: "Save card" }));
98
+ await waitFor(() => expect(success).toHaveBeenCalledOnce());
99
+ await userEvent.click(screen.getByRole("button", { name: "Save card" }));
100
+ expect(confirm).toHaveBeenCalledOnce();
101
+ });
102
+ it("blocks repeat confirmation through a custom render callback", async () => {
103
+ confirm.mockResolvedValue({ success: true });
104
+ const success = vi.fn();
105
+ let submit: (() => Promise<void>) | undefined;
106
+ render(
107
+ <SaveCard
108
+ publishableKey="pk_test"
109
+ scopeKey="buyer"
110
+ returnUrl="https://app.test/cards"
111
+ createSetupIntent={async () => ({
112
+ setupIntentId: "seti_one",
113
+ clientSecret: "secret",
114
+ })}
115
+ onSuccess={success}
116
+ renderForm={(state) => {
117
+ submit = state.submit;
118
+ return <button onClick={() => state.setConsent(true)}>Consent</button>;
119
+ }}
120
+ />,
121
+ );
122
+ await userEvent.click(await screen.findByRole("button", { name: "Consent" }));
123
+ await submit!();
124
+ await submit!();
125
+ expect(confirm).toHaveBeenCalledOnce();
126
+ expect(success).toHaveBeenCalledOnce();
127
+ });
@@ -0,0 +1,117 @@
1
+ "use client";
2
+ import { useRef, useState, type ReactNode } from "react";
3
+ import { PaymentElement } from "@stripe/react-stripe-js";
4
+ import {
5
+ StripeProvider,
6
+ type StripeProviderProps,
7
+ } from "../components/StripeProvider.js";
8
+ import { useConfirmPayment } from "../hooks/useConfirmPayment.js";
9
+ import { useWorkflowResource } from "./useWorkflowResource.js";
10
+ import { useWorkflowAction } from "./useWorkflowAction.js";
11
+ export type SaveCardFormState = {
12
+ paymentElement: ReactNode;
13
+ consent: boolean;
14
+ setConsent: (value: boolean) => void;
15
+ submit: () => Promise<void>;
16
+ isPending: boolean;
17
+ error: string | null;
18
+ };
19
+ export type SaveCardProps = Pick<
20
+ StripeProviderProps,
21
+ "publishableKey" | "appearance"
22
+ > & {
23
+ scopeKey: string;
24
+ requestId?: string;
25
+ returnUrl: string;
26
+ /** Authenticated callback authorizes customer, records usage consent and durable request identity. */
27
+ createSetupIntent: (request: {
28
+ requestId: string;
29
+ }) => Promise<{ setupIntentId: string; clientSecret: string }>;
30
+ onSuccess?: (setupIntentId: string) => void;
31
+ renderForm?: (state: SaveCardFormState) => ReactNode;
32
+ };
33
+ /** Unstyled card setup. Host owns usage disclosures and authorization. */
34
+ export function SaveCard(props: SaveCardProps) {
35
+ return (
36
+ <SaveCardBody
37
+ key={`${props.scopeKey}:${props.requestId ?? ""}`}
38
+ {...props}
39
+ />
40
+ );
41
+ }
42
+ function SaveCardBody(props: SaveCardProps) {
43
+ const [requestId] = useState(() => props.requestId ?? crypto.randomUUID());
44
+ const state = useWorkflowResource(() =>
45
+ props.createSetupIntent({ requestId }),
46
+ );
47
+ if (state.error)
48
+ return (
49
+ <div role="alert">
50
+ {state.error}
51
+ <button type="button" onClick={state.refresh}>
52
+ Retry
53
+ </button>
54
+ </div>
55
+ );
56
+ if (!state.data || state.isLoading)
57
+ return <p role="status">Preparing card setup…</p>;
58
+ return (
59
+ <StripeProvider
60
+ publishableKey={props.publishableKey}
61
+ appearance={props.appearance}
62
+ elementsOptions={{ clientSecret: state.data.clientSecret }}
63
+ >
64
+ <SaveCardForm {...props} setupIntentId={state.data.setupIntentId} />
65
+ </StripeProvider>
66
+ );
67
+ }
68
+ function SaveCardForm(props: SaveCardProps & { setupIntentId: string }) {
69
+ const { confirmSetup, isReady, hasElements } = useConfirmPayment();
70
+ const [consent, setConsent] = useState(false);
71
+ const completed = useRef(false);
72
+ const action = useWorkflowAction(async () => {
73
+ if (completed.current) return false;
74
+ if (!consent) throw new Error("Consent is required to save this card.");
75
+ const result = await confirmSetup(props.returnUrl, "always");
76
+ if (!result.success) throw new Error(result.error);
77
+ completed.current = true;
78
+ return true;
79
+ });
80
+ const state: SaveCardFormState = {
81
+ paymentElement: <PaymentElement />,
82
+ consent,
83
+ setConsent,
84
+ submit: async () => {
85
+ const saved = await action.run();
86
+ if (saved) props.onSuccess?.(props.setupIntentId);
87
+ },
88
+ isPending:
89
+ action.isPending ||
90
+ !isReady ||
91
+ !hasElements ||
92
+ action.result !== undefined,
93
+ error: action.error,
94
+ };
95
+ if (props.renderForm) return <>{props.renderForm(state)}</>;
96
+ return (
97
+ <div>
98
+ {state.paymentElement}
99
+ <label>
100
+ <input
101
+ type="checkbox"
102
+ checked={consent}
103
+ onChange={(e) => setConsent(e.target.checked)}
104
+ />
105
+ Save this card for future payments and show it at checkout.
106
+ </label>
107
+ {state.error && <p role="alert">{state.error}</p>}
108
+ <button
109
+ type="button"
110
+ disabled={!consent || state.isPending}
111
+ onClick={() => void state.submit()}
112
+ >
113
+ Save card
114
+ </button>
115
+ </div>
116
+ );
117
+ }
@@ -265,3 +265,6 @@ export type {
265
265
  AffiliatePayoutEditorState,
266
266
  AffiliatePayoutUpdate,
267
267
  } from "./AffiliatePayoutEditor.js";
268
+
269
+ export { EvidenceUpload } from "./EvidenceUpload.js";
270
+ export type { EvidenceUploadProps } from "./EvidenceUpload.js";
@@ -12,6 +12,7 @@ export type WorkflowActionState<T, Args extends unknown[]> = {
12
12
  export function useWorkflowAction<T, Args extends unknown[]>(
13
13
  action: (...args: Args) => Promise<T>,
14
14
  scope: string = "default",
15
+ onSuccess?: (result: T) => void,
15
16
  ): WorkflowActionState<T, Args> {
16
17
  const initial = {
17
18
  scope,
@@ -22,11 +23,13 @@ export function useWorkflowAction<T, Args extends unknown[]>(
22
23
  const [state, setState] = useState(initial);
23
24
  if (state.scope !== scope) setState(initial);
24
25
  const callback = useRef(action);
26
+ const success = useRef(onSuccess);
25
27
  const lifecycle = useRef({ version: 0, mounted: false });
26
28
  const lock = useRef<number | null>(null);
27
29
  useEffect(() => {
28
30
  callback.current = action;
29
- }, [action]);
31
+ success.current = onSuccess;
32
+ }, [action, onSuccess]);
30
33
  useEffect(() => {
31
34
  const version = ++lifecycle.current.version;
32
35
  lifecycle.current.mounted = true;
@@ -51,9 +54,11 @@ export function useWorkflowAction<T, Args extends unknown[]>(
51
54
  setState({ scope, isPending: true, error: null, result: undefined });
52
55
  const current = () =>
53
56
  lifecycle.current.mounted && lifecycle.current.version === version;
57
+ const complete = success.current;
54
58
  try {
55
59
  const result = await callback.current(...args);
56
60
  if (!current()) return undefined;
61
+ complete?.(result);
57
62
  setState({ scope, isPending: false, error: null, result });
58
63
  return result;
59
64
  } catch (error) {