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 workspace_id: None,
122 tenant_id: request.tenant_id.clone(),
123 request_id: None,
124 ip_address: None,
125 resource_id: None,
126 action: Some("permission_check".to_string()),
127 prev_hash: None,
128 }
129}
130
131fn audit_outcome_name(outcome: &AuditOutcome) -> &'static str {
132 match outcome {
133 AuditOutcome::Allowed => "allowed",
134 AuditOutcome::Denied => "denied",
135 AuditOutcome::Error => "error",
136 _ => "unknown",
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use std::sync::{Arc, Mutex};
143
144 use adk_auth::AuthError;
145 use async_trait::async_trait;
146 use chrono::{TimeZone, Utc};
147 use serde_json::json;
148
149 use super::*;
150 use crate::domain::{
151 Cart, CartLine, CommerceActor, CommerceActorRole, CommerceMode, MerchantRef, Money,
152 ProtocolExtensions, TransactionId,
153 };
154
155 struct RecordingAuditSink {
156 events: Mutex<Vec<AuditEvent>>,
157 }
158
159 impl RecordingAuditSink {
160 fn new() -> Self {
161 Self { events: Mutex::new(Vec::new()) }
162 }
163
164 fn recorded_events(&self) -> Vec<AuditEvent> {
165 self.events.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).clone()
166 }
167 }
168
169 #[async_trait]
170 impl AuditSink for RecordingAuditSink {
171 async fn log(&self, event: AuditEvent) -> Result<(), AuthError> {
172 self.events.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).push(event);
173 Ok(())
174 }
175 }
176
177 fn sample_transaction() -> TransactionRecord {
178 let created_at = Utc.with_ymd_and_hms(2026, 3, 22, 14, 0, 0).unwrap();
179 let mut record = TransactionRecord::new(
180 TransactionId::from("tx-audit"),
181 CommerceActor {
182 actor_id: "merchant-agent".to_string(),
183 role: CommerceActorRole::Merchant,
184 display_name: Some("merchant".to_string()),
185 tenant_id: Some("tenant-1".to_string()),
186 extensions: ProtocolExtensions::default(),
187 },
188 MerchantRef {
189 merchant_id: "merchant-123".to_string(),
190 legal_name: "Merchant Example LLC".to_string(),
191 display_name: Some("Merchant Example".to_string()),
192 statement_descriptor: Some("MERCHANT*EXAMPLE".to_string()),
193 country_code: Some("US".to_string()),
194 website: Some("https://merchant.example".to_string()),
195 extensions: ProtocolExtensions::default(),
196 },
197 CommerceMode::HumanPresent,
198 Cart {
199 cart_id: Some("cart-1".to_string()),
200 lines: vec![CartLine {
201 line_id: "line-1".to_string(),
202 merchant_sku: Some("sku-1".to_string()),
203 title: "Widget".to_string(),
204 quantity: 1,
205 unit_price: Money::new("USD", 3_500, 2),
206 total_price: Money::new("USD", 3_500, 2),
207 product_class: Some("widgets".to_string()),
208 extensions: ProtocolExtensions::default(),
209 }],
210 subtotal: Some(Money::new("USD", 3_500, 2)),
211 adjustments: Vec::new(),
212 total: Money::new("USD", 3_500, 2),
213 affiliate_attribution: None,
214 extensions: ProtocolExtensions::default(),
215 },
216 created_at,
217 );
218 record.session_identity = Some(adk_core::AdkIdentity::new(
219 adk_core::AppName::try_from("commerce-app").unwrap(),
220 adk_core::UserId::try_from("alice").unwrap(),
221 adk_core::SessionId::try_from("session-123").unwrap(),
222 ));
223 record
224 }
225
226 #[tokio::test]
227 async fn auditor_emits_structured_payment_metadata() {
228 let sink = Arc::new(RecordingAuditSink::new());
229 let auditor = PaymentAuditor::new(sink.clone());
230 let request = AuthenticatedPaymentRequest::new("alice")
231 .with_session_id("session-123")
232 .with_tenant_id("tenant-1")
233 .with_scopes(["payments:checkout:complete"])
234 .with_metadata("channel", json!("agent"));
235
236 auditor
237 .record_operation(
238 &request,
239 &sample_transaction(),
240 &ProtocolDescriptor::acp("2026-01-30"),
241 PaymentOperation::CompleteCheckout,
242 AuditOutcome::Allowed,
243 true,
244 )
245 .await
246 .unwrap();
247
248 let events = sink.recorded_events();
249 assert_eq!(events.len(), 1);
250 assert_eq!(events[0].resource, "payments.checkout.complete");
251 assert_eq!(events[0].user, "alice");
252 assert_eq!(events[0].session_id, None);
253
254 let metadata = events[0].metadata.as_ref().unwrap();
255 assert_eq!(metadata["transactionId"], "tx-audit");
256 assert_eq!(metadata["protocol"], "acp");
257 assert_eq!(metadata["protocolVersion"], "2026-01-30");
258 assert_eq!(metadata["operation"], "checkout_complete");
259 assert_eq!(metadata["interventionOccurred"], true);
260 assert_eq!(metadata["authenticatedRequest"]["tenantId"], "tenant-1");
261 assert_eq!(metadata["protocolActor"]["actorId"], "merchant-agent");
262 assert_eq!(metadata["sessionIdentity"], true);
263 }
264}