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.