use std::cmp::Ordering;
use adk_guardrail::Severity;
use crate::domain::{Money, ProtocolDescriptor, TransactionRecord};
use super::{PaymentPolicyDecision, PaymentPolicyFinding, PaymentPolicyGuardrail};
pub struct AmountThresholdGuardrail {
review_threshold_minor: Option<i64>,
hard_limit_minor: Option<i64>,
currency: Option<(String, u32)>,
}
impl AmountThresholdGuardrail {
#[must_use]
pub fn new(review_threshold_minor: Option<i64>, hard_limit_minor: Option<i64>) -> Self {
Self { review_threshold_minor, hard_limit_minor, currency: None }
}
#[must_use]
pub fn with_currency(mut self, currency: impl Into<String>, scale: u32) -> Self {
self.currency = Some((currency.into(), scale));
self
}
}
impl PaymentPolicyGuardrail for AmountThresholdGuardrail {
fn name(&self) -> &str {
"amount_threshold"
}
fn evaluate(
&self,
record: &TransactionRecord,
_protocol: &ProtocolDescriptor,
) -> PaymentPolicyDecision {
let total = &record.cart.total;
let total_text = format!("{} {}", total.to_decimal_string(), total.currency);
let (currency, scale) = match &self.currency {
Some((currency, scale)) if currency.eq_ignore_ascii_case(&total.currency) => {
(currency.clone(), *scale)
}
Some((currency, _)) => {
return PaymentPolicyDecision::deny(vec![PaymentPolicyFinding::new(
self.name(),
format!(
"transaction total {total_text} is not in the guardrail currency {currency}"
),
Severity::High,
)]);
}
None => (
total.currency.clone(),
Money::iso_minor_unit_scale(&total.currency).unwrap_or(total.scale),
),
};
let exceeds = |limit_minor: i64, label: &str| -> Result<Option<String>, String> {
let limit = Money::new(currency.clone(), limit_minor, scale);
let limit_text = format!("{} {currency}", limit.to_decimal_string());
match total.compare_amount(&limit) {
Some(Ordering::Greater) => Ok(Some(format!(
"transaction total {total_text} exceeds the {label} of {limit_text}"
))),
Some(Ordering::Less | Ordering::Equal) => Ok(None),
None => Err(format!(
"transaction total {total_text} cannot be compared with the {label} of {limit_text}"
)),
}
};
if let Some(limit) = self.hard_limit_minor {
match exceeds(limit, "hard limit") {
Ok(Some(reason)) | Err(reason) => {
return PaymentPolicyDecision::deny(vec![PaymentPolicyFinding::new(
self.name(),
reason,
Severity::High,
)]);
}
Ok(None) => {}
}
}
if let Some(threshold) = self.review_threshold_minor {
match exceeds(threshold, "review threshold") {
Ok(Some(reason)) => {
return PaymentPolicyDecision::escalate(vec![PaymentPolicyFinding::new(
self.name(),
reason,
Severity::Medium,
)]);
}
Err(reason) => {
return PaymentPolicyDecision::deny(vec![PaymentPolicyFinding::new(
self.name(),
reason,
Severity::High,
)]);
}
Ok(None) => {}
}
}
PaymentPolicyDecision::allow()
}
}
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Utc};
use super::*;
use crate::domain::{
Cart, CartLine, CommerceActor, CommerceActorRole, CommerceMode, MerchantRef,
ProtocolExtensions, TransactionId,
};
fn sample_record(total: Money) -> TransactionRecord {
TransactionRecord::new(
TransactionId::from("tx-amount"),
CommerceActor {
actor_id: "shopper-agent".to_string(),
role: CommerceActorRole::AgentSurface,
display_name: Some("shopper".to_string()),
tenant_id: Some("tenant-1".to_string()),
extensions: ProtocolExtensions::default(),
},
MerchantRef {
merchant_id: "merchant-1".to_string(),
legal_name: "Merchant Example LLC".to_string(),
display_name: Some("Merchant Example".to_string()),
statement_descriptor: None,
country_code: Some("US".to_string()),
website: Some("https://merchant.example".to_string()),
extensions: ProtocolExtensions::default(),
},
CommerceMode::HumanPresent,
Cart {
cart_id: Some("cart-1".to_string()),
lines: vec![CartLine {
line_id: "line-1".to_string(),
merchant_sku: Some("sku-1".to_string()),
title: "Widget".to_string(),
quantity: 1,
unit_price: total.clone(),
total_price: total.clone(),
product_class: Some("widgets".to_string()),
extensions: ProtocolExtensions::default(),
}],
subtotal: Some(total.clone()),
adjustments: Vec::new(),
total,
affiliate_attribution: None,
extensions: ProtocolExtensions::default(),
},
Utc.with_ymd_and_hms(2026, 3, 22, 15, 10, 0).unwrap(),
)
}
fn evaluate(guardrail: &AmountThresholdGuardrail, total: Money) -> PaymentPolicyDecision {
guardrail.evaluate(&sample_record(total), &ProtocolDescriptor::acp("2026-01-30"))
}
fn finding(reason: &str, severity: Severity) -> Vec<PaymentPolicyFinding> {
vec![PaymentPolicyFinding::new("amount_threshold", reason, severity)]
}
#[test]
fn amount_threshold_escalates_before_hard_limit() {
let guardrail = AmountThresholdGuardrail::new(Some(5_000), Some(10_000));
assert_eq!(
evaluate(&guardrail, Money::new("USD", 7_500, 2)),
PaymentPolicyDecision::escalate(finding(
"transaction total 75.00 USD exceeds the review threshold of 50.00 USD",
Severity::Medium,
))
);
}
#[test]
fn three_decimal_total_is_compared_by_value() {
let guardrail = AmountThresholdGuardrail::new(Some(5_000), Some(10_000));
assert_eq!(
evaluate(&guardrail, Money::new("USD", 40_000, 3)),
PaymentPolicyDecision::allow()
);
assert_eq!(
evaluate(&guardrail, Money::new("USD", 100_001, 3)),
PaymentPolicyDecision::deny(finding(
"transaction total 100.001 USD exceeds the hard limit of 100.00 USD",
Severity::High,
))
);
}
#[test]
fn currency_agnostic_thresholds_use_the_currency_minor_unit() {
let guardrail = AmountThresholdGuardrail::new(None, Some(5_000));
assert_eq!(
evaluate(&guardrail, Money::new("JPY", 4_000, 0)),
PaymentPolicyDecision::allow()
);
assert_eq!(
evaluate(&guardrail, Money::new("JPY", 10_000, 0)),
PaymentPolicyDecision::deny(finding(
"transaction total 10000 JPY exceeds the hard limit of 5000 JPY",
Severity::High,
))
);
}
#[test]
fn currency_bound_thresholds_deny_other_currencies() {
let guardrail =
AmountThresholdGuardrail::new(Some(5_000), Some(10_000)).with_currency("USD", 2);
assert_eq!(
evaluate(&guardrail, Money::new("JPY", 100, 0)),
PaymentPolicyDecision::deny(finding(
"transaction total 100 JPY is not in the guardrail currency USD",
Severity::High,
))
);
assert_eq!(
evaluate(&guardrail, Money::new("usd", 4_999, 2)),
PaymentPolicyDecision::allow()
);
}
#[test]
fn unalignable_amounts_fail_closed() {
let guardrail = AmountThresholdGuardrail::new(Some(5_000), None).with_currency("USD", 2);
assert!(evaluate(&guardrail, Money::new("USD", 1, 40)).is_deny());
}
}