Skip to main content

adk_payments/domain/
transaction.rs

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/// Canonical transaction identifier shared across all adapters.
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
17#[serde(transparent)]
18pub struct TransactionId(pub String);
19
20impl TransactionId {
21    /// Returns the transaction identifier as a string slice.
22    #[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/// Distinguishes user-present and pre-authorized deferred commerce.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum CommerceMode {
50    HumanPresent,
51    HumanNotPresent,
52}
53
54/// Canonical transaction state machine shared by ACP and AP2 adapters.
55#[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/// Lightweight summary tag for canonical transaction state.
70#[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    /// Returns `true` when the transition is allowed by the canonical
129    /// transaction state machine.
130    #[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    /// Returns `true` when no further canonical payment progress is expected.
163    #[must_use]
164    pub fn is_terminal(&self) -> bool {
165        matches!(self, Self::Completed | Self::Canceled | Self::Failed)
166    }
167
168    /// Returns the state tag without carrying the full transition payload.
169    #[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/// Canonical reference to a payment-method selection or delegated credential.
186#[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/// Additional protocol reference that does not fit a well-known canonical slot.
199#[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/// Correlated protocol identifiers for one canonical transaction.
208#[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/// Safe digest metadata for one raw protocol artifact stored outside transcript
230/// and memory surfaces.
231#[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    /// Creates a digest wrapper for one stored evidence reference.
240    #[must_use]
241    pub fn new(evidence_ref: EvidenceReference, created_at: DateTime<Utc>) -> Self {
242        Self { evidence_ref, created_at }
243    }
244}
245
246/// Masked transaction summary safe for transcript and memory surfaces.
247#[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    /// Derives a safe summary from canonical transaction state.
269    #[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    /// Returns a safe one-line summary suitable for transcript surfaces.
333    #[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/// Durable protocol-neutral transaction record.
360#[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    /// Creates a new canonical transaction record in the `draft` state.
392    #[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    /// Applies one canonical transaction-state transition.
438    ///
439    /// # Errors
440    ///
441    /// Returns an error when the transition skips or rewinds the canonical
442    /// payment lifecycle.
443    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    /// Attaches one protocol extension envelope without discarding its original
462    /// fields.
463    pub fn attach_extension(&mut self, envelope: ProtocolExtensionEnvelope) {
464        self.extensions.push(envelope);
465        self.recompute_safe_summary();
466    }
467
468    /// Attaches one evidence reference to the transaction record.
469    pub fn attach_evidence_ref(&mut self, evidence_ref: EvidenceReference) {
470        self.evidence_refs.push(evidence_ref);
471    }
472
473    /// Attaches a safe digest for a stored protocol artifact.
474    pub fn attach_evidence_digest(&mut self, digest: ProtocolEnvelopeDigest) {
475        self.evidence_digests.push(digest);
476        self.recompute_safe_summary();
477    }
478
479    /// Recomputes the safe summary after canonical state changes.
480    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}