Package Exports
- @startsimpli/billing
- @startsimpli/billing/client
- @startsimpli/billing/mock
- @startsimpli/billing/server
- @startsimpli/billing/types
Readme
@startsimpli/billing
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.
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.
Quick start (wire a new app in <40 LOC)
1. Django side (start-simpli-api/backend/)
# config/settings/base.py
BILLING_AUTO_SUBSCRIBE_PRODUCT_SLUGS = [
s.strip() for s in os.environ.get(
"BILLING_AUTO_SUBSCRIBE_PRODUCT_SLUGS", "vault,my-new-app"
).split(",") if s.strip()
]
BILLING_USAGE_ADAPTERS = {
"vault": "apps.vault.billing.usage.collect",
"my-new-app": "apps.my_new_app.billing.usage.collect",
}# apps/my_new_app/billing/usage.py
def collect(user, team) -> dict[str, int]:
"""Return raw counts keyed by ProductOffer.features[].key.
The billing endpoint merges these with limits from the user's current
subscription's offer features — apps just report counts."""
company = team.company
return {
"projects": company.projects.count(),
"members_per_project": company.projects.annotate(n=Count("members")).aggregate(m=Max("n"))["m"] or 0,
}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.
2. Next.js side (my-new-app/)
// src/app/(dashboard)/layout.tsx — hoist BillingProvider once
'use client';
import { BillingProvider } from '@startsimpli/billing';
import { authFetch } from '@startsimpli/auth';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<BillingProvider apiBaseUrl="/api" fetcher={authFetch}>
{children}
</BillingProvider>
);
}// src/app/(dashboard)/settings/billing/page.tsx — ~30 LOC
'use client';
import { useEffect, useState } from 'react';
import { SubscriptionManager, useCheckout } from '@startsimpli/billing';
import type { ProductOffer } from '@startsimpli/billing';
export default function BillingSettingsPage() {
const { checkout } = useCheckout();
const [status, setStatus] = useState<'success' | 'cancelled' | null>(null);
useEffect(() => {
const s = new URLSearchParams(window.location.search).get('status');
if (s === 'success' || s === 'cancelled') setStatus(s);
}, []);
const returnUrl = typeof window !== 'undefined'
? `${window.location.origin}/settings/billing` : '/settings/billing';
// Free offers → SubscriptionManager calls subscribe-free internally;
// paid offers route through this Stripe checkout shim.
const onPaidPlanChange = async (offer: ProductOffer) => {
const { url } = await checkout({
offerId: offer.id,
successUrl: `${returnUrl}?status=success`,
cancelUrl: `${returnUrl}?status=cancelled`,
});
window.location.href = url;
};
return (
<SubscriptionManager
productId="my-new-app"
returnUrl={returnUrl}
onPlanChange={onPaidPlanChange}
statusMessage={status}
/>
);
}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).
That's everything. The billing page now renders:
- Auto-enrolled Free plan with live usage progress
- Per-card CTAs that respect the user's current sub (Pro user sees "Downgrade to Free", not "Get Started")
- "Upgrade to
" CTA on free-tier users - Trial countdown, past-due red banner, cancelled-in-period banner with Reactivate
- Read-only view for non-owner team members
- Auto-revert to Free when a paid sub expires
Limit enforcement (the limit_reached 402 contract)
When you want to block creates past a tier's limit, raise apps.<myapp>.billing.limits.LimitReached (or use the helper):
# In your viewset's perform_create — adapter mirror of apps.vault.billing.limits
from apps.vault.billing.limits import enforce_limit
def perform_create(self, serializer):
team = self.request.user.team_memberships.first().team
current = Project.objects.filter(company=team.company).count()
enforce_limit(team, "projects", current, "projects") # 402 if at/over the offer's limit
serializer.save(...)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.
Client-side, sniff ApiException.code === 'limit_reached' and surface the backend's human message:
import { ApiException } from '@startsimpli/api';
try {
await createProject.mutateAsync(...);
notifyUsageChanged('my-new-app'); // refresh the LimitNudge bar immediately
} catch (err) {
if (err instanceof ApiException && err.code === 'limit_reached') {
setFormError(err.message); // 'Your Free plan allows 3 projects. Upgrade to add more.'
}
}Backend reference
Endpoints
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/api/v1/billing/products/ |
GET | Public | List public products with offers |
/api/v1/billing/products/{slug}/ |
GET | Public | Get product by slug (raw snake_case) |
/api/v1/billing/subscription/current/ |
GET | Required | Current sub incl. viewer_is_owner + owner_email |
/api/v1/billing/usage/{slug}/ |
GET | Required | Per-product {feature_key: {used, limit}} |
/api/v1/billing/offer-checkout/ |
POST | Required | Create Stripe checkout session |
/api/v1/billing/offer-portal/ |
POST | Required | Create Stripe customer portal session |
/api/v1/billing/subscribe-free/ |
POST | Required | Subscribe to a unit_price=0 offer (no Stripe) |
/api/v1/billing/success-sync/ |
POST | Required | Pull subscription state from Stripe on checkout return |
Settings
# Comma-separated env, defaults to "vault"
BILLING_AUTO_SUBSCRIBE_PRODUCT_SLUGS = ["vault", "raise-simpli", ...]
# Per-product usage adapter callable paths
BILLING_USAGE_ADAPTERS = {
"vault": "apps.vault.billing.usage.collect",
}Signals (auto-magic)
post_saveonTeamMember→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.post_saveonSubscription→ terminal-status auto-revert (startsim-7qh). When a PAID sub enterscancelled/incomplete_expired/unpaid/trial_expiredand no other active sub exists, reactivate the team's pre-existing Free row instead of leaving them on "No active subscription".
Admin workflow
- Add a
BillingProviderCredential(Stripe secret key + webhook secret). - Create a
BillingProduct(slug= the identifier the frontend uses asproductId). - Add
ProductOfferinlines.featuresJSON shape:[ {"key": "projects", "name": "Projects", "value": 3, "limit": 3}, {"key": "members_per_proj", "name": "Members per project", "value": -1, "limit": -1}, {"key": "sso", "name": "SSO", "value": true} ]
limit: -1means unlimited- boolean
value: truerenders a checkmark - boolean
value: falseis OMITTED from the card (you don't get to brag about what you don't have)
- Use "Sync to provider" admin action to push the offers to Stripe.
Frontend reference
Components
| Component | Purpose | Notes |
|---|---|---|
BillingProvider |
Context provider. Required wrapper. | Hoist to dashboard layout; one provider per app. |
SubscriptionManager |
The whole /settings/billing experience. |
Renders CurrentPlanCard + per-card grid + state banners + status banners. |
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. |
LimitNudge |
Usage strip for list-page headers. | data-state="ok" / "approaching" / "at-cap"; hidden on limit=-1 / missing / errors. |
PricingPage / PricingSection / PricingDetailPage |
Pre-billing display surfaces. | For marketing / landing pages. |
UpgradeModal |
Modal pricing overlay. | For mid-flow upgrade prompts. |
ManageSubscription |
Bare portal redirect button. | Use SubscriptionManager instead unless you really just want the button. |
PlanGate |
Feature-gate wrapper. | Show / hide children based on the user's offer features. |
Hooks
| Hook | Returns | Notes |
|---|---|---|
useProduct(slug) |
{ product, loading, error, refetch } |
Fetch product + offers |
useSubscription() |
{ subscription, loading, error, refetch } |
Current sub incl. viewerIsOwner + ownerEmail |
useUsage(slug) |
{ usage, loading, error, refetch } |
{feature_key: {used, limit}} map; auto-refetches on visibilitychange + billing:usage-changed event |
useCheckout() |
{ checkout, subscribeFree, loading, error } |
Stripe session creator + free-subscribe in one |
usePortal() |
{ openPortal, loading, error } |
Stripe customer portal session |
useSuccessSync() |
{ sync, data, loading, error } |
Pull subscription state from Stripe on ?status=success (auto-fired inside SubscriptionManager) |
Imperative helpers
import { notifyUsageChanged, USAGE_CHANGED_EVENT } from '@startsimpli/billing';
// Call after any mutation that changed counts. Every mounted useUsage hook
// (and therefore every LimitNudge) for the matching slug will refetch.
notifyUsageChanged('vault');
// notifyUsageChanged() with no slug fires for all slugs.State matrix
CurrentPlanCard shows AT MOST ONE banner at the top, with this precedence:
- past_due → red
role="alert""Payment failed on<date>. Update your card to keep your subscription active." + "Update card" portal CTA - cancelled OR cancelAtPeriodEnd → neutral "Your
<Plan>plan is cancelled. You'll keep access until<Mon DD, YYYY>." + "Reactivate" portal CTA - incomplete OR incomplete_expired → warning "Your
<Plan>plan setup is incomplete." + "Finish checkout" portal CTA - trialing → info "Your trial ends in N days (
<Mon DD, YYYY>)." (warning style when≤ 3days, info otherwise) + "Cancel trial" portal CTA. "ends tomorrow" / "ends today" phrasing on the tail. - none
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.
Per-card CTA matrix
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:
| User on | Card represents | CTA | State |
|---|---|---|---|
| (no sub) | any offer | offer.ctaText | enabled |
| Free | Free | "Current Plan" | disabled, with CURRENT PLAN badge |
| Free | Pro | offer.ctaText (e.g. "Start Free Trial") | enabled, trial copy visible |
| Pro | Free | "Downgrade to Free" | enabled, secondary outline styling |
| Pro | Pro | "Current Plan" | disabled, with CURRENT PLAN badge |
| Pro | Enterprise | offer.ctaText | enabled |
| Trialing Pro | Pro | "Current Plan" | disabled |
| Non-owner team member | ANY | shown but disabled with tooltip "Only the team owner can change the plan" | — |
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).
Architecture
- Provider-agnostic backend:
BaseBillingProvider→StripeBillingProvider(extensible to Paddle etc.) - BillingProviderFactory: Resolves credentials per-team with global fallback
- BillingService: Orchestrates sync, checkout, and portal operations
- ProductOffer: Supports flat / per_seat / tiered / volume / usage pricing models
- Apps register usage adapters (
BILLING_USAGE_ADAPTERS) rather than embedding usage logic in the billing app — keeps billing slug-agnostic
Error contract
Backend's apps.core.exceptions.custom_exception_handler ships a standardized JSON shape:
{
"error": "Your Free plan allows 3 environments. Upgrade to add more.",
"code": "limit_reached",
"statusCode": 402,
"feature_key": "environments",
"limit": 3,
"current": 3,
"timestamp": "2026-05-28T18:11:34.655168Z"
}@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.
Environment variables
NEXT_PUBLIC_API_URL=https://api.startsimpli.comBackend:
STRIPE_SECRET_KEY=sk_test_... # real ~107-char test key from dashboard.stripe.com/test/apikeys
STRIPE_WEBHOOK_SECRET=whsec_...
BILLING_AUTO_SUBSCRIBE_PRODUCT_SLUGS=vault,my-new-app # comma-separatedVerification
# Frontend (71 tests across SubscriptionManager, CurrentPlanCard, LimitNudge,
# useCheckout, useProduct, usePortal, useSubscription, BillingProvider)
cd packages/billing
pnpm test # or `pnpm vitest run`
pnpm tsc --noEmit # type-check
# Backend (368+ tests in apps/billing/ + apps/vault/)
cd start-simpli-api
docker compose -f docker-compose.local.yml exec -T django pytest apps/billing/tests/ -v
# systemeval gates (real DB in Docker)
systemeval test -e billing_auto_subscribe_gate
systemeval test -e billing_usage_endpoint_gateLive verification policy
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. 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.
Real-world reference
vault-web is the canonical integration:
- Backend slug:
vaultinBILLING_AUTO_SUBSCRIBE_PRODUCT_SLUGS+BILLING_USAGE_ADAPTERS - Usage adapter:
apps.vault.billing.usage.collect(reportsenvironments+secrets_per_env) - Limit enforcement:
apps.vault.billing.limits.enforce_limitinEnvironmentViewSet+SecretViewSet - Frontend:
<BillingProvider>invault-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.
Copy that pattern. If you find yourself writing app-local billing UI logic, stop — extend the shared layer.