autumn-billing adds subscription billing to an Autumn app: hosted checkout, the
customer portal, a webhook-fed local mirror of billing state, a plan gate for
handlers, and durable retries for failed payments. Stripe ships first behind a
provider-neutral BillingProvider trait.
- Crate:
autumn-billing - Builds on:
SignedWebhook(verified intake),#[job](durable retries), the notification store,plugin_migrations(mirror tables).
1. Install
autumn plugin add autumn-billing
Set the keys in the environment:
export STRIPE_SECRET_KEY=sk_live_...
export STRIPE_WEBHOOK_SECRET=whsec_...
Declare the webhook receiver in autumn.toml. SignedWebhook verifies the
signature and rejects replays; CSRF exempts the path because it is declared
here:
[[security.webhooks.endpoints]]
name = "billing"
path = "/billing/webhook"
provider = "stripe"
secret_env = "STRIPE_WEBHOOK_SECRET"
max_body_bytes = 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, the provider retries it unchanged, and the
event is never applied, so billing state goes stale. Boot fails if the entry
allows less than 4 MiB (a larger limit is yours to choose).
Boot fails with this exact snippet when the endpoint is missing.
2. Mount
use autumn_billing::prelude::*;
let billing = BillingConfig::from_autumn_toml("autumn.toml")?;
let plans = PlanCatalog::new().plan(
Plan::new("pro", "Pro", "price_123", Money::from_minor(1999, Currency::USD), BillingInterval::Month)
.entitlement("export"),
);
autumn_web::app()
.plugin(BillingPlugin::new().config(billing).plans(&plans))
.run()
.await;
Plans can also live in TOML:
[billing]
success_url = "/billing/success"
cancel_url = "/billing/cancel"
portal_return_url = "/account"
[[billing.plans]]
id = "pro"
name = "Pro"
price_id = "price_123"
amount_minor = 1999
currency = "USD"
interval = "month"
entitlements = ["export"]
The plugin registers its migration through plugin_migrations; the next
autumn migrate creates billing_customers, billing_subscriptions,
billing_invoices, billing_events and billing_dunning. A partial unique
index on billing_customers.user_id keeps one customer per user when two
checkouts race. Without a database pool the plugin falls back to an in-memory
mirror that is lost on restart; a production profile refuses to boot on it
unless billing.allow_memory_store_in_production = true. Production also
requires billing.stripe.api_base to start with https://.
3. Routes
| Method | Path | Purpose |
|---|---|---|
| POST | /billing/checkout | { "plan": "pro" } → 303 to hosted checkout (JSON: { "url", "id" }); 404 unknown plan |
| POST | /billing/portal | 303 to the hosted billing portal |
| GET | /billing/subscription | Current subscription + plan from the local mirror |
| POST | /billing/webhook | Signed provider webhook receiver |
Checkout and portal need a logged-in session (401 otherwise). Checkout returns
409 when the user already has a live subscription. Redirect URLs come from
config only. GET /billing/subscription never calls the provider.
4. Gate a handler
use autumn_billing::prelude::*;
struct Pro;
impl PlanRequirement for Pro {
fn rule() -> PlanRule { PlanRule::plan("pro") }
}
#[get("/reports")]
async fn reports(_pro: Entitled<Pro>) -> &'static str { "ok" }
Entitled<R> runs before the body is read: 401 without a session user, 403
when no entitled subscription satisfies the rule. The gate checks every
subscription of the customer, so a second, newer plan does not hide an older
plan that satisfies the rule. Inside a handler or a Policy, use the service
form:
async fn export(billing: Billing, session: Session) -> AutumnResult<&'static str> {
let user = billing.current_user(&session).await.map_err(BillingError::into_autumn)?;
billing.require(&user, &PlanRule::entitlement("export")).await.map_err(BillingError::into_autumn)?;
Ok("csv")
}
Billing::current_user reads the user id with the app's configured auth
session key and answers 401 when nobody is logged in. Billing::require
returns the subscription that satisfies the rule and has the latest
last_event_at.
Billing::current_subscription picks one subscription to show and is not a
gate.
Entitled means: status active or trialing (past_due only with
allow_past_due), the price maps to a catalog plan, and
current_period_end + grace_period is not in the past. The grace period
(default 72 h) bounds how long a dead webhook keeps a lapsed customer entitled.
When no period end is known the deadline is last_event_at + grace_period.
Everything else is denied.
5. The mirror and idempotency
The provider is the source of truth. Every webhook event is claimed in
billing_events by its provider event id before it is applied, so a
redelivered event applies once. Upserts carry the provider event time: an older
event never overwrites newer state, a same-instant conflict resolves to the
higher-ranked status, and a canceled subscription is terminal. A failure after
the claim releases it, so the provider's redelivery applies again.
6. Dunning
invoice.payment_failed opens a row in billing_dunning and enqueues the
autumn_billing_dunning_retry job at the first retry time. The row is the
schedule; the job only carries the invoice id and reads the row, so a duplicate
run, an early run, or a restart is safe. On startup the plugin re-arms every
open row and prunes applied ledger rows older than 30 days. A row left
running by a crashed process is reclaimed 10 minutes after its last update.
Each retry asks the provider to collect the invoice again with a stable
idempotency key:
- paid →
recovered, notificationbilling.payment_recovered - declined → next retry, notification
billing.payment_failed - declined after the last retry → subscription
unpaidin the mirror first, thencancel_subscriptionat the provider (ExhaustionAction::MarkUnpaidskips the cancel), notificationbilling.dunning_exhausted - transport error → the same attempt is rescheduled 15 minutes later and the
job returns
Ok; the schedule row stays the truth and is never dead-lettered
Defaults: 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).
Stripe's own Smart Retries would run in parallel; turn them off in the Stripe
dashboard, or keep them and mount DunningPolicy::disabled() so the plugin
only mirrors and notifies.
Notifications go to the in-app notification store. The recipient is
BillingHooks::recipient_for(user_id) for the customer's linked user; the
default parses a numeric id. Returning None suppresses the notification.
| Kind | Payload fields |
|---|---|
billing.payment_failed | invoice_id, provider_invoice_id, subscription_id, amount_due, attempt, 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 |
attempt is the number of the retry the plugin scheduled (null when dunning
is disabled). reason is the provider decline reason, when known.
7. Money
Money holds i64 minor units and a Currency; read them with minor() and
currency(). Amounts are integer minor units; Money::from_decimal and
to_decimal bridge to rust_decimal::Decimal exactly. The crate contains no
f32 or f64 (a test enforces it).
8. Testing
cargo test -p autumn-billing runs every flow in process with a fake provider
and signed Stripe fixtures. cargo test -p autumn-billing --test mirror_db -- --ignored runs the database store against a Postgres testcontainer.