š³ Payment Gateways & Financial Infrastructure Guide
Rullst Capital (rullst-capital) provides typed payment, subscription, payout,
and webhook adapters. Unsupported operations return typed errors, and mock
credentials select deterministic offline behavior.
The provider modules share common traits, but they do not all implement every operation. An adapterās presence is not a promise of geographic availability, tax treatment, settlement time, pricing, or regulatory suitability.
šļø Provider Landscape & Strategic Archetypes
graph TD
Capital[rullst-capital] --> Direct[Direct Merchant]
Capital --> MoR[Merchant of Record - MoR]
Capital --> Domestic[Domestic Payments]
Capital --> APAC[Asia-Pacific & China Cross-Border]
Capital --> Crypto[Web3 & Crypto]
Capital --> Payouts[Global Payouts]
Direct --> Stripe[Stripe]
Direct --> Razorpay[Razorpay India]
Direct --> MercadoPago[Mercado Pago]
Direct --> PicPay[PicPay]
MoR --> LemonSqueezy[Lemon Squeezy]
MoR --> Polar[Polar.sh]
MoR --> Paddle[Paddle]
Domestic --> InfinitePay[InfinitePay Brazil]
APAC --> Alipay[Alipay / Alipay+ China]
Crypto --> Coinbase[Coinbase Commerce]
Payouts --> Wise[Wise Transfers]
š Adapter inventory
| Adapter group | Included modules | Rullst contract |
|---|---|---|
| Direct payment APIs | Stripe, Mercado Pago, InfinitePay, PicPay, Razorpay | Implemented trait methods perform signed/credentialed requests; unsupported methods fail explicitly. |
| Merchant-of-record APIs | Lemon Squeezy, Polar, Paddle | Provider-specific checkout/subscription methods only; tax and merchant-of-record obligations remain governed by the provider contract. |
| Cross-border and wallets | Alipay | RSA2 operations that are not implemented fail closed; HMAC fixtures are not represented as RSA2. |
| Crypto commerce | Coinbase Commerce | Provider-specific charge and webhook flows; chain settlement is outside Rullstās trust boundary. |
| Payouts | Wise | Provider-specific payout operations; identity, compliance, currency, and availability checks remain external. |
Provider pricing and terms change. Check the providerās current official documentation and the concrete trait implementation before selecting an adapter.
Failures, timeouts, and retry ownership
Reviewed live methods use a single bounded egress contract: five-second connect and twenty-second whole-request timeouts, no redirects, no ambient proxy variables, and at most one MiB of JSON. Checkout responses additionally require an absolute credential-free HTTPS URL without a fragment. These controls do not prove that a provider account, product, price, or operation is accepted live.
CapitalError::Provider exposes only static provider/operation labels, a
ProviderFailureKind, optional HTTP status, bounded numeric Retry-After, and
one of three dispositions: permanent, transient, or rate-limited. It does not
retain a raw URL, credential, response body, or reqwest diagnostic. Log those
structured fields instead of formatting the original request.
Rullst deliberately does not retry mutations. A transient classification means only that a later attempt may succeed. Before retrying, the application must prove that the concrete operation forwards the same persisted idempotency key; otherwise reconcile provider state first. Backoff, jitter, attempt budgets, dead-letter handling, and operator alerts remain explicit application policy.
š Selection model
Choose a provider only after checking which trait methods the Rullst adapter implements, the currencies and countries enabled on the actual merchant account, the current provider contract, webhook replay/idempotency requirements, and the applicationās legal and tax responsibilities. Merchant-of-record status and tax handling are external contractual properties, not guarantees made by Rullst.
š» Rust Code Integration Examples
1. Initializing Your Preferred Gateway
In your main.rs:
use rullst_capital::{
init_provider, StripeProvider, LemonSqueezyProvider, InfinitePayProvider,
PolarProvider, PaddleProvider, MercadoPagoProvider, CoinbaseCommerceProvider,
PicPayProvider, AlipayProvider, RazorpayProvider,
};
#[rullst::runtime::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Select your active provider:
// Example A: InfinitePay for a configured Brazilian merchant account
init_provider(Box::new(InfinitePayProvider::new(
std::env::var("INFINITEPAY_API_KEY")?,
std::env::var("INFINITEPAY_WEBHOOK_SECRET")?,
)));
// Example B: Alipay for China & APAC Cross-Border E-Commerce
// init_provider(Box::new(AlipayProvider::new(
// std::env::var("ALIPAY_APP_ID")?,
// std::env::var("ALIPAY_PRIVATE_KEY")?,
// std::env::var("ALIPAY_PUBLIC_KEY")?,
// )));
// Example C: Stripe for Global SaaS
// init_provider(Box::new(StripeProvider::new(
// std::env::var("STRIPE_SECRET_KEY")?,
// std::env::var("STRIPE_WEBHOOK_SECRET")?,
// )));
// Example D: Polar.sh for Open-Source Devs
// init_provider(Box::new(PolarProvider::new(
// std::env::var("POLAR_ACCESS_TOKEN")?,
// std::env::var("POLAR_WEBHOOK_SECRET")?,
// )));
Ok(())
}
2. Generating Checkout Sessions
#![allow(unused)]
fn main() {
use rullst_capital::provider;
use axum::response::Redirect;
use rullst_capital::CapitalError;
pub async fn start_checkout(customer_email: String, plan_id: String) -> Result<Redirect, CapitalError> {
let p = provider().ok_or_else(|| CapitalError::ConfigurationError(
"No billing provider configured".to_string(),
))?;
let checkout_url = p.create_checkout_session(
&customer_email,
&plan_id,
"https://myapp.com/billing/callback",
).await?;
Ok(Redirect::to(&checkout_url))
}
}
3. Cryptographically Verified Webhook Endpoint
Rullst Capital provides Axum and Actix Web adapters for one canonical webhook
verifier. It bounds the original payload, verifies the selected provider before
dispatch, restores the exact signed bytes, and passes a strongly typed
WebhookEvent into the handler. The production entry points reject empty and
mock_* webhook configuration.
#![allow(unused)]
fn main() {
use axum::{Router, routing::post, Extension};
use rullst_capital::{verify_webhook, WebhookEvent, SubscriptionStatus};
async fn handle_billing_event(Extension(event): Extension<WebhookEvent>) {
match event.status {
SubscriptionStatus::Active => {
println!("š Subscription activated for: {}", event.customer_email);
// Grant premium access in database
}
SubscriptionStatus::Canceled => {
println!("ā ļø Subscription canceled for: {}", event.customer_email);
// Revoke access or downgrade plan
}
SubscriptionStatus::PastDue => {
println!("šØ Payment failed: {}", event.customer_email);
// Trigger automated dunning email
}
_ => {}
}
}
pub fn billing_routes() -> Router {
Router::new()
.route("/webhooks/capital", post(handle_billing_event))
.layer(axum::middleware::from_fn(verify_webhook))
}
}
Actix Web adapter
Enable rullst-capital with default-features = false, features = ["actix"],
or enable rullst/capital-actix through the umbrella crate, and add
actix-web as a direct application dependency. An explicit
provider-bound state avoids global provider configuration and makes the replay
boundary visible:
#![allow(unused)]
fn main() {
use actix_web::{App, HttpMessage, HttpRequest, HttpResponse, HttpServer, middleware, web};
use rullst_capital::{
InMemoryWebhookReplayStore, StripeProvider, WebhookEvent,
WebhookMiddlewareState, verify_webhook_actix_with_state,
};
use std::sync::Arc;
async fn handle_billing_event(request: HttpRequest) -> HttpResponse {
let Some(event) = request.extensions().get::<WebhookEvent>().cloned() else {
return HttpResponse::InternalServerError().finish();
};
// Apply an idempotent subscription transition using `event`.
HttpResponse::NoContent().finish()
}
async fn serve() -> std::io::Result<()> {
let provider = Arc::new(StripeProvider::new(
"sk_live_from_secret_store",
"whsec_from_secret_store",
));
let replay = Arc::new(InMemoryWebhookReplayStore::default());
let state = WebhookMiddlewareState::production_with_provider(provider, replay);
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(state.clone()))
.wrap(middleware::from_fn(verify_webhook_actix_with_state))
.route("/webhooks/capital", web::post().to(handle_billing_event))
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
}
The default in-memory replay store is atomic only inside one process. Enable
rullst-capital/webhook-sql (or umbrella rullst/capital-webhook-sql) to share
a bounded replay ledger across SQLite, PostgreSQL, MySQL, or MariaDB processes:
#![allow(unused)]
fn main() {
use rullst_capital::{
SqlWebhookReplayStore, StripeProvider, WebhookMiddlewareState,
};
use std::{sync::Arc, time::Duration};
async fn webhook_state(
database_url: String,
) -> Result<WebhookMiddlewareState, rullst_capital::CapitalError> {
let replay = Arc::new(
SqlWebhookReplayStore::connect(
database_url,
100_000,
Duration::from_secs(24 * 60 * 60),
)
.await?,
);
replay.prepare_schema().await?;
let provider = Arc::new(StripeProvider::new(
"sk_live_from_secret_store",
"whsec_from_secret_store",
));
Ok(WebhookMiddlewareState::production_with_provider(
provider, replay,
))
}
}
Run equivalent reviewed DDL through deployment migrations instead of relying on request-time setup. Capacity/TTL are immutable for an existing ledger; drift, corruption, storage failure, and a full unexpired ledger fail closed. Only provider-scoped SHA-256 claims are stored, not raw payloads or event IDs.
SQL-backed middleware claims the payload before handler dispatch. It prevents
cross-process replay but cannot make handler delivery exactly once: a crash can
still occur between admission and a business mutation. For an atomic
relational path, verify the exact provider payload through its low-level
contract, select the providerās stable event ID, and call
check_and_record_event_key_with_transaction in the same transaction as the
domain mutation. Do not also pre-claim that event through SQL middleware.
External calls, e-mail, and queues still need an outbox, idempotent consumers,
and reconciliation.
4. Provider-Specific Metered Usage
Use MeteredBillingProvider with StripeMeterEvent or
LemonSqueezyUsageRecord. The Stripe request carries customer, configured
event name, positive value, bounded timestamp and an identifier forwarded to
the provider and HTTP idempotency header. The Lemon Squeezy request carries the
numeric subscription-item relationship, positive quantity and an explicit
increment/set action matching the provider-side aggregation.
Do not retry Lemon submissions from memory alone: its reviewed request has no application event-key field. Claim the request key durably before sending and make reconciliation idempotent. Stripeās provider identifier also has only a rolling uniqueness window. Protocol fixtures verify request/response shape and bounds; they do not replace live provider-account testing.
5. Payment-Bound PDF Invoice Delivery
With the umbrella capital-mail feature, bind an authoritative invoice to the
final charge receipt and prepare a pipeline-validated HTML/PDF message through
rullst::mail::PaidInvoiceDelivery. Non-final/mock receipts and mismatched
recipient, minor-unit total or currency fail before delivery. Persist the
stable delivery key under a unique constraint before calling send; the
bridge is at-least-once and does not infer webhook reconciliation.
The complete runnable shape and its outbox boundary are shown in Tutorial 19.
6. International Payouts with Wise
#![allow(unused)]
fn main() {
use rullst_capital::{CapitalError, WiseProvider};
pub async fn disburse_affiliate_commission(
provider: &WiseProvider,
affiliate_email: &str,
amount_usd_cents: u64,
) -> Result<String, CapitalError> {
provider
.send_payout(affiliate_email, amount_usd_cents, "USD", "affiliate commission")
.await
}
}
š”ļø Security controls and boundaries
- Bounded verification: webhook handlers should bound the body before parsing and reject a missing or malformed signature. Reading and parsing still allocate according to the concrete HTTP stack and payload.
- Cryptographic verification: supported webhook adapters use HMAC or constant-time verification for the exact signed bytes. Each providerās timestamp/replay policy and deployed secret lifecycle still require review. The default replay store is process-local; multi-instance deployments need a durable shared idempotency boundary owned by the application.
- Typed parsing: supported provider responses map into Rust enums and structs without runtime reflection. A typed response does not establish authorization, idempotency, or correctness of the upstream service.