use crate::protocol::core::{ChallengeEcho, IntentName, MethodName, PaymentCredential, Receipt};
use crate::protocol::intents::ChargeRequest;
use crate::protocol::traits::VerificationError;
use std::future::Future;
#[derive(Debug, Clone, serde::Serialize)]
pub struct ChargeValidation {
pub challenge: ChallengeEcho,
pub credential: PaymentCredential,
pub details: serde_json::Value,
pub intent: IntentName,
pub method: MethodName,
pub request: ChargeRequest,
pub source: Option<String>,
}
impl ChargeValidation {
pub fn new(
credential: &PaymentCredential,
request: &ChargeRequest,
details: serde_json::Value,
) -> Self {
Self {
challenge: credential.challenge.clone(),
credential: credential.clone(),
details,
intent: credential.challenge.intent.clone(),
method: credential.challenge.method.clone(),
request: request.clone(),
source: credential.source.clone(),
}
}
}
pub trait ChargeMethod: Clone + Send + Sync {
fn method(&self) -> &str;
fn prepare_request(
&self,
request: ChargeRequest,
_credential: Option<&PaymentCredential>,
) -> ChargeRequest {
request
}
fn supports_validation(&self) -> bool {
false
}
fn validate(
&self,
_credential: &PaymentCredential,
_request: &ChargeRequest,
) -> impl Future<Output = Result<ChargeValidation, VerificationError>> + Send {
let method = self.method().to_string();
async move {
Err(VerificationError::new(format!(
"{method}/charge does not support non-mutating credential validation"
)))
}
}
fn broadcast(
&self,
credential: &PaymentCredential,
request: &ChargeRequest,
) -> impl Future<Output = Result<Receipt, VerificationError>> + Send {
self.verify(credential, request)
}
fn verify(
&self,
credential: &PaymentCredential,
request: &ChargeRequest,
) -> impl Future<Output = Result<Receipt, VerificationError>> + Send;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::core::{ChallengeEcho, PaymentPayload};
#[derive(Clone)]
struct TestChargeMethod;
#[allow(clippy::manual_async_fn)]
impl ChargeMethod for TestChargeMethod {
fn method(&self) -> &str {
"test"
}
fn verify(
&self,
_credential: &PaymentCredential,
_request: &ChargeRequest,
) -> impl Future<Output = Result<Receipt, VerificationError>> + Send {
async { Ok(Receipt::success("test", "test_ref")) }
}
}
#[test]
fn test_charge_method_name() {
let method = TestChargeMethod;
assert_eq!(method.method(), "test");
}
#[test]
fn test_charge_method_prepare_request_defaults_to_identity() {
let method = TestChargeMethod;
let request = ChargeRequest {
amount: "100".into(),
currency: "USD".into(),
..Default::default()
};
let prepared = method.prepare_request(request.clone(), None);
assert_eq!(prepared.amount, request.amount);
assert_eq!(prepared.currency, request.currency);
}
#[tokio::test]
async fn test_charge_method_verify() {
let method = TestChargeMethod;
let echo = ChallengeEcho {
id: "test".into(),
realm: "test.com".into(),
method: "test".into(),
intent: "charge".into(),
request: crate::protocol::core::Base64UrlJson::from_raw("eyJ0ZXN0IjoidmFsdWUifQ"),
expires: None,
digest: None,
opaque: None,
};
let credential = PaymentCredential::new(echo, PaymentPayload::hash("0x123"));
let request = ChargeRequest {
amount: "1000".into(),
currency: "usd".into(),
..Default::default()
};
let result = method.verify(&credential, &request).await;
assert!(result.is_ok());
let receipt = result.unwrap();
assert_eq!(receipt.reference, "test_ref");
}
#[tokio::test]
async fn test_charge_method_broadcast_falls_back_to_legacy_verify() {
let method = TestChargeMethod;
let echo = ChallengeEcho {
id: "test".into(),
realm: "test.com".into(),
method: "test".into(),
intent: "charge".into(),
request: crate::protocol::core::Base64UrlJson::from_raw("e30"),
expires: None,
digest: None,
opaque: None,
};
let credential = PaymentCredential::new(echo, PaymentPayload::hash("0x123"));
let receipt = method
.broadcast(&credential, &ChargeRequest::default())
.await
.unwrap();
assert_eq!(receipt.reference, "test_ref");
}
#[tokio::test]
async fn test_legacy_charge_method_reports_validation_unsupported() {
let method = TestChargeMethod;
let echo = ChallengeEcho {
id: "test".into(),
realm: "test.com".into(),
method: "test".into(),
intent: "charge".into(),
request: crate::protocol::core::Base64UrlJson::from_raw("e30"),
expires: None,
digest: None,
opaque: None,
};
let credential = PaymentCredential::new(echo, PaymentPayload::hash("0x123"));
let error = method
.validate(&credential, &ChargeRequest::default())
.await
.unwrap_err();
assert!(error.message.contains("test/charge"));
assert!(error.message.contains("does not support non-mutating"));
}
#[test]
fn test_charge_validation_serializes_public_contract() {
let request = ChargeRequest {
amount: "100".into(),
currency: "USD".into(),
..Default::default()
};
let credential = PaymentCredential::with_source(
ChallengeEcho {
id: "challenge".into(),
realm: "test.com".into(),
method: "test".into(),
intent: "charge".into(),
request: crate::protocol::core::Base64UrlJson::from_typed(&request).unwrap(),
expires: None,
digest: None,
opaque: None,
},
"did:example:payer",
PaymentPayload::hash("0x123"),
);
let value = serde_json::to_value(ChargeValidation::new(
&credential,
&request,
serde_json::json!({ "mode": "test" }),
))
.unwrap();
assert_eq!(value["challenge"]["id"], "challenge");
assert_eq!(value["credential"]["source"], "did:example:payer");
assert_eq!(value["details"]["mode"], "test");
assert_eq!(value["intent"], "charge");
assert_eq!(value["method"], "test");
assert_eq!(value["request"]["amount"], "100");
assert_eq!(value["source"], "did:example:payer");
}
}