1use std::sync::Arc;
2
3use adk_auth::{AuditEvent, AuditEventType, AuditOutcome, AuditSink};
4use chrono::Utc;
5use serde_json::json;
6
7use crate::domain::{ProtocolDescriptor, TransactionRecord};
8
9use super::{AuthenticatedPaymentRequest, PaymentOperation, PaymentsAuthError};
10
11#[derive(Clone)]
40pub struct PaymentAuditor {
41 sink: Arc<dyn AuditSink>,
42}
43
44impl PaymentAuditor {
45 #[must_use]
47 pub fn new(sink: Arc<dyn AuditSink>) -> Self {
48 Self { sink }
49 }
50
51 pub async fn record_operation(
57 &self,
58 request: &AuthenticatedPaymentRequest,
59 record: &TransactionRecord,
60 protocol: &ProtocolDescriptor,
61 operation: PaymentOperation,
62 outcome: AuditOutcome,
63 intervention_occurred: bool,
64 ) -> Result<(), PaymentsAuthError> {
65 let event = payment_audit_event(
66 request,
67 record,
68 protocol,
69 operation,
70 &outcome,
71 intervention_occurred,
72 );
73 self.sink.log(event).await.map_err(PaymentsAuthError::from)
74 }
75}
76
77fn payment_audit_event(
78 request: &AuthenticatedPaymentRequest,
79 record: &TransactionRecord,
80 protocol: &ProtocolDescriptor,
81 operation: PaymentOperation,
82 outcome: &AuditOutcome,
83 intervention_occurred: bool,
84) -> AuditEvent {
85 let metadata = json!({
86 "operation": operation.as_str(),
87 "transactionId": record.transaction_id.as_str(),
88 "protocol": protocol.name,
89 "protocolVersion": protocol.version,
90 "merchantOfRecord": {
91 "merchantId": record.merchant_of_record.merchant_id,
92 "legalName": record.merchant_of_record.legal_name,
93 "displayName": record.merchant_of_record.display_name,
94 },
95 "outcome": audit_outcome_name(outcome),
96 "interventionOccurred": intervention_occurred,
97 "authenticatedRequest": {
98 "userId": request.user_id,
99 "sessionId": request.session_id,
100 "tenantId": request.tenant_id,
101 "scopes": request.scopes,
102 "metadata": request.metadata,
103 },
104 "sessionIdentity": record.session_identity.is_some(),
105 "protocolActor": {
106 "actorId": record.initiated_by.actor_id,
107 "role": record.initiated_by.role,
108 "displayName": record.initiated_by.display_name,
109 "tenantId": record.initiated_by.tenant_id,
110 },
111 });
112
113 AuditEvent {
114 timestamp: Utc::now(),
115 user: request.user_id.clone(),
116 session_id: None, event_type: AuditEventType::PermissionCheck,
118 resource: operation.audit_resource().to_string(),
119 outcome: outcome.clone(),
120 metadata: Some(metadata),
121 }
122}
123
124fn audit_outcome_name(outcome: &AuditOutcome) -> &'static str {
125 match outcome {
126 AuditOutcome::Allowed => "allowed",
127 AuditOutcome::Denied => "denied",
128 AuditOutcome::Error => "error",
129 }
130}
131
132#[cfg(test)]
133mod tests {
134 use std::sync::{Arc, Mutex};
135
136 use adk_auth::AuthError;
137 use async_trait::async_trait;
138 use chrono::{TimeZone, Utc};
139 use serde_json::json;
140
141 use super::*;
142 use crate::domain::{
143 Cart, CartLine, CommerceActor, CommerceActorRole, CommerceMode, MerchantRef, Money,
144 ProtocolExtensions, TransactionId,
145 };
146
147 struct RecordingAuditSink {
148 events: Mutex<Vec<AuditEvent>>,
149 }
150
151 impl RecordingAuditSink {
152 fn new() -> Self {
153 Self { events: Mutex::new(Vec::new()) }
154 }
155
156 fn recorded_events(&self) -> Vec<AuditEvent> {
157 self.events.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).clone()
158 }
159 }
160
161 #[async_trait]
162 impl AuditSink for RecordingAuditSink {
163 async fn log(&self, event: AuditEvent) -> Result<(), AuthError> {
164 self.events.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).push(event);
165 Ok(())
166 }
167 }
168
169 fn sample_transaction() -> TransactionRecord {
170 let created_at = Utc.with_ymd_and_hms(2026, 3, 22, 14, 0, 0).unwrap();
171 let mut record = TransactionRecord::new(
172 TransactionId::from("tx-audit"),
173 CommerceActor {
174 actor_id: "merchant-agent".to_string(),
175 role: CommerceActorRole::Merchant,
176 display_name: Some("merchant".to_string()),
177 tenant_id: Some("tenant-1".to_string()),
178 extensions: ProtocolExtensions::default(),
179 },
180 MerchantRef {
181 merchant_id: "merchant-123".to_string(),
182 legal_name: "Merchant Example LLC".to_string(),
183 display_name: Some("Merchant Example".to_string()),
184 statement_descriptor: Some("MERCHANT*EXAMPLE".to_string()),
185 country_code: Some("US".to_string()),
186 website: Some("https://merchant.example".to_string()),
187 extensions: ProtocolExtensions::default(),
188 },
189 CommerceMode::HumanPresent,
190 Cart {
191 cart_id: Some("cart-1".to_string()),
192 lines: vec![CartLine {
193 line_id: "line-1".to_string(),
194 merchant_sku: Some("sku-1".to_string()),
195 title: "Widget".to_string(),
196 quantity: 1,
197 unit_price: Money::new("USD", 3_500, 2),
198 total_price: Money::new("USD", 3_500, 2),
199 product_class: Some("widgets".to_string()),
200 extensions: ProtocolExtensions::default(),
201 }],
202 subtotal: Some(Money::new("USD", 3_500, 2)),
203 adjustments: Vec::new(),
204 total: Money::new("USD", 3_500, 2),
205 affiliate_attribution: None,
206 extensions: ProtocolExtensions::default(),
207 },
208 created_at,
209 );
210 record.session_identity = Some(adk_core::AdkIdentity::new(
211 adk_core::AppName::try_from("commerce-app").unwrap(),
212 adk_core::UserId::try_from("alice").unwrap(),
213 adk_core::SessionId::try_from("session-123").unwrap(),
214 ));
215 record
216 }
217
218 #[tokio::test]
219 async fn auditor_emits_structured_payment_metadata() {
220 let sink = Arc::new(RecordingAuditSink::new());
221 let auditor = PaymentAuditor::new(sink.clone());
222 let request = AuthenticatedPaymentRequest::new("alice")
223 .with_session_id("session-123")
224 .with_tenant_id("tenant-1")
225 .with_scopes(["payments:checkout:complete"])
226 .with_metadata("channel", json!("agent"));
227
228 auditor
229 .record_operation(
230 &request,
231 &sample_transaction(),
232 &ProtocolDescriptor::acp("2026-01-30"),
233 PaymentOperation::CompleteCheckout,
234 AuditOutcome::Allowed,
235 true,
236 )
237 .await
238 .unwrap();
239
240 let events = sink.recorded_events();
241 assert_eq!(events.len(), 1);
242 assert_eq!(events[0].resource, "payments.checkout.complete");
243 assert_eq!(events[0].user, "alice");
244 assert_eq!(events[0].session_id, None);
245
246 let metadata = events[0].metadata.as_ref().unwrap();
247 assert_eq!(metadata["transactionId"], "tx-audit");
248 assert_eq!(metadata["protocol"], "acp");
249 assert_eq!(metadata["protocolVersion"], "2026-01-30");
250 assert_eq!(metadata["operation"], "checkout_complete");
251 assert_eq!(metadata["interventionOccurred"], true);
252 assert_eq!(metadata["authenticatedRequest"]["tenantId"], "tenant-1");
253 assert_eq!(metadata["protocolActor"]["actorId"], "merchant-agent");
254 assert_eq!(metadata["sessionIdentity"], true);
255 }
256}