@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.
- package/README.md +145 -0
- package/package.json +50 -0
- package/src/components/ManageSubscription.test.tsx +140 -0
- package/src/components/ManageSubscription.tsx +53 -0
- package/src/components/PricingDetailPage.integration.test.tsx +244 -0
- package/src/components/PricingDetailPage.test.tsx +404 -0
- package/src/components/PricingDetailPage.tsx +295 -0
- package/src/components/PricingPage.test.tsx +278 -0
- package/src/components/PricingPage.tsx +153 -0
- package/src/components/PricingSection.test.tsx +319 -0
- package/src/components/PricingSection.tsx +154 -0
- package/src/components/SubscriptionManager.test.tsx +498 -0
- package/src/components/SubscriptionManager.tsx +270 -0
- package/src/components/UpgradeModal.test.tsx +152 -0
- package/src/components/UpgradeModal.tsx +195 -0
- package/src/components/index.ts +12 -0
- package/src/hooks/BillingProvider.test.tsx +125 -0
- package/src/hooks/BillingProvider.tsx +52 -0
- package/src/hooks/index.ts +6 -0
- package/src/hooks/useCheckout.test.tsx +232 -0
- package/src/hooks/useCheckout.ts +75 -0
- package/src/hooks/usePortal.test.tsx +189 -0
- package/src/hooks/usePortal.ts +43 -0
- package/src/hooks/useProduct.test.tsx +155 -0
- package/src/hooks/useProduct.ts +43 -0
- package/src/hooks/useSubscription.test.tsx +167 -0
- package/src/hooks/useSubscription.ts +40 -0
- package/src/index.ts +47 -0
- package/src/server/index.ts +2 -0
- package/src/server/proxy.ts +89 -0
- package/src/types/index.ts +78 -0
- package/src/utils/api.test.ts +129 -0
- 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
|
+
});
|