diff --git a/.changelog/kind-bees-pack.md b/.changelog/kind-bees-pack.md new file mode 100644 index 0000000..ae4d349 --- /dev/null +++ b/.changelog/kind-bees-pack.md @@ -0,0 +1,5 @@ +--- +mpp: minor +--- + +Added a Stripe Shared Payment Token (SPT) example demonstrating the full 402 → challenge → credential → retry flow using Stripe's payment method. Includes a server with SPT proxy endpoint and a headless client using a test card. diff --git a/.changelog/unique-lakes-roll.md b/.changelog/unique-lakes-roll.md new file mode 100644 index 0000000..93ab302 --- /dev/null +++ b/.changelog/unique-lakes-roll.md @@ -0,0 +1,5 @@ +--- +mpp: minor +--- + +Added Stripe payment method support (`method="stripe"`, `intent="charge"`) with client-side `StripeProvider` for SPT creation, server-side `ChargeMethod` for PaymentIntent verification, and `Mpp::create_stripe()` builder integration. Added `stripe` and `integration-stripe` feature flags backed by `reqwest`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 875b74a..59fc639 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,10 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo update -p native-tls - uses: taiki-e/install-action@cargo-hack - - run: cargo test --features tempo,server,client,axum,middleware,tower,utils + - name: Tests + run: cargo test --features tempo,stripe,server,client,axum,middleware,tower,utils,integration-stripe + env: + STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} - run: cargo hack check --each-feature --no-dev-deps --skip integration - name: Check examples run: find examples -name Cargo.toml -exec cargo check --manifest-path {} \; diff --git a/Cargo.toml b/Cargo.toml index 8aa6aa4..5a7291a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ server = ["tokio", "futures-core", "async-stream"] # Method implementations evm = ["alloy", "hex", "rand"] tempo = ["evm", "tempo-alloy", "tempo-primitives", "uuid"] +stripe = ["dep:reqwest"] # Utilities utils = ["hex", "rand"] @@ -40,6 +41,7 @@ reqwest-rustls-tls = ["reqwest?/rustls-tls"] # Integration tests (requires a running Tempo localnet) integration = ["tempo", "server", "client", "axum"] +integration-stripe = ["stripe", "server", "client", "axum"] [dependencies] # Core dependencies (always included) diff --git a/README.md b/README.md index aac83a8..b9712ed 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ cargo add mpp ## Quick Start -### Server +### Server (Tempo) ```rust use mpp::server::{Mpp, tempo, TempoConfig}; @@ -47,7 +47,24 @@ let challenge = mpp.charge("1")?; let receipt = mpp.verify_credential(&credential).await?; ``` -### Client +### Server (Stripe) + +```rust +use mpp::server::{Mpp, stripe, StripeConfig}; + +let mpp = Mpp::create_stripe(stripe(StripeConfig { + secret_key: "sk_test_...", + network_id: "internal", + payment_method_types: &["card"], + currency: "usd", + decimals: 2, +}))?; + +let challenge = mpp.stripe_charge("1")?; +let receipt = mpp.verify_credential(&credential).await?; +``` + +### Client (Tempo) ```rust use mpp::client::{PaymentMiddleware, TempoProvider}; @@ -62,6 +79,29 @@ let client = ClientBuilder::new(reqwest::Client::new()) let resp = client.get("https://mpp.dev/api/ping/paid").send().await?; ``` +### Client (Stripe) + +```rust +use mpp::client::{Fetch, StripeProvider}; +use mpp::protocol::methods::stripe::CreateTokenResult; + +let provider = StripeProvider::new(|params| { + Box::pin(async move { + // Proxy SPT creation through your backend (requires Stripe secret key) + let resp = reqwest::Client::new() + .post("https://my-server.com/api/create-spt") + .json(¶ms) + .send().await?.json::().await?; + Ok(CreateTokenResult::from(resp["spt"].as_str().unwrap().to_string())) + }) +}); + +let resp = reqwest::Client::new() + .get("https://api.example.com/paid") + .send_with_payment(&provider) + .await?; +``` + ## Feature Flags | Feature | Description | @@ -69,6 +109,7 @@ let resp = client.get("https://mpp.dev/api/ping/paid").send().await?; | `client` | Client-side payment providers (`PaymentProvider` trait, `Fetch` extension) | | `server` | Server-side payment verification (`ChargeMethod` trait) | | `tempo` | [Tempo](https://tempo.xyz) blockchain support (includes `evm`) | +| `stripe` | [Stripe](https://stripe.com) payment support via SPTs | | `evm` | Shared EVM utilities (Address, U256, parsing) | | `middleware` | reqwest-middleware support with `PaymentMiddleware` (implies `client`) | | `tower` | Tower middleware for server-side integration | @@ -77,7 +118,7 @@ let resp = client.get("https://mpp.dev/api/ping/paid").send().await?; ## Payment Methods -MPP supports multiple [payment methods](https://mpp.dev/payment-methods/) through one protocol — [Tempo](https://mpp.dev/payment-methods/tempo/), [Stripe](https://mpp.dev/payment-methods/stripe/), [Lightning](https://mpp.dev/payment-methods/lightning/), [Card](https://mpp.dev/payment-methods/card/), and [custom methods](https://mpp.dev/payment-methods/custom). The server advertises which methods it accepts, and the client chooses which one to pay with. This SDK currently implements Tempo (charge and session intents). +MPP supports multiple [payment methods](https://mpp.dev/payment-methods/) through one protocol — [Tempo](https://mpp.dev/payment-methods/tempo/), [Stripe](https://mpp.dev/payment-methods/stripe/), [Lightning](https://mpp.dev/payment-methods/lightning/), [Card](https://mpp.dev/payment-methods/card/), and [custom methods](https://mpp.dev/payment-methods/custom). The server advertises which methods it accepts, and the client chooses which one to pay with. This SDK implements Tempo (charge and session intents) and Stripe (charge intent via Shared Payment Tokens). ## Protocol diff --git a/examples/README.md b/examples/README.md index ace2040..1c90ef8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,7 +6,8 @@ Standalone, runnable examples demonstrating the mpp HTTP 402 payment flow. | Example | Description | |---------|-------------| -| [basic](./basic/) | Payment-gated Fortune Teller API | +| [basic](./basic/) | Payment-gated Fortune Teller API (Tempo) | +| [stripe](./stripe/) | Payment-gated Fortune Teller API (Stripe SPT) | | [axum-extractor](./axum-extractor/) | Axum extractors with per-route pricing (`MppCharge`) | | [session/multi-fetch](./session/multi-fetch/) | Multiple paid requests over a single payment channel | | [session/sse](./session/sse/) | Pay-per-token LLM streaming with SSE | @@ -16,11 +17,16 @@ Standalone, runnable examples demonstrating the mpp HTTP 402 payment flow. Each example is a standalone Cargo crate with a server and client binary. ```bash -# Basic example +# Basic example (Tempo) cd examples/basic cargo run --bin basic-server # Terminal 1 cargo run --bin basic-client # Terminal 2 +# Stripe example +cd examples/stripe +STRIPE_SECRET_KEY=sk_test_... cargo run --bin stripe-server # Terminal 1 +cargo run --bin stripe-client # Terminal 2 + # Axum extractor (per-route pricing) cd examples/axum-extractor cargo run --bin axum-server # Terminal 1 diff --git a/examples/stripe/Cargo.toml b/examples/stripe/Cargo.toml new file mode 100644 index 0000000..bdac4ab --- /dev/null +++ b/examples/stripe/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "stripe-example" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "stripe-server" +path = "src/server.rs" + +[[bin]] +name = "stripe-client" +path = "src/client.rs" + +[dependencies] +mpp = { path = "../..", features = ["server", "client", "stripe"] } +axum = "0.7" +tokio = { version = "1", features = ["full"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +serde_json = "1" +serde = { version = "1", features = ["derive"] } +rand = "0.9" +base64 = "0.22" diff --git a/examples/stripe/README.md b/examples/stripe/README.md new file mode 100644 index 0000000..11dbda5 --- /dev/null +++ b/examples/stripe/README.md @@ -0,0 +1,76 @@ +# Stripe Example + +A pay-per-fortune API using Stripe's Shared Payment Token (SPT) flow. + +## What This Demonstrates + +- Server-side payment protection with `Mpp::create_stripe()` and the Stripe method +- SPT proxy endpoint (secret key stays server-side) +- Headless client using a test card (`pm_card_visa`) +- Full 402 → challenge → credential → retry flow + +## Prerequisites + +- Rust 1.80+ +- A Stripe test-mode secret key (`sk_test_...`) + +## Running + +**Start the server:** + +```bash +export STRIPE_SECRET_KEY=sk_test_... +cargo run --bin stripe-server +``` + +The server starts at http://localhost:3000. + +**Run the client** (in another terminal): + +```bash +cargo run --bin stripe-client +# 🥠 A smooth long journey! Great expectations. +# Payment receipt: pi_3Q... +``` + +## Testing Manually + +**Without payment** (returns 402): + +```bash +curl -i http://localhost:3000/api/fortune +# HTTP/1.1 402 Payment Required +# WWW-Authenticate: Payment ... +``` + +## How It Works + +``` +Client Server Stripe + │ │ │ + │ GET /api/fortune │ │ + ├──────────────────────────────> │ │ + │ │ │ + │ 402 + WWW-Authenticate │ │ + │<────────────────────────────── │ │ + │ │ │ + │ POST /api/create-spt │ │ + ├──────────────────────────────> │ Create SPT (test helper) │ + │ ├─────────────────────────────> │ + │ spt_... │ │ + │<────────────────────────────── │<───────────────────────────── │ + │ │ │ + │ GET /api/fortune │ │ + │ Authorization: Payment │ │ + ├──────────────────────────────> │ PaymentIntent (SPT + confirm)│ + │ ├─────────────────────────────> │ + │ │ pi_... succeeded │ + │ 200 + fortune + receipt │<───────────────────────────── │ + │<────────────────────────────── │ │ +``` + +1. Client requests the fortune → server returns 402 with a payment challenge +2. mpp client calls `create_token` → POSTs to `/api/create-spt` → server creates SPT via Stripe +3. Client retries with a credential containing the SPT +4. Server creates a PaymentIntent with `shared_payment_granted_token` and `confirm=true` +5. On success, returns the fortune with a receipt diff --git a/examples/stripe/src/client.rs b/examples/stripe/src/client.rs new file mode 100644 index 0000000..2cdbf2c --- /dev/null +++ b/examples/stripe/src/client.rs @@ -0,0 +1,100 @@ +//! # Stripe Fortune Teller CLI Client +//! +//! A CLI client that fetches a fortune from the payment-gated Fortune Teller API +//! using Stripe's Shared Payment Token (SPT) flow. +//! +//! Uses a test card (`pm_card_visa`) for headless operation — no browser needed. +//! +//! ## Running +//! +//! ```bash +//! # First start the server: +//! STRIPE_SECRET_KEY=sk_test_... cargo run --bin stripe-server +//! +//! # Then in another terminal: +//! cargo run --bin stripe-client +//! +//! # Or target a different server: +//! cargo run --bin stripe-client -- --server http://localhost:8000 +//! ``` + +use mpp::client::{Fetch, StripeProvider}; +use mpp::protocol::methods::stripe::CreateTokenResult; +use mpp::{parse_receipt, MppError}; +use reqwest::Client; + +#[tokio::main] +async fn main() { + let server_url = std::env::args() + .skip_while(|a| a != "--server") + .nth(1) + .unwrap_or_else(|| "http://localhost:3000".to_string()); + + let server_base = server_url.trim_end_matches('/').to_string(); + let spt_url = format!("{server_base}/api/create-spt"); + + let provider = StripeProvider::new(move |params| { + let spt_url = spt_url.clone(); + Box::pin(async move { + let resp = Client::new() + .post(&spt_url) + .json(&serde_json::json!({ + "paymentMethod": "pm_card_visa", + "amount": params.amount, + "currency": params.currency, + "expiresAt": params.expires_at, + })) + .send() + .await + .map_err(|e| MppError::Http(e.to_string()))?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(MppError::Http(format!("SPT creation failed: {body}"))); + } + + let json: serde_json::Value = resp + .json() + .await + .map_err(|e| MppError::Http(e.to_string()))?; + + let spt = json["spt"] + .as_str() + .ok_or_else(|| MppError::Http("missing spt in response".to_string()))? + .to_string(); + + Ok(CreateTokenResult::from(spt)) + }) + }); + + let fortune_url = format!("{server_base}/api/fortune"); + println!("Fetching {fortune_url} ..."); + + let resp = Client::new() + .get(&fortune_url) + .send_with_payment(&provider) + .await + .expect("request failed"); + + println!("Status: {}", resp.status()); + + if let Some(receipt_hdr) = resp.headers().get("payment-receipt") { + if let Ok(receipt_str) = receipt_hdr.to_str() { + if let Ok(receipt) = parse_receipt(receipt_str) { + println!("Payment receipt: {}", receipt.reference); + } + } + } + + let body = resp.text().await.expect("failed to read response body"); + + if let Ok(json) = serde_json::from_str::(&body) { + if let Some(fortune) = json.get("fortune").and_then(|v| v.as_str()) { + println!("\nFortune: {fortune}"); + } else { + println!("\nResponse: {json}"); + } + } else { + println!("\nResponse: {body}"); + } +} diff --git a/examples/stripe/src/server.rs b/examples/stripe/src/server.rs new file mode 100644 index 0000000..5c846ed --- /dev/null +++ b/examples/stripe/src/server.rs @@ -0,0 +1,208 @@ +//! # Stripe Fortune Teller API Server +//! +//! A payment-gated Fortune Teller API using Stripe's Shared Payment Token (SPT) +//! flow via the Machine Payment Protocol. +//! +//! Two endpoints: +//! +//! - `POST /api/create-spt` — proxy for SPT creation (secret key stays server-side) +//! - `GET /api/fortune` — paid endpoint ($1.00 per fortune) +//! +//! ## Running +//! +//! ```bash +//! export STRIPE_SECRET_KEY=sk_test_... +//! cargo run --bin stripe-server +//! ``` +//! +//! The server listens on `http://localhost:3000`. + +use axum::{ + extract::State, + http::{header, HeaderMap, StatusCode}, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use mpp::server::{stripe, Mpp, StripeChargeMethod, StripeConfig}; +use mpp::{format_www_authenticate, parse_authorization}; +use rand::seq::IndexedRandom; +use std::sync::Arc; + +type Payment = Mpp; + +const FORTUNES: &[&str] = &[ + "A beautiful, smart, and loving person will come into your life.", + "A dubious friend may be an enemy in camouflage.", + "A faithful friend is a strong defense.", + "A fresh start will put you on your way.", + "A golden egg of opportunity falls into your lap this month.", + "A good time to finish up old tasks.", + "A light heart carries you through all the hard times ahead.", + "A smooth long journey! Great expectations.", +]; + +struct AppState { + payment: Payment, + stripe_secret_key: String, +} + +#[tokio::main] +async fn main() { + let secret_key = + std::env::var("STRIPE_SECRET_KEY").expect("STRIPE_SECRET_KEY env var required"); + let network_id = + std::env::var("STRIPE_NETWORK_ID").unwrap_or_else(|_| "internal".to_string()); + + let payment = Mpp::create_stripe( + stripe(StripeConfig { + secret_key: &secret_key, + network_id: &network_id, + payment_method_types: &["card"], + currency: "usd", + decimals: 2, + }) + .secret_key( + &std::env::var("MPP_SECRET_KEY") + .unwrap_or_else(|_| "stripe-example-secret".to_string()), + ), + ) + .expect("failed to create Stripe payment handler"); + + let state = Arc::new(AppState { + payment, + stripe_secret_key: secret_key, + }); + + let app = Router::new() + .route("/api/health", get(health)) + .route("/api/create-spt", post(create_spt)) + .route("/api/fortune", get(fortune)) + .with_state(state); + + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") + .await + .expect("failed to bind"); + + println!("Stripe Fortune Teller API listening on http://localhost:3000"); + axum::serve(listener, app).await.expect("server error"); +} + +async fn health() -> impl IntoResponse { + Json(serde_json::json!({ "status": "ok" })) +} + +/// Proxy endpoint for SPT creation. +/// +/// The client calls this with a payment method ID and challenge details. +/// We call Stripe's test SPT endpoint using our secret key. +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateSptRequest { + payment_method: String, + amount: String, + currency: String, + expires_at: u64, +} + +async fn create_spt( + State(state): State>, + Json(body): Json, +) -> impl IntoResponse { + let auth_value = + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, format!("{}:", state.stripe_secret_key)); + + let params = [ + ("payment_method", body.payment_method), + ("usage_limits[currency]", body.currency), + ("usage_limits[max_amount]", body.amount), + ("usage_limits[expires_at]", body.expires_at.to_string()), + ]; + + let client = reqwest::Client::new(); + let resp = client + .post("https://api.stripe.com/v1/test_helpers/shared_payment/granted_tokens") + .header("Authorization", format!("Basic {auth_value}")) + .form(¶ms) + .send() + .await; + + match resp { + Ok(r) if r.status().is_success() => { + let json: serde_json::Value = r.json().await.unwrap_or_default(); + let spt = json["id"].as_str().unwrap_or_default(); + (StatusCode::OK, Json(serde_json::json!({ "spt": spt }))).into_response() + } + Ok(r) => { + let status = r.status().as_u16(); + let body = r.text().await.unwrap_or_default(); + eprintln!("Stripe SPT error ({status}): {body}"); + ( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({ "error": "SPT creation failed" })), + ) + .into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ) + .into_response(), + } +} + +async fn fortune( + State(state): State>, + headers: HeaderMap, +) -> impl IntoResponse { + // Check for payment credential in Authorization header + if let Some(auth) = headers.get(header::AUTHORIZATION) { + if let Ok(auth_str) = auth.to_str() { + if let Ok(credential) = parse_authorization(auth_str) { + match state.payment.verify_credential(&credential).await { + Ok(receipt) => { + let fortune = FORTUNES + .choose(&mut rand::rng()) + .unwrap_or(&"No fortune today."); + let receipt_header = receipt.to_header().unwrap_or_default(); + return ( + StatusCode::OK, + [("payment-receipt", receipt_header)], + Json(serde_json::json!({ + "fortune": fortune, + "receipt": receipt.reference, + })), + ) + .into_response(); + } + Err(e) => { + let body = serde_json::json!({ "error": e.to_string() }); + return (StatusCode::PAYMENT_REQUIRED, Json(body)).into_response(); + } + } + } + } + } + + // No valid credential — return 402 with challenge + match state.payment.stripe_charge("1") { + Ok(challenge) => match format_www_authenticate(&challenge) { + Ok(www_auth) => ( + StatusCode::PAYMENT_REQUIRED, + [(header::WWW_AUTHENTICATE, www_auth)], + Json(serde_json::json!({ "error": "Payment Required" })), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ) + .into_response(), + }, + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ) + .into_response(), + } +} diff --git a/src/client/mod.rs b/src/client/mod.rs index fd55432..3a0d888 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -45,3 +45,9 @@ pub use tempo::session::{channel_ops, TempoSessionProvider}; pub use tempo::{AutoswapConfig, TempoClientError, TempoProvider}; #[cfg(feature = "tempo")] pub use tempo_alloy::TempoNetwork; + +// Re-export Stripe types at client level for convenience +#[cfg(feature = "stripe")] +pub mod stripe; +#[cfg(feature = "stripe")] +pub use stripe::StripeProvider; diff --git a/src/client/stripe/mod.rs b/src/client/stripe/mod.rs new file mode 100644 index 0000000..0cb8f9c --- /dev/null +++ b/src/client/stripe/mod.rs @@ -0,0 +1,8 @@ +//! Stripe-specific client implementations. +//! +//! Provides [`StripeProvider`] which implements [`PaymentProvider`] for +//! Stripe charge challenges using Shared Payment Tokens (SPTs). + +mod provider; + +pub use provider::StripeProvider; diff --git a/src/client/stripe/provider.rs b/src/client/stripe/provider.rs new file mode 100644 index 0000000..5c0e9d6 --- /dev/null +++ b/src/client/stripe/provider.rs @@ -0,0 +1,163 @@ +//! Stripe payment provider for client-side credential creation. + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use crate::client::PaymentProvider; +use crate::error::{MppError, ResultExt}; +use crate::protocol::core::{PaymentChallenge, PaymentCredential}; +use crate::protocol::intents::ChargeRequest; +use crate::protocol::methods::stripe::types::CreateTokenResult; +use crate::protocol::methods::stripe::{ + StripeCredentialPayload, StripeMethodDetails, INTENT_CHARGE, METHOD_NAME, +}; + +/// Parameters passed to the `create_token` callback. +/// +/// Matches the mppx `OnChallengeParameters` shape. +#[derive(Debug, Clone, serde::Serialize)] +pub struct CreateTokenParams { + /// Payment amount in smallest currency unit. + pub amount: String, + /// Three-letter ISO currency code. + pub currency: String, + /// Stripe Business Network profile ID. + pub network_id: String, + /// SPT expiration as Unix timestamp (seconds). + pub expires_at: u64, + /// Optional metadata from the challenge's methodDetails. + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, + /// The full challenge as JSON, for advanced use cases. + #[serde(skip)] + pub challenge: serde_json::Value, +} + +/// Client-side Stripe payment provider. +/// +/// Handles 402 challenges with `method="stripe"` by creating an SPT via +/// the user-provided `create_token` callback and returning a credential. +/// +/// # Example +/// +/// ```ignore +/// use mpp::client::stripe::StripeProvider; +/// use mpp::protocol::methods::stripe::CreateTokenResult; +/// +/// let provider = StripeProvider::new(|params| { +/// Box::pin(async move { +/// let resp = reqwest::Client::new() +/// .post("https://my-server.com/api/create-spt") +/// .json(¶ms) +/// .send().await.map_err(|e| mpp::MppError::Http(e.to_string()))? +/// .json::().await +/// .map_err(|e| mpp::MppError::Http(e.to_string()))?; +/// Ok(CreateTokenResult { +/// spt: resp["spt"].as_str().unwrap().to_string(), +/// external_id: None, +/// }) +/// }) +/// }); +/// ``` +type CreateTokenFn = dyn Fn( + CreateTokenParams, + ) -> Pin> + Send>> + + Send + + Sync; + +#[derive(Clone)] +pub struct StripeProvider { + create_token: Arc, +} + +impl StripeProvider { + /// Create a new Stripe provider with the given SPT creation callback. + /// + /// The callback receives [`CreateTokenParams`] and should return a + /// [`CreateTokenResult`] containing the SPT and optional external ID. + pub fn new(create_token: F) -> Self + where + F: Fn( + CreateTokenParams, + ) + -> Pin> + Send>> + + Send + + Sync + + 'static, + { + Self { + create_token: Arc::new(create_token), + } + } +} + +impl PaymentProvider for StripeProvider { + fn supports(&self, method: &str, intent: &str) -> bool { + method == METHOD_NAME && intent == INTENT_CHARGE + } + + async fn pay(&self, challenge: &PaymentChallenge) -> Result { + let request: ChargeRequest = challenge + .request + .decode() + .mpp_config("failed to decode challenge request")?; + + let details: StripeMethodDetails = request + .method_details + .as_ref() + .map(|v| serde_json::from_value(v.clone())) + .transpose() + .mpp_config("invalid methodDetails")? + .unwrap_or_default(); + + let expires_at = challenge + .expires + .as_ref() + .and_then(|e| { + time::OffsetDateTime::parse(e, &time::format_description::well_known::Rfc3339).ok() + }) + .map(|dt| dt.unix_timestamp() as u64) + .unwrap_or_else(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 3600 + }); + + let params = CreateTokenParams { + amount: request.amount, + currency: request.currency, + network_id: details.network_id, + expires_at, + metadata: details.metadata, + challenge: serde_json::to_value(challenge).unwrap_or_default(), + }; + + let result = (self.create_token)(params).await?; + + let payload = StripeCredentialPayload { + spt: result.spt, + external_id: result.external_id, + }; + + Ok(PaymentCredential::new(challenge.to_echo(), payload)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_supports() { + let provider = StripeProvider::new(|_| { + Box::pin(async { Ok(CreateTokenResult::from("spt_test".to_string())) }) + }); + + assert!(provider.supports("stripe", "charge")); + assert!(!provider.supports("tempo", "charge")); + assert!(!provider.supports("stripe", "session")); + } +} diff --git a/src/protocol/intents/mod.rs b/src/protocol/intents/mod.rs index 538e3a3..c3df0d2 100644 --- a/src/protocol/intents/mod.rs +++ b/src/protocol/intents/mod.rs @@ -31,6 +31,12 @@ pub mod payment_request; pub mod session; pub use charge::ChargeRequest; + +/// Intent identifier for one-time payments. +pub const INTENT_CHARGE: &str = "charge"; + +/// Intent identifier for pay-as-you-go sessions. +pub const INTENT_SESSION: &str = "session"; pub use payment_request::{ deserialize as deserialize_request, deserialize_typed as deserialize_request_typed, from_challenge as request_from_challenge, from_challenge_typed as request_from_challenge_typed, diff --git a/src/protocol/methods/mod.rs b/src/protocol/methods/mod.rs index 6c4f502..5f3c225 100644 --- a/src/protocol/methods/mod.rs +++ b/src/protocol/methods/mod.rs @@ -5,17 +5,24 @@ //! # Available Methods //! //! - [`tempo`]: Tempo blockchain (requires `tempo` feature) +//! - [`stripe`]: Stripe payments via SPTs (requires `stripe` feature) //! //! # Architecture //! //! ```text //! methods/ -//! └── tempo/ # Tempo-specific (chain_id=42431, TIP-20, 2D nonces) -//! ├── types.rs # TempoMethodDetails -//! └── charge.rs # TempoChargeExt trait +//! ├── tempo/ # Tempo-specific (chain_id=42431, TIP-20, 2D nonces) +//! │ ├── types.rs # TempoMethodDetails +//! │ └── charge.rs # TempoChargeExt trait +//! └── stripe/ # Stripe SPT-based payments +//! ├── types.rs # StripeChargeRequest, StripeCredentialPayload +//! └── method.rs # ChargeMethod impl //! ``` //! //! Shared EVM utilities (Address, U256, parsing) are in the top-level `evm` module. #[cfg(feature = "tempo")] pub mod tempo; + +#[cfg(feature = "stripe")] +pub mod stripe; diff --git a/src/protocol/methods/stripe/method.rs b/src/protocol/methods/stripe/method.rs new file mode 100644 index 0000000..b4d734c --- /dev/null +++ b/src/protocol/methods/stripe/method.rs @@ -0,0 +1,264 @@ +//! Stripe charge method for server-side payment verification. +//! +//! Verifies payments by creating a Stripe PaymentIntent with the client's +//! Shared Payment Token (SPT). Supports both a pre-configured Stripe SDK +//! client and raw secret key modes. +//! +//! # Example +//! +//! ```ignore +//! use mpp::protocol::methods::stripe::method::ChargeMethod; +//! +//! let method = ChargeMethod::new("sk_test_...", "internal", vec!["card"]); +//! let receipt = method.verify(&credential, &request).await?; +//! ``` + +use std::collections::HashMap; +use std::future::Future; + +use crate::protocol::core::{PaymentCredential, Receipt}; +use crate::protocol::intents::ChargeRequest; +use crate::protocol::traits::{ChargeMethod as ChargeMethodTrait, VerificationError}; + +use super::types::{StripeCredentialPayload, StripeMethodDetails}; +use super::{DEFAULT_STRIPE_API_BASE, METHOD_NAME}; + +/// Minimal Stripe PaymentIntent response fields. +#[derive(serde::Deserialize)] +struct PaymentIntentResponse { + id: String, + status: String, +} + +/// Stripe charge method for one-time payment verification via SPTs. +#[derive(Clone)] +pub struct ChargeMethod { + secret_key: String, + network_id: String, + payment_method_types: Vec, + api_base: String, +} + +impl ChargeMethod { + /// Create a new Stripe charge method. + /// + /// # Arguments + /// + /// * `secret_key` - Stripe secret API key (e.g., `sk_test_...` or `sk_live_...`) + /// * `network_id` - Stripe Business Network profile ID + /// * `payment_method_types` - Accepted payment method types (e.g., `["card"]`) + pub fn new( + secret_key: impl Into, + network_id: impl Into, + payment_method_types: Vec, + ) -> Self { + Self { + secret_key: secret_key.into(), + network_id: network_id.into(), + payment_method_types, + api_base: DEFAULT_STRIPE_API_BASE.to_string(), + } + } + + /// Override the Stripe API base URL (for testing with a mock server). + pub fn with_api_base(mut self, url: impl Into) -> Self { + self.api_base = url.into(); + self + } + + /// Get the configured network ID. + pub fn network_id(&self) -> &str { + &self.network_id + } + + /// Get the configured payment method types. + pub fn payment_method_types(&self) -> &[String] { + &self.payment_method_types + } + + /// Create a Stripe PaymentIntent with the given SPT. + async fn create_payment_intent( + &self, + spt: &str, + amount: &str, + currency: &str, + idempotency_key: &str, + metadata: &HashMap, + ) -> Result<(String, String), VerificationError> { + let url = format!("{}/v1/payment_intents", self.api_base); + + let mut params = vec![ + ("amount".to_string(), amount.to_string()), + ( + "automatic_payment_methods[allow_redirects]".to_string(), + "never".to_string(), + ), + ( + "automatic_payment_methods[enabled]".to_string(), + "true".to_string(), + ), + ("confirm".to_string(), "true".to_string()), + ("currency".to_string(), currency.to_string()), + ("shared_payment_granted_token".to_string(), spt.to_string()), + ]; + + for (key, value) in metadata { + params.push((format!("metadata[{key}]"), value.clone())); + } + + let client = reqwest::Client::new(); + let response = client + .post(&url) + .header("Content-Type", "application/x-www-form-urlencoded") + .header( + "Authorization", + format!( + "Basic {}", + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + format!("{}:", self.secret_key) + ) + ), + ) + .header("Idempotency-Key", idempotency_key) + .form(¶ms) + .send() + .await + .map_err(|e| { + VerificationError::network_error(format!("Stripe API request failed: {e}")) + })?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + let message = serde_json::from_str::(&body) + .ok() + .and_then(|v| v["error"]["message"].as_str().map(String::from)) + .unwrap_or_else(|| format!("HTTP {status}")); + return Err(VerificationError::new(format!( + "Stripe PaymentIntent creation failed: {message}" + ))); + } + + let pi: PaymentIntentResponse = response + .json() + .await + .map_err(|e| VerificationError::new(format!("Failed to parse Stripe response: {e}")))?; + + Ok((pi.id, pi.status)) + } + + /// Build analytics metadata matching mppx's buildAnalytics(). + fn build_analytics(credential: &PaymentCredential) -> HashMap { + let challenge = &credential.challenge; + let mut meta = HashMap::new(); + meta.insert("mpp_version".into(), "1".into()); + meta.insert("mpp_is_mpp".into(), "true".into()); + meta.insert("mpp_intent".into(), challenge.intent.as_str().to_string()); + meta.insert("mpp_challenge_id".into(), challenge.id.clone()); + meta.insert("mpp_server_id".into(), challenge.realm.clone()); + if let Some(ref source) = credential.source { + meta.insert("mpp_client_id".into(), source.clone()); + } + meta + } +} + +impl ChargeMethodTrait for ChargeMethod { + fn method(&self) -> &str { + METHOD_NAME + } + + fn verify( + &self, + credential: &PaymentCredential, + _request: &ChargeRequest, + ) -> impl Future> + Send { + let credential = credential.clone(); + let this = self.clone(); + + async move { + // Parse the SPT from the credential payload + let payload: StripeCredentialPayload = + serde_json::from_value(credential.payload.clone()).map_err(|e| { + VerificationError::new(format!( + "Invalid credential payload: missing or malformed spt: {e}" + )) + })?; + + let challenge = &credential.challenge; + + // Note: expiry is already checked by Mpp::verify_hmac_and_expiry() + // before this method is called. + + // Decode the challenge request to get amount/currency + let charge_request: ChargeRequest = challenge.request.decode().map_err(|e| { + VerificationError::new(format!("Failed to decode challenge request: {e}")) + })?; + + // Build metadata: analytics + user metadata from methodDetails + let mut metadata = Self::build_analytics(&credential); + let details: StripeMethodDetails = charge_request + .method_details + .as_ref() + .map(|v| serde_json::from_value(v.clone())) + .transpose() + .map_err(|e| VerificationError::new(format!("Invalid methodDetails: {e}")))? + .unwrap_or_default(); + if let Some(user_meta) = details.metadata { + metadata.extend(user_meta); + } + + let idempotency_key = format!("mppx_{}_{}", challenge.id, payload.spt); + + let (pi_id, status) = this + .create_payment_intent( + &payload.spt, + &charge_request.amount, + &charge_request.currency, + &idempotency_key, + &metadata, + ) + .await?; + + match status.as_str() { + "succeeded" => Ok(Receipt::success(METHOD_NAME, &pi_id)), + "requires_action" => Err(VerificationError::new( + "Stripe PaymentIntent requires action (e.g., 3DS)", + )), + other => Err(VerificationError::new(format!( + "Stripe PaymentIntent status: {other}" + ))), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_charge_method_name() { + let method = ChargeMethod::new("sk_test", "internal", vec!["card".into()]); + assert_eq!(ChargeMethodTrait::method(&method), "stripe"); + } + + #[test] + fn test_with_api_base() { + let method = ChargeMethod::new("sk_test", "internal", vec!["card".into()]) + .with_api_base("http://localhost:9999"); + assert_eq!(method.api_base, "http://localhost:9999"); + } + + #[test] + fn test_accessors() { + let method = ChargeMethod::new( + "sk_test", + "my-network", + vec!["card".into(), "us_bank_account".into()], + ); + assert_eq!(method.network_id(), "my-network"); + assert_eq!(method.payment_method_types(), &["card", "us_bank_account"]); + } +} diff --git a/src/protocol/methods/stripe/mod.rs b/src/protocol/methods/stripe/mod.rs new file mode 100644 index 0000000..3a676d1 --- /dev/null +++ b/src/protocol/methods/stripe/mod.rs @@ -0,0 +1,38 @@ +//! Stripe-specific types and helpers for MPP. +//! +//! This module provides the Stripe payment method implementation for one-time +//! charges using Stripe's Shared Payment Tokens (SPTs). +//! +//! # Payment Flow +//! +//! 1. Server issues a 402 challenge with `method="stripe"`, `intent="charge"` +//! 2. Client creates an SPT via a server-proxied Stripe API call +//! 3. Client sends credential containing the SPT +//! 4. Server creates a Stripe PaymentIntent with `confirm: true` and the SPT +//! 5. If PaymentIntent status is `succeeded`, payment is verified +//! +//! # Types +//! +//! - [`StripeChargeRequest`]: Stripe-specific charge request fields +//! - [`StripeCredentialPayload`]: Client credential containing the SPT +//! +//! # Constants +//! +//! - [`METHOD_NAME`]: Payment method name ("stripe") +//! - [`INTENT_CHARGE`]: Charge intent name ("charge") + +pub mod types; + +#[cfg(feature = "server")] +pub mod method; + +pub use types::{CreateTokenResult, StripeCredentialPayload, StripeMethodDetails}; + +/// Payment method name for Stripe. +pub const METHOD_NAME: &str = "stripe"; + +/// Charge intent name (re-exported from [`crate::protocol::intents`]). +pub use crate::protocol::intents::INTENT_CHARGE; + +/// Default Stripe API base URL. +pub const DEFAULT_STRIPE_API_BASE: &str = "https://api.stripe.com"; diff --git a/src/protocol/methods/stripe/types.rs b/src/protocol/methods/stripe/types.rs new file mode 100644 index 0000000..01944df --- /dev/null +++ b/src/protocol/methods/stripe/types.rs @@ -0,0 +1,114 @@ +//! Stripe-specific types for MPP. + +use serde::{Deserialize, Serialize}; + +/// Stripe-specific method details nested under `methodDetails` in the challenge request. +/// +/// Matches the mppx wire format where `networkId`, `paymentMethodTypes`, and `metadata` +/// are nested inside the `methodDetails` field of the `ChargeRequest`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct StripeMethodDetails { + /// Stripe Business Network profile ID. + #[serde(rename = "networkId")] + pub network_id: String, + + /// Accepted Stripe payment method types (e.g., ["card"]). + #[serde(rename = "paymentMethodTypes")] + pub payment_method_types: Vec, + + /// Optional metadata key-value pairs. + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +/// Client credential payload for Stripe charge. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StripeCredentialPayload { + /// Shared Payment Token from Stripe. + pub spt: String, + + /// Optional external reference ID. + #[serde(rename = "externalId", skip_serializing_if = "Option::is_none")] + pub external_id: Option, +} + +/// Result returned by the `create_token` callback. +#[derive(Debug, Clone)] +pub struct CreateTokenResult { + /// Shared Payment Token from Stripe. + pub spt: String, + /// Optional per-payment external reference ID. + pub external_id: Option, +} + +impl From for CreateTokenResult { + fn from(spt: String) -> Self { + Self { + spt, + external_id: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_stripe_credential_payload_serde() { + let payload = StripeCredentialPayload { + spt: "spt_test_abc123".to_string(), + external_id: Some("order-42".to_string()), + }; + let json = serde_json::to_string(&payload).unwrap(); + assert!(json.contains("\"spt\":\"spt_test_abc123\"")); + assert!(json.contains("\"externalId\":\"order-42\"")); + let parsed: StripeCredentialPayload = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.spt, "spt_test_abc123"); + assert_eq!(parsed.external_id.as_deref(), Some("order-42")); + } + + #[test] + fn test_stripe_credential_payload_without_external_id() { + let payload = StripeCredentialPayload { + spt: "spt_test_xyz".to_string(), + external_id: None, + }; + let json = serde_json::to_string(&payload).unwrap(); + assert!(!json.contains("externalId")); + } + + #[test] + fn test_stripe_method_details_serde() { + let details = StripeMethodDetails { + network_id: "internal".to_string(), + payment_method_types: vec!["card".to_string()], + metadata: None, + }; + let json = serde_json::to_string(&details).unwrap(); + assert!(json.contains("\"networkId\":\"internal\"")); + assert!(json.contains("\"paymentMethodTypes\":[\"card\"]")); + let parsed: StripeMethodDetails = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.network_id, "internal"); + } + + #[test] + fn test_stripe_method_details_with_metadata() { + let mut metadata = std::collections::HashMap::new(); + metadata.insert("order_id".to_string(), "12345".to_string()); + let details = StripeMethodDetails { + network_id: "internal".to_string(), + payment_method_types: vec!["card".to_string()], + metadata: Some(metadata), + }; + let json = serde_json::to_string(&details).unwrap(); + assert!(json.contains("\"order_id\":\"12345\"")); + } + + #[test] + fn test_create_token_result_from_string() { + let result: CreateTokenResult = "spt_123".to_string().into(); + assert_eq!(result.spt, "spt_123"); + assert!(result.external_id.is_none()); + } +} diff --git a/src/protocol/methods/tempo/mod.rs b/src/protocol/methods/tempo/mod.rs index 313a373..7c7558d 100644 --- a/src/protocol/methods/tempo/mod.rs +++ b/src/protocol/methods/tempo/mod.rs @@ -172,11 +172,11 @@ pub const DEFAULT_EXPIRES_MINUTES: u64 = 5; /// Payment method name for Tempo. pub const METHOD_NAME: &str = "tempo"; -/// Charge intent name. -pub const INTENT_CHARGE: &str = "charge"; +/// Charge intent name (re-exported from [`crate::protocol::intents`]). +pub use crate::protocol::intents::INTENT_CHARGE; -/// Session intent name. -pub const INTENT_SESSION: &str = "session"; +/// Session intent name (re-exported from [`crate::protocol::intents`]). +pub use crate::protocol::intents::INTENT_SESSION; /// Create a Tempo charge challenge with minimal parameters. /// diff --git a/src/server/axum.rs b/src/server/axum.rs index 8c5a54b..fdebe32 100644 --- a/src/server/axum.rs +++ b/src/server/axum.rs @@ -298,6 +298,48 @@ where } } +#[cfg(feature = "stripe")] +impl ChargeChallenger for super::Mpp +where + S: Clone + Send + Sync + 'static, +{ + fn challenge( + &self, + amount: &str, + options: ChallengeOptions, + ) -> Result { + self.stripe_charge_with_options( + amount, + super::StripeChargeOptions { + description: options.description, + ..Default::default() + }, + ) + .map_err(|e| e.to_string()) + } + + fn verify_payment( + &self, + credential_str: &str, + ) -> std::pin::Pin> + Send>> { + let credential = match parse_authorization(credential_str) { + Ok(c) => c, + Err(e) => { + return Box::pin(std::future::ready(Err(format!( + "Invalid credential: {}", + e + )))) + } + }; + let mpp = self.clone(); + Box::pin(async move { + super::Mpp::verify_credential(&mpp, &credential) + .await + .map_err(|e| e.to_string()) + }) + } +} + impl FromRequestParts for MppCharge where Arc: FromRef, diff --git a/src/server/mod.rs b/src/server/mod.rs index f939e60..6266346 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -36,119 +36,32 @@ pub mod middleware; #[cfg(feature = "axum")] pub mod axum; +#[cfg(feature = "tempo")] +mod tempo; + +#[cfg(feature = "stripe")] +mod stripe; + pub use crate::protocol::traits::{ChargeMethod, ErrorCode, SessionMethod, VerificationError}; pub use amount::{parse_dollar_amount, AmountError}; pub use mpp::{Mpp, SessionVerifyResult}; +// Re-export tempo types at server level for backward compatibility #[cfg(feature = "tempo")] -pub use crate::protocol::methods::tempo::ChargeMethod as TempoChargeMethod; - -#[cfg(feature = "tempo")] -pub use crate::protocol::methods::tempo::{ - TempoChargeExt, TempoMethodDetails, CHAIN_ID, METHOD_NAME, +pub use tempo::{ + tempo, tempo_provider, SessionChannelStore, SessionMethodConfig, TempoBuilder, TempoChargeExt, + TempoChargeMethod, TempoConfig, TempoMethodDetails, TempoProvider, TempoSessionMethod, + CHAIN_ID, METHOD_NAME, }; -#[cfg(feature = "tempo")] -pub use crate::protocol::methods::tempo::session_method::{ - InMemoryChannelStore as SessionChannelStore, SessionMethod as TempoSessionMethod, - SessionMethodConfig, +// Re-export stripe types at server level for backward compatibility +#[cfg(feature = "stripe")] +pub use stripe::{ + stripe, StripeBuilder, StripeChargeMethod, StripeChargeOptions, StripeConfig, + StripeCredentialPayload, StripeMethodDetails, }; -// ==================== Simple API ==================== - -/// Configuration for the Tempo payment method. -/// -/// Only `recipient` is required. Everything else has smart defaults. -#[cfg(feature = "tempo")] -pub struct TempoConfig<'a> { - /// Recipient address for payments. - pub recipient: &'a str, -} - -/// Builder returned by [`tempo()`] for configuring a Tempo payment method. -/// -/// Has smart defaults for everything; use builder methods to override. -#[cfg(feature = "tempo")] -pub struct TempoBuilder { - pub(crate) currency: String, - pub(crate) currency_explicit: bool, - pub(crate) recipient: String, - pub(crate) rpc_url: String, - pub(crate) realm: String, - pub(crate) secret_key: Option, - pub(crate) decimals: u32, - pub(crate) fee_payer: bool, - pub(crate) chain_id: Option, - pub(crate) fee_payer_signer: Option, -} - -#[cfg(feature = "tempo")] -impl TempoBuilder { - /// Override the RPC URL (default: `https://rpc.tempo.xyz`). - /// - /// Also auto-detects the chain ID from the URL if not explicitly set: - /// - URLs containing "moderato" → chain ID 42431 (Tempo Moderato testnet) - /// - Otherwise → chain ID 4217 (Tempo mainnet) - pub fn rpc_url(mut self, url: &str) -> Self { - self.rpc_url = url.to_string(); - if self.chain_id.is_none() { - self.chain_id = Some(chain_id_from_rpc_url(url)); - } - self - } - - /// Explicitly set the chain ID for challenges. - pub fn chain_id(mut self, id: u64) -> Self { - self.chain_id = Some(id); - self - } - - /// Override the token currency (default: USDC on mainnet, pathUSD on testnet). - pub fn currency(mut self, addr: &str) -> Self { - self.currency = addr.to_string(); - self.currency_explicit = true; - self - } - - /// Override the realm (default: auto-detected from environment variables). - pub fn realm(mut self, realm: &str) -> Self { - self.realm = realm.to_string(); - self - } - - /// Override the secret key (default: reads `MPP_SECRET_KEY` env var). - pub fn secret_key(mut self, key: &str) -> Self { - self.secret_key = Some(key.to_string()); - self - } - - /// Override the token decimals (default: `6`). - pub fn decimals(mut self, d: u32) -> Self { - self.decimals = d; - self - } - - /// Enable fee sponsorship for all challenges (default: `false`). - /// - /// When enabled, all charge and session challenges will include - /// `feePayer: true` in their `methodDetails`. You should also call - /// [`fee_payer_signer`](Self::fee_payer_signer) to provide the signer - /// that will sponsor transaction fees. - pub fn fee_payer(mut self, enabled: bool) -> Self { - self.fee_payer = enabled; - self - } - - /// Set the signer used for fee sponsorship. - /// - /// When clients send transactions with `feePayer: true`, the server - /// uses this signer to co-sign and sponsor the transaction gas fees. - /// The signer's account must have sufficient balance for gas. - pub fn fee_payer_signer(mut self, signer: alloy::signers::local::PrivateKeySigner) -> Self { - self.fee_payer_signer = Some(signer); - self - } -} +// ==================== Shared Types ==================== /// Options for [`Mpp::session_challenge_with_details()`]. #[derive(Debug, Default)] @@ -177,104 +90,3 @@ pub struct ChargeOptions<'a> { /// Enable fee sponsorship. pub fee_payer: bool, } - -/// Create a Tempo payment method configuration with smart defaults. -/// -/// Only `currency` and `recipient` are required. Returns a [`TempoBuilder`] -/// that can be passed to [`Mpp::create()`]. -/// -/// # Defaults -/// -/// - **rpc_url**: `https://rpc.tempo.xyz` -/// - **realm**: auto-detected from `MPP_REALM`, `FLY_APP_NAME`, `HEROKU_APP_NAME`, -/// `HOST`, `HOSTNAME`, `RAILWAY_PUBLIC_DOMAIN`, `RENDER_EXTERNAL_HOSTNAME`, -/// `VERCEL_URL`, `WEBSITE_HOSTNAME` — falling back to `"MPP Payment"` -/// - **secret_key**: reads `MPP_SECRET_KEY` env var; required if not explicitly set -/// - **currency**: pathUSD (`0x20c0000000000000000000000000000000000000`) -/// - **decimals**: `6` (for pathUSD / standard stablecoins) -/// - **expires**: `now + 5 minutes` -/// -/// # Example -/// -/// ```ignore -/// use mpp::server::{Mpp, tempo, TempoConfig}; -/// -/// // Minimal — currency defaults to pathUSD -/// let mpp = Mpp::create(tempo(TempoConfig { -/// recipient: "0xabc...123", -/// }))?; -/// -/// // With overrides -/// let mpp = Mpp::create( -/// tempo(TempoConfig { -/// recipient: "0xabc...123", -/// }) -/// .currency("0xcustom_token_address") -/// .rpc_url("https://rpc.moderato.tempo.xyz") -/// .realm("my-api.com") -/// .secret_key("my-secret") -/// .decimals(18), -/// )?; -/// ``` -#[cfg(feature = "tempo")] -pub fn tempo(config: TempoConfig<'_>) -> TempoBuilder { - TempoBuilder { - currency: crate::protocol::methods::tempo::DEFAULT_CURRENCY_MAINNET.to_string(), - currency_explicit: false, - recipient: config.recipient.to_string(), - rpc_url: crate::protocol::methods::tempo::DEFAULT_RPC_URL.to_string(), - realm: mpp::detect_realm(), - secret_key: None, - decimals: 6, - fee_payer: false, - chain_id: None, - fee_payer_signer: None, - } -} - -/// Derive a chain ID from an RPC URL. -/// -/// Returns `MODERATO_CHAIN_ID` (42431) for URLs containing "moderato", -/// otherwise returns `CHAIN_ID` (4217). -#[cfg(feature = "tempo")] -fn chain_id_from_rpc_url(url: &str) -> u64 { - if url.contains("moderato") { - crate::protocol::methods::tempo::MODERATO_CHAIN_ID - } else { - crate::protocol::methods::tempo::CHAIN_ID - } -} - -// ==================== Advanced API ==================== - -/// Create a Tempo-compatible provider for server-side verification. -/// -/// This provider uses `TempoNetwork` which properly handles Tempo's -/// custom transaction type (0x76) and receipt format. -#[cfg(feature = "tempo")] -pub fn tempo_provider(rpc_url: &str) -> crate::error::Result { - use alloy::providers::ProviderBuilder; - use tempo_alloy::TempoNetwork; - - let url = rpc_url - .parse() - .map_err(|e| crate::error::MppError::InvalidConfig(format!("invalid RPC URL: {}", e)))?; - Ok(ProviderBuilder::new_with_network::().connect_http(url)) -} - -/// Type alias for the Tempo provider returned by [`tempo_provider`]. -#[cfg(feature = "tempo")] -pub type TempoProvider = alloy::providers::fillers::FillProvider< - alloy::providers::fillers::JoinFill< - alloy::providers::Identity, - alloy::providers::fillers::JoinFill< - alloy::providers::fillers::NonceFiller, - alloy::providers::fillers::JoinFill< - alloy::providers::fillers::GasFiller, - alloy::providers::fillers::ChainIdFiller, - >, - >, - >, - alloy::providers::RootProvider, - tempo_alloy::TempoNetwork, ->; diff --git a/src/server/mpp.rs b/src/server/mpp.rs index 5a60704..22d76e3 100644 --- a/src/server/mpp.rs +++ b/src/server/mpp.rs @@ -15,9 +15,9 @@ //! let challenge = mpp.charge("0.10")?; //! ``` -#[cfg(feature = "tempo")] +#[cfg(any(feature = "tempo", feature = "stripe"))] use crate::error::Result; -#[cfg(feature = "tempo")] +#[cfg(any(feature = "tempo", feature = "stripe"))] use crate::protocol::core::PaymentChallenge; use crate::protocol::core::{PaymentCredential, Receipt}; use crate::protocol::intents::ChargeRequest; @@ -662,6 +662,150 @@ impl Mpp> { } } +// ==================== Stripe charge helpers ==================== + +#[cfg(feature = "stripe")] +impl Mpp { + /// Generate a Stripe charge challenge for a dollar amount. + /// + /// Creates a `method=stripe`, `intent=charge` challenge with HMAC-bound ID. + pub fn stripe_charge(&self, amount: &str) -> Result { + self.stripe_charge_with_options(amount, super::StripeChargeOptions::default()) + } + + /// Generate a Stripe charge challenge with additional options. + /// + /// Accepts [`StripeChargeOptions`](super::StripeChargeOptions) for description, + /// external ID, expiration, and metadata. + pub fn stripe_charge_with_options( + &self, + amount: &str, + options: super::StripeChargeOptions<'_>, + ) -> Result { + use crate::protocol::core::Base64UrlJson; + use time::{Duration, OffsetDateTime}; + + use crate::protocol::methods::stripe::StripeMethodDetails; + + let base_units = super::parse_dollar_amount(amount, self.decimals)?; + let currency = self.currency.as_deref().unwrap_or("usd"); + + let details = StripeMethodDetails { + network_id: self.method.network_id().to_string(), + payment_method_types: self.method.payment_method_types().to_vec(), + metadata: options.metadata.cloned(), + }; + + let request = ChargeRequest { + amount: base_units, + currency: currency.to_string(), + description: options.description.map(|s| s.to_string()), + external_id: options.external_id.map(|s| s.to_string()), + method_details: Some(serde_json::to_value(&details).map_err(|e| { + crate::error::MppError::InvalidConfig(format!( + "failed to serialize methodDetails: {e}" + )) + })?), + ..Default::default() + }; + + let encoded_request = Base64UrlJson::from_typed(&request)?; + + let expires = if let Some(exp) = options.expires { + exp.to_string() + } else { + let expiry_time = OffsetDateTime::now_utc() + Duration::minutes(5); + expiry_time + .format(&time::format_description::well_known::Rfc3339) + .map_err(|e| { + crate::error::MppError::InvalidConfig(format!("failed to format expires: {e}")) + })? + }; + + let id = crate::protocol::core::compute_challenge_id( + &self.secret_key, + &self.realm, + crate::protocol::methods::stripe::METHOD_NAME, + crate::protocol::methods::stripe::INTENT_CHARGE, + encoded_request.raw(), + Some(&expires), + None, + None, + ); + + Ok(PaymentChallenge { + id, + realm: self.realm.clone(), + method: crate::protocol::methods::stripe::METHOD_NAME.into(), + intent: crate::protocol::methods::stripe::INTENT_CHARGE.into(), + request: encoded_request, + expires: Some(expires), + description: options.description.map(|s| s.to_string()), + digest: None, + opaque: None, + }) + } +} + +#[cfg(feature = "stripe")] +impl Mpp { + /// Create a Stripe payment handler from a [`StripeBuilder`](super::StripeBuilder). + /// + /// # Example + /// + /// ```ignore + /// use mpp::server::{Mpp, stripe, StripeConfig}; + /// + /// let mpp = Mpp::create_stripe(stripe(StripeConfig { + /// secret_key: "sk_test_...", + /// network_id: "internal", + /// payment_method_types: &["card"], + /// currency: "usd", + /// decimals: 2, + /// }) + /// .secret_key("my-hmac-secret"))?; + /// ``` + pub fn create_stripe(builder: super::StripeBuilder) -> Result { + let secret_key = builder + .hmac_secret_key + .or_else(|| std::env::var(SECRET_KEY_ENV_VAR).ok()) + .and_then(|value| { + if value.trim().is_empty() { + None + } else { + Some(value) + } + }) + .ok_or_else(|| { + crate::error::MppError::InvalidConfig(format!( + "Missing secret key. Set {} environment variable or pass .secret_key(...).", + SECRET_KEY_ENV_VAR + )) + })?; + + let mut method = crate::protocol::methods::stripe::method::ChargeMethod::new( + &builder.secret_key, + &builder.network_id, + builder.payment_method_types.clone(), + ); + if let Some(api_base) = builder.stripe_api_base { + method = method.with_api_base(api_base); + } + + Ok(Self { + method, + session_method: None, + realm: builder.realm, + secret_key, + currency: Some(builder.currency), + recipient: None, + decimals: builder.decimals as u32, + fee_payer: false, + chain_id: None, + }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -1725,4 +1869,131 @@ mod tests { let err = result.unwrap_err(); assert_eq!(err.code, Some(ErrorCode::Expired)); } + + // ==================== Stripe tests ==================== + + #[cfg(feature = "stripe")] + fn test_stripe_mpp() -> Mpp { + use crate::server::{stripe, StripeConfig}; + + Mpp::create_stripe( + stripe(StripeConfig { + secret_key: "sk_test_mock", + network_id: "test-net", + payment_method_types: &["card"], + currency: "usd", + decimals: 2, + }) + .secret_key("test-hmac-secret"), + ) + .expect("failed to create stripe mpp") + } + + #[cfg(feature = "stripe")] + #[test] + fn test_stripe_challenge_has_method_details() { + let mpp = test_stripe_mpp(); + let challenge = mpp.stripe_charge("1.00").unwrap(); + + let request: serde_json::Value = challenge.request.decode_value().expect("decode request"); + let details = &request["methodDetails"]; + assert_eq!(details["networkId"], "test-net"); + assert_eq!(details["paymentMethodTypes"], serde_json::json!(["card"])); + assert_eq!(challenge.method.as_str(), "stripe"); + assert_eq!(challenge.intent.as_str(), "charge"); + } + + #[cfg(feature = "stripe")] + #[test] + fn test_stripe_charge_with_options_description() { + use crate::server::StripeChargeOptions; + + let mpp = test_stripe_mpp(); + let challenge = mpp + .stripe_charge_with_options( + "0.50", + StripeChargeOptions { + description: Some("test desc"), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(challenge.description, Some("test desc".to_string())); + let request: serde_json::Value = challenge.request.decode_value().expect("decode request"); + assert_eq!(request["description"], "test desc"); + } + + #[cfg(feature = "stripe")] + #[test] + fn test_stripe_charge_with_options_external_id() { + use crate::server::StripeChargeOptions; + + let mpp = test_stripe_mpp(); + let challenge = mpp + .stripe_charge_with_options( + "0.50", + StripeChargeOptions { + external_id: Some("order-42"), + ..Default::default() + }, + ) + .unwrap(); + + let request: serde_json::Value = challenge.request.decode_value().expect("decode request"); + assert_eq!(request["externalId"], "order-42"); + } + + #[cfg(feature = "stripe")] + #[test] + fn test_stripe_charge_with_options_metadata() { + use crate::server::StripeChargeOptions; + + let mpp = test_stripe_mpp(); + let mut metadata = std::collections::HashMap::new(); + metadata.insert("key1".to_string(), "val1".to_string()); + + let challenge = mpp + .stripe_charge_with_options( + "0.50", + StripeChargeOptions { + metadata: Some(&metadata), + ..Default::default() + }, + ) + .unwrap(); + + let request: serde_json::Value = challenge.request.decode_value().expect("decode request"); + assert_eq!(request["methodDetails"]["metadata"]["key1"], "val1"); + } + + #[cfg(feature = "stripe")] + #[test] + fn test_stripe_charge_with_options_custom_expires() { + use crate::server::StripeChargeOptions; + + let mpp = test_stripe_mpp(); + let challenge = mpp + .stripe_charge_with_options( + "0.50", + StripeChargeOptions { + expires: Some("2099-01-01T00:00:00Z"), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(challenge.expires, Some("2099-01-01T00:00:00Z".to_string())); + } + + #[cfg(feature = "stripe")] + #[test] + fn test_stripe_charge_delegates_to_with_options() { + let mpp = test_stripe_mpp(); + let challenge = mpp.stripe_charge("0.10").unwrap(); + + let request: serde_json::Value = challenge.request.decode_value().expect("decode request"); + assert!(request["methodDetails"].is_object()); + assert!(challenge.description.is_none()); + } } diff --git a/src/server/stripe.rs b/src/server/stripe.rs new file mode 100644 index 0000000..72bad31 --- /dev/null +++ b/src/server/stripe.rs @@ -0,0 +1,104 @@ +//! Stripe payment method configuration and builder. + +use super::mpp::detect_realm; + +pub use crate::protocol::methods::stripe::method::ChargeMethod as StripeChargeMethod; +pub use crate::protocol::methods::stripe::{StripeCredentialPayload, StripeMethodDetails}; + +/// Configuration for the Stripe payment method. +/// +/// All fields are required for Stripe payment verification. +pub struct StripeConfig<'a> { + /// Stripe secret API key (e.g., `sk_test_...`). + pub secret_key: &'a str, + /// Stripe Business Network profile ID. + pub network_id: &'a str, + /// Accepted payment method types (e.g., `&["card"]`). + pub payment_method_types: &'a [&'a str], + /// Three-letter ISO currency code (e.g., "usd"). + pub currency: &'a str, + /// Token decimals for amount conversion (e.g., 2 for USD cents). + pub decimals: u8, +} + +/// Options for [`Mpp::stripe_charge_with_options()`](super::Mpp::stripe_charge_with_options). +#[derive(Debug, Default)] +pub struct StripeChargeOptions<'a> { + /// Human-readable description. + pub description: Option<&'a str>, + /// Merchant reference ID. + pub external_id: Option<&'a str>, + /// Custom expiration (ISO 8601). Default: now + 5 minutes. + pub expires: Option<&'a str>, + /// Optional metadata key-value pairs. + pub metadata: Option<&'a std::collections::HashMap>, +} + +/// Builder returned by [`stripe()`] for configuring a Stripe payment method. +pub struct StripeBuilder { + pub(crate) secret_key: String, + pub(crate) network_id: String, + pub(crate) payment_method_types: Vec, + pub(crate) currency: String, + pub(crate) decimals: u8, + pub(crate) realm: String, + pub(crate) hmac_secret_key: Option, + pub(crate) stripe_api_base: Option, +} + +impl StripeBuilder { + /// Override the realm (default: auto-detected from environment variables). + pub fn realm(mut self, realm: &str) -> Self { + self.realm = realm.to_string(); + self + } + + /// Override the HMAC secret key (default: reads `MPP_SECRET_KEY` env var). + pub fn secret_key(mut self, key: &str) -> Self { + self.hmac_secret_key = Some(key.to_string()); + self + } + + /// Override the Stripe API base URL (for testing with a mock server). + pub fn stripe_api_base(mut self, url: &str) -> Self { + self.stripe_api_base = Some(url.to_string()); + self + } +} + +/// Create a Stripe payment method configuration. +/// +/// Returns a [`StripeBuilder`] that can be passed to [`Mpp::create()`](super::Mpp::create). +/// +/// # Example +/// +/// ```ignore +/// use mpp::server::{Mpp, stripe, StripeConfig}; +/// +/// let mpp = Mpp::create( +/// stripe(StripeConfig { +/// secret_key: "sk_test_...", +/// network_id: "internal", +/// payment_method_types: &["card"], +/// currency: "usd", +/// decimals: 2, +/// }) +/// .secret_key("my-hmac-secret"), +/// )?; +/// ``` +pub fn stripe(config: StripeConfig<'_>) -> StripeBuilder { + StripeBuilder { + secret_key: config.secret_key.to_string(), + network_id: config.network_id.to_string(), + payment_method_types: config + .payment_method_types + .iter() + .map(|s| s.to_string()) + .collect(), + currency: config.currency.to_string(), + decimals: config.decimals, + realm: detect_realm(), + hmac_secret_key: None, + stripe_api_base: None, + } +} diff --git a/src/server/tempo.rs b/src/server/tempo.rs new file mode 100644 index 0000000..951a191 --- /dev/null +++ b/src/server/tempo.rs @@ -0,0 +1,198 @@ +//! Tempo payment method configuration and builder. + +use super::mpp::detect_realm; + +pub use crate::protocol::methods::tempo::session_method::{ + InMemoryChannelStore as SessionChannelStore, SessionMethod as TempoSessionMethod, + SessionMethodConfig, +}; +pub use crate::protocol::methods::tempo::ChargeMethod as TempoChargeMethod; +pub use crate::protocol::methods::tempo::{ + TempoChargeExt, TempoMethodDetails, CHAIN_ID, METHOD_NAME, +}; + +/// Configuration for the Tempo payment method. +/// +/// Only `recipient` is required. Everything else has smart defaults. +pub struct TempoConfig<'a> { + /// Recipient address for payments. + pub recipient: &'a str, +} + +/// Builder returned by [`tempo()`] for configuring a Tempo payment method. +/// +/// Has smart defaults for everything; use builder methods to override. +pub struct TempoBuilder { + pub(crate) currency: String, + pub(crate) currency_explicit: bool, + pub(crate) recipient: String, + pub(crate) rpc_url: String, + pub(crate) realm: String, + pub(crate) secret_key: Option, + pub(crate) decimals: u32, + pub(crate) fee_payer: bool, + pub(crate) chain_id: Option, + pub(crate) fee_payer_signer: Option, +} + +impl TempoBuilder { + /// Override the RPC URL (default: `https://rpc.tempo.xyz`). + /// + /// Also auto-detects the chain ID from the URL if not explicitly set: + /// - URLs containing "moderato" → chain ID 42431 (Tempo Moderato testnet) + /// - Otherwise → chain ID 4217 (Tempo mainnet) + pub fn rpc_url(mut self, url: &str) -> Self { + self.rpc_url = url.to_string(); + if self.chain_id.is_none() { + self.chain_id = Some(chain_id_from_rpc_url(url)); + } + self + } + + /// Explicitly set the chain ID for challenges. + pub fn chain_id(mut self, id: u64) -> Self { + self.chain_id = Some(id); + self + } + + /// Override the token currency (default: USDC on mainnet, pathUSD on testnet). + pub fn currency(mut self, addr: &str) -> Self { + self.currency = addr.to_string(); + self.currency_explicit = true; + self + } + + /// Override the realm (default: auto-detected from environment variables). + pub fn realm(mut self, realm: &str) -> Self { + self.realm = realm.to_string(); + self + } + + /// Override the secret key (default: reads `MPP_SECRET_KEY` env var). + pub fn secret_key(mut self, key: &str) -> Self { + self.secret_key = Some(key.to_string()); + self + } + + /// Override the token decimals (default: `6`). + pub fn decimals(mut self, d: u32) -> Self { + self.decimals = d; + self + } + + /// Enable fee sponsorship for all challenges (default: `false`). + /// + /// When enabled, all charge and session challenges will include + /// `feePayer: true` in their `methodDetails`. You should also call + /// [`fee_payer_signer`](Self::fee_payer_signer) to provide the signer + /// that will sponsor transaction fees. + pub fn fee_payer(mut self, enabled: bool) -> Self { + self.fee_payer = enabled; + self + } + + /// Set the signer used for fee sponsorship. + /// + /// When clients send transactions with `feePayer: true`, the server + /// uses this signer to co-sign and sponsor the transaction gas fees. + /// The signer's account must have sufficient balance for gas. + pub fn fee_payer_signer(mut self, signer: alloy::signers::local::PrivateKeySigner) -> Self { + self.fee_payer_signer = Some(signer); + self + } +} + +/// Create a Tempo payment method configuration with smart defaults. +/// +/// Only `currency` and `recipient` are required. Returns a [`TempoBuilder`] +/// that can be passed to [`Mpp::create()`](super::Mpp::create). +/// +/// # Defaults +/// +/// - **rpc_url**: `https://rpc.tempo.xyz` +/// - **realm**: auto-detected from `MPP_REALM`, `FLY_APP_NAME`, `HEROKU_APP_NAME`, +/// `HOST`, `HOSTNAME`, `RAILWAY_PUBLIC_DOMAIN`, `RENDER_EXTERNAL_HOSTNAME`, +/// `VERCEL_URL`, `WEBSITE_HOSTNAME` — falling back to `"MPP Payment"` +/// - **secret_key**: reads `MPP_SECRET_KEY` env var; required if not explicitly set +/// - **currency**: pathUSD (`0x20c0000000000000000000000000000000000000`) +/// - **decimals**: `6` (for pathUSD / standard stablecoins) +/// - **expires**: `now + 5 minutes` +/// +/// # Example +/// +/// ```ignore +/// use mpp::server::{Mpp, tempo, TempoConfig}; +/// +/// // Minimal — currency defaults to pathUSD +/// let mpp = Mpp::create(tempo(TempoConfig { +/// recipient: "0xabc...123", +/// }))?; +/// +/// // With overrides +/// let mpp = Mpp::create( +/// tempo(TempoConfig { +/// recipient: "0xabc...123", +/// }) +/// .currency("0xcustom_token_address") +/// .rpc_url("https://rpc.moderato.tempo.xyz") +/// .realm("my-api.com") +/// .secret_key("my-secret") +/// .decimals(18), +/// )?; +/// ``` +pub fn tempo(config: TempoConfig<'_>) -> TempoBuilder { + TempoBuilder { + currency: crate::protocol::methods::tempo::DEFAULT_CURRENCY_MAINNET.to_string(), + currency_explicit: false, + recipient: config.recipient.to_string(), + rpc_url: crate::protocol::methods::tempo::DEFAULT_RPC_URL.to_string(), + realm: detect_realm(), + secret_key: None, + decimals: 6, + fee_payer: false, + chain_id: None, + fee_payer_signer: None, + } +} + +/// Derive a chain ID from an RPC URL. +/// +/// Returns `MODERATO_CHAIN_ID` (42431) for URLs containing "moderato", +/// otherwise returns `CHAIN_ID` (4217). +fn chain_id_from_rpc_url(url: &str) -> u64 { + if url.contains("moderato") { + crate::protocol::methods::tempo::MODERATO_CHAIN_ID + } else { + crate::protocol::methods::tempo::CHAIN_ID + } +} + +/// Create a Tempo-compatible provider for server-side verification. +/// +/// This provider uses `TempoNetwork` which properly handles Tempo's +/// custom transaction type (0x76) and receipt format. +pub fn tempo_provider(rpc_url: &str) -> crate::error::Result { + use alloy::providers::ProviderBuilder; + use tempo_alloy::TempoNetwork; + + let url = rpc_url + .parse() + .map_err(|e| crate::error::MppError::InvalidConfig(format!("invalid RPC URL: {}", e)))?; + Ok(ProviderBuilder::new_with_network::().connect_http(url)) +} + +/// Type alias for the Tempo provider returned by [`tempo_provider`]. +pub type TempoProvider = alloy::providers::fillers::FillProvider< + alloy::providers::fillers::JoinFill< + alloy::providers::Identity, + alloy::providers::fillers::JoinFill< + alloy::providers::fillers::NonceFiller, + alloy::providers::fillers::JoinFill< + alloy::providers::fillers::GasFiller, + alloy::providers::fillers::ChainIdFiller, + >, + >, + >, + alloy::providers::RootProvider, + tempo_alloy::TempoNetwork, +>; diff --git a/tests/integration_stripe.rs b/tests/integration_stripe.rs new file mode 100644 index 0000000..c5647ce --- /dev/null +++ b/tests/integration_stripe.rs @@ -0,0 +1,906 @@ +//! Integration tests for the MPP Stripe charge flow. +//! +//! Unlike Tempo integration tests which require a live blockchain, Stripe tests +//! use a mock Stripe API server. The mock validates that the server sends the +//! correct PaymentIntent creation request (SPT, amount, currency, confirm=true) +//! and returns a succeeded PaymentIntent. +//! +//! # Running +//! +//! ```bash +//! cargo test --features integration-stripe --test integration_stripe +//! ``` + +#![cfg(feature = "integration-stripe")] + +use std::sync::Arc; + +use axum::extract::Form; +use axum::{routing::get, Json, Router}; +use mpp::client::{Fetch, StripeProvider}; +use mpp::protocol::core::PaymentCredential; +use mpp::protocol::methods::stripe::{CreateTokenResult, StripeCredentialPayload}; +use mpp::server::axum::{ChargeChallenger, ChargeConfig, MppCharge}; +use mpp::server::{stripe, Mpp, StripeChargeOptions, StripeConfig}; +use reqwest::Client; + +// ==================== Mock Stripe API ==================== + +/// Start a mock Stripe API server that accepts `POST /v1/payment_intents` +/// and returns a succeeded PaymentIntent. +async fn start_mock_stripe() -> (String, tokio::task::JoinHandle<()>) { + let app = Router::new().route( + "/v1/payment_intents", + axum::routing::post(mock_create_payment_intent), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind mock stripe"); + let addr = listener.local_addr().unwrap(); + let url = format!("http://127.0.0.1:{}", addr.port()); + + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.expect("mock stripe error"); + }); + + (url, handle) +} + +/// Mock Stripe PaymentIntent creation handler. +/// +/// Validates the request has required fields and returns a succeeded PI. +async fn mock_create_payment_intent( + Form(params): Form>, +) -> Json { + // Validate required fields match the mppx server implementation + assert!( + params.contains_key("shared_payment_granted_token"), + "missing shared_payment_granted_token" + ); + assert!(params.contains_key("amount"), "missing amount"); + assert!(params.contains_key("currency"), "missing currency"); + assert_eq!( + params.get("confirm").map(|s| s.as_str()), + Some("true"), + "confirm must be true" + ); + assert_eq!( + params + .get("automatic_payment_methods[enabled]") + .map(|s| s.as_str()), + Some("true"), + "automatic_payment_methods must be enabled" + ); + + Json(serde_json::json!({ + "id": format!("pi_mock_{}", params.get("shared_payment_granted_token").unwrap()), + "status": "succeeded", + "amount": params["amount"], + "currency": params["currency"], + })) +} + +/// Mock Stripe API that returns `requires_action` (simulates 3DS). +async fn start_mock_stripe_requires_action() -> (String, tokio::task::JoinHandle<()>) { + let app = Router::new().route( + "/v1/payment_intents", + axum::routing::post(|| async { + Json(serde_json::json!({ + "id": "pi_requires_action", + "status": "requires_action", + })) + }), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind"); + let addr = listener.local_addr().unwrap(); + let url = format!("http://127.0.0.1:{}", addr.port()); + + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + (url, handle) +} + +// ==================== MPP Server Helpers ==================== + +/// Start an axum server with a Stripe-backed Mpp instance. +async fn start_server( + mpp: Arc>, +) -> (String, tokio::task::JoinHandle<()>) { + let app = Router::new() + .route("/health", get(health)) + .route("/paid", get(paid)) + .route("/paid-premium", get(paid_premium)) + .with_state(mpp); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind"); + let addr = listener.local_addr().unwrap(); + let url = format!("http://127.0.0.1:{}", addr.port()); + + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.expect("server error"); + }); + + (url, handle) +} + +async fn health() -> Json { + Json(serde_json::json!({ "status": "ok" })) +} + +/// Mock Stripe API that returns a 400 error response. +async fn start_mock_stripe_error() -> (String, tokio::task::JoinHandle<()>) { + let app = Router::new().route( + "/v1/payment_intents", + axum::routing::post(|| async { + ( + axum::http::StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": { + "message": "Invalid payment token", + "type": "invalid_request_error", + "code": "resource_missing" + } + })), + ) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind"); + let addr = listener.local_addr().unwrap(); + let url = format!("http://127.0.0.1:{}", addr.port()); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (url, handle) +} + +/// Paid endpoint: issues a Stripe charge challenge and verifies credentials. +/// +/// Uses the raw Mpp API since the axum `MppCharge` extractor is Tempo-specific. +async fn paid( + axum::extract::State(mpp): axum::extract::State< + Arc>, + >, + req: axum::extract::Request, +) -> axum::response::Response { + use axum::response::IntoResponse; + + let auth_header = req + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + let issue_challenge = || { + let challenge = mpp.stripe_charge("0.10").expect("challenge creation"); + let www_auth = challenge.to_header().expect("format challenge"); + let mut resp = axum::http::StatusCode::PAYMENT_REQUIRED.into_response(); + resp.headers_mut().insert( + "www-authenticate", + www_auth.parse().expect("www-auth header value"), + ); + resp + }; + + match auth_header { + Some(auth) => { + let credential = match mpp::parse_authorization(&auth) { + Ok(c) => c, + Err(_) => return issue_challenge(), + }; + + match mpp.verify_credential(&credential).await { + Ok(receipt) => { + let body = serde_json::json!({ "message": "paid content" }); + let mut resp = axum::response::Json(body).into_response(); + let receipt_hdr = receipt.to_header().expect("format receipt"); + resp.headers_mut().insert( + "payment-receipt", + receipt_hdr.parse().expect("receipt header value"), + ); + resp + } + Err(_) => issue_challenge(), + } + } + None => issue_challenge(), + } +} + +/// Premium paid endpoint: uses `stripe_charge_with_options` with description. +async fn paid_premium( + axum::extract::State(mpp): axum::extract::State< + Arc>, + >, + req: axum::extract::Request, +) -> axum::response::Response { + use axum::response::IntoResponse; + + let auth_header = req + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + let issue_challenge = || { + let challenge = mpp + .stripe_charge_with_options( + "1.00", + StripeChargeOptions { + description: Some("Premium content"), + external_id: Some("premium-001"), + ..Default::default() + }, + ) + .expect("challenge creation"); + let www_auth = challenge.to_header().expect("format challenge"); + let mut resp = axum::http::StatusCode::PAYMENT_REQUIRED.into_response(); + resp.headers_mut().insert( + "www-authenticate", + www_auth.parse().expect("www-auth header value"), + ); + resp + }; + + match auth_header { + Some(auth) => match mpp::parse_authorization(&auth) { + Ok(credential) => match mpp.verify_credential(&credential).await { + Ok(receipt) => { + let body = serde_json::json!({ "message": "premium content" }); + let mut resp = axum::response::Json(body).into_response(); + let receipt_hdr = receipt.to_header().expect("format receipt"); + resp.headers_mut().insert( + "payment-receipt", + receipt_hdr.parse().expect("receipt header value"), + ); + resp + } + Err(_) => issue_challenge(), + }, + Err(_) => issue_challenge(), + }, + None => issue_challenge(), + } +} + +struct TenCents; +impl ChargeConfig for TenCents { + fn amount() -> &'static str { + "0.10" + } +} + +async fn paid_extractor(charge: MppCharge) -> Json { + Json(serde_json::json!({ + "message": "paid via extractor", + "method": charge.receipt.method.as_str(), + })) +} + +/// Start an axum server with a Stripe-backed Mpp instance using the MppCharge extractor. +async fn start_server_with_extractor( + mpp: Arc>, +) -> (String, tokio::task::JoinHandle<()>) { + let challenger: Arc = mpp; + let app = Router::new() + .route("/paid-extractor", get(paid_extractor)) + .with_state(challenger); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind"); + let addr = listener.local_addr().unwrap(); + let url = format!("http://127.0.0.1:{}", addr.port()); + + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.expect("server error"); + }); + + (url, handle) +} + +// ==================== Tests ==================== + +/// Full e2e: client hits paid endpoint → gets 402 with method=stripe → +/// creates SPT via callback → sends credential with SPT → server verifies +/// by calling (mock) Stripe API → returns 200 + Payment-Receipt. +#[tokio::test] +async fn test_e2e_stripe_charge() { + let (stripe_url, stripe_handle) = start_mock_stripe().await; + + let mpp = Mpp::create_stripe( + stripe(StripeConfig { + secret_key: "sk_test_mock_key", + network_id: "internal", + payment_method_types: &["card"], + currency: "usd", + decimals: 2, + }) + .stripe_api_base(&stripe_url) + .secret_key("test-hmac-secret"), + ) + .expect("failed to create Mpp"); + + let mpp = Arc::new(mpp); + let (url, handle) = start_server(mpp).await; + + // Create client provider with a mock createToken callback + let provider = StripeProvider::new(|_params| { + Box::pin(async move { + Ok(CreateTokenResult::from( + "spt_mock_test_token_123".to_string(), + )) + }) + }); + + let resp = Client::new() + .get(format!("{url}/paid")) + .send_with_payment(&provider) + .await + .expect("stripe payment failed"); + + assert_eq!(resp.status(), 200); + + // Verify receipt header + let receipt_hdr = resp + .headers() + .get("payment-receipt") + .expect("missing Payment-Receipt header") + .to_str() + .unwrap(); + let receipt = mpp::parse_receipt(receipt_hdr).expect("failed to parse receipt"); + assert_eq!(receipt.method.as_str(), "stripe"); + assert_eq!(receipt.status, mpp::ReceiptStatus::Success); + assert!( + receipt.reference.starts_with("pi_mock_"), + "receipt reference should be a mock PI id, got: {}", + receipt.reference + ); + + // Verify response body + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["message"], "paid content"); + + handle.abort(); + stripe_handle.abort(); +} + +/// 402 challenge should advertise method=stripe with correct fields. +#[tokio::test] +async fn test_stripe_402_challenge_format() { + let (stripe_url, stripe_handle) = start_mock_stripe().await; + + let mpp = Mpp::create_stripe( + stripe(StripeConfig { + secret_key: "sk_test_mock", + network_id: "test-network", + payment_method_types: &["card"], + currency: "usd", + decimals: 2, + }) + .stripe_api_base(&stripe_url) + .secret_key("test-secret"), + ) + .expect("failed to create Mpp"); + + let mpp = Arc::new(mpp); + let (url, handle) = start_server(mpp).await; + + // Hit without auth → expect 402 + let resp = Client::new() + .get(format!("{url}/paid")) + .send() + .await + .expect("request failed"); + + assert_eq!(resp.status(), 402); + + let www_auth = resp + .headers() + .get("www-authenticate") + .expect("missing WWW-Authenticate header") + .to_str() + .unwrap(); + assert!( + www_auth.starts_with("Payment "), + "WWW-Authenticate should start with 'Payment ', got: {www_auth}" + ); + + let challenge = mpp::parse_www_authenticate(www_auth).expect("failed to parse challenge"); + assert_eq!(challenge.method.as_str(), "stripe"); + assert_eq!(challenge.intent.as_str(), "charge"); + + // Verify request fields match the Stripe schema + let request: serde_json::Value = challenge + .request + .decode_value() + .expect("failed to decode request"); + assert!(request["amount"].is_string(), "amount should be a string"); + assert_eq!(request["currency"], "usd"); + + handle.abort(); + stripe_handle.abort(); +} + +/// Health endpoint works without payment. +#[tokio::test] +async fn test_stripe_health_no_payment() { + let (stripe_url, stripe_handle) = start_mock_stripe().await; + + let mpp = Mpp::create_stripe( + stripe(StripeConfig { + secret_key: "sk_test_mock", + network_id: "internal", + payment_method_types: &["card"], + currency: "usd", + decimals: 2, + }) + .stripe_api_base(&stripe_url) + .secret_key("test-secret"), + ) + .expect("failed to create Mpp"); + + let mpp = Arc::new(mpp); + let (url, handle) = start_server(mpp).await; + + let resp = Client::new() + .get(format!("{url}/health")) + .send() + .await + .expect("request failed"); + + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["status"], "ok"); + + handle.abort(); + stripe_handle.abort(); +} + +/// Stripe API returning `requires_action` should fail verification. +#[tokio::test] +async fn test_stripe_requires_action_rejected() { + let (stripe_url, stripe_handle) = start_mock_stripe_requires_action().await; + + let mpp = Mpp::create_stripe( + stripe(StripeConfig { + secret_key: "sk_test_mock", + network_id: "internal", + payment_method_types: &["card"], + currency: "usd", + decimals: 2, + }) + .stripe_api_base(&stripe_url) + .secret_key("test-secret"), + ) + .expect("failed to create Mpp"); + + let mpp = Arc::new(mpp); + let (url, handle) = start_server(mpp).await; + + let provider = StripeProvider::new(|_params| { + Box::pin(async move { + Ok(CreateTokenResult::from( + "spt_will_require_action".to_string(), + )) + }) + }); + + // The client will get a 402, create a credential, but the server + // verification will fail because Stripe returns requires_action. + // The middleware retries once and then returns the 402. + let resp = Client::new() + .get(format!("{url}/paid")) + .send_with_payment(&provider) + .await; + + if let Ok(r) = resp { + assert_eq!( + r.status(), + 402, + "should get 402 when Stripe requires action" + ); + } + + handle.abort(); + stripe_handle.abort(); +} + +/// 402 challenge should include `methodDetails` with `networkId` and `paymentMethodTypes`. +#[tokio::test] +async fn test_stripe_challenge_contains_method_details() { + let (stripe_url, stripe_handle) = start_mock_stripe().await; + + let mpp = Mpp::create_stripe( + stripe(StripeConfig { + secret_key: "sk_test_mock", + network_id: "test-network-id", + payment_method_types: &["card"], + currency: "usd", + decimals: 2, + }) + .stripe_api_base(&stripe_url) + .secret_key("test-secret"), + ) + .expect("failed to create Mpp"); + + let mpp = Arc::new(mpp); + let (url, handle) = start_server(mpp).await; + + let resp = Client::new() + .get(format!("{url}/paid")) + .send() + .await + .expect("request failed"); + + assert_eq!(resp.status(), 402); + + let www_auth = resp + .headers() + .get("www-authenticate") + .expect("missing WWW-Authenticate header") + .to_str() + .unwrap(); + + let challenge = mpp::parse_www_authenticate(www_auth).expect("failed to parse challenge"); + let request: serde_json::Value = challenge + .request + .decode_value() + .expect("failed to decode request"); + + assert_eq!( + request["methodDetails"]["networkId"], "test-network-id", + "methodDetails.networkId should match configured network_id" + ); + assert_eq!( + request["methodDetails"]["paymentMethodTypes"], + serde_json::json!(["card"]), + "methodDetails.paymentMethodTypes should be [\"card\"]" + ); + + handle.abort(); + stripe_handle.abort(); +} + +/// e2e test for `stripe_charge_with_options` with description and external_id. +#[tokio::test] +async fn test_e2e_stripe_charge_with_description() { + let (stripe_url, stripe_handle) = start_mock_stripe().await; + + let mpp = Mpp::create_stripe( + stripe(StripeConfig { + secret_key: "sk_test_mock", + network_id: "test-net", + payment_method_types: &["card"], + currency: "usd", + decimals: 2, + }) + .stripe_api_base(&stripe_url) + .secret_key("test-secret"), + ) + .expect("failed to create Mpp"); + + let mpp = Arc::new(mpp); + let (url, handle) = start_server(mpp).await; + + // Check 402 has description + let resp = Client::new() + .get(format!("{url}/paid-premium")) + .send() + .await + .expect("request failed"); + assert_eq!(resp.status(), 402); + + let www_auth = resp + .headers() + .get("www-authenticate") + .expect("missing header") + .to_str() + .unwrap(); + assert!( + www_auth.contains("description="), + "challenge should contain description" + ); + + let challenge = mpp::parse_www_authenticate(www_auth).expect("failed to parse"); + assert_eq!(challenge.description.as_deref(), Some("Premium content")); + + let request: serde_json::Value = challenge + .request + .decode_value() + .expect("failed to decode request"); + assert_eq!(request["description"], "Premium content"); + assert_eq!(request["externalId"], "premium-001"); + + // Also verify full e2e with payment + let provider = StripeProvider::new(|_params| { + Box::pin(async move { Ok(CreateTokenResult::from("spt_premium_token".to_string())) }) + }); + + let resp = Client::new() + .get(format!("{url}/paid-premium")) + .send_with_payment(&provider) + .await + .expect("payment failed"); + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["message"], "premium content"); + + handle.abort(); + stripe_handle.abort(); +} + +/// Stripe API returning an error body should result in failed verification. +#[tokio::test] +async fn test_stripe_error_body_parsing() { + let (stripe_url, stripe_handle) = start_mock_stripe_error().await; + + let mpp = Mpp::create_stripe( + stripe(StripeConfig { + secret_key: "sk_test_mock", + network_id: "internal", + payment_method_types: &["card"], + currency: "usd", + decimals: 2, + }) + .stripe_api_base(&stripe_url) + .secret_key("test-secret"), + ) + .expect("create mpp"); + + let mpp = Arc::new(mpp); + let (url, handle) = start_server(mpp).await; + + let provider = StripeProvider::new(|_| { + Box::pin(async move { Ok(CreateTokenResult::from("spt_bad_token".to_string())) }) + }); + + let resp = Client::new() + .get(format!("{url}/paid")) + .send_with_payment(&provider) + .await; + + // Should get 402 back (server verification failed, re-issues challenge) + if let Ok(r) = resp { + assert_eq!(r.status(), 402, "should get 402 when Stripe returns error"); + } + + handle.abort(); + stripe_handle.abort(); +} + +/// The `MppCharge` extractor works with Stripe's `ChargeChallenger` impl. +#[tokio::test] +async fn test_stripe_charge_via_mpp_charge_extractor() { + let (stripe_url, stripe_handle) = start_mock_stripe().await; + + let mpp = Mpp::create_stripe( + stripe(StripeConfig { + secret_key: "sk_test_mock", + network_id: "extractor-net", + payment_method_types: &["card"], + currency: "usd", + decimals: 2, + }) + .stripe_api_base(&stripe_url) + .secret_key("test-secret"), + ) + .expect("failed to create Mpp"); + + let mpp = Arc::new(mpp); + let (url, handle) = start_server_with_extractor(mpp).await; + + // Without auth → expect 402 challenge + let resp = Client::new() + .get(format!("{url}/paid-extractor")) + .send() + .await + .expect("request failed"); + assert_eq!(resp.status(), 402); + + let www_auth = resp + .headers() + .get("www-authenticate") + .expect("missing WWW-Authenticate header") + .to_str() + .unwrap(); + let challenge = mpp::parse_www_authenticate(www_auth).expect("failed to parse challenge"); + assert_eq!(challenge.method.as_str(), "stripe"); + assert_eq!(challenge.intent.as_str(), "charge"); + + // With payment → expect 200 + let provider = StripeProvider::new(|_params| { + Box::pin(async move { Ok(CreateTokenResult::from("spt_extractor_token".to_string())) }) + }); + + let resp = Client::new() + .get(format!("{url}/paid-extractor")) + .send_with_payment(&provider) + .await + .expect("payment failed"); + assert_eq!(resp.status(), 200); + + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["message"], "paid via extractor"); + assert_eq!(body["method"], "stripe"); + + handle.abort(); + stripe_handle.abort(); +} + +// ==================== Live Stripe API Tests ==================== +// +// These tests call the real Stripe test-mode API. Skipped at runtime +// when STRIPE_SECRET_KEY is not set (same pattern as mppx). + +fn stripe_secret_key() -> Option { + std::env::var("STRIPE_SECRET_KEY") + .ok() + .filter(|s| !s.is_empty()) +} + +/// Create a test SPT via Stripe's test helper endpoint. +async fn create_test_spt( + secret_key: &str, + amount: &str, + currency: &str, + network_id: Option<&str>, + expires_at: u64, +) -> Result { + let base_params = vec![ + ("payment_method".to_string(), "pm_card_visa".to_string()), + ("usage_limits[currency]".to_string(), currency.to_string()), + ("usage_limits[max_amount]".to_string(), amount.to_string()), + ( + "usage_limits[expires_at]".to_string(), + expires_at.to_string(), + ), + ]; + + let mut params = base_params.clone(); + if let Some(nid) = network_id { + params.push(("seller_details[network_id]".to_string(), nid.to_string())); + } + + let auth = format!( + "Basic {}", + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + format!("{secret_key}:") + ) + ); + + let client = Client::new(); + let url = "https://api.stripe.com/v1/test_helpers/shared_payment/granted_tokens"; + + let response = client + .post(url) + .header("Authorization", &auth) + .form(¶ms) + .send() + .await + .map_err(|e| format!("request failed: {e}"))?; + + // Fallback: retry without seller_details if Stripe rejects it + // (matches mppx's fallback in Charge.integration.test.ts). + let response = if !response.status().is_success() && network_id.is_some() { + let body = response.text().await.unwrap_or_default(); + if body.contains("Received unknown parameter") { + client + .post(url) + .header("Authorization", &auth) + .form(&base_params) + .send() + .await + .map_err(|e| format!("fallback request failed: {e}"))? + } else { + return Err(format!("Stripe SPT creation failed: {body}")); + } + } else { + response + }; + + if !response.status().is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(format!("Stripe SPT creation failed: {body}")); + } + + let body: serde_json::Value = response + .json() + .await + .map_err(|e| format!("parse error: {e}"))?; + + body["id"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "missing id in SPT response".to_string()) +} + +fn create_live_mpp(secret_key: &str) -> Mpp { + Mpp::create_stripe( + stripe(StripeConfig { + secret_key, + network_id: "internal", + payment_method_types: &["card"], + currency: "usd", + decimals: 2, + }) + .secret_key("live-test-hmac-secret"), + ) + .expect("failed to create Mpp") +} + +/// Live: create a real SPT, build credential, verify against Stripe. +#[tokio::test] +async fn test_live_stripe_charge_success() { + let Some(sk) = stripe_secret_key() else { + eprintln!("STRIPE_SECRET_KEY not set, skipping"); + return; + }; + + let mpp = create_live_mpp(&sk); + let challenge = mpp.stripe_charge("0.50").expect("challenge creation"); + + let request: serde_json::Value = challenge.request.decode_value().expect("decode request"); + let amount = request["amount"].as_str().expect("amount"); + assert_eq!(amount, "50"); + + let expires_at = (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs()) + + 3600; + + let spt = create_test_spt(&sk, amount, "usd", Some("internal"), expires_at) + .await + .expect("SPT creation failed"); + assert!(spt.starts_with("spt_"), "got: {spt}"); + + let credential = PaymentCredential::new( + challenge.to_echo(), + StripeCredentialPayload { + spt, + external_id: None, + }, + ); + + let receipt = mpp + .verify_credential(&credential) + .await + .expect("verification failed"); + assert!(receipt.is_success()); + assert_eq!(receipt.method.as_str(), "stripe"); + assert!(receipt.reference.starts_with("pi_")); +} + +/// Live: invalid SPT should be rejected by Stripe. +#[tokio::test] +async fn test_live_stripe_invalid_spt_rejected() { + let Some(sk) = stripe_secret_key() else { + eprintln!("STRIPE_SECRET_KEY not set, skipping"); + return; + }; + + let mpp = create_live_mpp(&sk); + let challenge = mpp.stripe_charge("0.10").expect("challenge creation"); + + let credential = PaymentCredential::new( + challenge.to_echo(), + StripeCredentialPayload { + spt: "spt_invalid_does_not_exist".to_string(), + external_id: None, + }, + ); + + let result = mpp.verify_credential(&credential).await; + assert!(result.is_err(), "invalid SPT should fail verification"); +}