adk_payments/guardrail/
amount_policy.rs1use adk_guardrail::Severity;
2
3use crate::domain::{ProtocolDescriptor, TransactionRecord};
4
5use super::{PaymentPolicyDecision, PaymentPolicyFinding, PaymentPolicyGuardrail};
6
7pub struct AmountThresholdGuardrail {
9 review_threshold_minor: Option<i64>,
10 hard_limit_minor: Option<i64>,
11}
12
13impl AmountThresholdGuardrail {
14 #[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}