mpp 0.10.4

Rust SDK for the Machine Payments Protocol (MPP)
Documentation
use crate::error::MppError;
use crate::protocol::core::accept_payment::{self, Entry};
use crate::protocol::core::PaymentChallenge;

#[derive(Debug, Clone)]
pub(crate) enum ChallengeSelectionError {
    Expired(Box<PaymentChallenge>),
    NoSupportedChallenge(String),
}

pub(crate) fn expired_payment_error(challenge: &PaymentChallenge) -> MppError {
    MppError::PaymentExpired(challenge.expires.clone())
}

pub(crate) fn select_supported_challenge<'a>(
    challenges: &'a [PaymentChallenge],
    ranking_accept: Option<&str>,
    mut supports: impl FnMut(&PaymentChallenge) -> bool,
) -> Result<&'a PaymentChallenge, ChallengeSelectionError> {
    let ranking_preferences = ranking_accept.and_then(|header| accept_payment::parse(header).ok());
    let supported: Vec<_> = challenges
        .iter()
        .filter(|challenge| supports(challenge))
        .collect();
    let payable: Vec<_> = supported
        .iter()
        .copied()
        .filter(|challenge| !challenge.is_expired())
        .collect();

    if let Some(challenge) = select_ranked_challenge(&payable, ranking_preferences.as_deref()) {
        return Ok(challenge);
    }

    if let Some(challenge) = select_ranked_challenge(&supported, ranking_preferences.as_deref()) {
        return Err(ChallengeSelectionError::Expired(Box::new(
            challenge.clone(),
        )));
    }

    let offered: Vec<_> = challenges
        .iter()
        .map(|challenge| format!("{}.{}", challenge.method, challenge.intent))
        .collect();
    Err(ChallengeSelectionError::NoSupportedChallenge(format!(
        "server offered [{}], but provider does not support any",
        offered.join(", ")
    )))
}

fn select_ranked_challenge<'a>(
    challenges: &[&'a PaymentChallenge],
    preferences: Option<&[Entry]>,
) -> Option<&'a PaymentChallenge> {
    match preferences {
        Some(preferences) => accept_payment::select(challenges, preferences).copied(),
        None => challenges.first().copied(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::protocol::core::Base64UrlJson;

    fn challenge_with_expires(expires: Option<&str>) -> PaymentChallenge {
        let challenge = PaymentChallenge::new(
            "challenge-123",
            "api.example.com",
            "tempo",
            "charge",
            Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap(),
        );

        match expires {
            Some(expires) => challenge.with_expires(expires),
            None => challenge,
        }
    }

    #[test]
    fn select_supported_challenge_fails_closed_for_malformed_expiry() {
        let challenges = vec![challenge_with_expires(Some("not-a-date"))];

        let err = select_supported_challenge(&challenges, None, |challenge| {
            challenge.method.as_str() == "tempo"
        })
        .unwrap_err();

        assert!(matches!(err, ChallengeSelectionError::Expired(_)));
    }

    #[test]
    fn select_supported_challenge_rejects_past_expiry() {
        let challenges = vec![challenge_with_expires(Some("2020-01-01T00:00:00Z"))];

        let err = select_supported_challenge(&challenges, None, |challenge| {
            challenge.method.as_str() == "tempo"
        })
        .unwrap_err();

        assert!(matches!(err, ChallengeSelectionError::Expired(_)));
    }
}