Skip to main content

adk_payments/guardrail/
amount_policy.rs

1use adk_guardrail::Severity;
2
3use crate::domain::{ProtocolDescriptor, TransactionRecord};
4
5use super::{PaymentPolicyDecision, PaymentPolicyFinding, PaymentPolicyGuardrail};
6
7/// Enforces soft-review and hard-stop thresholds for transaction totals.
8pub struct AmountThresholdGuardrail {
9    review_threshold_minor: Option<i64>,
10    hard_limit_minor: Option<i64>,
11}
12
13impl AmountThresholdGuardrail {
14    /// Creates a new amount-threshold guardrail.
15    #[must_use]
16    pub fn new(review_threshold_minor: Option<i64>, hard_limit_minor: Option<i64>) -> Self {
17        Self { review_threshold_minor, hard_limit_minor }
18    }
19}
20
21impl PaymentPolicyGuardrail for AmountThresholdGuardrail {
22    fn name(&self) -> &str {
23        "amount_threshold"
24    }
25
26    fn evaluate(
27        &self,
28        record: &TransactionRecord,
29        _protocol: &ProtocolDescriptor,
30    ) -> PaymentPolicyDecision {
31        let amount = record.cart.total.amount_minor;
32        let currency = record.cart.total.currency.as_str();
33
34        if let Some(limit) = self.hard_limit_minor
35            && amount > limit
36        {
37            return PaymentPolicyDecision::deny(vec![PaymentPolicyFinding::new(
38                self.name(),
39                format!(
40                    "transaction total {amount} {currency} exceeds the hard limit of {limit} {currency}"
41                ),
42                Severity::High,
43            )]);
44        }
45
46        if let Some(threshold) = self.review_threshold_minor
47            && amount > threshold
48        {
49            return PaymentPolicyDecision::escalate(vec![PaymentPolicyFinding::new(
50                self.name(),
51                format!(
52                    "transaction total {amount} {currency} exceeds the review threshold of {threshold} {currency}"
53                ),
54                Severity::Medium,
55            )]);
56        }
57
58        PaymentPolicyDecision::allow()
59    }
60}
61
62#[cfg(test)]
63mod tests {
64    use chrono::{TimeZone, Utc};
65
66    use super::*;
67    use crate::domain::{
68        Cart, CartLine, CommerceActor, CommerceActorRole, CommerceMode, MerchantRef, Money,
69        ProtocolExtensions, TransactionId,
70    };
71
72    fn sample_record(amount_minor: i64) -> TransactionRecord {
73        TransactionRecord::new(
74            TransactionId::from("tx-amount"),
75            CommerceActor {
76                actor_id: "shopper-agent".to_string(),
77                role: CommerceActorRole::AgentSurface,
78                display_name: Some("shopper".to_string()),
79                tenant_id: Some("tenant-1".to_string()),
80                extensions: ProtocolExtensions::default(),
81            },
82            MerchantRef {
83                merchant_id: "merchant-1".to_string(),
84                legal_name: "Merchant Example LLC".to_string(),
85                display_name: Some("Merchant Example".to_string()),
86                statement_descriptor: None,
87                country_code: Some("US".to_string()),
88                website: Some("https://merchant.example".to_string()),
89                extensions: ProtocolExtensions::default(),
90            },
91            CommerceMode::HumanPresent,
92            Cart {
93                cart_id: Some("cart-1".to_string()),
94                lines: vec![CartLine {
95                    line_id: "line-1".to_string(),
96                    merchant_sku: Some("sku-1".to_string()),
97                    title: "Widget".to_string(),
98                    quantity: 1,
99                    unit_price: Money::new("USD", amount_minor, 2),
100                    total_price: Money::new("USD", amount_minor, 2),
101                    product_class: Some("widgets".to_string()),
102                    extensions: ProtocolExtensions::default(),
103                }],
104                subtotal: Some(Money::new("USD", amount_minor, 2)),
105                adjustments: Vec::new(),
106                total: Money::new("USD", amount_minor, 2),
107                affiliate_attribution: None,
108                extensions: ProtocolExtensions::default(),
109            },
110            Utc.with_ymd_and_hms(2026, 3, 22, 15, 10, 0).unwrap(),
111        )
112    }
113
114    #[test]
115    fn amount_threshold_escalates_before_hard_limit() {
116        let guardrail = AmountThresholdGuardrail::new(Some(5_000), Some(10_000));
117        let decision =
118            guardrail.evaluate(&sample_record(7_500), &ProtocolDescriptor::acp("2026-01-30"));
119
120        assert!(decision.is_escalate());
121        assert_eq!(decision.findings()[0].guardrail, "amount_threshold");
122    }
123}