# 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.

## Introduction

(Feature: `billing`) `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](https://navree.com/docs/workspaces/introduction.md)), 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](https://navree.com/docs/digging-deeper/queues.md), and each one becomes a typed [event](https://navree.com/docs/digging-deeper/events.md) 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.

```toml title="Cargo.toml"
[dependencies]
navree = { git = "https://github.com/NavreeHQ/navree", features = ["billing"] }
```

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

```rust title="src/app.rs"
use navree::prelude::*;

pub fn app(sources: ConfigSources) -> AppBuilder {
    Navree::app()
        .module(ConfigModule::for_root(sources))
        .module(CryptoModule::from_config())
        .module(DatabaseModule::from_config())
        .module(EventsModule::for_root())
        .module(QueueModule::from_config())
        .module(ScheduleModule::for_root()) // reports metered usage every minute
        .module(HttpClientModule::from_config())
        .module(AuthModule::from_config())
        .module(AccessModule::for_root(AccessOptions::default()))
        .module(WorkspaceModule::from_config())
        .module(BillingModule::from_config()) // section `billing`
        .module(SettingsModule)
}
```

The module contributes the migration set `billing`, which runs after `auth` and `workspace` and creates the tables in [the tables](#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:

```toml title="config/app.toml"
[billing]
provider = "stripe"                               # stripe | paddle | fake
return_url = "https://app.acme.test/settings/billing"

[billing.plans.starter]
name = "Starter"
prices = { monthly = "price_1Q8StarterMonthly", yearly = "price_1Q8StarterYearly" }
trial_days = 14

[billing.plans.pro]
name = "Pro"
prices = { monthly = "price_1Q8ProMonthly", yearly = "price_1Q8ProYearly" }
trial_days = 14
seats = true                                      # quantity = member count

[billing.plans.pro.meters]
ai_summaries = "price_1Q8AiSummaries"            # a metered price
```

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

### Stripe

```env title=".env"
APP_BILLING__STRIPE__SECRET_KEY=sk_live_51Q8...
APP_BILLING__STRIPE__WEBHOOK_SECRET=whsec_Jx2...
```

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.

### Paddle

```env title=".env"
APP_BILLING__PROVIDER=paddle
APP_BILLING__PADDLE__API_KEY=pdl_live_apikey_01j...
APP_BILLING__PADDLE__WEBHOOK_SECRET=pdl_ntfset_01j...
APP_BILLING__PADDLE__ENVIRONMENT=production
```

`environment` is `sandbox` (the default) or `production`. Create a notification destination for `https://api.acme.test/api/billing/webhook` with the `subscription.*`, `transaction.*` and `customer.*` events, or run `navree billing:webhook <url>`. Paddle is the merchant of record, so it also handles sales tax and VAT for you.

Paddle's checkout is a transaction opened on your account's default payment link, so set one in the Paddle dashboard. A few checkout options work differently on Paddle: the trial is the trial period of the price in Paddle (a checkout's `trial_days` is not sent), `coupon` is a discount id, and the customer returns to the URLs configured on the payment link rather than `success_url` and `cancel_url`.

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](https://navree.com/docs/workspaces/introduction.md) 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:

```rust title="src/modules/settings/mod.rs"
use navree::prelude::*;
use navree::workspace::Workspace;

#[module(
    imports = [BillingModule::for_feature::<Workspace>()],
    controllers = [BillingController],
)]
pub struct SettingsModule;
```

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:

```rust title="src/modules/organizations/billing.rs"
use navree::billing::Billable;
use uuid::Uuid;

use super::entity::Model as Organization;

impl Billable for Organization {
    /// Stored with customers and subscriptions; keep it stable.
    const BILLABLE_TYPE: &'static str = "organization";

    fn billable_id(&self) -> Uuid {
        self.id
    }

    fn billing_name(&self) -> String {
        self.legal_name.clone()
    }

    fn billing_email(&self) -> Option<String> {
        self.billing_email.clone()
    }
}
```

`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](#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](#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:

```rust title="src/modules/settings/billing.rs"
use navree::billing::{Billing, CheckoutUrl};
use navree::prelude::*;
use navree::workspace::CurrentWorkspace;
use serde::Deserialize;

#[derive(Debug, Deserialize, garde::Validate)]
pub struct Upgrade {
    #[garde(length(min = 1))]
    pub plan: String,
    #[garde(skip)]
    pub yearly: bool,
}

#[injectable]
pub struct BillingController {
    billing: Arc<Billing>,
}

#[controller("/billing/upgrade", version = "1")]
#[guard(InWorkspace::guard())] // `billing.manage` is checked in the workspace
impl BillingController {
    /// `POST /api/v1/billing/upgrade`: a checkout URL for the current workspace.
    #[post("/")]
    #[permissions("billing.manage")]
    async fn upgrade(
        &self,
        workspace: CurrentWorkspace,
        CurrentUser(user): CurrentUser,
        Valid(Json(input)): Valid<Json<Upgrade>>,
    ) -> Result<Json<CheckoutUrl>> {
        let mut checkout = self
            .billing
            .checkout(&workspace, &input.plan)
            .customer_email(&user.email)
            .success_url("https://app.acme.test/settings/billing?upgraded=1")
            .cancel_url("https://app.acme.test/settings/billing");
        if input.yearly {
            checkout = checkout.yearly();
        }
        Ok(Json(checkout.create().await?.into()))
    }
}
```

```json
{ "url": "https://checkout.stripe.com/c/pay/cs_live_a1B2c3..." }
```

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 method | Effect |
|---|---|
| `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
let subscription = billing.subscription(&workspace).await?;

match subscription {
    Some(sub) if sub.valid() => {
        println!("{} on {} until {:?}", workspace.name, sub.plan, sub.current_period_end);
    }
    Some(sub) if sub.past_due() => { /* ask for a new card */ }
    _ => { /* free tier */ }
}

if billing.subscribed(&workspace, "pro").await? {
    // Pro features
}
```

| `Subscription` | |
|---|---|
| `plan`, `interval` | The plan key and `Monthly` or `Yearly` |
| `status` | `Trialing`, `Active`, `PastDue`, `Paused`, `Canceled` or `Incomplete` |
| `quantity` | Seats |
| `trial_ends_at`, `ends_at`, `current_period_end` | UTC 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](#trials). It derefs to the `Subscription`:

```rust
use navree::billing::Subscribed;

#[post("/{id}/summary")]
#[can(Read, Deal, from = "id")]
async fn summarize(&self, subscription: Subscribed, Path(id): Path<Uuid>) -> Result<Json<Summary>> {
    if !subscription.on_plan("pro") {
        return Err(AppError::forbidden().with_detail("AI summaries are part of the Pro plan"));
    }
    // ...
}
```

```json
{ "statusCode": 402, "message": "This workspace needs an active subscription", "error": "Payment Required", "code": "subscription_required" }
```

A front end maps the `code` to its "upgrade your plan" screen in the user's language (see [error codes](https://navree.com/docs/basics/errors.md#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](https://navree.com/docs/packages/feature-flags.md).

## Changing subscriptions

```rust
billing.swap(&workspace, "business").await?;     // prorated, effective now
billing.swap_yearly(&workspace, "pro").await?;   // the plan's yearly price
billing.update_quantity(&workspace, 12).await?;  // seats
billing.cancel(&workspace).await?;               // at the end of the period
billing.cancel_now(&workspace).await?;           // at once, no refund
billing.resume(&workspace).await?;               // undo a cancel before it ends
billing.pause(&workspace).await?;                // stop charging, keep it
billing.unpause(&workspace).await?;
```

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
billing.start_trial(&workspace, 14).await?;

// True for 14 days, with or without a subscription:
billing.on_trial(&workspace).await?;
billing.trial_ends_at(&workspace).await?;
```

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
billing.report_usage(&workspace, "ai_summaries", 1).await?;
```

`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
// This period's total, reported or not:
let used = billing.usage(&workspace, "ai_summaries").await?;
```

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
for invoice in billing.invoices(&workspace).await? {
    println!("{} {} {} {}", invoice.number, invoice.status, invoice.total_cents, invoice.currency);
    // invoice.pdf_url and invoice.hosted_url link to the provider's documents
}

let url = billing.portal_url(&workspace, "https://app.acme.test/settings/billing").await?;
```

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](https://navree.com/docs/workspaces/members-and-roles.md). For users, any signed-in user manages their own billing. For a billable type of your own they require the `billing.manage` permission.

```toml title="config/app.toml"
[workspace.roles.admin]
permissions = ["billing.manage"]
```

The routes:

| Route | Does |
|---|---|
| `GET /api/billing` | The 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/resume` | The 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/webhook` | The 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](https://navree.com/docs/digging-deeper/events.md):

| Event | `NAME` | When |
|---|---|---|
| `SubscriptionCreated` | `subscription.created` | A checkout completed |
| `SubscriptionUpdated` | `subscription.updated` | Plan, quantity, status or period changed |
| `SubscriptionCanceled` | `subscription.canceled` | Canceled, now or at the period's end (`ends_at`) |
| `TrialEnding` | `subscription.trial_ending` | Three days before a trial with a subscription ends (Stripe) |
| `InvoicePaid` | `invoice.paid` | A payment succeeded |
| `PaymentFailed` | `invoice.payment_failed` | A 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 `Option`s (an invoice need not belong to a subscription). This listener tells a workspace's owner that a payment failed, through a [notification](https://navree.com/docs/digging-deeper/notifications.md):

```rust title="src/modules/settings/listeners.rs"
use navree::BoxFuture;
use navree::billing::PaymentFailed;
use navree::events::Listener;
use navree::notify::Notifier;
use navree::prelude::*;
use navree::workspace::Workspaces;

use crate::notify::{NotifiableUser, PaymentFailedNotice};

#[injectable]
pub struct WarnOwnerOfFailedPayment {
    workspaces: Arc<Workspaces>,
    auth: Arc<AuthService>,
    notifier: Arc<Notifier>,
}

impl Listener<PaymentFailed> for WarnOwnerOfFailedPayment {
    fn handle<'a>(
        &'a self,
        event: &'a PaymentFailed,
        _ctx: &'a AppContext,
    ) -> BoxFuture<'a, Result<(), BoxError>> {
        Box::pin(async move {
            let Some(workspace) = self.workspaces.find(event.billable_id).await? else {
                return Ok(()); // deleted since
            };
            if let Some(owner) = self.auth.find_user(workspace.owner_id).await? {
                let notice = PaymentFailedNotice { amount_cents: event.amount_cents };
                self.notifier.send(&NotifiableUser(owner), notice).await?;
            }
            Ok(())
        })
    }

    fn queued(&self) -> bool {
        true
    }
}
```

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

| Table | Holds |
|---|---|
| `billing_customers` | One row per billable: `billable_type`, `billable_id`, the provider and its customer id, `email`, `trial_ends_at` |
| `subscriptions` | `plan`, `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_items` | Each price of a subscription, metered prices included (with their meter key) |
| `billing_usage` | Recorded usage, with `reported_at` once sent |
| `billing_events` | Every 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](https://navree.com/docs/tools/helm.md) and routes can check `#[can(Read, subscription::Entity)]`, as the [SaaS starter kit](https://navree.com/docs/getting-started/starter-kits.md)'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:

```rust title="tests/billing.rs"
use navree::billing::Billing;
use navree::db::Db;
use navree::prelude::*;
use navree::queue::Queue;
use navree::testing::{TestApp, principal};
use navree::workspace::{NewWorkspace, Workspaces};
use serde_json::json;

#[tokio::test]
async fn upgrading_starts_a_pro_trial() {
    let app = TestApp::builder()
        .temp_database()
        .fake_auth()
        .config("app.key", navree::crypto::generate_key())
        .config("billing.provider", "fake")
        .config("billing.plans.pro.prices.monthly", "price_pro")
        .config("billing.plans.pro.trial_days", 14)
        .config("queue.driver", "memory")
        .module(CryptoModule::from_config())
        .module(DatabaseModule::from_config())
        .module(EventsModule::for_root())
        .module(QueueModule::from_config())
        .module(AuthModule::from_config())
        .module(AccessModule::for_root(AccessOptions::default()))
        .module(WorkspaceModule::from_config())
        .module(BillingModule::from_config())
        .module(SettingsModule)
        .build()
        .await;
    let owner = app
        .get::<AuthService>()
        .await
        .create_user("Ada Lovelace", "ada@example.com", "password123")
        .await
        .unwrap();
    let uow = app.get::<Db>().await.uow(None, None).await;
    let workspace = app
        .get::<Workspaces>()
        .await
        .create(&uow, owner.id, NewWorkspace::named("Acme"))
        .await
        .unwrap();
    uow.commit().await.unwrap();

    let response = app
        .acting_as(principal(owner.id))
        .post("/api/v1/billing/upgrade")
        .header("X-Workspace", "acme")
        .json(&json!({ "plan": "pro", "yearly": false }))
        .send()
        .await
        .assert_status(200);

    let billing = app.get::<Billing>().await;
    let fake = billing.fake().expect("the fake provider");
    let checkout = fake.assert_checkout(|c| c.plan == "pro" && c.trial_days == Some(14));
    assert_eq!(response.json_value()["url"], json!(checkout.url));

    fake.complete_checkout(&checkout.id).await.unwrap();
    app.get::<Queue>().await.run_pending().await.unwrap();

    let sub = billing.subscription(&workspace).await.unwrap().unwrap();
    assert!(sub.on_trial() && sub.on_plan("pro"));
}
```

| `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

| Key | Default | Meaning |
|---|---|---|
| `billing.provider` | `stripe` | `stripe`, `paddle` or `fake` |
| `billing.currency` | `usd` | The currency of the fake provider's invoices (Stripe and Paddle take it from the price) |
| `billing.return_url` | `app.url` | Where checkout and the portal return |
| `billing.queue` | `default` | The queue of the webhook jobs |
| `billing.past_due_grace_days` | `7` | Days a past-due subscription stays valid |
| `billing.trial_plan` | the first plan by key | The plan a trial without a subscription grants |
| `billing.invoice_cache_secs` | `300` | How long invoice lists are cached (`0`: not cached) |
| `billing.routes` | `true` | Mounts the `/api/billing` routes |
| `billing.routes_billable` | the only registered billable | `workspace`, `user` or the `BILLABLE_TYPE` of a type of your own |
| `billing.webhook_route` | `true` | Mounts `POST /api/billing/webhook` |
| `billing.webhook_tolerance_secs` | `300` | Accepted clock skew of webhook signatures |
| `billing.stripe.secret_key`, `billing.stripe.webhook_secret` | none | Secrets; from `.env` |
| `billing.stripe.api_base` | `https://api.stripe.com` | The Stripe API's base URL |
| `billing.paddle.api_key`, `billing.paddle.webhook_secret` | none | Secrets; from `.env` |
| `billing.paddle.environment` | `sandbox` | `sandbox` or `production` |
| `billing.paddle.api_base` | the environment's | The Paddle API's base URL |
| `billing.plans.<key>.name` | the key | Display name |
| `billing.plans.<key>.prices` | none | `{ monthly, yearly }` price ids; at least one |
| `billing.plans.<key>.trial_days` | `0` | Trial on checkout |
| `billing.plans.<key>.seats` | `false` | Quantity 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`:

| Error | Status | `code` |
|---|---|---|
| `UnknownPlan`, `NoPrice`, `UnknownMeter`, `Invalid` | 400 | `validation_failed`, with the field under `errors` |
| `AlreadySubscribed` | 409 | `already_subscribed` |
| `NoSubscription` | 404 | `no_subscription` |
| `NoCustomer` | 404 | `no_billing_customer` |
| `InvalidWebhook` | 400 | `invalid_webhook_signature` |
| `InvalidPayload` | 400 | `invalid_webhook_payload` |
| `Provider` | 502 | `payment_provider_error` (no URL or key reaches the client) |
| `Db`, `Fake`, `Config`, `Other` | 500 | `internal_error` |
| no valid subscription (`Subscribed`) | 402 | `subscription_required` |

| Command | What it does |
|---|---|
| `billing:plans` | Lists 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-usage` | Reports 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) |

- [Workspaces](https://navree.com/docs/workspaces/introduction.md): The billable most SaaS apps charge.
- [Webhooks](https://navree.com/docs/packages/webhooks.md): Signed webhooks for your own events and other providers.
- [Feature flags](https://navree.com/docs/packages/feature-flags.md): Roll out features by plan, workspace or percentage.
- [Events](https://navree.com/docs/digging-deeper/events.md): Listen to subscription and invoice events.
