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.