feat/agentic-commerce-x402-local-sim
py 215 lines 7.28 KB
Raw
1 """Application controller for the deterministic x402 purchase sequence."""
2
3 from __future__ import annotations
4
5 from datetime import UTC, datetime
6
7 import httpx
8 from pydantic import ValidationError
9
10 from .audit import AuditTrail
11 from .codec import decode_json
12 from .errors import MerchantRejectedPayment, PolicyDenied, ProtocolError
13 from .merchant import (
14 PAYMENT_REQUIRED_HEADER,
15 PAYMENT_RESPONSE_HEADER,
16 PAYMENT_SIGNATURE_HEADER,
17 )
18 from .models import (
19 ApprovalGrant,
20 AuditEventType,
21 PaymentRequired,
22 PurchaseRequest,
23 PurchaseResult,
24 SettlementResponse,
25 SupplierRiskReport,
26 )
27 from .payments import LocalPaymentProcessor
28 from .policy import PolicyEngine
29
30
31 class CommerceApplication:
32 """Orchestrate HTTP, authorization, payment, receipt, and audit state."""
33
34 def __init__(
35 self,
36 *,
37 client: httpx.Client,
38 policy: PolicyEngine,
39 payments: LocalPaymentProcessor,
40 audit: AuditTrail | None = None,
41 ) -> None:
42 self.client = client
43 self.policy = policy
44 self.payments = payments
45 self.audit = audit or AuditTrail()
46
47 def purchase(
48 self,
49 request: PurchaseRequest,
50 *,
51 approval: ApprovalGrant | None = None,
52 now: datetime | None = None,
53 ) -> PurchaseResult:
54 now = now or datetime.now(UTC)
55 self.audit.append(
56 request_id=request.request_id,
57 event_type=AuditEventType.RESOURCE_REQUESTED,
58 detail={
59 "resource_url": str(request.resource_url),
60 "purpose": request.purpose,
61 },
62 )
63 preflight = self.policy.preflight(request, now=now)
64 if not preflight.allowed:
65 self.audit.append(
66 request_id=request.request_id,
67 event_type=AuditEventType.REQUEST_DENIED,
68 detail={"code": preflight.code, "stage": "preflight"},
69 )
70 raise PolicyDenied(preflight.code, preflight.reason)
71
72 initial = self.client.get(str(request.resource_url))
73 if initial.status_code != 402:
74 raise ProtocolError(
75 "payment_challenge_expected",
76 "The paid resource did not return HTTP 402.",
77 )
78
79 required = self._payment_required(initial)
80 requirement = required.accepts[0]
81 self.audit.append(
82 request_id=request.request_id,
83 event_type=AuditEventType.PAYMENT_REQUIRED,
84 detail={
85 "merchant_domain": requirement.merchant_domain,
86 "amount": str(requirement.decimal_amount),
87 "currency": requirement.currency,
88 "network": requirement.network,
89 },
90 )
91
92 decision = self.policy.authorize(
93 request,
94 required,
95 approval=approval,
96 now=now,
97 )
98 self.audit.append(
99 request_id=request.request_id,
100 event_type=AuditEventType.AUTHORIZATION_CHECKED,
101 detail={
102 "allowed": decision.allowed,
103 "code": decision.code,
104 "requires_human_approval": (decision.requires_human_approval),
105 },
106 )
107 if not decision.allowed:
108 self.audit.append(
109 request_id=request.request_id,
110 event_type=AuditEventType.REQUEST_DENIED,
111 detail={"code": decision.code},
112 )
113 raise PolicyDenied(decision.code, decision.reason)
114
115 self.audit.append(
116 request_id=request.request_id,
117 event_type=AuditEventType.PAYMENT_ATTEMPTED,
118 detail={"idempotency_key": request.idempotency_key},
119 )
120 authorization = self.payments.authorize(request, requirement)
121 self.policy.record(authorization.receipt)
122 self.audit.append(
123 request_id=request.request_id,
124 event_type=(
125 AuditEventType.PROOF_REUSED
126 if authorization.receipt.reused
127 else AuditEventType.PROOF_CREATED
128 ),
129 detail={"receipt_id": authorization.receipt.receipt_id},
130 )
131
132 self.audit.append(
133 request_id=request.request_id,
134 event_type=AuditEventType.MERCHANT_RETRY,
135 detail={"payment_header": PAYMENT_SIGNATURE_HEADER},
136 )
137 paid = self.client.get(
138 str(request.resource_url),
139 headers={
140 PAYMENT_SIGNATURE_HEADER: authorization.proof_header,
141 },
142 )
143 if paid.status_code != 200:
144 raise MerchantRejectedPayment(
145 "merchant_rejected_payment",
146 "The merchant did not accept the synthetic payment proof.",
147 )
148
149 settlement = self._settlement(paid)
150 if (
151 not settlement.success
152 or settlement.transaction != authorization.receipt.transaction
153 ):
154 raise ProtocolError(
155 "settlement_receipt_mismatch",
156 "The settlement response does not match the local receipt.",
157 )
158 try:
159 report = SupplierRiskReport.model_validate(paid.json())
160 except (ValueError, ValidationError) as exc:
161 raise ProtocolError(
162 "invalid_paid_resource",
163 "The paid resource did not match the typed report contract.",
164 ) from exc
165
166 self.audit.append(
167 request_id=request.request_id,
168 event_type=AuditEventType.CONTENT_RETURNED,
169 detail={
170 "report_id": report.report_id,
171 "receipt_id": authorization.receipt.receipt_id,
172 },
173 )
174 return PurchaseResult(
175 status="completed",
176 request_id=request.request_id,
177 authorization=decision,
178 receipt=authorization.receipt,
179 report=report,
180 audit_events=self.audit.for_request(request.request_id),
181 )
182
183 @staticmethod
184 def _payment_required(response: httpx.Response) -> PaymentRequired:
185 header = response.headers.get(PAYMENT_REQUIRED_HEADER)
186 if header is None:
187 raise ProtocolError(
188 "payment_required_header_missing",
189 "HTTP 402 did not include PAYMENT-REQUIRED.",
190 )
191 payload = decode_json(header, header_name=PAYMENT_REQUIRED_HEADER)
192 try:
193 return PaymentRequired.model_validate(payload)
194 except ValidationError as exc:
195 raise ProtocolError(
196 "invalid_payment_requirement",
197 "PAYMENT-REQUIRED does not match the expected contract.",
198 ) from exc
199
200 @staticmethod
201 def _settlement(response: httpx.Response) -> SettlementResponse:
202 header = response.headers.get(PAYMENT_RESPONSE_HEADER)
203 if header is None:
204 raise ProtocolError(
205 "payment_response_header_missing",
206 "Paid response did not include PAYMENT-RESPONSE.",
207 )
208 payload = decode_json(header, header_name=PAYMENT_RESPONSE_HEADER)
209 try:
210 return SettlementResponse.model_validate(payload)
211 except ValidationError as exc:
212 raise ProtocolError(
213 "invalid_settlement_response",
214 "PAYMENT-RESPONSE does not match the expected contract.",
215 ) from exc