use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use crate::domain::TransactionRecord;
use super::{PaymentOperation, PaymentsAuthError, check_payment_operation_scopes};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthenticatedPaymentRequest {
pub user_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scopes: Vec<String>,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub metadata: Map<String, Value>,
}
impl AuthenticatedPaymentRequest {
#[must_use]
pub fn new(user_id: impl Into<String>) -> Self {
Self {
user_id: user_id.into(),
session_id: None,
tenant_id: None,
scopes: Vec::new(),
metadata: Map::new(),
}
}
#[must_use]
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
self.session_id = Some(session_id.into());
self
}
#[must_use]
pub fn with_tenant_id(mut self, tenant_id: impl Into<String>) -> Self {
self.tenant_id = Some(tenant_id.into());
self
}
#[must_use]
pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.scopes = scopes.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn with_metadata(mut self, key: impl Into<String>, value: Value) -> Self {
self.metadata.insert(key.into(), value);
self
}
pub fn check_operation_scopes(
&self,
operation: PaymentOperation,
) -> Result<(), PaymentsAuthError> {
check_payment_operation_scopes(operation, &self.scopes)
}
pub fn assert_transaction_binding(
&self,
record: &TransactionRecord,
) -> Result<(), PaymentsAuthError> {
if let Some(session_identity) = &record.session_identity {
let expected_user = session_identity.user_id.as_ref();
if self.user_id != expected_user {
return Err(PaymentsAuthError::IdentityConflict {
transaction_id: record.transaction_id.to_string(),
binding: "session_user_id",
expected: expected_user.to_string(),
actual: self.user_id.clone(),
});
}
if let Some(session_id) = &self.session_id
&& session_id != session_identity.session_id.as_ref()
{
return Err(PaymentsAuthError::IdentityConflict {
transaction_id: record.transaction_id.to_string(),
binding: "session_id",
expected: session_identity.session_id.to_string(),
actual: session_id.clone(),
});
}
}
if let Some(request_tenant_id) = &self.tenant_id
&& let Some(expected_tenant_id) = &record.initiated_by.tenant_id
&& request_tenant_id != expected_tenant_id
{
return Err(PaymentsAuthError::IdentityConflict {
transaction_id: record.transaction_id.to_string(),
binding: "tenant_id",
expected: expected_tenant_id.clone(),
actual: request_tenant_id.clone(),
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Utc};
use serde_json::json;
use super::*;
use crate::domain::{
Cart, CartLine, CommerceActor, CommerceActorRole, CommerceMode, MerchantRef, Money,
ProtocolExtensions, TransactionId,
};
fn sample_transaction() -> TransactionRecord {
let created_at = Utc.with_ymd_and_hms(2026, 3, 22, 12, 0, 0).unwrap();
let mut record = TransactionRecord::new(
TransactionId::from("tx-tenant"),
CommerceActor {
actor_id: "shopper-agent".to_string(),
role: CommerceActorRole::AgentSurface,
display_name: Some("shopper".to_string()),
tenant_id: Some("tenant-1".to_string()),
extensions: ProtocolExtensions::default(),
},
MerchantRef {
merchant_id: "merchant-123".to_string(),
legal_name: "Merchant Example LLC".to_string(),
display_name: Some("Merchant Example".to_string()),
statement_descriptor: None,
country_code: Some("US".to_string()),
website: Some("https://merchant.example".to_string()),
extensions: ProtocolExtensions::default(),
},
CommerceMode::HumanPresent,
Cart {
cart_id: Some("cart-1".to_string()),
lines: vec![CartLine {
line_id: "line-1".to_string(),
merchant_sku: Some("sku-1".to_string()),
title: "Widget".to_string(),
quantity: 1,
unit_price: Money::new("USD", 2_500, 2),
total_price: Money::new("USD", 2_500, 2),
product_class: Some("widgets".to_string()),
extensions: ProtocolExtensions::default(),
}],
subtotal: Some(Money::new("USD", 2_500, 2)),
adjustments: Vec::new(),
total: Money::new("USD", 2_500, 2),
affiliate_attribution: None,
extensions: ProtocolExtensions::default(),
},
created_at,
);
record.session_identity = Some(adk_core::AdkIdentity::new(
adk_core::AppName::try_from("commerce-app").unwrap(),
adk_core::UserId::try_from("alice").unwrap(),
adk_core::SessionId::try_from("session-123").unwrap(),
));
record
}
#[test]
fn request_builder_replaces_scopes_and_metadata() {
let request = AuthenticatedPaymentRequest::new("alice")
.with_session_id("session-123")
.with_tenant_id("tenant-1")
.with_scopes(["payments:checkout:update"])
.with_metadata("channel", json!("agent"));
assert_eq!(request.user_id, "alice");
assert_eq!(request.session_id.as_deref(), Some("session-123"));
assert_eq!(request.tenant_id.as_deref(), Some("tenant-1"));
assert_eq!(request.scopes, vec!["payments:checkout:update".to_string()]);
assert_eq!(request.metadata.get("channel"), Some(&json!("agent")));
}
#[test]
fn binding_check_rejects_tenant_rebinding() {
let record = sample_transaction();
let err = AuthenticatedPaymentRequest::new("alice")
.with_session_id("session-123")
.with_tenant_id("tenant-2")
.assert_transaction_binding(&record)
.unwrap_err();
match err {
PaymentsAuthError::IdentityConflict { transaction_id, binding, expected, actual } => {
assert_eq!(transaction_id, "tx-tenant");
assert_eq!(binding, "tenant_id");
assert_eq!(expected, "tenant-1");
assert_eq!(actual, "tenant-2");
}
other => panic!("unexpected auth error: {other}"),
}
}
}