@startsimpli/billing 0.1.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 (33) hide show
  1. package/README.md +145 -0
  2. package/package.json +50 -0
  3. package/src/components/ManageSubscription.test.tsx +140 -0
  4. package/src/components/ManageSubscription.tsx +53 -0
  5. package/src/components/PricingDetailPage.integration.test.tsx +244 -0
  6. package/src/components/PricingDetailPage.test.tsx +404 -0
  7. package/src/components/PricingDetailPage.tsx +295 -0
  8. package/src/components/PricingPage.test.tsx +278 -0
  9. package/src/components/PricingPage.tsx +153 -0
  10. package/src/components/PricingSection.test.tsx +319 -0
  11. package/src/components/PricingSection.tsx +154 -0
  12. package/src/components/SubscriptionManager.test.tsx +498 -0
  13. package/src/components/SubscriptionManager.tsx +270 -0
  14. package/src/components/UpgradeModal.test.tsx +152 -0
  15. package/src/components/UpgradeModal.tsx +195 -0
  16. package/src/components/index.ts +12 -0
  17. package/src/hooks/BillingProvider.test.tsx +125 -0
  18. package/src/hooks/BillingProvider.tsx +52 -0
  19. package/src/hooks/index.ts +6 -0
  20. package/src/hooks/useCheckout.test.tsx +232 -0
  21. package/src/hooks/useCheckout.ts +75 -0
  22. package/src/hooks/usePortal.test.tsx +189 -0
  23. package/src/hooks/usePortal.ts +43 -0
  24. package/src/hooks/useProduct.test.tsx +155 -0
  25. package/src/hooks/useProduct.ts +43 -0
  26. package/src/hooks/useSubscription.test.tsx +167 -0
  27. package/src/hooks/useSubscription.ts +40 -0
  28. package/src/index.ts +47 -0
  29. package/src/server/index.ts +2 -0
  30. package/src/server/proxy.ts +89 -0
  31. package/src/types/index.ts +78 -0
  32. package/src/utils/api.test.ts +129 -0
  33. package/src/utils/api.ts +123 -0
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { renderHook, act, waitFor } from "@testing-library/react";
3
+ import React, { useState } from "react";
4
+ import { BillingProvider, useBillingContext } from "./BillingProvider";
5
+ import { useProduct } from "./useProduct";
6
+
7
+ describe("BillingProvider", () => {
8
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
9
+ <BillingProvider apiBaseUrl="https://api.test.com/api/v1">
10
+ {children}
11
+ </BillingProvider>
12
+ );
13
+
14
+ it("provides billing context to children", () => {
15
+ const { result } = renderHook(() => useBillingContext(), { wrapper });
16
+ expect(result.current.client).toBeDefined();
17
+ expect(result.current.config.apiBaseUrl).toBe(
18
+ "https://api.test.com/api/v1"
19
+ );
20
+ });
21
+
22
+ it("throws when used outside provider", () => {
23
+ expect(() => {
24
+ renderHook(() => useBillingContext());
25
+ }).toThrow("useBillingContext must be used within a <BillingProvider>");
26
+ });
27
+
28
+ it("passes auth token to config", () => {
29
+ const authWrapper = ({ children }: { children: React.ReactNode }) => (
30
+ <BillingProvider
31
+ apiBaseUrl="https://api.test.com/api/v1"
32
+ authToken="tok_123"
33
+ >
34
+ {children}
35
+ </BillingProvider>
36
+ );
37
+
38
+ const { result } = renderHook(() => useBillingContext(), {
39
+ wrapper: authWrapper,
40
+ });
41
+ expect(result.current.config.authToken).toBe("tok_123");
42
+ });
43
+
44
+ it("creates a new client when authToken changes", () => {
45
+ let token = "tok_first";
46
+
47
+ const DynamicWrapper = ({ children }: { children: React.ReactNode }) => (
48
+ <BillingProvider
49
+ apiBaseUrl="https://api.test.com/api/v1"
50
+ authToken={token}
51
+ >
52
+ {children}
53
+ </BillingProvider>
54
+ );
55
+
56
+ const { result, rerender } = renderHook(() => useBillingContext(), {
57
+ wrapper: DynamicWrapper,
58
+ });
59
+
60
+ const firstClient = result.current.client;
61
+ expect(result.current.config.authToken).toBe("tok_first");
62
+
63
+ // Change token and re-render
64
+ token = "tok_second";
65
+ rerender();
66
+
67
+ expect(result.current.config.authToken).toBe("tok_second");
68
+ // BillingProvider uses useMemo keyed on authToken, so client should change
69
+ expect(result.current.client).not.toBe(firstClient);
70
+ });
71
+
72
+ it("creates a new client when apiBaseUrl changes", () => {
73
+ let baseUrl = "https://api.test.com/api/v1";
74
+
75
+ const DynamicWrapper = ({ children }: { children: React.ReactNode }) => (
76
+ <BillingProvider apiBaseUrl={baseUrl}>
77
+ {children}
78
+ </BillingProvider>
79
+ );
80
+
81
+ const { result, rerender } = renderHook(() => useBillingContext(), {
82
+ wrapper: DynamicWrapper,
83
+ });
84
+
85
+ const firstClient = result.current.client;
86
+
87
+ // Change base URL and re-render
88
+ baseUrl = "https://api2.test.com/api/v1";
89
+ rerender();
90
+
91
+ expect(result.current.config.apiBaseUrl).toBe("https://api2.test.com/api/v1");
92
+ expect(result.current.client).not.toBe(firstClient);
93
+ });
94
+
95
+ it("API calls include auth token in headers", async () => {
96
+ const mockFetch = vi.fn().mockResolvedValue({
97
+ ok: true,
98
+ json: () => Promise.resolve({ slug: "test", name: "Test", description: "", id: "1", offers: [] }),
99
+ });
100
+
101
+ const authWrapper = ({ children }: { children: React.ReactNode }) => (
102
+ <BillingProvider
103
+ apiBaseUrl="https://api.test.com/api/v1"
104
+ authToken="tok_for_header_test"
105
+ fetcher={mockFetch as unknown as typeof fetch}
106
+ >
107
+ {children}
108
+ </BillingProvider>
109
+ );
110
+
111
+ // useProduct makes a GET request (public, no auth header)
112
+ // But we can verify the fetcher was injected properly
113
+ renderHook(() => useProduct("test-slug"), { wrapper: authWrapper });
114
+
115
+ await waitFor(() => {
116
+ expect(mockFetch).toHaveBeenCalled();
117
+ });
118
+
119
+ // Verify the fetch was called with the correct base URL
120
+ expect(mockFetch).toHaveBeenCalledWith(
121
+ "https://api.test.com/api/v1/billing/products/test-slug/",
122
+ expect.any(Object)
123
+ );
124
+ });
125
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * BillingProvider — React context for @simpli/billing.
3
+ *
4
+ * Wrap your app (or a section of it) with <BillingProvider> to configure
5
+ * the API connection. All billing hooks read from this context.
6
+ *
7
+ * Usage:
8
+ * <BillingProvider apiBaseUrl="https://api.startsimpli.com/api/v1" authToken={token}>
9
+ * <PricingPage productId="raise-simpli" />
10
+ * </BillingProvider>
11
+ */
12
+
13
+ import React, { createContext, useContext, useMemo } from "react";
14
+ import type { BillingConfig } from "../types";
15
+ import { BillingApiClient } from "../utils/api";
16
+
17
+ interface BillingContextValue {
18
+ client: BillingApiClient;
19
+ config: BillingConfig;
20
+ }
21
+
22
+ const BillingContext = createContext<BillingContextValue | null>(null);
23
+
24
+ export interface BillingProviderProps extends BillingConfig {
25
+ children: React.ReactNode;
26
+ }
27
+
28
+ export function BillingProvider({
29
+ children,
30
+ ...config
31
+ }: BillingProviderProps) {
32
+ const value = useMemo(() => {
33
+ const client = new BillingApiClient(config);
34
+ return { client, config };
35
+ }, [config.apiBaseUrl, config.authToken]);
36
+
37
+ return (
38
+ <BillingContext.Provider value={value}>
39
+ {children}
40
+ </BillingContext.Provider>
41
+ );
42
+ }
43
+
44
+ export function useBillingContext(): BillingContextValue {
45
+ const ctx = useContext(BillingContext);
46
+ if (!ctx) {
47
+ throw new Error(
48
+ "useBillingContext must be used within a <BillingProvider>"
49
+ );
50
+ }
51
+ return ctx;
52
+ }
@@ -0,0 +1,6 @@
1
+ export { BillingProvider, useBillingContext } from "./BillingProvider";
2
+ export type { BillingProviderProps } from "./BillingProvider";
3
+ export { useProduct } from "./useProduct";
4
+ export { useCheckout } from "./useCheckout";
5
+ export { usePortal } from "./usePortal";
6
+ export { useSubscription } from "./useSubscription";
@@ -0,0 +1,232 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { renderHook, act } from "@testing-library/react";
3
+ import React from "react";
4
+ import { BillingProvider } from "./BillingProvider";
5
+ import { useCheckout } from "./useCheckout";
6
+
7
+ function createWrapper(fetcher: typeof fetch) {
8
+ return function Wrapper({ children }: { children: React.ReactNode }) {
9
+ return (
10
+ <BillingProvider
11
+ apiBaseUrl="https://api.test.com/api/v1"
12
+ authToken="tok_123"
13
+ fetcher={fetcher}
14
+ >
15
+ {children}
16
+ </BillingProvider>
17
+ );
18
+ };
19
+ }
20
+
21
+ describe("useCheckout", () => {
22
+ it("creates checkout session and returns result", async () => {
23
+ const mockFetch = vi.fn().mockResolvedValue({
24
+ ok: true,
25
+ json: () =>
26
+ Promise.resolve({
27
+ session_id: "cs_test_123",
28
+ url: "https://checkout.stripe.com/test",
29
+ }),
30
+ });
31
+ const wrapper = createWrapper(mockFetch as unknown as typeof fetch);
32
+
33
+ const { result } = renderHook(() => useCheckout(), { wrapper });
34
+
35
+ let checkoutResult: unknown;
36
+ await act(async () => {
37
+ checkoutResult = await result.current.checkout({
38
+ offerId: "offer-1",
39
+ successUrl: "https://app.test.com/success",
40
+ cancelUrl: "https://app.test.com/cancel",
41
+ });
42
+ });
43
+
44
+ expect(checkoutResult).toEqual({
45
+ session_id: "cs_test_123",
46
+ url: "https://checkout.stripe.com/test",
47
+ });
48
+ expect(result.current.loading).toBe(false);
49
+ expect(result.current.error).toBeNull();
50
+
51
+ // Verify correct API call
52
+ expect(mockFetch).toHaveBeenCalledWith(
53
+ "https://api.test.com/api/v1/billing/offer-checkout/",
54
+ expect.objectContaining({
55
+ method: "POST",
56
+ headers: expect.objectContaining({
57
+ Authorization: "Bearer tok_123",
58
+ }),
59
+ })
60
+ );
61
+ });
62
+
63
+ it("sends correct request body", async () => {
64
+ const mockFetch = vi.fn().mockResolvedValue({
65
+ ok: true,
66
+ json: () =>
67
+ Promise.resolve({ session_id: "cs_1", url: "https://test.com" }),
68
+ });
69
+ const wrapper = createWrapper(mockFetch as unknown as typeof fetch);
70
+
71
+ const { result } = renderHook(() => useCheckout(), { wrapper });
72
+
73
+ await act(async () => {
74
+ await result.current.checkout({
75
+ offerId: "offer-abc",
76
+ successUrl: "https://success.com",
77
+ cancelUrl: "https://cancel.com",
78
+ quantity: 5,
79
+ });
80
+ });
81
+
82
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
83
+ expect(body.offer_id).toBe("offer-abc");
84
+ expect(body.success_url).toBe("https://success.com");
85
+ expect(body.cancel_url).toBe("https://cancel.com");
86
+ expect(body.quantity).toBe(5);
87
+ });
88
+
89
+ it("handles checkout error", async () => {
90
+ const mockFetch = vi.fn().mockResolvedValue({
91
+ ok: false,
92
+ status: 400,
93
+ json: () =>
94
+ Promise.resolve({
95
+ detail: "Team already has an active subscription",
96
+ }),
97
+ });
98
+ const wrapper = createWrapper(mockFetch as unknown as typeof fetch);
99
+
100
+ const { result } = renderHook(() => useCheckout(), { wrapper });
101
+
102
+ await act(async () => {
103
+ try {
104
+ await result.current.checkout({
105
+ offerId: "offer-1",
106
+ successUrl: "https://test.com/success",
107
+ cancelUrl: "https://test.com/cancel",
108
+ });
109
+ } catch {
110
+ // Expected
111
+ }
112
+ });
113
+
114
+ expect(result.current.error?.message).toBe(
115
+ "Team already has an active subscription"
116
+ );
117
+ });
118
+
119
+ it("checkout returns session URL for redirect", async () => {
120
+ const mockFetch = vi.fn().mockResolvedValue({
121
+ ok: true,
122
+ json: () =>
123
+ Promise.resolve({
124
+ session_id: "cs_redirect_1",
125
+ url: "https://checkout.stripe.com/pay/cs_redirect_1",
126
+ }),
127
+ });
128
+ const wrapper = createWrapper(mockFetch as unknown as typeof fetch);
129
+
130
+ const { result } = renderHook(() => useCheckout(), { wrapper });
131
+
132
+ let checkoutResult: unknown;
133
+ await act(async () => {
134
+ checkoutResult = await result.current.checkout({
135
+ offerId: "offer-pro",
136
+ successUrl: "https://app.test.com/success",
137
+ cancelUrl: "https://app.test.com/cancel",
138
+ });
139
+ });
140
+
141
+ expect(
142
+ (checkoutResult as { url: string }).url
143
+ ).toBe("https://checkout.stripe.com/pay/cs_redirect_1");
144
+ });
145
+
146
+ describe("subscribeFree", () => {
147
+ it("calls POST with offer_id and returns subscription result", async () => {
148
+ const mockFetch = vi.fn().mockResolvedValue({
149
+ ok: true,
150
+ json: () =>
151
+ Promise.resolve({
152
+ subscription_id: "sub_free_123",
153
+ status: "active",
154
+ }),
155
+ });
156
+ const wrapper = createWrapper(mockFetch as unknown as typeof fetch);
157
+
158
+ const { result } = renderHook(() => useCheckout(), { wrapper });
159
+
160
+ let freeResult: unknown;
161
+ await act(async () => {
162
+ freeResult = await result.current.subscribeFree("offer-free-1");
163
+ });
164
+
165
+ expect(freeResult).toEqual({
166
+ subscription_id: "sub_free_123",
167
+ status: "active",
168
+ });
169
+ expect(result.current.loading).toBe(false);
170
+ expect(result.current.error).toBeNull();
171
+
172
+ // Verify correct API call
173
+ expect(mockFetch).toHaveBeenCalledWith(
174
+ "https://api.test.com/api/v1/billing/subscribe-free/",
175
+ expect.objectContaining({
176
+ method: "POST",
177
+ headers: expect.objectContaining({
178
+ Authorization: "Bearer tok_123",
179
+ }),
180
+ })
181
+ );
182
+
183
+ // Verify request body
184
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
185
+ expect(body.offer_id).toBe("offer-free-1");
186
+ });
187
+
188
+ it("handles subscribeFree error", async () => {
189
+ const mockFetch = vi.fn().mockResolvedValue({
190
+ ok: false,
191
+ status: 400,
192
+ json: () =>
193
+ Promise.resolve({
194
+ detail: "Offer is not a free offer",
195
+ }),
196
+ });
197
+ const wrapper = createWrapper(mockFetch as unknown as typeof fetch);
198
+
199
+ const { result } = renderHook(() => useCheckout(), { wrapper });
200
+
201
+ await act(async () => {
202
+ try {
203
+ await result.current.subscribeFree("offer-paid");
204
+ } catch {
205
+ // Expected
206
+ }
207
+ });
208
+
209
+ expect(result.current.error?.message).toBe("Offer is not a free offer");
210
+ });
211
+
212
+ it("handles subscribeFree network error", async () => {
213
+ const mockFetch = vi
214
+ .fn()
215
+ .mockRejectedValue(new Error("Network timeout"));
216
+ const wrapper = createWrapper(mockFetch as unknown as typeof fetch);
217
+
218
+ const { result } = renderHook(() => useCheckout(), { wrapper });
219
+
220
+ await act(async () => {
221
+ try {
222
+ await result.current.subscribeFree("offer-free-1");
223
+ } catch {
224
+ // Expected
225
+ }
226
+ });
227
+
228
+ expect(result.current.error?.message).toBe("Network timeout");
229
+ expect(result.current.loading).toBe(false);
230
+ });
231
+ });
232
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * useCheckout — Create a checkout session and redirect, or subscribe to a free offer.
3
+ *
4
+ * Usage:
5
+ * const { checkout, subscribeFree, loading, error } = useCheckout();
6
+ * // For paid offers:
7
+ * await checkout({ offerId, successUrl, cancelUrl });
8
+ * // For free offers:
9
+ * await subscribeFree(offerId);
10
+ */
11
+
12
+ import { useCallback, useState } from "react";
13
+ import type { CheckoutResult, FreeSubscriptionResult } from "../types";
14
+ import { useBillingContext } from "./BillingProvider";
15
+
16
+ interface UseCheckoutResult {
17
+ checkout: (params: {
18
+ offerId: string;
19
+ successUrl: string;
20
+ cancelUrl: string;
21
+ quantity?: number;
22
+ }) => Promise<CheckoutResult>;
23
+ subscribeFree: (offerId: string) => Promise<FreeSubscriptionResult>;
24
+ loading: boolean;
25
+ error: Error | null;
26
+ }
27
+
28
+ export function useCheckout(): UseCheckoutResult {
29
+ const { client } = useBillingContext();
30
+ const [loading, setLoading] = useState(false);
31
+ const [error, setError] = useState<Error | null>(null);
32
+
33
+ const checkout = useCallback(
34
+ async (params: {
35
+ offerId: string;
36
+ successUrl: string;
37
+ cancelUrl: string;
38
+ quantity?: number;
39
+ }) => {
40
+ setLoading(true);
41
+ setError(null);
42
+ try {
43
+ const result = await client.createCheckout(params);
44
+ return result;
45
+ } catch (e) {
46
+ const err = e instanceof Error ? e : new Error(String(e));
47
+ setError(err);
48
+ throw err;
49
+ } finally {
50
+ setLoading(false);
51
+ }
52
+ },
53
+ [client]
54
+ );
55
+
56
+ const subscribeFree = useCallback(
57
+ async (offerId: string) => {
58
+ setLoading(true);
59
+ setError(null);
60
+ try {
61
+ const result = await client.subscribeFree(offerId);
62
+ return result;
63
+ } catch (e) {
64
+ const err = e instanceof Error ? e : new Error(String(e));
65
+ setError(err);
66
+ throw err;
67
+ } finally {
68
+ setLoading(false);
69
+ }
70
+ },
71
+ [client]
72
+ );
73
+
74
+ return { checkout, subscribeFree, loading, error };
75
+ }
@@ -0,0 +1,189 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { renderHook, act, waitFor } from "@testing-library/react";
3
+ import React from "react";
4
+ import { BillingProvider } from "./BillingProvider";
5
+ import { usePortal } from "./usePortal";
6
+
7
+ function createWrapper(fetcher: typeof fetch) {
8
+ return function Wrapper({ children }: { children: React.ReactNode }) {
9
+ return (
10
+ <BillingProvider
11
+ apiBaseUrl="https://api.test.com/api/v1"
12
+ authToken="tok_123"
13
+ fetcher={fetcher}
14
+ >
15
+ {children}
16
+ </BillingProvider>
17
+ );
18
+ };
19
+ }
20
+
21
+ describe("usePortal", () => {
22
+ it("creates portal session and returns URL", async () => {
23
+ const mockFetch = vi.fn().mockResolvedValue({
24
+ ok: true,
25
+ json: () =>
26
+ Promise.resolve({
27
+ url: "https://billing.stripe.com/portal/test",
28
+ }),
29
+ });
30
+ const wrapper = createWrapper(mockFetch as unknown as typeof fetch);
31
+
32
+ const { result } = renderHook(() => usePortal(), { wrapper });
33
+
34
+ let portalResult: unknown;
35
+ await act(async () => {
36
+ portalResult = await result.current.openPortal(
37
+ "https://app.test.com/settings"
38
+ );
39
+ });
40
+
41
+ expect(portalResult).toEqual({
42
+ url: "https://billing.stripe.com/portal/test",
43
+ });
44
+ expect(result.current.loading).toBe(false);
45
+ expect(result.current.error).toBeNull();
46
+ });
47
+
48
+ it("sends auth header and correct body", async () => {
49
+ const mockFetch = vi.fn().mockResolvedValue({
50
+ ok: true,
51
+ json: () => Promise.resolve({ url: "https://portal.test.com" }),
52
+ });
53
+ const wrapper = createWrapper(mockFetch as unknown as typeof fetch);
54
+
55
+ const { result } = renderHook(() => usePortal(), { wrapper });
56
+
57
+ await act(async () => {
58
+ await result.current.openPortal("https://app.test.com/settings");
59
+ });
60
+
61
+ expect(mockFetch).toHaveBeenCalledWith(
62
+ "https://api.test.com/api/v1/billing/offer-portal/",
63
+ expect.objectContaining({
64
+ method: "POST",
65
+ headers: expect.objectContaining({
66
+ Authorization: "Bearer tok_123",
67
+ }),
68
+ })
69
+ );
70
+
71
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
72
+ expect(body.return_url).toBe("https://app.test.com/settings");
73
+ });
74
+
75
+ it("handles portal error", async () => {
76
+ const mockFetch = vi.fn().mockResolvedValue({
77
+ ok: false,
78
+ status: 404,
79
+ json: () =>
80
+ Promise.resolve({ detail: "No active subscription found" }),
81
+ });
82
+ const wrapper = createWrapper(mockFetch as unknown as typeof fetch);
83
+
84
+ const { result } = renderHook(() => usePortal(), { wrapper });
85
+
86
+ await act(async () => {
87
+ try {
88
+ await result.current.openPortal("https://app.test.com/settings");
89
+ } catch {
90
+ // Expected
91
+ }
92
+ });
93
+
94
+ expect(result.current.error?.message).toBe(
95
+ "No active subscription found"
96
+ );
97
+ });
98
+
99
+ it("shows loading state during portal creation", async () => {
100
+ let resolveFetch!: (value: unknown) => void;
101
+ const fetchPromise = new Promise((resolve) => {
102
+ resolveFetch = resolve;
103
+ });
104
+ const mockFetch = vi.fn().mockReturnValue(fetchPromise);
105
+ const wrapper = createWrapper(mockFetch as unknown as typeof fetch);
106
+
107
+ const { result } = renderHook(() => usePortal(), { wrapper });
108
+
109
+ // Initially not loading
110
+ expect(result.current.loading).toBe(false);
111
+
112
+ // Start portal creation (don't await)
113
+ let portalPromise: Promise<unknown>;
114
+ act(() => {
115
+ portalPromise = result.current.openPortal("https://app.test.com/settings");
116
+ });
117
+
118
+ // Should be loading now
119
+ expect(result.current.loading).toBe(true);
120
+
121
+ // Resolve the fetch
122
+ await act(async () => {
123
+ resolveFetch({
124
+ ok: true,
125
+ json: () => Promise.resolve({ url: "https://portal.stripe.com/test" }),
126
+ });
127
+ await portalPromise;
128
+ });
129
+
130
+ // Loading should be false after resolution
131
+ expect(result.current.loading).toBe(false);
132
+ });
133
+
134
+ it("handles network timeout error", async () => {
135
+ const mockFetch = vi
136
+ .fn()
137
+ .mockRejectedValue(new Error("Request timed out"));
138
+ const wrapper = createWrapper(mockFetch as unknown as typeof fetch);
139
+
140
+ const { result } = renderHook(() => usePortal(), { wrapper });
141
+
142
+ await act(async () => {
143
+ try {
144
+ await result.current.openPortal("https://app.test.com/settings");
145
+ } catch {
146
+ // Expected
147
+ }
148
+ });
149
+
150
+ expect(result.current.error?.message).toBe("Request timed out");
151
+ expect(result.current.loading).toBe(false);
152
+ });
153
+
154
+ it("clears previous error on new openPortal call", async () => {
155
+ // First call: error
156
+ const mockFetch = vi
157
+ .fn()
158
+ .mockResolvedValueOnce({
159
+ ok: false,
160
+ status: 500,
161
+ json: () => Promise.resolve({ detail: "Server error" }),
162
+ })
163
+ .mockResolvedValueOnce({
164
+ ok: true,
165
+ json: () => Promise.resolve({ url: "https://portal.stripe.com/ok" }),
166
+ });
167
+ const wrapper = createWrapper(mockFetch as unknown as typeof fetch);
168
+
169
+ const { result } = renderHook(() => usePortal(), { wrapper });
170
+
171
+ // First call fails
172
+ await act(async () => {
173
+ try {
174
+ await result.current.openPortal("https://app.test.com/settings");
175
+ } catch {
176
+ // Expected
177
+ }
178
+ });
179
+
180
+ expect(result.current.error?.message).toBe("Server error");
181
+
182
+ // Second call succeeds - error should be cleared
183
+ await act(async () => {
184
+ await result.current.openPortal("https://app.test.com/settings");
185
+ });
186
+
187
+ expect(result.current.error).toBeNull();
188
+ });
189
+ });