mpp 0.12.0

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,
    mut select: impl FnMut(&[&'a PaymentChallenge]) -> Option<&'a PaymentChallenge>,
) -> 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();

    let payable = rank_challenges(&payable, ranking_preferences.as_deref());
    if !payable.is_empty() {
        return select(&payable).ok_or_else(|| {
            ChallengeSelectionError::NoSupportedChallenge(
                "provider rejected all supported payment challenges".to_string(),
            )
        });
    }

    let supported = rank_challenges(&supported, ranking_preferences.as_deref());
    if let Some(challenge) = select(&supported) {
        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 rank_challenges<'a>(
    challenges: &[&'a PaymentChallenge],
    preferences: Option<&[Entry]>,
) -> Vec<&'a PaymentChallenge> {
    match preferences {
        Some(preferences) => accept_payment::rank(challenges, preferences)
            .into_iter()
            .copied()
            .collect(),
        None => challenges.to_vec(),
    }
}

#[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",
            |candidates| candidates.first().copied(),
        )
        .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",
            |candidates| candidates.first().copied(),
        )
        .unwrap_err();

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

    #[test]
    fn provider_selects_between_equivalent_supported_challenges() {
        let challenges = vec![
            challenge_with_expires(None),
            PaymentChallenge::new(
                "challenge-preferred",
                "api.example.com",
                "tempo",
                "charge",
                Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap(),
            ),
        ];

        let selected = select_supported_challenge(
            &challenges,
            None,
            |_| true,
            |candidates| candidates.get(1).copied(),
        )
        .unwrap();

        assert_eq!(selected.id, "challenge-preferred");
    }
}