@startsimpli/billing 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +145 -0
  2. package/package.json +50 -0
  3. package/src/components/ManageSubscription.test.tsx +140 -0
  4. package/src/components/ManageSubscription.tsx +53 -0
  5. package/src/components/PricingDetailPage.integration.test.tsx +244 -0
  6. package/src/components/PricingDetailPage.test.tsx +404 -0
  7. package/src/components/PricingDetailPage.tsx +295 -0
  8. package/src/components/PricingPage.test.tsx +278 -0
  9. package/src/components/PricingPage.tsx +153 -0
  10. package/src/components/PricingSection.test.tsx +319 -0
  11. package/src/components/PricingSection.tsx +154 -0
  12. package/src/components/SubscriptionManager.test.tsx +498 -0
  13. package/src/components/SubscriptionManager.tsx +270 -0
  14. package/src/components/UpgradeModal.test.tsx +152 -0
  15. package/src/components/UpgradeModal.tsx +195 -0
  16. package/src/components/index.ts +12 -0
  17. package/src/hooks/BillingProvider.test.tsx +125 -0
  18. package/src/hooks/BillingProvider.tsx +52 -0
  19. package/src/hooks/index.ts +6 -0
  20. package/src/hooks/useCheckout.test.tsx +232 -0
  21. package/src/hooks/useCheckout.ts +75 -0
  22. package/src/hooks/usePortal.test.tsx +189 -0
  23. package/src/hooks/usePortal.ts +43 -0
  24. package/src/hooks/useProduct.test.tsx +155 -0
  25. package/src/hooks/useProduct.ts +43 -0
  26. package/src/hooks/useSubscription.test.tsx +167 -0
  27. package/src/hooks/useSubscription.ts +40 -0
  28. package/src/index.ts +47 -0
  29. package/src/server/index.ts +2 -0
  30. package/src/server/proxy.ts +89 -0
  31. package/src/types/index.ts +78 -0
  32. package/src/utils/api.test.ts +129 -0
  33. package/src/utils/api.ts +123 -0
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { BillingApiClient } from "./api";
3
+
4
+ describe("BillingApiClient", () => {
5
+ const mockFetch = vi.fn();
6
+
7
+ function createClient(authToken?: string) {
8
+ return new BillingApiClient({
9
+ apiBaseUrl: "https://api.test.com/api/v1",
10
+ authToken,
11
+ fetcher: mockFetch as unknown as typeof fetch,
12
+ });
13
+ }
14
+
15
+ beforeEach(() => {
16
+ mockFetch.mockReset();
17
+ });
18
+
19
+ describe("getProduct", () => {
20
+ it("calls correct URL with no auth", async () => {
21
+ mockFetch.mockResolvedValue({
22
+ ok: true,
23
+ json: () =>
24
+ Promise.resolve({ slug: "test", name: "Test", offers: [] }),
25
+ });
26
+
27
+ const client = createClient("tok_123");
28
+ await client.getProduct("test-product");
29
+
30
+ expect(mockFetch).toHaveBeenCalledWith(
31
+ "https://api.test.com/api/v1/billing/products/test-product/",
32
+ expect.objectContaining({
33
+ headers: expect.not.objectContaining({
34
+ Authorization: expect.any(String),
35
+ }),
36
+ })
37
+ );
38
+ });
39
+
40
+ it("throws on 404", async () => {
41
+ mockFetch.mockResolvedValue({ ok: false, status: 404 });
42
+
43
+ const client = createClient();
44
+ await expect(client.getProduct("nope")).rejects.toThrow("404");
45
+ });
46
+ });
47
+
48
+ describe("createCheckout", () => {
49
+ it("sends POST with auth header", async () => {
50
+ mockFetch.mockResolvedValue({
51
+ ok: true,
52
+ json: () =>
53
+ Promise.resolve({ session_id: "cs_1", url: "https://pay.com" }),
54
+ });
55
+
56
+ const client = createClient("tok_abc");
57
+ const result = await client.createCheckout({
58
+ offerId: "offer-1",
59
+ successUrl: "https://ok.com",
60
+ cancelUrl: "https://nope.com",
61
+ });
62
+
63
+ expect(result.session_id).toBe("cs_1");
64
+ expect(mockFetch).toHaveBeenCalledWith(
65
+ "https://api.test.com/api/v1/billing/offer-checkout/",
66
+ expect.objectContaining({
67
+ method: "POST",
68
+ headers: expect.objectContaining({
69
+ Authorization: "Bearer tok_abc",
70
+ }),
71
+ })
72
+ );
73
+ });
74
+
75
+ it("includes quantity in request body", async () => {
76
+ mockFetch.mockResolvedValue({
77
+ ok: true,
78
+ json: () =>
79
+ Promise.resolve({ session_id: "cs_1", url: "https://pay.com" }),
80
+ });
81
+
82
+ const client = createClient("tok");
83
+ await client.createCheckout({
84
+ offerId: "offer-1",
85
+ successUrl: "https://ok.com",
86
+ cancelUrl: "https://nope.com",
87
+ quantity: 10,
88
+ });
89
+
90
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
91
+ expect(body.quantity).toBe(10);
92
+ });
93
+
94
+ it("throws with detail message on failure", async () => {
95
+ mockFetch.mockResolvedValue({
96
+ ok: false,
97
+ status: 400,
98
+ json: () => Promise.resolve({ detail: "Offer not synced" }),
99
+ });
100
+
101
+ const client = createClient("tok");
102
+ await expect(
103
+ client.createCheckout({
104
+ offerId: "bad",
105
+ successUrl: "x",
106
+ cancelUrl: "y",
107
+ })
108
+ ).rejects.toThrow("Offer not synced");
109
+ });
110
+ });
111
+
112
+ describe("createPortal", () => {
113
+ it("sends POST with return_url", async () => {
114
+ mockFetch.mockResolvedValue({
115
+ ok: true,
116
+ json: () =>
117
+ Promise.resolve({ url: "https://portal.stripe.com/test" }),
118
+ });
119
+
120
+ const client = createClient("tok_xyz");
121
+ const result = await client.createPortal("https://app.com/settings");
122
+
123
+ expect(result.url).toBe("https://portal.stripe.com/test");
124
+
125
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
126
+ expect(body.return_url).toBe("https://app.com/settings");
127
+ });
128
+ });
129
+ });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * API client for billing endpoints.
3
+ */
4
+
5
+ import type {
6
+ BillingConfig,
7
+ BillingProduct,
8
+ CheckoutResult,
9
+ FreeSubscriptionResult,
10
+ PortalResult,
11
+ SubscriptionInfo,
12
+ } from "../types";
13
+
14
+ export class BillingApiClient {
15
+ private config: BillingConfig;
16
+
17
+ constructor(config: BillingConfig) {
18
+ this.config = config;
19
+ }
20
+
21
+ private get fetcher() {
22
+ return this.config.fetcher ?? fetch.bind(globalThis);
23
+ }
24
+
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;
33
+ }
34
+
35
+ /** Fetch a public billing product by slug. */
36
+ async getProduct(slug: string): Promise<BillingProduct> {
37
+ const url = `${this.config.apiBaseUrl}/billing/products/${slug}/`;
38
+ const resp = await this.fetcher(url, {
39
+ headers: this.headers(false),
40
+ });
41
+ if (!resp.ok) {
42
+ throw new Error(`Failed to fetch product "${slug}": ${resp.status}`);
43
+ }
44
+ return resp.json();
45
+ }
46
+
47
+ /** Create a checkout session for an offer. */
48
+ async createCheckout(params: {
49
+ offerId: string;
50
+ successUrl: string;
51
+ cancelUrl: string;
52
+ quantity?: number;
53
+ }): Promise<CheckoutResult> {
54
+ const url = `${this.config.apiBaseUrl}/billing/offer-checkout/`;
55
+ const resp = await this.fetcher(url, {
56
+ method: "POST",
57
+ headers: this.headers(true),
58
+ body: JSON.stringify({
59
+ offer_id: params.offerId,
60
+ success_url: params.successUrl,
61
+ cancel_url: params.cancelUrl,
62
+ quantity: params.quantity ?? 1,
63
+ }),
64
+ });
65
+ if (!resp.ok) {
66
+ const data = await resp.json().catch(() => ({}));
67
+ throw new Error(
68
+ data.detail ?? `Checkout failed: ${resp.status}`
69
+ );
70
+ }
71
+ return resp.json();
72
+ }
73
+
74
+ /** Subscribe to a free offer (no billing provider). */
75
+ async subscribeFree(offerId: string): Promise<FreeSubscriptionResult> {
76
+ const url = `${this.config.apiBaseUrl}/billing/subscribe-free/`;
77
+ const resp = await this.fetcher(url, {
78
+ method: "POST",
79
+ headers: this.headers(true),
80
+ body: JSON.stringify({ offer_id: offerId }),
81
+ });
82
+ if (!resp.ok) {
83
+ const data = await resp.json().catch(() => ({}));
84
+ throw new Error(data.detail ?? `Free signup failed: ${resp.status}`);
85
+ }
86
+ return resp.json();
87
+ }
88
+
89
+ /** Create a customer portal session. */
90
+ async createPortal(returnUrl: string): Promise<PortalResult> {
91
+ const url = `${this.config.apiBaseUrl}/billing/offer-portal/`;
92
+ const resp = await this.fetcher(url, {
93
+ method: "POST",
94
+ headers: this.headers(true),
95
+ body: JSON.stringify({ return_url: returnUrl }),
96
+ });
97
+ if (!resp.ok) {
98
+ const data = await resp.json().catch(() => ({}));
99
+ throw new Error(
100
+ data.detail ?? `Portal session failed: ${resp.status}`
101
+ );
102
+ }
103
+ return resp.json();
104
+ }
105
+
106
+ /** Get current user's subscription. */
107
+ async getSubscription(): Promise<SubscriptionInfo | null> {
108
+ const url = `${this.config.apiBaseUrl}/billing/subscription/current/`;
109
+ const resp = await this.fetcher(url, {
110
+ headers: this.headers(true),
111
+ });
112
+ if (resp.status === 404) {
113
+ return null; // No active subscription
114
+ }
115
+ if (!resp.ok) {
116
+ const data = await resp.json().catch(() => ({}));
117
+ throw new Error(
118
+ data.detail ?? `Failed to fetch subscription: ${resp.status}`
119
+ );
120
+ }
121
+ return resp.json();
122
+ }
123
+ }