Skip to content

Commit fc83b04

Browse files
vvillait88claude
andauthored
fix(checkout): emit Payment-Receipt header on MPP success (#50)
## Summary - Add `payment_receipt_header` symmetric to the existing `payment_response_header` on `SettleOutcome` + `MppxComposeOutcome`. - `make_mppx_compose_hook` serializes the upstream pympp Receipt (`Receipt.to_payment_receipt()`) and threads it onto the outcome. Defensive `getattr` so older pympp versions without the helper leave it `None`. - `Checkout` echoes `outcome.payment_receipt_header` as the `payment-receipt` HTTP header on success, mirroring `payment-response` for x402. - x402 + the two `$0` zero-settle carve-outs explicitly pass `None` (x402 uses payment-response only). Surfaced when smoke-testing live `agents.martinestate.com` via `tempo request`: every successful Tempo purchase printed `Warning: missing Payment-Receipt on successful paid charge response`. paymentauth.org §5 mandates the header so spec-strict MPP clients can lift tx_hash + source from headers without parsing the JSON body. We had the symmetric path for x402; just never wired the MPP one. Additive change. Existing consumer code keeps working (default `None` skips the echo). ## Test plan - [x] python: 1199 tests pass (4 new — checkout + make_mppx_compose_hook receipt round-trip + omitted-helper fallback) - [x] node (parallel PR): 1150 tests pass (3 new) - [x] ruff + ty clean - [ ] After merge + publish, re-run `tempo request` against live martin (post `agentscore-commerce@2.0.1` bump) and confirm the warning disappears --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1e6cf67 commit fc83b04

6 files changed

Lines changed: 267 additions & 2 deletions

File tree

‎agentscore_commerce/checkout.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,9 @@ class SettleOutcome:
450450
"""``"evm"`` / ``"solana"`` for chain signers; ``None`` otherwise."""
451451
payment_response_header: str | None = None
452452
"""The ``PAYMENT-RESPONSE`` header to echo (x402 success path). ``None`` for MPP."""
453+
payment_receipt_header: str | None = None
454+
"""The ``Payment-Receipt`` header to echo (MPP success path, paymentauth.org §5).
455+
``None`` for x402 and for the MPP zero-settle carve-out (no receipt minted)."""
453456
raw: Any = None
454457
"""The underlying settle result. Inspect for power-user fields (facilitator
455458
diagnostics, raw receipt blobs); prefer the normalized fields above for the
@@ -490,6 +493,11 @@ class MppxComposeOutcome:
490493
"""For ``status=200``: ``"evm"`` / ``"solana"`` depending on the rail."""
491494
payment_response_header: str | None = None
492495
"""For ``status=200``: optional PAYMENT-RESPONSE header echoed to the agent."""
496+
payment_receipt_header: str | None = None
497+
"""For ``status=200``: serialized ``Payment-Receipt`` header (base64url-encoded
498+
receipt struct per pympp's ``Receipt.to_payment_receipt``). Echoed to the agent
499+
so spec-strict MPP clients (tempo CLI, etc.) can lift tx_hash + source from
500+
headers without parsing the JSON body."""
493501
raw: Any = None
494502
"""The underlying pympp compose result for ``on_settled`` introspection."""
495503

@@ -540,6 +548,41 @@ async def _maybe_await(value: Any) -> Any:
540548
return value
541549

542550

551+
def _extract_mppx_receipt_header_from_raw(raw: Any) -> str | None:
552+
"""Best-effort ``Payment-Receipt`` extraction from a custom hook's ``raw``.
553+
554+
Handles the three shapes hand-rolled hooks commonly return on a 200:
555+
556+
* The raw object itself exposes ``to_payment_receipt()`` (pympp Receipt
557+
handed back directly).
558+
* ``raw`` is a tuple ``(credential, receipt)`` (the pympp ``Mpp.charge``
559+
return shape, unpacked but not re-wrapped).
560+
* ``raw`` is a dict with ``receipt`` key, or an object with ``.receipt``
561+
attribute (the auto-built hook's ``{"credential", "receipt"}`` dict).
562+
563+
Returns ``None`` when none of the shapes match, or the receipt's
564+
``to_payment_receipt`` raises: the response then omits the header rather
565+
than emitting a malformed value.
566+
"""
567+
candidates: list[Any] = [raw]
568+
if isinstance(raw, tuple | list) and len(raw) >= 2:
569+
candidates.append(raw[1])
570+
if isinstance(raw, dict) and "receipt" in raw:
571+
candidates.append(raw["receipt"])
572+
if hasattr(raw, "receipt"):
573+
candidates.append(raw.receipt)
574+
for candidate in candidates:
575+
to_header = getattr(candidate, "to_payment_receipt", None)
576+
if callable(to_header):
577+
try:
578+
value = to_header()
579+
except Exception: # noqa: S112
580+
continue
581+
if isinstance(value, str) and value:
582+
return value
583+
return None
584+
585+
543586
def _resolve_identity_metadata(ctx: CheckoutContext) -> dict[str, Any] | None:
544587
"""Compose the identity_metadata block from request + assess state.
545588
@@ -1574,6 +1617,7 @@ async def _handle_zero_settle(self, ctx: CheckoutContext) -> CheckoutResult:
15741617
signer_address=carve.signer_address,
15751618
signer_network="evm" if carve.signer_address else None,
15761619
payment_response_header=None,
1620+
payment_receipt_header=None,
15771621
raw=verified,
15781622
)
15791623
return await self._build_success(ctx, outcome)
@@ -1589,6 +1633,7 @@ async def _handle_zero_settle(self, ctx: CheckoutContext) -> CheckoutResult:
15891633
signer_address=carve.signer_address,
15901634
signer_network="evm" if carve.signer_address else None,
15911635
payment_response_header=None,
1636+
payment_receipt_header=None,
15921637
raw=None,
15931638
)
15941639
return await self._build_success(ctx, outcome)
@@ -1705,6 +1750,7 @@ async def _handle_x402(self, ctx: CheckoutContext) -> CheckoutResult:
17051750
signer_address=x402_signer.address if x402_signer else None,
17061751
signer_network=x402_signer.network if x402_signer else None,
17071752
payment_response_header=settle.payment_response_header,
1753+
payment_receipt_header=None,
17081754
raw=settle,
17091755
)
17101756
return await self._build_success(ctx, outcome)
@@ -1722,6 +1768,8 @@ async def _handle_mppx(self, ctx: CheckoutContext) -> CheckoutResult:
17221768
signer_address=composed.signer_address,
17231769
signer_network=composed.signer_network,
17241770
payment_response_header=composed.payment_response_header,
1771+
payment_receipt_header=composed.payment_receipt_header
1772+
or _extract_mppx_receipt_header_from_raw(composed.raw),
17251773
raw=composed.raw,
17261774
)
17271775
return await self._build_success(ctx, outcome)
@@ -1862,6 +1910,8 @@ async def _build_success(self, ctx: CheckoutContext, outcome: SettleOutcome) ->
18621910
headers: dict[str, str] = {}
18631911
if outcome.payment_response_header:
18641912
headers["payment-response"] = outcome.payment_response_header
1913+
if outcome.payment_receipt_header:
1914+
headers["payment-receipt"] = outcome.payment_receipt_header
18651915
return CheckoutResult(
18661916
status=200,
18671917
body=body,

‎agentscore_commerce/checkout_hooks.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,15 @@ async def hook(ctx: CheckoutContext) -> MppxComposeOutcome:
9393
signer_address = signer.address
9494
signer_network = signer.network
9595

96+
to_header = getattr(receipt, "to_payment_receipt", None)
97+
payment_receipt_header = to_header() if callable(to_header) else None
98+
9699
return MppxComposeOutcome(
97100
status=200,
98101
tx_hash=tx_hash,
99102
signer_address=signer_address,
100103
signer_network=signer_network,
104+
payment_receipt_header=payment_receipt_header,
101105
raw={"credential": credential, "receipt": receipt},
102106
)
103107

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "agentscore-commerce"
7-
version = "2.0.0"
7+
version = "2.0.1"
88
description = "Agent commerce SDK for Python — identity middleware (FastAPI, Flask, Django, AIOHTTP, Sanic, ASGI) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agent commerce."
99
readme = "README.md"
1010
license = "MIT"

‎tests/test_checkout.py‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,142 @@ async def test_compose_mppx_returns_200_runs_on_settled() -> None:
300300
on_settled.assert_awaited_once()
301301

302302

303+
@pytest.mark.asyncio
304+
async def test_compose_mppx_payment_receipt_header_surfaces_on_response() -> None:
305+
"""When ``compose_mppx`` populates ``payment_receipt_header``, Checkout echoes
306+
it as a ``payment-receipt`` HTTP header on the success response — symmetric
307+
to the existing ``payment_response_header`` (x402) behavior."""
308+
receipt_header = "eyJzdGF0dXMiOiJzdWNjZXNzIn0"
309+
compose_mppx = AsyncMock(
310+
return_value=MppxComposeOutcome(status=200, payment_receipt_header=receipt_header),
311+
)
312+
checkout = Checkout(
313+
rails={"tempo": TempoRailSpec(recipient="0xtempo")},
314+
url="https://api.example/purchase",
315+
compute_pricing=lambda _ctx: PricingResult(amount_usd=10.0),
316+
compose_mppx=compose_mppx,
317+
)
318+
result = await checkout.handle(_req(headers={"authorization": "Payment id=abc"}))
319+
assert result.status == 200
320+
assert result.headers["payment-receipt"] == receipt_header
321+
322+
323+
@pytest.mark.asyncio
324+
async def test_compose_mppx_omitted_payment_receipt_header_emits_no_header() -> None:
325+
"""Default ``payment_receipt_header=None`` on the compose outcome must NOT
326+
emit an empty ``payment-receipt`` header on the response."""
327+
compose_mppx = AsyncMock(return_value=MppxComposeOutcome(status=200))
328+
checkout = Checkout(
329+
rails={"tempo": TempoRailSpec(recipient="0xtempo")},
330+
url="https://api.example/purchase",
331+
compute_pricing=lambda _ctx: PricingResult(amount_usd=10.0),
332+
compose_mppx=compose_mppx,
333+
)
334+
result = await checkout.handle(_req(headers={"authorization": "Payment id=abc"}))
335+
assert result.status == 200
336+
assert "payment-receipt" not in result.headers
337+
338+
339+
@pytest.mark.asyncio
340+
async def test_compose_mppx_auto_extracts_receipt_header_from_raw_dict() -> None:
341+
"""When a custom compose_mppx returns ``raw={'credential': c, 'receipt': r}``
342+
(the auto-built hook's shape) without explicitly setting
343+
``payment_receipt_header``, Checkout lifts the header from ``r.to_payment_receipt()``."""
344+
345+
class _Receipt:
346+
@staticmethod
347+
def to_payment_receipt() -> str:
348+
return "auto-from-raw-dict"
349+
350+
compose_mppx = AsyncMock(
351+
return_value=MppxComposeOutcome(status=200, raw={"credential": object(), "receipt": _Receipt()}),
352+
)
353+
checkout = Checkout(
354+
rails={"tempo": TempoRailSpec(recipient="0xtempo")},
355+
url="https://api.example/purchase",
356+
compute_pricing=lambda _ctx: PricingResult(amount_usd=10.0),
357+
compose_mppx=compose_mppx,
358+
)
359+
result = await checkout.handle(_req(headers={"authorization": "Payment id=abc"}))
360+
assert result.status == 200
361+
assert result.headers["payment-receipt"] == "auto-from-raw-dict"
362+
363+
364+
@pytest.mark.asyncio
365+
async def test_compose_mppx_auto_extracts_receipt_header_from_raw_tuple() -> None:
366+
"""``raw=(credential, receipt)`` (the pympp Mpp.charge return) is also a
367+
recognized shape — the second element's ``to_payment_receipt()`` is lifted."""
368+
369+
class _Receipt:
370+
@staticmethod
371+
def to_payment_receipt() -> str:
372+
return "auto-from-raw-tuple"
373+
374+
compose_mppx = AsyncMock(
375+
return_value=MppxComposeOutcome(status=200, raw=(object(), _Receipt())),
376+
)
377+
checkout = Checkout(
378+
rails={"tempo": TempoRailSpec(recipient="0xtempo")},
379+
url="https://api.example/purchase",
380+
compute_pricing=lambda _ctx: PricingResult(amount_usd=10.0),
381+
compose_mppx=compose_mppx,
382+
)
383+
result = await checkout.handle(_req(headers={"authorization": "Payment id=abc"}))
384+
assert result.status == 200
385+
assert result.headers["payment-receipt"] == "auto-from-raw-tuple"
386+
387+
388+
@pytest.mark.asyncio
389+
async def test_compose_mppx_explicit_payment_receipt_header_wins_over_raw() -> None:
390+
"""When the hook sets ``payment_receipt_header`` explicitly, the auto-extract
391+
from ``raw`` is NOT consulted."""
392+
393+
class _Receipt:
394+
@staticmethod
395+
def to_payment_receipt() -> str:
396+
return "auto-IGNORED"
397+
398+
compose_mppx = AsyncMock(
399+
return_value=MppxComposeOutcome(
400+
status=200,
401+
payment_receipt_header="explicit-value",
402+
raw={"receipt": _Receipt()},
403+
),
404+
)
405+
checkout = Checkout(
406+
rails={"tempo": TempoRailSpec(recipient="0xtempo")},
407+
url="https://api.example/purchase",
408+
compute_pricing=lambda _ctx: PricingResult(amount_usd=10.0),
409+
compose_mppx=compose_mppx,
410+
)
411+
result = await checkout.handle(_req(headers={"authorization": "Payment id=abc"}))
412+
assert result.status == 200
413+
assert result.headers["payment-receipt"] == "explicit-value"
414+
415+
416+
@pytest.mark.asyncio
417+
async def test_compose_mppx_receipt_to_header_that_throws_falls_through() -> None:
418+
"""If ``to_payment_receipt()`` raises (unsupported pympp version, malformed
419+
receipt), the SDK omits the header rather than emitting a malformed value."""
420+
421+
class _BadReceipt:
422+
def to_payment_receipt(self) -> str:
423+
raise RuntimeError("malformed receipt")
424+
425+
compose_mppx = AsyncMock(
426+
return_value=MppxComposeOutcome(status=200, raw={"receipt": _BadReceipt()}),
427+
)
428+
checkout = Checkout(
429+
rails={"tempo": TempoRailSpec(recipient="0xtempo")},
430+
url="https://api.example/purchase",
431+
compute_pricing=lambda _ctx: PricingResult(amount_usd=10.0),
432+
compose_mppx=compose_mppx,
433+
)
434+
result = await checkout.handle(_req(headers={"authorization": "Payment id=abc"}))
435+
assert result.status == 200
436+
assert "payment-receipt" not in result.headers
437+
438+
303439
@pytest.mark.asyncio
304440
async def test_compose_mppx_returns_402_on_settle_leg_rejects_credential() -> None:
305441
"""When the agent sends Authorization: Payment and mppx returns 402 (credential

‎tests/test_seamless_helpers.py‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,81 @@ class _Ctx:
274274
assert out.signer_network == "evm"
275275

276276

277+
@pytest.mark.asyncio
278+
async def test_make_mppx_compose_hook_serializes_receipt_to_payment_receipt_header() -> None:
279+
"""When the pympp ``Receipt`` exposes ``to_payment_receipt()``, the compose
280+
hook lifts the serialized header onto ``MppxComposeOutcome.payment_receipt_header``
281+
so Checkout can echo it as the response's ``Payment-Receipt`` header."""
282+
283+
class _Credential:
284+
source = "did:pkh:eip155:8453:0xABCD000000000000000000000000000000000003"
285+
286+
class _Receipt:
287+
reference = "0xtx_hash"
288+
transaction = None
289+
290+
@staticmethod
291+
def to_payment_receipt() -> str:
292+
return "eyJzdGF0dXMiOiJzdWNjZXNzIn0"
293+
294+
class _Mpp:
295+
realm = "r"
296+
297+
async def charge(self, *, authorization: str | None, amount: str) -> tuple:
298+
return (_Credential(), _Receipt())
299+
300+
async def _getter() -> _Mpp:
301+
return _Mpp()
302+
303+
hook = make_mppx_compose_hook(server_getter=_getter)
304+
305+
class _Pricing:
306+
amount_usd = 0.1
307+
308+
class _Ctx:
309+
request = _req({"authorization": "Payment somevalidcred"})
310+
pricing = _Pricing()
311+
312+
out = await hook(_Ctx())
313+
assert out.status == 200
314+
assert out.payment_receipt_header == "eyJzdGF0dXMiOiJzdWNjZXNzIn0"
315+
316+
317+
@pytest.mark.asyncio
318+
async def test_make_mppx_compose_hook_omits_receipt_header_when_unavailable() -> None:
319+
"""Older pympp Receipts without ``to_payment_receipt()`` leave the header
320+
field None rather than raising or fabricating."""
321+
322+
class _Credential:
323+
source = "did:pkh:eip155:8453:0xABCD000000000000000000000000000000000003"
324+
325+
class _Receipt:
326+
reference = "0xtx_hash"
327+
transaction = None
328+
329+
class _Mpp:
330+
realm = "r"
331+
332+
async def charge(self, *, authorization: str | None, amount: str) -> tuple:
333+
return (_Credential(), _Receipt())
334+
335+
async def _getter() -> _Mpp:
336+
return _Mpp()
337+
338+
hook = make_mppx_compose_hook(server_getter=_getter)
339+
340+
class _Pricing:
341+
amount_usd = 0.1
342+
343+
class _Ctx:
344+
request = _req({"authorization": "Payment somevalidcred"})
345+
pricing = _Pricing()
346+
347+
out = await hook(_Ctx())
348+
assert out.status == 200
349+
assert out.payment_receipt_header is None
350+
351+
277352
@pytest.mark.asyncio
278353
async def test_make_mppx_compose_hook_returns_402_when_charge_raises() -> None:
279354
class _Mpp:

‎uv.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)