use std::sync::Arc;
use adk_core::Result;
use crate::domain::{
Cart, FulfillmentSelection, OrderSnapshot, PaymentMethodSelection, ProtocolExtensions,
ProtocolRefs, TransactionRecord, TransactionState,
};
use crate::kernel::commands::TransactionLookup;
use crate::kernel::errors::PaymentsKernelError;
use crate::kernel::service::TransactionStore;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProtocolOrigin {
Acp,
Ap2,
}
impl ProtocolOrigin {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Acp => "acp",
Self::Ap2 => "ap2",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProtocolRefKind {
AcpCheckoutSessionId,
AcpOrderId,
AcpDelegatePaymentId,
Ap2IntentMandateId,
Ap2CartMandateId,
Ap2PaymentMandateId,
Ap2PaymentReceiptId,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ProjectionResult<T> {
Projected(T),
Unsupported { field: String, source_protocol: String, reason: String },
}
impl<T> ProjectionResult<T> {
#[must_use]
pub fn ok(self) -> Option<T> {
match self {
Self::Projected(value) => Some(value),
Self::Unsupported { .. } => None,
}
}
#[must_use]
pub fn is_projected(&self) -> bool {
matches!(self, Self::Projected(_))
}
}
pub struct ProtocolCorrelator {
transaction_store: Arc<dyn TransactionStore>,
}
impl ProtocolCorrelator {
#[must_use]
pub fn new(transaction_store: Arc<dyn TransactionStore>) -> Self {
Self { transaction_store }
}
pub async fn get_transaction(
&self,
lookup: TransactionLookup,
) -> Result<Option<TransactionRecord>> {
self.transaction_store.get(lookup).await
}
pub async fn find_by_acp_checkout_session_id(
&self,
session_identity: Option<adk_core::AdkIdentity>,
acp_checkout_session_id: &str,
) -> Result<Option<TransactionRecord>> {
let unresolved = self
.transaction_store
.list_unresolved(crate::kernel::commands::ListUnresolvedTransactionsRequest {
session_identity: session_identity.clone(),
})
.await?;
Ok(unresolved.into_iter().find(|record| {
record.protocol_refs.acp_checkout_session_id.as_deref() == Some(acp_checkout_session_id)
}))
}
pub async fn find_by_ap2_mandate_id(
&self,
session_identity: Option<adk_core::AdkIdentity>,
mandate_id: &str,
) -> Result<Option<TransactionRecord>> {
let unresolved = self
.transaction_store
.list_unresolved(crate::kernel::commands::ListUnresolvedTransactionsRequest {
session_identity: session_identity.clone(),
})
.await?;
Ok(unresolved.into_iter().find(|record| {
let refs = &record.protocol_refs;
refs.ap2_intent_mandate_id.as_deref() == Some(mandate_id)
|| refs.ap2_cart_mandate_id.as_deref() == Some(mandate_id)
|| refs.ap2_payment_mandate_id.as_deref() == Some(mandate_id)
}))
}
pub fn attach_protocol_ref(
record: &mut TransactionRecord,
kind: ProtocolRefKind,
value: String,
) -> std::result::Result<(), PaymentsKernelError> {
let slot = match &kind {
ProtocolRefKind::AcpCheckoutSessionId => {
&mut record.protocol_refs.acp_checkout_session_id
}
ProtocolRefKind::AcpOrderId => &mut record.protocol_refs.acp_order_id,
ProtocolRefKind::AcpDelegatePaymentId => {
&mut record.protocol_refs.acp_delegate_payment_id
}
ProtocolRefKind::Ap2IntentMandateId => &mut record.protocol_refs.ap2_intent_mandate_id,
ProtocolRefKind::Ap2CartMandateId => &mut record.protocol_refs.ap2_cart_mandate_id,
ProtocolRefKind::Ap2PaymentMandateId => {
&mut record.protocol_refs.ap2_payment_mandate_id
}
ProtocolRefKind::Ap2PaymentReceiptId => {
&mut record.protocol_refs.ap2_payment_receipt_id
}
};
if let Some(existing) = slot.as_ref() {
if existing != &value {
return Err(PaymentsKernelError::UnsupportedAction {
action: format!("rebind protocol ref {kind:?} from `{existing}` to `{value}`"),
protocol: "kernel".to_string(),
});
}
return Ok(());
}
*slot = Some(value);
Ok(())
}
#[must_use]
pub fn correlated_refs(record: &TransactionRecord) -> &ProtocolRefs {
&record.protocol_refs
}
#[must_use]
pub fn contributing_protocols(record: &TransactionRecord) -> Vec<String> {
let mut protocols = std::collections::BTreeSet::new();
for digest in &record.evidence_digests {
protocols.insert(digest.evidence_ref.protocol.name.clone());
}
for evidence_ref in &record.evidence_refs {
protocols.insert(evidence_ref.protocol.name.clone());
}
for envelope in record.extensions.as_slice() {
protocols.insert(envelope.protocol.name.clone());
}
if record.protocol_refs.acp_checkout_session_id.is_some()
|| record.protocol_refs.acp_order_id.is_some()
|| record.protocol_refs.acp_delegate_payment_id.is_some()
{
protocols.insert("acp".to_string());
}
if record.protocol_refs.ap2_intent_mandate_id.is_some()
|| record.protocol_refs.ap2_cart_mandate_id.is_some()
|| record.protocol_refs.ap2_payment_mandate_id.is_some()
|| record.protocol_refs.ap2_payment_receipt_id.is_some()
{
protocols.insert("ap2".to_string());
}
protocols.into_iter().collect()
}
#[must_use]
pub fn is_dual_protocol(record: &TransactionRecord) -> bool {
let protocols = Self::contributing_protocols(record);
protocols.contains(&"acp".to_string()) && protocols.contains(&"ap2".to_string())
}
}
impl ProtocolCorrelator {
#[must_use]
pub fn project_acp_cart_to_canonical(record: &TransactionRecord) -> ProjectionResult<Cart> {
if record.cart.cart_id.is_some() || !record.cart.lines.is_empty() {
return ProjectionResult::Projected(record.cart.clone());
}
ProjectionResult::Unsupported {
field: "cart".to_string(),
source_protocol: "acp".to_string(),
reason: "no cart data available in the transaction record".to_string(),
}
}
#[must_use]
pub fn project_ap2_cart_to_canonical(record: &TransactionRecord) -> ProjectionResult<Cart> {
if record.cart.cart_id.is_some() || !record.cart.lines.is_empty() {
return ProjectionResult::Projected(record.cart.clone());
}
ProjectionResult::Unsupported {
field: "cart".to_string(),
source_protocol: "ap2".to_string(),
reason: "no cart data available in the transaction record".to_string(),
}
}
#[must_use]
pub fn project_order_to_canonical(
record: &TransactionRecord,
) -> ProjectionResult<OrderSnapshot> {
match &record.order {
Some(order) => ProjectionResult::Projected(order.clone()),
None => ProjectionResult::Unsupported {
field: "order".to_string(),
source_protocol: "kernel".to_string(),
reason: "transaction has no order snapshot yet".to_string(),
},
}
}
#[must_use]
pub fn project_settlement_state(
record: &TransactionRecord,
) -> ProjectionResult<TransactionState> {
ProjectionResult::Projected(record.state.clone())
}
#[must_use]
pub fn project_fulfillment_to_canonical(
record: &TransactionRecord,
) -> ProjectionResult<FulfillmentSelection> {
match &record.fulfillment {
Some(fulfillment) => ProjectionResult::Projected(fulfillment.clone()),
None => ProjectionResult::Unsupported {
field: "fulfillment".to_string(),
source_protocol: "kernel".to_string(),
reason: "transaction has no fulfillment selection".to_string(),
},
}
}
#[must_use]
pub fn project_payment_method(
record: &TransactionRecord,
) -> ProjectionResult<PaymentMethodSelection> {
for envelope in record.extensions.as_slice().iter().rev() {
if let Some(selection_kind) =
envelope.fields.get("selection_kind").and_then(serde_json::Value::as_str)
{
return ProjectionResult::Projected(PaymentMethodSelection {
selection_kind: selection_kind.to_string(),
reference: envelope
.fields
.get("reference")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
display_hint: envelope
.fields
.get("display_hint")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
extensions: ProtocolExtensions::default(),
});
}
}
ProjectionResult::Unsupported {
field: "payment_method".to_string(),
source_protocol: "kernel".to_string(),
reason: "no payment method selection found in transaction extensions".to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LossyConversionError {
pub source_protocol: String,
pub target_protocol: String,
pub field: String,
pub reason: String,
}
impl std::fmt::Display for LossyConversionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"lossy conversion refused: `{}` from {} cannot be safely mapped to {} ({})",
self.field, self.source_protocol, self.target_protocol, self.reason
)
}
}
impl From<LossyConversionError> for PaymentsKernelError {
fn from(value: LossyConversionError) -> Self {
PaymentsKernelError::UnsupportedAction {
action: format!(
"convert `{}` from {} to {}",
value.field, value.source_protocol, value.target_protocol
),
protocol: value.source_protocol,
}
}
}
impl ProtocolCorrelator {
pub fn refuse_acp_delegate_to_ap2_authorization(
field: &str,
) -> std::result::Result<(), PaymentsKernelError> {
Err(LossyConversionError {
source_protocol: "acp".to_string(),
target_protocol: "ap2".to_string(),
field: field.to_string(),
reason: "ACP delegated payment tokens are scoped PSP credentials; \
AP2 user authorization artifacts are cryptographic proofs of user consent. \
Converting one to the other would fabricate provenance."
.to_string(),
}
.into())
}
pub fn refuse_ap2_authorization_to_acp_delegate(
field: &str,
) -> std::result::Result<(), PaymentsKernelError> {
Err(LossyConversionError {
source_protocol: "ap2".to_string(),
target_protocol: "acp".to_string(),
field: field.to_string(),
reason: "AP2 signed user authorization artifacts prove consent through \
cryptographic signatures. ACP delegated payment tokens are PSP-issued \
scoped credentials. Converting one to the other would lose the \
cryptographic accountability chain."
.to_string(),
}
.into())
}
pub fn refuse_acp_session_to_ap2_mandate(
field: &str,
) -> std::result::Result<(), PaymentsKernelError> {
Err(LossyConversionError {
source_protocol: "acp".to_string(),
target_protocol: "ap2".to_string(),
field: field.to_string(),
reason: "ACP checkout sessions are merchant-facing HTTP resources with \
server-managed lifecycle. AP2 mandates are signed authorization \
artifacts with explicit role separation. Direct conversion would \
lose the authorization model."
.to_string(),
}
.into())
}
pub fn refuse_ap2_mandate_to_acp_session(
field: &str,
) -> std::result::Result<(), PaymentsKernelError> {
Err(LossyConversionError {
source_protocol: "ap2".to_string(),
target_protocol: "acp".to_string(),
field: field.to_string(),
reason: "AP2 mandates carry cryptographic authorization chains and role-separated \
provenance. ACP checkout sessions are server-managed merchant resources. \
Direct conversion would discard the mandate's authorization evidence."
.to_string(),
}
.into())
}
pub fn require_kernel_mediation(
record: &TransactionRecord,
source: ProtocolOrigin,
target: ProtocolOrigin,
operation: &str,
) -> std::result::Result<(), PaymentsKernelError> {
if source == target {
return Ok(());
}
if record.transaction_id.as_str().is_empty() {
return Err(PaymentsKernelError::UnsupportedAction {
action: format!("cross-protocol `{operation}` requires a canonical transaction ID"),
protocol: source.as_str().to_string(),
});
}
Ok(())
}
}