Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 46 additions & 5 deletions interop/peer.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,21 +42,48 @@ 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,
)
)
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(
Expand Down Expand Up @@ -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"}
)

Expand All @@ -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")
Expand Down
30 changes: 30 additions & 0 deletions tests/test_mpp_seller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}]
)
Expand Down
Loading