Suprnova's payments surface is provider-neutral. You pick an adapter crate - Stripe, Paddle, or one you write yourself - register it at boot, and your domain code calls the same four core traits (plus an optional fifth for server-side capture) regardless of which provider is behind it. Mirror tables in your database are kept in sync by webhooks, so your domain code reads from your own DB rather than hitting the provider API for every query.
No feature is gated to a single provider. Stripe's direct-capture model and Paddle's Merchant-of-Record model both fit into the same trait contract. The only surface that differs is Payment (server-side capture), which is optional - Paddle doesn't need it, so Paddle doesn't implement it. Providers advertise their capability by overriding PaymentProvider::as_payment() to return Some(&dyn Payment); callers query at runtime.
Why Suprnova diverges
Laravel ships Cashier as a first-party Stripe integration in the core docs. It's convenient, but Stripe-only - adding a second provider means forking Cashier or building a parallel surface. Suprnova treats payment providers the way it treats cache and storage drivers: one generic trait set, swappable adapters. Your domain code never names StripeProvider or PaddleProvider; it calls provider.subscribe(...) against Arc<dyn PaymentProvider> resolved from a registry, and the provider behind it is one bootstrap change away from being something else.
Quick start
Add the adapter crate. Until Suprnova ships its v0.1 release, the framework and its adapter crates are consumed by git rather than crates.io:
# Cargo.toml
[dependencies]
suprnova = { git = "https://github.com/eas4ai/suprnova.git", tag = "v1.2.0" }
suprnova-payments-stripe = { git = "https://github.com/eas4ai/suprnova.git", tag = "v1.2.0" }
Register the provider and the webhook router at boot. The webhook router is a regular Router you compose into your routes::register():
// src/bootstrap.rs
use Arc;
use PaymentProviderRegistry;
use StripeProvider;
pub async
// src/routes.rs
use Arc;
use webhook_routes;
use App;
use Router;
use DatabaseConnection;
/// `Application::routes(routes::register)` calls this once at boot.
/// We start from the payments webhook router, then layer the rest of
/// the app's routes on top with normal `.get(...)` / `.post(...)` calls.
webhook_routes(db) returns a Router containing just POST /webhooks/payments/{provider}. Because Router::get and Router::post each return a RouteBuilder that converts back to Router via .into(), chaining on top of the payments router is the most direct way to compose. If you already use the routes!{} macro for your normal routes, drop the webhook POST into the same block - webhook_routes is a convenience wrapper around one Router::new().post(...) call.
In your controller, look up the provider, create a customer, and open a checkout session:
// src/controllers/billing.rs
use Arc;
use *;
pub async
That SessionPayload goes into your Inertia page props. The frontend dispatches on payload.flow to render the right widget - see Payments - Frontend Integration.
Picking an adapter
Stripe
# Cargo.toml
suprnova-payments-stripe = { git = "https://github.com/eas4ai/suprnova.git", tag = "v1.2.0" }
Required env vars:
| Variable | Description |
|---|---|
STRIPE_SECRET_KEY |
Secret key (sk_live_… / sk_test_…) |
STRIPE_PUBLISHABLE_KEY |
Publishable key (pk_live_… / pk_test_…) |
STRIPE_WEBHOOK_SIGNING_SECRET |
Webhook endpoint signing secret (whsec_…) |
use StripeProvider;
use Arc;
use PaymentProviderRegistry;
// From env (recommended in production):
let stripe = from_env.expect;
// Or construct directly:
let stripe = new;
bind;
Stripe implements every trait including the optional Payment (server-side capture via PaymentIntents) and Promotions (promotion-code minting via /v1/promotion_codes). Both provider.as_payment() and provider.as_promotions() return Some.
Paddle
# Cargo.toml
suprnova-payments-paddle = { git = "https://github.com/eas4ai/suprnova.git", tag = "v1.2.0" }
Required env vars:
| Variable | Description |
|---|---|
PADDLE_API_KEY |
API key (pdl_live_apikey_… / pdl_sdbx_apikey_…) |
PADDLE_WEBHOOK_KEY |
Notification destination secret (pdl_ntfset_…) |
PADDLE_CLIENT_TOKEN |
Client-side token (live_… / test_…) |
PADDLE_ENVIRONMENT |
Optional, defaults to "sandbox" |
use ;
use Arc;
use PaymentProviderRegistry;
// From env:
let paddle = from_env.expect;
// Or construct directly:
let paddle = new.expect;
bind;
Paddle is a Merchant of Record - it manages tax, dunning, and the full subscription lifecycle. It does not expose server-side capture, so Payment is not implemented. Calling provider.as_payment() returns None. Subscriptions are created indirectly: call Checkout::start_session, complete the Paddle widget, and the SubscriptionCreated webhook arrives to confirm the subscription ID.
The trait split
PaymentProvider is an umbrella that bundles four universal traits - Checkout, Subscription, CustomerStore, WebhookHandler - every adapter implements. Two further traits are optional: Payment (server-side capture only makes sense for gateways like Stripe) and Promotions (promotion-code minting). Adapters opt in by overriding PaymentProvider::as_payment() / PaymentProvider::as_promotions().
Checkout - universal, opens the client widget
Every provider implements Checkout. Call start_session to get a flow-tagged SessionPayload that your frontend renders. session_status (default: NotSupported; overridden by providers whose sessions can be interrogated, e.g. Stripe) reports the authoritative provider-side state of a session you started earlier.
StartSessionRequest fields:
| Field | Type | Description |
|---|---|---|
mode |
SessionMode |
OneOff or Subscription |
customer_ref |
String |
Provider customer ID from CustomerStore::create_customer |
price_refs |
Vec<String> |
Provider price/product IDs |
success_return_url |
String |
Where to send the user after payment |
cancel_return_url |
String |
Where to send the user if they abandon |
amount_hint |
Option<Money> |
Override or hint for one-off amounts |
idempotency_key |
Option<String> |
For safe retries |
session_status is the server-side verification primitive for redirect
flows. When the customer lands back on your return page, do NOT trust the
query parameters their browser carried - pass the provider_session_id
you recorded at start_session time and branch on the result:
match provider.session_status.await?
The same call powers reconciliation sweeps: re-poll orders still open in your database and fulfil the ones whose sessions completed after the customer closed the tab.
Payment - optional, server-side capture
Only providers that expose server-side capture implement Payment. Stripe does; Paddle does not. To check at runtime:
let provider = get.unwrap;
if let Some = provider.as_payment
Full Payment interface:
ChargeResult is an enum tagged with kind - see the Money and ChargeResult section.
Promotions - optional, mint promotion codes
Providers with a promotion-code surface implement Promotions. The discount object itself (a percent- or amount-off coupon) is created ahead of time - typically once, in the provider's dashboard - and this trait mints codes off it, each restricted to one customer and one redemption window. That is the shape win-back and upsell campaigns need: every recipient gets a personal code, unusable by anyone else and dead after the window closes.
let provider = get.unwrap;
if let Some = provider.as_promotions
The MockPaymentProvider implements Promotions (codes mint as PROMO_MOCK_n) and records every request - assert on recorded_promotion_requests() in tests.
Subscription - subscribe, update, cancel, get
Cancel at period end (keeps access until billing cycle ends):
let sub = provider.cancel.await?;
// sub.cancel_at_period_end == true, sub.status == Active
// Cancel immediately:
let sub = provider.cancel.await?;
// sub.status == Canceled
Note: Paddle::subscribe returns PaymentError::NotSupported - Paddle creates subscriptions through checkout completion, not direct API calls. Use Checkout::start_session and wait for the SubscriptionCreated webhook.
CustomerStore - create, update, get, delete
CreateCustomerRequest takes user_id, email, name: Option<String>, and metadata: Option<Value>. CustomerRef comes back with provider_customer_id - store that alongside your user record to use in subsequent calls.
WebhookHandler - verify, parse, and extract
In practice you never call any of these directly - webhook_routes invokes them for every inbound webhook. They live on the trait so adapter crates can implement provider-specific signature verification, event parsing, and payload extraction in a testable way. The extract_* methods all have sensible defaults; the shipped Stripe and Paddle adapters override them with provider-shape-aware implementations (Stripe reaches into data.object.*, Paddle into data.*).
The flow-tagged Inertia payload
start_session returns a SessionPayload enum that serializes to JSON with a flow discriminator field. Your frontend switches on flow to render the right widget:
Serialized form of a StripeElements payload:
A MobileMoneyPrompt payload looks like this - there is no URL because the customer never leaves your page; the frontend renders message and starts polling:
Return whichever variant the provider produces from your controller as Inertia props. Frontend integration is described in Payments - Frontend Integration.
Mirror tables
Six tables are created by the framework migration. Pull in the public alias and include it in your app's migrator:
use ;
use CreatePaymentsTables;
;
The same module also exports a helper pub fn migrations() -> Vec<Box<dyn MigrationTrait>> if you'd rather call that and spread the result into your own list.
Table overview
| Table | Purpose |
|---|---|
payments_customers |
One row per (provider, user_id) pair |
payments_payment_methods |
Stored payment methods per customer |
payments_subscriptions |
Subscription lifecycle state |
payments_subscription_items |
Line items within a subscription |
payments_transactions |
One-off charges and subscription invoices |
payments_webhook_events |
Audit log and idempotency guard |
Every table has a provider_metadata JSON column. When the framework's neutral representation doesn't cover a provider-specific field, read it from there.
Transactions table
payments_transactions splits amounts into amount_total_minor and amount_tax_minor. Stripe reports amounts exclusive of tax - tax is zero on the transaction row, and any tax data lives in provider_metadata. Paddle reports amounts inclusive of tax and sets amount_tax_minor to the tax component. Both representations work; add amount_total_minor - amount_tax_minor for the net amount.
Webhook events table
payments_webhook_events has a UNIQUE(provider, provider_event_id) index. Every inbound webhook is checked against this before processing - duplicates return 200 OK without re-processing. This is load-bearing: Stripe, Paddle, and most providers retry failed webhooks aggressively.
Caveats
Domain code reads from the mirror tables, not directly from the provider API. Mutations (create subscription, cancel, etc.) go to the provider; the resulting webhook syncs the mirror tables back. This means there is a brief window between a mutation and the webhook arriving where your mirror tables lag behind. Design your UX to account for this (show "processing" states, rely on the provider's redirect URLs for immediate confirmation).
Webhook handling
Mount the webhook ingress route once at bootstrap - see the Quick start routes example for the composition pattern. webhook_routes(db) returns a Router carrying the single POST /webhooks/payments/{provider} handler that's built into the framework. You chain your own routes onto it (or call the route's underlying primitives directly inside your own routes!{} block).
The framework handler does this for each request:
- Looks up the named provider in
PaymentProviderRegistry. - Calls
WebhookHandler::verifyto check the signature. Returns 401 on failure. - Calls
WebhookHandler::parse_eventto build aWebhookEvent. Returns 400 on parse failure. - Checks
payments_webhook_eventsfor an existing row with the same(provider, provider_event_id). If found, returns 200 immediately - this is the idempotency guard. - Inserts the audit row.
WebhookEvent structure
NeutralEventKind covers the common path:
When neutral is None, the event is provider-specific. Read provider_event_type and raw_payload for the full data.
Mirror-table hydration
After the audit row is persisted, the framework dispatches the event to the relevant mirror table based on neutral. All mirror writes for one event happen inside a single DB transaction along with mark_processed - partial mirror state is never observable. Either everything commits together or everything rolls back.
NeutralEventKind |
Mirror effect |
|---|---|
SubscriptionCreated/Updated |
Calls Subscription::get(id) on the provider, upserts payments_subscriptions, syncs items. |
SubscriptionCanceled |
Same as above; also sets canceled_at and flips status to canceled on the existing row. |
PaymentSucceeded / Failed / Refunded / Disputed |
Upserts payments_transactions from the snapshot the provider produces from raw_payload. |
InvoicePaid / InvoiceFailed |
Upserts payments_transactions with provider_subscription_id linked. |
CustomerCreated / CustomerUpdated |
Updates the existing payments_customers row's email / provider_metadata from the provider's CustomerSnapshot. Never inserts. |
None (unmapped) |
Audit row only - no mirror change. |
The customer mirror is intentionally update-only on the webhook path. user_id is NOT NULL and only the app knows which user a provider customer belongs to (the link is created by your code right after CustomerStore::create_customer). Out-of-band customers - created in the Stripe dashboard, say - are logged but never synthesized into the mirror.
Failure recovery contract
The handler treats provider retries as the recovery mechanism:
- Hydration succeeds: transaction commits,
processed_atset,process_errorcleared. Response:200 ok. - Hydration fails: transaction rolls back (no partial mirror state), audit row keeps
processed_at = NULLandprocess_errorrecords the failure. Response:503 hydration-failed- the provider will retry with backoff. - Provider retries the failed event: idempotency check sees the existing audit row but
processed_at IS NULL, so hydration runs again. The retry replaces the staleprocess_errorwith the current attempt's outcome. - Provider retries a succeeded event: idempotency check sees
processed_at IS NOT NULL, returns200 duplicateimmediately. No re-hydration.
A subscription/customer event with a missing subscription_id / customer_id in the payload is treated as a Validation error (also 503 + process_error recorded). Silent success on a malformed payload would leave the mirror stale without operator visibility.
Items removed from a subscription on the provider side (e.g. user dropped a seat add-on) are removed from payments_subscription_items when the next subscription.updated webhook arrives. The provider's Subscription::get(id) response is the source of truth on every sync.
Payment methods beyond cards
PaymentMethod is the enum the framework uses for stored methods in payments_payment_methods and for any provider that exposes method metadata. It covers the obvious cases - cards, bank transfers, e-wallets - plus regional methods that are first-class in many markets:
The named operators and assets are the ones we've enumerated. The Custom { ... } variants on each cover regional operators and stablecoins we haven't pinned yet, so adding support for one doesn't force a framework release.
PhoneNumber and CountryCode are validated DTOs in suprnova::payments - they reject malformed input at construction time, which is where you want the failure rather than at the provider call.
Money
Amounts are represented as Money - an i64 minor-unit count plus a Currency. No f64 involved.
use ;
use Decimal;
use FromStr;
// From minor units (cents, pence, yen, etc.)
let price = from_minor_units; // $19.99
// From a decimal string
let price = from_decimal;
// Zero-decimal currencies - 1234 minor = 1234 JPY (no conversion)
let yen = from_minor_units;
// Arithmetic - panics on currency mismatch
let total = price + from_minor_units; // $20.99
// Negative values represent refunds or credits
let refund = from_minor_units; // -$5.00
// Read back
println!;
Add and Sub panic on currency mismatch and on i64 overflow. Use the panicking arithmetic for correctness - silent cross-currency addition is a bug, not a feature.
ChargeResult
Payment::charge returns a ChargeResult enum. Not every charge completes immediately - 3DS step-up and off-session cards can require a redirect or a client-side action:
Handle RequiresClientAction by returning the payload to your frontend. The frontend renders the 3DS challenge using client_secret + publishable_key. See Payments - Frontend Integration for the frontend dispatch code.
Idempotency keys
Every mutating DTO has an optional idempotency_key: Option<String>. Set one on retryable network calls:
provider.start_session.await?;
provider.subscribe.await?;
Stripe honors idempotency keys via the Idempotency-Key HTTP header. Paddle has an equivalent mechanism. If a request fails mid-flight and you retry with the same key, the provider returns the original response instead of creating a duplicate charge or subscription.
The discriminator pattern
Every adapter that claims to implement PaymentProvider must pass the same E2E flow:
create_customer → start_session → subscribe → get → cancel(at_period_end) → cancel(immediate) → assert as_payment invariant
The MockPaymentProvider included with the framework passes this:
use *;
async
MockPaymentProvider does not implement Payment - this exercises the same invariant as Paddle. StripeProvider and PaddleProvider both pass the same flow against the live API in integration tests.
Multi-provider apps
Register both adapters at boot and dispatch based on where each customer's record was created:
bind;
bind;
// Later, per request:
let provider_name = user.payment_provider.as_str; // "stripe" or "paddle"
let provider = get.expect;
let sub = provider.cancel.await?;
Common uses: route EU customers through Paddle (for MoR tax handling) and US customers through Stripe; A/B test checkout conversion between providers; use one provider for subscriptions and another for one-off charges.
Migration from Laravel Cashier
Cashier is Stripe-only by design. Suprnova ships multi-provider out of the box. Quick mapping:
| Laravel Cashier | Suprnova |
|---|---|
$user->newSubscription('default', 'price_pro')->create() |
provider.subscribe(SubscribeRequest { ... }).await |
$user->subscription('default')->cancel() |
provider.cancel(&sub_id, true).await |
Cashier::webhookHandler |
webhook_routes(db.clone()) |
$user->createAsStripeCustomer() |
provider.create_customer(CreateCustomerRequest { ... }).await |
$user->charge(1999, 'pm_...') |
payment.charge(ChargeRequest { ... }).await (if provider supports it) |
$invoice->download() |
Not built-in; read provider_metadata["invoice_pdf_url"] from the transactions mirror table |
Next
- Payments - Stripe Adapter - the gateway flow in detail: PaymentIntents, webhook signature format, event-type mapping
- Payments - Paddle Adapter - the MoR flow in detail: checkout-driven subscription creation, tax handling, notification verification
- Payments - Frontend Integration - Svelte 5, React 19, and Vue 3.5 dispatch-on-flow examples
- Writing a Payment Provider Adapter - build your own adapter crate end to end
- Database - the SeaORM layer the mirror tables sit on
