use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use crate::client::PaymentProvider;
use crate::error::{MppError, ResultExt};
use crate::protocol::core::{PaymentChallenge, PaymentCredential};
use crate::protocol::intents::ChargeRequest;
use crate::protocol::methods::stripe::types::CreateTokenResult;
use crate::protocol::methods::stripe::{
StripeCredentialPayload, StripeMethodDetails, INTENT_CHARGE, METHOD_NAME,
};
#[derive(Debug, Clone, serde::Serialize)]
pub struct CreateTokenParams {
pub amount: String,
pub currency: String,
pub network_id: String,
pub expires_at: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<std::collections::HashMap<String, String>>,
#[serde(skip)]
pub challenge: serde_json::Value,
}
type CreateTokenFn = dyn Fn(
CreateTokenParams,
) -> Pin<Box<dyn Future<Output = Result<CreateTokenResult, MppError>> + Send>>
+ Send
+ Sync;
#[derive(Clone)]
pub struct StripeProvider {
create_token: Arc<CreateTokenFn>,
}
impl StripeProvider {
pub fn new<F>(create_token: F) -> Self
where
F: Fn(
CreateTokenParams,
)
-> Pin<Box<dyn Future<Output = Result<CreateTokenResult, MppError>> + Send>>
+ Send
+ Sync
+ 'static,
{
Self {
create_token: Arc::new(create_token),
}
}
}
impl PaymentProvider for StripeProvider {
fn supports(&self, method: &str, intent: &str) -> bool {
method == METHOD_NAME && intent == INTENT_CHARGE
}
async fn pay(&self, challenge: &PaymentChallenge) -> Result<PaymentCredential, MppError> {
challenge.validate_for_charge(METHOD_NAME)?;
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();
if details.network_id.is_empty() {
return Err(MppError::InvalidConfig(
"networkId is required in challenge methodDetails".into(),
));
}
let expires_at = challenge
.expires
.as_ref()
.and_then(|e| {
time::OffsetDateTime::parse(e, &time::format_description::well_known::Rfc3339).ok()
})
.map(|dt| dt.unix_timestamp() as u64)
.unwrap_or_else(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
+ 3600
});
let params = CreateTokenParams {
amount: request.amount,
currency: request.currency,
network_id: details.network_id,
expires_at,
metadata: details.metadata,
challenge: serde_json::to_value(challenge).unwrap_or_default(),
};
let result = (self.create_token)(params).await?;
let payload = StripeCredentialPayload {
spt: result.spt,
external_id: request.external_id.or(result.external_id),
};
Ok(PaymentCredential::new(challenge.to_echo(), payload))
}
fn accept_payment_header(&self) -> Option<String> {
Some(crate::protocol::core::accept_payment::from_methods(&[(
METHOD_NAME,
INTENT_CHARGE,
)]))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::core::Base64UrlJson;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
#[test]
fn test_supports() {
let provider = StripeProvider::new(|_| {
Box::pin(async { Ok(CreateTokenResult::from("spt_test".to_string())) })
});
assert!(provider.supports("stripe", "charge"));
assert!(!provider.supports("tempo", "charge"));
assert!(!provider.supports("stripe", "session"));
}
#[tokio::test]
async fn test_pay_rejects_expired_challenge_before_creating_token() {
let called = Arc::new(AtomicBool::new(false));
let provider = StripeProvider::new({
let called = called.clone();
move |_| {
called.store(true, Ordering::SeqCst);
Box::pin(async { Ok(CreateTokenResult::from("spt_test".to_string())) })
}
});
let request = ChargeRequest {
amount: "1000".to_string(),
currency: "usd".to_string(),
method_details: Some(serde_json::json!({
"networkId": "internal",
"paymentMethodTypes": ["card"]
})),
..Default::default()
};
let challenge = PaymentChallenge::new(
"challenge-123",
"api.example.com",
"stripe",
"charge",
Base64UrlJson::from_typed(&request).unwrap(),
)
.with_expires("2020-01-01T00:00:00Z");
let err = provider.pay(&challenge).await.unwrap_err();
assert!(matches!(err, MppError::PaymentExpired(_)));
assert!(!called.load(Ordering::SeqCst));
}
fn charge_challenge(
external_id: Option<&str>,
method_details: Option<serde_json::Value>,
) -> PaymentChallenge {
let request = ChargeRequest {
amount: "1000".to_string(),
currency: "usd".to_string(),
external_id: external_id.map(str::to_string),
method_details,
..Default::default()
};
PaymentChallenge::new(
"challenge-123",
"api.example.com",
"stripe",
"charge",
Base64UrlJson::from_typed(&request).unwrap(),
)
}
#[tokio::test]
async fn test_pay_echoes_request_external_id() {
let cases = [
(Some("order-123"), None, Some("order-123")),
(Some("order-123"), Some("client-ref"), Some("order-123")),
(None, Some("client-ref"), Some("client-ref")),
];
for (request_id, token_id, expected) in cases {
let provider = StripeProvider::new(move |_| {
Box::pin(async move {
Ok(CreateTokenResult {
spt: "spt_test".to_string(),
external_id: token_id.map(str::to_string),
})
})
});
let challenge = charge_challenge(
request_id,
Some(serde_json::json!({
"networkId": "internal",
"paymentMethodTypes": ["card"]
})),
);
let credential = provider.pay(&challenge).await.unwrap();
let payload: StripeCredentialPayload = credential.payload_as().unwrap();
assert_eq!(payload.external_id.as_deref(), expected);
}
}
#[tokio::test]
async fn test_pay_rejects_missing_network_id() {
let cases = [
None,
Some(serde_json::json!({
"networkId": "",
"paymentMethodTypes": ["card"]
})),
];
for method_details in cases {
let called = Arc::new(AtomicBool::new(false));
let provider = StripeProvider::new({
let called = called.clone();
move |_| {
called.store(true, Ordering::SeqCst);
Box::pin(async { Ok(CreateTokenResult::from("spt_test".to_string())) })
}
});
let err = provider
.pay(&charge_challenge(None, method_details))
.await
.unwrap_err();
assert!(matches!(err, MppError::InvalidConfig(_)), "{err}");
assert!(!called.load(Ordering::SeqCst));
}
}
}