@startsimpli/billing 0.1.0 → 0.1.2
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/dist/index.d.mts +194 -0
- package/dist/index.d.ts +194 -0
- package/dist/index.js +961 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +946 -0
- package/dist/index.mjs.map +1 -0
- package/dist/types/index.d.mts +98 -0
- package/dist/types/index.d.ts +98 -0
- package/dist/types/index.js +4 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/index.mjs +3 -0
- package/dist/types/index.mjs.map +1 -0
- package/package.json +37 -12
- package/src/components/ManageSubscription.test.tsx +0 -1
- package/src/components/PricingDetailPage.integration.test.tsx +0 -1
- package/src/components/PricingDetailPage.test.tsx +0 -1
- package/src/components/PricingPage.test.tsx +0 -1
- package/src/components/PricingSection.test.tsx +0 -1
- package/src/components/SubscriptionManager.test.tsx +33 -1
- package/src/components/SubscriptionManager.tsx +6 -4
- package/src/components/UpgradeModal.test.tsx +0 -1
- package/src/hooks/BillingProvider.test.tsx +12 -63
- package/src/hooks/BillingProvider.tsx +13 -3
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useCheckout.test.tsx +6 -7
- package/src/hooks/usePortal.test.tsx +2 -3
- package/src/hooks/useSubscription.test.tsx +2 -3
- package/src/hooks/useSuccessSync.ts +48 -0
- package/src/index.ts +4 -1
- package/src/server/index.ts +2 -2
- package/src/server/proxy.ts +60 -1
- package/src/types/index.ts +36 -7
- package/src/utils/api.test.ts +13 -14
- package/src/utils/api.ts +28 -13
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
import { renderHook,
|
|
3
|
-
import React
|
|
2
|
+
import { renderHook, waitFor } from "@testing-library/react";
|
|
3
|
+
import React from "react";
|
|
4
4
|
import { BillingProvider, useBillingContext } from "./BillingProvider";
|
|
5
5
|
import { useProduct } from "./useProduct";
|
|
6
6
|
|
|
7
7
|
describe("BillingProvider", () => {
|
|
8
8
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
9
|
-
<BillingProvider apiBaseUrl="
|
|
9
|
+
<BillingProvider apiBaseUrl="/api">
|
|
10
10
|
{children}
|
|
11
11
|
</BillingProvider>
|
|
12
12
|
);
|
|
@@ -14,9 +14,7 @@ describe("BillingProvider", () => {
|
|
|
14
14
|
it("provides billing context to children", () => {
|
|
15
15
|
const { result } = renderHook(() => useBillingContext(), { wrapper });
|
|
16
16
|
expect(result.current.client).toBeDefined();
|
|
17
|
-
expect(result.current.config.apiBaseUrl).toBe(
|
|
18
|
-
"https://api.test.com/api/v1"
|
|
19
|
-
);
|
|
17
|
+
expect(result.current.config.apiBaseUrl).toBe("/api");
|
|
20
18
|
});
|
|
21
19
|
|
|
22
20
|
it("throws when used outside provider", () => {
|
|
@@ -25,52 +23,8 @@ describe("BillingProvider", () => {
|
|
|
25
23
|
}).toThrow("useBillingContext must be used within a <BillingProvider>");
|
|
26
24
|
});
|
|
27
25
|
|
|
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
26
|
it("creates a new client when apiBaseUrl changes", () => {
|
|
73
|
-
let baseUrl = "
|
|
27
|
+
let baseUrl = "/api";
|
|
74
28
|
|
|
75
29
|
const DynamicWrapper = ({ children }: { children: React.ReactNode }) => (
|
|
76
30
|
<BillingProvider apiBaseUrl={baseUrl}>
|
|
@@ -84,41 +38,36 @@ describe("BillingProvider", () => {
|
|
|
84
38
|
|
|
85
39
|
const firstClient = result.current.client;
|
|
86
40
|
|
|
87
|
-
|
|
88
|
-
baseUrl = "https://api2.test.com/api/v1";
|
|
41
|
+
baseUrl = "/api/v2";
|
|
89
42
|
rerender();
|
|
90
43
|
|
|
91
|
-
expect(result.current.config.apiBaseUrl).toBe("
|
|
44
|
+
expect(result.current.config.apiBaseUrl).toBe("/api/v2");
|
|
92
45
|
expect(result.current.client).not.toBe(firstClient);
|
|
93
46
|
});
|
|
94
47
|
|
|
95
|
-
it("API calls
|
|
48
|
+
it("routes API calls through the configured base URL", async () => {
|
|
96
49
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
97
50
|
ok: true,
|
|
98
51
|
json: () => Promise.resolve({ slug: "test", name: "Test", description: "", id: "1", offers: [] }),
|
|
99
52
|
});
|
|
100
53
|
|
|
101
|
-
const
|
|
54
|
+
const testWrapper = ({ children }: { children: React.ReactNode }) => (
|
|
102
55
|
<BillingProvider
|
|
103
|
-
apiBaseUrl="
|
|
104
|
-
authToken="tok_for_header_test"
|
|
56
|
+
apiBaseUrl="/api"
|
|
105
57
|
fetcher={mockFetch as unknown as typeof fetch}
|
|
106
58
|
>
|
|
107
59
|
{children}
|
|
108
60
|
</BillingProvider>
|
|
109
61
|
);
|
|
110
62
|
|
|
111
|
-
|
|
112
|
-
// But we can verify the fetcher was injected properly
|
|
113
|
-
renderHook(() => useProduct("test-slug"), { wrapper: authWrapper });
|
|
63
|
+
renderHook(() => useProduct("test-slug"), { wrapper: testWrapper });
|
|
114
64
|
|
|
115
65
|
await waitFor(() => {
|
|
116
66
|
expect(mockFetch).toHaveBeenCalled();
|
|
117
67
|
});
|
|
118
68
|
|
|
119
|
-
// Verify the fetch was called with the correct base URL
|
|
120
69
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
121
|
-
"
|
|
70
|
+
"/api/billing/products/test-slug/",
|
|
122
71
|
expect.any(Object)
|
|
123
72
|
);
|
|
124
73
|
});
|
|
@@ -4,10 +4,20 @@
|
|
|
4
4
|
* Wrap your app (or a section of it) with <BillingProvider> to configure
|
|
5
5
|
* the API connection. All billing hooks read from this context.
|
|
6
6
|
*
|
|
7
|
+
* Auth is handled server-side by your Next.js Route Handlers — you do NOT
|
|
8
|
+
* pass any tokens here. Set apiBaseUrl to the billing proxy prefix in your app.
|
|
9
|
+
*
|
|
7
10
|
* Usage:
|
|
8
|
-
* <BillingProvider apiBaseUrl="
|
|
9
|
-
* <PricingPage productId="
|
|
11
|
+
* <BillingProvider apiBaseUrl="/api">
|
|
12
|
+
* <PricingPage productId="crochet-patterns" />
|
|
10
13
|
* </BillingProvider>
|
|
14
|
+
*
|
|
15
|
+
* Your Next.js app must expose these Route Handlers under apiBaseUrl:
|
|
16
|
+
* GET /api/billing/products/[slug]/ → public (no auth needed)
|
|
17
|
+
* POST /api/billing/offer-checkout/ → calls fetchDjangoServiceToken + proxyBillingRequest
|
|
18
|
+
* POST /api/billing/subscribe-free/ → same
|
|
19
|
+
* POST /api/billing/offer-portal/ → same
|
|
20
|
+
* GET /api/billing/subscription/current/ → same
|
|
11
21
|
*/
|
|
12
22
|
|
|
13
23
|
import React, { createContext, useContext, useMemo } from "react";
|
|
@@ -32,7 +42,7 @@ export function BillingProvider({
|
|
|
32
42
|
const value = useMemo(() => {
|
|
33
43
|
const client = new BillingApiClient(config);
|
|
34
44
|
return { client, config };
|
|
35
|
-
}, [config.apiBaseUrl
|
|
45
|
+
}, [config.apiBaseUrl]);
|
|
36
46
|
|
|
37
47
|
return (
|
|
38
48
|
<BillingContext.Provider value={value}>
|
package/src/hooks/index.ts
CHANGED
|
@@ -9,7 +9,6 @@ function createWrapper(fetcher: typeof fetch) {
|
|
|
9
9
|
return (
|
|
10
10
|
<BillingProvider
|
|
11
11
|
apiBaseUrl="https://api.test.com/api/v1"
|
|
12
|
-
authToken="tok_123"
|
|
13
12
|
fetcher={fetcher}
|
|
14
13
|
>
|
|
15
14
|
{children}
|
|
@@ -48,13 +47,13 @@ describe("useCheckout", () => {
|
|
|
48
47
|
expect(result.current.loading).toBe(false);
|
|
49
48
|
expect(result.current.error).toBeNull();
|
|
50
49
|
|
|
51
|
-
// Verify correct API call
|
|
50
|
+
// Verify correct API call — no auth header, proxy handles auth server-side
|
|
52
51
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
53
52
|
"https://api.test.com/api/v1/billing/offer-checkout/",
|
|
54
53
|
expect.objectContaining({
|
|
55
54
|
method: "POST",
|
|
56
|
-
headers: expect.objectContaining({
|
|
57
|
-
Authorization:
|
|
55
|
+
headers: expect.not.objectContaining({
|
|
56
|
+
Authorization: expect.any(String),
|
|
58
57
|
}),
|
|
59
58
|
})
|
|
60
59
|
);
|
|
@@ -169,13 +168,13 @@ describe("useCheckout", () => {
|
|
|
169
168
|
expect(result.current.loading).toBe(false);
|
|
170
169
|
expect(result.current.error).toBeNull();
|
|
171
170
|
|
|
172
|
-
// Verify correct API call
|
|
171
|
+
// Verify correct API call — no auth header, proxy handles auth server-side
|
|
173
172
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
174
173
|
"https://api.test.com/api/v1/billing/subscribe-free/",
|
|
175
174
|
expect.objectContaining({
|
|
176
175
|
method: "POST",
|
|
177
|
-
headers: expect.objectContaining({
|
|
178
|
-
Authorization:
|
|
176
|
+
headers: expect.not.objectContaining({
|
|
177
|
+
Authorization: expect.any(String),
|
|
179
178
|
}),
|
|
180
179
|
})
|
|
181
180
|
);
|
|
@@ -9,7 +9,6 @@ function createWrapper(fetcher: typeof fetch) {
|
|
|
9
9
|
return (
|
|
10
10
|
<BillingProvider
|
|
11
11
|
apiBaseUrl="https://api.test.com/api/v1"
|
|
12
|
-
authToken="tok_123"
|
|
13
12
|
fetcher={fetcher}
|
|
14
13
|
>
|
|
15
14
|
{children}
|
|
@@ -62,8 +61,8 @@ describe("usePortal", () => {
|
|
|
62
61
|
"https://api.test.com/api/v1/billing/offer-portal/",
|
|
63
62
|
expect.objectContaining({
|
|
64
63
|
method: "POST",
|
|
65
|
-
headers: expect.objectContaining({
|
|
66
|
-
Authorization:
|
|
64
|
+
headers: expect.not.objectContaining({
|
|
65
|
+
Authorization: expect.any(String),
|
|
67
66
|
}),
|
|
68
67
|
})
|
|
69
68
|
);
|
|
@@ -40,7 +40,6 @@ function createWrapper(fetcher: typeof fetch) {
|
|
|
40
40
|
return (
|
|
41
41
|
<BillingProvider
|
|
42
42
|
apiBaseUrl="https://api.test.com/api/v1"
|
|
43
|
-
authToken="tok_123"
|
|
44
43
|
fetcher={fetcher}
|
|
45
44
|
>
|
|
46
45
|
{children}
|
|
@@ -82,8 +81,8 @@ describe("useSubscription", () => {
|
|
|
82
81
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
83
82
|
"https://api.test.com/api/v1/billing/subscription/current/",
|
|
84
83
|
expect.objectContaining({
|
|
85
|
-
headers: expect.objectContaining({
|
|
86
|
-
Authorization:
|
|
84
|
+
headers: expect.not.objectContaining({
|
|
85
|
+
Authorization: expect.any(String),
|
|
87
86
|
}),
|
|
88
87
|
})
|
|
89
88
|
);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useSuccessSync — Eagerly sync subscription state after Stripe checkout.
|
|
3
|
+
*
|
|
4
|
+
* Call this from your checkout success page. It POSTs to the success-sync
|
|
5
|
+
* endpoint which fetches the latest subscription from Stripe and populates
|
|
6
|
+
* the Redis cache + DB, so the UI can show subscription status immediately
|
|
7
|
+
* instead of waiting for the webhook.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* const { sync, data, loading, error } = useSuccessSync();
|
|
11
|
+
* useEffect(() => { sync(); }, [sync]);
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useCallback, useState } from "react";
|
|
15
|
+
import type { CachedSubscriptionState } from "../types";
|
|
16
|
+
import { useBillingContext } from "./BillingProvider";
|
|
17
|
+
|
|
18
|
+
interface UseSuccessSyncResult {
|
|
19
|
+
sync: () => Promise<CachedSubscriptionState | null>;
|
|
20
|
+
data: CachedSubscriptionState | null;
|
|
21
|
+
loading: boolean;
|
|
22
|
+
error: Error | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useSuccessSync(): UseSuccessSyncResult {
|
|
26
|
+
const { client } = useBillingContext();
|
|
27
|
+
const [data, setData] = useState<CachedSubscriptionState | null>(null);
|
|
28
|
+
const [loading, setLoading] = useState(false);
|
|
29
|
+
const [error, setError] = useState<Error | null>(null);
|
|
30
|
+
|
|
31
|
+
const sync = useCallback(async () => {
|
|
32
|
+
setLoading(true);
|
|
33
|
+
setError(null);
|
|
34
|
+
try {
|
|
35
|
+
const result = await client.successSync();
|
|
36
|
+
setData(result);
|
|
37
|
+
return result;
|
|
38
|
+
} catch (e) {
|
|
39
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
40
|
+
setError(err);
|
|
41
|
+
throw err;
|
|
42
|
+
} finally {
|
|
43
|
+
setLoading(false);
|
|
44
|
+
}
|
|
45
|
+
}, [client]);
|
|
46
|
+
|
|
47
|
+
return { sync, data, loading, error };
|
|
48
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Usage:
|
|
5
5
|
* import { BillingProvider, useProduct, useCheckout } from "@startsimpli/billing";
|
|
6
6
|
*
|
|
7
|
-
* <BillingProvider apiBaseUrl="/api
|
|
7
|
+
* <BillingProvider apiBaseUrl="/api">
|
|
8
8
|
* <PricingPage productId="raise-simpli" />
|
|
9
9
|
* </BillingProvider>
|
|
10
10
|
*/
|
|
@@ -17,6 +17,7 @@ export {
|
|
|
17
17
|
useCheckout,
|
|
18
18
|
usePortal,
|
|
19
19
|
useSubscription,
|
|
20
|
+
useSuccessSync,
|
|
20
21
|
} from "./hooks";
|
|
21
22
|
export type { BillingProviderProps } from "./hooks";
|
|
22
23
|
|
|
@@ -27,7 +28,9 @@ export type {
|
|
|
27
28
|
ProductOffer,
|
|
28
29
|
OfferFeature,
|
|
29
30
|
CheckoutResult,
|
|
31
|
+
CachedSubscriptionState,
|
|
30
32
|
FreeSubscriptionResult,
|
|
33
|
+
PaymentMethod,
|
|
31
34
|
PortalResult,
|
|
32
35
|
SubscriptionInfo,
|
|
33
36
|
} from "./types";
|
package/src/server/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { camelToSnake, proxyBillingRequest } from './proxy';
|
|
2
|
-
export type { BillingProxyOptions } from './proxy';
|
|
1
|
+
export { camelToSnake, proxyBillingRequest, fetchDjangoServiceToken } from './proxy';
|
|
2
|
+
export type { BillingProxyOptions, DjangoServiceTokenOptions } from './proxy';
|
package/src/server/proxy.ts
CHANGED
|
@@ -5,11 +5,70 @@
|
|
|
5
5
|
* camelCase-to-snake_case response normalization (Django's DRF camel-case
|
|
6
6
|
* middleware returns camelCase; some billing consumers expect snake_case).
|
|
7
7
|
*
|
|
8
|
-
* Import via: import { proxyBillingRequest, camelToSnake } from '@startsimpli/billing/server'
|
|
8
|
+
* Import via: import { proxyBillingRequest, fetchDjangoServiceToken, camelToSnake } from '@startsimpli/billing/server'
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { NextResponse } from 'next/server';
|
|
12
12
|
|
|
13
|
+
export interface DjangoServiceTokenOptions {
|
|
14
|
+
/** Django base URL. Defaults to INTERNAL_API_URL ?? NEXT_PUBLIC_API_URL env vars. */
|
|
15
|
+
apiUrl?: string;
|
|
16
|
+
/** Shared service key. Defaults to INTERNAL_SERVICE_KEY env var. */
|
|
17
|
+
serviceKey?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Exchange the shared INTERNAL_SERVICE_KEY for a Django JWT for the given user.
|
|
22
|
+
*
|
|
23
|
+
* Must only be called server-side (Route Handlers, Server Actions).
|
|
24
|
+
* Never expose the service key or the returned JWT to the browser.
|
|
25
|
+
*
|
|
26
|
+
* Django creates the user if they don't exist and ensures they have a Team,
|
|
27
|
+
* so billing endpoints (which require team membership) work immediately.
|
|
28
|
+
*
|
|
29
|
+
* Prefers INTERNAL_API_URL over NEXT_PUBLIC_API_URL for server-to-server
|
|
30
|
+
* calls — set INTERNAL_API_URL to the Docker service name in production
|
|
31
|
+
* (e.g. http://django:8000) to avoid routing through the public load balancer.
|
|
32
|
+
*/
|
|
33
|
+
export async function fetchDjangoServiceToken(
|
|
34
|
+
email: string,
|
|
35
|
+
name?: string | null,
|
|
36
|
+
opts?: DjangoServiceTokenOptions,
|
|
37
|
+
): Promise<string | undefined> {
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
39
|
+
const env = (typeof process !== 'undefined' ? process.env : {}) as Record<string, string | undefined>;
|
|
40
|
+
const apiUrl =
|
|
41
|
+
opts?.apiUrl ??
|
|
42
|
+
env['INTERNAL_API_URL'] ??
|
|
43
|
+
env['NEXT_PUBLIC_API_URL'] ??
|
|
44
|
+
'';
|
|
45
|
+
const serviceKey = opts?.serviceKey ?? env['INTERNAL_SERVICE_KEY'] ?? '';
|
|
46
|
+
|
|
47
|
+
if (!serviceKey) {
|
|
48
|
+
console.error('[billing] INTERNAL_SERVICE_KEY is not set — billing auth will fail');
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const response = await fetch(`${apiUrl}/api/v1/auth/service-token/`, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'Content-Type': 'application/json' },
|
|
56
|
+
body: JSON.stringify({ email, name: name ?? '', service_key: serviceKey }),
|
|
57
|
+
cache: 'no-store',
|
|
58
|
+
});
|
|
59
|
+
if (response.ok) {
|
|
60
|
+
const data = await response.json() as { access?: string };
|
|
61
|
+
return data.access;
|
|
62
|
+
}
|
|
63
|
+
const body = await response.text();
|
|
64
|
+
console.error('[billing] service-token failed:', response.status, body);
|
|
65
|
+
return undefined;
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.error('[billing] service-token request threw:', err);
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
13
72
|
/**
|
|
14
73
|
* Recursively converts camelCase object keys to snake_case.
|
|
15
74
|
* Useful when Django's camel-case middleware returns camelCase field names
|
package/src/types/index.ts
CHANGED
|
@@ -60,19 +60,48 @@ export interface SubscriptionInfo {
|
|
|
60
60
|
id: string;
|
|
61
61
|
offer: ProductOffer;
|
|
62
62
|
status: "active" | "trialing" | "past_due" | "cancelled" | "incomplete" | "incomplete_expired" | "unpaid";
|
|
63
|
-
current_period_start: string;
|
|
64
|
-
current_period_end: string;
|
|
63
|
+
current_period_start: string | null;
|
|
64
|
+
current_period_end: string | null;
|
|
65
65
|
cancel_at_period_end: boolean;
|
|
66
66
|
trial_end: string | null;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
export interface PaymentMethod {
|
|
70
|
+
brand: string | null;
|
|
71
|
+
last4: string | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface CachedSubscriptionState {
|
|
75
|
+
subscription_id: string | null;
|
|
76
|
+
provider_subscription_id: string | null;
|
|
77
|
+
status: string;
|
|
78
|
+
customer_id: string | null;
|
|
79
|
+
price_id: string | null;
|
|
80
|
+
offer_id: string | null;
|
|
81
|
+
product_slug: string | null;
|
|
82
|
+
offer_slug: string | null;
|
|
83
|
+
current_period_start: number | null;
|
|
84
|
+
current_period_end: number | null;
|
|
85
|
+
cancel_at_period_end: boolean;
|
|
86
|
+
trial_end: number | null;
|
|
87
|
+
payment_method: PaymentMethod | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
69
90
|
export interface BillingConfig {
|
|
70
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* Base path for billing API calls.
|
|
93
|
+
*
|
|
94
|
+
* Point this at your Next.js app's billing route prefix — NOT at Django directly.
|
|
95
|
+
* The Next.js Route Handlers act as an authenticated proxy: they add the Django JWT
|
|
96
|
+
* server-side using INTERNAL_SERVICE_KEY, so no credentials are needed here.
|
|
97
|
+
*
|
|
98
|
+
* Example (Next.js app router):
|
|
99
|
+
* apiBaseUrl="/api"
|
|
100
|
+
* → public: GET /api/billing/products/{slug}/
|
|
101
|
+
* → authed: POST /api/billing/offer-checkout/ (Route Handler adds Django JWT)
|
|
102
|
+
*/
|
|
71
103
|
apiBaseUrl: string;
|
|
72
104
|
|
|
73
|
-
/** Optional
|
|
74
|
-
authToken?: string;
|
|
75
|
-
|
|
76
|
-
/** Optional custom fetch implementation */
|
|
105
|
+
/** Optional custom fetch implementation (useful for testing). */
|
|
77
106
|
fetcher?: typeof fetch;
|
|
78
107
|
}
|
package/src/utils/api.test.ts
CHANGED
|
@@ -4,10 +4,9 @@ import { BillingApiClient } from "./api";
|
|
|
4
4
|
describe("BillingApiClient", () => {
|
|
5
5
|
const mockFetch = vi.fn();
|
|
6
6
|
|
|
7
|
-
function createClient(
|
|
7
|
+
function createClient() {
|
|
8
8
|
return new BillingApiClient({
|
|
9
|
-
apiBaseUrl: "
|
|
10
|
-
authToken,
|
|
9
|
+
apiBaseUrl: "/api",
|
|
11
10
|
fetcher: mockFetch as unknown as typeof fetch,
|
|
12
11
|
});
|
|
13
12
|
}
|
|
@@ -17,18 +16,18 @@ describe("BillingApiClient", () => {
|
|
|
17
16
|
});
|
|
18
17
|
|
|
19
18
|
describe("getProduct", () => {
|
|
20
|
-
it("calls correct URL
|
|
19
|
+
it("calls correct URL without auth header", async () => {
|
|
21
20
|
mockFetch.mockResolvedValue({
|
|
22
21
|
ok: true,
|
|
23
22
|
json: () =>
|
|
24
23
|
Promise.resolve({ slug: "test", name: "Test", offers: [] }),
|
|
25
24
|
});
|
|
26
25
|
|
|
27
|
-
const client = createClient(
|
|
26
|
+
const client = createClient();
|
|
28
27
|
await client.getProduct("test-product");
|
|
29
28
|
|
|
30
29
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
31
|
-
"
|
|
30
|
+
"/api/billing/products/test-product/",
|
|
32
31
|
expect.objectContaining({
|
|
33
32
|
headers: expect.not.objectContaining({
|
|
34
33
|
Authorization: expect.any(String),
|
|
@@ -46,14 +45,14 @@ describe("BillingApiClient", () => {
|
|
|
46
45
|
});
|
|
47
46
|
|
|
48
47
|
describe("createCheckout", () => {
|
|
49
|
-
it("sends POST
|
|
48
|
+
it("sends POST without auth header (proxy handles auth server-side)", async () => {
|
|
50
49
|
mockFetch.mockResolvedValue({
|
|
51
50
|
ok: true,
|
|
52
51
|
json: () =>
|
|
53
52
|
Promise.resolve({ session_id: "cs_1", url: "https://pay.com" }),
|
|
54
53
|
});
|
|
55
54
|
|
|
56
|
-
const client = createClient(
|
|
55
|
+
const client = createClient();
|
|
57
56
|
const result = await client.createCheckout({
|
|
58
57
|
offerId: "offer-1",
|
|
59
58
|
successUrl: "https://ok.com",
|
|
@@ -62,11 +61,11 @@ describe("BillingApiClient", () => {
|
|
|
62
61
|
|
|
63
62
|
expect(result.session_id).toBe("cs_1");
|
|
64
63
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
65
|
-
"
|
|
64
|
+
"/api/billing/offer-checkout/",
|
|
66
65
|
expect.objectContaining({
|
|
67
66
|
method: "POST",
|
|
68
|
-
headers: expect.objectContaining({
|
|
69
|
-
Authorization:
|
|
67
|
+
headers: expect.not.objectContaining({
|
|
68
|
+
Authorization: expect.any(String),
|
|
70
69
|
}),
|
|
71
70
|
})
|
|
72
71
|
);
|
|
@@ -79,7 +78,7 @@ describe("BillingApiClient", () => {
|
|
|
79
78
|
Promise.resolve({ session_id: "cs_1", url: "https://pay.com" }),
|
|
80
79
|
});
|
|
81
80
|
|
|
82
|
-
const client = createClient(
|
|
81
|
+
const client = createClient();
|
|
83
82
|
await client.createCheckout({
|
|
84
83
|
offerId: "offer-1",
|
|
85
84
|
successUrl: "https://ok.com",
|
|
@@ -98,7 +97,7 @@ describe("BillingApiClient", () => {
|
|
|
98
97
|
json: () => Promise.resolve({ detail: "Offer not synced" }),
|
|
99
98
|
});
|
|
100
99
|
|
|
101
|
-
const client = createClient(
|
|
100
|
+
const client = createClient();
|
|
102
101
|
await expect(
|
|
103
102
|
client.createCheckout({
|
|
104
103
|
offerId: "bad",
|
|
@@ -117,7 +116,7 @@ describe("BillingApiClient", () => {
|
|
|
117
116
|
Promise.resolve({ url: "https://portal.stripe.com/test" }),
|
|
118
117
|
});
|
|
119
118
|
|
|
120
|
-
const client = createClient(
|
|
119
|
+
const client = createClient();
|
|
121
120
|
const result = await client.createPortal("https://app.com/settings");
|
|
122
121
|
|
|
123
122
|
expect(result.url).toBe("https://portal.stripe.com/test");
|
package/src/utils/api.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type {
|
|
6
6
|
BillingConfig,
|
|
7
7
|
BillingProduct,
|
|
8
|
+
CachedSubscriptionState,
|
|
8
9
|
CheckoutResult,
|
|
9
10
|
FreeSubscriptionResult,
|
|
10
11
|
PortalResult,
|
|
@@ -22,21 +23,15 @@ export class BillingApiClient {
|
|
|
22
23
|
return this.config.fetcher ?? fetch.bind(globalThis);
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
private headers(
|
|
26
|
-
|
|
27
|
-
"Content-Type": "application/json",
|
|
28
|
-
};
|
|
29
|
-
if (authenticated && this.config.authToken) {
|
|
30
|
-
h["Authorization"] = `Bearer ${this.config.authToken}`;
|
|
31
|
-
}
|
|
32
|
-
return h;
|
|
26
|
+
private get headers(): HeadersInit {
|
|
27
|
+
return { "Content-Type": "application/json" };
|
|
33
28
|
}
|
|
34
29
|
|
|
35
30
|
/** Fetch a public billing product by slug. */
|
|
36
31
|
async getProduct(slug: string): Promise<BillingProduct> {
|
|
37
32
|
const url = `${this.config.apiBaseUrl}/billing/products/${slug}/`;
|
|
38
33
|
const resp = await this.fetcher(url, {
|
|
39
|
-
headers: this.headers
|
|
34
|
+
headers: this.headers,
|
|
40
35
|
});
|
|
41
36
|
if (!resp.ok) {
|
|
42
37
|
throw new Error(`Failed to fetch product "${slug}": ${resp.status}`);
|
|
@@ -54,7 +49,7 @@ export class BillingApiClient {
|
|
|
54
49
|
const url = `${this.config.apiBaseUrl}/billing/offer-checkout/`;
|
|
55
50
|
const resp = await this.fetcher(url, {
|
|
56
51
|
method: "POST",
|
|
57
|
-
headers: this.headers
|
|
52
|
+
headers: this.headers,
|
|
58
53
|
body: JSON.stringify({
|
|
59
54
|
offer_id: params.offerId,
|
|
60
55
|
success_url: params.successUrl,
|
|
@@ -76,7 +71,7 @@ export class BillingApiClient {
|
|
|
76
71
|
const url = `${this.config.apiBaseUrl}/billing/subscribe-free/`;
|
|
77
72
|
const resp = await this.fetcher(url, {
|
|
78
73
|
method: "POST",
|
|
79
|
-
headers: this.headers
|
|
74
|
+
headers: this.headers,
|
|
80
75
|
body: JSON.stringify({ offer_id: offerId }),
|
|
81
76
|
});
|
|
82
77
|
if (!resp.ok) {
|
|
@@ -91,7 +86,7 @@ export class BillingApiClient {
|
|
|
91
86
|
const url = `${this.config.apiBaseUrl}/billing/offer-portal/`;
|
|
92
87
|
const resp = await this.fetcher(url, {
|
|
93
88
|
method: "POST",
|
|
94
|
-
headers: this.headers
|
|
89
|
+
headers: this.headers,
|
|
95
90
|
body: JSON.stringify({ return_url: returnUrl }),
|
|
96
91
|
});
|
|
97
92
|
if (!resp.ok) {
|
|
@@ -103,11 +98,31 @@ export class BillingApiClient {
|
|
|
103
98
|
return resp.json();
|
|
104
99
|
}
|
|
105
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Eagerly sync subscription state after Stripe checkout redirect.
|
|
103
|
+
* Call this from your success_url page to avoid waiting for the webhook.
|
|
104
|
+
*/
|
|
105
|
+
async successSync(): Promise<CachedSubscriptionState | null> {
|
|
106
|
+
const url = `${this.config.apiBaseUrl}/billing/success-sync/`;
|
|
107
|
+
const resp = await this.fetcher(url, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: this.headers,
|
|
110
|
+
});
|
|
111
|
+
if (resp.status === 404) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
if (!resp.ok) {
|
|
115
|
+
const data = await resp.json().catch(() => ({}));
|
|
116
|
+
throw new Error(data.detail ?? `Success sync failed: ${resp.status}`);
|
|
117
|
+
}
|
|
118
|
+
return resp.json();
|
|
119
|
+
}
|
|
120
|
+
|
|
106
121
|
/** Get current user's subscription. */
|
|
107
122
|
async getSubscription(): Promise<SubscriptionInfo | null> {
|
|
108
123
|
const url = `${this.config.apiBaseUrl}/billing/subscription/current/`;
|
|
109
124
|
const resp = await this.fetcher(url, {
|
|
110
|
-
headers: this.headers
|
|
125
|
+
headers: this.headers,
|
|
111
126
|
});
|
|
112
127
|
if (resp.status === 404) {
|
|
113
128
|
return null; // No active subscription
|