@startsimpli/billing 0.4.12 → 0.4.15
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 +285 -87
- package/package.json +14 -13
- package/src/components/CurrentPlanCard.test.tsx +289 -0
- package/src/components/CurrentPlanCard.tsx +317 -0
- package/src/components/LimitNudge.test.tsx +132 -0
- package/src/components/LimitNudge.tsx +84 -0
- package/src/components/SubscriptionManager.test.tsx +772 -20
- package/src/components/SubscriptionManager.tsx +337 -81
- package/src/components/index.ts +4 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useUsage.ts +94 -0
- package/src/index.ts +7 -1
- package/src/mock/__tests__/mock-backend.test.ts +128 -0
- package/src/mock/backend.ts +25 -0
- package/src/mock/index.ts +7 -0
- package/src/mock/mock-backend.ts +103 -0
- package/src/types/index.ts +44 -1
- package/src/utils/api.ts +72 -1
package/README.md
CHANGED
|
@@ -1,145 +1,343 @@
|
|
|
1
1
|
# @startsimpli/billing
|
|
2
2
|
|
|
3
|
-
Universal billing
|
|
3
|
+
Universal billing UX for StartSimpli apps — auto-enrol on signup, real current-plan card with usage progress, structured limit-reached errors, full subscription-state matrix (trial / past-due / cancelled-in-period / incomplete / non-owner), and a tier-limit nudge that any list page can drop in.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**Shipping principle:** every component, hook, and adapter contract lives here or in `apps.billing` (Django). Apps add only (a) a slug to `BILLING_AUTO_SUBSCRIBE_PRODUCT_SLUGS` + `BILLING_USAGE_ADAPTERS`, (b) one `apps.<name>.billing.usage.collect(user, team)` callable, (c) `<BillingProvider>` at the dashboard root, and (d) `<SubscriptionManager productId="..." />` on `/settings/billing`. ~40 LOC of app-side glue. Don't reinvent.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
---
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## Quick start (wire a new app in <40 LOC)
|
|
10
10
|
|
|
11
|
-
|
|
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 |
|
|
11
|
+
### 1. Django side (`start-simpli-api/backend/`)
|
|
18
12
|
|
|
19
|
-
|
|
13
|
+
```python
|
|
14
|
+
# config/settings/base.py
|
|
15
|
+
BILLING_AUTO_SUBSCRIBE_PRODUCT_SLUGS = [
|
|
16
|
+
s.strip() for s in os.environ.get(
|
|
17
|
+
"BILLING_AUTO_SUBSCRIBE_PRODUCT_SLUGS", "vault,my-new-app"
|
|
18
|
+
).split(",") if s.strip()
|
|
19
|
+
]
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
BILLING_USAGE_ADAPTERS = {
|
|
22
|
+
"vault": "apps.vault.billing.usage.collect",
|
|
23
|
+
"my-new-app": "apps.my_new_app.billing.usage.collect",
|
|
24
|
+
}
|
|
25
|
+
```
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
```python
|
|
28
|
+
# apps/my_new_app/billing/usage.py
|
|
29
|
+
def collect(user, team) -> dict[str, int]:
|
|
30
|
+
"""Return raw counts keyed by ProductOffer.features[].key.
|
|
31
|
+
The billing endpoint merges these with limits from the user's current
|
|
32
|
+
subscription's offer features — apps just report counts."""
|
|
33
|
+
company = team.company
|
|
34
|
+
return {
|
|
35
|
+
"projects": company.projects.count(),
|
|
36
|
+
"members_per_project": company.projects.annotate(n=Count("members")).aggregate(m=Max("n"))["m"] or 0,
|
|
37
|
+
}
|
|
38
|
+
```
|
|
27
39
|
|
|
28
|
-
|
|
40
|
+
That's the entire backend wiring. Auto-enrol on signup, the usage endpoint, the `limit_reached` 402 contract — all already in `apps.billing`. See "Limit enforcement" below for how to opt your data models in.
|
|
29
41
|
|
|
30
|
-
|
|
42
|
+
### 2. Next.js side (`my-new-app/`)
|
|
31
43
|
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
44
|
+
```tsx
|
|
45
|
+
// src/app/(dashboard)/layout.tsx — hoist BillingProvider once
|
|
46
|
+
'use client';
|
|
47
|
+
import { BillingProvider } from '@startsimpli/billing';
|
|
48
|
+
import { authFetch } from '@startsimpli/auth';
|
|
49
|
+
|
|
50
|
+
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
|
51
|
+
return (
|
|
52
|
+
<BillingProvider apiBaseUrl="/api" fetcher={authFetch}>
|
|
53
|
+
{children}
|
|
54
|
+
</BillingProvider>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
// src/app/(dashboard)/settings/billing/page.tsx — ~30 LOC
|
|
61
|
+
'use client';
|
|
62
|
+
import { useEffect, useState } from 'react';
|
|
63
|
+
import { SubscriptionManager, useCheckout } from '@startsimpli/billing';
|
|
64
|
+
import type { ProductOffer } from '@startsimpli/billing';
|
|
65
|
+
|
|
66
|
+
export default function BillingSettingsPage() {
|
|
67
|
+
const { checkout } = useCheckout();
|
|
68
|
+
const [status, setStatus] = useState<'success' | 'cancelled' | null>(null);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const s = new URLSearchParams(window.location.search).get('status');
|
|
72
|
+
if (s === 'success' || s === 'cancelled') setStatus(s);
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
const returnUrl = typeof window !== 'undefined'
|
|
76
|
+
? `${window.location.origin}/settings/billing` : '/settings/billing';
|
|
77
|
+
|
|
78
|
+
// Free offers → SubscriptionManager calls subscribe-free internally;
|
|
79
|
+
// paid offers route through this Stripe checkout shim.
|
|
80
|
+
const onPaidPlanChange = async (offer: ProductOffer) => {
|
|
81
|
+
const { url } = await checkout({
|
|
82
|
+
offerId: offer.id,
|
|
83
|
+
successUrl: `${returnUrl}?status=success`,
|
|
84
|
+
cancelUrl: `${returnUrl}?status=cancelled`,
|
|
85
|
+
});
|
|
86
|
+
window.location.href = url;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<SubscriptionManager
|
|
91
|
+
productId="my-new-app"
|
|
92
|
+
returnUrl={returnUrl}
|
|
93
|
+
onPlanChange={onPaidPlanChange}
|
|
94
|
+
statusMessage={status}
|
|
95
|
+
/>
|
|
96
|
+
);
|
|
37
97
|
}
|
|
38
98
|
```
|
|
39
99
|
|
|
40
|
-
|
|
100
|
+
You also need 7 thin proxy routes under `src/app/api/billing/*` (products, subscription/current, offer-checkout, offer-portal, subscribe-free, success-sync, usage/[slug]) — copy them verbatim from `vault-web/src/app/api/billing/`. The products + subscription proxies need `fromSnake: true` (Django's public endpoints bypass the camelCase middleware).
|
|
41
101
|
|
|
42
|
-
|
|
102
|
+
That's everything. The billing page now renders:
|
|
103
|
+
- Auto-enrolled Free plan with live usage progress
|
|
104
|
+
- Per-card CTAs that respect the user's current sub (Pro user sees "Downgrade to Free", not "Get Started")
|
|
105
|
+
- "Upgrade to <NextTier>" CTA on free-tier users
|
|
106
|
+
- Trial countdown, past-due red banner, cancelled-in-period banner with Reactivate
|
|
107
|
+
- Read-only view for non-owner team members
|
|
108
|
+
- Auto-revert to Free when a paid sub expires
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Limit enforcement (the `limit_reached` 402 contract)
|
|
113
|
+
|
|
114
|
+
When you want to block creates past a tier's limit, raise `apps.<myapp>.billing.limits.LimitReached` (or use the helper):
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
# In your viewset's perform_create — adapter mirror of apps.vault.billing.limits
|
|
118
|
+
from apps.vault.billing.limits import enforce_limit
|
|
119
|
+
|
|
120
|
+
def perform_create(self, serializer):
|
|
121
|
+
team = self.request.user.team_memberships.first().team
|
|
122
|
+
current = Project.objects.filter(company=team.company).count()
|
|
123
|
+
enforce_limit(team, "projects", current, "projects") # 402 if at/over the offer's limit
|
|
124
|
+
serializer.save(...)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The handler at `apps.core.exceptions.custom_exception_handler` preserves your structured `code` (`limit_reached`) and ships `feature_key`, `limit`, `current` at the top level of the response. Silent (allows) on missing-sub / missing-feature / `limit=-1` (unlimited) — billing misconfig must never block a healthy team.
|
|
128
|
+
|
|
129
|
+
Client-side, sniff `ApiException.code === 'limit_reached'` and surface the backend's human message:
|
|
43
130
|
|
|
44
131
|
```tsx
|
|
45
|
-
import {
|
|
132
|
+
import { ApiException } from '@startsimpli/api';
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
await createProject.mutateAsync(...);
|
|
136
|
+
notifyUsageChanged('my-new-app'); // refresh the LimitNudge bar immediately
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (err instanceof ApiException && err.code === 'limit_reached') {
|
|
139
|
+
setFormError(err.message); // 'Your Free plan allows 3 projects. Upgrade to add more.'
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
```
|
|
46
143
|
|
|
47
|
-
|
|
48
|
-
<BillingProvider apiBaseUrl="/api/v1" authToken={accessToken}>
|
|
49
|
-
<PricingPage productId="raise-simpli" onSelectOffer={handleSelect} />
|
|
50
|
-
</BillingProvider>
|
|
144
|
+
---
|
|
51
145
|
|
|
52
|
-
|
|
53
|
-
const { checkout, loading } = useCheckout()
|
|
54
|
-
await checkout({ offerId, successUrl, cancelUrl })
|
|
146
|
+
## Backend reference
|
|
55
147
|
|
|
56
|
-
|
|
57
|
-
|
|
148
|
+
### Endpoints
|
|
149
|
+
|
|
150
|
+
| Endpoint | Method | Auth | Description |
|
|
151
|
+
|---|---|---|---|
|
|
152
|
+
| `/api/v1/billing/products/` | GET | Public | List public products with offers |
|
|
153
|
+
| `/api/v1/billing/products/{slug}/` | GET | Public | Get product by slug (raw snake_case) |
|
|
154
|
+
| `/api/v1/billing/subscription/current/` | GET | Required | Current sub incl. `viewer_is_owner` + `owner_email` |
|
|
155
|
+
| `/api/v1/billing/usage/{slug}/` | GET | Required | Per-product `{feature_key: {used, limit}}` |
|
|
156
|
+
| `/api/v1/billing/offer-checkout/` | POST | Required | Create Stripe checkout session |
|
|
157
|
+
| `/api/v1/billing/offer-portal/` | POST | Required | Create Stripe customer portal session |
|
|
158
|
+
| `/api/v1/billing/subscribe-free/` | POST | Required | Subscribe to a `unit_price=0` offer (no Stripe) |
|
|
159
|
+
| `/api/v1/billing/success-sync/` | POST | Required | Pull subscription state from Stripe on checkout return |
|
|
160
|
+
|
|
161
|
+
### Settings
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
# Comma-separated env, defaults to "vault"
|
|
165
|
+
BILLING_AUTO_SUBSCRIBE_PRODUCT_SLUGS = ["vault", "raise-simpli", ...]
|
|
166
|
+
|
|
167
|
+
# Per-product usage adapter callable paths
|
|
168
|
+
BILLING_USAGE_ADAPTERS = {
|
|
169
|
+
"vault": "apps.vault.billing.usage.collect",
|
|
170
|
+
}
|
|
58
171
|
```
|
|
59
172
|
|
|
173
|
+
### Signals (auto-magic)
|
|
174
|
+
|
|
175
|
+
- `post_save` on `TeamMember` → `auto_subscribe_team_to_default_free_offers` (startsim-bc8). When a team's OWNER joins, subscribe the team to every listed product's free offer.
|
|
176
|
+
- `post_save` on `Subscription` → terminal-status auto-revert (startsim-7qh). When a PAID sub enters `cancelled` / `incomplete_expired` / `unpaid` / `trial_expired` and no other active sub exists, reactivate the team's pre-existing Free row instead of leaving them on "No active subscription".
|
|
177
|
+
|
|
178
|
+
### Admin workflow
|
|
179
|
+
|
|
180
|
+
1. Add a `BillingProviderCredential` (Stripe secret key + webhook secret).
|
|
181
|
+
2. Create a `BillingProduct` (`slug` = the identifier the frontend uses as `productId`).
|
|
182
|
+
3. Add `ProductOffer` inlines. `features` JSON shape:
|
|
183
|
+
```json
|
|
184
|
+
[
|
|
185
|
+
{"key": "projects", "name": "Projects", "value": 3, "limit": 3},
|
|
186
|
+
{"key": "members_per_proj", "name": "Members per project", "value": -1, "limit": -1},
|
|
187
|
+
{"key": "sso", "name": "SSO", "value": true}
|
|
188
|
+
]
|
|
189
|
+
```
|
|
190
|
+
- `limit: -1` means unlimited
|
|
191
|
+
- boolean `value: true` renders a checkmark
|
|
192
|
+
- boolean `value: false` is OMITTED from the card (you don't get to brag about what you don't have)
|
|
193
|
+
4. Use "Sync to provider" admin action to push the offers to Stripe.
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Frontend reference
|
|
198
|
+
|
|
60
199
|
### Components
|
|
61
200
|
|
|
62
|
-
| Component |
|
|
63
|
-
|
|
64
|
-
| `BillingProvider` |
|
|
65
|
-
| `
|
|
66
|
-
| `
|
|
67
|
-
| `
|
|
68
|
-
| `
|
|
69
|
-
| `
|
|
70
|
-
| `
|
|
201
|
+
| Component | Purpose | Notes |
|
|
202
|
+
|---|---|---|
|
|
203
|
+
| `BillingProvider` | Context provider. Required wrapper. | Hoist to dashboard layout; one provider per app. |
|
|
204
|
+
| `SubscriptionManager` | The whole `/settings/billing` experience. | Renders CurrentPlanCard + per-card grid + state banners + status banners. |
|
|
205
|
+
| `CurrentPlanCard` | The "you are here" widget. | State banners (trial countdown, past-due, cancelled-in-period, incomplete) + feature list with usage progress + upgrade slot. Presentational — testable without provider context. |
|
|
206
|
+
| `LimitNudge` | Usage strip for list-page headers. | `data-state="ok" / "approaching" / "at-cap"`; hidden on `limit=-1` / missing / errors. |
|
|
207
|
+
| `PricingPage` / `PricingSection` / `PricingDetailPage` | Pre-billing display surfaces. | For marketing / landing pages. |
|
|
208
|
+
| `UpgradeModal` | Modal pricing overlay. | For mid-flow upgrade prompts. |
|
|
209
|
+
| `ManageSubscription` | Bare portal redirect button. | Use `SubscriptionManager` instead unless you really just want the button. |
|
|
210
|
+
| `PlanGate` | Feature-gate wrapper. | Show / hide children based on the user's offer features. |
|
|
71
211
|
|
|
72
212
|
### Hooks
|
|
73
213
|
|
|
74
|
-
| Hook | Returns |
|
|
75
|
-
|
|
76
|
-
| `useProduct(slug)` | `{ product, loading, error }` | Fetch product + offers |
|
|
77
|
-
| `
|
|
78
|
-
| `
|
|
79
|
-
| `
|
|
214
|
+
| Hook | Returns | Notes |
|
|
215
|
+
|---|---|---|
|
|
216
|
+
| `useProduct(slug)` | `{ product, loading, error, refetch }` | Fetch product + offers |
|
|
217
|
+
| `useSubscription()` | `{ subscription, loading, error, refetch }` | Current sub incl. `viewerIsOwner` + `ownerEmail` |
|
|
218
|
+
| `useUsage(slug)` | `{ usage, loading, error, refetch }` | `{feature_key: {used, limit}}` map; auto-refetches on `visibilitychange` + `billing:usage-changed` event |
|
|
219
|
+
| `useCheckout()` | `{ checkout, subscribeFree, loading, error }` | Stripe session creator + free-subscribe in one |
|
|
220
|
+
| `usePortal()` | `{ openPortal, loading, error }` | Stripe customer portal session |
|
|
221
|
+
| `useSuccessSync()` | `{ sync, data, loading, error }` | Pull subscription state from Stripe on `?status=success` (auto-fired inside SubscriptionManager) |
|
|
222
|
+
|
|
223
|
+
### Imperative helpers
|
|
224
|
+
|
|
225
|
+
```tsx
|
|
226
|
+
import { notifyUsageChanged, USAGE_CHANGED_EVENT } from '@startsimpli/billing';
|
|
227
|
+
|
|
228
|
+
// Call after any mutation that changed counts. Every mounted useUsage hook
|
|
229
|
+
// (and therefore every LimitNudge) for the matching slug will refetch.
|
|
230
|
+
notifyUsageChanged('vault');
|
|
231
|
+
|
|
232
|
+
// notifyUsageChanged() with no slug fires for all slugs.
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## State matrix
|
|
238
|
+
|
|
239
|
+
`CurrentPlanCard` shows AT MOST ONE banner at the top, with this precedence:
|
|
240
|
+
|
|
241
|
+
1. **past_due** → red `role="alert"` "Payment failed on `<date>`. Update your card to keep your subscription active." + "Update card" portal CTA
|
|
242
|
+
2. **cancelled** OR **cancelAtPeriodEnd** → neutral "Your `<Plan>` plan is cancelled. You'll keep access until `<Mon DD, YYYY>`." + "Reactivate" portal CTA
|
|
243
|
+
3. **incomplete** OR **incomplete_expired** → warning "Your `<Plan>` plan setup is incomplete." + "Finish checkout" portal CTA
|
|
244
|
+
4. **trialing** → info "Your trial ends in N days (`<Mon DD, YYYY>`)." (warning style when `≤ 3` days, info otherwise) + "Cancel trial" portal CTA. "ends tomorrow" / "ends today" phrasing on the tail.
|
|
245
|
+
5. none
|
|
246
|
+
|
|
247
|
+
The status badge in the top-right of the card (`data-testid="plan-status"`) carries `role="status"` and shows the human label (Active / Trialing / Past due / Cancelled / Incomplete / Expired / Unpaid). Trialing includes `until <YYYY-MM-DD>` inline.
|
|
248
|
+
|
|
249
|
+
## Per-card CTA matrix
|
|
250
|
+
|
|
251
|
+
The Change Plan grid shows EVERY offer (including the user's current plan, marked with a `CURRENT PLAN` badge + indigo ring). Each card's CTA branches on its direction relative to the user's current sub:
|
|
252
|
+
|
|
253
|
+
| User on | Card represents | CTA | State |
|
|
254
|
+
|---|---|---|---|
|
|
255
|
+
| (no sub) | any offer | offer.ctaText | enabled |
|
|
256
|
+
| Free | Free | "Current Plan" | **disabled**, with `CURRENT PLAN` badge |
|
|
257
|
+
| Free | Pro | offer.ctaText (e.g. "Start Free Trial") | enabled, trial copy visible |
|
|
258
|
+
| Pro | Free | "Downgrade to Free" | enabled, secondary outline styling |
|
|
259
|
+
| Pro | Pro | "Current Plan" | **disabled**, with `CURRENT PLAN` badge |
|
|
260
|
+
| Pro | Enterprise | offer.ctaText | enabled |
|
|
261
|
+
| Trialing Pro | Pro | "Current Plan" | **disabled** |
|
|
262
|
+
| Non-owner team member | ANY | shown but **disabled** with tooltip "Only the team owner can change the plan" | — |
|
|
263
|
+
|
|
264
|
+
The standalone "Upgrade to `<PaidName>` →" button on the current-plan card picks the NEXT tier above the current sub by `sortOrder` (featured-preferred). Returns null when the user is on the top tier or there's no paid offer at all (free-only apps).
|
|
265
|
+
|
|
266
|
+
---
|
|
80
267
|
|
|
81
268
|
## Architecture
|
|
82
269
|
|
|
83
|
-
- **Provider-agnostic**: `BaseBillingProvider` → `StripeBillingProvider` (extensible to Paddle
|
|
270
|
+
- **Provider-agnostic backend**: `BaseBillingProvider` → `StripeBillingProvider` (extensible to Paddle etc.)
|
|
84
271
|
- **BillingProviderFactory**: Resolves credentials per-team with global fallback
|
|
85
272
|
- **BillingService**: Orchestrates sync, checkout, and portal operations
|
|
86
|
-
- **ProductOffer**: Supports flat
|
|
87
|
-
|
|
88
|
-
## Integration with @startsimpli/auth
|
|
273
|
+
- **ProductOffer**: Supports flat / per_seat / tiered / volume / usage pricing models
|
|
274
|
+
- **Apps register usage adapters** (`BILLING_USAGE_ADAPTERS`) rather than embedding usage logic in the billing app — keeps billing slug-agnostic
|
|
89
275
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
```tsx
|
|
93
|
-
import { BillingProvider } from '@startsimpli/billing'
|
|
94
|
-
import { useAuth } from '@startsimpli/auth'
|
|
276
|
+
### Error contract
|
|
95
277
|
|
|
96
|
-
|
|
97
|
-
const { tokens } = useAuth()
|
|
278
|
+
Backend's `apps.core.exceptions.custom_exception_handler` ships a standardized JSON shape:
|
|
98
279
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
280
|
+
```json
|
|
281
|
+
{
|
|
282
|
+
"error": "Your Free plan allows 3 environments. Upgrade to add more.",
|
|
283
|
+
"code": "limit_reached",
|
|
284
|
+
"statusCode": 402,
|
|
285
|
+
"feature_key": "environments",
|
|
286
|
+
"limit": 3,
|
|
287
|
+
"current": 3,
|
|
288
|
+
"timestamp": "2026-05-28T18:11:34.655168Z"
|
|
107
289
|
}
|
|
108
290
|
```
|
|
109
291
|
|
|
110
|
-
|
|
292
|
+
`@startsimpli/api`'s `ApiException` parses this into `.code` + `.details`. Apps branch on `err.code === 'limit_reached'` to surface the human message verbatim and link to /settings/billing.
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
## Environment variables
|
|
111
297
|
|
|
112
298
|
```env
|
|
113
299
|
NEXT_PUBLIC_API_URL=https://api.startsimpli.com
|
|
114
300
|
```
|
|
115
301
|
|
|
116
|
-
Backend
|
|
302
|
+
Backend:
|
|
117
303
|
```env
|
|
118
|
-
STRIPE_SECRET_KEY=sk_test_...
|
|
304
|
+
STRIPE_SECRET_KEY=sk_test_... # real ~107-char test key from dashboard.stripe.com/test/apikeys
|
|
119
305
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
|
306
|
+
BILLING_AUTO_SUBSCRIBE_PRODUCT_SLUGS=vault,my-new-app # comma-separated
|
|
120
307
|
```
|
|
121
308
|
|
|
122
|
-
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Verification
|
|
123
312
|
|
|
124
313
|
```bash
|
|
125
|
-
# Frontend tests
|
|
314
|
+
# Frontend (71 tests across SubscriptionManager, CurrentPlanCard, LimitNudge,
|
|
315
|
+
# useCheckout, useProduct, usePortal, useSubscription, BillingProvider)
|
|
126
316
|
cd packages/billing
|
|
127
|
-
|
|
317
|
+
pnpm test # or `pnpm vitest run`
|
|
318
|
+
pnpm tsc --noEmit # type-check
|
|
128
319
|
|
|
129
|
-
# Backend tests
|
|
320
|
+
# Backend (368+ tests in apps/billing/ + apps/vault/)
|
|
130
321
|
cd start-simpli-api
|
|
131
|
-
docker
|
|
322
|
+
docker compose -f docker-compose.local.yml exec -T django pytest apps/billing/tests/ -v
|
|
323
|
+
|
|
324
|
+
# systemeval gates (real DB in Docker)
|
|
325
|
+
systemeval test -e billing_auto_subscribe_gate
|
|
326
|
+
systemeval test -e billing_usage_endpoint_gate
|
|
132
327
|
```
|
|
133
328
|
|
|
134
|
-
|
|
329
|
+
### Live verification policy
|
|
135
330
|
|
|
136
|
-
|
|
137
|
-
# Type checking
|
|
138
|
-
npm run type-check
|
|
331
|
+
Every UI change here must be driven through a real browser via the debugg-ai MCP (`mcp__debugg-ai__check_app_in_browser`) against a running localhost dev server. Type-checks + vitest are NOT proof. See the [MCP-default rule in CLAUDE.md](/CLAUDE.md). The canonical pattern for state-driven UI is to flip the test user's `Subscription.status` via Django shell, then drive the billing page — see `startsim-c6p` close-reasons for examples of every state.
|
|
139
332
|
|
|
140
|
-
|
|
141
|
-
npm run test:watch
|
|
333
|
+
---
|
|
142
334
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
335
|
+
## Real-world reference
|
|
336
|
+
|
|
337
|
+
`vault-web` is the canonical integration:
|
|
338
|
+
- Backend slug: `vault` in `BILLING_AUTO_SUBSCRIBE_PRODUCT_SLUGS` + `BILLING_USAGE_ADAPTERS`
|
|
339
|
+
- Usage adapter: `apps.vault.billing.usage.collect` (reports `environments` + `secrets_per_env`)
|
|
340
|
+
- Limit enforcement: `apps.vault.billing.limits.enforce_limit` in `EnvironmentViewSet` + `SecretViewSet`
|
|
341
|
+
- Frontend: `<BillingProvider>` in `vault-web/src/app/(dashboard)/layout.tsx` (one provider for the whole dashboard); `<SubscriptionManager productId="vault" />` in `/settings/billing` (34 LOC including the Stripe URL shim); `<LimitNudge>` in `/environments` (env count) and `/environments/[slug]` (per-env secret count); `notifyUsageChanged('vault')` after every env/secret mutation.
|
|
342
|
+
|
|
343
|
+
Copy that pattern. If you find yourself writing app-local billing UI logic, stop — extend the shared layer.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/billing",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.15",
|
|
4
4
|
"description": "Universal billing integration for StartSimpli Next.js apps",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
".": "./src/index.ts",
|
|
9
9
|
"./client": "./src/index.ts",
|
|
10
10
|
"./server": "./src/server/index.ts",
|
|
11
|
-
"./types": "./src/types/index.ts"
|
|
11
|
+
"./types": "./src/types/index.ts",
|
|
12
|
+
"./mock": "./src/mock/index.ts"
|
|
12
13
|
},
|
|
13
14
|
"files": [
|
|
14
15
|
"src",
|
|
@@ -17,15 +18,6 @@
|
|
|
17
18
|
"publishConfig": {
|
|
18
19
|
"access": "public"
|
|
19
20
|
},
|
|
20
|
-
"scripts": {
|
|
21
|
-
"build": "tsup",
|
|
22
|
-
"dev": "tsup --watch",
|
|
23
|
-
"type-check": "tsc --noEmit",
|
|
24
|
-
"test": "vitest run",
|
|
25
|
-
"test:watch": "vitest",
|
|
26
|
-
"test:coverage": "vitest run --coverage",
|
|
27
|
-
"clean": "rm -rf dist"
|
|
28
|
-
},
|
|
29
21
|
"peerDependencies": {
|
|
30
22
|
"next": ">=14.0.0",
|
|
31
23
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -53,5 +45,14 @@
|
|
|
53
45
|
"nextjs",
|
|
54
46
|
"django",
|
|
55
47
|
"startsimpli"
|
|
56
|
-
]
|
|
57
|
-
|
|
48
|
+
],
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsup",
|
|
51
|
+
"dev": "tsup --watch",
|
|
52
|
+
"type-check": "tsc --noEmit",
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"test:watch": "vitest",
|
|
55
|
+
"test:coverage": "vitest run --coverage",
|
|
56
|
+
"clean": "rm -rf dist"
|
|
57
|
+
}
|
|
58
|
+
}
|