@fantastic.dev/better-stripe 0.14.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 (43) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +34 -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/core/fees.d.ts +2 -0
  12. package/dist/client/core/fees.d.ts.map +1 -1
  13. package/dist/client/core/fees.js +7 -0
  14. package/dist/client/core/fees.js.map +1 -1
  15. package/dist/client/index.d.ts +15 -23
  16. package/dist/client/index.d.ts.map +1 -1
  17. package/dist/client/index.js +15 -13
  18. package/dist/client/index.js.map +1 -1
  19. package/dist/react/hooks/useConfirmPayment.d.ts +1 -1
  20. package/dist/react/hooks/useConfirmPayment.d.ts.map +1 -1
  21. package/dist/react/hooks/useConfirmPayment.js +13 -2
  22. package/dist/react/hooks/useConfirmPayment.js.map +1 -1
  23. package/dist/react/index.d.ts +2 -0
  24. package/dist/react/index.d.ts.map +1 -1
  25. package/dist/react/index.js +1 -0
  26. package/dist/react/index.js.map +1 -1
  27. package/dist/react/workflows/SaveCard.d.ts +27 -0
  28. package/dist/react/workflows/SaveCard.d.ts.map +1 -0
  29. package/dist/react/workflows/SaveCard.js +56 -0
  30. package/dist/react/workflows/SaveCard.js.map +1 -0
  31. package/docs/react-workflows.md +41 -0
  32. package/package.json +1 -1
  33. package/src/client/billing/checkoutOptions.ts +27 -0
  34. package/src/client/billing/intents.test.ts +216 -0
  35. package/src/client/billing/intents.ts +120 -0
  36. package/src/client/billing/intentsTransport.test.ts +94 -0
  37. package/src/client/core/fees.ts +8 -0
  38. package/src/client/index.ts +36 -39
  39. package/src/react/hooks/useConfirmPayment.test.tsx +47 -0
  40. package/src/react/hooks/useConfirmPayment.ts +16 -2
  41. package/src/react/index.ts +3 -0
  42. package/src/react/workflows/SaveCard.test.tsx +127 -0
  43. package/src/react/workflows/SaveCard.tsx +117 -0
@@ -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
+ }