@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,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usePortal — Create a customer portal session and redirect.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* const { openPortal, loading, error } = usePortal();
|
|
6
|
+
* await openPortal("https://app.example.com/settings");
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback, useState } from "react";
|
|
10
|
+
import type { PortalResult } from "../types";
|
|
11
|
+
import { useBillingContext } from "./BillingProvider";
|
|
12
|
+
|
|
13
|
+
interface UsePortalResult {
|
|
14
|
+
openPortal: (returnUrl: string) => Promise<PortalResult>;
|
|
15
|
+
loading: boolean;
|
|
16
|
+
error: Error | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function usePortal(): UsePortalResult {
|
|
20
|
+
const { client } = useBillingContext();
|
|
21
|
+
const [loading, setLoading] = useState(false);
|
|
22
|
+
const [error, setError] = useState<Error | null>(null);
|
|
23
|
+
|
|
24
|
+
const openPortal = useCallback(
|
|
25
|
+
async (returnUrl: string) => {
|
|
26
|
+
setLoading(true);
|
|
27
|
+
setError(null);
|
|
28
|
+
try {
|
|
29
|
+
const result = await client.createPortal(returnUrl);
|
|
30
|
+
return result;
|
|
31
|
+
} catch (e) {
|
|
32
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
33
|
+
setError(err);
|
|
34
|
+
throw err;
|
|
35
|
+
} finally {
|
|
36
|
+
setLoading(false);
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
[client]
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return { openPortal, loading, error };
|
|
43
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { renderHook, waitFor } from "@testing-library/react";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { BillingProvider } from "./BillingProvider";
|
|
5
|
+
import { useProduct } from "./useProduct";
|
|
6
|
+
import type { BillingProduct } from "../types";
|
|
7
|
+
|
|
8
|
+
const mockProduct: BillingProduct = {
|
|
9
|
+
id: "prod-1",
|
|
10
|
+
slug: "raise-simpli",
|
|
11
|
+
name: "RaiseSimpli",
|
|
12
|
+
description: "VC fundraising platform",
|
|
13
|
+
offers: [
|
|
14
|
+
{
|
|
15
|
+
id: "offer-1",
|
|
16
|
+
name: "Starter",
|
|
17
|
+
slug: "starter-monthly",
|
|
18
|
+
unit_price: "0.00",
|
|
19
|
+
currency: "USD",
|
|
20
|
+
pricing_model: "flat",
|
|
21
|
+
interval_unit: "month",
|
|
22
|
+
interval_count: 1,
|
|
23
|
+
interval_display: "per month",
|
|
24
|
+
is_recurring: true,
|
|
25
|
+
is_free: true,
|
|
26
|
+
features: [],
|
|
27
|
+
is_active: true,
|
|
28
|
+
is_featured: false,
|
|
29
|
+
trial_days: 0,
|
|
30
|
+
sort_order: 0,
|
|
31
|
+
cta_text: "Start Free",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "offer-2",
|
|
35
|
+
name: "Pro",
|
|
36
|
+
slug: "pro-monthly",
|
|
37
|
+
unit_price: "49.00",
|
|
38
|
+
currency: "USD",
|
|
39
|
+
pricing_model: "flat",
|
|
40
|
+
interval_unit: "month",
|
|
41
|
+
interval_count: 1,
|
|
42
|
+
interval_display: "per month",
|
|
43
|
+
is_recurring: true,
|
|
44
|
+
is_free: false,
|
|
45
|
+
features: [{ key: "seats", name: "Team seats", value: 5, limit: 5 }],
|
|
46
|
+
is_active: true,
|
|
47
|
+
is_featured: true,
|
|
48
|
+
trial_days: 14,
|
|
49
|
+
sort_order: 1,
|
|
50
|
+
cta_text: "Start Trial",
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function createWrapper(fetcher: typeof fetch) {
|
|
56
|
+
return function Wrapper({ children }: { children: React.ReactNode }) {
|
|
57
|
+
return (
|
|
58
|
+
<BillingProvider
|
|
59
|
+
apiBaseUrl="https://api.test.com/api/v1"
|
|
60
|
+
fetcher={fetcher}
|
|
61
|
+
>
|
|
62
|
+
{children}
|
|
63
|
+
</BillingProvider>
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("useProduct", () => {
|
|
69
|
+
it("fetches product by slug", async () => {
|
|
70
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
71
|
+
ok: true,
|
|
72
|
+
json: () => Promise.resolve(mockProduct),
|
|
73
|
+
});
|
|
74
|
+
const wrapper = createWrapper(mockFetch as unknown as typeof fetch);
|
|
75
|
+
|
|
76
|
+
const { result } = renderHook(() => useProduct("raise-simpli"), {
|
|
77
|
+
wrapper,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Initially loading
|
|
81
|
+
expect(result.current.loading).toBe(true);
|
|
82
|
+
|
|
83
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
84
|
+
|
|
85
|
+
expect(result.current.product).toEqual(mockProduct);
|
|
86
|
+
expect(result.current.error).toBeNull();
|
|
87
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
88
|
+
"https://api.test.com/api/v1/billing/products/raise-simpli/",
|
|
89
|
+
expect.any(Object)
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("handles fetch error", async () => {
|
|
94
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
95
|
+
ok: false,
|
|
96
|
+
status: 404,
|
|
97
|
+
});
|
|
98
|
+
const wrapper = createWrapper(mockFetch as unknown as typeof fetch);
|
|
99
|
+
|
|
100
|
+
const { result } = renderHook(() => useProduct("nonexistent"), {
|
|
101
|
+
wrapper,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
105
|
+
|
|
106
|
+
expect(result.current.product).toBeNull();
|
|
107
|
+
expect(result.current.error).toBeInstanceOf(Error);
|
|
108
|
+
expect(result.current.error?.message).toContain("nonexistent");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("handles network error", async () => {
|
|
112
|
+
const mockFetch = vi
|
|
113
|
+
.fn()
|
|
114
|
+
.mockRejectedValue(new Error("Network failure"));
|
|
115
|
+
const wrapper = createWrapper(mockFetch as unknown as typeof fetch);
|
|
116
|
+
|
|
117
|
+
const { result } = renderHook(() => useProduct("raise-simpli"), {
|
|
118
|
+
wrapper,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
122
|
+
|
|
123
|
+
expect(result.current.product).toBeNull();
|
|
124
|
+
expect(result.current.error?.message).toBe("Network failure");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("provides refetch function", async () => {
|
|
128
|
+
let callCount = 0;
|
|
129
|
+
const mockFetch = vi.fn().mockImplementation(() => {
|
|
130
|
+
callCount++;
|
|
131
|
+
return Promise.resolve({
|
|
132
|
+
ok: true,
|
|
133
|
+
json: () =>
|
|
134
|
+
Promise.resolve({
|
|
135
|
+
...mockProduct,
|
|
136
|
+
name: `RaiseSimpli v${callCount}`,
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
const wrapper = createWrapper(mockFetch as unknown as typeof fetch);
|
|
141
|
+
|
|
142
|
+
const { result } = renderHook(() => useProduct("raise-simpli"), {
|
|
143
|
+
wrapper,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
147
|
+
expect(result.current.product?.name).toBe("RaiseSimpli v1");
|
|
148
|
+
|
|
149
|
+
// Refetch
|
|
150
|
+
result.current.refetch();
|
|
151
|
+
await waitFor(() =>
|
|
152
|
+
expect(result.current.product?.name).toBe("RaiseSimpli v2")
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useProduct — Fetch a BillingProduct by slug.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* const { product, loading, error } = useProduct("raise-simpli");
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useCallback, useEffect, useState } from "react";
|
|
9
|
+
import type { BillingProduct } from "../types";
|
|
10
|
+
import { useBillingContext } from "./BillingProvider";
|
|
11
|
+
|
|
12
|
+
interface UseProductResult {
|
|
13
|
+
product: BillingProduct | null;
|
|
14
|
+
loading: boolean;
|
|
15
|
+
error: Error | null;
|
|
16
|
+
refetch: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useProduct(slug: string): UseProductResult {
|
|
20
|
+
const { client } = useBillingContext();
|
|
21
|
+
const [product, setProduct] = useState<BillingProduct | null>(null);
|
|
22
|
+
const [loading, setLoading] = useState(true);
|
|
23
|
+
const [error, setError] = useState<Error | null>(null);
|
|
24
|
+
|
|
25
|
+
const fetchProduct = useCallback(async () => {
|
|
26
|
+
setLoading(true);
|
|
27
|
+
setError(null);
|
|
28
|
+
try {
|
|
29
|
+
const data = await client.getProduct(slug);
|
|
30
|
+
setProduct(data);
|
|
31
|
+
} catch (e) {
|
|
32
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
33
|
+
} finally {
|
|
34
|
+
setLoading(false);
|
|
35
|
+
}
|
|
36
|
+
}, [client, slug]);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
fetchProduct();
|
|
40
|
+
}, [fetchProduct]);
|
|
41
|
+
|
|
42
|
+
return { product, loading, error, refetch: fetchProduct };
|
|
43
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { renderHook, waitFor, act } from "@testing-library/react";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { BillingProvider } from "./BillingProvider";
|
|
5
|
+
import { useSubscription } from "./useSubscription";
|
|
6
|
+
import type { SubscriptionInfo, ProductOffer } from "../types";
|
|
7
|
+
|
|
8
|
+
const mockOffer: ProductOffer = {
|
|
9
|
+
id: "offer-2",
|
|
10
|
+
name: "Pro",
|
|
11
|
+
slug: "pro-monthly",
|
|
12
|
+
unit_price: "49.00",
|
|
13
|
+
currency: "USD",
|
|
14
|
+
pricing_model: "flat",
|
|
15
|
+
interval_unit: "month",
|
|
16
|
+
interval_count: 1,
|
|
17
|
+
interval_display: "per month",
|
|
18
|
+
is_recurring: true,
|
|
19
|
+
is_free: false,
|
|
20
|
+
features: [{ key: "seats", name: "Team seats", value: 5, limit: 5 }],
|
|
21
|
+
is_active: true,
|
|
22
|
+
is_featured: true,
|
|
23
|
+
trial_days: 14,
|
|
24
|
+
sort_order: 1,
|
|
25
|
+
cta_text: "Start Trial",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const mockSubscription: SubscriptionInfo = {
|
|
29
|
+
id: "sub-123",
|
|
30
|
+
offer: mockOffer,
|
|
31
|
+
status: "active",
|
|
32
|
+
current_period_start: "2026-01-01T00:00:00Z",
|
|
33
|
+
current_period_end: "2026-02-01T00:00:00Z",
|
|
34
|
+
cancel_at_period_end: false,
|
|
35
|
+
trial_end: null,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function createWrapper(fetcher: typeof fetch) {
|
|
39
|
+
return function Wrapper({ children }: { children: React.ReactNode }) {
|
|
40
|
+
return (
|
|
41
|
+
<BillingProvider
|
|
42
|
+
apiBaseUrl="https://api.test.com/api/v1"
|
|
43
|
+
authToken="tok_123"
|
|
44
|
+
fetcher={fetcher}
|
|
45
|
+
>
|
|
46
|
+
{children}
|
|
47
|
+
</BillingProvider>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("useSubscription", () => {
|
|
53
|
+
it("starts in loading state", () => {
|
|
54
|
+
const mockFetch = vi.fn().mockReturnValue(
|
|
55
|
+
new Promise(() => {})
|
|
56
|
+
) as unknown as typeof fetch;
|
|
57
|
+
const wrapper = createWrapper(mockFetch);
|
|
58
|
+
|
|
59
|
+
const { result } = renderHook(() => useSubscription(), { wrapper });
|
|
60
|
+
|
|
61
|
+
expect(result.current.loading).toBe(true);
|
|
62
|
+
expect(result.current.subscription).toBeNull();
|
|
63
|
+
expect(result.current.error).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("fetches subscription successfully", async () => {
|
|
67
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
68
|
+
ok: true,
|
|
69
|
+
status: 200,
|
|
70
|
+
json: () => Promise.resolve(mockSubscription),
|
|
71
|
+
});
|
|
72
|
+
const wrapper = createWrapper(mockFetch as unknown as typeof fetch);
|
|
73
|
+
|
|
74
|
+
const { result } = renderHook(() => useSubscription(), { wrapper });
|
|
75
|
+
|
|
76
|
+
expect(result.current.loading).toBe(true);
|
|
77
|
+
|
|
78
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
79
|
+
|
|
80
|
+
expect(result.current.subscription).toEqual(mockSubscription);
|
|
81
|
+
expect(result.current.error).toBeNull();
|
|
82
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
83
|
+
"https://api.test.com/api/v1/billing/subscription/current/",
|
|
84
|
+
expect.objectContaining({
|
|
85
|
+
headers: expect.objectContaining({
|
|
86
|
+
Authorization: "Bearer tok_123",
|
|
87
|
+
}),
|
|
88
|
+
})
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns null when no subscription exists (404)", async () => {
|
|
93
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
94
|
+
ok: false,
|
|
95
|
+
status: 404,
|
|
96
|
+
});
|
|
97
|
+
const wrapper = createWrapper(mockFetch as unknown as typeof fetch);
|
|
98
|
+
|
|
99
|
+
const { result } = renderHook(() => useSubscription(), { wrapper });
|
|
100
|
+
|
|
101
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
102
|
+
|
|
103
|
+
expect(result.current.subscription).toBeNull();
|
|
104
|
+
expect(result.current.error).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("sets error on non-404 API failure", async () => {
|
|
108
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
109
|
+
ok: false,
|
|
110
|
+
status: 500,
|
|
111
|
+
json: () => Promise.resolve({ detail: "Internal server error" }),
|
|
112
|
+
});
|
|
113
|
+
const wrapper = createWrapper(mockFetch as unknown as typeof fetch);
|
|
114
|
+
|
|
115
|
+
const { result } = renderHook(() => useSubscription(), { wrapper });
|
|
116
|
+
|
|
117
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
118
|
+
|
|
119
|
+
expect(result.current.subscription).toBeNull();
|
|
120
|
+
expect(result.current.error).toBeInstanceOf(Error);
|
|
121
|
+
expect(result.current.error?.message).toContain("Internal server error");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("handles network error", async () => {
|
|
125
|
+
const mockFetch = vi
|
|
126
|
+
.fn()
|
|
127
|
+
.mockRejectedValue(new Error("Network failure"));
|
|
128
|
+
const wrapper = createWrapper(mockFetch as unknown as typeof fetch);
|
|
129
|
+
|
|
130
|
+
const { result } = renderHook(() => useSubscription(), { wrapper });
|
|
131
|
+
|
|
132
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
133
|
+
|
|
134
|
+
expect(result.current.subscription).toBeNull();
|
|
135
|
+
expect(result.current.error?.message).toBe("Network failure");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("provides refetch function", async () => {
|
|
139
|
+
let callCount = 0;
|
|
140
|
+
const mockFetch = vi.fn().mockImplementation(() => {
|
|
141
|
+
callCount++;
|
|
142
|
+
return Promise.resolve({
|
|
143
|
+
ok: true,
|
|
144
|
+
status: 200,
|
|
145
|
+
json: () =>
|
|
146
|
+
Promise.resolve({
|
|
147
|
+
...mockSubscription,
|
|
148
|
+
id: `sub-${callCount}`,
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
const wrapper = createWrapper(mockFetch as unknown as typeof fetch);
|
|
153
|
+
|
|
154
|
+
const { result } = renderHook(() => useSubscription(), { wrapper });
|
|
155
|
+
|
|
156
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
157
|
+
expect(result.current.subscription?.id).toBe("sub-1");
|
|
158
|
+
|
|
159
|
+
// Refetch
|
|
160
|
+
act(() => {
|
|
161
|
+
result.current.refetch();
|
|
162
|
+
});
|
|
163
|
+
await waitFor(() =>
|
|
164
|
+
expect(result.current.subscription?.id).toBe("sub-2")
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useSubscription — Fetch current user's active subscription.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useCallback, useEffect, useState } from "react";
|
|
6
|
+
import type { SubscriptionInfo } from "../types";
|
|
7
|
+
import { useBillingContext } from "./BillingProvider";
|
|
8
|
+
|
|
9
|
+
interface UseSubscriptionResult {
|
|
10
|
+
subscription: SubscriptionInfo | null;
|
|
11
|
+
loading: boolean;
|
|
12
|
+
error: Error | null;
|
|
13
|
+
refetch: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useSubscription(): UseSubscriptionResult {
|
|
17
|
+
const { client } = useBillingContext();
|
|
18
|
+
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(null);
|
|
19
|
+
const [loading, setLoading] = useState(true);
|
|
20
|
+
const [error, setError] = useState<Error | null>(null);
|
|
21
|
+
|
|
22
|
+
const fetchSubscription = useCallback(async () => {
|
|
23
|
+
setLoading(true);
|
|
24
|
+
setError(null);
|
|
25
|
+
try {
|
|
26
|
+
const data = await client.getSubscription();
|
|
27
|
+
setSubscription(data);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
setError(e instanceof Error ? e : new Error(String(e)));
|
|
30
|
+
} finally {
|
|
31
|
+
setLoading(false);
|
|
32
|
+
}
|
|
33
|
+
}, [client]);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
fetchSubscription();
|
|
37
|
+
}, [fetchSubscription]);
|
|
38
|
+
|
|
39
|
+
return { subscription, loading, error, refetch: fetchSubscription };
|
|
40
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @startsimpli/billing — Universal billing integration for StartSimpli apps.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* import { BillingProvider, useProduct, useCheckout } from "@startsimpli/billing";
|
|
6
|
+
*
|
|
7
|
+
* <BillingProvider apiBaseUrl="/api/v1" authToken={token}>
|
|
8
|
+
* <PricingPage productId="raise-simpli" />
|
|
9
|
+
* </BillingProvider>
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Context & Hooks
|
|
13
|
+
export {
|
|
14
|
+
BillingProvider,
|
|
15
|
+
useBillingContext,
|
|
16
|
+
useProduct,
|
|
17
|
+
useCheckout,
|
|
18
|
+
usePortal,
|
|
19
|
+
useSubscription,
|
|
20
|
+
} from "./hooks";
|
|
21
|
+
export type { BillingProviderProps } from "./hooks";
|
|
22
|
+
|
|
23
|
+
// Types
|
|
24
|
+
export type {
|
|
25
|
+
BillingConfig,
|
|
26
|
+
BillingProduct,
|
|
27
|
+
ProductOffer,
|
|
28
|
+
OfferFeature,
|
|
29
|
+
CheckoutResult,
|
|
30
|
+
FreeSubscriptionResult,
|
|
31
|
+
PortalResult,
|
|
32
|
+
SubscriptionInfo,
|
|
33
|
+
} from "./types";
|
|
34
|
+
|
|
35
|
+
// Components
|
|
36
|
+
export { PricingPage, PricingSection, PricingDetailPage, UpgradeModal, ManageSubscription, SubscriptionManager } from "./components";
|
|
37
|
+
export type {
|
|
38
|
+
PricingPageProps,
|
|
39
|
+
PricingSectionProps,
|
|
40
|
+
PricingDetailPageProps,
|
|
41
|
+
UpgradeModalProps,
|
|
42
|
+
ManageSubscriptionProps,
|
|
43
|
+
SubscriptionManagerProps,
|
|
44
|
+
} from "./components";
|
|
45
|
+
|
|
46
|
+
// API client (for advanced usage)
|
|
47
|
+
export { BillingApiClient } from "./utils/api";
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side billing proxy utilities for Next.js Route Handlers.
|
|
3
|
+
*
|
|
4
|
+
* Provides a typed proxy to the Django billing API, with optional
|
|
5
|
+
* camelCase-to-snake_case response normalization (Django's DRF camel-case
|
|
6
|
+
* middleware returns camelCase; some billing consumers expect snake_case).
|
|
7
|
+
*
|
|
8
|
+
* Import via: import { proxyBillingRequest, camelToSnake } from '@startsimpli/billing/server'
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { NextResponse } from 'next/server';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Recursively converts camelCase object keys to snake_case.
|
|
15
|
+
* Useful when Django's camel-case middleware returns camelCase field names
|
|
16
|
+
* but downstream consumers expect snake_case.
|
|
17
|
+
*/
|
|
18
|
+
export function camelToSnake(obj: unknown): unknown {
|
|
19
|
+
if (Array.isArray(obj)) return obj.map(camelToSnake);
|
|
20
|
+
if (obj !== null && typeof obj === 'object') {
|
|
21
|
+
return Object.fromEntries(
|
|
22
|
+
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [
|
|
23
|
+
k.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`),
|
|
24
|
+
camelToSnake(v),
|
|
25
|
+
])
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return obj;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface BillingProxyOptions {
|
|
32
|
+
method?: string;
|
|
33
|
+
body?: unknown;
|
|
34
|
+
/** If true, apply camelToSnake transform to the response body */
|
|
35
|
+
normalize?: boolean;
|
|
36
|
+
/** If true, skip Authorization header even when accessToken is provided */
|
|
37
|
+
public?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Proxy a request to the Django billing API from a Next.js Route Handler.
|
|
42
|
+
*
|
|
43
|
+
* @param apiBaseUrl - Django API base URL (e.g. process.env.NEXT_PUBLIC_API_URL)
|
|
44
|
+
* @param path - Billing API path (e.g. '/api/v1/billing/subscription/current/')
|
|
45
|
+
* @param accessToken - JWT access token from the current session (or undefined)
|
|
46
|
+
* @param options - method, body, normalize, public
|
|
47
|
+
*/
|
|
48
|
+
export async function proxyBillingRequest(
|
|
49
|
+
apiBaseUrl: string,
|
|
50
|
+
path: string,
|
|
51
|
+
accessToken: string | undefined,
|
|
52
|
+
options: BillingProxyOptions = {}
|
|
53
|
+
): Promise<NextResponse> {
|
|
54
|
+
const { method = 'GET', body, normalize = false } = options;
|
|
55
|
+
const url = `${apiBaseUrl}${path}`;
|
|
56
|
+
|
|
57
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
58
|
+
if (!options.public && accessToken) {
|
|
59
|
+
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const response = await fetch(url, {
|
|
64
|
+
method,
|
|
65
|
+
headers,
|
|
66
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
67
|
+
cache: 'no-store',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
const errorData = await response.text();
|
|
72
|
+
return NextResponse.json(
|
|
73
|
+
{ error: 'Billing API error', status: response.status, details: errorData },
|
|
74
|
+
{ status: response.status }
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const data = await response.json();
|
|
79
|
+
return NextResponse.json(normalize ? camelToSnake(data) : data);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return NextResponse.json(
|
|
82
|
+
{
|
|
83
|
+
error: 'Internal server error',
|
|
84
|
+
details: error instanceof Error ? error.message : 'Unknown error',
|
|
85
|
+
},
|
|
86
|
+
{ status: 500 }
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for @simpli/billing.
|
|
3
|
+
*
|
|
4
|
+
* These types mirror the backend API response from:
|
|
5
|
+
* GET /api/v1/billing/products/{slug}/
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ProductOffer {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
slug: string;
|
|
12
|
+
unit_price: string; // Decimal as string from DRF
|
|
13
|
+
currency: string;
|
|
14
|
+
pricing_model: "flat" | "per_seat" | "tiered" | "volume" | "usage";
|
|
15
|
+
interval_unit: "day" | "week" | "month" | "year" | null;
|
|
16
|
+
interval_count: number | null;
|
|
17
|
+
interval_display: string;
|
|
18
|
+
is_recurring: boolean;
|
|
19
|
+
is_free: boolean;
|
|
20
|
+
features: OfferFeature[];
|
|
21
|
+
is_active: boolean;
|
|
22
|
+
is_featured: boolean;
|
|
23
|
+
trial_days: number;
|
|
24
|
+
sort_order: number;
|
|
25
|
+
cta_text: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface OfferFeature {
|
|
29
|
+
key: string;
|
|
30
|
+
name: string;
|
|
31
|
+
value: boolean | number | string | null;
|
|
32
|
+
category?: string;
|
|
33
|
+
limit?: number | null;
|
|
34
|
+
tooltip?: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BillingProduct {
|
|
38
|
+
id: string;
|
|
39
|
+
slug: string;
|
|
40
|
+
name: string;
|
|
41
|
+
description: string;
|
|
42
|
+
offers: ProductOffer[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CheckoutResult {
|
|
46
|
+
session_id: string;
|
|
47
|
+
url: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface FreeSubscriptionResult {
|
|
51
|
+
subscription_id: string;
|
|
52
|
+
status: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface PortalResult {
|
|
56
|
+
url: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SubscriptionInfo {
|
|
60
|
+
id: string;
|
|
61
|
+
offer: ProductOffer;
|
|
62
|
+
status: "active" | "trialing" | "past_due" | "cancelled" | "incomplete" | "incomplete_expired" | "unpaid";
|
|
63
|
+
current_period_start: string;
|
|
64
|
+
current_period_end: string;
|
|
65
|
+
cancel_at_period_end: boolean;
|
|
66
|
+
trial_end: string | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface BillingConfig {
|
|
70
|
+
/** Base URL for the billing API (e.g., "https://api.startsimpli.com/api/v1") */
|
|
71
|
+
apiBaseUrl: string;
|
|
72
|
+
|
|
73
|
+
/** Optional auth token for authenticated endpoints (checkout, portal) */
|
|
74
|
+
authToken?: string;
|
|
75
|
+
|
|
76
|
+
/** Optional custom fetch implementation */
|
|
77
|
+
fetcher?: typeof fetch;
|
|
78
|
+
}
|