From 2acc05dded758f10db00a3778e0e354519c884fd Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Mon, 23 Mar 2026 05:48:31 +0000 Subject: [PATCH 01/15] feat: scaffold Stripe payment method support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the initial implementation for Stripe charge method (TOOLS-322): - src/protocol/methods/stripe/ — types (StripeChargeRequest, StripeCredentialPayload) and server-side ChargeMethod that verifies payments by creating a Stripe PaymentIntent with an SPT - src/client/stripe/ — StripeProvider implementing PaymentProvider with a user-provided createToken callback - src/server/ — StripeBuilder, StripeConfig, stripe() builder fn, Mpp::create_stripe(), Mpp::stripe_charge() - tests/integration_stripe.rs — e2e tests against a mock Stripe API (full 402 flow, challenge format, requires_action rejection) - Cargo.toml — stripe and integration-stripe feature flags Co-Authored-By: grandizzy <38490174+grandizzy@users.noreply.github.com> --- Cargo.toml | 2 + src/client/mod.rs | 6 + src/client/stripe/mod.rs | 8 + src/client/stripe/provider.rs | 180 ++++++++++++ src/protocol/methods/mod.rs | 13 +- src/protocol/methods/stripe/method.rs | 244 ++++++++++++++++ src/protocol/methods/stripe/mod.rs | 38 +++ src/protocol/methods/stripe/types.rs | 113 ++++++++ src/server/mod.rs | 95 +++++++ src/server/mpp.rs | 117 +++++++- tests/integration_stripe.rs | 389 ++++++++++++++++++++++++++ 11 files changed, 1200 insertions(+), 5 deletions(-) create mode 100644 src/client/stripe/mod.rs create mode 100644 src/client/stripe/provider.rs create mode 100644 src/protocol/methods/stripe/method.rs create mode 100644 src/protocol/methods/stripe/mod.rs create mode 100644 src/protocol/methods/stripe/types.rs create mode 100644 tests/integration_stripe.rs diff --git a/Cargo.toml b/Cargo.toml index 8aa6aa45..5a7291a6 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/src/client/mod.rs b/src/client/mod.rs index fd554323..3a0d8884 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 00000000..0cb8f9c4 --- /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 00000000..5eba3ea8 --- /dev/null +++ b/src/client/stripe/provider.rs @@ -0,0 +1,180 @@ +//! Stripe payment provider for client-side credential creation. +//! +//! The provider handles the `method="stripe"`, `intent="charge"` flow by +//! delegating SPT creation to a user-provided async callback. + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use crate::client::PaymentProvider; +use crate::error::MppError; +use crate::protocol::core::{PaymentChallenge, PaymentCredential}; +use crate::protocol::methods::stripe::{StripeCredentialPayload, METHOD_NAME}; + +/// Parameters passed to the `create_token` callback. +#[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. + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +/// Trait alias for the SPT creation callback. +pub trait CreateTokenFn: + Fn(CreateTokenParams) -> Pin> + Send>> + + Send + + Sync +{ +} + +impl CreateTokenFn for F where + F: Fn(CreateTokenParams) -> Pin> + Send>> + + Send + + Sync +{ +} + +/// 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; +/// +/// 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? +/// .json::().await?; +/// Ok(resp["spt"].as_str().unwrap().to_string()) +/// }) +/// }); +/// ``` +#[derive(Clone)] +pub struct StripeProvider { + create_token: Arc, + external_id: Option, +} + +impl StripeProvider { + /// Create a new Stripe provider with the given SPT creation callback. + pub fn new(create_token: F) -> Self + where + F: Fn(CreateTokenParams) -> Pin> + Send>> + + Send + + Sync + + 'static, + { + Self { + create_token: Arc::new(create_token), + external_id: None, + } + } + + /// Set an external reference ID to include in credential payloads. + pub fn with_external_id(mut self, id: impl Into) -> Self { + self.external_id = Some(id.into()); + self + } +} + +impl PaymentProvider for StripeProvider { + fn supports(&self, method: &str, intent: &str) -> bool { + method == METHOD_NAME && intent == "charge" + } + + async fn pay(&self, challenge: &PaymentChallenge) -> Result { + let request: serde_json::Value = + challenge + .request + .decode_value() + .map_err(|e| MppError::InvalidChallenge { + id: None, + reason: Some(format!("Failed to decode challenge request: {e}")), + })?; + + let amount = request["amount"] + .as_str() + .ok_or_else(|| MppError::InvalidChallenge { + id: None, + reason: Some("Missing amount in challenge".into()), + })? + .to_string(); + let currency = request["currency"] + .as_str() + .ok_or_else(|| MppError::InvalidChallenge { + id: None, + reason: Some("Missing currency in challenge".into()), + })? + .to_string(); + + let network_id = request + .get("methodDetails") + .and_then(|md| md.get("networkId")) + .or_else(|| request.get("networkId")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + 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, + currency, + network_id, + expires_at, + metadata: None, + }; + + let spt = (self.create_token)(params).await?; + + let payload = StripeCredentialPayload { + spt, + external_id: self.external_id.clone(), + }; + + let echo = challenge.to_echo(); + Ok(PaymentCredential::new(echo, payload)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_supports() { + let provider = StripeProvider::new(|_| Box::pin(async { Ok("spt_test".to_string()) })); + + assert!(provider.supports("stripe", "charge")); + assert!(!provider.supports("tempo", "charge")); + assert!(!provider.supports("stripe", "session")); + } +} diff --git a/src/protocol/methods/mod.rs b/src/protocol/methods/mod.rs index 6c4f502b..5f3c2253 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 00000000..9ee0f2e2 --- /dev/null +++ b/src/protocol/methods/stripe/method.rs @@ -0,0 +1,244 @@ +//! 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::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; +use super::{DEFAULT_STRIPE_API_BASE, METHOD_NAME}; + +/// 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: Option<&std::collections::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()), + ]; + + if let Some(meta) = metadata { + for (key, value) in meta { + 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() { + return Err(VerificationError::new( + "Stripe PaymentIntent creation failed", + )); + } + + let body: serde_json::Value = response + .json() + .await + .map_err(|e| VerificationError::new(format!("Failed to parse Stripe response: {e}")))?; + + let id = body["id"] + .as_str() + .ok_or_else(|| VerificationError::new("Missing id in Stripe response"))? + .to_string(); + let status = body["status"] + .as_str() + .ok_or_else(|| VerificationError::new("Missing status in Stripe response"))? + .to_string(); + + Ok((id, status)) + } +} + +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; + + // Check expiry + if let Some(ref expires) = challenge.expires { + if let Ok(expires_at) = time::OffsetDateTime::parse( + expires, + &time::format_description::well_known::Rfc3339, + ) { + if expires_at <= time::OffsetDateTime::now_utc() { + return Err(VerificationError::expired(format!( + "Challenge expired at {expires}" + ))); + } + } + } + + // 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}")) + })?; + + 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, + None, + ) + .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 00000000..96c228b8 --- /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::{StripeChargeRequest, StripeCredentialPayload}; + +/// Payment method name for Stripe. +pub const METHOD_NAME: &str = "stripe"; + +/// Charge intent name. +pub const INTENT_CHARGE: &str = "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 00000000..859c6a20 --- /dev/null +++ b/src/protocol/methods/stripe/types.rs @@ -0,0 +1,113 @@ +//! Stripe-specific types for MPP. + +use serde::{Deserialize, Serialize}; + +/// Stripe-specific charge request fields. +/// +/// These fields are included in the challenge's `request` object +/// alongside the standard `amount`, `currency`, and `recipient` fields +/// from [`ChargeRequest`](crate::protocol::intents::ChargeRequest). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StripeChargeRequest { + /// Payment amount in smallest currency unit. + pub amount: String, + + /// Three-letter ISO currency code (e.g., "usd"). + pub currency: String, + + /// Token decimals for amount conversion. + pub decimals: u8, + + /// 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, + + /// Human-readable description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Merchant reference ID. + #[serde(rename = "externalId", skip_serializing_if = "Option::is_none")] + pub external_id: Option, + + /// Optional metadata key-value pairs. + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, + + /// Recipient address. + #[serde(skip_serializing_if = "Option::is_none")] + pub recipient: Option, +} + +/// Client credential payload for Stripe charge. +/// +/// The client sends this after creating an SPT via the server-proxied +/// Stripe API endpoint. +#[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, +} + +#[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_charge_request_serde() { + let req = StripeChargeRequest { + amount: "1000".to_string(), + currency: "usd".to_string(), + decimals: 2, + network_id: "internal".to_string(), + payment_method_types: vec!["card".to_string()], + description: Some("Test charge".to_string()), + external_id: None, + metadata: None, + recipient: None, + }; + + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"networkId\":\"internal\"")); + assert!(json.contains("\"paymentMethodTypes\":[\"card\"]")); + + let parsed: StripeChargeRequest = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.network_id, "internal"); + assert_eq!(parsed.payment_method_types, vec!["card"]); + } +} diff --git a/src/server/mod.rs b/src/server/mod.rs index f939e60c..2bda56e2 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -54,6 +54,12 @@ pub use crate::protocol::methods::tempo::session_method::{ SessionMethodConfig, }; +#[cfg(feature = "stripe")] +pub use crate::protocol::methods::stripe::method::ChargeMethod as StripeChargeMethod; + +#[cfg(feature = "stripe")] +pub use crate::protocol::methods::stripe::{StripeChargeRequest, StripeCredentialPayload}; + // ==================== Simple API ==================== /// Configuration for the Tempo payment method. @@ -150,6 +156,23 @@ impl TempoBuilder { } } +/// Configuration for the Stripe payment method. +/// +/// All fields are required for Stripe payment verification. +#[cfg(feature = "stripe")] +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::session_challenge_with_details()`]. #[derive(Debug, Default)] pub struct SessionChallengeOptions<'a> { @@ -178,6 +201,40 @@ pub struct ChargeOptions<'a> { pub fee_payer: bool, } +/// Builder returned by [`stripe()`] for configuring a Stripe payment method. +#[cfg(feature = "stripe")] +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, +} + +#[cfg(feature = "stripe")] +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 Tempo payment method configuration with smart defaults. /// /// Only `currency` and `recipient` are required. Returns a [`TempoBuilder`] @@ -245,6 +302,44 @@ fn chain_id_from_rpc_url(url: &str) -> u64 { } } +/// Create a Stripe payment method configuration. +/// +/// Returns a [`StripeBuilder`] that can be passed to [`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"), +/// )?; +/// ``` +#[cfg(feature = "stripe")] +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: mpp::detect_realm(), + hmac_secret_key: None, + stripe_api_base: None, + } +} + // ==================== Advanced API ==================== /// Create a Tempo-compatible provider for server-side verification. diff --git a/src/server/mpp.rs b/src/server/mpp.rs index 5a60704a..4bc8f4ac 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,119 @@ 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 { + use crate::protocol::core::Base64UrlJson; + use time::{Duration, OffsetDateTime}; + + let base_units = super::parse_dollar_amount(amount, self.decimals)?; + let currency = self.currency.as_deref().unwrap_or("usd"); + + let request = ChargeRequest { + amount: base_units, + currency: currency.to_string(), + ..Default::default() + }; + + let encoded_request = Base64UrlJson::from_typed(&request)?; + + let expiry_time = OffsetDateTime::now_utc() + Duration::minutes(5); + let expires = 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: None, + 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::*; diff --git a/tests/integration_stripe.rs b/tests/integration_stripe.rs new file mode 100644 index 00000000..b1df004c --- /dev/null +++ b/tests/integration_stripe.rs @@ -0,0 +1,389 @@ +//! 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::server::{stripe, Mpp, 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)) + .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" })) +} + +/// 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(), + } +} + +// ==================== 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("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("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; + + match resp { + Ok(r) => { + assert_eq!( + r.status(), + 402, + "should get 402 when Stripe requires action" + ); + } + Err(_) => { + // Also acceptable — client may error out after failed retry + } + } + + handle.abort(); + stripe_handle.abort(); +} From 864356c443e8a80560a9e434a25781746b04e965 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Mar 2026 05:49:16 +0000 Subject: [PATCH 02/15] chore: add changelog --- .changelog/unique-lakes-roll.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changelog/unique-lakes-roll.md diff --git a/.changelog/unique-lakes-roll.md b/.changelog/unique-lakes-roll.md new file mode 100644 index 00000000..93ab3027 --- /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`. From 01f5e62742b49d497e083f0e6dea418189be8b99 Mon Sep 17 00:00:00 2001 From: grandizzy Date: Mon, 23 Mar 2026 09:07:24 +0200 Subject: [PATCH 03/15] feat: complete Stripe method implementation - Add spec-required methodDetails (networkId, paymentMethodTypes) to challenges - Add StripeChargeOptions (description, external_id, expires, metadata) - Add ChargeChallenger impl so MppCharge extractor works with Stripe - Parse Stripe error response bodies for better error messages - Add Stripe unit and integration tests Amp-Thread-ID: https://ampcode.com/threads/T-019d1969-811c-7725-aaa6-1e0116d5652c Co-authored-by: Amp --- src/protocol/methods/stripe/method.rs | 12 +- src/server/axum.rs | 42 ++++ src/server/mod.rs | 14 ++ src/server/mpp.rs | 173 ++++++++++++- tests/integration_stripe.rs | 345 +++++++++++++++++++++++++- 5 files changed, 575 insertions(+), 11 deletions(-) diff --git a/src/protocol/methods/stripe/method.rs b/src/protocol/methods/stripe/method.rs index 9ee0f2e2..edb06e1d 100644 --- a/src/protocol/methods/stripe/method.rs +++ b/src/protocol/methods/stripe/method.rs @@ -123,9 +123,15 @@ impl ChargeMethod { })?; if !response.status().is_success() { - return Err(VerificationError::new( - "Stripe PaymentIntent creation failed", - )); + 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 body: serde_json::Value = response diff --git a/src/server/axum.rs b/src/server/axum.rs index 8c5a54bf..fdebe323 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 2bda56e2..9a27902f 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -201,6 +201,20 @@ pub struct ChargeOptions<'a> { pub fee_payer: bool, } +/// Options for [`Mpp::stripe_charge_with_options()`]. +#[cfg(feature = "stripe")] +#[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. #[cfg(feature = "stripe")] pub struct StripeBuilder { diff --git a/src/server/mpp.rs b/src/server/mpp.rs index 4bc8f4ac..f59c8f02 100644 --- a/src/server/mpp.rs +++ b/src/server/mpp.rs @@ -670,26 +670,58 @@ impl Mpp { /// /// 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}; let base_units = super::parse_dollar_amount(amount, self.decimals)?; let currency = self.currency.as_deref().unwrap_or("usd"); + let mut details = serde_json::Map::new(); + details.insert( + "networkId".into(), + serde_json::json!(self.method.network_id()), + ); + details.insert( + "paymentMethodTypes".into(), + serde_json::json!(self.method.payment_method_types()), + ); + if let Some(metadata) = options.metadata { + details.insert("metadata".into(), serde_json::json!(metadata)); + } + 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::Value::Object(details)), ..Default::default() }; let encoded_request = Base64UrlJson::from_typed(&request)?; - let expiry_time = OffsetDateTime::now_utc() + Duration::minutes(5); - let expires = expiry_time - .format(&time::format_description::well_known::Rfc3339) - .map_err(|e| { - crate::error::MppError::InvalidConfig(format!("failed to format expires: {e}")) - })?; + 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, @@ -709,7 +741,7 @@ impl Mpp { intent: crate::protocol::methods::stripe::INTENT_CHARGE.into(), request: encoded_request, expires: Some(expires), - description: None, + description: options.description.map(|s| s.to_string()), digest: None, opaque: None, }) @@ -1838,4 +1870,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/tests/integration_stripe.rs b/tests/integration_stripe.rs index b1df004c..76f6a723 100644 --- a/tests/integration_stripe.rs +++ b/tests/integration_stripe.rs @@ -18,7 +18,8 @@ use std::sync::Arc; use axum::extract::Form; use axum::{routing::get, Json, Router}; use mpp::client::{Fetch, StripeProvider}; -use mpp::server::{stripe, Mpp, StripeConfig}; +use mpp::server::axum::{ChargeChallenger, ChargeConfig, MppCharge}; +use mpp::server::{stripe, Mpp, StripeChargeOptions, StripeConfig}; use reqwest::Client; // ==================== Mock Stripe API ==================== @@ -112,6 +113,7 @@ async fn start_server( 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") @@ -131,6 +133,34 @@ 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. @@ -184,6 +214,98 @@ async fn paid( } } +/// 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 → @@ -387,3 +509,224 @@ async fn test_stripe_requires_action_rejected() { 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("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("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) + match resp { + Ok(r) => assert_eq!(r.status(), 402, "should get 402 when Stripe returns error"), + Err(_) => {} // client error also acceptable + } + + 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("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(); +} From 789ddb92ad016f6269df361a749d7a90a86e979b Mon Sep 17 00:00:00 2001 From: grandizzy Date: Mon, 23 Mar 2026 09:08:49 +0200 Subject: [PATCH 04/15] Run stripe integration tests in CI --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 875b74a5..b4d895ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ 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 + - run: cargo test --features tempo,stripe,server,client,axum,middleware,tower,utils,integration-stripe - 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 {} \; From 49173c8cc3d6fb55b0a54c3bae0042f6be633a09 Mon Sep 17 00:00:00 2001 From: grandizzy Date: Mon, 23 Mar 2026 09:21:11 +0200 Subject: [PATCH 05/15] docs: add Stripe examples to README Amp-Thread-ID: https://ampcode.com/threads/T-019d1969-811c-7725-aaa6-1e0116d5652c Co-authored-by: Amp --- README.md | 46 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index aac83a81..927ee44c 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,28 @@ 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}; + +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(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 +108,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 +117,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 From dc734624e2943962c543498dcfa85aaf77704789 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Mon, 23 Mar 2026 07:43:11 +0000 Subject: [PATCH 06/15] fix: align Stripe method with mppx TS SDK wire format - Replace StripeChargeRequest with StripeMethodDetails (correct wire shape: networkId/paymentMethodTypes/metadata nested under methodDetails) - stripe_charge() now emits methodDetails with networkId and paymentMethodTypes from config (fixes challenge schema bug) - Add stripe_charge_with_options() + StripeChargeOptions - Server verify() propagates metadata: analytics keys (mpp_version, mpp_is_mpp, mpp_intent, mpp_challenge_id, mpp_server_id, mpp_client_id) + user metadata from methodDetails - Callback returns CreateTokenResult { spt, external_id } instead of plain String (per-payment externalId support) - Provider extracts metadata from methodDetails.metadata and passes challenge JSON to callback - Remove CreateTokenFn trait alias; use plain generic F: Fn(...) - Provider reads networkId from methodDetails (not top-level) Co-Authored-By: grandizzy <38490174+grandizzy@users.noreply.github.com> --- src/client/stripe/provider.rs | 87 +++++++++++++------------ src/protocol/methods/stripe/method.rs | 38 +++++++++-- src/protocol/methods/stripe/mod.rs | 2 +- src/protocol/methods/stripe/types.rs | 91 ++++++++++++++------------- src/server/mod.rs | 2 +- tests/integration_stripe.rs | 25 +++++--- 6 files changed, 145 insertions(+), 100 deletions(-) diff --git a/src/client/stripe/provider.rs b/src/client/stripe/provider.rs index 5eba3ea8..6e60d642 100644 --- a/src/client/stripe/provider.rs +++ b/src/client/stripe/provider.rs @@ -1,7 +1,4 @@ //! Stripe payment provider for client-side credential creation. -//! -//! The provider handles the `method="stripe"`, `intent="charge"` flow by -//! delegating SPT creation to a user-provided async callback. use std::future::Future; use std::pin::Pin; @@ -10,9 +7,12 @@ use std::sync::Arc; use crate::client::PaymentProvider; use crate::error::MppError; use crate::protocol::core::{PaymentChallenge, PaymentCredential}; +use crate::protocol::methods::stripe::types::CreateTokenResult; use crate::protocol::methods::stripe::{StripeCredentialPayload, 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. @@ -23,24 +23,12 @@ pub struct CreateTokenParams { pub network_id: String, /// SPT expiration as Unix timestamp (seconds). pub expires_at: u64, - /// Optional metadata. + /// Optional metadata from the challenge's methodDetails. #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option>, -} - -/// Trait alias for the SPT creation callback. -pub trait CreateTokenFn: - Fn(CreateTokenParams) -> Pin> + Send>> - + Send - + Sync -{ -} - -impl CreateTokenFn for F where - F: Fn(CreateTokenParams) -> Pin> + Send>> - + Send - + Sync -{ + /// The full challenge as JSON, for advanced use cases. + #[serde(skip)] + pub challenge: serde_json::Value, } /// Client-side Stripe payment provider. @@ -52,44 +40,54 @@ impl CreateTokenFn for F where /// /// ```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? -/// .json::().await?; -/// Ok(resp["spt"].as_str().unwrap().to_string()) +/// .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, +/// }) /// }) /// }); /// ``` #[derive(Clone)] pub struct StripeProvider { - create_token: Arc, - external_id: Option, + create_token: Arc< + dyn Fn( + CreateTokenParams, + ) + -> Pin> + Send>> + + Send + + Sync, + >, } 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>> + F: Fn( + CreateTokenParams, + ) + -> Pin> + Send>> + Send + Sync + 'static, { Self { create_token: Arc::new(create_token), - external_id: None, } } - - /// Set an external reference ID to include in credential payloads. - pub fn with_external_id(mut self, id: impl Into) -> Self { - self.external_id = Some(id.into()); - self - } } impl PaymentProvider for StripeProvider { @@ -122,14 +120,18 @@ impl PaymentProvider for StripeProvider { })? .to_string(); - let network_id = request - .get("methodDetails") + let method_details = request.get("methodDetails"); + + let network_id = method_details .and_then(|md| md.get("networkId")) - .or_else(|| request.get("networkId")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); + let metadata: Option> = method_details + .and_then(|md| md.get("metadata")) + .and_then(|m| serde_json::from_value(m.clone()).ok()); + let expires_at = challenge .expires .as_ref() @@ -145,19 +147,22 @@ impl PaymentProvider for StripeProvider { + 3600 }); + let challenge_json = serde_json::to_value(challenge).unwrap_or_default(); + let params = CreateTokenParams { amount, currency, network_id, expires_at, - metadata: None, + metadata, + challenge: challenge_json, }; - let spt = (self.create_token)(params).await?; + let result = (self.create_token)(params).await?; let payload = StripeCredentialPayload { - spt, - external_id: self.external_id.clone(), + spt: result.spt, + external_id: result.external_id, }; let echo = challenge.to_echo(); @@ -171,7 +176,9 @@ mod tests { #[test] fn test_supports() { - let provider = StripeProvider::new(|_| Box::pin(async { Ok("spt_test".to_string()) })); + let provider = StripeProvider::new(|_| { + Box::pin(async { Ok(CreateTokenResult::from("spt_test".to_string())) }) + }); assert!(provider.supports("stripe", "charge")); assert!(!provider.supports("tempo", "charge")); diff --git a/src/protocol/methods/stripe/method.rs b/src/protocol/methods/stripe/method.rs index edb06e1d..351c286e 100644 --- a/src/protocol/methods/stripe/method.rs +++ b/src/protocol/methods/stripe/method.rs @@ -13,6 +13,7 @@ //! let receipt = method.verify(&credential, &request).await?; //! ``` +use std::collections::HashMap; use std::future::Future; use crate::protocol::core::{PaymentCredential, Receipt}; @@ -75,7 +76,7 @@ impl ChargeMethod { amount: &str, currency: &str, idempotency_key: &str, - metadata: Option<&std::collections::HashMap>, + metadata: &HashMap, ) -> Result<(String, String), VerificationError> { let url = format!("{}/v1/payment_intents", self.api_base); @@ -94,10 +95,8 @@ impl ChargeMethod { ("shared_payment_granted_token".to_string(), spt.to_string()), ]; - if let Some(meta) = metadata { - for (key, value) in meta { - params.push((format!("metadata[{key}]"), value.clone())); - } + for (key, value) in metadata { + params.push((format!("metadata[{key}]"), value.clone())); } let client = reqwest::Client::new(); @@ -150,6 +149,21 @@ impl ChargeMethod { Ok((id, 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 { @@ -195,6 +209,18 @@ impl ChargeMethodTrait for ChargeMethod { VerificationError::new(format!("Failed to decode challenge request: {e}")) })?; + // Build metadata: analytics + user metadata from methodDetails + let mut metadata = Self::build_analytics(&credential); + if let Some(ref md) = charge_request.method_details { + if let Some(user_meta) = md.get("metadata").and_then(|m| m.as_object()) { + for (k, v) in user_meta { + if let Some(s) = v.as_str() { + metadata.insert(k.clone(), s.to_string()); + } + } + } + } + let idempotency_key = format!("mppx_{}_{}", challenge.id, payload.spt); let (pi_id, status) = this @@ -203,7 +229,7 @@ impl ChargeMethodTrait for ChargeMethod { &charge_request.amount, &charge_request.currency, &idempotency_key, - None, + &metadata, ) .await?; diff --git a/src/protocol/methods/stripe/mod.rs b/src/protocol/methods/stripe/mod.rs index 96c228b8..254376ea 100644 --- a/src/protocol/methods/stripe/mod.rs +++ b/src/protocol/methods/stripe/mod.rs @@ -26,7 +26,7 @@ pub mod types; #[cfg(feature = "server")] pub mod method; -pub use types::{StripeChargeRequest, StripeCredentialPayload}; +pub use types::{CreateTokenResult, StripeCredentialPayload, StripeMethodDetails}; /// Payment method name for Stripe. pub const METHOD_NAME: &str = "stripe"; diff --git a/src/protocol/methods/stripe/types.rs b/src/protocol/methods/stripe/types.rs index 859c6a20..3d9aadb1 100644 --- a/src/protocol/methods/stripe/types.rs +++ b/src/protocol/methods/stripe/types.rs @@ -2,22 +2,12 @@ use serde::{Deserialize, Serialize}; -/// Stripe-specific charge request fields. +/// Stripe-specific method details nested under `methodDetails` in the challenge request. /// -/// These fields are included in the challenge's `request` object -/// alongside the standard `amount`, `currency`, and `recipient` fields -/// from [`ChargeRequest`](crate::protocol::intents::ChargeRequest). +/// Matches the mppx wire format where `networkId`, `paymentMethodTypes`, and `metadata` +/// are nested inside the `methodDetails` field of the `ChargeRequest`. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StripeChargeRequest { - /// Payment amount in smallest currency unit. - pub amount: String, - - /// Three-letter ISO currency code (e.g., "usd"). - pub currency: String, - - /// Token decimals for amount conversion. - pub decimals: u8, - +pub struct StripeMethodDetails { /// Stripe Business Network profile ID. #[serde(rename = "networkId")] pub network_id: String, @@ -26,27 +16,12 @@ pub struct StripeChargeRequest { #[serde(rename = "paymentMethodTypes")] pub payment_method_types: Vec, - /// Human-readable description. - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - - /// Merchant reference ID. - #[serde(rename = "externalId", skip_serializing_if = "Option::is_none")] - pub external_id: Option, - /// Optional metadata key-value pairs. #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option>, - - /// Recipient address. - #[serde(skip_serializing_if = "Option::is_none")] - pub recipient: Option, } /// Client credential payload for Stripe charge. -/// -/// The client sends this after creating an SPT via the server-proxied -/// Stripe API endpoint. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StripeCredentialPayload { /// Shared Payment Token from Stripe. @@ -57,6 +32,24 @@ pub struct StripeCredentialPayload { 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::*; @@ -67,11 +60,9 @@ mod tests { 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")); @@ -83,31 +74,41 @@ mod tests { 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_charge_request_serde() { - let req = StripeChargeRequest { - amount: "1000".to_string(), - currency: "usd".to_string(), - decimals: 2, + fn test_stripe_method_details_serde() { + let details = StripeMethodDetails { network_id: "internal".to_string(), payment_method_types: vec!["card".to_string()], - description: Some("Test charge".to_string()), - external_id: None, metadata: None, - recipient: None, }; - - let json = serde_json::to_string(&req).unwrap(); + let json = serde_json::to_string(&details).unwrap(); assert!(json.contains("\"networkId\":\"internal\"")); assert!(json.contains("\"paymentMethodTypes\":[\"card\"]")); - - let parsed: StripeChargeRequest = serde_json::from_str(&json).unwrap(); + let parsed: StripeMethodDetails = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.network_id, "internal"); - assert_eq!(parsed.payment_method_types, vec!["card"]); + } + + #[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/server/mod.rs b/src/server/mod.rs index 9a27902f..6f98262d 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -58,7 +58,7 @@ pub use crate::protocol::methods::tempo::session_method::{ pub use crate::protocol::methods::stripe::method::ChargeMethod as StripeChargeMethod; #[cfg(feature = "stripe")] -pub use crate::protocol::methods::stripe::{StripeChargeRequest, StripeCredentialPayload}; +pub use crate::protocol::methods::stripe::{StripeCredentialPayload, StripeMethodDetails}; // ==================== Simple API ==================== diff --git a/tests/integration_stripe.rs b/tests/integration_stripe.rs index 76f6a723..b3a68f88 100644 --- a/tests/integration_stripe.rs +++ b/tests/integration_stripe.rs @@ -18,6 +18,7 @@ use std::sync::Arc; use axum::extract::Form; use axum::{routing::get, Json, Router}; use mpp::client::{Fetch, StripeProvider}; +use mpp::protocol::methods::stripe::CreateTokenResult; use mpp::server::axum::{ChargeChallenger, ChargeConfig, MppCharge}; use mpp::server::{stripe, Mpp, StripeChargeOptions, StripeConfig}; use reqwest::Client; @@ -333,7 +334,11 @@ async fn test_e2e_stripe_charge() { // Create client provider with a mock createToken callback let provider = StripeProvider::new(|_params| { - Box::pin(async move { Ok("spt_mock_test_token_123".to_string()) }) + Box::pin(async move { + Ok(CreateTokenResult::from( + "spt_mock_test_token_123".to_string(), + )) + }) }); let resp = Client::new() @@ -482,7 +487,11 @@ async fn test_stripe_requires_action_rejected() { let (url, handle) = start_server(mpp).await; let provider = StripeProvider::new(|_params| { - Box::pin(async move { Ok("spt_will_require_action".to_string()) }) + 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 @@ -617,8 +626,9 @@ async fn test_e2e_stripe_charge_with_description() { assert_eq!(request["externalId"], "premium-001"); // Also verify full e2e with payment - let provider = - StripeProvider::new(|_params| Box::pin(async move { Ok("spt_premium_token".to_string()) })); + 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")) @@ -654,8 +664,9 @@ async fn test_stripe_error_body_parsing() { let mpp = Arc::new(mpp); let (url, handle) = start_server(mpp).await; - let provider = - StripeProvider::new(|_| Box::pin(async move { Ok("spt_bad_token".to_string()) })); + let provider = StripeProvider::new(|_| { + Box::pin(async move { Ok(CreateTokenResult::from("spt_bad_token".to_string())) }) + }); let resp = Client::new() .get(format!("{url}/paid")) @@ -713,7 +724,7 @@ async fn test_stripe_charge_via_mpp_charge_extractor() { // With payment → expect 200 let provider = StripeProvider::new(|_params| { - Box::pin(async move { Ok("spt_extractor_token".to_string()) }) + Box::pin(async move { Ok(CreateTokenResult::from("spt_extractor_token".to_string())) }) }); let resp = Client::new() From 95af71ba81f4b4c0a020133b407ebeb87745402b Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Mon, 23 Mar 2026 07:47:47 +0000 Subject: [PATCH 07/15] fix: clippy lints and update README examples Co-Authored-By: grandizzy <38490174+grandizzy@users.noreply.github.com> --- README.md | 3 ++- src/client/stripe/provider.rs | 15 +++++++-------- tests/integration_stripe.rs | 22 ++++++++-------------- 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 927ee44c..b9712ed7 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ let resp = client.get("https://mpp.dev/api/ping/paid").send().await?; ```rust use mpp::client::{Fetch, StripeProvider}; +use mpp::protocol::methods::stripe::CreateTokenResult; let provider = StripeProvider::new(|params| { Box::pin(async move { @@ -91,7 +92,7 @@ let provider = StripeProvider::new(|params| { .post("https://my-server.com/api/create-spt") .json(¶ms) .send().await?.json::().await?; - Ok(resp["spt"].as_str().unwrap().to_string()) + Ok(CreateTokenResult::from(resp["spt"].as_str().unwrap().to_string())) }) }); diff --git a/src/client/stripe/provider.rs b/src/client/stripe/provider.rs index 6e60d642..b95640ec 100644 --- a/src/client/stripe/provider.rs +++ b/src/client/stripe/provider.rs @@ -57,16 +57,15 @@ pub struct CreateTokenParams { /// }) /// }); /// ``` +type CreateTokenFn = dyn Fn( + CreateTokenParams, + ) -> Pin> + Send>> + + Send + + Sync; + #[derive(Clone)] pub struct StripeProvider { - create_token: Arc< - dyn Fn( - CreateTokenParams, - ) - -> Pin> + Send>> - + Send - + Sync, - >, + create_token: Arc, } impl StripeProvider { diff --git a/tests/integration_stripe.rs b/tests/integration_stripe.rs index b3a68f88..58fa5b25 100644 --- a/tests/integration_stripe.rs +++ b/tests/integration_stripe.rs @@ -502,17 +502,12 @@ async fn test_stripe_requires_action_rejected() { .send_with_payment(&provider) .await; - match resp { - Ok(r) => { - assert_eq!( - r.status(), - 402, - "should get 402 when Stripe requires action" - ); - } - Err(_) => { - // Also acceptable — client may error out after failed retry - } + if let Ok(r) = resp { + assert_eq!( + r.status(), + 402, + "should get 402 when Stripe requires action" + ); } handle.abort(); @@ -674,9 +669,8 @@ async fn test_stripe_error_body_parsing() { .await; // Should get 402 back (server verification failed, re-issues challenge) - match resp { - Ok(r) => assert_eq!(r.status(), 402, "should get 402 when Stripe returns error"), - Err(_) => {} // client error also acceptable + if let Ok(r) = resp { + assert_eq!(r.status(), 402, "should get 402 when Stripe returns error"); } handle.abort(); From 80b1fea7e4f2909534e56ae4aadc802cb5713331 Mon Sep 17 00:00:00 2001 From: grandizzy Date: Mon, 23 Mar 2026 10:03:44 +0200 Subject: [PATCH 08/15] refactor: deduplicate INTENT_CHARGE/INTENT_SESSION constants into protocol::intents Amp-Thread-ID: https://ampcode.com/threads/T-019d1969-811c-7725-aaa6-1e0116d5652c Co-authored-by: Amp --- src/client/stripe/provider.rs | 4 ++-- src/protocol/intents/mod.rs | 6 ++++++ src/protocol/methods/stripe/mod.rs | 4 ++-- src/protocol/methods/tempo/mod.rs | 8 ++++---- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/client/stripe/provider.rs b/src/client/stripe/provider.rs index b95640ec..c8b198ff 100644 --- a/src/client/stripe/provider.rs +++ b/src/client/stripe/provider.rs @@ -8,7 +8,7 @@ use crate::client::PaymentProvider; use crate::error::MppError; use crate::protocol::core::{PaymentChallenge, PaymentCredential}; use crate::protocol::methods::stripe::types::CreateTokenResult; -use crate::protocol::methods::stripe::{StripeCredentialPayload, METHOD_NAME}; +use crate::protocol::methods::stripe::{StripeCredentialPayload, INTENT_CHARGE, METHOD_NAME}; /// Parameters passed to the `create_token` callback. /// @@ -91,7 +91,7 @@ impl StripeProvider { impl PaymentProvider for StripeProvider { fn supports(&self, method: &str, intent: &str) -> bool { - method == METHOD_NAME && intent == "charge" + method == METHOD_NAME && intent == INTENT_CHARGE } async fn pay(&self, challenge: &PaymentChallenge) -> Result { diff --git a/src/protocol/intents/mod.rs b/src/protocol/intents/mod.rs index 538e3a3b..c3df0d2e 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/stripe/mod.rs b/src/protocol/methods/stripe/mod.rs index 254376ea..3a676d18 100644 --- a/src/protocol/methods/stripe/mod.rs +++ b/src/protocol/methods/stripe/mod.rs @@ -31,8 +31,8 @@ pub use types::{CreateTokenResult, StripeCredentialPayload, StripeMethodDetails} /// Payment method name for Stripe. pub const METHOD_NAME: &str = "stripe"; -/// 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; /// Default Stripe API base URL. pub const DEFAULT_STRIPE_API_BASE: &str = "https://api.stripe.com"; diff --git a/src/protocol/methods/tempo/mod.rs b/src/protocol/methods/tempo/mod.rs index 313a3731..7c7558d0 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. /// From abe5748d3239ddedca049bd7c75dd20061409e94 Mon Sep 17 00:00:00 2001 From: grandizzy Date: Mon, 23 Mar 2026 10:23:16 +0200 Subject: [PATCH 09/15] refactor: use typed serde structs and ResultExt across Stripe code Amp-Thread-ID: https://ampcode.com/threads/T-019d1969-811c-7725-aaa6-1e0116d5652c Co-authored-by: Amp --- src/client/stripe/provider.rs | 73 +++++++++------------------ src/protocol/methods/stripe/method.rs | 54 ++++++++------------ src/protocol/methods/stripe/types.rs | 2 +- src/server/mpp.rs | 25 +++++---- 4 files changed, 59 insertions(+), 95 deletions(-) diff --git a/src/client/stripe/provider.rs b/src/client/stripe/provider.rs index c8b198ff..5c0e9d6d 100644 --- a/src/client/stripe/provider.rs +++ b/src/client/stripe/provider.rs @@ -5,10 +5,13 @@ use std::pin::Pin; use std::sync::Arc; use crate::client::PaymentProvider; -use crate::error::MppError; +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, INTENT_CHARGE, METHOD_NAME}; +use crate::protocol::methods::stripe::{ + StripeCredentialPayload, StripeMethodDetails, INTENT_CHARGE, METHOD_NAME, +}; /// Parameters passed to the `create_token` callback. /// @@ -95,41 +98,18 @@ impl PaymentProvider for StripeProvider { } async fn pay(&self, challenge: &PaymentChallenge) -> Result { - let request: serde_json::Value = - challenge - .request - .decode_value() - .map_err(|e| MppError::InvalidChallenge { - id: None, - reason: Some(format!("Failed to decode challenge request: {e}")), - })?; - - let amount = request["amount"] - .as_str() - .ok_or_else(|| MppError::InvalidChallenge { - id: None, - reason: Some("Missing amount in challenge".into()), - })? - .to_string(); - let currency = request["currency"] - .as_str() - .ok_or_else(|| MppError::InvalidChallenge { - id: None, - reason: Some("Missing currency in challenge".into()), - })? - .to_string(); - - let method_details = request.get("methodDetails"); - - let network_id = method_details - .and_then(|md| md.get("networkId")) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - let metadata: Option> = method_details - .and_then(|md| md.get("metadata")) - .and_then(|m| serde_json::from_value(m.clone()).ok()); + 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 @@ -139,22 +119,20 @@ impl PaymentProvider for StripeProvider { }) .map(|dt| dt.unix_timestamp() as u64) .unwrap_or_else(|| { - (std::time::SystemTime::now() + std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() - .as_secs()) + .as_secs() + 3600 }); - let challenge_json = serde_json::to_value(challenge).unwrap_or_default(); - let params = CreateTokenParams { - amount, - currency, - network_id, + amount: request.amount, + currency: request.currency, + network_id: details.network_id, expires_at, - metadata, - challenge: challenge_json, + metadata: details.metadata, + challenge: serde_json::to_value(challenge).unwrap_or_default(), }; let result = (self.create_token)(params).await?; @@ -164,8 +142,7 @@ impl PaymentProvider for StripeProvider { external_id: result.external_id, }; - let echo = challenge.to_echo(); - Ok(PaymentCredential::new(echo, payload)) + Ok(PaymentCredential::new(challenge.to_echo(), payload)) } } diff --git a/src/protocol/methods/stripe/method.rs b/src/protocol/methods/stripe/method.rs index 351c286e..b4d734c6 100644 --- a/src/protocol/methods/stripe/method.rs +++ b/src/protocol/methods/stripe/method.rs @@ -20,9 +20,16 @@ use crate::protocol::core::{PaymentCredential, Receipt}; use crate::protocol::intents::ChargeRequest; use crate::protocol::traits::{ChargeMethod as ChargeMethodTrait, VerificationError}; -use super::types::StripeCredentialPayload; +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 { @@ -133,21 +140,12 @@ impl ChargeMethod { ))); } - let body: serde_json::Value = response + let pi: PaymentIntentResponse = response .json() .await .map_err(|e| VerificationError::new(format!("Failed to parse Stripe response: {e}")))?; - let id = body["id"] - .as_str() - .ok_or_else(|| VerificationError::new("Missing id in Stripe response"))? - .to_string(); - let status = body["status"] - .as_str() - .ok_or_else(|| VerificationError::new("Missing status in Stripe response"))? - .to_string(); - - Ok((id, status)) + Ok((pi.id, pi.status)) } /// Build analytics metadata matching mppx's buildAnalytics(). @@ -190,19 +188,8 @@ impl ChargeMethodTrait for ChargeMethod { let challenge = &credential.challenge; - // Check expiry - if let Some(ref expires) = challenge.expires { - if let Ok(expires_at) = time::OffsetDateTime::parse( - expires, - &time::format_description::well_known::Rfc3339, - ) { - if expires_at <= time::OffsetDateTime::now_utc() { - return Err(VerificationError::expired(format!( - "Challenge expired at {expires}" - ))); - } - } - } + // 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| { @@ -211,14 +198,15 @@ impl ChargeMethodTrait for ChargeMethod { // Build metadata: analytics + user metadata from methodDetails let mut metadata = Self::build_analytics(&credential); - if let Some(ref md) = charge_request.method_details { - if let Some(user_meta) = md.get("metadata").and_then(|m| m.as_object()) { - for (k, v) in user_meta { - if let Some(s) = v.as_str() { - metadata.insert(k.clone(), s.to_string()); - } - } - } + 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); diff --git a/src/protocol/methods/stripe/types.rs b/src/protocol/methods/stripe/types.rs index 3d9aadb1..01944dfb 100644 --- a/src/protocol/methods/stripe/types.rs +++ b/src/protocol/methods/stripe/types.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; /// /// Matches the mppx wire format where `networkId`, `paymentMethodTypes`, and `metadata` /// are nested inside the `methodDetails` field of the `ChargeRequest`. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct StripeMethodDetails { /// Stripe Business Network profile ID. #[serde(rename = "networkId")] diff --git a/src/server/mpp.rs b/src/server/mpp.rs index f59c8f02..22d76e37 100644 --- a/src/server/mpp.rs +++ b/src/server/mpp.rs @@ -685,28 +685,27 @@ impl Mpp { 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 mut details = serde_json::Map::new(); - details.insert( - "networkId".into(), - serde_json::json!(self.method.network_id()), - ); - details.insert( - "paymentMethodTypes".into(), - serde_json::json!(self.method.payment_method_types()), - ); - if let Some(metadata) = options.metadata { - details.insert("metadata".into(), serde_json::json!(metadata)); - } + 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::Value::Object(details)), + method_details: Some(serde_json::to_value(&details).map_err(|e| { + crate::error::MppError::InvalidConfig(format!( + "failed to serialize methodDetails: {e}" + )) + })?), ..Default::default() }; From 7ae2af3e73845f0c1d81c2e91226c5cbddbcc548 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:30:03 +0000 Subject: [PATCH 10/15] feat: add live Stripe integration tests + CI support - tests/integration_stripe_live.rs: tests against real Stripe test-mode API using test_helpers/shared_payment/granted_tokens endpoint. Skipped when STRIPE_SECRET_KEY is not set. - CI runs live tests only when STRIPE_SECRET_KEY secret exists - Tests: happy path (real SPT + PaymentIntent), invalid SPT rejection, expired challenge rejection Co-Authored-By: grandizzy <38490174+grandizzy@users.noreply.github.com> --- .github/workflows/ci.yml | 7 +- Cargo.toml | 1 + tests/integration_stripe_live.rs | 236 +++++++++++++++++++++++++++++++ 3 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 tests/integration_stripe_live.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4d895ac..3471c3c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,12 @@ jobs: - run: cargo update -p native-tls - uses: taiki-e/install-action@cargo-hack - run: cargo test --features tempo,stripe,server,client,axum,middleware,tower,utils,integration-stripe - - run: cargo hack check --each-feature --no-dev-deps --skip integration + - name: Live Stripe integration tests + if: env.STRIPE_SECRET_KEY != '' + run: cargo test --features integration-stripe-live --test integration_stripe_live + env: + STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} + - run: cargo hack check --each-feature --no-dev-deps --skip integration,integration-stripe-live - name: Check examples run: find examples -name Cargo.toml -exec cargo check --manifest-path {} \; diff --git a/Cargo.toml b/Cargo.toml index 5a7291a6..ecc79f8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,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"] +integration-stripe-live = ["stripe", "server", "client"] [dependencies] # Core dependencies (always included) diff --git a/tests/integration_stripe_live.rs b/tests/integration_stripe_live.rs new file mode 100644 index 00000000..e66b2a44 --- /dev/null +++ b/tests/integration_stripe_live.rs @@ -0,0 +1,236 @@ +//! Live integration tests for the MPP Stripe charge flow. +//! +//! These tests call the real Stripe test-mode API to create SPTs and verify +//! PaymentIntents. They require a `STRIPE_SECRET_KEY` env var with a `sk_test_*` +//! key that has SPT (Shared Payment Tokens) access enabled. +//! +//! Skipped automatically when `STRIPE_SECRET_KEY` is not set. +//! +//! # Running +//! +//! ```bash +//! STRIPE_SECRET_KEY=sk_test_... cargo test --features integration-stripe-live --test integration_stripe_live +//! ``` + +#![cfg(feature = "integration-stripe-live")] + +use mpp::protocol::core::PaymentCredential; +use mpp::protocol::methods::stripe::method::ChargeMethod; +use mpp::protocol::methods::stripe::StripeCredentialPayload; +use mpp::server::{stripe, Mpp, StripeConfig}; + +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. +/// +/// This calls `POST /v1/test_helpers/shared_payment/granted_tokens` +/// which is only available in test mode and requires SPT access. +async fn create_test_spt( + secret_key: &str, + amount: &str, + currency: &str, + network_id: Option<&str>, + expires_at: u64, +) -> Result { + let mut 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(), + ), + ]; + + if let Some(nid) = network_id { + params.push(("seller_details[network_id]".to_string(), nid.to_string())); + } + + let client = reqwest::Client::new(); + let response = client + .post("https://api.stripe.com/v1/test_helpers/shared_payment/granted_tokens") + .header( + "Authorization", + format!( + "Basic {}", + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + format!("{secret_key}:") + ) + ), + ) + .form(¶ms) + .send() + .await + .map_err(|e| format!("request failed: {e}"))?; + + 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()) +} + +/// Helper: create Mpp instance for live Stripe tests. +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") +} + +// ==================== Tests ==================== + +/// Happy path: 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 live Stripe test"); + return; + }; + + let mpp = create_live_mpp(&sk); + + // Create challenge + let challenge = mpp.stripe_charge("0.50").expect("challenge creation"); + assert_eq!(challenge.method.as_str(), "stripe"); + assert_eq!(challenge.intent.as_str(), "charge"); + + // Decode request to get amount + let request: serde_json::Value = challenge + .request + .decode_value() + .expect("decode challenge request"); + let amount = request["amount"].as_str().expect("amount"); + assert_eq!(amount, "50"); // $0.50 with 2 decimals + + // Create SPT via Stripe test helper + 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_"), + "SPT should start with spt_, got: {spt}" + ); + + // Build credential + let payload = StripeCredentialPayload { + spt, + external_id: None, + }; + let credential = PaymentCredential::new(challenge.to_echo(), payload); + + // Verify against real Stripe + 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_"), + "receipt reference should be a PaymentIntent ID, got: {}", + receipt.reference + ); +} + +/// 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 live Stripe test"); + return; + }; + + let mpp = create_live_mpp(&sk); + let challenge = mpp.stripe_charge("0.10").expect("challenge creation"); + + let payload = StripeCredentialPayload { + spt: "spt_invalid_does_not_exist".to_string(), + external_id: None, + }; + let credential = PaymentCredential::new(challenge.to_echo(), payload); + + let result = mpp.verify_credential(&credential).await; + assert!(result.is_err(), "invalid SPT should fail verification"); +} + +/// Expired challenge should be rejected before calling Stripe. +#[tokio::test] +async fn test_live_stripe_expired_challenge_rejected() { + let Some(sk) = stripe_secret_key() else { + eprintln!("STRIPE_SECRET_KEY not set, skipping live Stripe test"); + return; + }; + + let mpp = create_live_mpp(&sk); + + // Create a challenge with past expiration + let challenge = mpp + .stripe_charge_with_options( + "0.10", + mpp::server::StripeChargeOptions { + description: None, + external_id: None, + expires: None, + metadata: None, + }, + ) + .expect("challenge creation"); + + // Manually build an expired credential by modifying the echo + let mut echo = challenge.to_echo(); + let past = (time::OffsetDateTime::now_utc() - time::Duration::minutes(10)) + .format(&time::format_description::well_known::Rfc3339) + .unwrap(); + echo.expires = Some(past.clone()); + + // Recompute challenge ID with expired timestamp so HMAC matches + let _expired_challenge = mpp + .stripe_charge("0.10") + .expect("challenge for expired test"); + + // Use a valid SPT format but the challenge should be rejected on expiry + let payload = StripeCredentialPayload { + spt: "spt_doesnt_matter".to_string(), + external_id: None, + }; + + // Build credential with the original (non-expired) challenge but + // we can't easily forge a valid HMAC for an expired challenge, + // so this tests the server-side expiry check path + let credential = PaymentCredential::new(echo, payload); + let result = mpp.verify_credential(&credential).await; + assert!( + result.is_err(), + "expired challenge should fail verification" + ); +} From 29dc990bc781267594b4f060a563b3c4fc523507 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:43:09 +0000 Subject: [PATCH 11/15] fix: live Stripe test fallback for seller_details + suppress dead_code warning - create_test_spt now retries without seller_details when Stripe returns 'Received unknown parameter' (matches mppx fallback) - Suppress dead_code warning on ResultExt trait Co-Authored-By: grandizzy <38490174+grandizzy@users.noreply.github.com> --- src/error.rs | 1 + tests/integration_stripe_live.rs | 46 +++++++++++++++++++++++--------- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/src/error.rs b/src/error.rs index 54b72852..c48f8abc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -289,6 +289,7 @@ pub enum MppError { // ==================== Result Extension Trait ==================== /// Extension trait for mapping errors into [`MppError`] with a contextual message. +#[allow(dead_code)] pub(crate) trait ResultExt { /// Map the error into [`MppError::Http`] with the given context prefix. fn mpp_http(self, context: &str) -> std::result::Result; diff --git a/tests/integration_stripe_live.rs b/tests/integration_stripe_live.rs index e66b2a44..53129785 100644 --- a/tests/integration_stripe_live.rs +++ b/tests/integration_stripe_live.rs @@ -36,7 +36,7 @@ async fn create_test_spt( network_id: Option<&str>, expires_at: u64, ) -> Result { - let mut params = vec![ + 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()), @@ -46,28 +46,50 @@ async fn create_test_spt( ), ]; + 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 = reqwest::Client::new(); + let url = "https://api.stripe.com/v1/test_helpers/shared_payment/granted_tokens"; + let response = client - .post("https://api.stripe.com/v1/test_helpers/shared_payment/granted_tokens") - .header( - "Authorization", - format!( - "Basic {}", - base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - format!("{secret_key}:") - ) - ), - ) + .post(url) + .header("Authorization", &auth) .form(¶ms) .send() .await .map_err(|e| format!("request failed: {e}"))?; + // Fallback: if Stripe rejects seller_details/metadata (not all accounts + // have these params enabled), retry with base params only. + // Matches mppx's fallback behavior 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}")); From 237c00e70c938e390adfd3d423891bdd5d41c084 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:51:21 +0000 Subject: [PATCH 12/15] refactor: merge live Stripe tests into integration_stripe, runtime-gated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove integration-stripe-live feature flag. Live tests now live in integration_stripe.rs and skip at runtime when STRIPE_SECRET_KEY is not set (matching mppx pattern). CI passes STRIPE_SECRET_KEY as env var to the test step. Revert #[allow(dead_code)] on ResultExt — pre-existing, not ours. Co-Authored-By: grandizzy <38490174+grandizzy@users.noreply.github.com> --- .github/workflows/ci.yml | 8 +- Cargo.toml | 1 - src/error.rs | 1 - tests/integration_stripe.rs | 171 +++++++++++++++++++- tests/integration_stripe_live.rs | 258 ------------------------------- 5 files changed, 173 insertions(+), 266 deletions(-) delete mode 100644 tests/integration_stripe_live.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3471c3c1..59fc6397 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,13 +51,11 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo update -p native-tls - uses: taiki-e/install-action@cargo-hack - - run: cargo test --features tempo,stripe,server,client,axum,middleware,tower,utils,integration-stripe - - name: Live Stripe integration tests - if: env.STRIPE_SECRET_KEY != '' - run: cargo test --features integration-stripe-live --test integration_stripe_live + - 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,integration-stripe-live + - 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 ecc79f8b..5a7291a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,6 @@ reqwest-rustls-tls = ["reqwest?/rustls-tls"] # Integration tests (requires a running Tempo localnet) integration = ["tempo", "server", "client", "axum"] integration-stripe = ["stripe", "server", "client", "axum"] -integration-stripe-live = ["stripe", "server", "client"] [dependencies] # Core dependencies (always included) diff --git a/src/error.rs b/src/error.rs index c48f8abc..54b72852 100644 --- a/src/error.rs +++ b/src/error.rs @@ -289,7 +289,6 @@ pub enum MppError { // ==================== Result Extension Trait ==================== /// Extension trait for mapping errors into [`MppError`] with a contextual message. -#[allow(dead_code)] pub(crate) trait ResultExt { /// Map the error into [`MppError::Http`] with the given context prefix. fn mpp_http(self, context: &str) -> std::result::Result; diff --git a/tests/integration_stripe.rs b/tests/integration_stripe.rs index 58fa5b25..c5647cea 100644 --- a/tests/integration_stripe.rs +++ b/tests/integration_stripe.rs @@ -18,7 +18,8 @@ use std::sync::Arc; use axum::extract::Form; use axum::{routing::get, Json, Router}; use mpp::client::{Fetch, StripeProvider}; -use mpp::protocol::methods::stripe::CreateTokenResult; +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; @@ -735,3 +736,171 @@ async fn test_stripe_charge_via_mpp_charge_extractor() { 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"); +} diff --git a/tests/integration_stripe_live.rs b/tests/integration_stripe_live.rs deleted file mode 100644 index 53129785..00000000 --- a/tests/integration_stripe_live.rs +++ /dev/null @@ -1,258 +0,0 @@ -//! Live integration tests for the MPP Stripe charge flow. -//! -//! These tests call the real Stripe test-mode API to create SPTs and verify -//! PaymentIntents. They require a `STRIPE_SECRET_KEY` env var with a `sk_test_*` -//! key that has SPT (Shared Payment Tokens) access enabled. -//! -//! Skipped automatically when `STRIPE_SECRET_KEY` is not set. -//! -//! # Running -//! -//! ```bash -//! STRIPE_SECRET_KEY=sk_test_... cargo test --features integration-stripe-live --test integration_stripe_live -//! ``` - -#![cfg(feature = "integration-stripe-live")] - -use mpp::protocol::core::PaymentCredential; -use mpp::protocol::methods::stripe::method::ChargeMethod; -use mpp::protocol::methods::stripe::StripeCredentialPayload; -use mpp::server::{stripe, Mpp, StripeConfig}; - -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. -/// -/// This calls `POST /v1/test_helpers/shared_payment/granted_tokens` -/// which is only available in test mode and requires SPT access. -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 = reqwest::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: if Stripe rejects seller_details/metadata (not all accounts - // have these params enabled), retry with base params only. - // Matches mppx's fallback behavior 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()) -} - -/// Helper: create Mpp instance for live Stripe tests. -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") -} - -// ==================== Tests ==================== - -/// Happy path: 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 live Stripe test"); - return; - }; - - let mpp = create_live_mpp(&sk); - - // Create challenge - let challenge = mpp.stripe_charge("0.50").expect("challenge creation"); - assert_eq!(challenge.method.as_str(), "stripe"); - assert_eq!(challenge.intent.as_str(), "charge"); - - // Decode request to get amount - let request: serde_json::Value = challenge - .request - .decode_value() - .expect("decode challenge request"); - let amount = request["amount"].as_str().expect("amount"); - assert_eq!(amount, "50"); // $0.50 with 2 decimals - - // Create SPT via Stripe test helper - 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_"), - "SPT should start with spt_, got: {spt}" - ); - - // Build credential - let payload = StripeCredentialPayload { - spt, - external_id: None, - }; - let credential = PaymentCredential::new(challenge.to_echo(), payload); - - // Verify against real Stripe - 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_"), - "receipt reference should be a PaymentIntent ID, got: {}", - receipt.reference - ); -} - -/// 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 live Stripe test"); - return; - }; - - let mpp = create_live_mpp(&sk); - let challenge = mpp.stripe_charge("0.10").expect("challenge creation"); - - let payload = StripeCredentialPayload { - spt: "spt_invalid_does_not_exist".to_string(), - external_id: None, - }; - let credential = PaymentCredential::new(challenge.to_echo(), payload); - - let result = mpp.verify_credential(&credential).await; - assert!(result.is_err(), "invalid SPT should fail verification"); -} - -/// Expired challenge should be rejected before calling Stripe. -#[tokio::test] -async fn test_live_stripe_expired_challenge_rejected() { - let Some(sk) = stripe_secret_key() else { - eprintln!("STRIPE_SECRET_KEY not set, skipping live Stripe test"); - return; - }; - - let mpp = create_live_mpp(&sk); - - // Create a challenge with past expiration - let challenge = mpp - .stripe_charge_with_options( - "0.10", - mpp::server::StripeChargeOptions { - description: None, - external_id: None, - expires: None, - metadata: None, - }, - ) - .expect("challenge creation"); - - // Manually build an expired credential by modifying the echo - let mut echo = challenge.to_echo(); - let past = (time::OffsetDateTime::now_utc() - time::Duration::minutes(10)) - .format(&time::format_description::well_known::Rfc3339) - .unwrap(); - echo.expires = Some(past.clone()); - - // Recompute challenge ID with expired timestamp so HMAC matches - let _expired_challenge = mpp - .stripe_charge("0.10") - .expect("challenge for expired test"); - - // Use a valid SPT format but the challenge should be rejected on expiry - let payload = StripeCredentialPayload { - spt: "spt_doesnt_matter".to_string(), - external_id: None, - }; - - // Build credential with the original (non-expired) challenge but - // we can't easily forge a valid HMAC for an expired challenge, - // so this tests the server-side expiry check path - let credential = PaymentCredential::new(echo, payload); - let result = mpp.verify_credential(&credential).await; - assert!( - result.is_err(), - "expired challenge should fail verification" - ); -} From b7b3927bd574cd6fb9fc2fd15b294abd26ce4ca1 Mon Sep 17 00:00:00 2001 From: grandizzy Date: Tue, 24 Mar 2026 13:34:57 +0200 Subject: [PATCH 13/15] refactor: extract server tempo/stripe config into submodules Amp-Thread-ID: https://ampcode.com/threads/T-019d1f49-ad9f-74a8-9268-37d860491bb8 Co-authored-by: Amp --- src/server/mod.rs | 331 +++---------------------------------------- src/server/stripe.rs | 104 ++++++++++++++ src/server/tempo.rs | 198 ++++++++++++++++++++++++++ 3 files changed, 319 insertions(+), 314 deletions(-) create mode 100644 src/server/stripe.rs create mode 100644 src/server/tempo.rs diff --git a/src/server/mod.rs b/src/server/mod.rs index 6f98262d..62663461 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -36,142 +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, -}; - -#[cfg(feature = "tempo")] -pub use crate::protocol::methods::tempo::session_method::{ - InMemoryChannelStore as SessionChannelStore, SessionMethod as TempoSessionMethod, - SessionMethodConfig, +pub use tempo::{ + tempo, tempo_provider, SessionChannelStore, SessionMethodConfig, TempoBuilder, TempoChargeExt, + TempoChargeMethod, TempoConfig, TempoMethodDetails, TempoProvider, TempoSessionMethod, + CHAIN_ID, METHOD_NAME, }; +// Re-export stripe types at server level for backward compatibility #[cfg(feature = "stripe")] -pub use crate::protocol::methods::stripe::method::ChargeMethod as StripeChargeMethod; - -#[cfg(feature = "stripe")] -pub use crate::protocol::methods::stripe::{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 - } -} +pub use stripe::{ + stripe, StripeBuilder, StripeChargeMethod, StripeChargeOptions, StripeConfig, + StripeCredentialPayload, StripeMethodDetails, +}; -/// Configuration for the Stripe payment method. -/// -/// All fields are required for Stripe payment verification. -#[cfg(feature = "stripe")] -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, -} +// ==================== Shared Types ==================== /// Options for [`Mpp::session_challenge_with_details()`]. #[derive(Debug, Default)] @@ -200,190 +90,3 @@ pub struct ChargeOptions<'a> { /// Enable fee sponsorship. pub fee_payer: bool, } - -/// Options for [`Mpp::stripe_charge_with_options()`]. -#[cfg(feature = "stripe")] -#[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. -#[cfg(feature = "stripe")] -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, -} - -#[cfg(feature = "stripe")] -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 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 - } -} - -/// Create a Stripe payment method configuration. -/// -/// Returns a [`StripeBuilder`] that can be passed to [`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"), -/// )?; -/// ``` -#[cfg(feature = "stripe")] -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: mpp::detect_realm(), - hmac_secret_key: None, - stripe_api_base: None, - } -} - -// ==================== 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/stripe.rs b/src/server/stripe.rs new file mode 100644 index 00000000..72bad31a --- /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 00000000..951a1913 --- /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, +>; From 55cd3af8f88cb3b1373654d29f0f9f4f380ff8da Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:02:29 +0530 Subject: [PATCH 14/15] docs: add Stripe example (server + client) (#147) * docs: add Stripe example (server + client) Pay-per-fortune example demonstrating the full SPT flow: - Axum server with /api/create-spt proxy and /api/fortune gated endpoint - Headless CLI client using pm_card_visa test card - Mirrors the pympp examples/stripe/ structure Co-authored-by: grandizzy <38490174+grandizzy@users.noreply.github.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d20a8-7850-76dc-a346-432e5d2f4f5f * chore: add changelog --------- Co-authored-by: grandizzy <38490174+grandizzy@users.noreply.github.com> Co-authored-by: github-actions[bot] --- .changelog/kind-bees-pack.md | 5 + examples/README.md | 10 +- examples/stripe/Cargo.toml | 23 ++++ examples/stripe/README.md | 76 +++++++++++++ examples/stripe/src/client.rs | 100 ++++++++++++++++ examples/stripe/src/server.rs | 208 ++++++++++++++++++++++++++++++++++ 6 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 .changelog/kind-bees-pack.md create mode 100644 examples/stripe/Cargo.toml create mode 100644 examples/stripe/README.md create mode 100644 examples/stripe/src/client.rs create mode 100644 examples/stripe/src/server.rs diff --git a/.changelog/kind-bees-pack.md b/.changelog/kind-bees-pack.md new file mode 100644 index 00000000..ae4d349b --- /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/examples/README.md b/examples/README.md index ace20409..1c90ef84 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 00000000..bdac4ab1 --- /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 00000000..11dbda5b --- /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 00000000..f58c643c --- /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!("\n🥠 {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 00000000..5c846ed0 --- /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(), + } +} From fbcde070298778384647ba9955246eeef54715c8 Mon Sep 17 00:00:00 2001 From: decofe <256792747+decofe@users.noreply.github.com> Date: Wed, 25 Mar 2026 05:48:51 +0000 Subject: [PATCH 15/15] fix: remove emoji from stripe example to pass no-emojis lint Co-Authored-By: grandizzy <38490174+grandizzy@users.noreply.github.com> --- examples/stripe/src/client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/stripe/src/client.rs b/examples/stripe/src/client.rs index f58c643c..2cdbf2c7 100644 --- a/examples/stripe/src/client.rs +++ b/examples/stripe/src/client.rs @@ -90,7 +90,7 @@ async fn main() { if let Ok(json) = serde_json::from_str::(&body) { if let Some(fortune) = json.get("fortune").and_then(|v| v.as_str()) { - println!("\n🥠 {fortune}"); + println!("\nFortune: {fortune}"); } else { println!("\nResponse: {json}"); }