raisfast 0.2.19

The last backend you'll ever need. Rust-powered headless CMS with built-in blog, ecommerce, wallet, payment and 4 plugin engines.
use crate::errors::app_error::{AppError, AppResult};
use crate::models::payment_channel::PaymentChannel;

#[derive(Debug, Clone)]
pub struct RoutingContext {
    pub currency: String,
    pub country: Option<String>,
    pub language: Option<String>,
}

#[derive(Debug, Clone)]
pub struct RankedChannel {
    pub channel: PaymentChannel,
    pub effective_priority: f64,
    pub is_recommended: bool,
}

#[derive(Debug, Clone, serde::Deserialize)]
struct RoutingSettings {
    #[serde(default)]
    countries: Vec<String>,
    #[serde(default)]
    currencies: Vec<String>,
    #[serde(default)]
    languages: Vec<String>,
    #[serde(default)]
    priority: i64,
}

fn parse_settings(channel: &PaymentChannel) -> Option<RoutingSettings> {
    channel
        .settings
        .as_deref()
        .and_then(|s| serde_json::from_str(s).ok())
}

pub fn select_channels(channels: &[PaymentChannel], ctx: &RoutingContext) -> Vec<RankedChannel> {
    let mut ranked: Vec<RankedChannel> = channels
        .iter()
        .filter(|c| c.is_active != 0)
        .filter_map(|c| rank_channel(c, ctx))
        .collect();

    ranked.sort_by(|a, b| {
        b.effective_priority
            .partial_cmp(&a.effective_priority)
            .unwrap_or(std::cmp::Ordering::Equal)
            .then_with(|| a.channel.sort_order.cmp(&b.channel.sort_order))
    });

    if let Some(first) = ranked.first_mut() {
        first.is_recommended = true;
    }

    ranked
}

fn rank_channel(channel: &PaymentChannel, ctx: &RoutingContext) -> Option<RankedChannel> {
    let settings = parse_settings(channel);

    let base_priority = settings.as_ref().map(|s| s.priority).unwrap_or(0);

    let currencies = settings
        .as_ref()
        .map(|s| s.currencies.as_slice())
        .unwrap_or(&[]);

    let countries = settings
        .as_ref()
        .map(|s| s.countries.as_slice())
        .unwrap_or(&[]);

    let languages = settings
        .as_ref()
        .map(|s| s.languages.as_slice())
        .unwrap_or(&[]);

    if !currencies.is_empty()
        && !currencies
            .iter()
            .any(|c| c.eq_ignore_ascii_case(&ctx.currency))
    {
        return None;
    }

    let mut effective_priority = base_priority as f64;

    if let Some(ref country) = ctx.country {
        let upper = country.to_uppercase();
        if countries.iter().any(|c| c == "*") {
            effective_priority /= 10.0;
        } else if !countries.iter().any(|c| c.eq_ignore_ascii_case(&upper)) {
            return None;
        }
    } else if !countries.iter().any(|c| c == "*") {
        effective_priority /= 10.0;
    }

    if let Some(ref lang) = ctx.language {
        let lang_lower = lang.to_lowercase();
        let lang_prefix = lang_lower.split('-').next().unwrap_or(&lang_lower);

        let exact = languages
            .iter()
            .any(|l| l.eq_ignore_ascii_case(&lang_lower));
        let prefix = !exact
            && languages
                .iter()
                .any(|l| l.eq_ignore_ascii_case(lang_prefix));

        if exact {
            effective_priority += 50.0;
        } else if prefix {
            effective_priority += 25.0;
        }
    }

    Some(RankedChannel {
        channel: channel.clone(),
        effective_priority,
        is_recommended: false,
    })
}

pub fn select_best_channel(
    channels: &[PaymentChannel],
    ctx: &RoutingContext,
) -> AppResult<PaymentChannel> {
    let ranked = select_channels(channels, ctx);
    ranked.into_iter().next().map(|r| r.channel).ok_or_else(|| {
        AppError::BadRequest(format!(
            "no payment channel available for currency={}",
            ctx.currency
        ))
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::utils::tz::Timestamp;

    fn make_channel(provider: &str, settings: Option<&str>) -> PaymentChannel {
        PaymentChannel {
            id: crate::types::snowflake_id::SnowflakeId(1),
            tenant_id: None,
            provider: provider.to_string(),
            name: provider.to_string(),
            is_live: 0,
            credentials: "{}".to_string(),
            webhook_secret: None,
            settings: settings.map(String::from),
            is_active: 1,
            sort_order: 0,
            version: 1,
            created_at: Timestamp::default(),
            updated_at: Timestamp::default(),
        }
    }

    fn make_channel_with_sort(provider: &str, settings: Option<&str>, sort: i64) -> PaymentChannel {
        PaymentChannel {
            sort_order: sort,
            ..make_channel(provider, settings)
        }
    }

    #[test]
    fn selects_by_currency() {
        let channels = vec![
            make_channel("stripe", Some(r#"{"currencies":["USD","EUR"]}"#)),
            make_channel("alipay", Some(r#"{"currencies":["CNY"]}"#)),
        ];
        let ctx = RoutingContext {
            currency: "CNY".into(),
            country: None,
            language: None,
        };
        let result = select_channels(&channels, &ctx);
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].channel.provider, "alipay");
    }

    #[test]
    fn wildcard_country_demoted() {
        let channels = vec![
            make_channel("alipay", Some(r#"{"countries":["CN"],"priority":90}"#)),
            make_channel("dodo", Some(r#"{"countries":["*"],"priority":10}"#)),
        ];
        let ctx = RoutingContext {
            currency: "USD".into(),
            country: Some("CN".into()),
            language: None,
        };
        let result = select_channels(&channels, &ctx);
        assert_eq!(result[0].channel.provider, "alipay");
        assert_eq!(result[1].channel.provider, "dodo");
        assert!(result[0].effective_priority > result[1].effective_priority);
    }

    #[test]
    fn country_mismatch_excluded() {
        let channels = vec![
            make_channel("alipay", Some(r#"{"countries":["CN"]}"#)),
            make_channel("stripe", Some(r#"{"countries":["US","GB"]}"#)),
        ];
        let ctx = RoutingContext {
            currency: "USD".into(),
            country: Some("JP".into()),
            language: None,
        };
        let result = select_channels(&channels, &ctx);
        assert!(result.is_empty());
    }

    #[test]
    fn language_exact_match_bonus() {
        let channels = vec![
            make_channel("stripe", Some(r#"{"languages":["en"],"priority":50}"#)),
            make_channel("dodo", Some(r#"{"languages":["zh"],"priority":50}"#)),
        ];
        let ctx = RoutingContext {
            currency: "USD".into(),
            country: None,
            language: Some("zh-CN".into()),
        };
        let result = select_channels(&channels, &ctx);
        assert_eq!(result[0].channel.provider, "dodo");
    }

    #[test]
    fn language_prefix_match_bonus() {
        let channels = vec![
            make_channel("a", Some(r#"{"languages":["zh"],"priority":50}"#)),
            make_channel("b", Some(r#"{"priority":50}"#)),
        ];
        let ctx = RoutingContext {
            currency: "USD".into(),
            country: None,
            language: Some("zh-CN".into()),
        };
        let result = select_channels(&channels, &ctx);
        assert_eq!(result[0].channel.provider, "a");
    }

    #[test]
    fn first_result_marked_recommended() {
        let channels = vec![
            make_channel("alipay", Some(r#"{"priority":100}"#)),
            make_channel("stripe", Some(r#"{"priority":50}"#)),
        ];
        let ctx = RoutingContext {
            currency: "USD".into(),
            country: None,
            language: None,
        };
        let result = select_channels(&channels, &ctx);
        assert!(result[0].is_recommended);
        assert!(!result[1].is_recommended);
    }

    #[test]
    fn sort_order_tiebreak() {
        let channels = vec![
            make_channel_with_sort("stripe", Some(r#"{"priority":50}"#), 2),
            make_channel_with_sort("dodo", Some(r#"{"priority":50}"#), 1),
        ];
        let ctx = RoutingContext {
            currency: "USD".into(),
            country: None,
            language: None,
        };
        let result = select_channels(&channels, &ctx);
        assert_eq!(result[0].channel.provider, "dodo");
    }

    #[test]
    fn inactive_channel_excluded() {
        let mut ch = make_channel("stripe", Some(r#"{"priority":100}"#));
        ch.is_active = 0;
        let channels = vec![ch];
        let ctx = RoutingContext {
            currency: "USD".into(),
            country: None,
            language: None,
        };
        let result = select_channels(&channels, &ctx);
        assert!(result.is_empty());
    }

    #[test]
    fn no_settings_passes_through() {
        let channels = vec![make_channel("stripe", None)];
        let ctx = RoutingContext {
            currency: "USD".into(),
            country: None,
            language: None,
        };
        let result = select_channels(&channels, &ctx);
        assert_eq!(result.len(), 1);
    }

    #[test]
    fn select_best_returns_error_when_empty() {
        let channels = vec![make_channel("stripe", Some(r#"{"currencies":["EUR"]}"#))];
        let ctx = RoutingContext {
            currency: "USD".into(),
            country: None,
            language: None,
        };
        assert!(select_best_channel(&channels, &ctx).is_err());
    }
}