Manual contentsPaymentsBrowse 103 chapters
Manual 17 min read

Payments - Stripe Adapter

suprnova-payments-stripe is the reference adapter for Suprnova's provider-neutral payments surface. It implements all five payment traits (Checkout, Payment, Subscription, CustomerStore, WebhookHandler) against the Stripe API via async-stripe 1.0.0-rc.5. Reach for this chapter when you need to know exactly which Stripe endpoint a method calls, how the webhook signature format is verified, how PaymentIntents flow through ChargeResult, or which event types map onto the neutral event enum.

For the trait shapes themselves, env-var setup, and the bootstrap pattern, read Payments first. This chapter is the Stripe-specific deep dive.

Gateway, not Merchant of Record

Stripe is by default a payment gateway: you receive funds directly into your own bank account, and you are responsible for tax collection and remittance, invoicing, dunning, and chargeback handling. Contrast with Paddle (Payments - Paddle), where Paddle is the Merchant of Record - they collect the funds, file the tax, and pay you out net of fees.

The practical consequence for this chapter: StripeProvider implements Payment (you can authorise, capture, refund, and void a card on the server). PaddleProvider does not. The trait split exists because the two flows are genuinely different - not because we ran out of time.

Stripe Managed Payments (opt-in Merchant of Record)

Stripe's Managed Payments program moves Stripe into the Merchant of Record seat for eligible transactions - Stripe becomes the legal seller, calculates, collects, files, and remits sales tax/VAT/GST, and owns disputes. The program has hard integration constraints:

  • Hosted Checkout only. Sessions must run on Stripe's hosted page. Elements/custom flows are excluded - which is why the adapter's hosted one-off path (below) is the only OneOff shape that composes with it.
  • Predefined Prices with eligible tax codes. Line items must reference price_… objects whose products carry a tax code labeled Managed-Payments-eligible in the Stripe dashboard. Ad-hoc amounts are rejected.
  • Account enrollment. The Stripe account must be onboarded to the program; sessions carrying the flag on a non-enrolled account fail.

Enable it per provider with .with_managed_payments(true) or STRIPE_MANAGED_PAYMENTS=true - the adapter then sends managed_payments[enabled]=true when creating hosted one-off sessions. When off (the default) the field is omitted entirely.

Why Suprnova diverges

Laravel ships Cashier as a first-party Stripe integration in the core docs. It is convenient, but Stripe-only - and adding a second provider means either forking Cashier or building a parallel surface.

Suprnova keeps Stripe at arm's length. The Stripe adapter is one crate that registers itself against the same five traits any other provider implements. Your domain code never names StripeProvider; it calls provider.charge(...) against Arc<dyn PaymentProvider> resolved from the registry, and the Stripe behaviour is one swap-out from the Paddle behaviour. When you later add Mollie, or wire up a regional gateway that doesn't exist yet, you implement the same five traits and the rest of your app does not move.

Construction

use suprnova_payments_stripe::StripeProvider;
use std::sync::Arc;
use suprnova::payments::PaymentProviderRegistry;

// Production: read from env.
let stripe = StripeProvider::from_env()
    .expect("STRIPE_SECRET_KEY / PUBLISHABLE_KEY / WEBHOOK_SIGNING_SECRET");

// Tests / explicit config:
let stripe = StripeProvider::new(
    "sk_test_...",
    "pk_test_...",
    "whsec_...",
);

PaymentProviderRegistry::bind("stripe", Arc::new(stripe));

StripeProvider is Clone (cheap - the underlying stripe::Client is Arc-backed) and holds these values:

Field Source Use
secret_key sk_live_… / sk_test_… HTTP Authorization: Bearer … on every API call
publishable_key pk_live_… / pk_test_… Surfaced inside SessionPayload::StripeElements so the frontend can mount Stripe.js without a separate config lookup
webhook_signing_secret whsec_… HMAC-SHA256 verification of the Stripe-Signature header
managed_payments STRIPE_MANAGED_PAYMENTS (true/1) or .with_managed_payments(bool) Sends managed_payments[enabled]=true on hosted one-off session creation (see Managed Payments)

from_env() returns Result<Self, String> - the error message names the missing required variable (STRIPE_MANAGED_PAYMENTS is optional; absent means off). There is no panic path at boot.

Checkout sessions

Checkout::start_session picks its Stripe surface from the request:

Request shape Stripe object SessionPayload variant
OneOff + non-empty price_refs Hosted Checkout Session, mode=payment StripeCheckoutRedirect { url, provider_session_id: "cs_…" }
OneOff + empty price_refs + amount_hint PaymentIntent StripeElements { client_secret, publishable_key, provider_session_id: "pi_…" }
Subscription + price_refs Hosted Checkout Session, mode=subscription StripeCheckoutRedirect

The hosted one-off path sends allow_promotion_codes=true (customers can enter promotion codes on Stripe's page - pair with the Promotions trait below) and, when the provider is configured for it, the Managed Payments flag. Put Stripe's {CHECKOUT_SESSION_ID} template literal in your success_return_url - Stripe substitutes the real cs_… id on redirect, and your return page feeds it to session_status.

Checkout::session_status maps GET /v1/checkout/sessions/{id} onto the neutral CheckoutSessionState:

Stripe status / payment_status CheckoutSessionState
open Open
expired Expired
complete + paid or no_payment_required Complete { paid: true, payment_ref, amount_total }
complete + unpaid (delayed settlement) Complete { paid: false, … }

payment_ref carries the session's PaymentIntent id (pi_…) so return pages and sweeps can correlate the session with Payment operations and the payments_transactions mirror. amount_total is the settled total with provider-side discounts and Managed-Payments tax already folded in.

Promotion codes

StripeProvider implements the optional Promotions trait (provider.as_promotions() returns Some). create_promotion_code maps to POST /v1/promotion_codes: it mints a code off a pre-created coupon (coupon_ref), restricted to one customer (customer_ref), with an optional expiry and redemption cap. Restrictions are enforced by Stripe at redemption - a code minted for customer A is rejected when customer B types it, expired codes are rejected, and max_redemptions: Some(1) makes the code single-use. See the Promotions section of Payments for the campaign pattern.

The PaymentIntent lifecycle

Stripe represents a single charge attempt as a PaymentIntent. The intent moves through statuses; the Suprnova Payment trait drives the transitions. Every StripeProvider Payment method maps to one /v1/payment_intents/... endpoint:

Payment method Stripe endpoint What it does
charge POST /v1/payment_intents Create + confirm in one call against a saved payment method. capture_method: "manual" so the intent moves to requires_capture, not succeeded.
capture POST /v1/payment_intents/{id}/capture Settle a previously-authorised intent. Status requires_capturesucceeded.
refund POST /v1/refunds Fully or partially reverse a captured intent.
void POST /v1/payment_intents/{id}/cancel Release an authorisation before capture. Status requires_capturecanceled.
status GET /v1/payment_intents/{id} Retrieve the current status (returns PaymentStatus).

Authorise first, capture later

StripeProvider::charge does not immediately settle the funds. It sends capture_method=manual + confirm=true, which authorises the card and reserves the funds, then waits for an explicit capture call. This is the canonical two-step flow:

use suprnova::payments::{
    PaymentProviderRegistry, ChargeRequest, ChargeResult,
    Money, Currency, PaymentStatus,
};

let provider = PaymentProviderRegistry::get("stripe").unwrap();
let payment = provider.as_payment()
    .expect("Stripe implements Payment");

let result = payment.charge(ChargeRequest {
    customer_ref: "cus_NffrFeUfNV2Hib".into(),
    payment_method_ref: "pm_card_visa".into(),
    amount: Money::from_minor_units(2999, Currency::USD),
    description: Some("Pro plan, manual capture".into()),
    idempotency_key: Some("order-12345".into()),  // see "Idempotency" below
    metadata: None,
}).await?;

match result {
    ChargeResult::Completed { provider_transaction_id, status, .. }
        if status == PaymentStatus::Pending => {
        // Authorised - settle when the order ships.
        let settled = payment.capture(&provider_transaction_id).await?;
        assert!(matches!(
            settled,
            ChargeResult::Completed { status: PaymentStatus::Succeeded, .. }
        ));
    }
    ChargeResult::RequiresClientAction { client_secret, .. } => {
        // 3DS step-up needed - see "3DS and SCA" below.
    }
    other => panic!("unexpected charge result: {other:?}"),
}

If you want immediate capture - the common e-commerce one-shot - use Checkout::start_session with SessionMode::OneOff instead. That path creates a PaymentIntent with automatic_payment_methods enabled and hands the client secret to the frontend so the customer's browser confirms the intent in-place. Payment::charge is for server-driven flows where you already hold the customer's saved payment method and want explicit authorise-then-capture control (typical for marketplaces, delayed-fulfilment SaaS, or split-shipment commerce).

Status mapping

Stripe statuses fold into Suprnova's PaymentStatus enum:

PaymentIntentStatus PaymentStatus
Succeeded Succeeded
Processing Pending
RequiresCapture Pending (authorised, awaiting capture)
RequiresAction Pending (returned as RequiresClientAction from charge)
RequiresConfirmation Pending
RequiresPaymentMethod Pending
Canceled Canceled
new Stripe status (enum is #[non_exhaustive]) Failed

The non_exhaustive fallback is intentional. Stripe occasionally adds states (e.g. when introducing new payment method types). Surfacing them as Failed is the conservative default - your app treats the charge as not-yet-confirmed until you upgrade the adapter.

3DS and SCA

European Strong Customer Authentication, India's RBI rules, and several other regulators require the cardholder to authenticate the charge in a separate browser context. Stripe surfaces this as requires_action with a next_action block.

StripeProvider::charge translates this into one of two ChargeResult variants:

ChargeResult::RequiresClientAction {
    provider_transaction_id,   // pi_xxx - keep this around
    action_kind: "stripe_3ds", // Stripe-specific tag
    client_secret,             // hand to Stripe.js
    publishable_key,           // hand to Stripe.js
}

When the intent's next_action contains a redirect URL (some authentication flows are URL-redirect rather than in-place modal), the result is rewritten as:

ChargeResult::RedirectRequired {
    provider_transaction_id,
    url,                       // redirect the browser here
    return_to: None,
}

Your controller hands the RequiresClientAction payload to the Inertia page; the frontend calls stripe.confirmCardPayment(client_secret, ...) and the customer completes 3DS. When confirmation succeeds, Stripe fires payment_intent.succeeded and the webhook route writes the mirror row. See Payments - Frontend Integration for the Svelte / React / Vue snippets.

Void vs refund

void releases an authorisation before capture; refund reverses a captured payment. Calling void on a captured intent will fail - Stripe rejects with a message containing "already succeeded" or "You cannot cancel", and the adapter surfaces that as PaymentError::Validation so your handler can distinguish a recoverable user error (use refund instead) from a true provider outage. Any other failure is PaymentError::Provider.

let voided = payment.void("pi_3PNzj...").await;
match voided {
    Ok(()) => { /* authorisation released */ }
    Err(suprnova::payments::PaymentError::Validation(msg)) => {
        // Already captured - call refund instead.
        let refund = payment.refund(RefundRequest {
            provider_transaction_id: "pi_3PNzj...".into(),
            amount: None,           // full refund
            reason: Some("requested_by_customer".into()),
            idempotency_key: None,  // refund() does not forward this - see "Idempotency"
        }).await?;
    }
    Err(e) => return Err(e.into()),
}

Customers

StripeProvider implements CustomerStore against /v1/customers. The adapter maps a returned Customer into the neutral CustomerRef, preserving the email and your application's user_id:

use suprnova::payments::CreateCustomerRequest;

let customer = provider.create_customer(CreateCustomerRequest {
    user_id: "user-42".into(),       // your app's user id
    email: "alice@example.com".into(),
    name: Some("Alice Example".into()),
    metadata: None,
}).await?;

// customer.provider_customer_id == "cus_NffrFeUfNV2Hib"
// Persist this alongside your User row so subsequent
// charges, subscriptions, and webhooks resolve back.

update_customer, get_customer, and delete_customer hit POST /v1/customers/{id}, GET /v1/customers/{id}, and DELETE /v1/customers/{id} respectively. Stripe's delete returns a DeletedCustomer envelope which the adapter discards - only the success/failure of the call is propagated.

Subscriptions

StripeProvider::subscribe posts to /v1/subscriptions with the customer ref, an items[] array, and an optional trial_period_days:

use suprnova::payments::{SubscribeRequest, SubscriptionStatus};

let sub = provider.subscribe(SubscribeRequest {
    customer_ref: "cus_NffrFeUfNV2Hib".into(),
    price_refs: vec!["price_pro_monthly".into()],
    trial_days: Some(14),
    idempotency_key: None,
    metadata: None,
}).await?;

assert!(matches!(
    sub.status,
    SubscriptionStatus::Trialing | SubscriptionStatus::Active
));

println!("Period ends at {}", sub.current_period_end);
for item in &sub.items {
    println!(
        "  {} × {} @ {:?}",
        item.quantity, item.provider_price_id, item.unit_amount,
    );
}

Period boundaries

Stripe moved the current_period_start / current_period_end timestamps from the parent Subscription onto each SubscriptionItem in API version 2023-08-16. Multi-item subscriptions can in theory have divergent item periods, but in practice every item on a single subscription shares the parent's billing cycle. The adapter takes the first item's period as the parent period in the returned SubscriptionResult. If you genuinely need per-item periods, read them from sub.items[n] - they are preserved on the snapshot.

Cancel at period end vs immediately

// Soft cancel - keep access until current_period_end:
let sub = provider.cancel("sub_1234", /* at_period_end */ true).await?;
// sub.cancel_at_period_end == true
// sub.status == Active

// Immediate cancel - Stripe DELETE /v1/subscriptions/{id}:
let sub = provider.cancel("sub_1234", /* at_period_end */ false).await?;
// sub.status == Canceled

The two paths hit different Stripe endpoints. Soft cancel is POST /v1/subscriptions/{id} with cancel_at_period_end=true - the subscription stays active until the end of the billing period, then Stripe finalises it. Immediate cancel is DELETE /v1/subscriptions/{id} with prorate=false and invoice_now=false.

update() is intentionally limited

UpdateSubscriptionRequest has two fields the adapter acts on: cancel_at_period_end and new_price_refs. The first is supported; the second returns PaymentError::NotSupported:

provider.update(UpdateSubscriptionRequest {
    provider_subscription_id: "sub_1234".into(),
    new_price_refs: Some(vec!["price_team_yearly".into()]),
    cancel_at_period_end: None,
    idempotency_key: None,
}).await
// → Err(PaymentError::NotSupported(
//      "Stripe price-set replacement on existing subscription not in v1. \
//       Cancel the subscription and create a new one with the new price set."
//   ))

This is one of the few places NotSupported is the honest answer rather than a deferral. Stripe price-set replacement requires deleting and re-creating subscription items - the shape varies by provider (proration, billing-cycle anchoring, retained-trial behaviour) and collapsing it into a single neutral API would hide more than it helped. The recommended path is to cancel the existing subscription and subscribe again with the new price set, applying your own proration policy if you need one.

Webhooks

Stripe sends webhooks signed with HMAC-SHA256 in the format:

Stripe-Signature: t=1717000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

StripeProvider::verify parses the header, recomputes HMAC-SHA256 over "{timestamp}.{raw_body}" using the webhook signing secret, and does a constant-time comparison against every v1= value in the header. Multiple v1= values exist during signing-secret rotation - Stripe overlaps the old and new secrets for a window so you can re-sign and deploy without a flag-day cutover.

Stripe-Signature: t=1717000000,v1=<old_sig>,v1=<new_sig>

The adapter accepts the request if any v1= value matches. A header missing t= or with no v1= values is rejected as PaymentError::WebhookSignature. Non-ASCII bytes anywhere in the header are also rejected - Stripe never sends them, and treating them as invalid is safer than substituting a replacement character.

You never call verify directly. The framework's webhook_routes(db.clone()) registers POST /webhooks/payments/{provider} and invokes the adapter's verify + parse_event + payload extractors for every request that lands there. See Idempotency for the retry-aware audit behaviour - including the rule that previously-failed events re-attempt hydration when the provider retries.

Event → neutral mapping

Stripe event types map onto Suprnova's NeutralEventKind via the stripe_event_to_neutral function. The mapping table:

Stripe event type NeutralEventKind
payment_intent.succeeded PaymentSucceeded
payment_intent.payment_failed PaymentFailed
charge.refunded PaymentRefunded
charge.dispute.created PaymentDisputed
customer.subscription.created SubscriptionCreated
customer.subscription.updated SubscriptionUpdated
customer.subscription.deleted SubscriptionCanceled
customer.subscription.paused SubscriptionUpdated
customer.subscription.resumed SubscriptionUpdated
customer.subscription.trial_will_end SubscriptionUpdated
invoice.payment_succeeded / invoice.paid InvoicePaid
invoice.payment_failed InvoiceFailed
customer.created CustomerCreated
customer.updated CustomerUpdated
anything else None

Events that map to None (Radar fraud signals, payouts, balance transfers, dispute lifecycle events past created) are still persisted to the payments_webhook_events audit table - they just do not drive the mirror tables. If you need them, read directly from event.raw_payload in a custom handler.

The mapping is also re-exported at the crate root so you can use it outside the webhook route:

use suprnova_payments_stripe::stripe_event_to_neutral;
use suprnova::payments::NeutralEventKind;

assert_eq!(
    stripe_event_to_neutral("payment_intent.succeeded"),
    Some(NeutralEventKind::PaymentSucceeded),
);
assert_eq!(
    stripe_event_to_neutral("radar.early_fraud_warning.created"),
    None,
);

Payload extraction

After verify and parse_event succeed, the framework calls extract_payload_ids, extract_payment_snapshot, and extract_customer_snapshot to pull the fields that drive the mirror tables (see Eloquent for the underlying read-from-your-own-DB pattern). Stripe is structurally consistent: every webhook puts the relevant entity at data.object, with id as its primary key.

The extractors handle four event families:

  • Subscription events - pull data.object.id (the subscription id) and data.object.customer.
  • Customer events - pull data.object.id (the customer id).
  • PaymentIntent / Charge events - pull data.object.id, data.object.amount, data.object.currency, data.object.customer, and (for payment_intent.succeeded only) data.object.created as paid_at.
  • Invoice events - pull data.object.id, the customer pointer, data.object.subscription (recurring charges only), amount_paid (falling back to amount_due), tax, currency, and data.object.status_transitions.paid_at.

Anything else returns None from the snapshot extractors; the audit row still lands.

Mirror tables

Six tables back the payments surface in your application's database. Apply the framework migration alongside your own:

use sea_orm_migration::{MigrationTrait, MigratorTrait};
use suprnova::payments::migrations::CreatePaymentsTables;

pub struct Migrator;

impl MigratorTrait for Migrator {
    fn migrations() -> Vec<Box<dyn MigrationTrait>> {
        vec![
            // ... your migrations ...
            Box::new(CreatePaymentsTables),
        ]
    }
}

The tables created are payments_customers, payments_payment_methods, payments_subscriptions, payments_subscription_items, payments_transactions, and payments_webhook_events. The webhook route hydrates them inside a single DB transaction per event - partial state is never observable, and the audit row carries process_error across retries so failures stay visible to operators.

Idempotency

Outbound idempotency on Stripe API calls and inbound idempotency on webhook deliveries are two separate stories. Read them as such.

Outbound: per-method coverage

Stripe supports request idempotency via the Idempotency-Key HTTP request header - the same key with the same body returns the same response object for a 24-hour replay window; a mismatched body returns an error. The Suprnova Stripe adapter does not uniformly thread the DTO's idempotency_key field onto that header today. The actual behaviour as of this writing:

Method DTO field What the adapter does
Payment::charge ChargeRequest::idempotency_key Forwarded into the POST body as idempotency_key=... (not the HTTP header). Stripe's API does not read body-form idempotency keys, so this is best treated as not effective until the adapter migrates to the request-header path.
Payment::refund RefundRequest::idempotency_key Silently discarded - the field is not forwarded.
Checkout::start_session StartSessionRequest::idempotency_key Silently discarded.
Subscription::subscribe / update *Request::idempotency_key Silently discarded.

If you rely on at-most-once semantics for charge/refund retries against Stripe today, gate the retry at your own call site (a deterministic domain key persisted in your DB, with a unique index preventing the second insert) until the adapter wires the header through. The DTO fields are accepted on the API but not currently honoured all the way to the wire - set them to None in tests and production code so the gap is explicit, and don't assume Stripe is deduplicating your retries.

This is a known gap in the v1 adapter and a candidate fix for the next release; the surface shape stays the same once the wiring lands.

Inbound: webhook deduplication

Webhook idempotency is handled by the framework on the ingress side and is fully wired. Every event lands in payments_webhook_events with a UNIQUE index on (provider, provider_event_id). Duplicate deliveries of an event that was already processed return 200 to Stripe immediately without re-running hydration; duplicates of a previously failed event re-attempt hydration so the provider's retry is your recovery mechanism. See Idempotency for the full audit + retry contract.

Testing

The adapter is hyper-backed and rustls-fronted. Tests that construct a StripeProvider need a registered crypto provider; we install ring exactly once in #[cfg(test)]:

#[cfg(test)]
mod tests {
    use suprnova_payments_stripe::StripeProvider;
    use std::sync::OnceLock;

    fn install_crypto_provider() {
        static ONCE: OnceLock<()> = OnceLock::new();
        ONCE.get_or_init(|| {
            let _ = rustls::crypto::ring::default_provider().install_default();
        });
    }

    fn provider() -> StripeProvider {
        install_crypto_provider();
        StripeProvider::new("sk_test_dummy", "pk_test_dummy", "whsec_dummy")
    }

    #[test]
    fn parses_subscription_webhook_ids() {
        let p = provider();
        let event = /* construct WebhookEvent with raw_payload */;
        let ids = p.extract_payload_ids(&event);
        assert_eq!(ids.subscription_id.as_deref(), Some("sub_abc"));
    }
}

For integration tests that hit the live Stripe sandbox, set STRIPE_SECRET_KEY and friends in your test env. For unit tests of your own controllers, prefer MockPaymentProvider from the framework - it implements all five traits with predictable returns and zero network.

Next

  • Payments - the trait surface, the registry, the bootstrap pattern, and the flow-tagged SessionPayload.
  • Payments - Paddle - the Merchant-of-Record counterpart; same five traits, different responsibility split.
  • Payments - Provider Guide - how to write an adapter for a gateway Suprnova doesn't ship.
  • Payments - Frontend Integration - Svelte / React / Vue dispatch on SessionPayload.flow, including the Stripe.js confirm-card-payment loop.
  • Idempotency - the audit + retry contract that makes webhook handling safe under at-least-once delivery.
  • Eloquent - query the mirror tables alongside your own models; everything is just a SeaORM entity.