Payment Providers

The Payment capability wraps any hosted-checkout provider (Stripe, Lemon Squeezy, Paddle, ...) behind a deliberately small trait so a storefront can create checkouts, look up subscriptions, and decode webhooks without hard-coding a vendor SDK.

Tip

Webhook signature verification is handled by the Webhook capability, not this trait. Always run a WebhookPlugin verifier before calling decode_webhook_event.

Philosophy: A Small Trait On Purpose

Stripe, Lemon Squeezy, and Paddle disagree on the shape of their line items, product objects, and webhook envelopes. They agree on three things every storefront needs:

1. Send the customer to a hosted checkout for a bag of line items. 2. Look up whether a known subscription is still active. 3. Decide what a webhook event means in the abstract.

The trait exposes exactly those three operations. Everything vendor-specific (Stripe's automatic_tax, Lemon Squeezy's variant_id, Paddle's custom_data) lives in an attributes: HashMap<String, String> escape hatch on each data type. The trait itself never grows a field that mentions a specific vendor.

The Trait

pub trait PaymentPlugin: Send + Sync {
    fn name(&self) -> &str;
    fn provider(&self) -> &str;  // "stripe", "lemonsqueezy", "paddle"

    fn create_checkout(
        &self,
        items: Vec<LineItem>,
        opts: CheckoutOpts,
    ) -> Result<CheckoutSession, PaymentError>;

    fn get_subscription(&self, id: &str) -> Result<Subscription, PaymentError>;

    fn decode_webhook_event(
        &self,
        raw_body: &[u8],
    ) -> Result<PaymentEvent, PaymentError>;
}

Signature verification for webhooks is handled separately by a WebhookPlugin; decode_webhook_event assumes the body has already been authenticated.

Key Types

Type Purpose
Money amount_cents: i64 + Currency. Integer cents across every currency, including zero-decimal ones like JPY.
Currency ISO-4217 three-letter code, validated at construction.
LineItem name, description, quantity, unit_price, sku.
CheckoutOpts customer_email, success_url, cancel_url, metadata, mode.
CheckoutMode OneTime or Subscription { interval } with Day/Week/Month/Year.
CheckoutSession id, url, expires_at, attributes.
Subscription id, customer_id, status, billing period, cancel_at_period_end, attributes.
SubscriptionStatus Active, Trialing, PastDue, Canceled, Unpaid, Incomplete.
PaymentEvent 7 variants: CheckoutCompleted, SubscriptionCreated, SubscriptionUpdated, SubscriptionCanceled, PaymentSucceeded, PaymentFailed, Unknown { event_type }.
PaymentError InvalidCurrency (400), InvalidAmount (400), NotFound (404), ProviderError (502), Backend (500).

Reference Implementations

Plugin Provider Notes
@bext/pay-stripe (live) Stripe StripeLivePaymentPlugin — real api.stripe.com REST calls (checkout sessions, subscription lookup).
@bext/pay-stripe (double) Stripe StripePaymentPlugin — network-free reference double; deterministic stub sessions + the shared webhook decoder.
@bext/pay-lemonsqueezy Lemon Squeezy Network-free reference double (Lemon Squeezy live client is future work).

The server picks the Stripe plugin by configuration: set BEXT_PAYMENT_STRIPE_KEY (or BEXT_PAYMENT_API_KEY) to a secret key and the live client is used (real HTTP to Stripe); with no key set, the network-free double is used so tests and offline environments exercise the trait contract without hitting a live API. Webhook decoding is shared by both and is always available; webhook signature verification is a separate concern handled by @bext/webhook-verifiers (real HMAC-SHA256) in front of the payment handler.

Example: Create A Checkout

use bext_plugin_api::payment::*;
use std::collections::HashMap;

let plugin: &dyn PaymentPlugin = /* @bext/pay-stripe */;

let session = plugin.create_checkout(
    vec![LineItem {
        name: "Pro Plan".into(),
        description: Some("Monthly access".into()),
        quantity: 1,
        unit_price: Money::new(1999, Currency::from_code("USD")?),
        sku: Some("PRO-M-1".into()),
    }],
    CheckoutOpts {
        customer_email: Some("alice@example.com".into()),
        success_url: "https://app.example.com/thanks".into(),
        cancel_url:  "https://app.example.com/pricing".into(),
        metadata: HashMap::new(),
        mode: CheckoutMode::Subscription {
            interval: SubscriptionInterval::Month,
        },
    },
)?;

// Redirect the customer
redirect(&session.url);

Example: Handle A Webhook

fn on_webhook(plugin: &dyn PaymentPlugin, raw: &[u8]) -> Result<(), PaymentError> {
    // Signature is verified by a WebhookPlugin upstream of this call.
    match plugin.decode_webhook_event(raw)? {
        PaymentEvent::CheckoutCompleted { session_id, customer_id } => {
            mark_paid(&session_id, customer_id.as_deref());
        }
        PaymentEvent::SubscriptionCreated(sub)
        | PaymentEvent::SubscriptionUpdated(sub) => {
            upsert_subscription(&sub);
        }
        PaymentEvent::SubscriptionCanceled { subscription_id } => {
            revoke_access(&subscription_id);
        }
        PaymentEvent::PaymentSucceeded { amount, customer_id } => {
            record_payment(amount, customer_id.as_deref());
        }
        PaymentEvent::PaymentFailed { reason, customer_id } => {
            alert_dunning(&reason, customer_id.as_deref());
        }
        PaymentEvent::Unknown { event_type } => {
            tracing::info!(event_type, "unknown payment event (ignored)");
        }
    }
    Ok(())
}

Example: Look Up A Subscription

let sub = plugin.get_subscription("sub_abc123")?;
if sub.status == SubscriptionStatus::Active && !sub.cancel_at_period_end {
    allow_pro_features(&sub.customer_id);
}

Currency Rules

Currency::from_code rejects anything that is not exactly three uppercase ASCII letters. The ISO list itself is not enforced at construction — that is the payment processor's job at the API boundary — but typos like "usd" or "US" fail fast.

Money is signed (i64) so refunds and adjustments round-trip through the same type. Zero-decimal currencies (JPY, KRW) use the same field; the plugin divides by 1 instead of 100.

Feature Flag

None. The Payment trait and types live in bext-plugin-api and are always available; no cargo feature gates them.

See Also

- @bext/pay-stripe — Stripe reference implementation.

- @bext/pay-lemonsqueezy — Lemon Squeezy reference implementation.

- Webhook — handles signature verification before decode_webhook_event runs.

- Capabilities overview — the full list of pluggable capabilities.

- Session — where customer identity flows from into checkout metadata.