Suprnova's payments surface is provider-neutral. Register an adapter at boot and call its supported operations through the common payment traits. Stripe and Paddle hydrate customer-linked mirror tables from webhooks. The NOWPayments invoice adapter records verified notifications in the audit log; applications reconcile their own orders using authenticated payment lookup.
Providers expose the same trait contract, but their capabilities differ. Unsupported operations return PaymentError::NotSupported. Server-side capture and promotions are optional capabilities, queried through PaymentProvider::as_payment() and PaymentProvider::as_promotions().
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 = "v3.0.0" }
suprnova-payments-stripe = { git = "https://github.com/eas4ai/suprnova.git", tag = "v3.0.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.
Every payments type has one path, suprnova::payments::<Type>, for example suprnova::payments::StartSessionRequest. The modules that hold the request and result types are reached as suprnova::payments::dto::<module>; a path such as suprnova::payments::session::StartSessionRequest does not compile.
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 = "v3.0.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 = "v3.0.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.
NOWPayments
The suprnova-payments-nowpayments adapter supports hosted one-off invoices and
verified IPNs. It uses explicit unsupported-operation errors for customer and
subscription operations. Customerless invoice events are audited without
fabricating customer-linked transaction mirrors. See the
NOWPayments guide for configuration and reconciliation.
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, Stripe and Paddle) 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> |
Forwarded by Stripe; rejected by Paddle when present |
metadata |
Option<Value> |
Correlation data attached to the provider checkout and its payment or subscription |
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
Change the prices of a subscription with update and new_price_refs. The list is the set of prices the subscription has after the call. An item whose price is in the list keeps its id and quantity. An item whose price is not in the list is removed. A price with no item is added with a quantity of 1, except in a swap: when the change removes exactly one item and adds exactly one price, the new price takes the quantity of the removed item. The adapter computes the change from a read of the subscription, so it is not atomic. An empty list, or a list that names a price twice, is PaymentError::Validation on every adapter and on the mock.
use ;
let sub = provider.update.await?;
proration says how the change is billed and is read only when new_price_refs is Some. None is ProrateAtRenewal.
Proration |
Meaning | Stripe proration_behavior |
Paddle proration_billing_mode |
|---|---|---|---|
ProrateNow |
Bill the prorated difference at once. | always_invoice |
prorated_immediately |
ProrateAtRenewal |
Put the prorated difference on the next invoice. | create_prorations |
prorated_next_billing_period |
DoNotProrate |
Charge nothing for the change. | none |
do_not_bill |
Proration is #[non_exhaustive], so a match on it needs a wildcard arm. On Paddle, a price change together with cancel_at_period_end, or with an idempotency_key, is NotSupported. See Payments - Stripe Adapter and Payments - Paddle Adapter.
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 database error during hydration stays a PaymentError::Database { context, source }. source is the suprnova::DbErr, and context names the step that failed (begin tx, lock audit row, commit) or is None. The route answers 503 for every database error, and process_error records the text of the error. The type lets your own code ask what kind of failure it was: a lost connection is gone at the next attempt, a violated constraint is not. Debug prints the text of the database error and not its Debug, because the driver's detail for a violated constraint names the values of the key.
use Error;
use DbErr;
use PaymentError;
A statement of your own that returns a DbErr ends in ? inside a function that returns PaymentResult, because PaymentError implements From<DbErr>. PaymentError::database("commit", error) builds the variant with a context.
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 the enums don't name, 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
Checkout, charge, refund, and subscription mutation DTOs expose an optional
idempotency_key: Option<String>. Set one on supported Stripe calls:
provider.start_session.await?;
provider.subscribe.await?;
Stripe forwards supported request keys in the Idempotency-Key HTTP header.
Retry the same operation with the same key and parameters. Paddle does not
accept client-supplied idempotency keys; its checkout, its price change, and
its scheduling of a cancellation with update return NotSupported when a
key is present. Persist the transaction ID
when creation succeeds. After an uncertain Paddle create, reconcile provider
state before issuing another create. Correlation metadata does not deduplicate
requests.
Every call of the Stripe and Paddle adapters has a deadline of 30 seconds. A read that runs out of time returns PaymentError::Provider with the text <provider> <operation> timed out. A call that changes something returns PaymentError::Provider with the text <provider> <operation> timed out; outcome is unknown, reconcile before retrying. In that case the provider may have carried out the change: read the state at the provider before you send the call again. On Stripe you can also send it again with the same idempotency key. See Payments - Stripe Adapter and Payments - Paddle Adapter.
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
