@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
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
|
+
});
|