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

Rullst Capital: billing boundaries

Vision preserved: capabilities removed from the usable-today contract are still evaluated item by item in the capability ledger, including whether each one is worth implementing and why.

rullst-capital provides provider-neutral billing abstractions, revenue metrics, verified webhook plumbing and two payment-provider adapters: Stripe (supported) and InfinitePay (experimental until validated against a live account). Provider capabilities are not uniform: consult the adapter API and its tests before depending on a particular checkout, refund or webhook operation. Other gateways are integrated in application code by implementing the provider traits; see Writing your own payment provider.

v13 removed the Paddle, Lemon Squeezy, Polar, Razorpay, Mercado Pago, Alipay, Coinbase Commerce, PicPay and Wise (payout) adapters and the NFS-e fiscal preparation module. NFS-e was never validated with a real municipality and may return as a separate product outside Rullst. See the v13 migration guide row “Capital providers and NFS-e removed”.

RevenueDashboardManager is a bounded process-local presentation source. Applications call update_metrics with values reconciled from their durable billing database and call record_event after a verified webhook path. Recording an event deliberately does not invent a plan price, fee, currency or subscriber count from its event name.

Payment providers

Initialize only the provider required by the application and treat credentials as deployment secrets. An empty or mock_* API credential selects the adapter’s deterministic offline behavior instead of a live request where that adapter documents support for it, so verify that production credentials are present. An empty webhook secret is a configuration error; a mock_* webhook secret selects mock verification, which the production webhook middleware rejects.

Every reviewed live method uses the same pooled outbound client with a five-second connect timeout, twenty-second whole-request timeout, disabled redirects and ambient proxy discovery, and one-MiB JSON limit. Returned checkout locations must be bounded absolute HTTPS URLs without credentials or fragments. CapitalError::Provider exposes redacted static provider/operation labels, failure kind, optional HTTP status and bounded numeric Retry-After. Its permanent/transient/rate-limited class is evidence for application policy, not permission to repeat a mutation. Only retry after proving that the exact operation forwards a persisted idempotency key and retaining reconciliation.

Immediate charges without raw payment data

Billable::charge_with accepts only a provider-tokenized customer and payment method. Amounts are integer minor units and every attempt needs an application-owned idempotency key. The reviewed live adapter is Stripe Payment Intents; other billing adapters fail with UnsupportedOperation instead of pretending to charge.

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

async fn collect_order<Account>(
    account: &Account,
    stripe: &StripeProvider,
) -> Result<(), CapitalError>
where
    Account: rullst_capital::Billable + Sync,
{
    let receipt = account
        .charge_with(
            stripe,
            4_990, // BRL 49.90 in cents
            "BRL",
            "cus_provider_owned",
            "pm_provider_tokenized",
            "order_42-attempt_1",
        )
        .await?;
    if receipt.is_succeeded() {
        // Reconcile durable order state; do not grant access from this alone.
    }
    Ok(())
}
}

The application must establish the customer’s authority over both provider IDs, retain the key with its order, reconcile signed webhooks and handle mandate/SCA rules. Empty/mock_* credentials produce a deterministic receipt with the distinct non-success ChargeStatus::Mock; it must never grant access or be booked as revenue. This API has no raw-card field and never guesses currency.

Provider-specific metered usage

Use the static-dispatch MeteredBillingProvider boundary for new code. StripeMeterEvent requires the authoritative cus_* customer, configured meter event name, positive quantity, timestamp and a unique visible identifier; the adapter sends the default stripe_customer_id/value payload and binds all of those fields in the accepted response.

The response is capped at one MiB and offline credentials return a deterministic UsageStatus::Mock. Stripe provides only rolling identifier deduplication. A custom adapter whose provider has no event-key field reports UsageDeduplication::ApplicationOutboxRequired; the application must then atomically claim event_key() in a durable outbox before sending. It must also reconcile invoices and derive quotas/entitlements from authoritative state. The old uniform BillingProvider::report_usage remains source-compatible for mocks but fails closed in live Stripe configurations instead of guessing required fields.

Coupons and relative trial extensions

CouponCode validates/redacts the provider coupon identifier, while a statically dispatched SubscriptionHandle applies it. Stripe uses the current expanded discounts[0][coupon] update and binds the response to the requested subscription/coupon. Adapters without reviewed subscription-discount contracts, including InfinitePay, return UnsupportedOperation instead of silently succeeding.

handle.extend_trial(15) resolves a bounded 15-day expiration from the current UTC clock. Retryable workers should persist the command creation time and call extend_trial_days_at(15, command_created_at); this emits the same absolute expiration on every attempt. set_trial_end is available for explicit reconciliation. Stripe has bounded protocol/response-binding tests for trial updates. Authorization, command serialization, billing-cycle policy, webhook reconciliation and live account acceptance remain host/release responsibilities. See the billing tutorial.

Shared subscriptions and strict resource quotas

One Team/Workspace model can own Billable and its tier policy while every authorized member uses the same bounded BillingSubject. Derive that subject from trusted TenantContext, never from a client-selected owner ID. Billable::quota_request derives the limit from tier_limit; QuotaGate reserves before invoking a create operation, suppresses an exact retry and compensates an ordinary callback error.

InMemoryQuotaStore provides deterministic process-local behavior. Enable quota-sql directly or capital-quota-sql on the umbrella crate for a durable unique-claim and conditional-counter implementation on SQLite, PostgreSQL, MySQL and MariaDB. Use reserve_with_transaction and perform the domain insert in that same transaction whenever quota and creation must commit atomically. The complete tutorial shows this path and its replay semantics.

The application still owns membership, authoritative tier/webhook state, migrations, reconciliation of abandoned standalone reservations and adapters for Turso or non-relational stores. Rullst does not intercept writes performed outside the explicit quota boundary.

Payment-bound invoice PDF and delivery

Enable the umbrella capital-mail feature (or Capital’s invoice-pdf and Mail’s capital-invoice features separately). Invoice::bind_succeeded_charge rejects non-final/mock receipts and any recipient, minor-unit total or currency mismatch. PaidInvoiceDelivery::prepare then creates escaped HTML, a bounded native PDF attachment and a message that has already passed Mail pre-flight. It can use the configured facade, a tenant route or an explicit static driver.

The returned stable delivery_key is for a unique durable outbox record owned by the application. Rullst does not infer a webhook, claim that key atomically, guarantee attachment support at every provider or promise exactly-once delivery. See the SaaS billing tutorial.

Webhook endpoints must use the Capital verification middleware. Its Axum and opt-in Actix adapters call the same canonical verifier. For supported protocols it performs cryptographic verification, timestamp freshness checks where the protocol signs a timestamp (Stripe), a two-megabyte body limit, and bounded replay protection before the application receives a normalized event. A webhook route may receive a narrowly scoped CSRF exemption only when this verifier remains mandatory on that exact route. See the payment guide for Actix setup.

The default store is process-local. The opt-in webhook-sql feature (capital-webhook-sql on the umbrella crate) persists bounded provider-scoped payload digests or stable event IDs across SQLite, PostgreSQL, MySQL, and MariaDB processes. SQL-backed middleware is a replay firewall that claims before dispatch, not exactly-once delivery. For an atomic relational business transition, use the verified provider event ID with check_and_record_event_key_with_transaction in the domain transaction and do not pre-claim it through SQL middleware. Cross-system effects still require an outbox, idempotent consumers, and reconciliation.

#![allow(unused)]
fn main() {
use axum::{routing::post, Router};
use rullst_capital::verify_webhook;

async fn billing_webhook() {
    // Read the verified event inserted by the middleware in real handlers.
}

let router: Router = Router::new()
    .route("/webhooks/billing", post(billing_webhook))
    .layer(axum::middleware::from_fn(verify_webhook));
}

Operational checklist

  • Derive tenant identity from an authenticated context, not an arbitrary client header.
  • Keep webhook secrets non-empty, rotate them, and retain replay state according to the provider contract.
  • Reconcile provider events idempotently in the application database.
  • Treat Studio revenue panels as observability views, not an accounting ledger.
  • Verify every adapter capability in a sandbox before enabling live traffic.