navree
Documentation

Billing

Subscriptions, trials, seats, metered usage, invoices and the customer portal for users or workspaces, with Stripe or Paddle and verified webhooks processed on the queue.

On this page

Introduction

Featurebilling navree-billing charges your customers through Stripe or Paddle and keeps a copy of their subscriptions in your database, so a check like "is this workspace on Pro?" is a local query, not an API call.

You make a type billable (a user or, in most SaaS apps, a workspace), list your plans in configuration, and the Billing service does the rest:

  • Checkout. Send the customer to the provider's hosted checkout for a plan, with or without a trial.
  • Subscriptions. Swap plans with proration, change the seat count, cancel at the end of the period, resume, pause.
  • Trials, with or without a payment method up front.
  • Metered usage. Record usage as it happens; Navree reports it to the provider in batches.
  • Invoices and the customer portal, where customers update their card and download receipts.
  • Webhooks. The provider's events are verified, stored once and processed on the queue, and each one becomes a typed event your listeners handle.

Card numbers never touch your servers: checkout and the portal are hosted by the provider.

Installation

Turn on the billing feature of the navree facade. It brings navree-billing, and with it the auth, workspace, database, events, HTTP client and scheduling features it builds on. Billing is opt-in: the default full feature leaves it out.

Cargo.toml

Add BillingModule to your app root. It needs the database. Its routes need an authenticator (auth) and, for workspace billing, access control and workspaces. The queue runs the webhook jobs, the scheduler reports metered usage, the events bus delivers the billing events, and the HTTP client calls the provider's API (a default client is built when HttpClientModule is not in the app):

src/app.rs

The module contributes the migration set billing, which runs after auth and workspace and creates the tables in the tables. navree migrate runs it with your own migrations.

Configuration

Choose a provider and list your plans in the billing section. Each plan has a key you use in code (pro; lower-case letters, digits, _ and -) and the provider's price ids:

config/app.toml

The API keys and the webhook signing secret are secrets. Put them in .env, never in the TOML file:

.env

Create the webhook endpoint in the Stripe dashboard, pointing at https://api.acme.test/api/billing/webhook, and copy its signing secret. Select the checkout.session.*, customer.*, customer.subscription.* and invoice.* events, or run navree billing:webhook https://api.acme.test/api/billing/webhook to create the endpoint with the right events through the API; it prints the signing secret to put in .env.

Every request is pinned to Stripe API version 2025-03-31.basil (navree::billing::STRIPE_API_VERSION) with the Stripe-Version header, and billing:webhook creates the endpoint on the same version, so the payloads have the shape Navree reads.

Every key also comes from the environment with the APP_ prefix and __ between levels: APP_BILLING__PLANS__PRO__TRIAL_DAYS=30. A plan without a monthly or yearly price, an unknown provider or a missing secret fails the boot, naming the key; secrets are never printed. The secret key (billing.stripe.secret_key or billing.paddle.api_key) is always required for its provider, the webhook secret while billing.webhook_route is on. To configure the module in code, pass BillingOptions to BillingModule::for_root.

Billable models

navree-billing implements Billable for the workspace model (navree::workspace::Workspace) and for the auth User, and for their request extractors CurrentWorkspace and CurrentUser, so a handler passes what it already has. Register the type you bill with for_feature, in the module that owns billing:

src/modules/settings/mod.rs

Bill users instead with BillingModule::for_feature::<navree::auth::User>(). To bill a type of your own, such as an organizations table, implement the trait:

src/modules/organizations/billing.rs

billing_email may return None (a workspace has no email of its own); the checkout then uses the email passed to customer_email, which the built-in routes fill with the signed-in user's, or the provider's checkout asks for one. The provider's customer is created on the first checkout and linked to the billable in billing_customers; on Paddle, which needs an email for a customer, a checkout without one links the customer when the payment's webhook arrives.

A type of your own works with every Billing method. To use it on routes (the built-in routes and the Subscribed extractor), also implement Billable::from_request, which finds the request's billable; the built-in billables resolve it from CurrentWorkspace and CurrentUser.

Checkout

Start a subscription by sending the customer to the provider's checkout. checkout returns a builder; create returns the hosted page's URL:

src/modules/settings/billing.rs
JSON

The front end redirects the browser to url. When the customer pays, the provider sends a webhook, the subscription appears in your database and SubscriptionCreated is emitted. Don't treat the redirect to success_url as proof of payment: it only means the customer came back.

Builder methodEffect
yearly(), monthly(), interval(i)Which of the plan's prices to charge (monthly by default)
trial_days(n)Overrides the plan's trial_days; 0 skips the trial
quantity(n)Seats; with seats = true the default is the workspace's member count, otherwise 1
coupon(code)A provider coupon or promotion code (Stripe: a coupon id or a promo_... id; Paddle: a discount id)
allow_promotion_codes()Lets the customer type a code on the checkout page (Stripe)
customer_email(email)Prefills the email when the billable has none
success_url(url), cancel_url(url)Where the customer returns; both default to billing.return_url
metadata(key, value)Stored on the provider's subscription

create returns a Checkout (id, url, plan, interval, quantity, trial_days), which converts into the CheckoutUrl response. An unknown plan key is BillingError::UnknownPlan, a 400 in a handler (errors.plan); a plan without a price for the interval is BillingError::NoPrice (errors.interval). A billable that already has a valid subscription gets a 409: change its plan with swap instead.

Checking subscriptions

subscription reads the local copy, so it costs one indexed query. It returns the billable's valid subscription, else its latest one that has not ended, else its latest one:

Rust
Subscription
plan, intervalThe plan key and Monthly or Yearly
statusTrialing, Active, PastDue, Paused, Canceled or Incomplete
quantitySeats
trial_ends_at, ends_at, current_period_endUTC timestamps; ends_at is set once it is canceled
valid()Trialing, active, on its grace period after a cancellation, or past due for less than billing.past_due_grace_days
on_trial(), on_grace_period(), canceled(), past_due(), paused(), ended()What they say
on_plan(plan)Whether it is on that plan key

The checks are evaluated at the time the subscription was read, with the app's dyn Clock. subscriptions(&billable) lists every subscription, newest first.

Requiring a subscription on a route

The Subscribed extractor answers 402 unless the request's billable (the CurrentWorkspace, or the CurrentUser when you bill users) has a valid subscription or a trial without one. It derefs to the Subscription:

Rust
JSON

A front end maps the code to its "upgrade your plan" screen in the user's language (see error codes).

billing.access(&billable) gives the same answer outside a request. To show a feature to some plans before you gate it, combine billing with feature flags.

Changing subscriptions

Rust

Every call updates the provider first and the local copy after it, in the current unit of work (the request's in a handler) or its own, and returns the updated Subscription. The webhook that follows confirms the change and is a no-op when nothing differs. A billable without a subscription gets BillingError::NoSubscription, a 404.

  • Proration. Swaps and quantity changes are prorated and charged on the next invoice (Proration::NextInvoice). swap_with(&workspace, "business", Proration::InvoiceNow) charges the difference at once; Proration::None doesn't prorate. swap keeps the current interval; swap_monthly, swap_yearly and swap_to(&workspace, plan, Some(interval), proration) choose it. Metered prices follow the new plan's meters.
  • Seats. For a plan with seats = true, Navree listens to the workspace events MemberAdded and MemberRemoved (a queued listener) and updates the quantity when someone joins or leaves; a swap onto such a plan takes the member count. Set seats = false and call update_quantity yourself to bill something else per unit.
  • Grace period. After cancel, valid() stays true until ends_at, the end of the paid period. resume works until then.

Trials

A plan's trial_days applies to every checkout of that plan. Pass .trial_days(n) to override it for one checkout. The provider collects the card at checkout and charges it when the trial ends; on Stripe, three days before, it sends a "trial ending" webhook and Navree emits TrialEnding. Paddle sends no such webhook, so TrialEnding is not emitted there.

To let a new workspace try the product before it enters a card, start a trial without a subscription:

Rust

A trial without a subscription lives in billing_customers.trial_ends_at and never reaches the provider. Subscribed lets the workspace through while it lasts, with subscription.plan set to the plan in billing.trial_plan (default: the first plan by key) and is_trial_without_subscription() true.

Metered usage

Bill per use (AI summaries generated, emails sent, API calls) with a metered price. Add it under the plan's meters and record usage where it happens:

Rust

report_usage writes one row to billing_usage in the current unit of work, so a request that fails records nothing and the call costs no network round trip. A scheduled task, billing:report-usage, runs every minute with ScheduleModule in the app: it sums the unreported rows per billable and meter and sends them to the provider, then marks them reported. On Stripe that is a meter event for the meter behind the metered price (Navree reads the meter's event name from the price once); on Paddle, a one-time charge added to the next renewal. Without the scheduler, run the command yourself, from cron or a worker.

Rust

Reporting a meter no plan has, or usage for a billable whose plan has no such meter, is BillingError::UnknownMeter (a 400); usage for a billable without a valid subscription is BillingError::NoSubscription. Rows of a billable whose subscription ended before they were reported stay unreported.

Invoices and the customer portal

Rust

Invoices are read from the provider (on Paddle, the customer's billed transactions, without document links) and cached in memory for billing.invoice_cache_secs (300); a payment webhook for the billable clears its cache. The customer portal is the provider's hosted page where customers change their card, switch plans you allow there, cancel and download every invoice. Changes made in the portal reach you as webhooks, like any other change. A billable without a provider customer yet gets BillingError::NoCustomer from portal_url.

Built-in routes

With billing.routes = true (the default), the module mounts JSON routes for your billing settings page, under /api/billing. They act on the request's billable. For a workspace they run behind the InWorkspace guard and require the billing.manage permission in the workspace; a workspace's owner has every permission, so grant it to admins in the workspace roles. For users, any signed-in user manages their own billing. For a billable type of your own they require the billing.manage permission.

config/app.toml

The routes:

RouteDoes
GET /api/billingThe subscription, the trial, this period's usage per meter and the plans
GET /api/billing/plans{data}: the configured plans with their names, intervals and prices
POST /api/billing/checkout{plan, interval?}: {url} of a checkout (prefilled with the signed-in user's email)
POST /api/billing/swap{plan, interval?}: the updated subscription
POST /api/billing/cancel, POST /api/billing/resumeThe updated subscription
GET /api/billing/invoices{data}: the invoices
POST /api/billing/portal{return_url?}: {url} of the customer portal; return_url must be on the origin of billing.return_url
POST /api/billing/webhookThe provider's webhooks; public and verified; {received, duplicate}

When you register both User and Workspace as billables, set billing.routes_billable to workspace or user; until you do, the boot fails, naming the key. Turn the routes off with billing.routes = false and keep only the webhook with billing.webhook_route = true.

Webhooks and events

The provider tells you about every change through webhooks. POST /api/billing/webhook handles them in three steps:

  1. It verifies the signature: Stripe-Signature (t=..,v1=.., an HMAC-SHA256 of the timestamp and the raw body) or Paddle-Signature (ts=..;h1=.., the same scheme), with 5 minutes of tolerance in either direction, and answers 400 to anything that fails, including a body it cannot read. The comparison is constant-time and several signatures in one header (secret rotation) are accepted.
  2. It stores the event in billing_events, keyed by the provider's event id. A redelivered event is answered 200 and not stored again.
  3. It answers 200 and dispatches a billing.process_event job on billing.queue (without a queue module, the job runs at once). The job updates the local customer and subscription rows, ignores states older than the one it already has (events can arrive out of order), clears the billable's cached invoices and emits a Navree event once its unit of work commits.

The events are ordinary events:

EventNAMEWhen
SubscriptionCreatedsubscription.createdA checkout completed
SubscriptionUpdatedsubscription.updatedPlan, quantity, status or period changed
SubscriptionCanceledsubscription.canceledCanceled, now or at the period's end (ends_at)
TrialEndingsubscription.trial_endingThree days before a trial with a subscription ends (Stripe)
InvoicePaidinvoice.paidA payment succeeded
PaymentFailedinvoice.payment_failedA payment failed; the provider retries it

Each carries billable_type, billable_id, subscription_id and plan; the invoice events add invoice_id, amount_cents and currency, and their subscription_id and plan are Options (an invoice need not belong to a subscription). This listener tells a workspace's owner that a payment failed, through a notification:

src/modules/settings/listeners.rs

A job that fails is retried with backoff (8 attempts, from 10 seconds to 10 minutes apart); each failure is recorded on its billing_events row (attempts, error), and billing:events --failed lists the events that have not succeeded since. billing:sync <type> <id> pulls a billable's subscriptions from the provider when you need to repair a copy by hand (billing:sync workspace 0199...).

The tables

TableHolds
billing_customersOne row per billable: billable_type, billable_id, the provider and its customer id, email, trial_ends_at
subscriptionsplan, interval, status, quantity, trial_ends_at, ends_at, current_period_start, current_period_end, past_due_since, the provider's ids and the time of the provider state it reflects
subscription_itemsEach price of a subscription, metered prices included (with their meter key)
billing_usageRecorded usage, with reported_at once sent
billing_eventsEvery webhook received: type, payload, processed_at, attempts, error

Read them through Billing rather than querying them: a subscription row is only as fresh as the last webhook, and the service methods know which fields the provider owns. The entities are in navree::billing::entity. Subscriptions are an access subject, Subscription (SUBSCRIPTION_SUBJECT, permissions subscriptions.*), so an admin panel can list them with Helm and routes can check #[can(Read, subscription::Entity)], as the SaaS starter kit's admin panel does.

Testing

Set billing.provider = "fake". The fake provider records checkouts and usage instead of calling Stripe or Paddle, and simulates the provider's webhooks so your listeners run as they would in production:

tests/billing.rs
BillingFake
checkouts(), assert_checkout(predicate)The checkouts created; assert_checkout returns the match (id, url, plan, interval, price_id, quantity, trial_days, coupon, customer_email, metadata, ...)
complete_checkout(id)Sends the webhooks of a paid checkout: customer, subscription, first invoice
fail_next_payment(&billable)The next payment (a renewal, or the end of a trial) sends invoice.payment_failed
end_trial(&billable), renew(&billable)Sends the webhooks of a trial ending or a period renewing (or, after cancel, of the subscription ending)
reported_usage(&billable, meter)The usage the reporter sent
send_webhook(type, json)Any event: json is a Stripe-shaped data.object
webhook_secret()The secret the fake signs with (Stripe-Signature), to post to the webhook route yourself

The simulated webhooks are Stripe-shaped events signed with the fake's own secret, and they go through the same verification, storage, job and mapping as real ones, so run the queue (or use queue.driver = "memory" and run_pending) before you assert on subscriptions. Changes made through Billing (swap, cancel, ...) update the fake and the local copy at once; the fake sends no webhook for them. Fake invoices cost 1000 cents per seat and period (nothing during a trial). Override dyn Clock with a FixedClock to test trials and grace periods.

To test against the provider's own test mode, use real test keys (sk_test_..., the Paddle sandbox) in a separate, opt-in test suite, and forward webhooks to your machine with the provider's CLI. billing.stripe.api_base and billing.paddle.api_base point the clients at a mock server or a proxy.

Reference

KeyDefaultMeaning
billing.providerstripestripe, paddle or fake
billing.currencyusdThe currency of the fake provider's invoices (Stripe and Paddle take it from the price)
billing.return_urlapp.urlWhere checkout and the portal return
billing.queuedefaultThe queue of the webhook jobs
billing.past_due_grace_days7Days a past-due subscription stays valid
billing.trial_planthe first plan by keyThe plan a trial without a subscription grants
billing.invoice_cache_secs300How long invoice lists are cached (0: not cached)
billing.routestrueMounts the /api/billing routes
billing.routes_billablethe only registered billableworkspace, user or the BILLABLE_TYPE of a type of your own
billing.webhook_routetrueMounts POST /api/billing/webhook
billing.webhook_tolerance_secs300Accepted clock skew of webhook signatures
billing.stripe.secret_key, billing.stripe.webhook_secretnoneSecrets; from .env
billing.stripe.api_basehttps://api.stripe.comThe Stripe API's base URL
billing.paddle.api_key, billing.paddle.webhook_secretnoneSecrets; from .env
billing.paddle.environmentsandboxsandbox or production
billing.paddle.api_basethe environment'sThe Paddle API's base URL
billing.plans.<key>.namethe keyDisplay name
billing.plans.<key>.pricesnone{ monthly, yearly } price ids; at least one
billing.plans.<key>.trial_days0Trial on checkout
billing.plans.<key>.seatsfalseQuantity follows the workspace's member count
billing.plans.<key>.meters{}Meter key to metered price id

BillingError returned from a handler with ? becomes an error response with its own code:

ErrorStatuscode
UnknownPlan, NoPrice, UnknownMeter, Invalid400validation_failed, with the field under errors
AlreadySubscribed409already_subscribed
NoSubscription404no_subscription
NoCustomer404no_billing_customer
InvalidWebhook400invalid_webhook_signature
InvalidPayload400invalid_webhook_payload
Provider502payment_provider_error (no URL or key reaches the client)
Db, Fake, Config, Other500internal_error
no valid subscription (Subscribed)402subscription_required
CommandWhat it does
billing:plansLists the plans and checks every price id against the provider
billing:webhook <url>Creates the provider's webhook endpoint with the events Navree handles, and prints its secret
billing:report-usageReports unreported usage (scheduled every minute)
billing:sync <type> <id>Pulls a billable's subscriptions from the provider
billing:events [--failed]Lists received webhook events
billing:prune-events [--days N]Deletes processed events older than N days (default 90)