From cfb6199f17522f9b7ad47c4b24cf99531918e893 Mon Sep 17 00:00:00 2001 From: Nas Kavian Date: Thu, 8 Oct 2026 11:18:02 -0700 Subject: [PATCH] test: verify card and instrument interoperability --- README.md | 7 ++++++ interop/peer.py | 51 ++++++++++++++++++++++++++++++++++++---- tests/test_mpp_seller.py | 30 +++++++++++++++++++++++ 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 79c41aa..7f9ed61 100644 --- a/README.md +++ b/README.md @@ -415,6 +415,13 @@ when application authentication uses `Authorization`; pympp then uses `Payment-Authorization` for the payment credential. The decorator does not implement your application's authentication or access-control policy. +pympp's `pay` decorator returns HTTP `402` for payment errors, including a pending +settlement whose Problem Details body has `status: 503`. The InFlow Seller preserves +that problem; the HTTP status is chosen by pympp. Node's middleware uses the problem's +status for the HTTP response too. Inspect the problem body rather than interpreting +every `402` as a declined payment. Keep the original transaction and credential when +checking or retrying a pending settlement; do not create a replacement purchase. + ### Prices and payment verification For `method="inflow"`, `charge_request` preserves the decimal price, sets the diff --git a/interop/peer.py b/interop/peer.py index 2b65866..7f14f61 100644 --- a/interop/peer.py +++ b/interop/peer.py @@ -42,13 +42,34 @@ async def run(settings: dict[str, Any]) -> None: options = ClientOptions(base_url=settings["Platform"], api_key=f"test-only-{role}-key") async with AsyncExitStack() as stack: if role == "buyer": + if settings.get("StatusID"): + if protocol == "mpp": + mpp_status = await stack.enter_async_context(BuyerMethod(options)) + snapshots = [ + await mpp_status.get_payment_status(settings["StatusID"]) for _ in range(2) + ] + else: + x402_status = await stack.enter_async_context(await Buyer.create(options)) + snapshots = [ + await x402_status.get_payment_status(settings["StatusID"]) for _ in range(2) + ] + print(json.dumps(snapshots), flush=True) + return if protocol == "mpp": method = await stack.enter_async_context( BuyerMethod( options, - method="tempo" if variant == "tempo" else "inflow", + method=variant if variant in ("tempo", "card") else "inflow", intent="subscription" if variant == "subscription" else "charge", subscription_id=settings.get("SubscriptionID"), + instrument_id=settings.get("InstrumentID"), + merchant={ + "name": "Interop shop", + "url": "https://shop.example", + "countryCode": "US", + } + if variant == "card" + else None, poll_interval=0, pending_timeout=5, ) @@ -56,7 +77,13 @@ async def run(settings: dict[str, Any]) -> None: transport: httpx.AsyncBaseTransport = payment_transport([method]) else: buyer = await stack.enter_async_context( - await Buyer.create(options, poll_interval=0, pending_timeout=5) + await Buyer.create( + options, + prefer=("instrument",) if variant == "instrument" else ("balance", "exact"), + instrument_id=settings.get("InstrumentID"), + poll_interval=0, + pending_timeout=5, + ) ) transport = x402AsyncTransport(buyer) http = await stack.enter_async_context( @@ -95,15 +122,27 @@ async def handle() -> None: if protocol == "mpp": seller = await stack.enter_async_context( - await MppSeller.create(options, method="tempo" if variant == "tempo" else "inflow") + await MppSeller.create( + options, method=variant if variant in ("tempo", "card", "stripe") else "inflow" + ) ) - terms = seller.charge_request( + terms = ( + seller.card_request + if variant == "card" + else seller.stripe_request + if variant == "stripe" + else seller.charge_request + )( { "amount": "10000", "currency": "0x20c0000000000000000000000000000000000000", "recipient": "0x1111111111111111111111111111111111111111", } if variant == "tempo" + else {"amount": "1.25"} + if variant in ("card", "stripe") + else {"amount": "1.25", "currency": "USD"} + if variant == "instrument" else {"amount": "0.01", "currency": "USDC"} ) @@ -130,7 +169,9 @@ async def paid( resource = x402ResourceServer(facilitator) for registration in await x_seller.scheme_registrations(schemes=[variant]): resource.register(registration["network"], registration["server"]) - route = await x_seller.route("0.01 USDC", schemes=[variant]) + route = await x_seller.route( + "1.25 USD" if variant == "instrument" else "0.01 USDC", schemes=[variant] + ) app.middleware("http")(payment_middleware({"GET /paid": route}, resource)) @app.get("/paid") diff --git a/tests/test_mpp_seller.py b/tests/test_mpp_seller.py index b2fcd71..194b5dc 100644 --- a/tests/test_mpp_seller.py +++ b/tests/test_mpp_seller.py @@ -155,6 +155,36 @@ async def test_framework_roundtrip(requires_auth: bool) -> None: assert platform.closed +async def test_framework_preserves_pending_problem_with_upstream_402() -> None: + platform = Platform() + problem = { + "type": "https://paymentauth.org/problems/settlement-unavailable", + "title": "Settlement Pending", + "status": 503, + "detail": "Awaiting authentication", + } + platform.result = {"problem": problem} + async with ( + await Seller.create(options(platform)) as seller, + httpx.AsyncClient( + transport=httpx.ASGITransport(app_for(seller)), base_url="https://seller.example" + ) as client, + ): + credential = await credential_for(client) + response = await client.get("/paid", headers={"Authorization": credential}) + assert response.status_code == 402 + assert response.headers["content-type"] == "application/problem+json" + assert "payment-receipt" not in response.headers + body = response.json() + assert {key: body[key] for key in problem} == problem + assert "ok" not in body + assert [r.url.path for r in platform.requests] == [ + "/v1/mpp/config", + "/v1/mpp/validate", + "/v1/mpp/broadcast", + ] + + @pytest.mark.parametrize( "response", [None, {}, {"success": False, "problem": PROBLEM}, {"success": True}] )