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

Shell
autumn plugin add autumn-billing

Set the keys in the environment:

Shell
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:

TOML
[[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

Rust
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:

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

MethodPathPurpose
POST/billing/checkout{ "plan": "pro" } → 303 to hosted checkout (JSON: { "url", "id" }); 404 unknown plan
POST/billing/portal303 to the hosted billing portal
GET/billing/subscriptionCurrent subscription + plan from the local mirror
POST/billing/webhookSigned 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

Rust
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:

Rust
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, notification billing.payment_recovered
  • declined → next retry, notification billing.payment_failed
  • declined after the last retry → subscription unpaid in the mirror first, then cancel_subscription at the provider (ExhaustionAction::MarkUnpaid skips the cancel), notification billing.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.

KindPayload fields
billing.payment_failedinvoice_id, provider_invoice_id, subscription_id, amount_due, attempt, provider_attempt_count, reason
billing.payment_recoveredinvoice_id, provider_invoice_id, subscription_id, amount_paid
billing.dunning_exhaustedinvoice_id, provider_invoice_id, subscription_id, amount_due, attempts, action
billing.subscription_canceledsubscription_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.