autumn-billing
Subscription billing for autumn-web applications. Stripe is the first provider.
The plugin gives you:
- Hosted checkout and the customer portal (
POST /billing/checkout,POST /billing/portal). - A local mirror of customers, subscriptions and invoices, fed by signed webhooks.
- A plan gate: the
Entitled<R>extractor and theBillinghandle read the mirror only. - Dunning: durable payment retries on a schedule you set, with in-app notifications.
The provider stays the source of truth. The mirror is what your handlers read.
Installation
Or add the dependency by hand:
[]
= "0.10.0"
Mount
use *;
let billing = from_autumn_toml?;
let plans = new.plan;
app
.plugin
.run
.await;
BillingPlugin also accepts .provider(..), .store(..) and .hooks(..).
The default store is the database when a pool exists, else memory (lost on
restart). In production the memory store is a boot error unless
billing.allow_memory_store_in_production = true.
The bare .plugin(autumn_billing::BillingPlugin::new()) boots with config
from the environment only and an empty plan catalog. Registering the plugin
twice is a no-op: the framework installs each plugin once.
Webhook endpoint
Declare the receiver in autumn.toml. SignedWebhook verifies the
signature and CSRF exempts the path. Boot fails when the entry is missing,
when its provider preset is not the one the billing provider needs, or when
it allows less than 4 MiB of request body.
[[]]
= "billing"
= "/billing/webhook"
= "stripe"
= "STRIPE_WEBHOOK_SECRET"
= 4194304
max_body_bytes is not optional here: the webhook default is 1 MiB, and a
provider event that carries many invoice lines can be larger. An oversized body
is rejected before it is reconciled and the provider retries it unchanged, so
the event is never applied. A larger limit is yours to choose.
Configuration
Every [billing] key with its default. Omit a key to keep the default.
[]
= "/billing" # mount point of the routes
= "billing" # name of the [[security.webhooks.endpoints]] entry
= "/billing/success" # redirect after a completed checkout
= "/billing/cancel" # redirect after an abandoned checkout
= "/" # redirect when the customer leaves the portal
= false # entitle `past_due` subscriptions
= 72 # entitlement past `current_period_end`
= false # boot without a database pool in production
[]
= true # `false`: mirror and notify, never retry
= [24, 72, 120] # delay before retry 1, 2 and 3
= "cancel_subscription" # or "mark_unpaid"
[]
= "sk_test_..." # STRIPE_SECRET_KEY wins
= "whsec_..." # STRIPE_WEBHOOK_SECRET wins
= "https://api.stripe.com" # AUTUMN_BILLING__STRIPE__API_BASE wins; https:// in production
[[]] # none by default; merged with the code catalog
= "pro"
= "Pro"
= "price_pro"
= 1999
= "USD"
= "month" # "day", "week", "month" or "year"
= ["export"]
Load the section with BillingConfig::from_autumn_toml("autumn.toml"), or
build it in code: BillingConfig::from_env().urls(..).dunning(..).
Environment
Environment values win over autumn.toml values. Blank values are ignored.
| Variable | Purpose |
|---|---|
STRIPE_SECRET_KEY |
API key (sk_live_… / sk_test_…). Production rejects test keys. Overrides billing.stripe.secret_key. |
STRIPE_WEBHOOK_SECRET |
Webhook signing secret (whsec_…). Overrides billing.stripe.webhook_secret. |
AUTUMN_BILLING__STRIPE__API_BASE |
Stripe API base URL. Overrides billing.stripe.api_base. Production requires https://. |
AUTUMN_BILLING__ROUTE_PREFIX |
Mount point of the routes. Overrides billing.route_prefix. |
Plans
In code:
let plans = new
.plan
.plan;
Or as [[billing.plans]] entries in autumn.toml (see Configuration).
Both sources merge into one catalog.
Gate a route
;
async
Entitled<R> runs before the body is read. It answers 401 without a
session user and 403 when no entitled subscription satisfies R. The gate
checks every subscription of the customer, not only the one that
Billing::current_subscription shows.
For a decision inside a handler, Billing::current_user reads the user id
with the app's configured auth session key:
async
A user is entitled when the mirror subscription is active or trialing
(past_due only with allow_past_due = true), the price maps to a catalog
plan, and current_period_end plus grace_period (default 72h) is not in
the past. Without a known period end the grace period counts from the last
event applied to the row. Every missing piece denies.
Routes
| Method | Path | Auth | Response |
|---|---|---|---|
| POST | /billing/checkout |
session | 303 to checkout, or JSON { "url", "id" } with Accept: application/json. 404 unknown plan, 409 live subscription exists, or a checkout for the user is in progress. |
| POST | /billing/portal |
session | 303 to the portal, or JSON { "url", "id" }. 404 when the user has no customer. |
| GET | /billing/subscription |
session | JSON {subscription, plan, entitled} from the mirror. |
| POST | /billing/webhook |
Stripe signature | JSON {accepted, outcome, event_id}. 503 while another delivery of the event is being applied: the provider retries. |
checkout takes {"plan": "pro"} as JSON or plan=pro as a form. Redirect
URLs come from the config only. Each process runs one checkout per user at a
time. Replicas do not share this limit.
Dunning
When an invoice payment fails the plugin opens a billing_dunning row and
schedules the retry job. The default policy runs three retries. The first
retry runs 1 day after the failure, the second 3 days after that, the third
5 days after that (days 1, 4 and 9). After the last failed retry the plugin
cancels the subscription. Every retry goes through the store row, so a
duplicate or early job run is a no-op.
A failure can arrive before its subscription event. The invoice keeps the provider subscription id. When the plugin stores the subscription, it links the invoice and its open dunning row.
A custom BillingStore must override link_subscription to get this link.
The default does nothing. Invoices stored before the upgrade have no provider
subscription id. The link does not cover them.
let billing = from_env
.dunning;
After a restart the plugin re-arms every pending row from the store. No
retry is lost. A retry whose provider call fails in transport keeps its
attempt number and runs again 15 minutes later; a row left running by a
crashed process is reclaimed after 10 minutes.
Stripe Smart Retries: disable them in the Stripe dashboard when the
plugin retries, or the two schedules compete. To keep Stripe's retries, use
DunningPolicy::disabled(): the plugin mirrors and notifies but never retries.
Notifications
Each step writes to the in-app notification store (Notifications). The
recipient is BillingHooks::recipient_for(user_id) for the customer's
linked user. None suppresses the notification.
| Kind | Payload fields |
|---|---|
billing.payment_failed |
invoice_id, provider_invoice_id, subscription_id, amount_due, attempt (scheduled retry number; null when dunning is disabled), provider_attempt_count, reason |
billing.payment_recovered |
invoice_id, provider_invoice_id, subscription_id, amount_paid |
billing.dunning_exhausted |
invoice_id, provider_invoice_id, subscription_id, amount_due, attempts, action |
billing.subscription_canceled |
subscription_id, provider_subscription_id, plan_id |
Money fields serialize as {"minor": 1999, "currency": "USD"}.
Database
Five tables: billing_customers, billing_subscriptions,
billing_invoices, billing_events (the idempotency ledger) and
billing_dunning. The migration is 20260910203829_billing_mirror, registered
by the plugin. A partial unique index on billing_customers.user_id keeps
one customer per user when two checkouts race. Applied ledger rows older than
30 days are pruned at startup. Without a pool the plugin uses the in-memory
store; production refuses it unless allow_memory_store_in_production is set.
Tests
Postgres contract tests need Docker: