1use serde::{Deserialize, Serialize};
2use serde_json::{Map, Value};
3
4use crate::domain::TransactionRecord;
5
6use super::{PaymentOperation, PaymentsAuthError, check_payment_operation_scopes};
7
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct AuthenticatedPaymentRequest {
30 pub user_id: String,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub session_id: Option<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub tenant_id: Option<String>,
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub scopes: Vec<String>,
37 #[serde(default, skip_serializing_if = "Map::is_empty")]
38 pub metadata: Map<String, Value>,
39}
40
41impl AuthenticatedPaymentRequest {
42 #[must_use]
44 pub fn new(user_id: impl Into<String>) -> Self {
45 Self {
46 user_id: user_id.into(),
47 session_id: None,
48 tenant_id: None,
49 scopes: Vec::new(),
50 metadata: Map::new(),
51 }
52 }
53
54 #[must_use]
56 pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
57 self.session_id = Some(session_id.into());
58 self
59 }
60
61 #[must_use]
63 pub fn with_tenant_id(mut self, tenant_id: impl Into<String>) -> Self {
64 self.tenant_id = Some(tenant_id.into());
65 self
66 }
67
68 #[must_use]
70 pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
71 where
72 I: IntoIterator<Item = S>,
73 S: Into<String>,
74 {
75 self.scopes = scopes.into_iter().map(Into::into).collect();
76 self
77 }
78
79 #[must_use]
81 pub fn with_metadata(mut self, key: impl Into<String>, value: Value) -> Self {
82 self.metadata.insert(key.into(), value);
83 self
84 }
85
86 pub fn check_operation_scopes(
93 &self,
94 operation: PaymentOperation,
95 ) -> Result<(), PaymentsAuthError> {
96 check_payment_operation_scopes(operation, &self.scopes)
97 }
98
99 pub fn assert_transaction_binding(
108 &self,
109 record: &TransactionRecord,
110 ) -> Result<(), PaymentsAuthError> {
111 if let Some(session_identity) = &record.session_identity {
112 let expected_user = session_identity.user_id.as_ref();
113 if self.user_id != expected_user {
114 return Err(PaymentsAuthError::IdentityConflict {
115 transaction_id: record.transaction_id.to_string(),
116 binding: "session_user_id",
117 expected: expected_user.to_string(),
118 actual: self.user_id.clone(),
119 });
120 }
121
122 if let Some(session_id) = &self.session_id
123 && session_id != session_identity.session_id.as_ref()
124 {
125 return Err(PaymentsAuthError::IdentityConflict {
126 transaction_id: record.transaction_id.to_string(),
127 binding: "session_id",
128 expected: session_identity.session_id.to_string(),
129 actual: session_id.clone(),
130 });
131 }
132 }
133
134 if let Some(request_tenant_id) = &self.tenant_id
135 && let Some(expected_tenant_id) = &record.initiated_by.tenant_id
136 && request_tenant_id != expected_tenant_id
137 {
138 return Err(PaymentsAuthError::IdentityConflict {
139 transaction_id: record.transaction_id.to_string(),
140 binding: "tenant_id",
141 expected: expected_tenant_id.clone(),
142 actual: request_tenant_id.clone(),
143 });
144 }
145
146 Ok(())
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use chrono::{TimeZone, Utc};
153 use serde_json::json;
154
155 use super::*;
156 use crate::domain::{
157 Cart, CartLine, CommerceActor, CommerceActorRole, CommerceMode, MerchantRef, Money,
158 ProtocolExtensions, TransactionId,
159 };
160
161 fn sample_transaction() -> TransactionRecord {
162 let created_at = Utc.with_ymd_and_hms(2026, 3, 22, 12, 0, 0).unwrap();
163 let mut record = TransactionRecord::new(
164 TransactionId::from("tx-tenant"),
165 CommerceActor {
166 actor_id: "shopper-agent".to_string(),
167 role: CommerceActorRole::AgentSurface,
168 display_name: Some("shopper".to_string()),
169 tenant_id: Some("tenant-1".to_string()),
170 extensions: ProtocolExtensions::default(),
171 },
172 MerchantRef {
173 merchant_id: "merchant-123".to_string(),
174 legal_name: "Merchant Example LLC".to_string(),
175 display_name: Some("Merchant Example".to_string()),
176 statement_descriptor: None,
177 country_code: Some("US".to_string()),
178 website: Some("https://merchant.example".to_string()),
179 extensions: ProtocolExtensions::default(),
180 },
181 CommerceMode::HumanPresent,
182 Cart {
183 cart_id: Some("cart-1".to_string()),
184 lines: vec![CartLine {
185 line_id: "line-1".to_string(),
186 merchant_sku: Some("sku-1".to_string()),
187 title: "Widget".to_string(),
188 quantity: 1,
189 unit_price: Money::new("USD", 2_500, 2),
190 total_price: Money::new("USD", 2_500, 2),
191 product_class: Some("widgets".to_string()),
192 extensions: ProtocolExtensions::default(),
193 }],
194 subtotal: Some(Money::new("USD", 2_500, 2)),
195 adjustments: Vec::new(),
196 total: Money::new("USD", 2_500, 2),
197 affiliate_attribution: None,
198 extensions: ProtocolExtensions::default(),
199 },
200 created_at,
201 );
202 record.session_identity = Some(adk_core::AdkIdentity::new(
203 adk_core::AppName::try_from("commerce-app").unwrap(),
204 adk_core::UserId::try_from("alice").unwrap(),
205 adk_core::SessionId::try_from("session-123").unwrap(),
206 ));
207 record
208 }
209
210 #[test]
211 fn request_builder_replaces_scopes_and_metadata() {
212 let request = AuthenticatedPaymentRequest::new("alice")
213 .with_session_id("session-123")
214 .with_tenant_id("tenant-1")
215 .with_scopes(["payments:checkout:update"])
216 .with_metadata("channel", json!("agent"));
217
218 assert_eq!(request.user_id, "alice");
219 assert_eq!(request.session_id.as_deref(), Some("session-123"));
220 assert_eq!(request.tenant_id.as_deref(), Some("tenant-1"));
221 assert_eq!(request.scopes, vec!["payments:checkout:update".to_string()]);
222 assert_eq!(request.metadata.get("channel"), Some(&json!("agent")));
223 }
224
225 #[test]
226 fn binding_check_rejects_tenant_rebinding() {
227 let record = sample_transaction();
228 let err = AuthenticatedPaymentRequest::new("alice")
229 .with_session_id("session-123")
230 .with_tenant_id("tenant-2")
231 .assert_transaction_binding(&record)
232 .unwrap_err();
233
234 match err {
235 PaymentsAuthError::IdentityConflict { transaction_id, binding, expected, actual } => {
236 assert_eq!(transaction_id, "tx-tenant");
237 assert_eq!(binding, "tenant_id");
238 assert_eq!(expected, "tenant-1");
239 assert_eq!(actual, "tenant-2");
240 }
241 other => panic!("unexpected auth error: {other}"),
242 }
243 }
244}