Skip to main content

chio_kernel/
payment.rs

1use std::time::Duration;
2
3use chio_core::{capability::MonetaryAmount, receipt::SettlementStatus};
4use serde::{de::DeserializeOwned, Deserialize, Serialize};
5
6/// Result of a payment authorization or settlement hold.
7#[derive(Debug, Clone, PartialEq)]
8pub struct PaymentAuthorization {
9    /// Payment rail's authorization or hold identifier.
10    pub authorization_id: String,
11    /// Whether the rail already considers the funds fully settled.
12    pub settled: bool,
13    /// Rail-specific metadata such as idempotency keys, quote IDs, or expiry.
14    pub metadata: serde_json::Value,
15}
16
17/// Result of a capture, settlement, release, or refund operation.
18#[derive(Debug, Clone, PartialEq)]
19pub struct PaymentResult {
20    /// Stable rail reference for the resulting financial operation.
21    pub transaction_id: String,
22    /// Richer rail-side settlement state, mapped onto the canonical receipt enum.
23    pub settlement_status: RailSettlementStatus,
24    /// Rail-specific metadata such as confirmations or idempotency keys.
25    pub metadata: serde_json::Value,
26}
27
28/// Richer settlement states surfaced by payment rails.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum RailSettlementStatus {
32    Authorized,
33    Captured,
34    Settled,
35    Pending,
36    Failed,
37    Released,
38    Refunded,
39}
40
41impl RailSettlementStatus {
42    /// Map rail-specific settlement states onto the receipt-side canonical enum.
43    #[must_use]
44    pub const fn to_receipt_status(self) -> SettlementStatus {
45        match self {
46            Self::Authorized | Self::Captured | Self::Pending => SettlementStatus::Pending,
47            Self::Settled | Self::Released | Self::Refunded => SettlementStatus::Settled,
48            Self::Failed => SettlementStatus::Failed,
49        }
50    }
51}
52
53/// Canonical settlement fields as they appear on signed financial receipts.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ReceiptSettlement {
56    pub payment_reference: Option<String>,
57    pub settlement_status: SettlementStatus,
58}
59
60/// Governed request details forwarded to payment rails when present.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63pub struct GovernedPaymentContext {
64    pub intent_id: String,
65    pub intent_hash: String,
66    pub purpose: String,
67    pub server_id: String,
68    pub tool_name: String,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub approval_token_id: Option<String>,
71}
72
73/// Commerce approval details forwarded to seller-scoped payment rails.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "camelCase")]
76pub struct CommercePaymentContext {
77    pub seller: String,
78    pub shared_payment_token_id: String,
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub max_amount: Option<MonetaryAmount>,
81}
82
83/// Canonical authorization request forwarded to a payment rail.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct PaymentAuthorizeRequest {
87    pub amount_units: u64,
88    pub currency: String,
89    pub payer: String,
90    pub payee: String,
91    pub reference: String,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub governed: Option<GovernedPaymentContext>,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub commerce: Option<CommercePaymentContext>,
96}
97
98impl ReceiptSettlement {
99    #[must_use]
100    pub const fn not_applicable() -> Self {
101        Self {
102            payment_reference: None,
103            settlement_status: SettlementStatus::NotApplicable,
104        }
105    }
106
107    #[must_use]
108    pub const fn settled() -> Self {
109        Self {
110            payment_reference: None,
111            settlement_status: SettlementStatus::Settled,
112        }
113    }
114
115    #[must_use]
116    pub const fn failed() -> Self {
117        Self {
118            payment_reference: None,
119            settlement_status: SettlementStatus::Failed,
120        }
121    }
122
123    #[must_use]
124    pub fn from_authorization(authorization: &PaymentAuthorization) -> Self {
125        Self {
126            payment_reference: Some(authorization.authorization_id.clone()),
127            settlement_status: if authorization.settled {
128                SettlementStatus::Settled
129            } else {
130                SettlementStatus::Pending
131            },
132        }
133    }
134
135    #[must_use]
136    pub fn from_payment_result(result: &PaymentResult) -> Self {
137        Self {
138            payment_reference: Some(result.transaction_id.clone()),
139            settlement_status: result.settlement_status.to_receipt_status(),
140        }
141    }
142
143    #[must_use]
144    pub fn into_receipt_parts(self) -> (Option<String>, SettlementStatus) {
145        (self.payment_reference, self.settlement_status)
146    }
147}
148
149/// Trait for executing payments against an external rail.
150pub trait PaymentAdapter: Send + Sync {
151    /// Authorize or prepay up to `amount_units` before the tool executes.
152    fn authorize(
153        &self,
154        request: &PaymentAuthorizeRequest,
155    ) -> Result<PaymentAuthorization, PaymentError>;
156
157    /// Finalize payment for the actual cost after tool execution.
158    fn capture(
159        &self,
160        authorization_id: &str,
161        amount_units: u64,
162        currency: &str,
163        reference: &str,
164    ) -> Result<PaymentResult, PaymentError>;
165
166    /// Release an unused authorization hold.
167    fn release(
168        &self,
169        authorization_id: &str,
170        reference: &str,
171    ) -> Result<PaymentResult, PaymentError>;
172
173    /// Refund a previously executed payment.
174    fn refund(
175        &self,
176        transaction_id: &str,
177        amount_units: u64,
178        currency: &str,
179        reference: &str,
180    ) -> Result<PaymentResult, PaymentError>;
181}
182
183#[derive(Debug, thiserror::Error)]
184pub enum PaymentError {
185    #[error("payment declined: {0}")]
186    Declined(String),
187
188    #[error("insufficient funds")]
189    InsufficientFunds,
190
191    #[error("payment rail unavailable: {0}")]
192    Unavailable(String),
193
194    #[error("payment rail error: {0}")]
195    RailError(String),
196}
197
198/// Thin prepaid HTTP payment bridge for x402-style per-request settlement.
199///
200/// The adapter intentionally stays narrow: it only performs one remote
201/// authorization request and treats later capture/release/refund actions as
202/// prepaid bookkeeping. This keeps the bridge small while still giving the
203/// kernel a real external authorization hop before execution.
204#[derive(Debug, Clone)]
205pub struct X402PaymentAdapter {
206    base_url: String,
207    authorize_path: String,
208    bearer_token: Option<String>,
209    http: ureq::Agent,
210}
211
212/// Thin shared-payment-token payment bridge for ACP-style commerce approvals.
213///
214/// This adapter performs one remote authorization call before execution and
215/// then lets the kernel reconcile the local hold as capture/release/refund
216/// bookkeeping after tool execution. This keeps ACP-specific logic adapter
217/// scoped while still exercising a real external authorization hop.
218#[derive(Debug, Clone)]
219pub struct AcpPaymentAdapter {
220    base_url: String,
221    authorize_path: String,
222    bearer_token: Option<String>,
223    http: ureq::Agent,
224}
225
226impl X402PaymentAdapter {
227    #[must_use]
228    pub fn new(base_url: impl Into<String>) -> Self {
229        Self {
230            base_url: base_url.into().trim_end_matches('/').to_string(),
231            authorize_path: "/authorize".to_string(),
232            bearer_token: None,
233            http: build_http_agent(Duration::from_secs(5)),
234        }
235    }
236
237    #[must_use]
238    pub fn with_authorize_path(mut self, path: impl Into<String>) -> Self {
239        self.authorize_path = normalize_http_path(&path.into());
240        self
241    }
242
243    #[must_use]
244    pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
245        self.bearer_token = Some(token.into());
246        self
247    }
248
249    #[must_use]
250    pub fn with_timeout(mut self, timeout: Duration) -> Self {
251        self.http = build_http_agent(timeout);
252        self
253    }
254}
255
256impl AcpPaymentAdapter {
257    #[must_use]
258    pub fn new(base_url: impl Into<String>) -> Self {
259        Self {
260            base_url: base_url.into().trim_end_matches('/').to_string(),
261            authorize_path: "/authorize".to_string(),
262            bearer_token: None,
263            http: build_http_agent(Duration::from_secs(5)),
264        }
265    }
266
267    #[must_use]
268    pub fn with_authorize_path(mut self, path: impl Into<String>) -> Self {
269        self.authorize_path = normalize_http_path(&path.into());
270        self
271    }
272
273    #[must_use]
274    pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
275        self.bearer_token = Some(token.into());
276        self
277    }
278
279    #[must_use]
280    pub fn with_timeout(mut self, timeout: Duration) -> Self {
281        self.http = build_http_agent(timeout);
282        self
283    }
284}
285
286impl PaymentAdapter for X402PaymentAdapter {
287    fn authorize(
288        &self,
289        request: &PaymentAuthorizeRequest,
290    ) -> Result<PaymentAuthorization, PaymentError> {
291        let response: X402AuthorizeResponse = post_json(
292            &self.http,
293            &self.base_url,
294            self.bearer_token.as_deref(),
295            &self.authorize_path,
296            request,
297        )?;
298        Ok(PaymentAuthorization {
299            authorization_id: response.authorization_id,
300            settled: response.settled,
301            metadata: merge_json_values(
302                Some(response.metadata),
303                Some(serde_json::json!({
304                    "adapter": "x402",
305                    "mode": "prepaid"
306                })),
307            )
308            .unwrap_or_else(|| serde_json::json!({ "adapter": "x402", "mode": "prepaid" })),
309        })
310    }
311
312    fn capture(
313        &self,
314        authorization_id: &str,
315        _amount_units: u64,
316        _currency: &str,
317        reference: &str,
318    ) -> Result<PaymentResult, PaymentError> {
319        Ok(PaymentResult {
320            transaction_id: authorization_id.to_string(),
321            settlement_status: RailSettlementStatus::Settled,
322            metadata: serde_json::json!({
323                "adapter": "x402",
324                "mode": "prepaid",
325                "action": "capture",
326                "reference": reference
327            }),
328        })
329    }
330
331    fn release(
332        &self,
333        authorization_id: &str,
334        reference: &str,
335    ) -> Result<PaymentResult, PaymentError> {
336        Ok(PaymentResult {
337            transaction_id: authorization_id.to_string(),
338            settlement_status: RailSettlementStatus::Released,
339            metadata: serde_json::json!({
340                "adapter": "x402",
341                "mode": "prepaid",
342                "action": "release",
343                "reference": reference
344            }),
345        })
346    }
347
348    fn refund(
349        &self,
350        transaction_id: &str,
351        amount_units: u64,
352        currency: &str,
353        reference: &str,
354    ) -> Result<PaymentResult, PaymentError> {
355        Ok(PaymentResult {
356            transaction_id: transaction_id.to_string(),
357            settlement_status: RailSettlementStatus::Refunded,
358            metadata: serde_json::json!({
359                "adapter": "x402",
360                "mode": "prepaid",
361                "action": "refund",
362                "amount_units": amount_units,
363                "currency": currency,
364                "reference": reference
365            }),
366        })
367    }
368}
369
370impl PaymentAdapter for AcpPaymentAdapter {
371    fn authorize(
372        &self,
373        request: &PaymentAuthorizeRequest,
374    ) -> Result<PaymentAuthorization, PaymentError> {
375        let response: AcpAuthorizeResponse = post_json(
376            &self.http,
377            &self.base_url,
378            self.bearer_token.as_deref(),
379            &self.authorize_path,
380            request,
381        )?;
382        Ok(PaymentAuthorization {
383            authorization_id: response.authorization_id,
384            settled: response.settled,
385            metadata: merge_json_values(
386                Some(response.metadata),
387                Some(serde_json::json!({
388                    "adapter": "acp",
389                    "mode": "shared_payment_token_hold"
390                })),
391            )
392            .unwrap_or_else(|| {
393                serde_json::json!({
394                    "adapter": "acp",
395                    "mode": "shared_payment_token_hold"
396                })
397            }),
398        })
399    }
400
401    fn capture(
402        &self,
403        authorization_id: &str,
404        amount_units: u64,
405        currency: &str,
406        reference: &str,
407    ) -> Result<PaymentResult, PaymentError> {
408        Ok(PaymentResult {
409            transaction_id: authorization_id.to_string(),
410            settlement_status: RailSettlementStatus::Settled,
411            metadata: serde_json::json!({
412                "adapter": "acp",
413                "mode": "shared_payment_token_hold",
414                "action": "capture",
415                "amount_units": amount_units,
416                "currency": currency,
417                "reference": reference
418            }),
419        })
420    }
421
422    fn release(
423        &self,
424        authorization_id: &str,
425        reference: &str,
426    ) -> Result<PaymentResult, PaymentError> {
427        Ok(PaymentResult {
428            transaction_id: authorization_id.to_string(),
429            settlement_status: RailSettlementStatus::Released,
430            metadata: serde_json::json!({
431                "adapter": "acp",
432                "mode": "shared_payment_token_hold",
433                "action": "release",
434                "reference": reference
435            }),
436        })
437    }
438
439    fn refund(
440        &self,
441        transaction_id: &str,
442        amount_units: u64,
443        currency: &str,
444        reference: &str,
445    ) -> Result<PaymentResult, PaymentError> {
446        Ok(PaymentResult {
447            transaction_id: transaction_id.to_string(),
448            settlement_status: RailSettlementStatus::Refunded,
449            metadata: serde_json::json!({
450                "adapter": "acp",
451                "mode": "shared_payment_token_hold",
452                "action": "refund",
453                "amount_units": amount_units,
454                "currency": currency,
455                "reference": reference
456            }),
457        })
458    }
459}
460
461#[derive(Debug, Deserialize)]
462#[serde(rename_all = "camelCase")]
463struct X402AuthorizeResponse {
464    #[serde(
465        alias = "authorization_id",
466        alias = "transaction_id",
467        alias = "transactionId"
468    )]
469    authorization_id: String,
470    #[serde(default = "default_true")]
471    settled: bool,
472    #[serde(default)]
473    metadata: serde_json::Value,
474}
475
476#[derive(Debug, Deserialize)]
477#[serde(rename_all = "camelCase")]
478struct AcpAuthorizeResponse {
479    #[serde(
480        alias = "authorization_id",
481        alias = "token_id",
482        alias = "tokenId",
483        alias = "authorizationId"
484    )]
485    authorization_id: String,
486    #[serde(default)]
487    settled: bool,
488    #[serde(default)]
489    metadata: serde_json::Value,
490}
491
492fn post_json<B: Serialize, T: DeserializeOwned>(
493    http: &ureq::Agent,
494    base_url: &str,
495    bearer_token: Option<&str>,
496    path: &str,
497    body: &B,
498) -> Result<T, PaymentError> {
499    let url = format!("{base_url}{path}");
500    let payload = serde_json::to_value(body)
501        .map_err(|error| PaymentError::RailError(format!("invalid request payload: {error}")))?;
502    let mut request = http.post(&url);
503    if let Some(token) = bearer_token {
504        request = request.set("Authorization", &format!("Bearer {token}"));
505    }
506    match request.send_json(payload) {
507        Ok(response) => {
508            let body = response.into_string().map_err(|error| {
509                PaymentError::RailError(format!(
510                    "failed to read payment rail response body: {error}"
511                ))
512            })?;
513            serde_json::from_str(&body).map_err(|error| {
514                PaymentError::RailError(format!(
515                    "failed to decode payment rail response body: {error}"
516                ))
517            })
518        }
519        Err(error) => Err(map_http_payment_error(error)),
520    }
521}
522
523fn build_http_agent(timeout: Duration) -> ureq::Agent {
524    ureq::AgentBuilder::new()
525        .timeout_connect(timeout)
526        .timeout_read(timeout)
527        .timeout_write(timeout)
528        .build()
529}
530
531fn normalize_http_path(path: &str) -> String {
532    if path.starts_with('/') {
533        path.to_string()
534    } else {
535        format!("/{path}")
536    }
537}
538
539fn default_true() -> bool {
540    true
541}
542
543fn map_http_payment_error(error: ureq::Error) -> PaymentError {
544    match error {
545        ureq::Error::Status(402, _response) => PaymentError::InsufficientFunds,
546        ureq::Error::Status(status, response) if (400..500).contains(&status) => {
547            PaymentError::Declined(response_error_message(response))
548        }
549        ureq::Error::Status(_, response) => {
550            PaymentError::Unavailable(response_error_message(response))
551        }
552        ureq::Error::Transport(error) => PaymentError::Unavailable(error.to_string()),
553    }
554}
555
556fn response_error_message(response: ureq::Response) -> String {
557    let status_text = response.status_text().to_string();
558    match response.into_string() {
559        Ok(body) if !body.trim().is_empty() => serde_json::from_str::<serde_json::Value>(&body)
560            .ok()
561            .and_then(|json| {
562                json.get("error")
563                    .or_else(|| json.get("message"))
564                    .and_then(serde_json::Value::as_str)
565                    .map(ToOwned::to_owned)
566            })
567            .unwrap_or(body),
568        _ => status_text,
569    }
570}
571
572fn merge_json_values(
573    base: Option<serde_json::Value>,
574    extra: Option<serde_json::Value>,
575) -> Option<serde_json::Value> {
576    match (base, extra) {
577        (None, extra) => extra,
578        (Some(base), None) => Some(base),
579        (Some(mut base), Some(extra)) => {
580            if let (Some(base_obj), Some(extra_obj)) = (base.as_object_mut(), extra.as_object()) {
581                for (key, value) in extra_obj {
582                    base_obj.insert(key.clone(), value.clone());
583                }
584                Some(base)
585            } else {
586                Some(base)
587            }
588        }
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595    use std::io::{Read, Write};
596    use std::net::TcpListener;
597    use std::sync::mpsc;
598    use std::thread;
599
600    #[test]
601    fn rail_settlement_status_maps_to_canonical_receipt_states() {
602        assert_eq!(
603            RailSettlementStatus::Authorized.to_receipt_status(),
604            SettlementStatus::Pending
605        );
606        assert_eq!(
607            RailSettlementStatus::Captured.to_receipt_status(),
608            SettlementStatus::Pending
609        );
610        assert_eq!(
611            RailSettlementStatus::Pending.to_receipt_status(),
612            SettlementStatus::Pending
613        );
614        assert_eq!(
615            RailSettlementStatus::Settled.to_receipt_status(),
616            SettlementStatus::Settled
617        );
618        assert_eq!(
619            RailSettlementStatus::Released.to_receipt_status(),
620            SettlementStatus::Settled
621        );
622        assert_eq!(
623            RailSettlementStatus::Refunded.to_receipt_status(),
624            SettlementStatus::Settled
625        );
626        assert_eq!(
627            RailSettlementStatus::Failed.to_receipt_status(),
628            SettlementStatus::Failed
629        );
630    }
631
632    #[test]
633    fn authorization_maps_to_receipt_reference_and_state() {
634        let pending = PaymentAuthorization {
635            authorization_id: "auth_123".to_string(),
636            settled: false,
637            metadata: serde_json::json!({ "provider": "stripe" }),
638        };
639        let settled = PaymentAuthorization {
640            authorization_id: "auth_456".to_string(),
641            settled: true,
642            metadata: serde_json::json!({ "provider": "x402" }),
643        };
644
645        let pending_receipt = ReceiptSettlement::from_authorization(&pending);
646        let settled_receipt = ReceiptSettlement::from_authorization(&settled);
647
648        assert_eq!(
649            pending_receipt.payment_reference.as_deref(),
650            Some("auth_123")
651        );
652        assert_eq!(pending_receipt.settlement_status, SettlementStatus::Pending);
653        assert_eq!(
654            settled_receipt.payment_reference.as_deref(),
655            Some("auth_456")
656        );
657        assert_eq!(settled_receipt.settlement_status, SettlementStatus::Settled);
658    }
659
660    #[test]
661    fn payment_result_maps_to_receipt_reference_and_state() {
662        let result = PaymentResult {
663            transaction_id: "txn_123".to_string(),
664            settlement_status: RailSettlementStatus::Failed,
665            metadata: serde_json::json!({ "provider": "stablecoin" }),
666        };
667
668        let receipt = ReceiptSettlement::from_payment_result(&result);
669
670        assert_eq!(receipt.payment_reference.as_deref(), Some("txn_123"));
671        assert_eq!(receipt.settlement_status, SettlementStatus::Failed);
672    }
673
674    #[test]
675    fn x402_adapter_posts_authorize_request_and_returns_settled_payment() {
676        let (url, request_rx, handle) = spawn_once_json_server(
677            200,
678            serde_json::json!({
679                "authorizationId": "x402_txn_123",
680                "settled": true,
681                "metadata": {
682                    "network": "base"
683                }
684            }),
685        );
686        let adapter = X402PaymentAdapter::new(url).with_timeout(Duration::from_secs(2));
687
688        let authorization = adapter
689            .authorize(&PaymentAuthorizeRequest {
690                amount_units: 125,
691                currency: "USD".to_string(),
692                payer: "agent-1".to_string(),
693                payee: "tool-server".to_string(),
694                reference: "req-1".to_string(),
695                governed: None,
696                commerce: None,
697            })
698            .expect("authorization should succeed");
699
700        let request = request_rx.recv().expect("request should be captured");
701        assert!(request.starts_with("POST /authorize HTTP/1.1"));
702        assert!(request.contains("\"amountUnits\":125"));
703        assert!(request.contains("\"currency\":\"USD\""));
704        assert!(request.contains("\"payer\":\"agent-1\""));
705        assert!(request.contains("\"payee\":\"tool-server\""));
706        assert!(request.contains("\"reference\":\"req-1\""));
707
708        assert_eq!(authorization.authorization_id, "x402_txn_123");
709        assert!(authorization.settled);
710        assert_eq!(authorization.metadata["adapter"], "x402");
711        assert_eq!(authorization.metadata["network"], "base");
712
713        handle.join().expect("server thread should exit cleanly");
714    }
715
716    #[test]
717    fn x402_adapter_maps_http_402_to_insufficient_funds() {
718        let (url, _request_rx, handle) = spawn_once_json_server(
719            402,
720            serde_json::json!({
721                "error": "insufficient funds"
722            }),
723        );
724        let adapter = X402PaymentAdapter::new(url).with_timeout(Duration::from_secs(2));
725
726        let error = adapter
727            .authorize(&PaymentAuthorizeRequest {
728                amount_units: 125,
729                currency: "USD".to_string(),
730                payer: "agent-1".to_string(),
731                payee: "tool-server".to_string(),
732                reference: "req-1".to_string(),
733                governed: None,
734                commerce: None,
735            })
736            .expect_err("authorization should fail");
737
738        assert!(matches!(error, PaymentError::InsufficientFunds));
739
740        handle.join().expect("server thread should exit cleanly");
741    }
742
743    #[test]
744    fn x402_adapter_uses_custom_path_bearer_token_and_governed_payload() {
745        let (url, request_rx, handle) = spawn_once_json_server(
746            200,
747            serde_json::json!({
748                "authorizationId": "x402_txn_custom",
749                "settled": true,
750                "metadata": {
751                    "network": "base-sepolia"
752                }
753            }),
754        );
755        let adapter = X402PaymentAdapter::new(url)
756            .with_authorize_path("/paywall/authorize")
757            .with_bearer_token("secret-token")
758            .with_timeout(Duration::from_secs(2));
759
760        let authorization = adapter
761            .authorize(&PaymentAuthorizeRequest {
762                amount_units: 4200,
763                currency: "USD".to_string(),
764                payer: "agent-2".to_string(),
765                payee: "payments-api".to_string(),
766                reference: "req-governed-x402".to_string(),
767                governed: Some(GovernedPaymentContext {
768                    intent_id: "intent-42".to_string(),
769                    intent_hash: "intent-hash-42".to_string(),
770                    purpose: "purchase premium dataset".to_string(),
771                    server_id: "payments-api".to_string(),
772                    tool_name: "fetch_dataset".to_string(),
773                    approval_token_id: Some("approval-42".to_string()),
774                }),
775                commerce: None,
776            })
777            .expect("authorization should succeed");
778
779        let request = request_rx.recv().expect("request should be captured");
780        assert!(request.starts_with("POST /paywall/authorize HTTP/1.1"));
781        assert!(request.contains("Authorization: Bearer secret-token"));
782        assert!(request.contains("\"governed\":{"));
783        assert!(request.contains("\"intentId\":\"intent-42\""));
784        assert!(request.contains("\"approvalTokenId\":\"approval-42\""));
785
786        assert_eq!(authorization.authorization_id, "x402_txn_custom");
787        assert_eq!(authorization.metadata["adapter"], "x402");
788        assert_eq!(authorization.metadata["mode"], "prepaid");
789
790        handle.join().expect("server thread should exit cleanly");
791    }
792
793    #[test]
794    fn acp_adapter_posts_authorize_request_with_commerce_context_and_returns_hold() {
795        let (url, request_rx, handle) = spawn_once_json_server(
796            200,
797            serde_json::json!({
798                "authorizationId": "acp_hold_123",
799                "settled": false,
800                "metadata": {
801                    "provider": "stripe",
802                    "seller": "merchant.example"
803                }
804            }),
805        );
806        let adapter = AcpPaymentAdapter::new(url)
807            .with_authorize_path("/commerce/authorize")
808            .with_bearer_token("acp-secret")
809            .with_timeout(Duration::from_secs(2));
810
811        let authorization = adapter
812            .authorize(&PaymentAuthorizeRequest {
813                amount_units: 4200,
814                currency: "USD".to_string(),
815                payer: "agent-9".to_string(),
816                payee: "merchant.example".to_string(),
817                reference: "req-acp-1".to_string(),
818                governed: Some(GovernedPaymentContext {
819                    intent_id: "intent-acp-1".to_string(),
820                    intent_hash: "intent-hash-acp-1".to_string(),
821                    purpose: "purchase governed commerce result".to_string(),
822                    server_id: "commerce-srv".to_string(),
823                    tool_name: "checkout".to_string(),
824                    approval_token_id: Some("approval-acp-1".to_string()),
825                }),
826                commerce: Some(CommercePaymentContext {
827                    seller: "merchant.example".to_string(),
828                    shared_payment_token_id: "spt_live_123".to_string(),
829                    max_amount: Some(MonetaryAmount {
830                        units: 5000,
831                        currency: "USD".to_string(),
832                    }),
833                }),
834            })
835            .expect("authorization should succeed");
836
837        let request = request_rx.recv().expect("request should be captured");
838        assert!(request.starts_with("POST /commerce/authorize HTTP/1.1"));
839        assert!(request.contains("Authorization: Bearer acp-secret"));
840        assert!(request.contains("\"commerce\":{"));
841        assert!(request.contains("\"seller\":\"merchant.example\""));
842        assert!(request.contains("\"sharedPaymentTokenId\":\"spt_live_123\""));
843        assert!(request.contains("\"maxAmount\":{"));
844        assert!(request.contains("\"units\":5000"));
845
846        assert_eq!(authorization.authorization_id, "acp_hold_123");
847        assert!(!authorization.settled);
848        assert_eq!(authorization.metadata["adapter"], "acp");
849        assert_eq!(authorization.metadata["mode"], "shared_payment_token_hold");
850        assert_eq!(authorization.metadata["provider"], "stripe");
851
852        handle.join().expect("server thread should exit cleanly");
853    }
854
855    fn spawn_once_json_server(
856        status_code: u16,
857        body: serde_json::Value,
858    ) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
859        let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
860        let address = listener
861            .local_addr()
862            .expect("listener should expose local address");
863        let (request_tx, request_rx) = mpsc::channel();
864        let body_text = body.to_string();
865        let handle = thread::spawn(move || {
866            let (mut stream, _) = listener.accept().expect("server should accept request");
867            let mut request = Vec::new();
868            let mut chunk = [0_u8; 1024];
869            let mut header_end = None;
870            let mut content_length = 0_usize;
871
872            stream
873                .set_read_timeout(Some(Duration::from_secs(2)))
874                .expect("server should configure read timeout");
875            loop {
876                let read = stream
877                    .read(&mut chunk)
878                    .expect("server should read request bytes");
879                if read == 0 {
880                    break;
881                }
882                request.extend_from_slice(&chunk[..read]);
883
884                if header_end.is_none() {
885                    header_end = find_header_end(&request);
886                    if let Some(end) = header_end {
887                        content_length = parse_content_length(&request[..end]);
888                    }
889                }
890
891                if let Some(end) = header_end {
892                    if request.len() >= end + content_length {
893                        break;
894                    }
895                }
896            }
897            request_tx
898                .send(String::from_utf8_lossy(&request).into_owned())
899                .expect("request should be sent to test");
900            let response = format!(
901                "HTTP/1.1 {status_code} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
902                status_text(status_code),
903                body_text.len(),
904                body_text
905            );
906            stream
907                .write_all(response.as_bytes())
908                .expect("server should write response");
909        });
910        (format!("http://{address}"), request_rx, handle)
911    }
912
913    fn find_header_end(request: &[u8]) -> Option<usize> {
914        request
915            .windows(4)
916            .position(|window| window == b"\r\n\r\n")
917            .map(|position| position + 4)
918    }
919
920    fn parse_content_length(headers: &[u8]) -> usize {
921        let text = String::from_utf8_lossy(headers);
922        text.lines()
923            .find_map(|line| {
924                let (name, value) = line.split_once(':')?;
925                if name.eq_ignore_ascii_case("content-length") {
926                    value.trim().parse::<usize>().ok()
927                } else {
928                    None
929                }
930            })
931            .unwrap_or(0)
932    }
933
934    fn status_text(status_code: u16) -> &'static str {
935        match status_code {
936            200 => "OK",
937            402 => "Payment Required",
938            _ => "Error",
939        }
940    }
941}