Billing Management
Prov0.1.0Subscriptions on your own Stripe account: a plan catalog with entitlements, Stripe Checkout and Customer Portal, verified idempotent webhooks that sync customers, subscriptions and invoices into your database, and fast entitlement checks that never call Stripe.
terminalnpx ajolla add billingWhat you get
- /billing page: current plan, renewal date, upgrade or downgrade, invoices
- Stripe Checkout for new subscriptions and the Customer Portal for changes
- Signed, idempotent webhook that syncs subscriptions in order
- hasEntitlement() and getLimit() read synced tables, never Stripe
- Plan editor with per-plan entitlement JSON, or sync plans from Stripe
- Admin overview: MRR estimate, subscriptions, customers and webhook health
- Users or organizations as subscription owners
- Payment-failed, renewal and trial-ending notifications
Routes it adds
/admin/billing/admin/billing/customers/admin/billing/events/admin/billing/plans/admin/billing/plans/[planId]/admin/billing/plans/new/admin/billing/subscriptions/billing
/api/ajolla/billing/checkout/return/api/ajolla/billing/webhook
Plus 1 Supabase migration with row-level security, and an AGENTS.md your coding agent reads after install.
Installs with
Kernel (added automatically).
Configuration
- STRIPE_SECRET_KEY (required)
- Your Stripe secret key or, better, a restricted key (rk_…) with write access to Customers, Checkout Sessions, Customer Portal and Subscriptions, and read access to Products, Prices and Invoices. Server-only.
- STRIPE_WEBHOOK_SECRET (required)
- Signing secret (whsec_…) of the webhook endpoint pointing at /api/ajolla/billing/webhook.
- NEXT_PUBLIC_SITE_URL (required)
- Absolute URL of your app (e.g. https://example.com); used for Checkout and Portal return URLs and links in notifications.
- STRIPE_PORTAL_CONFIGURATION_ID
- Optional Customer Portal configuration (bpc_…) to use instead of your default one.
Events
Other modules (and your own code) can react to these:
billing.subscription_createdbilling.subscription_updatedbilling.subscription_canceledbilling.payment_failednotifications.requested
AGENTS.md
Read the instructions installed with this module
markdown# Ajolla Billing Management
Subscriptions sold through **your own Stripe account**. Stripe Checkout starts a subscription, the Stripe Customer Portal changes or cancels it, and a verified webhook syncs customers, subscriptions and invoices into your database. Your code checks entitlements against those synced tables, so it never calls Stripe on a page view.
## What was installed
**Routes**
- `/billing`: the signed-in user's plan, status and renewal date, plus friendly notices for trialing, past-due, unpaid, incomplete, ending and canceled subscriptions. It also lists what the plan includes, offers upgrade, downgrade or subscribe for each active price, has a "Manage billing" button for the Portal, and shows recent invoices with a refresh button. `/billing?org=<orgId>` shows the same page for an organization. Viewing it needs `billing.view` or `billing.manage` in that org, and changing it needs `billing.manage`.
- `/admin/billing`: estimated MRR, subscription counts by status, customer count, webhook health, manual sync buttons and recent subscriptions (needs `billing.manage`).
- `/admin/billing/subscriptions`: filter by status or plan, resync any row from Stripe.
- `/admin/billing/customers`: search by email or `cus_…` id.
- `/admin/billing/plans`, `/admin/billing/plans/new`, `/admin/billing/plans/[planId]`: the plan editor (name, key, features, entitlement JSON, sort order, active, default), attaching Stripe prices, and "Sync from Stripe".
- `/admin/billing/events`: the webhook log (processed, ignored or failed, with error details).
- `POST /api/ajolla/billing/webhook`: the Stripe webhook.
- `GET /api/ajolla/billing/checkout/return`: Checkout's success URL. It syncs the new subscription at once, then redirects to `/billing`.
**Code**
- `lib/ajolla/billing/core.ts`: all database logic, framework-free.
- `entitlements.ts`: pure plan and entitlement evaluation.
- `stripe-sync.ts`: pure Stripe → row projections, catalog import and MRR maths.
- `notices.ts`: notification wording.
- `view.ts`: labels, formatting and form helpers.
- `app/billing/_lib/stripe.ts`: every Stripe call (client, checkout, portal, sync, events).
- `app/billing/_lib/owner.ts`: decides whether `/billing` is for the user or an org.
- `components/ajolla/billing/*`
- `lib/ajolla/admin/billing.ts`
**Tables** (`ajolla_billing_`)
- `plans`: key, name, features, an `entitlements` jsonb, an optional `stripe_product_id`, `active`, and `is_default` (at most one; the free fallback).
- `prices`: Stripe price ids attached to plans, with amount, interval and optional trial days.
- `customers`: one Stripe customer per owner.
- `subscriptions`: synced from Stripe. Updates apply only when newer by `synced_at`, so out-of-order webhooks can't regress state.
- `invoices`: a cache of invoices.
- `events`: the webhook log. Its unique `stripe_event_id` makes delivery idempotent.
Owners are polymorphic. Exactly one of `user_id` or `org_id` is set. `org_id` has no foreign key, so Billing works with or without Roles.
**RLS**
- Anyone, including anonymous visitors, can read active plans and prices (the pricing catalog).
- Users read their own customer, subscription and invoice rows. Org rows need `billing.view` or `billing.manage` in that org.
- `billing.manage` reads everything and edits plans and prices.
- **Nobody writes customers, subscriptions, invoices or events through the API.** Only the server writes them, using the secret key, in the signature-verified webhook, in checkout after checking the signed-in user, or in admin sync actions after `requirePermission("billing.manage")`. Otherwise a user could grant themselves a plan.
**Events**
- `billing.subscription_created`, `billing.subscription_updated` and `billing.subscription_canceled`. Their payload is `{ subscriptionId, stripeSubscriptionId, userId, orgId, planKey, previousPlanKey, status, previousStatus, cancelAtPeriodEnd, currentPeriodEnd }`. They are emitted only when something actually changed, so retries and manual syncs don't duplicate them.
- `billing.payment_failed`, with `{ invoiceId, stripeInvoiceId, stripeSubscriptionId, userId, orgId, amountDue, currency, attemptCount, nextAttemptAt }`.
- `notifications.requested` for a failed payment, an upcoming renewal (`invoice.upcoming`), a trial ending and a subscription ending. Orgs are notified at the billing email. Install Notifications to deliver them. Without it, nothing breaks.
**Env**
- `STRIPE_SECRET_KEY`: prefer a restricted key (`rk_…`).
- `STRIPE_WEBHOOK_SECRET`
- `NEXT_PUBLIC_SITE_URL`: used for return URLs and notification links.
- `STRIPE_PORTAL_CONFIGURATION_ID`: optional.
## Setup
1. Apply migrations with `supabase db push`.
2. Create a restricted API key in Stripe (Developers → API keys). It needs write access to Customers, Checkout Sessions, Customer portal and Subscriptions, and read access to Products, Prices and Invoices. Set it as `STRIPE_SECRET_KEY`, and set `NEXT_PUBLIC_SITE_URL`. Develop against a Stripe sandbox, not live mode.
3. Add a webhook endpoint for `https://<your-app>/api/ajolla/billing/webhook` with the events listed at the top of `app/api/ajolla/billing/webhook/route.ts`. Copy its signing secret into `STRIPE_WEBHOOK_SECRET`. Locally, use `stripe listen --forward-to localhost:3000/api/ajolla/billing/webhook`.
4. Configure the Customer Portal in Stripe (Settings → Billing → Customer portal). Enable payment method updates, cancellation and **subscription updates with your plans' products**, because plan changes from `/billing` open the Portal's confirmation screen.
5. Set up plans at `/admin/billing/plans`. You can create a **Free** plan marked *Default* plus paid plans with Stripe prices attached. Or create Products and recurring Prices in Stripe and click **Sync from Stripe**. Optional product metadata: `ajolla_plan_key` (e.g. `pro`), `ajolla_entitlements` (JSON such as `{"projects":10,"api":true}`) and `ajolla_ignore=true`. Edit entitlements in the admin afterwards; later syncs keep them unless the metadata is set.
6. Link `/billing` from your navigation.
## Checking entitlements in your code
```ts
import { getAjollaScope, requireAjollaUser } from "@/lib/ajolla/access";
import { getLimit, getSubscription, hasEntitlement, requireEntitlement } from "@/lib/ajolla/billing/core";
const user = await requireAjollaUser();
const scope = await getAjollaScope();
if (await hasEntitlement(scope, user.id, "api")) { /* … */ }
const maxProjects = await getLimit(scope, user.id, "projects"); // Infinity when "unlimited", 0 when not included
await requireEntitlement(scope, user.id, "exports"); // throws AjollaError("upgrade_required", 402)
const sub = await getSubscription(scope, user.id); // status, current_period_end, plan_id…
// Organizations: pass { kind: "org", id: orgId } instead of user.id.
```
Each check is two small indexed queries on the synced tables. Wrap them in React `cache()` if one request checks many keys.
- `active`, `trialing` and `past_due` grant the plan. `past_due` keeps access while Stripe retries the card. To change that, edit `ENTITLED_STATUSES` in `entitlements.ts`.
- Everything else, and any subscription whose price isn't attached to a plan, falls back to the default plan.
When org members who lack `billing.view` must be checked, run the check on the server with an admin scope. Do this only after confirming membership yourself.
## Customising
- **Statuses and fallback:** see `ENTITLED_STATUSES` and `resolveEntitlements()` in `entitlements.ts`.
- **Checkout options:** see `createCheckoutUrl()` in `app/billing/_lib/stripe.ts`. Promotion codes and billing address are on. One free trial per owner applies when a price has trial days. If you have a Stripe Tax registration, add `automatic_tax: { enabled: true }` there.
- **Notification wording:** edit `notices.ts`.
- **Statement of cost:** Stripe is called only on user actions and webhooks.
- Checkout makes 1 to 2 calls.
- The Portal makes 1 call.
- A plan change makes 2 calls.
- An invoice refresh makes 1 call and is limited to once every 5 minutes per customer.
- The webhook makes 1 call per subscription event (it re-reads the subscription) and 0 for invoice events.
- Admin "sync recent" makes 1 list call, and catalog sync makes up to 10 calls.
- **Webhook log:** processed events older than 90 days are pruned automatically, and failed events are kept for review.
- **Styles:** edit `components/ajolla/billing/billing.module.css` and the kit's `--aj-*` variables.