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
OneOffshape 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 StripeProvider;
use Arc;
use PaymentProviderRegistry;
// Production: read from env.
let stripe = from_env
.expect;
// Tests / explicit config:
let stripe = new;
bind;
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_capture → succeeded. |
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_capture → canceled. |
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 ;
let provider = get.unwrap;
let payment = provider.as_payment
.expect;
let result = payment.charge.await?;
match result
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:
RequiresClientAction
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:
RedirectRequired
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.await;
match voided
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 CreateCustomerRequest;
let customer = provider.create_customer.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 ;
let sub = provider.subscribe.await?;
assert!;
println!;
for item in &sub.items
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.await?;
// sub.cancel_at_period_end == true
// sub.status == Active
// Immediate cancel - Stripe DELETE /v1/subscriptions/{id}:
let sub = provider.cancel.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.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 stripe_event_to_neutral;
use NeutralEventKind;
assert_eq!;
assert_eq!;
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) anddata.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 (forpayment_intent.succeededonly)data.object.createdaspaid_at. - Invoice events - pull
data.object.id, the customer pointer,data.object.subscription(recurring charges only),amount_paid(falling back toamount_due),tax,currency, anddata.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 ;
use 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)]:
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.
