@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.
@@ -1,12 +1,12 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
- import { renderHook, act, waitFor } from "@testing-library/react";
3
- import React, { useState } from "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="https://api.test.com/api/v1">
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 = "https://api.test.com/api/v1";
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
- // Change base URL and re-render
88
- baseUrl = "https://api2.test.com/api/v1";
41
+ baseUrl = "/api/v2";
89
42
  rerender();
90
43
 
91
- expect(result.current.config.apiBaseUrl).toBe("https://api2.test.com/api/v1");
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 include auth token in headers", async () => {
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 authWrapper = ({ children }: { children: React.ReactNode }) => (
54
+ const testWrapper = ({ children }: { children: React.ReactNode }) => (
102
55
  <BillingProvider
103
- apiBaseUrl="https://api.test.com/api/v1"
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
- // 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 });
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
- "https://api.test.com/api/v1/billing/products/test-slug/",
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="https://api.startsimpli.com/api/v1" authToken={token}>
9
- * <PricingPage productId="raise-simpli" />
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, config.authToken]);
45
+ }, [config.apiBaseUrl]);
36
46
 
37
47
  return (
38
48
  <BillingContext.Provider value={value}>
@@ -4,3 +4,4 @@ export { useProduct } from "./useProduct";
4
4
  export { useCheckout } from "./useCheckout";
5
5
  export { usePortal } from "./usePortal";
6
6
  export { useSubscription } from "./useSubscription";
7
+ export { useSuccessSync } from "./useSuccessSync";
@@ -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: "Bearer tok_123",
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: "Bearer tok_123",
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: "Bearer tok_123",
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: "Bearer tok_123",
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/v1" authToken={token}>
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";
@@ -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';
@@ -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
@@ -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
- /** Base URL for the billing API (e.g., "https://api.startsimpli.com/api/v1") */
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 auth token for authenticated endpoints (checkout, portal) */
74
- authToken?: string;
75
-
76
- /** Optional custom fetch implementation */
105
+ /** Optional custom fetch implementation (useful for testing). */
77
106
  fetcher?: typeof fetch;
78
107
  }
@@ -4,10 +4,9 @@ import { BillingApiClient } from "./api";
4
4
  describe("BillingApiClient", () => {
5
5
  const mockFetch = vi.fn();
6
6
 
7
- function createClient(authToken?: string) {
7
+ function createClient() {
8
8
  return new BillingApiClient({
9
- apiBaseUrl: "https://api.test.com/api/v1",
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 with no auth", async () => {
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("tok_123");
26
+ const client = createClient();
28
27
  await client.getProduct("test-product");
29
28
 
30
29
  expect(mockFetch).toHaveBeenCalledWith(
31
- "https://api.test.com/api/v1/billing/products/test-product/",
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 with auth header", async () => {
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("tok_abc");
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
- "https://api.test.com/api/v1/billing/offer-checkout/",
64
+ "/api/billing/offer-checkout/",
66
65
  expect.objectContaining({
67
66
  method: "POST",
68
- headers: expect.objectContaining({
69
- Authorization: "Bearer tok_abc",
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("tok");
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("tok");
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("tok_xyz");
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(authenticated: boolean = false): HeadersInit {
26
- const h: Record<string, string> = {
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(false),
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(true),
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(true),
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(true),
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(true),
125
+ headers: this.headers,
111
126
  });
112
127
  if (resp.status === 404) {
113
128
  return null; // No active subscription