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#[derive(Debug, Clone, PartialEq)]
11pub struct PaymentAuthorization {
12 pub authorization_id: String,
14 pub state: PaymentAuthorizationState,
16 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 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#[derive(Debug, Clone, PartialEq)]
562pub struct PaymentResult {
563 pub transaction_id: String,
565 pub settlement_status: RailSettlementStatus,
567 pub metadata: serde_json::Value,
569}
570
571#[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
610pub struct ReceiptSettlement {
611 pub payment_reference: Option<String>,
612 pub settlement_status: SettlementStatus,
613}
614
615#[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#[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#[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
707pub 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 fn authorize(
723 &self,
724 request: &PaymentAuthorizeRequest,
725 ) -> Result<PaymentAuthorization, PaymentError>;
726
727 fn capture(
731 &self,
732 authorization_id: &str,
733 amount_units: u64,
734 currency: &str,
735 reference: &str,
736 ) -> Result<PaymentResult, PaymentError>;
737
738 fn release(
742 &self,
743 authorization_id: &str,
744 reference: &str,
745 ) -> Result<PaymentResult, PaymentError>;
746
747 fn refund(
749 &self,
750 transaction_id: &str,
751 amount_units: u64,
752 currency: &str,
753 reference: &str,
754 ) -> Result<PaymentResult, PaymentError>;
755
756 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#[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#[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 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 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}