@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,498 @@
1
+ /**
2
+ * TDD tests for SubscriptionManager component.
3
+ *
4
+ * SubscriptionManager is an interactive subscription management widget
5
+ * for logged-in users. It displays the current subscription, allows
6
+ * upgrade/downgrade options, and provides portal access.
7
+ *
8
+ * Usage:
9
+ * <SubscriptionManager
10
+ * productId="raise-simpli"
11
+ * returnUrl="https://app.example.com/settings"
12
+ * onPlanChange={(offer) => console.log(offer)}
13
+ * />
14
+ */
15
+
16
+ import { describe, it, expect, vi } from "vitest";
17
+ import { render, screen, waitFor } from "@testing-library/react";
18
+ import userEvent from "@testing-library/user-event";
19
+ import React from "react";
20
+ import { BillingProvider } from "../hooks/BillingProvider";
21
+ import { SubscriptionManager } from "./SubscriptionManager";
22
+ import type { BillingProduct, SubscriptionInfo } from "../types";
23
+
24
+ const mockProduct: BillingProduct = {
25
+ id: "prod-1",
26
+ slug: "raise-simpli",
27
+ name: "RaiseSimpli",
28
+ description: "VC fundraising platform",
29
+ offers: [
30
+ {
31
+ id: "offer-1",
32
+ name: "Starter",
33
+ slug: "starter-monthly",
34
+ unit_price: "0.00",
35
+ currency: "USD",
36
+ pricing_model: "flat",
37
+ interval_unit: "month",
38
+ interval_count: 1,
39
+ interval_display: "per month",
40
+ is_recurring: true,
41
+ is_free: true,
42
+ features: [
43
+ { key: "seats", name: "Team seats", value: 1, limit: 1 },
44
+ { key: "projects", name: "Projects", value: 3, limit: 3 },
45
+ ],
46
+ is_active: true,
47
+ is_featured: false,
48
+ trial_days: 0,
49
+ sort_order: 0,
50
+ cta_text: "Start Free",
51
+ },
52
+ {
53
+ id: "offer-2",
54
+ name: "Pro",
55
+ slug: "pro-monthly",
56
+ unit_price: "49.00",
57
+ currency: "USD",
58
+ pricing_model: "flat",
59
+ interval_unit: "month",
60
+ interval_count: 1,
61
+ interval_display: "per month",
62
+ is_recurring: true,
63
+ is_free: false,
64
+ features: [
65
+ { key: "seats", name: "Team seats", value: 5, limit: 5 },
66
+ { key: "projects", name: "Projects", value: "Unlimited" },
67
+ ],
68
+ is_active: true,
69
+ is_featured: true,
70
+ trial_days: 14,
71
+ sort_order: 1,
72
+ cta_text: "Start Trial",
73
+ },
74
+ {
75
+ id: "offer-3",
76
+ name: "Enterprise",
77
+ slug: "enterprise-monthly",
78
+ unit_price: "199.00",
79
+ currency: "USD",
80
+ pricing_model: "per_seat",
81
+ interval_unit: "month",
82
+ interval_count: 1,
83
+ interval_display: "per month",
84
+ is_recurring: true,
85
+ is_free: false,
86
+ features: [
87
+ { key: "seats", name: "Team seats", value: "Unlimited" },
88
+ { key: "projects", name: "Projects", value: "Unlimited" },
89
+ ],
90
+ is_active: true,
91
+ is_featured: false,
92
+ trial_days: 0,
93
+ sort_order: 2,
94
+ cta_text: "Contact Sales",
95
+ },
96
+ ],
97
+ };
98
+
99
+ const mockSubscriptionActive: SubscriptionInfo = {
100
+ id: "sub-123",
101
+ offer: mockProduct.offers[1], // Pro plan
102
+ status: "active",
103
+ current_period_start: "2026-01-01T00:00:00Z",
104
+ current_period_end: "2026-02-01T00:00:00Z",
105
+ cancel_at_period_end: false,
106
+ trial_end: null,
107
+ };
108
+
109
+ const mockSubscriptionTrialing: SubscriptionInfo = {
110
+ id: "sub-456",
111
+ offer: mockProduct.offers[1], // Pro plan
112
+ status: "trialing",
113
+ current_period_start: "2026-01-01T00:00:00Z",
114
+ current_period_end: "2026-02-01T00:00:00Z",
115
+ cancel_at_period_end: false,
116
+ trial_end: "2026-01-15T00:00:00Z",
117
+ };
118
+
119
+ const mockSubscriptionPastDue: SubscriptionInfo = {
120
+ id: "sub-789",
121
+ offer: mockProduct.offers[1], // Pro plan
122
+ status: "past_due",
123
+ current_period_start: "2026-01-01T00:00:00Z",
124
+ current_period_end: "2026-02-01T00:00:00Z",
125
+ cancel_at_period_end: false,
126
+ trial_end: null,
127
+ };
128
+
129
+ const mockSubscriptionCancelled: SubscriptionInfo = {
130
+ id: "sub-999",
131
+ offer: mockProduct.offers[1], // Pro plan
132
+ status: "cancelled",
133
+ current_period_start: "2026-01-01T00:00:00Z",
134
+ current_period_end: "2026-02-01T00:00:00Z",
135
+ cancel_at_period_end: true,
136
+ trial_end: null,
137
+ };
138
+
139
+ function createWrapper(fetcher: typeof fetch) {
140
+ return function Wrapper({ children }: { children: React.ReactNode }) {
141
+ return (
142
+ <BillingProvider
143
+ apiBaseUrl="https://api.test.com/api/v1"
144
+ authToken="tok_123"
145
+ fetcher={fetcher}
146
+ >
147
+ {children}
148
+ </BillingProvider>
149
+ );
150
+ };
151
+ }
152
+
153
+ function mockFetchRouter(routes: Record<string, any>) {
154
+ return vi.fn((url: string) => {
155
+ // Match subscription endpoint
156
+ if (url.includes("/billing/subscription/current/")) {
157
+ const response = routes["/billing/subscription/current/"];
158
+ if (response === null) {
159
+ return Promise.resolve({
160
+ ok: false,
161
+ status: 404,
162
+ json: () => Promise.resolve({ detail: "No subscription found" }),
163
+ });
164
+ }
165
+ if (response instanceof Error) {
166
+ return Promise.resolve({
167
+ ok: false,
168
+ status: 500,
169
+ json: () => Promise.resolve({ detail: response.message }),
170
+ });
171
+ }
172
+ return Promise.resolve({
173
+ ok: true,
174
+ json: () => Promise.resolve(response),
175
+ });
176
+ }
177
+
178
+ // Match product endpoint
179
+ if (url.includes("/billing/products/")) {
180
+ const response = routes["/billing/products/"];
181
+ return Promise.resolve({
182
+ ok: true,
183
+ json: () => Promise.resolve(response),
184
+ });
185
+ }
186
+
187
+ return Promise.resolve({
188
+ ok: false,
189
+ status: 404,
190
+ });
191
+ }) as unknown as typeof fetch;
192
+ }
193
+
194
+ describe("SubscriptionManager", () => {
195
+ it("renders loading state while fetching subscription", () => {
196
+ // Never resolves — stays in loading
197
+ const fetcher = vi.fn().mockReturnValue(
198
+ new Promise(() => {})
199
+ ) as unknown as typeof fetch;
200
+ const Wrapper = createWrapper(fetcher);
201
+
202
+ render(
203
+ <Wrapper>
204
+ <SubscriptionManager
205
+ productId="raise-simpli"
206
+ returnUrl="https://app.test.com/settings"
207
+ />
208
+ </Wrapper>
209
+ );
210
+
211
+ expect(screen.getByTestId("subscription-manager")).toBeDefined();
212
+ expect(screen.getByText(/loading/i)).toBeDefined();
213
+ });
214
+
215
+ it("shows current plan details when subscribed (plan name, price)", async () => {
216
+ const fetcher = mockFetchRouter({
217
+ "/billing/subscription/current/": mockSubscriptionActive,
218
+ "/billing/products/": mockProduct,
219
+ });
220
+ const Wrapper = createWrapper(fetcher);
221
+
222
+ render(
223
+ <Wrapper>
224
+ <SubscriptionManager
225
+ productId="raise-simpli"
226
+ returnUrl="https://app.test.com/settings"
227
+ />
228
+ </Wrapper>
229
+ );
230
+
231
+ await waitFor(() => {
232
+ expect(screen.getByTestId("current-plan")).toBeDefined();
233
+ });
234
+
235
+ // Check plan name
236
+ expect(screen.getByText("Pro")).toBeDefined();
237
+
238
+ // Check price
239
+ expect(screen.getByText(/\$49/)).toBeDefined();
240
+ });
241
+
242
+ it("shows subscription status badge (active, trialing, past_due, cancelled)", async () => {
243
+ const fetcher = mockFetchRouter({
244
+ "/billing/subscription/current/": mockSubscriptionActive,
245
+ "/billing/products/": mockProduct,
246
+ });
247
+ const Wrapper = createWrapper(fetcher);
248
+
249
+ render(
250
+ <Wrapper>
251
+ <SubscriptionManager
252
+ productId="raise-simpli"
253
+ returnUrl="https://app.test.com/settings"
254
+ />
255
+ </Wrapper>
256
+ );
257
+
258
+ await waitFor(() => {
259
+ expect(screen.getByTestId("plan-status")).toBeDefined();
260
+ });
261
+
262
+ // Check status badge displays "active"
263
+ expect(screen.getByText(/active/i)).toBeDefined();
264
+ });
265
+
266
+ it("shows trialing badge with trial end date when trialing", async () => {
267
+ const fetcher = mockFetchRouter({
268
+ "/billing/subscription/current/": mockSubscriptionTrialing,
269
+ "/billing/products/": mockProduct,
270
+ });
271
+ const Wrapper = createWrapper(fetcher);
272
+
273
+ render(
274
+ <Wrapper>
275
+ <SubscriptionManager
276
+ productId="raise-simpli"
277
+ returnUrl="https://app.test.com/settings"
278
+ />
279
+ </Wrapper>
280
+ );
281
+
282
+ await waitFor(() => {
283
+ expect(screen.getByTestId("plan-status")).toBeDefined();
284
+ });
285
+
286
+ // Check trialing status
287
+ expect(screen.getByText(/trialing/i)).toBeDefined();
288
+
289
+ // Check trial end date is displayed
290
+ expect(screen.getByText(/2026-01-15/)).toBeDefined();
291
+ });
292
+
293
+ it("shows billing period dates", async () => {
294
+ const fetcher = mockFetchRouter({
295
+ "/billing/subscription/current/": mockSubscriptionActive,
296
+ "/billing/products/": mockProduct,
297
+ });
298
+ const Wrapper = createWrapper(fetcher);
299
+
300
+ render(
301
+ <Wrapper>
302
+ <SubscriptionManager
303
+ productId="raise-simpli"
304
+ returnUrl="https://app.test.com/settings"
305
+ />
306
+ </Wrapper>
307
+ );
308
+
309
+ await waitFor(() => {
310
+ expect(screen.getByTestId("current-plan")).toBeDefined();
311
+ });
312
+
313
+ // Check billing period dates are displayed
314
+ expect(screen.getByText(/2026-01-01/)).toBeDefined();
315
+ expect(screen.getByText(/2026-02-01/)).toBeDefined();
316
+ });
317
+
318
+ it("shows available offers for upgrade (excludes current plan)", async () => {
319
+ const fetcher = mockFetchRouter({
320
+ "/billing/subscription/current/": mockSubscriptionActive, // Currently on Pro
321
+ "/billing/products/": mockProduct,
322
+ });
323
+ const Wrapper = createWrapper(fetcher);
324
+
325
+ render(
326
+ <Wrapper>
327
+ <SubscriptionManager
328
+ productId="raise-simpli"
329
+ returnUrl="https://app.test.com/settings"
330
+ />
331
+ </Wrapper>
332
+ );
333
+
334
+ await waitFor(() => {
335
+ expect(screen.getByTestId("available-plans")).toBeDefined();
336
+ });
337
+
338
+ // Should show Starter and Enterprise, but NOT Pro (current plan)
339
+ expect(screen.getByText("Starter")).toBeDefined();
340
+ expect(screen.getByText("Enterprise")).toBeDefined();
341
+
342
+ // Pro should only appear once (in current plan section)
343
+ const proElements = screen.getAllByText("Pro");
344
+ expect(proElements.length).toBe(1);
345
+ });
346
+
347
+ it("calls onPlanChange when upgrade option clicked", async () => {
348
+ const onPlanChange = vi.fn();
349
+ const fetcher = mockFetchRouter({
350
+ "/billing/subscription/current/": mockSubscriptionActive, // Currently on Pro
351
+ "/billing/products/": mockProduct,
352
+ });
353
+ const Wrapper = createWrapper(fetcher);
354
+
355
+ render(
356
+ <Wrapper>
357
+ <SubscriptionManager
358
+ productId="raise-simpli"
359
+ returnUrl="https://app.test.com/settings"
360
+ onPlanChange={onPlanChange}
361
+ />
362
+ </Wrapper>
363
+ );
364
+
365
+ await waitFor(() => {
366
+ expect(screen.getByTestId("available-plans")).toBeDefined();
367
+ });
368
+
369
+ // Click Enterprise upgrade option
370
+ const enterpriseButton = screen.getByTestId("upgrade-cta-enterprise-monthly");
371
+ await userEvent.click(enterpriseButton);
372
+
373
+ expect(onPlanChange).toHaveBeenCalledWith(
374
+ expect.objectContaining({ slug: "enterprise-monthly" })
375
+ );
376
+ });
377
+
378
+ it("shows Manage Billing portal button", async () => {
379
+ const fetcher = mockFetchRouter({
380
+ "/billing/subscription/current/": mockSubscriptionActive,
381
+ "/billing/products/": mockProduct,
382
+ });
383
+ const Wrapper = createWrapper(fetcher);
384
+
385
+ render(
386
+ <Wrapper>
387
+ <SubscriptionManager
388
+ productId="raise-simpli"
389
+ returnUrl="https://app.test.com/settings"
390
+ />
391
+ </Wrapper>
392
+ );
393
+
394
+ await waitFor(() => {
395
+ expect(screen.getByTestId("portal-button")).toBeDefined();
396
+ });
397
+
398
+ expect(screen.getByText(/manage billing/i)).toBeDefined();
399
+ });
400
+
401
+ it("shows No active subscription when no subscription exists", async () => {
402
+ const fetcher = mockFetchRouter({
403
+ "/billing/subscription/current/": null, // 404 - no subscription
404
+ "/billing/products/": mockProduct,
405
+ });
406
+ const Wrapper = createWrapper(fetcher);
407
+
408
+ render(
409
+ <Wrapper>
410
+ <SubscriptionManager
411
+ productId="raise-simpli"
412
+ returnUrl="https://app.test.com/settings"
413
+ />
414
+ </Wrapper>
415
+ );
416
+
417
+ await waitFor(() => {
418
+ expect(screen.getByText(/no active subscription/i)).toBeDefined();
419
+ });
420
+ });
421
+
422
+ it("shows subscribe options when no subscription", async () => {
423
+ const fetcher = mockFetchRouter({
424
+ "/billing/subscription/current/": null, // 404 - no subscription
425
+ "/billing/products/": mockProduct,
426
+ });
427
+ const Wrapper = createWrapper(fetcher);
428
+
429
+ render(
430
+ <Wrapper>
431
+ <SubscriptionManager
432
+ productId="raise-simpli"
433
+ returnUrl="https://app.test.com/settings"
434
+ />
435
+ </Wrapper>
436
+ );
437
+
438
+ await waitFor(() => {
439
+ expect(screen.getByTestId("available-plans")).toBeDefined();
440
+ });
441
+
442
+ // Should show all available plans
443
+ expect(screen.getByText("Starter")).toBeDefined();
444
+ expect(screen.getByText("Pro")).toBeDefined();
445
+ expect(screen.getByText("Enterprise")).toBeDefined();
446
+ });
447
+
448
+ it("handles subscription fetch error", async () => {
449
+ const fetcher = mockFetchRouter({
450
+ "/billing/subscription/current/": new Error("Network error"),
451
+ "/billing/products/": mockProduct,
452
+ });
453
+ const Wrapper = createWrapper(fetcher);
454
+
455
+ render(
456
+ <Wrapper>
457
+ <SubscriptionManager
458
+ productId="raise-simpli"
459
+ returnUrl="https://app.test.com/settings"
460
+ />
461
+ </Wrapper>
462
+ );
463
+
464
+ await waitFor(() => {
465
+ expect(screen.getByText(/error/i)).toBeDefined();
466
+ });
467
+
468
+ expect(screen.getByText(/network error/i)).toBeDefined();
469
+ });
470
+
471
+ it("shows past due warning styling", async () => {
472
+ const fetcher = mockFetchRouter({
473
+ "/billing/subscription/current/": mockSubscriptionPastDue,
474
+ "/billing/products/": mockProduct,
475
+ });
476
+ const Wrapper = createWrapper(fetcher);
477
+
478
+ render(
479
+ <Wrapper>
480
+ <SubscriptionManager
481
+ productId="raise-simpli"
482
+ returnUrl="https://app.test.com/settings"
483
+ />
484
+ </Wrapper>
485
+ );
486
+
487
+ await waitFor(() => {
488
+ expect(screen.getByTestId("plan-status")).toBeDefined();
489
+ });
490
+
491
+ // Check past_due status is displayed
492
+ expect(screen.getByText(/past.due/i)).toBeDefined();
493
+
494
+ // Check for warning styling indicator
495
+ const statusBadge = screen.getByTestId("plan-status");
496
+ expect(statusBadge.className).toMatch(/warning|error|danger|alert/i);
497
+ });
498
+ });