Skip to main content

chio_kernel/
payment.rs

1use std::time::Duration;
2
3use chio_core::{capability::scope::MonetaryAmount, receipt::economics::SettlementStatus};
4use serde::{de::DeserializeOwned, Deserialize, Serialize};
5
6mod sim;
7pub use sim::SimPaymentAdapter;
8
9/// Result of a payment authorization or settlement hold.
10#[derive(Debug, Clone, PartialEq)]
11pub struct PaymentAuthorization {
12    /// Payment rail's authorization or hold identifier.
13    pub authorization_id: String,
14    /// Whether authorization created a reversible hold or completed final prepayment.
15    pub state: PaymentAuthorizationState,
16    /// Rail-specific metadata such as idempotency keys, quote IDs, or expiry.
17    pub metadata: serde_json::Value,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum PaymentAuthorizationState {
23    Held,
24    PrepaidFinal,
25}
26
27impl PaymentAuthorizationState {
28    #[must_use]
29    pub const fn is_final(self) -> bool {
30        matches!(self, Self::PrepaidFinal)
31    }
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum PaymentRailMode {
37    ReversibleHold,
38    PrepaidFinal,
39}
40
41impl PaymentRailMode {
42    #[must_use]
43    pub const fn accepts(self, state: PaymentAuthorizationState) -> bool {
44        matches!(
45            (self, state),
46            (Self::ReversibleHold, PaymentAuthorizationState::Held)
47                | (Self::PrepaidFinal, PaymentAuthorizationState::PrepaidFinal)
48        )
49    }
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum PaymentJournalState {
55    HoldPlaced,
56    Authorized,
57    Settling,
58    Settled,
59    Closed,
60    ReconcileFailed,
61}
62
63impl PaymentJournalState {
64    #[must_use]
65    pub const fn can_advance_to(self, next: Self, rail_mode: PaymentRailMode) -> bool {
66        matches!(
67            (rail_mode, self, next),
68            (
69                PaymentRailMode::ReversibleHold,
70                Self::HoldPlaced,
71                Self::Authorized | Self::ReconcileFailed
72            ) | (
73                PaymentRailMode::ReversibleHold,
74                Self::Authorized,
75                Self::Settling | Self::ReconcileFailed
76            ) | (
77                PaymentRailMode::ReversibleHold,
78                Self::Settling,
79                Self::Settled | Self::ReconcileFailed
80            ) | (
81                // A reconciliation failure records that the rail rejected the
82                // settlement intent, not that the intent is abandoned. The journal
83                // retains its settle action and authorization, so a later pass can
84                // re-drive the same intent to completion.
85                PaymentRailMode::ReversibleHold,
86                Self::ReconcileFailed,
87                Self::Settled
88            ) | (_, Self::Settled, Self::Closed)
89                | (_, Self::HoldPlaced, Self::Closed)
90                | (
91                    PaymentRailMode::PrepaidFinal,
92                    Self::HoldPlaced,
93                    Self::Settled | Self::ReconcileFailed
94                )
95        )
96    }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub enum PaymentSettleAction {
102    Capture,
103    Release,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(tag = "kind", rename_all = "snake_case")]
108pub enum PaymentJournalTransition {
109    AuthorizationHeld {
110        authorization_id: String,
111    },
112    PrepaymentSettled {
113        authorization_id: String,
114    },
115    CancelBeforeAuthorization,
116    BeginCapture {
117        amount_units: u64,
118    },
119    BeginRelease {
120        authority: PaymentReleaseAuthorityBinding,
121    },
122    SettlementCompleted {
123        transaction_id: String,
124    },
125    ReconcileFailed,
126    Close,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum PaymentReleaseAuthorityKind {
132    PreDispatchNoEffect,
133    TransportNotAccepted,
134    ContractualZeroCharge,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "camelCase")]
139pub struct PaymentReleaseAuthorityBinding {
140    pub kind: PaymentReleaseAuthorityKind,
141    pub operation_id: String,
142    pub operation_version: u64,
143    pub evidence_id: String,
144    pub evidence_digest: String,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "camelCase")]
149pub struct PaymentJournalRecord {
150    pub operation_id: String,
151    pub journal_version: u64,
152    pub request_namespace_digest: String,
153    pub request_id: String,
154    pub capability_id: String,
155    pub grant_index: u32,
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub hold_id: Option<String>,
158    pub rail: String,
159    pub rail_mode: PaymentRailMode,
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub authorization_id: Option<String>,
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub transaction_id: Option<String>,
164    pub amount_units: u64,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub settle_action: Option<PaymentSettleAction>,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub settle_amount_units: Option<u64>,
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub release_authority: Option<PaymentReleaseAuthorityBinding>,
171    pub currency: String,
172    pub state: PaymentJournalState,
173    pub created_at_unix_ms: u64,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
177#[error("invalid payment journal record: {0}")]
178pub struct PaymentJournalError(String);
179
180impl PaymentJournalRecord {
181    #[must_use]
182    pub fn matches_hold_replay(&self, proposed: &Self) -> bool {
183        if proposed.state != PaymentJournalState::HoldPlaced
184            || proposed.journal_version != 1
185            || self.validate().is_err()
186            || proposed.validate().is_err()
187        {
188            return false;
189        }
190        self.operation_id == proposed.operation_id
191            && self.request_namespace_digest == proposed.request_namespace_digest
192            && self.request_id == proposed.request_id
193            && self.capability_id == proposed.capability_id
194            && self.grant_index == proposed.grant_index
195            && self.hold_id == proposed.hold_id
196            && self.rail == proposed.rail
197            && self.rail_mode == proposed.rail_mode
198            && self.amount_units == proposed.amount_units
199            && self.currency == proposed.currency
200    }
201
202    pub fn validate(&self) -> Result<(), PaymentJournalError> {
203        validate_payment_text("operation_id", &self.operation_id)?;
204        if self.journal_version == 0 || self.journal_version > ((1_u64 << 53) - 1) {
205            return Err(PaymentJournalError(
206                "journal_version must be a positive I-JSON safe integer".to_owned(),
207            ));
208        }
209        validate_payment_digest("request_namespace_digest", &self.request_namespace_digest)?;
210        validate_payment_text("request_id", &self.request_id)?;
211        validate_payment_text("capability_id", &self.capability_id)?;
212        let hold_id = self
213            .hold_id
214            .as_deref()
215            .ok_or_else(|| PaymentJournalError("hold_id is required".to_owned()))?;
216        validate_payment_text("hold_id", hold_id)?;
217        validate_payment_text("rail", &self.rail)?;
218        if self.rail == "unspecified" {
219            return Err(PaymentJournalError(
220                "rail must identify a recoverable payment adapter".to_owned(),
221            ));
222        }
223        if self.amount_units == 0 || self.amount_units > ((1_u64 << 53) - 1) {
224            return Err(PaymentJournalError(
225                "amount_units must be a positive I-JSON safe integer".to_owned(),
226            ));
227        }
228        if self.currency.len() != 3 || !self.currency.bytes().all(|byte| byte.is_ascii_uppercase())
229        {
230            return Err(PaymentJournalError(
231                "currency must be a three-letter uppercase code".to_owned(),
232            ));
233        }
234        if self.created_at_unix_ms == 0 || self.created_at_unix_ms > ((1_u64 << 53) - 1) {
235            return Err(PaymentJournalError(
236                "created_at_unix_ms must be a positive I-JSON safe integer".to_owned(),
237            ));
238        }
239        self.authorization_id
240            .as_deref()
241            .map(|value| validate_payment_text("authorization_id", value))
242            .transpose()?;
243        self.transaction_id
244            .as_deref()
245            .map(|value| validate_payment_text("transaction_id", value))
246            .transpose()?;
247        match self.state {
248            PaymentJournalState::HoldPlaced => {
249                if self.journal_version != 1 {
250                    return Err(PaymentJournalError(
251                        "hold_placed must be journal version 1".to_owned(),
252                    ));
253                }
254                self.validate_empty_settlement("hold_placed")?;
255            }
256            PaymentJournalState::Authorized => {
257                if self.rail_mode != PaymentRailMode::ReversibleHold {
258                    return Err(PaymentJournalError(
259                        "only a reversible rail can retain an authorized hold".to_owned(),
260                    ));
261                }
262                self.require_authorization_id("authorized")?;
263                if self.transaction_id.is_some()
264                    || self.settle_action.is_some()
265                    || self.settle_amount_units.is_some()
266                    || self.release_authority.is_some()
267                {
268                    return Err(PaymentJournalError(
269                        "authorized state cannot contain a terminal settle result".to_owned(),
270                    ));
271                }
272            }
273            PaymentJournalState::Settling => {
274                if self.rail_mode != PaymentRailMode::ReversibleHold {
275                    return Err(PaymentJournalError(
276                        "only a reversible rail can enter settling".to_owned(),
277                    ));
278                }
279                self.require_authorization_id("settling")?;
280                if self.transaction_id.is_some() {
281                    return Err(PaymentJournalError(
282                        "settling cannot contain a terminal transaction_id".to_owned(),
283                    ));
284                }
285                self.validate_settle_intent()?;
286            }
287            PaymentJournalState::Closed if self.authorization_id.is_none() => {
288                if self.journal_version != 2 {
289                    return Err(PaymentJournalError(
290                        "pre-authorization cancellation must be journal version 2".to_owned(),
291                    ));
292                }
293                self.validate_empty_settlement("pre-authorization cancellation")?;
294            }
295            PaymentJournalState::Settled | PaymentJournalState::Closed => {
296                self.require_authorization_id("terminal")?;
297                match self.rail_mode {
298                    PaymentRailMode::PrepaidFinal => {
299                        if self.transaction_id.is_some()
300                            || self.settle_action.is_some()
301                            || self.settle_amount_units.is_some()
302                            || self.release_authority.is_some()
303                        {
304                            return Err(PaymentJournalError(
305                                "final prepayment cannot contain synthetic settlement fields"
306                                    .to_owned(),
307                            ));
308                        }
309                    }
310                    PaymentRailMode::ReversibleHold => {
311                        if self.transaction_id.is_none() {
312                            return Err(PaymentJournalError(
313                                "a terminal reversible hold requires transaction_id".to_owned(),
314                            ));
315                        }
316                        self.validate_settle_intent()?;
317                    }
318                }
319            }
320            PaymentJournalState::ReconcileFailed => self.validate_reconcile_shape()?,
321        }
322        Ok(())
323    }
324
325    pub fn apply_transition(
326        &self,
327        transition: &PaymentJournalTransition,
328    ) -> Result<Self, PaymentJournalError> {
329        self.validate()?;
330        let mut next = self.clone();
331        next.journal_version = self
332            .journal_version
333            .checked_add(1)
334            .ok_or_else(|| PaymentJournalError("journal_version overflowed".to_owned()))?;
335        let next_state = match transition {
336            PaymentJournalTransition::AuthorizationHeld { authorization_id } => {
337                if self.state != PaymentJournalState::HoldPlaced
338                    || self.rail_mode != PaymentRailMode::ReversibleHold
339                {
340                    return Err(PaymentJournalError(
341                        "held authorization requires a reversible hold_placed journal".to_owned(),
342                    ));
343                }
344                next.authorization_id = Some(authorization_id.clone());
345                PaymentJournalState::Authorized
346            }
347            PaymentJournalTransition::PrepaymentSettled { authorization_id } => {
348                if self.state != PaymentJournalState::HoldPlaced
349                    || self.rail_mode != PaymentRailMode::PrepaidFinal
350                {
351                    return Err(PaymentJournalError(
352                        "final prepayment requires a prepaid hold_placed journal".to_owned(),
353                    ));
354                }
355                next.authorization_id = Some(authorization_id.clone());
356                PaymentJournalState::Settled
357            }
358            PaymentJournalTransition::CancelBeforeAuthorization => {
359                if self.state != PaymentJournalState::HoldPlaced {
360                    return Err(PaymentJournalError(
361                        "pre-authorization cancellation requires a hold_placed journal".to_owned(),
362                    ));
363                }
364                PaymentJournalState::Closed
365            }
366            PaymentJournalTransition::BeginCapture { amount_units } => {
367                if self.state != PaymentJournalState::Authorized {
368                    return Err(PaymentJournalError(
369                        "capture intent requires an authorized journal".to_owned(),
370                    ));
371                }
372                next.settle_action = Some(PaymentSettleAction::Capture);
373                next.settle_amount_units = Some(*amount_units);
374                PaymentJournalState::Settling
375            }
376            PaymentJournalTransition::BeginRelease { authority } => {
377                if self.state != PaymentJournalState::Authorized {
378                    return Err(PaymentJournalError(
379                        "release intent requires an authorized journal".to_owned(),
380                    ));
381                }
382                next.settle_action = Some(PaymentSettleAction::Release);
383                next.release_authority = Some(authority.clone());
384                PaymentJournalState::Settling
385            }
386            PaymentJournalTransition::SettlementCompleted { transaction_id } => {
387                if !matches!(
388                    self.state,
389                    PaymentJournalState::Settling | PaymentJournalState::ReconcileFailed
390                ) {
391                    return Err(PaymentJournalError(
392                        "settlement completion requires a settling or reconcile_failed journal"
393                            .to_owned(),
394                    ));
395                }
396                next.transaction_id = Some(transaction_id.clone());
397                PaymentJournalState::Settled
398            }
399            PaymentJournalTransition::ReconcileFailed => {
400                if matches!(
401                    self.state,
402                    PaymentJournalState::Settled
403                        | PaymentJournalState::Closed
404                        | PaymentJournalState::ReconcileFailed
405                ) {
406                    return Err(PaymentJournalError(
407                        "terminal payment journal cannot enter reconciliation failure".to_owned(),
408                    ));
409                }
410                PaymentJournalState::ReconcileFailed
411            }
412            PaymentJournalTransition::Close => {
413                if self.state != PaymentJournalState::Settled {
414                    return Err(PaymentJournalError(
415                        "only a settled payment journal can close".to_owned(),
416                    ));
417                }
418                PaymentJournalState::Closed
419            }
420        };
421        if !self.state.can_advance_to(next_state, self.rail_mode) {
422            return Err(PaymentJournalError(
423                "payment journal transition is not permitted".to_owned(),
424            ));
425        }
426        next.state = next_state;
427        next.validate()?;
428        Ok(next)
429    }
430
431    fn require_authorization_id(&self, state: &str) -> Result<(), PaymentJournalError> {
432        if self.authorization_id.is_none() {
433            return Err(PaymentJournalError(format!(
434                "{state} state requires authorization_id"
435            )));
436        }
437        Ok(())
438    }
439
440    fn validate_empty_settlement(&self, state: &str) -> Result<(), PaymentJournalError> {
441        if self.authorization_id.is_some()
442            || self.transaction_id.is_some()
443            || self.settle_action.is_some()
444            || self.settle_amount_units.is_some()
445            || self.release_authority.is_some()
446        {
447            return Err(PaymentJournalError(format!(
448                "{state} cannot contain rail results or a settle intent"
449            )));
450        }
451        Ok(())
452    }
453
454    fn validate_settle_intent(&self) -> Result<(), PaymentJournalError> {
455        match self.settle_action {
456            Some(PaymentSettleAction::Capture) => {
457                let amount = self.settle_amount_units.ok_or_else(|| {
458                    PaymentJournalError("capture requires settle_amount_units".to_owned())
459                })?;
460                if amount == 0 || amount > self.amount_units {
461                    return Err(PaymentJournalError(
462                        "settle_amount_units must be within the authorized amount".to_owned(),
463                    ));
464                }
465                if self.release_authority.is_some() {
466                    return Err(PaymentJournalError(
467                        "capture cannot contain release authority".to_owned(),
468                    ));
469                }
470            }
471            Some(PaymentSettleAction::Release) => {
472                if self.settle_amount_units.is_some() {
473                    return Err(PaymentJournalError(
474                        "release cannot contain settle_amount_units".to_owned(),
475                    ));
476                }
477                self.release_authority
478                    .as_ref()
479                    .ok_or_else(|| {
480                        PaymentJournalError("release requires verified authority".to_owned())
481                    })?
482                    .validate_for(&self.operation_id)?;
483            }
484            None => {
485                return Err(PaymentJournalError(
486                    "settling requires a committed action".to_owned(),
487                ));
488            }
489        }
490        Ok(())
491    }
492
493    fn validate_reconcile_shape(&self) -> Result<(), PaymentJournalError> {
494        if self.transaction_id.is_some() && self.authorization_id.is_none() {
495            return Err(PaymentJournalError(
496                "reconcile_failed transaction requires authorization_id".to_owned(),
497            ));
498        }
499        match self.settle_action {
500            Some(_) => {
501                if self.rail_mode != PaymentRailMode::ReversibleHold {
502                    return Err(PaymentJournalError(
503                        "final prepayment cannot contain a settle intent".to_owned(),
504                    ));
505                }
506                self.require_authorization_id("reconcile_failed")?;
507                self.validate_settle_intent()
508            }
509            None => {
510                if self.settle_amount_units.is_some() || self.release_authority.is_some() {
511                    return Err(PaymentJournalError(
512                        "reconcile_failed contains an incomplete settle intent".to_owned(),
513                    ));
514                }
515                Ok(())
516            }
517        }
518    }
519}
520
521impl PaymentReleaseAuthorityBinding {
522    fn validate_for(&self, operation_id: &str) -> Result<(), PaymentJournalError> {
523        if self.operation_id != operation_id {
524            return Err(PaymentJournalError(
525                "release authority is bound to another operation".to_owned(),
526            ));
527        }
528        if self.operation_version == 0 || self.operation_version > ((1_u64 << 53) - 1) {
529            return Err(PaymentJournalError(
530                "release authority version must be a positive I-JSON safe integer".to_owned(),
531            ));
532        }
533        validate_payment_text("release evidence_id", &self.evidence_id)?;
534        validate_payment_digest("release evidence_digest", &self.evidence_digest)
535    }
536}
537
538fn validate_payment_text(field: &str, value: &str) -> Result<(), PaymentJournalError> {
539    if value.is_empty() || value.len() > 512 || value.chars().any(char::is_control) {
540        return Err(PaymentJournalError(format!(
541            "{field} must contain 1 to 512 non-control bytes"
542        )));
543    }
544    Ok(())
545}
546
547fn validate_payment_digest(field: &str, value: &str) -> Result<(), PaymentJournalError> {
548    if value.len() != 64
549        || !value
550            .bytes()
551            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
552    {
553        return Err(PaymentJournalError(format!(
554            "{field} must be a lowercase SHA-256 digest"
555        )));
556    }
557    Ok(())
558}
559
560/// Result of a capture, settlement, release, or refund operation.
561#[derive(Debug, Clone, PartialEq)]
562pub struct PaymentResult {
563    /// Stable rail reference for the resulting financial operation.
564    pub transaction_id: String,
565    /// Richer rail-side settlement state, mapped onto the canonical receipt enum.
566    pub settlement_status: RailSettlementStatus,
567    /// Rail-specific metadata such as confirmations or idempotency keys.
568    pub metadata: serde_json::Value,
569}
570
571/// Richer settlement states surfaced by payment rails.
572#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
573#[serde(rename_all = "snake_case")]
574pub enum RailSettlementStatus {
575    Authorized,
576    Captured,
577    Settled,
578    Pending,
579    Failed,
580    Released,
581    Refunded,
582}
583
584#[derive(Debug, Clone, PartialEq)]
585pub enum RailSettlementState {
586    NoAuthorization,
587    Held {
588        authorization_id: String,
589    },
590    Settled {
591        authorization_id: String,
592        result: PaymentResult,
593    },
594}
595
596impl RailSettlementStatus {
597    /// Map rail-specific settlement states onto the receipt-side canonical enum.
598    #[must_use]
599    pub const fn to_receipt_status(self) -> SettlementStatus {
600        match self {
601            Self::Authorized | Self::Captured | Self::Pending => SettlementStatus::Pending,
602            Self::Settled | Self::Released | Self::Refunded => SettlementStatus::Settled,
603            Self::Failed => SettlementStatus::Failed,
604        }
605    }
606}
607
608/// Canonical settlement fields as they appear on signed financial receipts.
609#[derive(Debug, Clone, PartialEq, Eq)]
610pub struct ReceiptSettlement {
611    pub payment_reference: Option<String>,
612    pub settlement_status: SettlementStatus,
613}
614
615/// Governed request details forwarded to payment rails when present.
616#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
617#[serde(rename_all = "camelCase")]
618pub struct GovernedPaymentContext {
619    pub intent_id: String,
620    pub intent_hash: String,
621    pub purpose: String,
622    pub server_id: String,
623    pub tool_name: String,
624    #[serde(default, skip_serializing_if = "Option::is_none")]
625    pub approval_token_id: Option<String>,
626}
627
628/// Commerce approval details forwarded to seller-scoped payment rails.
629#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
630#[serde(rename_all = "camelCase")]
631pub struct CommercePaymentContext {
632    pub seller: String,
633    pub settlement_destination_ref: String,
634    pub payee_binding_digest: String,
635    pub pre_action_authority_digest: String,
636    pub shared_payment_token_id: String,
637    #[serde(default, skip_serializing_if = "Option::is_none")]
638    pub max_amount: Option<MonetaryAmount>,
639}
640
641/// Canonical authorization request forwarded to a payment rail.
642#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
643#[serde(rename_all = "camelCase")]
644pub struct PaymentAuthorizeRequest {
645    pub amount_units: u64,
646    pub currency: String,
647    pub payer: String,
648    pub payee: String,
649    pub reference: String,
650    #[serde(default, skip_serializing_if = "Option::is_none")]
651    pub governed: Option<GovernedPaymentContext>,
652    #[serde(default, skip_serializing_if = "Option::is_none")]
653    pub commerce: Option<CommercePaymentContext>,
654}
655
656impl ReceiptSettlement {
657    #[must_use]
658    pub const fn not_applicable() -> Self {
659        Self {
660            payment_reference: None,
661            settlement_status: SettlementStatus::NotApplicable,
662        }
663    }
664
665    #[must_use]
666    pub const fn settled() -> Self {
667        Self {
668            payment_reference: None,
669            settlement_status: SettlementStatus::Settled,
670        }
671    }
672
673    #[must_use]
674    pub const fn failed() -> Self {
675        Self {
676            payment_reference: None,
677            settlement_status: SettlementStatus::Failed,
678        }
679    }
680
681    #[must_use]
682    pub fn from_authorization(authorization: &PaymentAuthorization) -> Self {
683        Self {
684            payment_reference: Some(authorization.authorization_id.clone()),
685            settlement_status: if authorization.state.is_final() {
686                SettlementStatus::Settled
687            } else {
688                SettlementStatus::Pending
689            },
690        }
691    }
692
693    #[must_use]
694    pub fn from_payment_result(result: &PaymentResult) -> Self {
695        Self {
696            payment_reference: Some(result.transaction_id.clone()),
697            settlement_status: result.settlement_status.to_receipt_status(),
698        }
699    }
700
701    #[must_use]
702    pub fn into_receipt_parts(self) -> (Option<String>, SettlementStatus) {
703        (self.payment_reference, self.settlement_status)
704    }
705}
706
707/// Trait for executing payments against an external rail.
708pub trait PaymentAdapter: Send + Sync {
709    fn rail_id(&self) -> &'static str {
710        "unspecified"
711    }
712
713    fn rail_mode(&self) -> Option<PaymentRailMode> {
714        None
715    }
716
717    /// Authorize or prepay up to `amount_units` before the tool executes.
718    ///
719    /// Implementations must be idempotent by `request.reference`: repeating the
720    /// same request returns the same authorization and creates at most one
721    /// rail-side hold or prepayment.
722    fn authorize(
723        &self,
724        request: &PaymentAuthorizeRequest,
725    ) -> Result<PaymentAuthorization, PaymentError>;
726
727    /// Finalize payment for the actual cost after tool execution.
728    ///
729    /// Implementations must be idempotent by `(authorization_id, reference)`.
730    fn capture(
731        &self,
732        authorization_id: &str,
733        amount_units: u64,
734        currency: &str,
735        reference: &str,
736    ) -> Result<PaymentResult, PaymentError>;
737
738    /// Release an unused authorization hold.
739    ///
740    /// Implementations must be idempotent by `(authorization_id, reference)`.
741    fn release(
742        &self,
743        authorization_id: &str,
744        reference: &str,
745    ) -> Result<PaymentResult, PaymentError>;
746
747    /// Refund a previously executed payment.
748    fn refund(
749        &self,
750        transaction_id: &str,
751        amount_units: u64,
752        currency: &str,
753        reference: &str,
754    ) -> Result<PaymentResult, PaymentError>;
755
756    /// Return the side-effect-free rail state for a durable reference.
757    ///
758    /// This query must remain answerable when `authorization_id` is absent so
759    /// recovery can close the crash window after authorization but before the
760    /// rail-assigned identifier reaches the local journal.
761    fn settlement_state(
762        &self,
763        reference: &str,
764        authorization_id: Option<&str>,
765    ) -> Result<RailSettlementState, PaymentError> {
766        let _ = (reference, authorization_id);
767        Err(PaymentError::Unavailable(
768            "settlement_state query is unsupported by this payment adapter".to_owned(),
769        ))
770    }
771}
772
773#[derive(Debug, thiserror::Error)]
774pub enum PaymentError {
775    #[error("payment declined: {0}")]
776    Declined(String),
777
778    #[error("insufficient funds")]
779    InsufficientFunds,
780
781    #[error("payment rail unavailable: {0}")]
782    Unavailable(String),
783
784    #[error("payment rail error: {0}")]
785    RailError(String),
786}
787
788/// Thin prepaid HTTP payment bridge for x402-style per-request settlement.
789///
790/// The adapter intentionally stays narrow: it only performs one remote
791/// authorization request and treats later capture/release/refund actions as
792/// prepaid bookkeeping. This keeps the bridge small while still giving the
793/// kernel a real external authorization hop before execution.
794#[derive(Debug, Clone)]
795pub struct X402PaymentAdapter {
796    base_url: String,
797    authorize_path: String,
798    bearer_token: Option<String>,
799    http: ureq::Agent,
800}
801
802/// Thin shared-payment-token payment bridge for ACP-style commerce approvals.
803///
804/// This adapter performs one remote authorization call before execution and
805/// then lets the kernel reconcile the local hold as capture/release/refund
806/// bookkeeping after tool execution. This keeps ACP-specific logic adapter
807/// scoped while still exercising a real external authorization hop.
808#[derive(Debug, Clone)]
809pub struct AcpPaymentAdapter {
810    base_url: String,
811    authorize_path: String,
812    bearer_token: Option<String>,
813    http: ureq::Agent,
814}
815
816impl X402PaymentAdapter {
817    #[must_use]
818    pub fn new(base_url: impl Into<String>) -> Self {
819        Self {
820            base_url: base_url.into().trim_end_matches('/').to_string(),
821            authorize_path: "/authorize".to_string(),
822            bearer_token: None,
823            http: build_http_agent(Duration::from_secs(5)),
824        }
825    }
826
827    #[must_use]
828    pub fn with_authorize_path(mut self, path: impl Into<String>) -> Self {
829        self.authorize_path = normalize_http_path(&path.into());
830        self
831    }
832
833    #[must_use]
834    pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
835        self.bearer_token = Some(token.into());
836        self
837    }
838
839    #[must_use]
840    pub fn with_timeout(mut self, timeout: Duration) -> Self {
841        self.http = build_http_agent(timeout);
842        self
843    }
844}
845
846impl AcpPaymentAdapter {
847    #[must_use]
848    pub fn new(base_url: impl Into<String>) -> Self {
849        Self {
850            base_url: base_url.into().trim_end_matches('/').to_string(),
851            authorize_path: "/authorize".to_string(),
852            bearer_token: None,
853            http: build_http_agent(Duration::from_secs(5)),
854        }
855    }
856
857    #[must_use]
858    pub fn with_authorize_path(mut self, path: impl Into<String>) -> Self {
859        self.authorize_path = normalize_http_path(&path.into());
860        self
861    }
862
863    #[must_use]
864    pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
865        self.bearer_token = Some(token.into());
866        self
867    }
868
869    #[must_use]
870    pub fn with_timeout(mut self, timeout: Duration) -> Self {
871        self.http = build_http_agent(timeout);
872        self
873    }
874}
875
876impl PaymentAdapter for X402PaymentAdapter {
877    fn rail_id(&self) -> &'static str {
878        "x402"
879    }
880
881    fn rail_mode(&self) -> Option<PaymentRailMode> {
882        Some(PaymentRailMode::PrepaidFinal)
883    }
884
885    fn authorize(
886        &self,
887        request: &PaymentAuthorizeRequest,
888    ) -> Result<PaymentAuthorization, PaymentError> {
889        let response: X402AuthorizeResponse = post_json(
890            &self.http,
891            &self.base_url,
892            self.bearer_token.as_deref(),
893            &self.authorize_path,
894            request,
895        )?;
896        let state = if response.settled {
897            PaymentAuthorizationState::PrepaidFinal
898        } else {
899            PaymentAuthorizationState::Held
900        };
901        if !PaymentRailMode::PrepaidFinal.accepts(state) {
902            return Err(PaymentError::RailError(
903                "x402 authorization did not complete final prepayment".to_owned(),
904            ));
905        }
906        Ok(PaymentAuthorization {
907            authorization_id: response.authorization_id,
908            state,
909            metadata: merge_json_values(
910                Some(response.metadata),
911                Some(serde_json::json!({
912                    "adapter": "x402",
913                    "mode": "prepaid"
914                })),
915            )
916            .unwrap_or_else(|| serde_json::json!({ "adapter": "x402", "mode": "prepaid" })),
917        })
918    }
919
920    fn capture(
921        &self,
922        authorization_id: &str,
923        _amount_units: u64,
924        _currency: &str,
925        reference: &str,
926    ) -> Result<PaymentResult, PaymentError> {
927        Ok(PaymentResult {
928            transaction_id: authorization_id.to_string(),
929            settlement_status: RailSettlementStatus::Settled,
930            metadata: serde_json::json!({
931                "adapter": "x402",
932                "mode": "prepaid",
933                "action": "capture",
934                "reference": reference
935            }),
936        })
937    }
938
939    fn release(
940        &self,
941        authorization_id: &str,
942        reference: &str,
943    ) -> Result<PaymentResult, PaymentError> {
944        Ok(PaymentResult {
945            transaction_id: authorization_id.to_string(),
946            settlement_status: RailSettlementStatus::Released,
947            metadata: serde_json::json!({
948                "adapter": "x402",
949                "mode": "prepaid",
950                "action": "release",
951                "reference": reference
952            }),
953        })
954    }
955
956    fn refund(
957        &self,
958        transaction_id: &str,
959        amount_units: u64,
960        currency: &str,
961        reference: &str,
962    ) -> Result<PaymentResult, PaymentError> {
963        Ok(PaymentResult {
964            transaction_id: transaction_id.to_string(),
965            settlement_status: RailSettlementStatus::Refunded,
966            metadata: serde_json::json!({
967                "adapter": "x402",
968                "mode": "prepaid",
969                "action": "refund",
970                "amount_units": amount_units,
971                "currency": currency,
972                "reference": reference
973            }),
974        })
975    }
976}
977
978impl PaymentAdapter for AcpPaymentAdapter {
979    fn rail_id(&self) -> &'static str {
980        "acp"
981    }
982
983    fn rail_mode(&self) -> Option<PaymentRailMode> {
984        Some(PaymentRailMode::ReversibleHold)
985    }
986
987    fn authorize(
988        &self,
989        request: &PaymentAuthorizeRequest,
990    ) -> Result<PaymentAuthorization, PaymentError> {
991        let response: AcpAuthorizeResponse = post_json(
992            &self.http,
993            &self.base_url,
994            self.bearer_token.as_deref(),
995            &self.authorize_path,
996            request,
997        )?;
998        let state = if response.settled {
999            PaymentAuthorizationState::PrepaidFinal
1000        } else {
1001            PaymentAuthorizationState::Held
1002        };
1003        if !PaymentRailMode::ReversibleHold.accepts(state) {
1004            return Err(PaymentError::RailError(
1005                "ACP authorization did not create a reversible hold".to_owned(),
1006            ));
1007        }
1008        Ok(PaymentAuthorization {
1009            authorization_id: response.authorization_id,
1010            state,
1011            metadata: merge_json_values(
1012                Some(response.metadata),
1013                Some(serde_json::json!({
1014                    "adapter": "acp",
1015                    "mode": "shared_payment_token_hold"
1016                })),
1017            )
1018            .unwrap_or_else(|| {
1019                serde_json::json!({
1020                    "adapter": "acp",
1021                    "mode": "shared_payment_token_hold"
1022                })
1023            }),
1024        })
1025    }
1026
1027    fn capture(
1028        &self,
1029        authorization_id: &str,
1030        amount_units: u64,
1031        currency: &str,
1032        reference: &str,
1033    ) -> Result<PaymentResult, PaymentError> {
1034        Ok(PaymentResult {
1035            transaction_id: authorization_id.to_string(),
1036            settlement_status: RailSettlementStatus::Settled,
1037            metadata: serde_json::json!({
1038                "adapter": "acp",
1039                "mode": "shared_payment_token_hold",
1040                "action": "capture",
1041                "amount_units": amount_units,
1042                "currency": currency,
1043                "reference": reference
1044            }),
1045        })
1046    }
1047
1048    fn release(
1049        &self,
1050        authorization_id: &str,
1051        reference: &str,
1052    ) -> Result<PaymentResult, PaymentError> {
1053        Ok(PaymentResult {
1054            transaction_id: authorization_id.to_string(),
1055            settlement_status: RailSettlementStatus::Released,
1056            metadata: serde_json::json!({
1057                "adapter": "acp",
1058                "mode": "shared_payment_token_hold",
1059                "action": "release",
1060                "reference": reference
1061            }),
1062        })
1063    }
1064
1065    fn refund(
1066        &self,
1067        transaction_id: &str,
1068        amount_units: u64,
1069        currency: &str,
1070        reference: &str,
1071    ) -> Result<PaymentResult, PaymentError> {
1072        Ok(PaymentResult {
1073            transaction_id: transaction_id.to_string(),
1074            settlement_status: RailSettlementStatus::Refunded,
1075            metadata: serde_json::json!({
1076                "adapter": "acp",
1077                "mode": "shared_payment_token_hold",
1078                "action": "refund",
1079                "amount_units": amount_units,
1080                "currency": currency,
1081                "reference": reference
1082            }),
1083        })
1084    }
1085}
1086
1087#[derive(Debug, Deserialize)]
1088#[serde(rename_all = "camelCase")]
1089struct X402AuthorizeResponse {
1090    #[serde(
1091        alias = "authorization_id",
1092        alias = "transaction_id",
1093        alias = "transactionId"
1094    )]
1095    authorization_id: String,
1096    #[serde(default = "default_true")]
1097    settled: bool,
1098    #[serde(default)]
1099    metadata: serde_json::Value,
1100}
1101
1102#[derive(Debug, Deserialize)]
1103#[serde(rename_all = "camelCase")]
1104struct AcpAuthorizeResponse {
1105    #[serde(
1106        alias = "authorization_id",
1107        alias = "token_id",
1108        alias = "tokenId",
1109        alias = "authorizationId"
1110    )]
1111    authorization_id: String,
1112    #[serde(default)]
1113    settled: bool,
1114    #[serde(default)]
1115    metadata: serde_json::Value,
1116}
1117
1118fn post_json<B: Serialize, T: DeserializeOwned>(
1119    http: &ureq::Agent,
1120    base_url: &str,
1121    bearer_token: Option<&str>,
1122    path: &str,
1123    body: &B,
1124) -> Result<T, PaymentError> {
1125    let url = format!("{base_url}{path}");
1126    let payload = serde_json::to_value(body)
1127        .map_err(|error| PaymentError::RailError(format!("invalid request payload: {error}")))?;
1128    let mut request = http.post(&url);
1129    if let Some(token) = bearer_token {
1130        request = request.set("Authorization", &format!("Bearer {token}"));
1131    }
1132    match request.send_json(payload) {
1133        Ok(response) => {
1134            let body = response.into_string().map_err(|error| {
1135                PaymentError::RailError(format!(
1136                    "failed to read payment rail response body: {error}"
1137                ))
1138            })?;
1139            serde_json::from_str(&body).map_err(|error| {
1140                PaymentError::RailError(format!(
1141                    "failed to decode payment rail response body: {error}"
1142                ))
1143            })
1144        }
1145        Err(error) => Err(map_http_payment_error(error)),
1146    }
1147}
1148
1149fn build_http_agent(timeout: Duration) -> ureq::Agent {
1150    ureq::AgentBuilder::new()
1151        .timeout_connect(timeout)
1152        .timeout_read(timeout)
1153        .timeout_write(timeout)
1154        .build()
1155}
1156
1157fn normalize_http_path(path: &str) -> String {
1158    if path.starts_with('/') {
1159        path.to_string()
1160    } else {
1161        format!("/{path}")
1162    }
1163}
1164
1165fn default_true() -> bool {
1166    true
1167}
1168
1169fn map_http_payment_error(error: ureq::Error) -> PaymentError {
1170    match error {
1171        ureq::Error::Status(402, _response) => PaymentError::InsufficientFunds,
1172        ureq::Error::Status(status, response) if (400..500).contains(&status) => {
1173            PaymentError::Declined(response_error_message(response))
1174        }
1175        ureq::Error::Status(_, response) => {
1176            PaymentError::Unavailable(response_error_message(response))
1177        }
1178        ureq::Error::Transport(error) => PaymentError::Unavailable(error.to_string()),
1179    }
1180}
1181
1182fn response_error_message(response: ureq::Response) -> String {
1183    let status_text = response.status_text().to_string();
1184    match response.into_string() {
1185        Ok(body) if !body.trim().is_empty() => serde_json::from_str::<serde_json::Value>(&body)
1186            .ok()
1187            .and_then(|json| {
1188                json.get("error")
1189                    .or_else(|| json.get("message"))
1190                    .and_then(serde_json::Value::as_str)
1191                    .map(ToOwned::to_owned)
1192            })
1193            .unwrap_or(body),
1194        _ => status_text,
1195    }
1196}
1197
1198fn merge_json_values(
1199    base: Option<serde_json::Value>,
1200    extra: Option<serde_json::Value>,
1201) -> Option<serde_json::Value> {
1202    match (base, extra) {
1203        (None, extra) => extra,
1204        (Some(base), None) => Some(base),
1205        (Some(mut base), Some(extra)) => {
1206            if let (Some(base_obj), Some(extra_obj)) = (base.as_object_mut(), extra.as_object()) {
1207                for (key, value) in extra_obj {
1208                    base_obj.insert(key.clone(), value.clone());
1209                }
1210                Some(base)
1211            } else {
1212                Some(base)
1213            }
1214        }
1215    }
1216}
1217
1218#[cfg(test)]
1219mod tests {
1220    use super::*;
1221    use std::io::{Read, Write};
1222    use std::net::TcpListener;
1223    use std::sync::mpsc;
1224    use std::thread;
1225
1226    fn hold_placed_payment_journal() -> PaymentJournalRecord {
1227        PaymentJournalRecord {
1228            operation_id: "op-1".to_owned(),
1229            journal_version: 1,
1230            request_namespace_digest:
1231                "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(),
1232            request_id: "request-1".to_owned(),
1233            capability_id: "capability-1".to_owned(),
1234            grant_index: 0,
1235            hold_id: Some("hold-1".to_owned()),
1236            rail: "acp".to_owned(),
1237            rail_mode: PaymentRailMode::ReversibleHold,
1238            authorization_id: None,
1239            transaction_id: None,
1240            amount_units: 125,
1241            settle_action: None,
1242            settle_amount_units: None,
1243            release_authority: None,
1244            currency: "USD".to_owned(),
1245            state: PaymentJournalState::HoldPlaced,
1246            created_at_unix_ms: 1_700_000_000_000,
1247        }
1248    }
1249
1250    #[test]
1251    fn payment_journal_accepts_durable_hold_placed_record() {
1252        let record = hold_placed_payment_journal();
1253
1254        assert_eq!(record.validate(), Ok(()));
1255    }
1256
1257    #[test]
1258    fn payment_journal_requires_reversible_mode_for_authorized_hold() {
1259        let mut record = hold_placed_payment_journal();
1260        record.state = PaymentJournalState::Authorized;
1261        record.rail_mode = PaymentRailMode::PrepaidFinal;
1262        record.authorization_id = Some("authorization-1".to_owned());
1263
1264        assert!(record.validate().is_err());
1265    }
1266
1267    #[test]
1268    fn payment_journal_requires_committed_action_while_settling() {
1269        let mut record = hold_placed_payment_journal();
1270        record.state = PaymentJournalState::Settling;
1271        record.authorization_id = Some("authorization-1".to_owned());
1272
1273        assert!(record.validate().is_err());
1274    }
1275
1276    #[test]
1277    fn payment_journal_rejects_cross_operation_release_authority() {
1278        let mut record = hold_placed_payment_journal();
1279        record.state = PaymentJournalState::Settling;
1280        record.authorization_id = Some("authorization-1".to_owned());
1281        record.settle_action = Some(PaymentSettleAction::Release);
1282        record.release_authority = Some(PaymentReleaseAuthorityBinding {
1283            kind: PaymentReleaseAuthorityKind::PreDispatchNoEffect,
1284            operation_id: "another-operation".to_owned(),
1285            operation_version: 2,
1286            evidence_id: "release-evidence-1".to_owned(),
1287            evidence_digest: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
1288                .to_owned(),
1289        });
1290
1291        assert!(record.validate().is_err());
1292    }
1293
1294    #[test]
1295    fn payment_journal_rejects_synthetic_capture_for_final_prepayment() {
1296        let mut record = hold_placed_payment_journal();
1297        record.state = PaymentJournalState::Settled;
1298        record.rail = "x402".to_owned();
1299        record.rail_mode = PaymentRailMode::PrepaidFinal;
1300        record.authorization_id = Some("authorization-1".to_owned());
1301        record.transaction_id = Some("transaction-1".to_owned());
1302        record.settle_action = Some(PaymentSettleAction::Capture);
1303        record.settle_amount_units = Some(125);
1304
1305        assert!(record.validate().is_err());
1306    }
1307
1308    #[test]
1309    fn payment_journal_generic_advancement_cannot_skip_terminal_evidence() {
1310        assert!(PaymentJournalState::HoldPlaced.can_advance_to(
1311            PaymentJournalState::Authorized,
1312            PaymentRailMode::ReversibleHold
1313        ));
1314        assert!(PaymentJournalState::HoldPlaced
1315            .can_advance_to(PaymentJournalState::Settled, PaymentRailMode::PrepaidFinal));
1316        assert!(PaymentJournalState::HoldPlaced
1317            .can_advance_to(PaymentJournalState::Closed, PaymentRailMode::ReversibleHold));
1318        assert!(!PaymentJournalState::Authorized
1319            .can_advance_to(PaymentJournalState::Closed, PaymentRailMode::ReversibleHold));
1320    }
1321
1322    #[test]
1323    fn payment_journal_reconcile_failure_replays_its_settlement_intent() {
1324        let settling = hold_placed_payment_journal()
1325            .apply_transition(&PaymentJournalTransition::AuthorizationHeld {
1326                authorization_id: "authorization-1".to_owned(),
1327            })
1328            .expect("record held authorization")
1329            .apply_transition(&PaymentJournalTransition::BeginCapture { amount_units: 75 })
1330            .expect("record capture intent");
1331        let failed = settling
1332            .apply_transition(&PaymentJournalTransition::ReconcileFailed)
1333            .expect("record reconciliation failure");
1334
1335        // The intent survives the seal, so a later pass has everything it needs.
1336        assert_eq!(failed.state, PaymentJournalState::ReconcileFailed);
1337        assert_eq!(failed.settle_action, Some(PaymentSettleAction::Capture));
1338        assert_eq!(failed.settle_amount_units, Some(75));
1339        assert_eq!(failed.authorization_id.as_deref(), Some("authorization-1"));
1340        assert_eq!(failed.validate(), Ok(()));
1341
1342        let settled = failed
1343            .apply_transition(&PaymentJournalTransition::SettlementCompleted {
1344                transaction_id: "transaction-1".to_owned(),
1345            })
1346            .expect("retry a reconcile_failed settlement");
1347
1348        assert_eq!(settled.state, PaymentJournalState::Settled);
1349        assert_eq!(settled.transaction_id.as_deref(), Some("transaction-1"));
1350        assert_eq!(settled.validate(), Ok(()));
1351    }
1352
1353    #[test]
1354    fn payment_journal_reconcile_failure_stays_sealed_for_final_prepayment() {
1355        let mut record = hold_placed_payment_journal();
1356        record.rail = "x402".to_owned();
1357        record.rail_mode = PaymentRailMode::PrepaidFinal;
1358        let failed = record
1359            .apply_transition(&PaymentJournalTransition::ReconcileFailed)
1360            .expect("record reconciliation failure");
1361
1362        // A final prepayment carries no replayable settle intent, so the seal holds.
1363        assert!(!PaymentJournalState::ReconcileFailed
1364            .can_advance_to(PaymentJournalState::Settled, PaymentRailMode::PrepaidFinal));
1365        assert!(failed
1366            .apply_transition(&PaymentJournalTransition::SettlementCompleted {
1367                transaction_id: "transaction-1".to_owned(),
1368            })
1369            .is_err());
1370    }
1371
1372    #[test]
1373    fn payment_journal_cancels_before_authorization_without_settlement_fields() {
1374        let cancelled = hold_placed_payment_journal()
1375            .apply_transition(&PaymentJournalTransition::CancelBeforeAuthorization)
1376            .expect("cancel unstarted payment");
1377
1378        assert_eq!(cancelled.journal_version, 2);
1379        assert_eq!(cancelled.state, PaymentJournalState::Closed);
1380        assert!(cancelled.authorization_id.is_none());
1381        assert!(cancelled.transaction_id.is_none());
1382        assert!(cancelled.settle_action.is_none());
1383        assert_eq!(cancelled.validate(), Ok(()));
1384        assert!(cancelled
1385            .apply_transition(&PaymentJournalTransition::CancelBeforeAuthorization)
1386            .is_err());
1387    }
1388
1389    #[test]
1390    fn payment_journal_capture_transition_is_monotonic_and_replayable() {
1391        let authorized = hold_placed_payment_journal()
1392            .apply_transition(&PaymentJournalTransition::AuthorizationHeld {
1393                authorization_id: "authorization-1".to_owned(),
1394            })
1395            .expect("record held authorization");
1396        let settling = authorized
1397            .apply_transition(&PaymentJournalTransition::BeginCapture { amount_units: 75 })
1398            .expect("record capture intent");
1399        let settled = settling
1400            .apply_transition(&PaymentJournalTransition::SettlementCompleted {
1401                transaction_id: "transaction-1".to_owned(),
1402            })
1403            .expect("record settlement result");
1404
1405        assert_eq!(authorized.journal_version, 2);
1406        assert_eq!(authorized.state, PaymentJournalState::Authorized);
1407        assert_eq!(settling.journal_version, 3);
1408        assert_eq!(settling.settle_action, Some(PaymentSettleAction::Capture));
1409        assert_eq!(settling.settle_amount_units, Some(75));
1410        assert_eq!(settled.journal_version, 4);
1411        assert_eq!(settled.state, PaymentJournalState::Settled);
1412        assert_eq!(settled.transaction_id.as_deref(), Some("transaction-1"));
1413    }
1414
1415    #[test]
1416    fn payment_journal_final_prepayment_skips_releasable_states() {
1417        let mut record = hold_placed_payment_journal();
1418        record.rail = "x402".to_owned();
1419        record.rail_mode = PaymentRailMode::PrepaidFinal;
1420
1421        let settled = record
1422            .apply_transition(&PaymentJournalTransition::PrepaymentSettled {
1423                authorization_id: "prepayment-1".to_owned(),
1424            })
1425            .expect("record final prepayment");
1426
1427        assert_eq!(settled.state, PaymentJournalState::Settled);
1428        assert_eq!(settled.journal_version, 2);
1429        assert!(settled.transaction_id.is_none());
1430        assert!(settled
1431            .apply_transition(&PaymentJournalTransition::BeginCapture { amount_units: 125 })
1432            .is_err());
1433    }
1434
1435    #[test]
1436    fn rail_settlement_status_maps_to_canonical_receipt_states() {
1437        assert_eq!(
1438            RailSettlementStatus::Authorized.to_receipt_status(),
1439            SettlementStatus::Pending
1440        );
1441        assert_eq!(
1442            RailSettlementStatus::Captured.to_receipt_status(),
1443            SettlementStatus::Pending
1444        );
1445        assert_eq!(
1446            RailSettlementStatus::Pending.to_receipt_status(),
1447            SettlementStatus::Pending
1448        );
1449        assert_eq!(
1450            RailSettlementStatus::Settled.to_receipt_status(),
1451            SettlementStatus::Settled
1452        );
1453        assert_eq!(
1454            RailSettlementStatus::Released.to_receipt_status(),
1455            SettlementStatus::Settled
1456        );
1457        assert_eq!(
1458            RailSettlementStatus::Refunded.to_receipt_status(),
1459            SettlementStatus::Settled
1460        );
1461        assert_eq!(
1462            RailSettlementStatus::Failed.to_receipt_status(),
1463            SettlementStatus::Failed
1464        );
1465    }
1466
1467    #[test]
1468    fn authorization_maps_to_receipt_reference_and_state() {
1469        let pending = PaymentAuthorization {
1470            authorization_id: "auth_123".to_string(),
1471            state: PaymentAuthorizationState::Held,
1472            metadata: serde_json::json!({ "provider": "stripe" }),
1473        };
1474        let settled = PaymentAuthorization {
1475            authorization_id: "auth_456".to_string(),
1476            state: PaymentAuthorizationState::PrepaidFinal,
1477            metadata: serde_json::json!({ "provider": "x402" }),
1478        };
1479
1480        let pending_receipt = ReceiptSettlement::from_authorization(&pending);
1481        let settled_receipt = ReceiptSettlement::from_authorization(&settled);
1482
1483        assert_eq!(
1484            pending_receipt.payment_reference.as_deref(),
1485            Some("auth_123")
1486        );
1487        assert_eq!(pending_receipt.settlement_status, SettlementStatus::Pending);
1488        assert_eq!(
1489            settled_receipt.payment_reference.as_deref(),
1490            Some("auth_456")
1491        );
1492        assert_eq!(settled_receipt.settlement_status, SettlementStatus::Settled);
1493    }
1494
1495    #[test]
1496    fn payment_result_maps_to_receipt_reference_and_state() {
1497        let result = PaymentResult {
1498            transaction_id: "txn_123".to_string(),
1499            settlement_status: RailSettlementStatus::Failed,
1500            metadata: serde_json::json!({ "provider": "stablecoin" }),
1501        };
1502
1503        let receipt = ReceiptSettlement::from_payment_result(&result);
1504
1505        assert_eq!(receipt.payment_reference.as_deref(), Some("txn_123"));
1506        assert_eq!(receipt.settlement_status, SettlementStatus::Failed);
1507    }
1508
1509    #[test]
1510    fn x402_adapter_posts_authorize_request_and_returns_settled_payment() {
1511        let (url, request_rx, handle) = spawn_once_json_server(
1512            200,
1513            serde_json::json!({
1514                "authorizationId": "x402_txn_123",
1515                "settled": true,
1516                "metadata": {
1517                    "network": "base"
1518                }
1519            }),
1520        );
1521        let adapter = X402PaymentAdapter::new(url).with_timeout(Duration::from_secs(2));
1522
1523        let authorization = adapter
1524            .authorize(&PaymentAuthorizeRequest {
1525                amount_units: 125,
1526                currency: "USD".to_string(),
1527                payer: "agent-1".to_string(),
1528                payee: "tool-server".to_string(),
1529                reference: "req-1".to_string(),
1530                governed: None,
1531                commerce: None,
1532            })
1533            .expect("authorization should succeed");
1534
1535        let request = request_rx.recv().expect("request should be captured");
1536        assert!(request.starts_with("POST /authorize HTTP/1.1"));
1537        assert!(request.contains("\"amountUnits\":125"));
1538        assert!(request.contains("\"currency\":\"USD\""));
1539        assert!(request.contains("\"payer\":\"agent-1\""));
1540        assert!(request.contains("\"payee\":\"tool-server\""));
1541        assert!(request.contains("\"reference\":\"req-1\""));
1542
1543        assert_eq!(authorization.authorization_id, "x402_txn_123");
1544        assert_eq!(authorization.state, PaymentAuthorizationState::PrepaidFinal);
1545        assert_eq!(authorization.metadata["adapter"], "x402");
1546        assert_eq!(authorization.metadata["network"], "base");
1547
1548        handle.join().expect("server thread should exit cleanly");
1549    }
1550
1551    #[test]
1552    fn x402_adapter_maps_http_402_to_insufficient_funds() {
1553        let (url, _request_rx, handle) = spawn_once_json_server(
1554            402,
1555            serde_json::json!({
1556                "error": "insufficient funds"
1557            }),
1558        );
1559        let adapter = X402PaymentAdapter::new(url).with_timeout(Duration::from_secs(2));
1560
1561        let error = adapter
1562            .authorize(&PaymentAuthorizeRequest {
1563                amount_units: 125,
1564                currency: "USD".to_string(),
1565                payer: "agent-1".to_string(),
1566                payee: "tool-server".to_string(),
1567                reference: "req-1".to_string(),
1568                governed: None,
1569                commerce: None,
1570            })
1571            .expect_err("authorization should fail");
1572
1573        assert!(matches!(error, PaymentError::InsufficientFunds));
1574
1575        handle.join().expect("server thread should exit cleanly");
1576    }
1577
1578    #[test]
1579    fn x402_adapter_uses_custom_path_bearer_token_and_governed_payload() {
1580        let (url, request_rx, handle) = spawn_once_json_server(
1581            200,
1582            serde_json::json!({
1583                "authorizationId": "x402_txn_custom",
1584                "settled": true,
1585                "metadata": {
1586                    "network": "base-sepolia"
1587                }
1588            }),
1589        );
1590        let adapter = X402PaymentAdapter::new(url)
1591            .with_authorize_path("/paywall/authorize")
1592            .with_bearer_token("secret-token")
1593            .with_timeout(Duration::from_secs(2));
1594
1595        let authorization = adapter
1596            .authorize(&PaymentAuthorizeRequest {
1597                amount_units: 4200,
1598                currency: "USD".to_string(),
1599                payer: "agent-2".to_string(),
1600                payee: "payments-api".to_string(),
1601                reference: "req-governed-x402".to_string(),
1602                governed: Some(GovernedPaymentContext {
1603                    intent_id: "intent-42".to_string(),
1604                    intent_hash: "intent-hash-42".to_string(),
1605                    purpose: "purchase premium dataset".to_string(),
1606                    server_id: "payments-api".to_string(),
1607                    tool_name: "fetch_dataset".to_string(),
1608                    approval_token_id: Some("approval-42".to_string()),
1609                }),
1610                commerce: None,
1611            })
1612            .expect("authorization should succeed");
1613
1614        let request = request_rx.recv().expect("request should be captured");
1615        assert!(request.starts_with("POST /paywall/authorize HTTP/1.1"));
1616        assert!(request.contains("Authorization: Bearer secret-token"));
1617        assert!(request.contains("\"governed\":{"));
1618        assert!(request.contains("\"intentId\":\"intent-42\""));
1619        assert!(request.contains("\"approvalTokenId\":\"approval-42\""));
1620
1621        assert_eq!(authorization.authorization_id, "x402_txn_custom");
1622        assert_eq!(authorization.metadata["adapter"], "x402");
1623        assert_eq!(authorization.metadata["mode"], "prepaid");
1624
1625        handle.join().expect("server thread should exit cleanly");
1626    }
1627
1628    #[test]
1629    fn acp_adapter_posts_authorize_request_with_commerce_context_and_returns_hold() {
1630        let (url, request_rx, handle) = spawn_once_json_server(
1631            200,
1632            serde_json::json!({
1633                "authorizationId": "acp_hold_123",
1634                "settled": false,
1635                "metadata": {
1636                    "provider": "stripe",
1637                    "seller": "merchant.example"
1638                }
1639            }),
1640        );
1641        let adapter = AcpPaymentAdapter::new(url)
1642            .with_authorize_path("/commerce/authorize")
1643            .with_bearer_token("acp-secret")
1644            .with_timeout(Duration::from_secs(2));
1645
1646        let authorization = adapter
1647            .authorize(&PaymentAuthorizeRequest {
1648                amount_units: 4200,
1649                currency: "USD".to_string(),
1650                payer: "agent-9".to_string(),
1651                payee: "merchant.example".to_string(),
1652                reference: "req-acp-1".to_string(),
1653                governed: Some(GovernedPaymentContext {
1654                    intent_id: "intent-acp-1".to_string(),
1655                    intent_hash: "intent-hash-acp-1".to_string(),
1656                    purpose: "purchase governed commerce result".to_string(),
1657                    server_id: "commerce-srv".to_string(),
1658                    tool_name: "checkout".to_string(),
1659                    approval_token_id: Some("approval-acp-1".to_string()),
1660                }),
1661                commerce: Some(CommercePaymentContext {
1662                    seller: "merchant.example".to_string(),
1663                    settlement_destination_ref: "acct:merchant-primary".to_string(),
1664                    payee_binding_digest: "payee-binding-acp-1".to_string(),
1665                    pre_action_authority_digest: "approval-digest-acp-1".to_string(),
1666                    shared_payment_token_id: "spt_live_123".to_string(),
1667                    max_amount: Some(MonetaryAmount {
1668                        units: 5000,
1669                        currency: "USD".to_string(),
1670                    }),
1671                }),
1672            })
1673            .expect("authorization should succeed");
1674
1675        let request = request_rx.recv().expect("request should be captured");
1676        assert!(request.starts_with("POST /commerce/authorize HTTP/1.1"));
1677        assert!(request.contains("Authorization: Bearer acp-secret"));
1678        assert!(request.contains("\"commerce\":{"));
1679        assert!(request.contains("\"seller\":\"merchant.example\""));
1680        assert!(request.contains("\"settlementDestinationRef\":\"acct:merchant-primary\""));
1681        assert!(request.contains("\"payeeBindingDigest\":\"payee-binding-acp-1\""));
1682        assert!(request.contains("\"preActionAuthorityDigest\":\"approval-digest-acp-1\""));
1683        assert!(request.contains("\"sharedPaymentTokenId\":\"spt_live_123\""));
1684        assert!(request.contains("\"maxAmount\":{"));
1685        assert!(request.contains("\"units\":5000"));
1686
1687        assert_eq!(authorization.authorization_id, "acp_hold_123");
1688        assert_eq!(authorization.state, PaymentAuthorizationState::Held);
1689        assert_eq!(authorization.metadata["adapter"], "acp");
1690        assert_eq!(authorization.metadata["mode"], "shared_payment_token_hold");
1691        assert_eq!(authorization.metadata["provider"], "stripe");
1692
1693        handle.join().expect("server thread should exit cleanly");
1694    }
1695
1696    fn spawn_once_json_server(
1697        status_code: u16,
1698        body: serde_json::Value,
1699    ) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
1700        let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
1701        let address = listener
1702            .local_addr()
1703            .expect("listener should expose local address");
1704        let (request_tx, request_rx) = mpsc::channel();
1705        let body_text = body.to_string();
1706        let handle = thread::spawn(move || {
1707            let (mut stream, _) = listener.accept().expect("server should accept request");
1708            let mut request = Vec::new();
1709            let mut chunk = [0_u8; 1024];
1710            let mut header_end = None;
1711            let mut content_length = 0_usize;
1712
1713            stream
1714                .set_read_timeout(Some(Duration::from_secs(2)))
1715                .expect("server should configure read timeout");
1716            loop {
1717                let read = stream
1718                    .read(&mut chunk)
1719                    .expect("server should read request bytes");
1720                if read == 0 {
1721                    break;
1722                }
1723                request.extend_from_slice(&chunk[..read]);
1724
1725                if header_end.is_none() {
1726                    header_end = find_header_end(&request);
1727                    if let Some(end) = header_end {
1728                        content_length = parse_content_length(&request[..end]);
1729                    }
1730                }
1731
1732                if let Some(end) = header_end {
1733                    if request.len() >= end + content_length {
1734                        break;
1735                    }
1736                }
1737            }
1738            request_tx
1739                .send(String::from_utf8_lossy(&request).into_owned())
1740                .expect("request should be sent to test");
1741            let response = format!(
1742                "HTTP/1.1 {status_code} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1743                status_text(status_code),
1744                body_text.len(),
1745                body_text
1746            );
1747            stream
1748                .write_all(response.as_bytes())
1749                .expect("server should write response");
1750        });
1751        (format!("http://{address}"), request_rx, handle)
1752    }
1753
1754    fn find_header_end(request: &[u8]) -> Option<usize> {
1755        request
1756            .windows(4)
1757            .position(|window| window == b"\r\n\r\n")
1758            .map(|position| position + 4)
1759    }
1760
1761    fn parse_content_length(headers: &[u8]) -> usize {
1762        let text = String::from_utf8_lossy(headers);
1763        text.lines()
1764            .find_map(|line| {
1765                let (name, value) = line.split_once(':')?;
1766                if name.eq_ignore_ascii_case("content-length") {
1767                    value.trim().parse::<usize>().ok()
1768                } else {
1769                    None
1770                }
1771            })
1772            .unwrap_or(0)
1773    }
1774
1775    fn status_text(status_code: u16) -> &'static str {
1776        match status_code {
1777            200 => "OK",
1778            402 => "Payment Required",
1779            _ => "Error",
1780        }
1781    }
1782}