JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 393
  • Score
    100M100P100Q77846F

Universal billing integration for StartSimpli Next.js apps

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_save on TeamMemberauto_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_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".

Admin workflow

  1. Add a BillingProviderCredential (Stripe secret key + webhook secret).
  2. Create a BillingProduct (slug = the identifier the frontend uses as productId).
  3. Add ProductOffer inlines. features JSON 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: -1 means unlimited
    • boolean value: true renders a checkmark
    • boolean value: false is OMITTED from the card (you don't get to brag about what you don't have)
  4. 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:

  1. past_due → red role="alert" "Payment failed on <date>. Update your card to keep your subscription active." + "Update card" portal CTA
  2. cancelled OR cancelAtPeriodEnd → neutral "Your <Plan> plan is cancelled. You'll keep access until <Mon DD, YYYY>." + "Reactivate" portal CTA
  3. incomplete OR incomplete_expired → warning "Your <Plan> plan setup is incomplete." + "Finish checkout" portal CTA
  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.
  5. 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: BaseBillingProviderStripeBillingProvider (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.com

Backend:

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-separated

Verification

# 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_gate

Live 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: vault in BILLING_AUTO_SUBSCRIBE_PRODUCT_SLUGS + BILLING_USAGE_ADAPTERS
  • Usage adapter: apps.vault.billing.usage.collect (reports environments + secrets_per_env)
  • Limit enforcement: apps.vault.billing.limits.enforce_limit in EnvironmentViewSet + SecretViewSet
  • 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.

Copy that pattern. If you find yourself writing app-local billing UI logic, stop — extend the shared layer.