1use std::fmt;
2
3use adk_core::AdkIdentity;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeSet;
7
8use crate::domain::{
9 Cart, CommerceActor, EvidenceReference, FulfillmentSelection, InterventionState, MerchantRef,
10 OrderSnapshot, OrderState, PaymentProcessorRef, ProtocolDescriptor, ProtocolExtensionEnvelope,
11 ProtocolExtensions, ReceiptState,
12};
13use crate::kernel::PaymentsKernelError;
14
15#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
17#[serde(transparent)]
18pub struct TransactionId(pub String);
19
20impl TransactionId {
21 #[must_use]
23 pub fn as_str(&self) -> &str {
24 &self.0
25 }
26}
27
28impl From<&str> for TransactionId {
29 fn from(value: &str) -> Self {
30 Self(value.to_string())
31 }
32}
33
34impl From<String> for TransactionId {
35 fn from(value: String) -> Self {
36 Self(value)
37 }
38}
39
40impl fmt::Display for TransactionId {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 f.write_str(self.as_str())
43 }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum CommerceMode {
50 HumanPresent,
51 HumanNotPresent,
52}
53
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum TransactionState {
58 Draft,
59 Negotiating,
60 AwaitingUserAuthorization,
61 AwaitingPaymentMethod,
62 InterventionRequired(Box<InterventionState>),
63 Authorized,
64 Completed,
65 Canceled,
66 Failed,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum TransactionStateTag {
73 Draft,
74 Negotiating,
75 AwaitingUserAuthorization,
76 AwaitingPaymentMethod,
77 InterventionRequired,
78 Authorized,
79 Completed,
80 Canceled,
81 Failed,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85enum TransactionPhase {
86 Draft,
87 Negotiating,
88 AwaitingUserAuthorization,
89 AwaitingPaymentMethod,
90 InterventionRequired,
91 Authorized,
92 Completed,
93 Canceled,
94 Failed,
95}
96
97impl TransactionPhase {
98 const fn as_str(self) -> &'static str {
99 match self {
100 Self::Draft => "draft",
101 Self::Negotiating => "negotiating",
102 Self::AwaitingUserAuthorization => "awaiting_user_authorization",
103 Self::AwaitingPaymentMethod => "awaiting_payment_method",
104 Self::InterventionRequired => "intervention_required",
105 Self::Authorized => "authorized",
106 Self::Completed => "completed",
107 Self::Canceled => "canceled",
108 Self::Failed => "failed",
109 }
110 }
111}
112
113impl TransactionState {
114 fn phase(&self) -> TransactionPhase {
115 match self {
116 Self::Draft => TransactionPhase::Draft,
117 Self::Negotiating => TransactionPhase::Negotiating,
118 Self::AwaitingUserAuthorization => TransactionPhase::AwaitingUserAuthorization,
119 Self::AwaitingPaymentMethod => TransactionPhase::AwaitingPaymentMethod,
120 Self::InterventionRequired(_) => TransactionPhase::InterventionRequired,
121 Self::Authorized => TransactionPhase::Authorized,
122 Self::Completed => TransactionPhase::Completed,
123 Self::Canceled => TransactionPhase::Canceled,
124 Self::Failed => TransactionPhase::Failed,
125 }
126 }
127
128 #[must_use]
131 pub fn can_transition_to(&self, next: &Self) -> bool {
132 use TransactionPhase::{
133 Authorized, AwaitingPaymentMethod, AwaitingUserAuthorization, Canceled, Completed,
134 Draft, Failed, InterventionRequired, Negotiating,
135 };
136
137 match (self.phase(), next.phase()) {
138 (from, to) if from == to => true,
139 (Draft, Negotiating | Canceled | Failed) => true,
140 (
141 Negotiating,
142 AwaitingUserAuthorization
143 | AwaitingPaymentMethod
144 | InterventionRequired
145 | Canceled
146 | Failed,
147 ) => true,
148 (
149 AwaitingUserAuthorization,
150 AwaitingPaymentMethod | InterventionRequired | Canceled | Failed,
151 ) => true,
152 (AwaitingPaymentMethod, Authorized | InterventionRequired | Canceled | Failed) => true,
153 (
154 InterventionRequired,
155 AwaitingUserAuthorization | AwaitingPaymentMethod | Authorized | Canceled | Failed,
156 ) => true,
157 (Authorized, Completed | Canceled | Failed) => true,
158 _ => false,
159 }
160 }
161
162 #[must_use]
164 pub fn is_terminal(&self) -> bool {
165 matches!(self, Self::Completed | Self::Canceled | Self::Failed)
166 }
167
168 #[must_use]
170 pub fn tag(&self) -> TransactionStateTag {
171 match self {
172 Self::Draft => TransactionStateTag::Draft,
173 Self::Negotiating => TransactionStateTag::Negotiating,
174 Self::AwaitingUserAuthorization => TransactionStateTag::AwaitingUserAuthorization,
175 Self::AwaitingPaymentMethod => TransactionStateTag::AwaitingPaymentMethod,
176 Self::InterventionRequired(_) => TransactionStateTag::InterventionRequired,
177 Self::Authorized => TransactionStateTag::Authorized,
178 Self::Completed => TransactionStateTag::Completed,
179 Self::Canceled => TransactionStateTag::Canceled,
180 Self::Failed => TransactionStateTag::Failed,
181 }
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187#[serde(rename_all = "camelCase")]
188pub struct PaymentMethodSelection {
189 pub selection_kind: String,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub reference: Option<String>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub display_hint: Option<String>,
194 #[serde(default, skip_serializing_if = "ProtocolExtensions::is_empty")]
195 pub extensions: ProtocolExtensions,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "camelCase")]
201pub struct ProtocolReference {
202 pub protocol: ProtocolDescriptor,
203 pub reference_kind: String,
204 pub reference_value: String,
205}
206
207#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct ProtocolRefs {
211 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub acp_checkout_session_id: Option<String>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub acp_order_id: Option<String>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub acp_delegate_payment_id: Option<String>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub ap2_intent_mandate_id: Option<String>,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub ap2_cart_mandate_id: Option<String>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub ap2_payment_mandate_id: Option<String>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub ap2_payment_receipt_id: Option<String>,
225 #[serde(default, skip_serializing_if = "Vec::is_empty")]
226 pub additional: Vec<ProtocolReference>,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232#[serde(rename_all = "camelCase")]
233pub struct ProtocolEnvelopeDigest {
234 pub evidence_ref: EvidenceReference,
235 pub created_at: DateTime<Utc>,
236}
237
238impl ProtocolEnvelopeDigest {
239 #[must_use]
241 pub fn new(evidence_ref: EvidenceReference, created_at: DateTime<Utc>) -> Self {
242 Self { evidence_ref, created_at }
243 }
244}
245
246#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
248#[serde(rename_all = "camelCase")]
249pub struct SafeTransactionSummary {
250 pub transaction_id: TransactionId,
251 pub merchant_name: String,
252 pub item_titles: Vec<String>,
253 pub total: crate::domain::Money,
254 pub mode: CommerceMode,
255 pub state: TransactionStateTag,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub order_state: Option<OrderState>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub receipt_state: Option<ReceiptState>,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub next_required_action: Option<String>,
262 #[serde(default, skip_serializing_if = "Vec::is_empty")]
263 pub protocol_tags: Vec<String>,
264 pub updated_at: DateTime<Utc>,
265}
266
267impl SafeTransactionSummary {
268 #[must_use]
270 pub fn from_record(record: &TransactionRecord) -> Self {
271 let mut protocol_tags = BTreeSet::new();
272
273 for digest in &record.evidence_digests {
274 let descriptor = &digest.evidence_ref.protocol;
275 let tag = descriptor.version.as_ref().map_or_else(
276 || descriptor.name.clone(),
277 |version| format!("{}@{version}", descriptor.name),
278 );
279 protocol_tags.insert(tag);
280 }
281
282 for envelope in record.extensions.as_slice() {
283 let descriptor = &envelope.protocol;
284 let tag = descriptor.version.as_ref().map_or_else(
285 || descriptor.name.clone(),
286 |version| format!("{}@{version}", descriptor.name),
287 );
288 protocol_tags.insert(tag);
289 }
290
291 let next_required_action = match &record.state {
292 TransactionState::Draft | TransactionState::Negotiating => {
293 Some("continue checkout negotiation".to_string())
294 }
295 TransactionState::AwaitingUserAuthorization => {
296 Some("obtain explicit user authorization".to_string())
297 }
298 TransactionState::AwaitingPaymentMethod => {
299 Some("collect or delegate a payment method".to_string())
300 }
301 TransactionState::InterventionRequired(intervention) => intervention
302 .instructions
303 .clone()
304 .or_else(|| Some("complete the required payment intervention".to_string())),
305 TransactionState::Authorized => {
306 Some("await order completion or settlement".to_string())
307 }
308 TransactionState::Completed | TransactionState::Canceled | TransactionState::Failed => {
309 None
310 }
311 };
312
313 Self {
314 transaction_id: record.transaction_id.clone(),
315 merchant_name: record
316 .merchant_of_record
317 .display_name
318 .clone()
319 .unwrap_or_else(|| record.merchant_of_record.legal_name.clone()),
320 item_titles: record.cart.lines.iter().map(|line| line.title.clone()).collect(),
321 total: record.cart.total.clone(),
322 mode: record.mode,
323 state: record.state.tag(),
324 order_state: record.order.as_ref().map(|order| order.state),
325 receipt_state: record.order.as_ref().map(|order| order.receipt_state),
326 next_required_action,
327 protocol_tags: protocol_tags.into_iter().collect(),
328 updated_at: record.last_updated_at,
329 }
330 }
331
332 #[must_use]
334 pub fn transcript_text(&self) -> String {
335 let items = if self.item_titles.is_empty() {
336 "items unavailable".to_string()
337 } else {
338 self.item_titles.join(", ")
339 };
340
341 let next = self
342 .next_required_action
343 .as_ref()
344 .map_or_else(String::new, |action| format!(" Next action: {action}."));
345
346 format!(
347 "Transaction {} with {} is {:?} for {} {}. Items: {}.{}",
348 self.transaction_id,
349 self.merchant_name,
350 self.state,
351 self.total.amount_minor,
352 self.total.currency,
353 items,
354 next
355 )
356 }
357}
358
359#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
361#[serde(rename_all = "camelCase")]
362pub struct TransactionRecord {
363 pub transaction_id: TransactionId,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub session_identity: Option<AdkIdentity>,
366 pub initiated_by: CommerceActor,
367 pub merchant_of_record: MerchantRef,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub payment_processor: Option<PaymentProcessorRef>,
370 pub mode: CommerceMode,
371 pub state: TransactionState,
372 pub cart: Cart,
373 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub fulfillment: Option<FulfillmentSelection>,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub order: Option<OrderSnapshot>,
377 #[serde(default)]
378 pub protocol_refs: ProtocolRefs,
379 #[serde(default, skip_serializing_if = "ProtocolExtensions::is_empty")]
380 pub extensions: ProtocolExtensions,
381 #[serde(default, skip_serializing_if = "Vec::is_empty")]
382 pub evidence_refs: Vec<EvidenceReference>,
383 pub safe_summary: SafeTransactionSummary,
384 #[serde(default, skip_serializing_if = "Vec::is_empty")]
385 pub evidence_digests: Vec<ProtocolEnvelopeDigest>,
386 pub created_at: DateTime<Utc>,
387 pub last_updated_at: DateTime<Utc>,
388}
389
390impl TransactionRecord {
391 #[must_use]
393 pub fn new(
394 transaction_id: TransactionId,
395 initiated_by: CommerceActor,
396 merchant_of_record: MerchantRef,
397 mode: CommerceMode,
398 cart: Cart,
399 created_at: DateTime<Utc>,
400 ) -> Self {
401 let total = cart.total.clone();
402 let mut record = Self {
403 transaction_id,
404 session_identity: None,
405 initiated_by,
406 merchant_of_record,
407 payment_processor: None,
408 mode,
409 state: TransactionState::Draft,
410 cart,
411 fulfillment: None,
412 order: None,
413 protocol_refs: ProtocolRefs::default(),
414 extensions: ProtocolExtensions::default(),
415 evidence_refs: Vec::new(),
416 safe_summary: SafeTransactionSummary {
417 transaction_id: TransactionId::from("pending"),
418 merchant_name: String::new(),
419 item_titles: Vec::new(),
420 total,
421 mode,
422 state: TransactionStateTag::Draft,
423 order_state: None,
424 receipt_state: None,
425 next_required_action: None,
426 protocol_tags: Vec::new(),
427 updated_at: created_at,
428 },
429 evidence_digests: Vec::new(),
430 created_at,
431 last_updated_at: created_at,
432 };
433 record.recompute_safe_summary();
434 record
435 }
436
437 pub fn transition_to(
444 &mut self,
445 next: TransactionState,
446 updated_at: DateTime<Utc>,
447 ) -> std::result::Result<(), PaymentsKernelError> {
448 if !self.state.can_transition_to(&next) {
449 return Err(PaymentsKernelError::InvalidTransactionTransition {
450 from: self.state.phase().as_str(),
451 to: next.phase().as_str(),
452 });
453 }
454
455 self.state = next;
456 self.last_updated_at = updated_at;
457 self.recompute_safe_summary();
458 Ok(())
459 }
460
461 pub fn attach_extension(&mut self, envelope: ProtocolExtensionEnvelope) {
464 self.extensions.push(envelope);
465 self.recompute_safe_summary();
466 }
467
468 pub fn attach_evidence_ref(&mut self, evidence_ref: EvidenceReference) {
470 self.evidence_refs.push(evidence_ref);
471 }
472
473 pub fn attach_evidence_digest(&mut self, digest: ProtocolEnvelopeDigest) {
475 self.evidence_digests.push(digest);
476 self.recompute_safe_summary();
477 }
478
479 pub fn recompute_safe_summary(&mut self) {
481 self.safe_summary = SafeTransactionSummary::from_record(self);
482 }
483}
484
485#[cfg(test)]
486mod tests {
487 use chrono::{TimeZone, Utc};
488 use serde_json::{Map, json};
489
490 use super::*;
491 use crate::domain::{
492 CartLine, FulfillmentKind, InterventionKind, InterventionStatus, Money, OrderState,
493 ReceiptState,
494 };
495
496 fn sample_actor() -> CommerceActor {
497 CommerceActor {
498 actor_id: "shopper-agent".to_string(),
499 role: crate::domain::CommerceActorRole::AgentSurface,
500 display_name: Some("shopper".to_string()),
501 tenant_id: Some("tenant-1".to_string()),
502 extensions: ProtocolExtensions::default(),
503 }
504 }
505
506 fn sample_merchant() -> MerchantRef {
507 MerchantRef {
508 merchant_id: "merchant-123".to_string(),
509 legal_name: "Merchant Example LLC".to_string(),
510 display_name: Some("Merchant Example".to_string()),
511 statement_descriptor: Some("MERCHANT*EXAMPLE".to_string()),
512 country_code: Some("US".to_string()),
513 website: Some("https://merchant.example".to_string()),
514 extensions: ProtocolExtensions::default(),
515 }
516 }
517
518 fn sample_cart() -> Cart {
519 Cart {
520 cart_id: Some("cart-1".to_string()),
521 lines: vec![CartLine {
522 line_id: "line-1".to_string(),
523 merchant_sku: Some("sku-123".to_string()),
524 title: "Widget".to_string(),
525 quantity: 1,
526 unit_price: Money::new("USD", 1_500, 2),
527 total_price: Money::new("USD", 1_500, 2),
528 product_class: Some("widgets".to_string()),
529 extensions: ProtocolExtensions::default(),
530 }],
531 subtotal: Some(Money::new("USD", 1_500, 2)),
532 adjustments: Vec::new(),
533 total: Money::new("USD", 1_500, 2),
534 affiliate_attribution: None,
535 extensions: ProtocolExtensions::default(),
536 }
537 }
538
539 fn sample_record() -> TransactionRecord {
540 TransactionRecord::new(
541 TransactionId::from("tx-123"),
542 sample_actor(),
543 sample_merchant(),
544 CommerceMode::HumanPresent,
545 sample_cart(),
546 Utc.with_ymd_and_hms(2026, 3, 22, 10, 0, 0).unwrap(),
547 )
548 }
549
550 #[test]
551 fn transaction_state_machine_allows_happy_path() {
552 let mut record = sample_record();
553
554 record
555 .transition_to(
556 TransactionState::Negotiating,
557 Utc.with_ymd_and_hms(2026, 3, 22, 10, 5, 0).unwrap(),
558 )
559 .unwrap();
560 record
561 .transition_to(
562 TransactionState::AwaitingPaymentMethod,
563 Utc.with_ymd_and_hms(2026, 3, 22, 10, 6, 0).unwrap(),
564 )
565 .unwrap();
566 record
567 .transition_to(
568 TransactionState::Authorized,
569 Utc.with_ymd_and_hms(2026, 3, 22, 10, 7, 0).unwrap(),
570 )
571 .unwrap();
572 record
573 .transition_to(
574 TransactionState::Completed,
575 Utc.with_ymd_and_hms(2026, 3, 22, 10, 8, 0).unwrap(),
576 )
577 .unwrap();
578
579 assert_eq!(record.state, TransactionState::Completed);
580 assert!(record.state.is_terminal());
581 }
582
583 #[test]
584 fn transaction_state_machine_rejects_skipped_transition() {
585 let mut record = sample_record();
586 let err = record
587 .transition_to(
588 TransactionState::Completed,
589 Utc.with_ymd_and_hms(2026, 3, 22, 10, 5, 0).unwrap(),
590 )
591 .unwrap_err();
592
593 assert_eq!(
594 err,
595 PaymentsKernelError::InvalidTransactionTransition { from: "draft", to: "completed" }
596 );
597 assert_eq!(record.state, TransactionState::Draft);
598 }
599
600 #[test]
601 fn order_and_receipt_state_machines_enforce_progression() {
602 let mut order = OrderSnapshot {
603 order_id: Some("order-1".to_string()),
604 receipt_id: None,
605 state: OrderState::Draft,
606 receipt_state: ReceiptState::NotRequested,
607 extensions: ProtocolExtensions::default(),
608 };
609
610 order.transition_order_state(OrderState::PendingPayment).unwrap();
611 order.transition_order_state(OrderState::Authorized).unwrap();
612 order.transition_receipt_state(ReceiptState::Pending).unwrap();
613 order.transition_receipt_state(ReceiptState::Authorized).unwrap();
614
615 let err = order.transition_order_state(OrderState::Refunded).unwrap_err();
616 assert_eq!(
617 err,
618 PaymentsKernelError::InvalidOrderTransition {
619 from: OrderState::Authorized,
620 to: OrderState::Refunded,
621 }
622 );
623 }
624
625 #[test]
626 fn protocol_extensions_round_trip_without_loss() {
627 let mut record = sample_record();
628 record.fulfillment = Some(FulfillmentSelection {
629 fulfillment_id: "ship-1".to_string(),
630 kind: FulfillmentKind::Shipping,
631 label: "Standard".to_string(),
632 amount: Some(Money::new("USD", 300, 2)),
633 destination: None,
634 requires_user_selection: false,
635 extensions: ProtocolExtensions::default(),
636 });
637 record.order = Some(OrderSnapshot {
638 order_id: Some("order-2".to_string()),
639 receipt_id: Some("receipt-2".to_string()),
640 state: OrderState::PendingPayment,
641 receipt_state: ReceiptState::Pending,
642 extensions: ProtocolExtensions::default(),
643 });
644 record.state = TransactionState::InterventionRequired(Box::new(InterventionState {
645 intervention_id: "int-1".to_string(),
646 kind: InterventionKind::ThreeDsChallenge,
647 status: InterventionStatus::Pending,
648 instructions: Some("Complete 3DS".to_string()),
649 continuation_token: Some("continue-1".to_string()),
650 requested_by: None,
651 expires_at: None,
652 extensions: ProtocolExtensions::default(),
653 }));
654
655 let evidence_ref = EvidenceReference {
656 evidence_id: "ev-1".to_string(),
657 protocol: ProtocolDescriptor::acp("2026-01-30"),
658 artifact_kind: "checkout_session".to_string(),
659 digest: Some("sha256:abc".to_string()),
660 };
661
662 let mut acp_fields = Map::new();
663 acp_fields.insert("paymentHandler".to_string(), json!({"type": "card"}));
664 acp_fields.insert("affiliateAttribution".to_string(), json!({"partnerId": "aff-1"}));
665
666 let mut ap2_fields = Map::new();
667 ap2_fields.insert("cartMandate".to_string(), json!({"id": "cm-1"}));
668 ap2_fields.insert("riskData".to_string(), json!({"score": 42}));
669
670 record.attach_extension(ProtocolExtensionEnvelope {
671 protocol: ProtocolDescriptor::acp("2026-01-30"),
672 fields: acp_fields,
673 evidence_refs: vec![evidence_ref.clone()],
674 });
675 record.attach_extension(ProtocolExtensionEnvelope {
676 protocol: ProtocolDescriptor::ap2("v0.1-alpha"),
677 fields: ap2_fields,
678 evidence_refs: vec![EvidenceReference {
679 evidence_id: "ev-2".to_string(),
680 protocol: ProtocolDescriptor::ap2("v0.1-alpha"),
681 artifact_kind: "payment_mandate".to_string(),
682 digest: Some("sha256:def".to_string()),
683 }],
684 });
685 record.attach_evidence_ref(evidence_ref);
686
687 let encoded = serde_json::to_value(&record).unwrap();
688 let decoded: TransactionRecord = serde_json::from_value(encoded).unwrap();
689
690 assert_eq!(decoded.extensions.as_slice().len(), 2);
691 assert_eq!(
692 decoded.extensions.as_slice()[0].fields["paymentHandler"],
693 json!({"type": "card"})
694 );
695 assert_eq!(decoded.extensions.as_slice()[1].fields["cartMandate"], json!({"id": "cm-1"}));
696 assert_eq!(decoded.evidence_refs.len(), 1);
697 }
698}