Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Tutorial 19: SaaS billing with Rullst Capital 💳

This tutorial creates a checkout adapter boundary and explains how to feed the optional local Studio view without treating it as an accounting system.

1. Scaffold and configure the selected adapter

cargo rullst make:billing --model Workspace

The command detects a relational SQLx or Turso-primary project, adds the exact orm and capital facade features once, generates a reversible matching migration, registers the models/controller/page modules, and refuses to overwrite an earlier billing scaffold. The generated runtime supports the Stripe live integration or explicit offline fixtures; BILLING_PROVIDER accepts only stripe. To bill through another gateway, implement the Capital provider traits in the application as described in Writing your own payment provider. v13 removed the other built-in adapters; see the v13 migration guide.

The generated Stripe flow persists an opaque local owner, account/test-live scope, immutable customer and checkout intents, session IDs, and event receipts. Signed Checkout/subscription notifications reconcile current provider state. Database revision fencing prevents delayed reads from replacing newer state, and event completion shares the subscription transaction.

Configure BILLING_PROVIDER=stripe, BILLING_ACCOUNT_ID, BILLING_API_KEY, BILLING_WEBHOOK_SECRET, an HTTPS BILLING_REDIRECT_URL, and the explicit BILLING_ALLOWED_PLAN_IDS recurring-price allowlist in the process environment or the project’s .env (in v13 the generated code reads the process environment first, then .env, through rullst::config::project_setting). Follow the generated BILLING.md for webhook event selection, pinned API version, CSP, recovery and migration details. Keep credentials outside source control. Test and live keys use separate persisted namespaces; mixed real/mock credentials fail closed. Live keys also require BILLING_LIVE_ACKNOWLEDGEMENT=I_UNDERSTAND_REAL_CHARGES; without it the generated live path stays unavailable.

Existing controllers are application-owned: merge the generated modules and add the billing-state/event tables through a new migration. Updating a dependency does not rewrite existing controllers or safely backfill legacy customer ownership.

2. Create a checkout session

Mount the generated authenticated checkout POST behind session/tenant ownership and CSRF verification. The form supplies only an allowlisted plan. The generated handler persists the intent before dispatch and the session ID before its HTTP 303 redirect. Repeated requests retrieve and reuse an open session. Older unknown outcomes use read-only recovery rather than new keys after provider retention.

When calling the low-level adapter directly, persist and authorize the same customer/attempt boundary explicitly:

#![allow(unused)]
fn main() {
use rullst_capital::{CapitalError, StripeCheckoutRequest, StripeProvider};

async fn checkout_url(
    stripe: &StripeProvider,
    persisted_attempt: &StripeCheckoutRequest,
) -> Result<String, CapitalError> {
    let session = stripe.create_subscription_checkout(persisted_attempt).await?;
    // Persist session.id() and its digest before returning the URL to the browser.
    Ok(session.url().to_owned())
}
}

Email is optional contact data, never the webhook owner key. The ID-bound portal uses the same persisted customer. The application supplies authentication and membership, configured products/prices, retention, entitlement/settlement policy, and actual provider-account sandbox acceptance. Navigation and session creation never grant paid access.

2.1 Handle provider failure without blindly repeating a charge

Built-in live adapters return a redacted CapitalError::Provider for outbound request construction, transport, HTTP status, bounded-response, JSON, and response-contract failures. Inspect its stable class for telemetry or a durable job decision:

#![allow(unused)]
fn main() {
use rullst_capital::{CapitalError, ProviderFailureClass};

fn provider_disposition(error: &CapitalError) -> Option<ProviderFailureClass> {
    match error {
        CapitalError::Provider(failure) => Some(failure.class()),
        _ => None,
    }
}
}

Do not turn Transient or RateLimited into an unconditional loop. Persist the original command and idempotency identity, confirm that the selected operation actually forwards that identity, cap attempts with backoff/jitter, and reconcile signed provider events. Checkout creation in the legacy unified trait does not accept an application idempotency key, so reconcile before repeating it. The shared client already applies finite timeouts, disables redirects and ambient proxies, caps JSON to one MiB, and validates returned checkout URLs as absolute credential-free HTTPS.

3. Make a bounded immediate charge when checkout is not the right flow

For a payment method already tokenized and authorized for off-session reuse at Stripe, the model deriving Billable can perform one fully specified charge:

#![allow(unused)]
fn main() {
use rullst_capital::{Billable as _, CapitalError, StripeProvider};

async fn charge_saved_method(
    account: &(impl rullst_capital::Billable + Sync),
    stripe: &StripeProvider,
) -> Result<String, CapitalError> {
    let receipt = account
        .charge_with(
            stripe,
            2_500,
            "BRL",
            "cus_from_authoritative_state",
            "pm_from_authoritative_state",
            "order_2026_0001-attempt_1",
        )
        .await?;
    Ok(receipt.charge_id().to_string())
}
}

This deliberately is not charge(amount): currency, provider customer, tokenized payment method and retry identity cannot be inferred safely. The Stripe adapter uses Payment Intents with immediate off-session confirmation and the provider idempotency header. Only succeeded and processing are accepted; an amount/currency mismatch or a flow requiring customer action fails closed. Credentials beginning with mock_ return the same deterministic receipt with the distinct non-success ChargeStatus::Mock for an exact retry. The mock is not a mandate, durable idempotency store or live sandbox test. Other adapters return UnsupportedOperation until reviewed individually.

4. Render and deliver the invoice only after final success

Enable capital-mail on the umbrella crate (or invoice-pdf on Capital plus capital-invoice on Mail). Build the invoice from authoritative order state, then bind it to the returned receipt:

#![allow(unused)]
fn main() {
use chrono::Utc;
use rullst::capital::{ChargeReceipt, Invoice, InvoiceItem};
use rullst::mail::PaidInvoiceDelivery;

async fn deliver_invoice(receipt: &ChargeReceipt) -> Result<(), Box<dyn std::error::Error>> {
    let invoice = Invoice {
        invoice_id: "INV-2026-0001".to_string(),
        customer_email: "[email protected]".to_string(),
        date: Utc::now(),
        items: vec![InvoiceItem {
            description: "Pro subscription".to_string(),
            amount: 25.00,
        }],
        total: 25.00,
        currency: "BRL".to_string(),
    };

    let paid = invoice.bind_succeeded_charge(receipt)?;
    // Real providers require a sender on a domain verified for the account.
    let delivery = PaidInvoiceDelivery::prepare(&paid)?.from("[email protected]")?;

    // In production, atomically claim this stable key in a durable outbox.
    let _delivery_key = delivery.delivery_key();
    delivery.send().await?;
    Ok(())
}
}

The binding rejects Processing, Mock, a mismatched recipient, amount or currency. The default PDF is paginated, bounded to sixteen MiB and supports WinAnsi text (including common Portuguese characters); pass a checked TTF/OTF to Capital for other scripts. Mail applies its mandatory pre-flight before the facade queues or sends the HTML message and attachment; from re-runs it for the verified sender, which every real transport requires: none invents one.

This helper does not subscribe to webhooks by itself. Reconcile the provider event, build the authoritative invoice and insert delivery_key under a unique database constraint in the same application workflow. Mail delivery remains at least once: a crash and retry can still require provider/application deduplication.

5. Report metered usage without confusing provider identities

Use the provider-specific static trait for new code. Stripe Meter Events need a customer ID and configured event name, not a subscription-item ID:

#![allow(unused)]
fn main() {
use rullst_capital::{
    CapitalError, MeteredBillingProvider as _, StripeMeterEvent, StripeProvider,
};

async fn report_ai_exercises(stripe: &StripeProvider) -> Result<(), CapitalError> {
    let event = StripeMeterEvent::new(
        "cus_from_authoritative_state",
        "ai_exercises",
        3,
        "usage:school-7:attempt-99",
    )?;
    let receipt = stripe.report_metered_usage(&event).await?;
    assert_eq!(receipt.quantity(), 3);
    Ok(())
}
}

Stripe receives the identifier but enforces it only within a rolling window, so keep a durable application record of submitted events. A custom adapter whose provider receives no event key reports UsageDeduplication::ApplicationOutboxRequired; durable application deduplication is then mandatory. The adapter caps and binds responses, while provider sandbox/live acceptance, retry, invoice reconciliation and entitlement updates remain release and application work. Empty or mock_* API keys produce a deterministic UsageStatus::Mock, never billable evidence.

6. Verify webhooks before business processing

Mount rullst_capital::verify_webhook on the exact provider callback route as shown in the Capital crate guide. Apply any CSRF exemption only to that exact signed route. Never update access or subscription state from an unverified request.

The default replay store protects one process. For multiple processes, rullst-capital/webhook-sql provides a bounded SQL ledger for SQLite, PostgreSQL, MySQL, and MariaDB. Its middleware form claims the signed payload before dispatch and therefore does not guarantee exactly-once processing after a crash. When a subscription mutation must be atomic, use the provider’s verified stable event ID with SqlWebhookReplayStore::check_and_record_event_key_with_transaction in the same database transaction as that mutation; do not also pre-claim that event through SQL middleware. See the payment guide for setup and operational boundaries.

7. Use a bounded subscription handle and grace period

#![allow(unused)]
fn main() {
use rullst_capital::{Billable as _, CapitalError, GracePeriod, StripeProvider};

async fn pause_with_local_policy(
    workspace: &impl rullst_capital::Billable,
    provider: &StripeProvider,
) -> Result<(), CapitalError> {
    let grace = GracePeriod::new(1_900_000_000, 1_900_604_800)?;
    let handle = workspace
        .subscription_with(provider)?
        .with_grace_period(grace);
    handle.pause().await
}
}

The grace value does not schedule the pause or grant access by itself. Persist it with authoritative subscription state, evaluate it against a trusted clock inside the entitlement check, and confirm the selected adapter’s live pause or cancel semantics.

The same statically dispatched handle validates coupon IDs and gives the historical relative-trial API its intended meaning:

#![allow(unused)]
fn main() {
use rullst_capital::{Billable as _, CapitalError, StripeProvider};

async fn grant_a_retention_offer(
    workspace: &impl rullst_capital::Billable,
    stripe: &StripeProvider,
    command_created_at: i64,
) -> Result<(), CapitalError> {
    let subscription = workspace.subscription_with(stripe)?;
    subscription.apply_coupon("RETENTION_25").await?;

    // Fifteen whole days. Persist command_created_at and reuse it on retries.
    subscription
        .extend_trial_days_at(15, command_created_at)
        .await
}
}

extend_trial(15) uses the current UTC time for interactive convenience. Workers should persist their command time and use extend_trial_days_at so a retry sends the identical expiration; set_trial_end is the explicit absolute timestamp operation. Stripe has the reviewed live coupon path and reviewed trial-update protocol fixtures; adapters without those contracts fail explicitly. Authorize the subscription owner before building the handle, serialize conflicting updates, and reconcile the signed provider webhook because trial changes can affect billing anchors and charges.

8. Enforce one shared workspace quota before creation

Enable quota-sql directly, or capital-quota-sql on the umbrella crate. The authenticated middleware must first establish the active TenantContext; do not build a billing subject from an arbitrary header or request field.

Billable::quota_request reads the limit from the subscription owner’s tier_limit implementation. A limit of Some(0) (a plan that grants none of the feature) returns QuotaError::LimitExceeded, the same denial as a used-up limit. Give every attempted creation a stable event key, normally the ID of the application command/request rather than a random value generated on every retry.

#![allow(unused)]
fn main() {
use rullst::{
    capital::{Billable as _, BillingSubject, QuotaError, SqlQuotaStore},
    security::TenantContext,
};

async fn create_project(
    workspace: &impl rullst::capital::Billable,
    tenant: &TenantContext,
    quotas: &SqlQuotaStore,
    project_id: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
    let subject = BillingSubject::from_tenant(tenant)?;
    let request = workspace.quota_request(
        subject,
        "projects",
        format!("create-project:{project_id}"),
        1,
    )?;

    let mut transaction = quotas.pool().begin().await?;
    let grant = match quotas
        .reserve_with_transaction(&mut transaction, &request)
        .await
    {
        Ok(grant) => grant,
        Err(QuotaError::LimitExceeded { .. }) => {
            transaction.rollback().await?;
            return Ok(false);
        }
        Err(error) => {
            transaction.rollback().await?;
            return Err(error.into());
        }
    };

    if grant.is_replay() {
        transaction.rollback().await?;
        return Ok(true);
    }

    let inserted = rullst::orm::sqlx::query(
        "INSERT INTO projects (id, workspace_id) VALUES (?, ?)",
    )
    .bind(project_id)
    .bind(tenant.tenant_id.as_str())
    .execute(&mut *transaction)
    .await;
    if let Err(error) = inserted {
        transaction.rollback().await?;
        return Err(error.into());
    }
    transaction.commit().await?;
    Ok(true)
}
}

The placeholder above is SQLite/MySQL syntax; use $1, $2 for a raw PostgreSQL insert, or use the ORM operation that participates in the same transaction. SqlQuotaStore uses a unique event claim plus a conditional counter update, so concurrent members cannot both pass the last available unit. An exact retry returns is_replay() without consuming again; reusing the same key with different units or a different limit fails closed.

For work that cannot share the SQL transaction, QuotaGate::execute still blocks the callback before an over-limit/replayed operation and releases the reservation after an ordinary callback error. A process crash between a standalone reservation and the external side effect is intentionally conservative and needs application reconciliation; the framework never risks exceeding the quota to guess whether that external effect happened.

9. Supply an optional local revenue snapshot

RevenueDashboardManager does not derive money or subscribers from event names. After durable reconciliation, the application may call update_metrics with its authoritative snapshot and record_event with a bounded inspection record. The standalone Studio can display that process-local source at /studio/capital when it is explicitly connected. It neither auto-discovers webhook routes nor auto-syncs the application database.

Provider-hosted checkout normally keeps card collection away from the application, but the final data boundary depends on the selected provider flow, application logs, analytics, and deployment.