@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
package/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # @startsimpli/billing
2
+
3
+ Universal billing integration for StartSimpli Next.js apps. Products and pricing are configured in Django admin — frontend components auto-fetch and render.
4
+
5
+ ## Backend Setup
6
+
7
+ The billing backend lives in `start-simpli-api/apps/billing/`. Models, admin, and API are already configured.
8
+
9
+ ### Key Endpoints
10
+
11
+ | Endpoint | Method | Auth | Description |
12
+ |----------|--------|------|-------------|
13
+ | `/api/v1/billing/products/` | GET | Public | List public products with offers |
14
+ | `/api/v1/billing/products/{slug}/` | GET | Public | Get product by slug |
15
+ | `/api/v1/billing/offer-checkout/` | POST | Required | Create checkout session |
16
+ | `/api/v1/billing/offer-portal/` | POST | Required | Create portal session |
17
+ | `/api/v1/billing/subscription/current/` | GET | Required | Get current user's subscription |
18
+
19
+ ### Admin Workflow
20
+
21
+ 1. Add a `BillingProviderCredential` (Stripe secret key + webhook secret)
22
+ 2. Create a `BillingProduct` (slug = identifier used by frontend)
23
+ 3. Add `ProductOffer` inlines (pricing tiers)
24
+ 4. Use "Sync to provider" admin action to push to Stripe
25
+
26
+ ## Frontend Setup
27
+
28
+ ### Installation
29
+
30
+ In your Next.js app's `package.json`:
31
+
32
+ ```json
33
+ {
34
+ "dependencies": {
35
+ "@startsimpli/billing": "workspace:*"
36
+ }
37
+ }
38
+ ```
39
+
40
+ Add `transpilePackages: ['@startsimpli/billing']` to `next.config.js`.
41
+
42
+ ### Usage
43
+
44
+ ```tsx
45
+ import { BillingProvider, PricingPage, useCheckout, ManageSubscription } from '@startsimpli/billing'
46
+
47
+ // Wrap your app (or a subtree) with BillingProvider
48
+ <BillingProvider apiBaseUrl="/api/v1" authToken={accessToken}>
49
+ <PricingPage productId="raise-simpli" onSelectOffer={handleSelect} />
50
+ </BillingProvider>
51
+
52
+ // Checkout hook
53
+ const { checkout, loading } = useCheckout()
54
+ await checkout({ offerId, successUrl, cancelUrl })
55
+
56
+ // Subscription management
57
+ <ManageSubscription returnUrl="/settings" buttonText="Manage Billing" />
58
+ ```
59
+
60
+ ### Components
61
+
62
+ | Component | Props | Description |
63
+ |-----------|-------|-------------|
64
+ | `BillingProvider` | `apiBaseUrl`, `authToken` | Context provider — required wrapper |
65
+ | `PricingPage` | `productId`, `onSelectOffer?` | Full pricing display with offer cards |
66
+ | `PricingSection` | `productId`, `onSelectOffer?` | Pricing section component |
67
+ | `PricingDetailPage` | `productId`, `onSelectOffer?` | Detailed pricing page |
68
+ | `UpgradeModal` | `productId`, `open`, `onClose`, `onSelectOffer?` | Modal pricing overlay |
69
+ | `ManageSubscription` | `returnUrl`, `buttonText?` | Portal redirect button |
70
+ | `SubscriptionManager` | `productId?` | Full subscription management interface |
71
+
72
+ ### Hooks
73
+
74
+ | Hook | Returns | Description |
75
+ |------|---------|-------------|
76
+ | `useProduct(slug)` | `{ product, loading, error }` | Fetch product + offers |
77
+ | `useCheckout()` | `{ checkout, subscribeFree, loading, error }` | Create checkout session or free subscription |
78
+ | `usePortal()` | `{ openPortal, loading, error }` | Open customer portal |
79
+ | `useSubscription()` | `{ subscription, loading, error, refetch }` | Get current user's subscription |
80
+
81
+ ## Architecture
82
+
83
+ - **Provider-agnostic**: `BaseBillingProvider` → `StripeBillingProvider` (extensible to Paddle, etc.)
84
+ - **BillingProviderFactory**: Resolves credentials per-team with global fallback
85
+ - **BillingService**: Orchestrates sync, checkout, and portal operations
86
+ - **ProductOffer**: Supports flat, per_seat, tiered, volume, and usage pricing models
87
+
88
+ ## Integration with @startsimpli/auth
89
+
90
+ The billing package integrates seamlessly with `@startsimpli/auth`:
91
+
92
+ ```tsx
93
+ import { BillingProvider } from '@startsimpli/billing'
94
+ import { useAuth } from '@startsimpli/auth'
95
+
96
+ function App() {
97
+ const { tokens } = useAuth()
98
+
99
+ return (
100
+ <BillingProvider
101
+ apiBaseUrl={process.env.NEXT_PUBLIC_API_URL + '/api/v1'}
102
+ authToken={tokens?.access}
103
+ >
104
+ {/* Your app */}
105
+ </BillingProvider>
106
+ )
107
+ }
108
+ ```
109
+
110
+ ## Environment Variables
111
+
112
+ ```env
113
+ NEXT_PUBLIC_API_URL=https://api.startsimpli.com
114
+ ```
115
+
116
+ Backend requires:
117
+ ```env
118
+ STRIPE_SECRET_KEY=sk_test_...
119
+ STRIPE_WEBHOOK_SECRET=whsec_...
120
+ ```
121
+
122
+ ## Tests
123
+
124
+ ```bash
125
+ # Frontend tests (39 tests)
126
+ cd packages/billing
127
+ npm test
128
+
129
+ # Backend tests (184 tests)
130
+ cd start-simpli-api
131
+ docker-compose -f docker-compose.local.yml exec -T django pytest apps/billing/tests/ -v
132
+ ```
133
+
134
+ ## Development
135
+
136
+ ```bash
137
+ # Type checking
138
+ npm run type-check
139
+
140
+ # Watch mode for tests
141
+ npm run test:watch
142
+
143
+ # Coverage report
144
+ npm run test:coverage
145
+ ```
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@startsimpli/billing",
3
+ "version": "0.1.0",
4
+ "description": "Universal billing integration for StartSimpli Next.js apps",
5
+ "main": "./src/index.ts",
6
+ "types": "./src/index.ts",
7
+ "files": ["src"],
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "exports": {
12
+ ".": "./src/index.ts",
13
+ "./client": "./src/index.ts",
14
+ "./server": "./src/server/index.ts",
15
+ "./types": "./src/types/index.ts"
16
+ },
17
+ "scripts": {
18
+ "test": "vitest run",
19
+ "test:watch": "vitest",
20
+ "test:coverage": "vitest run --coverage",
21
+ "type-check": "tsc --noEmit"
22
+ },
23
+ "peerDependencies": {
24
+ "next": ">=14.0.0",
25
+ "react": "^18.0.0 || ^19.0.0",
26
+ "react-dom": "^18.0.0 || ^19.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "@testing-library/jest-dom": "^6.0.0",
30
+ "@testing-library/react": "^16.0.0",
31
+ "@testing-library/user-event": "^14.6.1",
32
+ "@types/node": "^20.17.14",
33
+ "@types/react": "^19.2.0",
34
+ "@types/react-dom": "^19.2.0",
35
+ "@vitest/ui": "^3.0.0",
36
+ "jsdom": "^25.0.0",
37
+ "next": "^15.5.12",
38
+ "react": "^19.2.4",
39
+ "react-dom": "^19.2.4",
40
+ "typescript": "^5.7.3",
41
+ "vitest": "^3.0.0"
42
+ },
43
+ "keywords": [
44
+ "billing",
45
+ "stripe",
46
+ "nextjs",
47
+ "django",
48
+ "startsimpli"
49
+ ]
50
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * TDD tests for ManageSubscription component.
3
+ *
4
+ * ManageSubscription is a button/link that opens the billing provider's
5
+ * customer portal for managing an existing subscription.
6
+ *
7
+ * Usage:
8
+ * <ManageSubscription returnUrl="https://app.example.com/settings" />
9
+ */
10
+
11
+ import { describe, it, expect, vi } from "vitest";
12
+ import { render, screen, waitFor } from "@testing-library/react";
13
+ import userEvent from "@testing-library/user-event";
14
+ import React from "react";
15
+ import { BillingProvider } from "../hooks/BillingProvider";
16
+ import { ManageSubscription } from "./ManageSubscription";
17
+
18
+ function createWrapper(fetcher: typeof fetch) {
19
+ return function Wrapper({ children }: { children: React.ReactNode }) {
20
+ return (
21
+ <BillingProvider
22
+ apiBaseUrl="https://api.test.com/api/v1"
23
+ authToken="tok_123"
24
+ fetcher={fetcher}
25
+ >
26
+ {children}
27
+ </BillingProvider>
28
+ );
29
+ };
30
+ }
31
+
32
+ describe("ManageSubscription", () => {
33
+ it("renders a manage billing button", () => {
34
+ const mockFetch = vi.fn() as unknown as typeof fetch;
35
+ const Wrapper = createWrapper(mockFetch);
36
+
37
+ render(
38
+ <Wrapper>
39
+ <ManageSubscription returnUrl="https://app.test.com/settings" />
40
+ </Wrapper>
41
+ );
42
+
43
+ expect(screen.getByText(/manage/i)).toBeDefined();
44
+ });
45
+
46
+ it("calls portal API on click and redirects", async () => {
47
+ const mockFetch = vi.fn().mockResolvedValue({
48
+ ok: true,
49
+ json: () =>
50
+ Promise.resolve({ url: "https://billing.stripe.com/portal/test" }),
51
+ }) as unknown as typeof fetch;
52
+ const Wrapper = createWrapper(mockFetch);
53
+
54
+ // Mock window.location.href
55
+ const originalLocation = window.location;
56
+ const mockLocation = { ...originalLocation, href: "" };
57
+ Object.defineProperty(window, "location", {
58
+ value: mockLocation,
59
+ writable: true,
60
+ });
61
+
62
+ render(
63
+ <Wrapper>
64
+ <ManageSubscription returnUrl="https://app.test.com/settings" />
65
+ </Wrapper>
66
+ );
67
+
68
+ await userEvent.click(screen.getByText(/manage/i));
69
+
70
+ await waitFor(() => {
71
+ expect(mockLocation.href).toBe(
72
+ "https://billing.stripe.com/portal/test"
73
+ );
74
+ });
75
+
76
+ // Restore
77
+ Object.defineProperty(window, "location", {
78
+ value: originalLocation,
79
+ writable: true,
80
+ });
81
+ });
82
+
83
+ it("shows loading state while creating portal session", async () => {
84
+ const mockFetch = vi.fn().mockReturnValue(
85
+ new Promise(() => {})
86
+ ) as unknown as typeof fetch;
87
+ const Wrapper = createWrapper(mockFetch);
88
+
89
+ render(
90
+ <Wrapper>
91
+ <ManageSubscription returnUrl="https://app.test.com/settings" />
92
+ </Wrapper>
93
+ );
94
+
95
+ await userEvent.click(screen.getByText(/manage/i));
96
+
97
+ await waitFor(() => {
98
+ const button = screen.getByRole("button");
99
+ expect(button.getAttribute("disabled")).not.toBeNull();
100
+ });
101
+ });
102
+
103
+ it("shows error on portal failure", async () => {
104
+ const mockFetch = vi.fn().mockResolvedValue({
105
+ ok: false,
106
+ status: 404,
107
+ json: () =>
108
+ Promise.resolve({ detail: "No active subscription found" }),
109
+ }) as unknown as typeof fetch;
110
+ const Wrapper = createWrapper(mockFetch);
111
+
112
+ render(
113
+ <Wrapper>
114
+ <ManageSubscription returnUrl="https://app.test.com/settings" />
115
+ </Wrapper>
116
+ );
117
+
118
+ await userEvent.click(screen.getByText(/manage/i));
119
+
120
+ await waitFor(() => {
121
+ expect(screen.getByText(/no active subscription/i)).toBeDefined();
122
+ });
123
+ });
124
+
125
+ it("accepts custom button text", () => {
126
+ const mockFetch = vi.fn() as unknown as typeof fetch;
127
+ const Wrapper = createWrapper(mockFetch);
128
+
129
+ render(
130
+ <Wrapper>
131
+ <ManageSubscription
132
+ returnUrl="https://app.test.com/settings"
133
+ buttonText="Billing Settings"
134
+ />
135
+ </Wrapper>
136
+ );
137
+
138
+ expect(screen.getByText("Billing Settings")).toBeDefined();
139
+ });
140
+ });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * ManageSubscription — Button that opens the billing provider's customer portal.
3
+ *
4
+ * Usage:
5
+ * <ManageSubscription returnUrl="https://app.example.com/settings" />
6
+ */
7
+
8
+ import React from "react";
9
+ import { usePortal } from "../hooks/usePortal";
10
+
11
+ export interface ManageSubscriptionProps {
12
+ returnUrl: string;
13
+ buttonText?: string;
14
+ }
15
+
16
+ export function ManageSubscription({
17
+ returnUrl,
18
+ buttonText = "Manage Billing",
19
+ }: ManageSubscriptionProps) {
20
+ const { openPortal, loading, error } = usePortal();
21
+
22
+ const handleClick = async () => {
23
+ try {
24
+ const result = await openPortal(returnUrl);
25
+ window.location.href = result.url;
26
+ } catch {
27
+ // Error is captured by the hook's error state
28
+ }
29
+ };
30
+
31
+ return (
32
+ <div data-testid="manage-subscription">
33
+ <button
34
+ onClick={handleClick}
35
+ disabled={loading}
36
+ className={`
37
+ inline-flex items-center gap-2 px-6 py-3 text-sm font-semibold rounded-xl border transition-colors
38
+ ${loading
39
+ ? "bg-gray-50 text-gray-400 border-gray-200 cursor-not-allowed"
40
+ : "bg-white text-gray-700 border-gray-200 hover:bg-gray-50 hover:border-gray-300 cursor-pointer"
41
+ }
42
+ `}
43
+ >
44
+ {loading ? "Loading..." : buttonText}
45
+ </button>
46
+ {error && (
47
+ <p data-testid="portal-error" className="mt-2 text-sm text-red-600">
48
+ {error.message}
49
+ </p>
50
+ )}
51
+ </div>
52
+ );
53
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Integration tests for PricingDetailPage component.
3
+ *
4
+ * Tests the full flow: loading -> data loaded -> user interaction -> callback.
5
+ */
6
+
7
+ import { describe, it, expect, vi } from "vitest";
8
+ import { render, screen, waitFor } from "@testing-library/react";
9
+ import userEvent from "@testing-library/user-event";
10
+ import React from "react";
11
+ import { BillingProvider } from "../hooks/BillingProvider";
12
+ import { PricingDetailPage } from "./PricingDetailPage";
13
+ import type { BillingProduct } from "../types";
14
+
15
+ const mockProduct: BillingProduct = {
16
+ id: "prod-1",
17
+ slug: "raise-simpli",
18
+ name: "RaiseSimpli",
19
+ description: "VC fundraising platform",
20
+ offers: [
21
+ {
22
+ id: "offer-1",
23
+ name: "Starter",
24
+ slug: "starter-monthly",
25
+ unit_price: "0.00",
26
+ currency: "USD",
27
+ pricing_model: "flat",
28
+ interval_unit: "month",
29
+ interval_count: 1,
30
+ interval_display: "per month",
31
+ is_recurring: true,
32
+ is_free: true,
33
+ features: [
34
+ { key: "seats", name: "Team seats", value: 1, limit: 1, category: "core" },
35
+ { key: "projects", name: "Projects", value: 3, limit: 3, category: "core" },
36
+ ],
37
+ is_active: true,
38
+ is_featured: false,
39
+ trial_days: 0,
40
+ sort_order: 0,
41
+ cta_text: "Start Free",
42
+ },
43
+ {
44
+ id: "offer-2",
45
+ name: "Pro",
46
+ slug: "pro-monthly",
47
+ unit_price: "49.00",
48
+ currency: "USD",
49
+ pricing_model: "flat",
50
+ interval_unit: "month",
51
+ interval_count: 1,
52
+ interval_display: "per month",
53
+ is_recurring: true,
54
+ is_free: false,
55
+ features: [
56
+ { key: "seats", name: "Team seats", value: 5, limit: 5, category: "core" },
57
+ { key: "projects", name: "Projects", value: "Unlimited", category: "core" },
58
+ ],
59
+ is_active: true,
60
+ is_featured: true,
61
+ trial_days: 14,
62
+ sort_order: 1,
63
+ cta_text: "Start Trial",
64
+ },
65
+ ],
66
+ };
67
+
68
+ function createWrapper(fetchImpl: typeof fetch) {
69
+ return function Wrapper({ children }: { children: React.ReactNode }) {
70
+ return (
71
+ <BillingProvider
72
+ apiBaseUrl="https://api.test.com/api/v1"
73
+ authToken="tok_123"
74
+ fetcher={fetchImpl}
75
+ >
76
+ {children}
77
+ </BillingProvider>
78
+ );
79
+ };
80
+ }
81
+
82
+ describe("PricingDetailPage integration", () => {
83
+ it("shows loading, then renders pricing cards after data loads", async () => {
84
+ let resolveFetch!: (value: unknown) => void;
85
+ const fetchPromise = new Promise((resolve) => {
86
+ resolveFetch = resolve;
87
+ });
88
+ const fetcher = vi.fn().mockReturnValue(fetchPromise) as unknown as typeof fetch;
89
+ const Wrapper = createWrapper(fetcher);
90
+
91
+ render(
92
+ <Wrapper>
93
+ <PricingDetailPage productId="raise-simpli" />
94
+ </Wrapper>
95
+ );
96
+
97
+ // Phase 1: loading
98
+ expect(screen.getByText(/loading/i)).toBeDefined();
99
+
100
+ // Resolve the fetch
101
+ resolveFetch({
102
+ ok: true,
103
+ json: () => Promise.resolve(mockProduct),
104
+ });
105
+
106
+ // Phase 2: loaded - shows pricing content
107
+ await waitFor(() => {
108
+ expect(screen.getByText("RaiseSimpli")).toBeDefined();
109
+ });
110
+ // "Starter" and "Pro" appear in both the card and the comparison table header
111
+ expect(screen.getAllByText("Starter").length).toBeGreaterThan(0);
112
+ expect(screen.getAllByText("Pro").length).toBeGreaterThan(0);
113
+ expect(screen.queryByText(/loading/i)).toBeNull();
114
+ });
115
+
116
+ it("full flow: view pricing -> click CTA -> verify callback with correct offer", async () => {
117
+ const onSelect = vi.fn();
118
+ const fetcher = vi.fn().mockResolvedValue({
119
+ ok: true,
120
+ json: () => Promise.resolve(mockProduct),
121
+ }) as unknown as typeof fetch;
122
+ const Wrapper = createWrapper(fetcher);
123
+
124
+ render(
125
+ <Wrapper>
126
+ <PricingDetailPage productId="raise-simpli" onSelectOffer={onSelect} />
127
+ </Wrapper>
128
+ );
129
+
130
+ // Wait for pricing to load
131
+ await waitFor(() => {
132
+ expect(screen.getByText("Start Free")).toBeDefined();
133
+ });
134
+
135
+ // Click the free plan CTA
136
+ await userEvent.click(screen.getByText("Start Free"));
137
+
138
+ expect(onSelect).toHaveBeenCalledTimes(1);
139
+ expect(onSelect).toHaveBeenCalledWith(
140
+ expect.objectContaining({
141
+ id: "offer-1",
142
+ slug: "starter-monthly",
143
+ name: "Starter",
144
+ is_free: true,
145
+ })
146
+ );
147
+ });
148
+
149
+ it("click paid plan CTA -> callback receives the paid offer", async () => {
150
+ const onSelect = vi.fn();
151
+ const fetcher = vi.fn().mockResolvedValue({
152
+ ok: true,
153
+ json: () => Promise.resolve(mockProduct),
154
+ }) as unknown as typeof fetch;
155
+ const Wrapper = createWrapper(fetcher);
156
+
157
+ render(
158
+ <Wrapper>
159
+ <PricingDetailPage productId="raise-simpli" onSelectOffer={onSelect} />
160
+ </Wrapper>
161
+ );
162
+
163
+ await waitFor(() => {
164
+ expect(screen.getByText("Start Trial")).toBeDefined();
165
+ });
166
+
167
+ // Click the Pro plan CTA
168
+ await userEvent.click(screen.getByText("Start Trial"));
169
+
170
+ expect(onSelect).toHaveBeenCalledTimes(1);
171
+ expect(onSelect).toHaveBeenCalledWith(
172
+ expect.objectContaining({
173
+ id: "offer-2",
174
+ slug: "pro-monthly",
175
+ name: "Pro",
176
+ is_free: false,
177
+ unit_price: "49.00",
178
+ })
179
+ );
180
+ });
181
+
182
+ it("loading -> error transition when API fails", async () => {
183
+ let resolveFetch!: (value: unknown) => void;
184
+ const fetchPromise = new Promise((resolve) => {
185
+ resolveFetch = resolve;
186
+ });
187
+ const fetcher = vi.fn().mockReturnValue(fetchPromise) as unknown as typeof fetch;
188
+ const Wrapper = createWrapper(fetcher);
189
+
190
+ render(
191
+ <Wrapper>
192
+ <PricingDetailPage productId="raise-simpli" />
193
+ </Wrapper>
194
+ );
195
+
196
+ // Phase 1: loading
197
+ expect(screen.getByText(/loading/i)).toBeDefined();
198
+
199
+ // Resolve with an error
200
+ resolveFetch({
201
+ ok: false,
202
+ status: 500,
203
+ });
204
+
205
+ // Phase 2: error state
206
+ await waitFor(() => {
207
+ expect(screen.getByText(/unable to load/i)).toBeDefined();
208
+ });
209
+ expect(screen.queryByText(/loading/i)).toBeNull();
210
+ });
211
+
212
+ it("multiple CTA clicks call onSelectOffer with the correct offer each time", async () => {
213
+ const onSelect = vi.fn();
214
+ const fetcher = vi.fn().mockResolvedValue({
215
+ ok: true,
216
+ json: () => Promise.resolve(mockProduct),
217
+ }) as unknown as typeof fetch;
218
+ const Wrapper = createWrapper(fetcher);
219
+
220
+ render(
221
+ <Wrapper>
222
+ <PricingDetailPage productId="raise-simpli" onSelectOffer={onSelect} />
223
+ </Wrapper>
224
+ );
225
+
226
+ await waitFor(() => {
227
+ expect(screen.getByText("Start Free")).toBeDefined();
228
+ });
229
+
230
+ // Click free plan
231
+ await userEvent.click(screen.getByText("Start Free"));
232
+ expect(onSelect).toHaveBeenLastCalledWith(
233
+ expect.objectContaining({ slug: "starter-monthly" })
234
+ );
235
+
236
+ // Click paid plan
237
+ await userEvent.click(screen.getByText("Start Trial"));
238
+ expect(onSelect).toHaveBeenLastCalledWith(
239
+ expect.objectContaining({ slug: "pro-monthly" })
240
+ );
241
+
242
+ expect(onSelect).toHaveBeenCalledTimes(2);
243
+ });
244
+ });