1use std::sync::Arc;
2
3use adk_guardrail::Severity;
4
5use crate::domain::{ProtocolDescriptor, TransactionRecord};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct PaymentPolicyFinding {
10 pub guardrail: String,
12 pub reason: String,
14 pub severity: Severity,
16}
17
18impl PaymentPolicyFinding {
19 #[must_use]
21 pub fn new(
22 guardrail: impl Into<String>,
23 reason: impl Into<String>,
24 severity: Severity,
25 ) -> Self {
26 Self { guardrail: guardrail.into(), reason: reason.into(), severity }
27 }
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum PaymentPolicyDecision {
33 Allow,
35 Escalate { findings: Vec<PaymentPolicyFinding> },
37 Deny { findings: Vec<PaymentPolicyFinding> },
39}
40
41impl PaymentPolicyDecision {
42 #[must_use]
44 pub const fn allow() -> Self {
45 Self::Allow
46 }
47
48 #[must_use]
50 pub fn escalate(findings: Vec<PaymentPolicyFinding>) -> Self {
51 Self::Escalate { findings }
52 }
53
54 #[must_use]
56 pub fn deny(findings: Vec<PaymentPolicyFinding>) -> Self {
57 Self::Deny { findings }
58 }
59
60 #[must_use]
62 pub const fn is_allow(&self) -> bool {
63 matches!(self, Self::Allow)
64 }
65
66 #[must_use]
68 pub const fn is_escalate(&self) -> bool {
69 matches!(self, Self::Escalate { .. })
70 }
71
72 #[must_use]
74 pub const fn is_deny(&self) -> bool {
75 matches!(self, Self::Deny { .. })
76 }
77
78 #[must_use]
80 pub fn findings(&self) -> &[PaymentPolicyFinding] {
81 match self {
82 Self::Allow => &[],
83 Self::Escalate { findings } | Self::Deny { findings } => findings.as_slice(),
84 }
85 }
86
87 #[must_use]
89 pub fn highest_severity(&self) -> Option<Severity> {
90 self.findings()
91 .iter()
92 .map(|finding| finding.severity)
93 .max_by_key(|severity| severity_rank(*severity))
94 }
95}
96
97pub trait PaymentPolicyGuardrail: Send + Sync {
99 fn name(&self) -> &str;
101
102 fn evaluate(
104 &self,
105 record: &TransactionRecord,
106 protocol: &ProtocolDescriptor,
107 ) -> PaymentPolicyDecision;
108}
109
110pub struct PaymentPolicySet {
112 guardrails: Vec<Arc<dyn PaymentPolicyGuardrail>>,
113}
114
115impl PaymentPolicySet {
116 #[must_use]
118 pub fn new() -> Self {
119 Self { guardrails: Vec::new() }
120 }
121
122 #[must_use]
124 pub fn with(mut self, guardrail: impl PaymentPolicyGuardrail + 'static) -> Self {
125 self.guardrails.push(Arc::new(guardrail));
126 self
127 }
128
129 #[must_use]
131 pub fn with_arc(mut self, guardrail: Arc<dyn PaymentPolicyGuardrail>) -> Self {
132 self.guardrails.push(guardrail);
133 self
134 }
135
136 #[must_use]
138 pub fn guardrails(&self) -> &[Arc<dyn PaymentPolicyGuardrail>] {
139 &self.guardrails
140 }
141
142 #[must_use]
144 pub fn evaluate(
145 &self,
146 record: &TransactionRecord,
147 protocol: &ProtocolDescriptor,
148 ) -> PaymentPolicyDecision {
149 let mut denied = Vec::new();
150 let mut escalated = Vec::new();
151
152 for guardrail in &self.guardrails {
153 match guardrail.evaluate(record, protocol) {
154 PaymentPolicyDecision::Allow => {}
155 PaymentPolicyDecision::Escalate { findings } => escalated.extend(findings),
156 PaymentPolicyDecision::Deny { findings } => denied.extend(findings),
157 }
158 }
159
160 sort_findings(&mut denied);
161 sort_findings(&mut escalated);
162
163 if !denied.is_empty() {
164 PaymentPolicyDecision::deny(denied)
165 } else if !escalated.is_empty() {
166 PaymentPolicyDecision::escalate(escalated)
167 } else {
168 PaymentPolicyDecision::allow()
169 }
170 }
171}
172
173impl Default for PaymentPolicySet {
174 fn default() -> Self {
175 Self::new()
176 }
177}
178
179fn sort_findings(findings: &mut [PaymentPolicyFinding]) {
180 findings.sort_by(|left, right| {
181 left.guardrail
182 .cmp(&right.guardrail)
183 .then(severity_rank(right.severity).cmp(&severity_rank(left.severity)))
184 .then(left.reason.cmp(&right.reason))
185 });
186}
187
188const fn severity_rank(severity: Severity) -> u8 {
189 match severity {
190 Severity::Low => 0,
191 Severity::Medium => 1,
192 Severity::High => 2,
193 Severity::Critical => 3,
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use adk_guardrail::Severity;
200 use chrono::{TimeZone, Utc};
201
202 use super::*;
203 use crate::domain::{
204 Cart, CartLine, CommerceActor, CommerceActorRole, CommerceMode, MerchantRef, Money,
205 ProtocolExtensions, TransactionId,
206 };
207
208 struct StaticDecisionGuardrail {
209 name: &'static str,
210 decision: PaymentPolicyDecision,
211 }
212
213 impl PaymentPolicyGuardrail for StaticDecisionGuardrail {
214 fn name(&self) -> &str {
215 self.name
216 }
217
218 fn evaluate(
219 &self,
220 _record: &TransactionRecord,
221 _protocol: &ProtocolDescriptor,
222 ) -> PaymentPolicyDecision {
223 self.decision.clone()
224 }
225 }
226
227 fn sample_record() -> TransactionRecord {
228 TransactionRecord::new(
229 TransactionId::from("tx-policy"),
230 CommerceActor {
231 actor_id: "shopper-agent".to_string(),
232 role: CommerceActorRole::AgentSurface,
233 display_name: Some("shopper".to_string()),
234 tenant_id: Some("tenant-1".to_string()),
235 extensions: ProtocolExtensions::default(),
236 },
237 MerchantRef {
238 merchant_id: "merchant-1".to_string(),
239 legal_name: "Merchant Example LLC".to_string(),
240 display_name: Some("Merchant Example".to_string()),
241 statement_descriptor: None,
242 country_code: Some("US".to_string()),
243 website: Some("https://merchant.example".to_string()),
244 extensions: ProtocolExtensions::default(),
245 },
246 CommerceMode::HumanPresent,
247 Cart {
248 cart_id: Some("cart-1".to_string()),
249 lines: vec![CartLine {
250 line_id: "line-1".to_string(),
251 merchant_sku: Some("sku-1".to_string()),
252 title: "Widget".to_string(),
253 quantity: 1,
254 unit_price: Money::new("USD", 1_500, 2),
255 total_price: Money::new("USD", 1_500, 2),
256 product_class: Some("widgets".to_string()),
257 extensions: ProtocolExtensions::default(),
258 }],
259 subtotal: Some(Money::new("USD", 1_500, 2)),
260 adjustments: Vec::new(),
261 total: Money::new("USD", 1_500, 2),
262 affiliate_attribution: None,
263 extensions: ProtocolExtensions::default(),
264 },
265 Utc.with_ymd_and_hms(2026, 3, 22, 15, 0, 0).unwrap(),
266 )
267 }
268
269 #[test]
270 fn policy_set_prefers_denials_over_escalations() {
271 let set = PaymentPolicySet::new()
272 .with(StaticDecisionGuardrail {
273 name: "amount_threshold",
274 decision: PaymentPolicyDecision::escalate(vec![PaymentPolicyFinding::new(
275 "amount_threshold",
276 "needs approval",
277 Severity::Medium,
278 )]),
279 })
280 .with(StaticDecisionGuardrail {
281 name: "merchant_allowlist",
282 decision: PaymentPolicyDecision::deny(vec![PaymentPolicyFinding::new(
283 "merchant_allowlist",
284 "merchant is blocked",
285 Severity::High,
286 )]),
287 });
288
289 let decision = set.evaluate(&sample_record(), &ProtocolDescriptor::acp("2026-01-30"));
290
291 assert!(decision.is_deny());
292 assert_eq!(decision.findings().len(), 1);
293 assert_eq!(decision.findings()[0].guardrail, "merchant_allowlist");
294 assert_eq!(decision.highest_severity(), Some(Severity::High));
295 }
296}