npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@startsimpli/billing

v0.4.15

Published

Universal billing integration for StartSimpli Next.js apps

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.