1use std::time::Duration;
2
3use chio_core::{capability::MonetaryAmount, receipt::SettlementStatus};
4use serde::{de::DeserializeOwned, Deserialize, Serialize};
5
6#[derive(Debug, Clone, PartialEq)]
8pub struct PaymentAuthorization {
9 pub authorization_id: String,
11 pub settled: bool,
13 pub metadata: serde_json::Value,
15}
16
17#[derive(Debug, Clone, PartialEq)]
19pub struct PaymentResult {
20 pub transaction_id: String,
22 pub settlement_status: RailSettlementStatus,
24 pub metadata: serde_json::Value,
26}
27
28#[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct ReceiptSettlement {
56 pub payment_reference: Option<String>,
57 pub settlement_status: SettlementStatus,
58}
59
60#[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#[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#[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
149pub trait PaymentAdapter: Send + Sync {
151 fn authorize(
153 &self,
154 request: &PaymentAuthorizeRequest,
155 ) -> Result<PaymentAuthorization, PaymentError>;
156
157 fn capture(
159 &self,
160 authorization_id: &str,
161 amount_units: u64,
162 currency: &str,
163 reference: &str,
164 ) -> Result<PaymentResult, PaymentError>;
165
166 fn release(
168 &self,
169 authorization_id: &str,
170 reference: &str,
171 ) -> Result<PaymentResult, PaymentError>;
172
173 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#[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#[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}