From 21dffb95e0aa87b6b28edaefa8ae4d79f7798be9 Mon Sep 17 00:00:00 2001 From: Nas Kavian Date: Thu, 8 Oct 2026 00:43:45 -0700 Subject: [PATCH] feat: support card payments and payment recovery --- README.md | 157 +++++++++++++- conformance/adapter.py | 97 +++++++-- conformance/inflow-specs.lock.json | 2 +- examples/README.md | 91 +++++++- examples/mpp_buyer.py | 20 +- examples/mpp_seller.py | 23 ++- examples/x402_buyer.py | 7 +- examples/x402_seller.py | 19 +- scripts/conformance.mjs | 117 ++++++++++- scripts/conformance.test.mjs | 139 ++++++++++++- scripts/verify_distribution.py | 18 ++ src/inflowpay/_runtime.py | 10 + src/inflowpay/mpp/_requests.py | 69 ++++++- src/inflowpay/mpp/buyer.py | 72 ++++++- src/inflowpay/mpp/seller.py | 189 ++++++++++++++++- src/inflowpay/x402/_seller.py | 9 +- src/inflowpay/x402/buyer.py | 12 ++ tests/test_card_buyer.py | 321 +++++++++++++++++++++++++++++ tests/test_card_seller.py | 301 +++++++++++++++++++++++++++ tests/test_examples.py | 112 +++++++++- tests/test_instrument_parity.py | 203 ++++++++++++++++++ tests/test_payment_status.py | 154 ++++++++++++++ tests/test_stripe_seller.py | 312 ++++++++++++++++++++++++++++ 23 files changed, 2383 insertions(+), 71 deletions(-) create mode 100644 tests/test_card_buyer.py create mode 100644 tests/test_card_seller.py create mode 100644 tests/test_instrument_parity.py create mode 100644 tests/test_payment_status.py create mode 100644 tests/test_stripe_seller.py diff --git a/README.md b/README.md index be57ab0..79c41aa 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,7 @@ retains the platform's payload, payer source, and additional wire fields. | Defaults: `method="inflow", intent="charge"` | Pay an InFlow charge using the rail advertised by the seller. | | `instrument_id="..."` | Select the funding instrument for an InFlow instrument-rail charge. | | `method="tempo"` | Ask InFlow to produce a Tempo charge credential. No local wallet is required. | +| `method="card", merchant={...}` | Obtain an encrypted Visa CARD credential using the primary linked instrument, or the supplied `instrument_id`. | | `intent="subscription"` | Purchase a subscription through the create-and-approve flow. | | `intent="subscription", subscription_id="..."` | Authorize access using that existing subscription and the current seller challenge; do not purchase another subscription. | @@ -240,6 +241,44 @@ one instance's settings between concurrent requests. Do not register several met with the same method/intent expecting pympp to choose a funding instrument: pympp selects the first matching method. Select the intended instance in your application. +### Pay with a linked Visa card + +Use `method="card"` for a merchant advertising CARD charge, not for an InFlow +instrument-rail challenge or a Stripe Shared Payment Token challenge: + +```python +async with BuyerMethod( + ClientOptions(environment="sandbox", api_key=os.environ["INFLOW_API_KEY"]), + method="card", + merchant={ + "name": "Example Store", + "url": "https://store.example", + "countryCode": "US", + }, +) as method: + async with httpx.AsyncClient(transport=payment_transport([method])) as http: + response = await http.get("https://store.example/report") + response.raise_for_status() +``` + +The merchant name must be nonblank and at most 200 characters, the URL must be +absolute HTTP or HTTPS and at most 2048 characters, and `countryCode` must contain +two letters. InFlow checks the country, merchant, and card eligibility. The selected +card must have an enabled, unexpired USD allowance sufficient for the purchase. +Omit `instrument_id` to use the account's primary instrument; supply a hyphenated +UUID to select another linked card. The SDK does not create or infer an allowance. + +Merchant settings are copied when the method is constructed. Use a separate instance +for each merchant context, because pympp does not pass Node's per-call context to +credential creation. That context does not replace the seller's signed challenge. + +InFlow issues the encrypted credential; this SDK does not access card numbers or +decrypt its payload. The buyer rejects a returned CARD credential whose challenge +differs from the requested one, or whose payload has an invalid structure. Valid +billing fields and extensions are preserved. A ready credential is not a settlement +receipt: the seller still needs to accept and process it. An uncertain response is +not permission to create a second purchase. + ### Waiting, errors, and shutdown `poll_interval` defaults to five seconds; the platform's `retryAfterSeconds` takes @@ -434,9 +473,9 @@ challenges = parse_challenge_headers( ) ``` -`validate_request` checks InFlow charge/subscription and Tempo charge request shapes; -`validate_payload` checks InFlow or Tempo credential payload shapes. Both return a -deep copy. These checks do not establish supported currencies, account permissions, +`validate_request` checks InFlow charge/subscription, Tempo charge, and CARD charge +request shapes; `validate_payload` checks InFlow, Tempo, or CARD credential payload +shapes. Both return a deep copy. These checks do not establish account permissions, signature validity, or settlement. Those require the payment workflow and platform. `decode_credential` and `decode_receipt` retain the complete decoded JSON object, @@ -462,6 +501,82 @@ instead of pympp's fixed-field models, which can discard fields. These are codecs and shape checks, not proof of payment. pympp owns challenge authentication and transport; the InFlow platform owns payment verification and settlement. +### Stripe Shared Payment Token acceptance + +Create a Seller with `await Seller.create(options, method="stripe")`, using an +InFlow Seller API key. The Seller must have a verified Stripe business profile +advertised by `/v1/mpp/config`; setup fails if Stripe charge is unavailable. +The SDK reads the network profile and allowed payment methods from that response. +The application does not need a Stripe secret key. + +Use `seller.stripe_request({"amount": "1.25"})` to prepare USD 1.25. This method +accepts dollar strings from `"0.50"` through `"999999.99"` and returns integer +cents in the challenge. Extra fractional digits are rejected, not rounded. +Pass its result to the standalone `mpp.server.pay` decorator with `method="stripe"` +and `intent=seller`. Do not pass dollar prices directly to pympp's high-level +route helpers. Currency, precision, network profile, and payment-method options +cannot override the configured USD Stripe offer. + +Optional `externalId` identifies the purchase; a credential must repeat it exactly +when supplied, including an empty string. Optional `metadata` is a dictionary of +up to 45 string entries, with nonblank keys up to 40 characters and values up to +500. Brackets and the keys `externalId`, `inflowMppTransactionId`, `mppChallengeId`, +`mppIntent`, `mppMethod`, and `stripeNetworkProfile` are reserved. + +An external Buyer supplies the Shared Payment Token. The InFlow Buyer SDK does +not create Stripe tokens. The Seller forwards the credential to InFlow for +validation and then settlement; failed or pending settlement does not release +the resource. A successful receipt must identify the Stripe method and the +submitted challenge. [Run the Stripe Seller example](examples/README.md#stripe-seller). + +### CARD acceptance + +Create a Seller with `await Seller.create(options, method="card")`, using an +InFlow Seller API key. Configuration supplies the recipient, merchant name, Visa +network, and RSA public encryption key. Setup fails when that profile is unavailable +or incomplete; the application cannot override these fields when pricing a route. + +Use `seller.card_request({"amount": "1.25"})` to prepare USD 1.25, then pass the result +to the standalone `pay` decorator with `method="card"` and `intent=seller`. Amounts +are decimal dollar strings from `"0.50"` through `"999999.99"`, converted exactly to +integer cents. Optional `billingRequired` is a boolean; an omitted value remains +omitted. Optional `externalId` allows up to 255 characters, including an empty string. + +The SDK checks the encrypted credential's structure without decrypting it, forwards +it to InFlow for validation and settlement, and requires a successful receipt for +the same CARD challenge before delivery. pympp checks the signed challenge and route +terms first. The description-preservation limitation below applies to this seller +flow, even when a buyer preserves the full challenge. + +### Challenge description preservation + +pympp 0.11.0 drops the optional, top-level `challenge.description` when converting +a challenge to a credential echo or parsing and serializing a credential. This +affects the pympp-backed payment flow, including CARD. A Seller forwarding a +parsed credential therefore sends it without that description, even when the +Buyer supplied one. The encoded request, payment amount, and encrypted payload +are not changed by this omission. A `description` inside the request is a +different field and is not the field lost here. + +Applications that require an exact copy of the complete challenge cannot rely on +these conversions when `challenge.description` is present. Challenges without +that optional field avoid this particular limitation. Do not treat a missing +description as evidence that a payment failed or submit a replacement payment +because of it. + +[Upstream issue #272](https://github.com/tempoxyz/pympp/issues/272) tracks this +credential-format defect against the MPP draft and mppx. The Seller does +not replace pympp's credential parser or reconstruct a missing description. InFlow's +Buyer retains the original challenge fields when producing its outgoing credential; +that does not prevent a receiving pympp Seller from dropping the description. + +Shared conformance executes the description-preservation case and reports its +failure. CI permits that specific pympp 0.11.0 failure only when a companion check +confirms that the same operation succeeds with just the outbound description +omitted and the platform echoing that received credential. Other failures remain +blocking. An upstream version change or an unexpected pass requires reviewing or +removing the allowance. + ### Seller route limitations InFlow charges use decimal amounts: `"0.50"` means half a unit of the specified @@ -597,6 +712,42 @@ InFlow-managed payment can wait for the account owner to approve it. The default approval wait is 15 minutes, polling every 5 seconds; configure `pending_timeout` and `poll_interval` in seconds on `Buyer.create()`. +### Payment status and recovery + +Both `BuyerMethod` (MPP) and `Buyer` (x402) expose +`await buyer.get_payment_status(transaction_id)`. Pass the original transaction +identifier after an uncertain payment result. The method returns the server's +transaction dictionary, including `status` and any `nextAction`, without creating, +confirming, or cancelling a payment. It does not open the next-action URL. + +If `nextAction.type` is `authenticate_card`, your application can direct the buyer +to its `url` to authenticate in the dashboard. A later call reads the current +transaction state. Credential or payload availability does not establish that a +payment settled; neither do `INITIATED`, `PENDING`, or `PROCESSING` statuses. + +Each call makes one request by default. Set `retries=1` to retry a transient read +failure; retries are capped at three. Cancelling the read leaves the payment alone. +An HTTP error, including a 404, is not permission to create a replacement payment. +Keep the original transaction identifier when the outcome remains uncertain. + +### Linked-card payments + +For a linked-card payment, pass `instrument_id="your-card-uuid"` to `Buyer.create()`. +It is forwarded only for the `instrument` scheme. Omit it to let InFlow select the +account's primary card. A rejected selection returns the server error; the SDK does +not try another card or create a replacement purchase. An explicit selection takes +precedence over `instrumentId` in transaction request extensions. +For automatic HTTP selection, also include `"instrument"` in `prefer`, for example +`prefer=("instrument",)`. The default preference remains balance followed by exact; +setting a card identifier does not change which payment scheme is selected. + +Seller instrument offers require `schemes=["instrument"]` and a fiat USD price. +They are not included by default and do not use stablecoin expansion. Prices must +represent whole cents from USD 0.50 to 92233720368547758.07; the advertised wire +scale is retained. No blockchain wallet is required to construct a configured +instrument offer. MPP instrument receipts must match the method and challenge +before the protected resource is released. + ### Show an approval before waiting Use `await buyer.prepare(requirement, resource)` when your application needs the diff --git a/conformance/adapter.py b/conformance/adapter.py index 8e2d7e2..848ee5f 100644 --- a/conformance/adapter.py +++ b/conformance/adapter.py @@ -8,6 +8,7 @@ import httpx from fastapi import FastAPI, Request from mpp import Challenge, Credential, Receipt +from mpp.errors import InvalidChallengeError from mpp.server.decorator import pay from mpp.server.intent import broadcast_credential from starlette.responses import JSONResponse @@ -51,7 +52,7 @@ def options(data: Data, transport: httpx.AsyncBaseTransport | None = None) -> Cl def classify(error: BaseException, operation: str, data: Data) -> Data: details: Data = {} if isinstance(error, InflowApiError): - if operation.startswith("x402."): + if operation.startswith("x402.") or operation.endswith(".payment-status"): return { "code": "api-error", "message": "InFlow API request failed.", @@ -65,13 +66,15 @@ def classify(error: BaseException, operation: str, data: Data) -> Data: details["transaction_id"] = error.transaction_id elif isinstance(error, MppPaymentFailedError): code = "payment-failed" + if error.transaction_id is not None: + details["transaction_id"] = error.transaction_id if error.problem is not None: details["problem"] = error.problem elif isinstance(error, MppCredentialProblemError): code = "payment-failed" if data.get("include_problem", True): details["problem"] = error.problem - elif isinstance(error, MppMalformedCredentialError): + elif isinstance(error, (MppMalformedCredentialError, InvalidChallengeError)): code = "invalid-credential" elif isinstance(error, mpp.MppCodecError): code = ( @@ -86,18 +89,27 @@ def classify(error: BaseException, operation: str, data: Data) -> Data: if error.status is not None: details["status"] = error.status elif ( - type(error) is ValueError - and operation in ("x402.seller.offers", "x402.seller.route") - and str(error) - in ( - "Price must be '$1.00', '1.00 USDC', or a plain amount with currency", - "A currency is required for a plain amount", - "Price cannot be represented in the asset's decimal precision", + ( + type(error) is ValueError + and operation in ("x402.seller.offers", "x402.seller.route") + and str(error) + in ( + "Price must be '$1.00', '1.00 USDC', or a plain amount with currency", + "A currency is required for a plain amount", + "Price cannot be represented in the asset's decimal precision", + "Instrument payments require USD 0.50-92233720368547758.07", + ) + ) + or ( + type(error) is ValueError + and operation == "mpp.buyer.fulfil" + and data["challenge"]["method"] == "card" + ) + or ( + type(error) is ValueError + and operation == "x402.buyer.sign" + and str(error) == "Invalid payment identifier" ) - ) or ( - type(error) is ValueError - and operation == "x402.buyer.sign" - and str(error) == "Invalid payment identifier" ): code = "invalid-input" else: @@ -150,6 +162,7 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response: pending_timeout=data.get("timeout_ms", 5000) / 1000, instrument_id=data["context"].get("instrumentId"), subscription_id=data["context"].get("subscriptionId"), + merchant=data["context"].get("merchant"), ) as buyer: payment = asyncio.create_task(buyer.create_credential(challenge)) return mpp.decode_credential((await payment).to_authorization()[8:]) @@ -165,7 +178,11 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response: method = wire["challenge"]["method"] if wire else data["method"] async with await MppSeller.create(options(data), method=method) as seller: if operation == "mpp.seller.prepare": - return seller.charge_request(data["request"]) + if method == "card": + return await route_binding(seller, data, prepare_only=True) + return (seller.stripe_request if method == "stripe" else seller.charge_request)( + data["request"] + ) if operation == "mpp.seller.route-binding": return await route_binding(seller, data) credential = Credential.from_authorization("Payment " + mpp.encode(wire)) @@ -179,6 +196,7 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response: return mpp.decode_receipt(receipt.to_payment_receipt()) value = await seller.validate(credential, request) observed = mpp.decode_credential(value.credential.to_authorization()[8:]) + observed.setdefault("source", "") return { "success": True, "challenge": observed["challenge"], @@ -191,9 +209,12 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response: } -async def route_binding(seller: MppSeller, data: Data) -> object: +async def route_binding(seller: MppSeller, data: Data, *, prepare_only: bool = False) -> object: app = FastAPI() - terms = seller.charge_request(data["request"]) + prepare = {"stripe": seller.stripe_request, "card": seller.card_request}.get( + seller.method, seller.charge_request + ) + terms = prepare(data["request"]) @app.get("/test") @pay( @@ -211,10 +232,18 @@ async def handler(request: Request, credential: Credential, receipt: Receipt) -> ) as client: initial = await client.get("/test") challenge = Challenge.from_www_authenticate(initial.headers["www-authenticate"]) + if prepare_only: + prepared = mpp.decode(challenge.request_b64) + if not isinstance(prepared, dict): + raise RuntimeError("Expected framework request object") + # Framework resource binding is not part of the shared offer projection. + return {key: value for key, value in prepared.items() if key != "_mppx_scope"} credential = Credential( - challenge=challenge.to_echo(), payload=data["credential_payload"], source=data["source"] + challenge=challenge.to_echo(), + payload=data["credential_payload"], + source=data.get("source"), ) - terms = seller.charge_request(data["replacement_request"]) + terms = prepare(data["replacement_request"]) response = await client.get( "/test", headers={"Authorization": credential.to_authorization()} ) @@ -284,6 +313,7 @@ def offer(value: Any) -> Data: if operation in ("x402.buyer.sign", "x402.buyer.cancel", "x402.buyer.concurrent-await"): async with await Buyer.create( options(data), + instrument_id=data.get("instrument_id"), poll_interval=data.get("poll_interval_ms", 1) / 1000, pending_timeout=data.get("timeout_ms", 2000) / 1000, ) as buyer: @@ -432,8 +462,37 @@ async def respond(request: Data) -> Data: raise RuntimeError("Unsupported adapter version") data, operation = request["input"], request["operation"] before = deepcopy(data) + result: object + observation: Data try: - if operation.startswith("mpp."): + if operation.endswith(".buyer.payment-status"): + + async def token() -> str: + return str(data["access_token"]) + + config = options(data) + if "access_token" in data: + config = ClientOptions(base_url=config.base_url, access_token=token) + buyer = ( + BuyerMethod(config) + if operation.startswith("mpp.") + else await Buyer.create(config) + ) + async with buyer: + snapshots = [] + for _ in range(data.get("reads", 1)): + snapshot = await buyer.get_payment_status( + data["transaction_id"], retries=data.get("retries", 0) + ) + snapshots.append( + { + key: snapshot[key] + for key in ("transactionId", "status", "nextAction") + if key in snapshot + } + ) + result = snapshots + elif operation.startswith("mpp."): result = await mpp_execute(operation, data) elif operation.startswith("x402."): result = await x402_execute(operation, data) diff --git a/conformance/inflow-specs.lock.json b/conformance/inflow-specs.lock.json index 107576d..00aeeb4 100644 --- a/conformance/inflow-specs.lock.json +++ b/conformance/inflow-specs.lock.json @@ -1,4 +1,4 @@ { "repository": "inflowpayai/inflow-specs", - "revision": "46f65400aa6e5b7a8f719a8378d5f36186e5c94d" + "revision": "e9535e66c8418689502707e856335ef8a863c52c" } diff --git a/examples/README.md b/examples/README.md index dcfc3f9..e38d68b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -70,6 +70,55 @@ runs. This example uses one charge offer. MPP Seller subscriptions and composed InFlow offers are not supported by this integration; see the [upstream limitations](../README.md#seller-route-limitations). +### Stripe Seller + +The same Seller program can offer a Stripe charge for **USD 1.25**. Use a Sandbox +Seller account with a verified Stripe business profile enabled in its payment +configuration. Keep the Seller API key and private challenge key configured as above: + +```sh +MPP_METHOD=stripe uv run --locked python -m examples.mpp_seller +curl -i http://127.0.0.1:3000/api/widgets +``` + +The response advertises `stripe/charge` with `"amount":"125"` (integer cents), +`"currency":"usd"`, and the profile and payment methods supplied by InFlow. +An external Stripe-capable Buyer must supply a valid Shared Payment Token to pay. +The InFlow Buyer example does not create these tokens. Without one, you can verify +the challenge but cannot complete a payment. No Stripe secret key belongs in this +Seller example; token validation and processing run on InFlow. + +### CARD Buyer and Seller + +CARD uses an encrypted Visa credential, not a Stripe Shared Payment Token. The +Seller's authenticated configuration must advertise CARD with its recipient and +public encryption key. With the Seller key and challenge secret set, run: + +```sh +MPP_METHOD=card uv run --locked python -m examples.mpp_seller +curl -i http://127.0.0.1:3000/api/widgets +``` + +The challenge offers **USD 1.25**, encoded as `"amount":"125"` cents. In a separate +terminal with the Buyer key, provide the actual merchant's details: + +```sh +export INFLOW_API_KEY='your-sandbox-buyer-key' +export MPP_METHOD=card +export CARD_MERCHANT_NAME='Your merchant name' +export CARD_MERCHANT_URL='https://your-merchant.example' +export CARD_MERCHANT_COUNTRY='US' +uv run --locked python -m examples.mpp_buyer +``` + +The Buyer account needs a linked, enabled Visa card with an unexpired USD allowance +covering the purchase. Set `INFLOW_INSTRUMENT_ID` to that card's UUID, or omit it +to select the primary instrument. The example does not configure allowances or +handle raw card numbers. Without eligible configuration you can inspect the Seller's +challenge, but cannot complete a payment. A ready credential still requires Seller +processing; successful delivery includes a matching CARD receipt. Read the +[pympp description limitation](../README.md#challenge-description-preservation). + ## x402 Start the [Seller](x402_seller.py) in one terminal: @@ -108,14 +157,44 @@ settles before releasing the response. The handler only returns content: payment middleware does not make database writes or other application side effects atomic with settlement. No external wallet or retry-recovery hook is registered here. +### x402 instrument payments + +To offer **USD 1.25** through InFlow's instrument scheme, start the Seller with +`X402_SCHEME=instrument`. It must have that scheme enabled in its configuration: + +```sh +X402_SCHEME=instrument uv run --locked python -m examples.x402_seller +``` + +In the Buyer terminal, select the same scheme. Optionally supply a linked +instrument's UUID; when omitted, InFlow selects the primary instrument: + +```sh +export X402_SCHEME=instrument +export INFLOW_INSTRUMENT_ID='your-linked-instrument-uuid' +uv run --locked python -m examples.x402_buyer +``` + +`X402_SCHEME=instrument` restricts selection to instrument offers; setting an +instrument identifier alone does not select that scheme. The default example +continues to use balance/exact USDC offers. x402 instrument payments are distinct +from MPP CARD and Stripe Shared Payment Tokens; none of these examples converts +one credential format into another. + ## Settings and safe testing -| Variable | Used by | Meaning | -| ----------------- | ---------- | ----------------------------------------------------------- | -| `INFLOW_API_KEY` | All | Sandbox key; Sellers require a Seller account key | -| `MPP_SECRET_KEY` | MPP Seller | Private key for signing challenges | -| `TARGET_URL` | Buyers | Defaults to the matching local Seller's `/api/widgets` | -| `INFLOW_BASE_URL` | All | Optional platform override; leave unset to use Sandbox | +| Variable | Used by | Meaning | +| ----------------------- | ----------- | ---------------------------------------------------------- | +| `INFLOW_API_KEY` | All | Sandbox key; Sellers require a Seller account key | +| `MPP_SECRET_KEY` | MPP Seller | Private key for signing challenges | +| `MPP_METHOD` | MPP | `inflow` by default; `card`, or `stripe` for the Seller only | +| `X402_SCHEME` | x402 | `default` for balance/exact, or `instrument` | +| `INFLOW_INSTRUMENT_ID` | Buyers | Optional linked instrument UUID | +| `CARD_MERCHANT_NAME` | CARD Buyer | Merchant name | +| `CARD_MERCHANT_URL` | CARD Buyer | Absolute merchant website URL | +| `CARD_MERCHANT_COUNTRY` | CARD Buyer | Two-letter merchant country code | +| `TARGET_URL` | Buyers | Defaults to the matching local Seller's `/api/widgets` | +| `INFLOW_BASE_URL` | All | Optional platform override; leave unset to use Sandbox | The programs read exported variables, not `.env` files. Never commit real keys. Leave `INFLOW_BASE_URL` unset unless intentionally testing a private deployment: diff --git a/examples/mpp_buyer.py b/examples/mpp_buyer.py index d617fa6..9087e65 100644 --- a/examples/mpp_buyer.py +++ b/examples/mpp_buyer.py @@ -5,7 +5,7 @@ import httpx from inflowpay import ClientOptions -from inflowpay.mpp import decode_receipt +from inflowpay.mpp import WireObject, decode_receipt from inflowpay.mpp.buyer import BuyerMethod, payment_transport @@ -14,13 +14,29 @@ async def run() -> None: if not key: raise ValueError("Set INFLOW_API_KEY to your Sandbox buyer API key.") target = os.environ.get("TARGET_URL", "http://127.0.0.1:3000/api/widgets") + payment_method = os.environ.get("MPP_METHOD", "inflow") + if payment_method not in ("inflow", "card"): + raise ValueError( + "MPP_METHOD must be inflow or card; this Buyer does not issue Stripe tokens." + ) + merchant: WireObject | None = None + if payment_method == "card": + # Supply the actual merchant context; the SDK does not infer it from the target URL. + merchant = { + "name": os.environ.get("CARD_MERCHANT_NAME", ""), + "url": os.environ.get("CARD_MERCHANT_URL", ""), + "countryCode": os.environ.get("CARD_MERCHANT_COUNTRY", ""), + } print("Requesting resource; approve in the Sandbox dashboard if requested.", flush=True) # The platform key belongs to BuyerMethod, never to the merchant HTTP client. async with ( BuyerMethod( ClientOptions( environment="sandbox", api_key=key, base_url=os.environ.get("INFLOW_BASE_URL") - ) + ), + method=payment_method, + merchant=merchant, + instrument_id=os.environ.get("INFLOW_INSTRUMENT_ID"), ) as method, httpx.AsyncClient(transport=payment_transport([method]), follow_redirects=False) as http, ): diff --git a/examples/mpp_seller.py b/examples/mpp_seller.py index f3b9478..84632f6 100644 --- a/examples/mpp_seller.py +++ b/examples/mpp_seller.py @@ -15,16 +15,25 @@ @asynccontextmanager -async def application(options: ClientOptions, secret: str) -> AsyncIterator[FastAPI]: +async def application( + options: ClientOptions, secret: str, *, method: str = "inflow" +) -> AsyncIterator[FastAPI]: # Keep the Seller open for the whole server lifetime, not just startup. - async with await Seller.create(options) as seller: + async with await Seller.create(options, method=method) as seller: + # Stripe and CARD prices are dollars here; their challenges contain integer cents. + if method == "card": + terms = seller.card_request({"amount": "1.25"}) + elif method == "stripe": + terms = seller.stripe_request({"amount": "1.25"}) + else: + terms = seller.charge_request({"amount": "0.01", "currency": "USDC"}) app = FastAPI() @app.get("/api/widgets") @pay( intent=seller, method=seller.method, - request=seller.charge_request({"amount": "0.01", "currency": "USDC"}), + request=terms, realm="localhost", secret_key=secret, ) @@ -52,9 +61,13 @@ async def run() -> None: options = ClientOptions( environment="sandbox", api_key=key, base_url=os.environ.get("INFLOW_BASE_URL") ) - async with application(options, secret) as app: + method = os.environ.get("MPP_METHOD", "inflow") + if method not in ("inflow", "stripe", "card"): + raise ValueError("MPP_METHOD must be inflow, stripe, or card") + async with application(options, secret, method=method) as app: + price = "0.01 USDC" if method == "inflow" else f"1.25 USD via {method}" print( - "MPP: http://127.0.0.1:3000/api/widgets costs 0.01 USDC; /free requires no payment.", + f"MPP: http://127.0.0.1:3000/api/widgets costs {price}; /free requires no payment.", flush=True, ) await uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=3000)).serve() diff --git a/examples/x402_buyer.py b/examples/x402_buyer.py index 7cb988d..7ce255a 100644 --- a/examples/x402_buyer.py +++ b/examples/x402_buyer.py @@ -15,13 +15,18 @@ async def run() -> None: if not key: raise ValueError("Set INFLOW_API_KEY to your Sandbox buyer API key.") target = os.environ.get("TARGET_URL", "http://127.0.0.1:3001/api/widgets") + scheme = os.environ.get("X402_SCHEME", "default") + if scheme not in ("default", "instrument"): + raise ValueError("X402_SCHEME must be default or instrument") print("Requesting resource; approve in the Sandbox dashboard if requested.", flush=True) # No external wallet or recovery hook is registered: a failed paid retry stops here. async with ( await Buyer.create( ClientOptions( environment="sandbox", api_key=key, base_url=os.environ.get("INFLOW_BASE_URL") - ) + ), + prefer=("instrument",) if scheme == "instrument" else ("balance", "exact"), + instrument_id=os.environ.get("INFLOW_INSTRUMENT_ID"), ) as buyer, httpx.AsyncClient(transport=x402AsyncTransport(buyer), follow_redirects=False) as http, ): diff --git a/examples/x402_seller.py b/examples/x402_seller.py index 4c04137..5377f4e 100644 --- a/examples/x402_seller.py +++ b/examples/x402_seller.py @@ -16,7 +16,9 @@ @asynccontextmanager -async def application(options: ClientOptions) -> AsyncIterator[FastAPI]: +async def application( + options: ClientOptions, *, instrument: bool = False +) -> AsyncIterator[FastAPI]: # Each client owns its HTTP connection pool; keep both open while serving. async with ( await Seller.create(options) as seller, @@ -25,9 +27,12 @@ async def application(options: ClientOptions) -> AsyncIterator[FastAPI]: resource = x402ResourceServer(facilitator) for registration in await seller.scheme_registrations(): resource.register(registration["network"], registration["server"]) - offers = await seller.offers("0.01 USDC", schemes=["balance", "exact"]) + offers = await seller.offers( + "$1.25" if instrument else "0.01 USDC", + schemes=["instrument"] if instrument else ["balance", "exact"], + ) if not offers: - raise ValueError("The Seller configuration has no USDC payment offers.") + raise ValueError("The Seller configuration has no matching payment offers.") app = FastAPI() app.middleware("http")( payment_middleware({"GET /api/widgets": RouteConfig(accepts=offers)}, resource) @@ -52,9 +57,13 @@ async def run() -> None: options = ClientOptions( environment="sandbox", api_key=key, base_url=os.environ.get("INFLOW_BASE_URL") ) - async with application(options) as app: + scheme = os.environ.get("X402_SCHEME", "default") + if scheme not in ("default", "instrument"): + raise ValueError("X402_SCHEME must be default or instrument") + async with application(options, instrument=scheme == "instrument") as app: + price = "1.25 USD via instrument" if scheme == "instrument" else "0.01 USDC" print( - "x402: http://127.0.0.1:3001/api/widgets costs 0.01 USDC; /free requires no payment.", + f"x402: http://127.0.0.1:3001/api/widgets costs {price}; /free requires no payment.", flush=True, ) await uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=3001)).serve() diff --git a/scripts/conformance.mjs b/scripts/conformance.mjs index 4587845..914f060 100644 --- a/scripts/conformance.mjs +++ b/scripts/conformance.mjs @@ -21,6 +21,50 @@ export function checkContract(path, revision) { ) throw new Error(`Use a clean contract checkout at ${revision}`); } +const descriptionIssue = "https://github.com/tempoxyz/pympp/issues/272"; +const descriptionCase = "mpp.card.verify-reference"; + +export function acceptsDescriptionFailure(report, diagnostic) { + const failures = report.results.filter((item) => item.status !== "passed"); + return ( + report.completed === true && + !report.runner_error && + !report.passed && + report.implementation.dependencies.pympp === "0.11.0" && + failures.length === 1 && + failures[0].case_id === descriptionCase && + failures[0].suite === "mpp-seller" && + failures[0].status === "failed" && + failures[0].message === "Unexpected platform request at exchange 2" && + diagnostic?.completed === true && + diagnostic.passed === true && + !diagnostic.runner_error && + diagnostic.results.length === 1 && + diagnostic.results[0].case_id === descriptionCase && + diagnostic.results[0].status === "passed" + ); +} + +export function descriptionDiagnostic(index) { + const item = structuredClone( + index.cases.find((item) => item.id === descriptionCase), + ); + if (!item?.input.credential.challenge.description) + throw new Error("Description fixture changed; review pympp#272 allowance"); + // Keep the caller's complete credential; the platform echoes the received credential. + for (const exchange of item.platform.exchanges) { + if (exchange.request.json?.credential) { + exchange.request = structuredClone(exchange.request); + delete exchange.request.json.credential.challenge.description; + } + if (exchange.response?.json?.credential) { + exchange.response = structuredClone(exchange.response); + delete exchange.response.json.credential.challenge.description; + delete exchange.response.json.challenge.description; + } + } + return { ...index, cases: [item] }; +} async function main() { const { values } = parseArgs({ options: { @@ -61,7 +105,15 @@ async function main() { process.once("SIGINT", abort); process.once("SIGTERM", abort); try { - for (const suite of ["runtime", "mpp", "x402", "tap"]) { + for (const suite of [ + "runtime", + "mpp", + "x402", + "tap", + "payment-status", + "stripe", + "card", + ]) { if (controller.signal.aborted) throw new Error("Conformance interrupted"); const fixtures = await import( pathToFileURL(join(contractRoot, `fixtures/${suite}.mjs`)) @@ -72,18 +124,32 @@ async function main() { 0o600, ); try { - const report = await run({ + const configuration = { index: suite === "runtime" ? runtimeCases(fixtures.runtimeScenarios) - : fixtures[`${suite}Cases`], + : fixtures[ + suite === "payment-status" + ? "paymentStatusCases" + : `${suite}Cases` + ], capabilities: { suites: suite === "runtime" ? ["runtime"] - : suite === "tap" - ? ["tap-seller"] - : [`${suite}-core`, `${suite}-buyer`, `${suite}-seller`], + : suite === "payment-status" + ? ["mpp-buyer", "x402-buyer"] + : suite === "card" + ? ["mpp-seller", "mpp-buyer"] + : suite === "stripe" + ? ["mpp-seller"] + : suite === "tap" + ? ["tap-seller"] + : [ + `${suite}-core`, + `${suite}-buyer`, + `${suite}-seller`, + ], supported_features: [], unsupported_features: suite === "mpp" @@ -101,12 +167,47 @@ async function main() { contractRoot, sdkRoot: root, signal: controller.signal, - }); + }; + const report = await run(configuration); await output.writeFile(JSON.stringify(report, null, 2) + "\n"); console.log( `${suite}: ${report.results.filter((r) => r.status === "passed").length}/${report.results.length} passed`, ); - if (!report.passed) { + let accepted = false; + if (suite === "card") { + const diagnostic = await run({ + ...configuration, + index: descriptionDiagnostic(configuration.index), + capabilities: { + ...configuration.capabilities, + suites: ["mpp-seller"], + }, + }); + const diagnosticOutput = await open( + join(outputDirectory, "card-description-diagnostic.json"), + "wx", + 0o600, + ); + try { + await diagnosticOutput.writeFile( + JSON.stringify(diagnostic, null, 2) + "\n", + ); + } finally { + await diagnosticOutput.close(); + } + accepted = acceptsDescriptionFailure(report, diagnostic); + if (accepted) + console.warn( + `Known failed case ${descriptionCase}: ${descriptionIssue}`, + ); + else { + process.exitCode = 1; + console.error( + `Review or remove the pympp#272 allowance: ${descriptionIssue}`, + ); + } + } + if (!report.passed && !accepted) { process.exitCode = 1; console.error( report.runner_error ?? diff --git a/scripts/conformance.test.mjs b/scripts/conformance.test.mjs index e98e4cd..c898e14 100644 --- a/scripts/conformance.test.mjs +++ b/scripts/conformance.test.mjs @@ -3,7 +3,11 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { runtimeCases } from "../conformance/runtime-cases.mjs"; -import { checkContract } from "./conformance.mjs"; +import { + acceptsDescriptionFailure, + checkContract, + descriptionDiagnostic, +} from "./conformance.mjs"; const root = fileURLToPath(new URL("..", import.meta.url)); test("runtime cases preserve fixtures and use public operations", () => { @@ -105,3 +109,136 @@ test("missing output configuration fails before launching an adapter", () => { /--contract-root PATH --output-dir EXISTING_DIRECTORY/, ); }); + +test("description allowance requires the exact failure and a passing diagnostic", () => { + const report = { + completed: true, + passed: false, + implementation: { dependencies: { pympp: "0.11.0" } }, + results: [ + { + case_id: "mpp.card.verify-reference", + suite: "mpp-seller", + status: "failed", + message: "Unexpected platform request at exchange 2", + }, + ], + }; + const diagnostic = { + completed: true, + passed: true, + results: [{ case_id: "mpp.card.verify-reference", status: "passed" }], + }; + assert.equal(acceptsDescriptionFailure(report, diagnostic), true); + for (const change of [ + (r) => { + r.completed = false; + }, + (r) => { + r.passed = true; + }, + (r) => { + r.runner_error = "Adapter failed"; + }, + (r) => { + r.implementation.dependencies.pympp = "0.12.0"; + }, + (r) => { + r.results[0].case_id = "another-case"; + }, + (r) => { + r.results[0].suite = "mpp-buyer"; + }, + (r) => { + r.results[0].status = "skipped"; + }, + (r) => { + r.results[0].status = "passed"; + }, + (r) => { + r.results[0].message = "Unexpected platform request at exchange 3"; + }, + (r) => { + r.results.push({ case_id: "other", status: "failed" }); + }, + (r) => { + r.results.push({ case_id: "other", status: "not_run" }); + }, + ]) { + const changed = structuredClone(report); + change(changed); + assert.equal(acceptsDescriptionFailure(changed, diagnostic), false); + } + for (const change of [ + (d) => { + d.completed = false; + }, + (d) => { + d.passed = false; + }, + (d) => { + d.runner_error = "Failure"; + }, + (d) => { + d.results = []; + }, + (d) => { + d.results[0].case_id = "other"; + }, + (d) => { + d.results[0].status = "failed"; + }, + ]) { + const changed = structuredClone(diagnostic); + change(changed); + assert.equal(acceptsDescriptionFailure(report, changed), false); + } + assert.equal(acceptsDescriptionFailure(report), false); +}); + +test("description diagnostic changes only outbound descriptions and their platform echoes", () => { + const credential = { + challenge: { + description: "Test purchase", + expires: "2030", + request: "encoded", + }, + }; + const index = { + cases: [ + { + id: "mpp.card.verify-reference", + input: { credential }, + platform: { + exchanges: [ + { request: { method: "GET" } }, + { + request: { json: { credential } }, + response: { + json: { credential, challenge: credential.challenge }, + }, + }, + { request: { json: { credential } } }, + ], + }, + }, + ], + }; + const before = structuredClone(index); + const diagnostic = descriptionDiagnostic(index); + assert.deepEqual(index, before); + const expected = structuredClone(before); + // Independent objects represent the JSON fixture, rather than shared references. + const unaliased = JSON.parse(JSON.stringify(expected)); + delete unaliased.cases[0].platform.exchanges[1].request.json.credential + .challenge.description; + delete unaliased.cases[0].platform.exchanges[2].request.json.credential + .challenge.description; + delete unaliased.cases[0].platform.exchanges[1].response.json.credential + .challenge.description; + delete unaliased.cases[0].platform.exchanges[1].response.json.challenge + .description; + assert.deepEqual(diagnostic, unaliased); + assert.equal(diagnostic.cases.length, 1); + assert.throws(() => descriptionDiagnostic({ cases: [] }), /fixture changed/); +}); diff --git a/scripts/verify_distribution.py b/scripts/verify_distribution.py index 1c11d09..cd86a11 100644 --- a/scripts/verify_distribution.py +++ b/scripts/verify_distribution.py @@ -80,6 +80,24 @@ async def check_buyer(): transport = payment_transport([buyer]) await transport.aclose() asyncio.run(check_buyer()) +async def check_card_buyer(): + import json + key = dict(kty='RSA', alg='RSA-OAEP-256', use='enc', kid='test', n='test', e='AQAB') + wire = dict(id='test', realm='seller.example', method='card', intent='charge', + description='Report', request=encode(dict(amount='125', currency='usd', recipient='seller', + methodDetails=dict(merchantName='Seller', acceptedNetworks=['visa'], encryptionJwk=key)))) + payload = dict(encryptedPayload='test-only', network='visa', panLastFour='4242', + panExpirationMonth='12', panExpirationYear='2030') + merchant = dict(name='Seller', url='https://seller.example', countryCode='US') + def respond(request): + assert json.loads(request.content) == dict(challenge=wire, options=dict(merchant=merchant)) + credential = encode(dict(challenge=wire, payload=payload)) + return httpx.Response(200, json=dict(state='ready', credential=credential)) + async with BuyerMethod(ClientOptions(transport=httpx.MockTransport(respond)), + method='card', merchant=merchant) as buyer: + credential = await buyer.create_credential(to_pympp_challenge(wire)) + assert decode(credential.to_authorization()[8:]) == dict(challenge=wire, payload=payload) +asyncio.run(check_card_buyer()) async def check_seller(): transport = httpx.MockTransport(lambda request: httpx.Response(200, json={ 'sellerId': '11111111-1111-4111-8111-111111111111', diff --git a/src/inflowpay/_runtime.py b/src/inflowpay/_runtime.py index fb09ab0..922ecda 100644 --- a/src/inflowpay/_runtime.py +++ b/src/inflowpay/_runtime.py @@ -235,6 +235,16 @@ async def _attempt( return _parse(response) return _error(path, response, (token,) if token else ()) + async def get_payment_status( + self, transaction_id: str, *, retries: int = 0 + ) -> dict[str, object]: + value = await self.request( + "GET", f"/v1/transactions/{quote(transaction_id, safe='')}", retries=retries + ) + if not isinstance(value, dict): + raise ValueError("Payment status response must be an object") + return {str(key): item for key, item in value.items()} + async def cancel_approval(self, approval_id: str) -> None: async def cancel() -> None: try: diff --git a/src/inflowpay/mpp/_requests.py b/src/inflowpay/mpp/_requests.py index d1ea55a..ef74e65 100644 --- a/src/inflowpay/mpp/_requests.py +++ b/src/inflowpay/mpp/_requests.py @@ -25,6 +25,8 @@ def _optional_strings(data: WireObject, fields: tuple[str, ...], pattern: str = def validate_request(method: str, intent: str, value: object) -> WireObject: request = object_value(value) + if method == "card" and intent == "charge": + return _card_request(request) if method == "inflow" and intent in ("charge", "subscription"): amount = _match(request.get("amount"), r"-?[0-9]+(?:\.[0-9]+)?") string(request.get("currency")) @@ -91,9 +93,74 @@ def validate_request(method: str, intent: str, value: object) -> WireObject: return deepcopy(request) +def _card_request(request: WireObject) -> WireObject: + amount = _match(request.get("amount"), r"[1-9][0-9]{0,7}") + if int(amount) < 50 or request.get("currency") != "usd": + raise MppCodecError("CARD requires USD and at least 50 cents") + details = object_value(request.get("methodDetails")) + networks = details.get("acceptedNetworks") + if not isinstance(networks, list) or not networks or any(item != "visa" for item in networks): + raise MppCodecError("CARD requires the Visa network") + recipient, merchant = string(request.get("recipient")), string(details.get("merchantName")) + if ( + len(recipient.encode("utf-16-le")) // 2 > 255 + or len(merchant.encode("utf-16-le")) // 2 > 255 + ): + raise MppCodecError("CARD recipient and merchant name allow at most 255 characters") + key = object_value(details.get("encryptionJwk")) + if key.get("kty") != "RSA" or key.get("alg") != "RSA-OAEP-256" or key.get("use") != "enc": + raise MppCodecError("CARD requires an RSA-OAEP-256 public encryption key") + normalized: WireObject = { + "acceptedNetworks": deepcopy(networks), + "merchantName": merchant, + "encryptionJwk": { + "kty": "RSA", + "alg": "RSA-OAEP-256", + "use": "enc", + "kid": string(key.get("kid")), + "n": _match(key.get("n"), r"[A-Za-z0-9_-]+"), + "e": _match(key.get("e"), r"[A-Za-z0-9_-]+"), + }, + } + if "billingRequired" in details: + if type(details["billingRequired"]) is not bool: + raise MppCodecError("billingRequired must be a boolean") + normalized["billingRequired"] = details["billingRequired"] + result: WireObject = { + "amount": amount, + "currency": "usd", + "recipient": recipient, + "methodDetails": normalized, + } + for field in ("externalId", "description"): + if field in request: + value = request[field] + if not isinstance(value, str) or ( + field == "externalId" and len(value.encode("utf-16-le")) // 2 > 255 + ): + raise MppCodecError(f"Invalid CARD {field}") + result[field] = value + return result + + def validate_payload(method: str, value: object) -> WireObject: payload = object_value(value) - if method == "tempo": + if method == "card": + encrypted = string(payload.get("encryptedPayload")) + if len(encrypted.encode("utf-16-le")) // 2 > 16384 or payload.get("network") != "visa": + raise MppCodecError("Invalid CARD encrypted payload or network") + _match(payload.get("panLastFour"), r"[0-9]{4}") + _match(payload.get("panExpirationMonth"), r"(?:0[1-9]|1[0-2])") + _match(payload.get("panExpirationYear"), r"[0-9]{4}") + for field in ("cardholderFullName", "paymentAccountReference"): + if field in payload and not isinstance(payload[field], str): + raise MppCodecError(f"Invalid CARD {field}") + if "billingAddress" in payload: + address = object_value(payload["billingAddress"]) + for field in ("line1", "line2", "city", "state", "zip", "countryCode"): + if field in address and not isinstance(address[field], str): + raise MppCodecError(f"Invalid CARD billing {field}") + elif method == "tempo": _optional_strings(payload, ("hash", "signature"), r"0x[0-9a-fA-F]+") _optional_strings(payload, ("transactionId",)) kind = payload.get("type") diff --git a/src/inflowpay/mpp/buyer.py b/src/inflowpay/mpp/buyer.py index 3bcb3f3..8027431 100644 --- a/src/inflowpay/mpp/buyer.py +++ b/src/inflowpay/mpp/buyer.py @@ -2,6 +2,7 @@ import asyncio import math +import re from collections.abc import Sequence from copy import deepcopy from dataclasses import dataclass, field @@ -18,7 +19,7 @@ from .._runtime import Client from ..options import ClientOptions from ._pympp import from_pympp_challenge -from ._requests import validate_request +from ._requests import validate_payload, validate_request from ._wire import ( MppCodecError, WireObject, @@ -44,8 +45,9 @@ class MppMalformedCredentialError(ValueError): class MppPaymentFailedError(Exception): - def __init__(self, problem: WireObject | None) -> None: + def __init__(self, problem: WireObject | None, transaction_id: str | None = None) -> None: self.problem = deepcopy(problem) + self.transaction_id = transaction_id details = problem or {} super().__init__(details.get("detail") or details.get("title") or "MPP payment failed") @@ -80,8 +82,14 @@ def to_authorization(self) -> str: def _credential(response: WireObject, challenge: WireObject) -> Credential: try: wire = decode_credential(string(response.get("credential"))) - # Echo the selected challenge, preserving the platform's payload and payer source. - wire["challenge"] = deepcopy(challenge) + if challenge["method"] == "card": + # CARD binds the issued encrypted credential to the complete requested challenge. + if encode(wire["challenge"]) != encode(challenge): + raise MppMalformedCredentialError("CARD credential does not match the challenge") + validate_payload("card", wire["payload"]) + else: + # InFlow and Tempo echo the selected challenge, as their Node methods do. + wire["challenge"] = deepcopy(challenge) parsed = Credential.from_authorization("Payment " + encode(wire)) source = wire.get("source") return _WireCredential( @@ -103,6 +111,26 @@ def _identifier(value: WireObject, key: str) -> str | None: return item if isinstance(item, str) and item else None +def _card_merchant(value: WireObject | None) -> WireObject: + merchant = object_value(value) + name, url, country = (string(merchant.get(key)) for key in ("name", "url", "countryCode")) + if not name.strip() or len(name.encode("utf-16-le")) // 2 > 200: + raise ValueError("CARD merchant name must contain 1 to 200 characters") + try: + parsed = httpx.URL(url) + except httpx.InvalidURL as error: + raise ValueError("Invalid CARD merchant url") from error + if ( + len(url.encode("utf-16-le")) // 2 > 2048 + or parsed.scheme not in ("http", "https") + or not parsed.host + ): + raise ValueError("CARD merchant url must be an absolute HTTP or HTTPS URL") + if not re.fullmatch(r"[A-Za-z]{2}", country): + raise ValueError("CARD merchant countryCode must contain two letters") + return {"name": name, "url": url, "countryCode": country} + + class BuyerMethod: def __init__( self, @@ -112,6 +140,7 @@ def __init__( intent: str = "charge", instrument_id: str | None = None, subscription_id: str | None = None, + merchant: WireObject | None = None, poll_interval: float = 5, pending_timeout: float = 900, ) -> None: @@ -119,13 +148,28 @@ def __init__( ("inflow", "charge"), ("inflow", "subscription"), ("tempo", "charge"), + ("card", "charge"), ): raise ValueError("Unsupported MPP Buyer method or intent") for value in (instrument_id, subscription_id): if value is not None: UUID(value) - if instrument_id is not None and (method, intent) != ("inflow", "charge"): - raise ValueError("instrument_id applies only to InFlow charge") + if instrument_id is not None and (method, intent) not in ( + ("inflow", "charge"), + ("card", "charge"), + ): + raise ValueError("instrument_id applies only to InFlow or CARD charge") + if ( + method == "card" + and instrument_id is not None + and not re.fullmatch( + r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", instrument_id + ) + ): + raise ValueError("CARD instrument_id must be a hyphenated UUID") + if merchant is not None and method != "card": + raise ValueError("merchant applies only to CARD charge") + self._merchant = _card_merchant(merchant) if method == "card" else None if subscription_id is not None and intent != "subscription": raise ValueError("subscription_id applies only to InFlow subscription") if any(not math.isfinite(value) or value < 0 for value in (poll_interval, pending_timeout)): @@ -166,6 +210,13 @@ async def cleanup(self) -> None: task.cancel() await asyncio.gather(*tasks, return_exceptions=True) + async def get_payment_status( + self, transaction_id: str, *, retries: int = 0 + ) -> dict[str, object]: + if self._closed: + raise RuntimeError("MPP Buyer method is closed") + return await self._client.get_payment_status(transaction_id, retries=retries) + async def cancel_approval(self, approval_id: str) -> None: await self._client.cancel_approval(approval_id) @@ -195,7 +246,11 @@ async def _create(self, challenge: WireObject) -> Credential: if "problem" in response: raise MppPaymentFailedError(object_value(response["problem"])) return _credential(response, challenge) - options = {} if self._instrument_id is None else {"instrumentId": self._instrument_id} + options: WireObject = ( + {} if self._instrument_id is None else {"instrumentId": self._instrument_id} + ) + if self._merchant is not None: + options["merchant"] = deepcopy(self._merchant) response = _response( await self._client.request( "POST", @@ -225,7 +280,8 @@ async def _resolve(self, response: WireObject, challenge: WireObject) -> Credent if state == "failed": problem = response.get("problem") raise MppPaymentFailedError( - None if problem is None else object_value(problem) + None if problem is None else object_value(problem), + _identifier(response, "transactionId"), ) if state == "expired": raise MppPaymentExpiredError(_identifier(response, "transactionId")) diff --git a/src/inflowpay/mpp/seller.py b/src/inflowpay/mpp/seller.py index 6eee524..16a9972 100644 --- a/src/inflowpay/mpp/seller.py +++ b/src/inflowpay/mpp/seller.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from copy import deepcopy from dataclasses import dataclass, field from types import TracebackType @@ -7,17 +8,18 @@ from uuid import uuid4 from mpp import Credential, Receipt -from mpp.errors import PaymentError +from mpp.errors import InvalidChallengeError, PaymentError # pympp exposes Validation here but omits an explicit typed re-export in 0.11.0. from mpp.server import Validation # type: ignore[attr-defined] from .._runtime import Client from ..options import ClientOptions -from ._requests import validate_request +from ._requests import validate_payload, validate_request from ._wire import ( MppCodecError, WireObject, + decode, decode_credential, decode_receipt, encode, @@ -91,6 +93,84 @@ def _wire_credential(credential: Credential) -> WireObject: return wire +def _stripe_profile(config: WireObject) -> WireObject: + methods = config["supportedMethods"] + assert isinstance(methods, list) + method = next( + (item for item in methods if isinstance(item, dict) and item.get("id") == "stripe"), {} + ) + details = method.get("methodDetails") + currencies, intents = method.get("supportedCurrencies"), method.get("supportedIntents") + if not isinstance(details, dict): + raise MppSellerConfigurationError("Stripe method details are missing") + network, payment_types = details.get("networkId"), details.get("paymentMethodTypes") + if ( + not isinstance(currencies, list) + or "USD" not in currencies + or not isinstance(intents, list) + or "charge" not in intents + or not isinstance(network, str) + or not network.strip() + or not isinstance(payment_types, list) + or not payment_types + or any(not isinstance(item, str) or not item.strip() for item in payment_types) + ): + raise MppSellerConfigurationError("Seller configuration does not enable Stripe charge") + return deepcopy({"networkId": network, "paymentMethodTypes": payment_types}) + + +def _text_length(value: str) -> int: + return len(value.encode("utf-16-le")) // 2 + + +def _card_profile(config: WireObject) -> WireObject: + methods = config["supportedMethods"] + assert isinstance(methods, list) + method = next( + (item for item in methods if isinstance(item, dict) and item.get("id") == "card"), {} + ) + currencies, intents = method.get("supportedCurrencies"), method.get("supportedIntents") + try: + if ( + not isinstance(currencies, list) + or "USD" not in currencies + or not isinstance(intents, list) + or "charge" not in intents + ): + raise MppCodecError("CARD charge is unavailable") + details = object_value(method.get("methodDetails")) + return validate_request( + "card", + "charge", + { + "amount": "100", + "currency": "usd", + "recipient": details.get("recipient"), + "methodDetails": details, + }, + ) + except MppCodecError as error: + raise MppSellerConfigurationError( + "Seller configuration does not enable CARD charge" + ) from error + + +def _dollar_cents(amount: object, method: str) -> str: + if not isinstance(amount, str) or not re.fullmatch( + r"(?:0|[1-9][0-9]*)(?:\.[0-9]{1,2})?", amount + ): + raise MppCodecError( + f"{method} amount must be a decimal USD string with at most two fractional digits" + ) + whole, _, fraction = amount.partition(".") + if ( + len(whole) > 6 + or not 50 <= (cents := int(whole) * 100 + int(fraction.ljust(2, "0"))) <= 99999999 + ): + raise MppCodecError(f"{method} amount must be between USD 0.50 and 999999.99") + return str(cents) + + class Seller: name = "charge" @@ -101,8 +181,10 @@ def __init__(self, client: Client, config: WireObject, method: str) -> None: @classmethod async def create(cls, options: ClientOptions, *, method: str = "inflow") -> Self: - if method not in ("inflow", "tempo"): - raise MppSellerConfigurationError("Seller supports inflow and tempo charges") + if method not in ("inflow", "tempo", "stripe", "card"): + raise MppSellerConfigurationError( + "Seller supports inflow, tempo, stripe and card charges" + ) if options.api_key is None: raise MppSellerConfigurationError("Seller setup requires an InFlow Seller API key") client = Client(options) @@ -112,6 +194,10 @@ async def create(cls, options: ClientOptions, *, method: str = "inflow") -> Self object_value(config.get("featureFlags")) if not isinstance(config.get("supportedMethods"), list): raise MppCodecError("Expected supportedMethods array") + if method == "stripe": + _stripe_profile(config) + if method == "card": + _card_profile(config) return cls(client, config, method) except BaseException: await client.aclose() @@ -132,6 +218,10 @@ async def aclose(self) -> None: await self._client.aclose() def charge_request(self, request: WireObject) -> WireObject: + if self.method in ("stripe", "card"): + raise MppSellerConfigurationError( + f"Use {self.method}_request with a decimal USD amount" + ) result = deepcopy(request) # Use standalone pympp.pay: its high-level routes convert all prices to token units. if "decimals" in result: @@ -150,6 +240,61 @@ def charge_request(self, request: WireObject) -> WireObject: } return validate_request(self.method, "charge", result) + def stripe_request(self, request: WireObject) -> WireObject: + if self.method != "stripe": + raise MppSellerConfigurationError("stripe_request requires a Stripe Seller") + cents = _dollar_cents(request.get("amount"), "Stripe") + details = _stripe_profile(self._config) + if "metadata" in request: + metadata = object_value(request["metadata"]) + if len(metadata) > 45: + raise MppCodecError("Stripe metadata allows at most 45 entries") + reserved = { + "externalId", + "inflowMppTransactionId", + "mppChallengeId", + "mppIntent", + "mppMethod", + "stripeNetworkProfile", + } + for key, value in metadata.items(): + if ( + not key.strip() + or _text_length(key) > 40 + or "[" in key + or "]" in key + or key in reserved + ): + raise MppCodecError("Stripe metadata key is invalid or reserved") + if not isinstance(value, str) or _text_length(value) > 500: + raise MppCodecError( + "Stripe metadata values must be strings of at most 500 characters" + ) + details["metadata"] = deepcopy(metadata) + result: WireObject = {"amount": str(cents), "currency": "usd", "methodDetails": details} + for key in ("externalId", "description", "recipient"): + if key in request: + value = request[key] + if not isinstance(value, str) or ( + key == "externalId" and _text_length(value) > 255 + ): + raise MppCodecError(f"Invalid Stripe {key}") + result[key] = value + return result + + def card_request(self, request: WireObject) -> WireObject: + if self.method != "card": + raise MppSellerConfigurationError("card_request requires a CARD Seller") + result = _card_profile(self._config) + result["amount"] = _dollar_cents(request.get("amount"), "CARD") + details = object_value(result["methodDetails"]) + if "billingRequired" in request: + details["billingRequired"] = request["billingRequired"] + for name in ("externalId", "description"): + if name in request: + result[name] = request[name] + return validate_request("card", "charge", result) + def _rail(self, currency: str, details: WireObject) -> WireObject: methods = self._config["supportedMethods"] assert isinstance(methods, list) @@ -194,6 +339,28 @@ def _rail(self, currency: str, details: WireObject) -> WireObject: } async def validate(self, credential: Credential, request: WireObject) -> Validation: + if self.method == "card": + try: + validate_payload("card", credential.payload) + except MppCodecError as error: + raise InvalidChallengeError(credential.challenge.id, str(error)) from error + if self.method == "stripe": + if not isinstance(credential.payload.get("spt"), str) or ( + "externalId" in credential.payload + and not isinstance(credential.payload["externalId"], str) + ): + raise InvalidChallengeError( + credential.challenge.id, "Invalid Stripe credential payload" + ) + terms = object_value(decode(credential.challenge.request)) + if ( + "externalId" in terms + and credential.payload.get("externalId") != terms["externalId"] + ): + raise InvalidChallengeError( + credential.challenge.id, + "credential externalId does not match the challenge reference", + ) wire = _wire_credential(credential) result = await self._client.request( "POST", "/v1/mpp/validate", body={"credential": wire}, retries=3 @@ -224,6 +391,7 @@ async def validate(self, credential: Credential, request: WireObject) -> Validat ) async def broadcast(self, credential: Credential, request: WireObject) -> Receipt: + details = object_value(decode(credential.challenge.request)).get("methodDetails") flags = object_value(self._config["featureFlags"]) headers = ( {"Idempotency-Key": str(uuid4())} if flags.get("idempotencyKeyEnabled") is True else {} @@ -240,6 +408,19 @@ async def broadcast(self, credential: Credential, request: WireObject) -> Receip if "receipt" not in response: raise MppCredentialProblemError(response.get("problem")) wire = decode_receipt(encode(response["receipt"])) + # Card settlement must identify the purchase before releasing its resource. + if ( + credential.challenge.method in ("stripe", "card") + or ( + credential.challenge.method == "inflow" + and isinstance(details, dict) + and details.get("rail") == "instrument" + ) + ) and ( + wire.get("method") != credential.challenge.method + or wire.get("challengeId") != credential.challenge.id + ): + raise MppCredentialProblemError() receipt = Receipt.from_payment_receipt(encode(wire)) except (MppCodecError, ValueError) as error: raise MppCredentialProblemError() from error diff --git a/src/inflowpay/x402/_seller.py b/src/inflowpay/x402/_seller.py index a541d8d..b168f3c 100644 --- a/src/inflowpay/x402/_seller.py +++ b/src/inflowpay/x402/_seller.py @@ -156,7 +156,14 @@ def include(scheme: str, network: str) -> bool: ) for method_info in config.payment_methods: if include(method_info.scheme, method_info.network): - for item in currencies: + instrument = method_info.scheme == "instrument" + if instrument: + if schemes is None or "instrument" not in schemes or selected != "USD": + continue + cents = int(_atomic(integer, fraction, 2)) + if not 50 <= cents <= 9223372036854775807: + raise ValueError("Instrument payments require USD 0.50-92233720368547758.07") + for item in ["USD"] if instrument else currencies: result.append( PaymentOption( scheme=method_info.scheme, diff --git a/src/inflowpay/x402/buyer.py b/src/inflowpay/x402/buyer.py index 67e22fb..ae3452a 100644 --- a/src/inflowpay/x402/buyer.py +++ b/src/inflowpay/x402/buyer.py @@ -60,6 +60,7 @@ def __init__( prefer: Sequence[str], poll_interval: float, pending_timeout: float, + instrument_id: str | None = None, ) -> None: super().__init__() self._client = client @@ -69,6 +70,7 @@ def __init__( self._prefer = tuple(prefer) self._poll_interval = poll_interval self._pending_timeout = pending_timeout + self._instrument_id = instrument_id self._payments: WeakSet[PreparedPayment] = WeakSet() self._closed = False @@ -80,6 +82,7 @@ async def create( prefer: Sequence[str] = ("balance", "exact"), poll_interval: float = 5, pending_timeout: float = 900, + instrument_id: str | None = None, ) -> Self: validate_wait(poll_interval, pending_timeout) client = Client(options) @@ -93,6 +96,7 @@ async def create( prefer=prefer, poll_interval=poll_interval, pending_timeout=pending_timeout, + instrument_id=instrument_id, ) except BaseException: await client.aclose() @@ -306,6 +310,8 @@ async def _prepare( ) if payment_id is not None: body["remotePaymentId"] = payment_id + if context.selected_requirements.scheme == "instrument" and self._instrument_id is not None: + body["instrumentId"] = self._instrument_id # Creation is not retried: without a remotePaymentId it creates a second approval. created = response_object( await self._client.request("POST", "/v1/transactions/x402", body=body) @@ -324,6 +330,12 @@ async def after(payload: PaymentPayload) -> None: self._payments.add(prepared) return prepared + async def get_payment_status( + self, transaction_id: str, *, retries: int = 0 + ) -> dict[str, object]: + self._check_open() + return await self._client.get_payment_status(transaction_id, retries=retries) + async def get_x402_payload(self, transaction_id: str) -> dict[str, object]: self._check_open() return response_object( diff --git a/tests/test_card_buyer.py b/tests/test_card_buyer.py new file mode 100644 index 0000000..3a83682 --- /dev/null +++ b/tests/test_card_buyer.py @@ -0,0 +1,321 @@ +import asyncio +import json +from copy import deepcopy + +import httpx +import pytest + +from inflowpay import ClientOptions, InflowApiError +from inflowpay.mpp import ( + WireObject, + decode_credential, + encode, + render_challenge_header, + to_pympp_challenge, +) +from inflowpay.mpp._wire import JsonValue +from inflowpay.mpp.buyer import ( + BuyerMethod, + MppMalformedCredentialError, + MppPaymentExpiredError, + MppPaymentFailedError, + payment_transport, +) +from test_card_seller import PAYLOAD, TERMS +from test_mpp_buyer import ID, PENDING, Exchange, server + +MERCHANT: WireObject = {"name": "Test Seller", "url": "https://seller.example", "countryCode": "US"} +CHALLENGE: WireObject = { + "id": "card-test", + "realm": "seller.example", + "method": "card", + "intent": "charge", + "request": encode(TERMS), + "description": "Test purchase", + "expires": "2099-01-01T00:00:00Z", + "opaque": encode({"item": "report"}), + "digest": "sha-256=test-only", +} +CREDENTIAL: WireObject = {"challenge": CHALLENGE, "payload": PAYLOAD, "source": "did:example:buyer"} + + +def ready(credential: WireObject = CREDENTIAL) -> WireObject: + return {"state": "ready", "credential": encode(credential)} + + +@pytest.mark.parametrize( + "field,value", + [ + ("name", " "), + ("name", "x" * 201), + ("name", None), + ("url", "/relative"), + ("url", "ftp://seller.example"), + ("url", "https://"), + ("url", "https://seller.example/" + "x" * 2048), + ("url", "https://host:bad"), + ("countryCode", "USA"), + ("countryCode", "12"), + ], +) +def test_invalid_merchant(field: str, value: JsonValue) -> None: + with pytest.raises(ValueError): + BuyerMethod(ClientOptions(), method="card", merchant={**MERCHANT, field: value}) + + +def test_required_and_misplaced_merchant() -> None: + with pytest.raises(ValueError): + BuyerMethod(ClientOptions(), method="card") + with pytest.raises(ValueError, match="merchant applies"): + BuyerMethod(ClientOptions(), merchant=MERCHANT) + with pytest.raises(ValueError, match="hyphenated UUID"): + BuyerMethod( + ClientOptions(), method="card", merchant=MERCHANT, instrument_id=ID.replace("-", "") + ) + + +@pytest.mark.parametrize("selected", [False, True]) +async def test_options_snapshot_and_credential(selected: bool) -> None: + merchant = deepcopy(MERCHANT) + calls: list[WireObject] = [] + + def handle(request: httpx.Request) -> httpx.Response: + calls.append(json.loads(request.content)) + return httpx.Response(200, json=ready()) + + async with BuyerMethod( + ClientOptions(transport=httpx.MockTransport(handle)), + method="card", + merchant=merchant, + instrument_id=ID if selected else None, + ) as buyer: + merchant["name"] = "Changed by caller" + credential = await buyer.create_credential(to_pympp_challenge(CHALLENGE)) + assert calls == [ + { + "challenge": CHALLENGE, + "options": {"merchant": MERCHANT, **({"instrumentId": ID} if selected else {})}, + } + ] + assert decode_credential(credential.to_authorization()[8:]) == CREDENTIAL + + +@pytest.mark.parametrize( + "field,value", + [ + ("id", "other"), + ("realm", "other"), + ("method", "inflow"), + ("intent", "subscription"), + ("description", "Other purchase"), + ("opaque", encode({"item": "other"})), + ("expires", "2098-01-01T00:00:00Z"), + ("digest", "sha-256=other"), + ("request", encode({**TERMS, "amount": "200"})), + ], +) +async def test_rejects_changed_challenge(field: str, value: JsonValue) -> None: + response = ready({**CREDENTIAL, "challenge": {**CHALLENGE, field: value}}) + async with BuyerMethod( + ClientOptions(transport=httpx.MockTransport(lambda _: httpx.Response(200, json=response))), + method="card", + merchant=MERCHANT, + ) as buyer: + with pytest.raises(MppMalformedCredentialError): + await buyer.create_credential(to_pympp_challenge(CHALLENGE)) + + +@pytest.mark.parametrize( + "response", + [ + {"state": "ready"}, + ready({**CREDENTIAL, "payload": {**PAYLOAD, "encryptedPayload": ""}}), + ready({**CREDENTIAL, "payload": {**PAYLOAD, "network": "mastercard"}}), + ready( + { + **CREDENTIAL, + "challenge": { + key: value for key, value in CHALLENGE.items() if key != "description" + }, + } + ), + ], +) +async def test_invalid_ready_cancels_approval(response: WireObject) -> None: + paths: list[str] = [] + + def handle(request: httpx.Request) -> httpx.Response: + paths.append(request.url.path) + if request.url.path.endswith("/cancel"): + return httpx.Response(503) + return httpx.Response(200, json={**response, "approvalId": "approval"}) + + async with BuyerMethod( + ClientOptions(transport=httpx.MockTransport(handle)), + method="card", + merchant=MERCHANT, + ) as buyer: + with pytest.raises(MppMalformedCredentialError): + await buyer.create_credential(to_pympp_challenge(CHALLENGE)) + assert paths == ["/v1/transactions/mpp", "/v1/approvals/approval/cancel"] + + +@pytest.mark.parametrize("auth", ["api-key", "bearer"]) +@pytest.mark.parametrize( + "outcome", ["ready", "rejected", "failed", "expired", "mismatch", "unsupported"] +) +async def test_real_http_transport(auth: str, outcome: str) -> None: + async def token() -> str: + return "test-token" + + advertised = deepcopy(CHALLENGE) + if outcome == "unsupported": + advertised["request"] = encode({**TERMS, "currency": "eur"}) + credential = deepcopy(CREDENTIAL) + if outcome == "mismatch": + credential["challenge"] = {**CHALLENGE, "description": "Changed"} + headers = ( + {"x-api-key": "test-key", "authorization": ""} + if auth == "api-key" + else {"authorization": "Bearer test-token", "x-api-key": ""} + ) + final = ( + {"state": outcome, "transactionId": "transaction"} + if outcome in ("failed", "expired") + else ready(credential) + ) + platform: list[Exchange] = ( + [] + if outcome == "unsupported" + else [ + { + "request": { + "method": "POST", + "path": "/v1/transactions/mpp", + "headers": headers, + "json": {"challenge": CHALLENGE, "options": {"merchant": MERCHANT}}, + }, + "response": {"status": 200, "json": PENDING}, + }, + { + "request": { + "method": "GET", + "path": "/v1/transactions/transaction/mpp", + "headers": headers, + }, + "response": {"status": 200, "json": final}, + }, + ] + ) + if outcome in ("failed", "expired", "mismatch"): + platform.append( + { + "request": { + "method": "POST", + "path": "/v1/approvals/approval/cancel", + "headers": headers, + }, + "response": {"status": 204}, + } + ) + seller: list[Exchange] = [ + { + "request": { + "method": "GET", + "path": "/paid", + "headers": {"authorization": "", "x-api-key": ""}, + }, + "response": { + "status": 402, + "headers": {"www-authenticate": render_challenge_header(advertised)}, + }, + }, + ] + if outcome in ("ready", "rejected"): + seller.append( + { + "request": { + "method": "GET", + "path": "/paid", + "headers": {"authorization": "Payment " + encode(CREDENTIAL), "x-api-key": ""}, + }, + "response": { + "status": 200 if outcome == "ready" else 402, + "headers": {"www-authenticate": render_challenge_header(advertised)}, + }, + } + ) + async with ( + server(platform) as base, + server(seller) as origin, + BuyerMethod( + ClientOptions( + base_url=base, + api_key="test-key" if auth == "api-key" else None, + access_token=token if auth == "bearer" else None, + ), + method="card", + merchant=MERCHANT, + ) as buyer, + httpx.AsyncClient(transport=payment_transport([buyer])) as http, + ): + if outcome in ("ready", "rejected"): + assert (await http.get(origin + "/paid")).status_code == ( + 200 if outcome == "ready" else 402 + ) + else: + expected = { + "failed": MppPaymentFailedError, + "expired": MppPaymentExpiredError, + "mismatch": MppMalformedCredentialError, + "unsupported": ValueError, + }[outcome] + with pytest.raises(expected): + await http.get(origin + "/paid") + + +async def test_cancel_and_unknown_creation_do_not_repay() -> None: + entered = asyncio.Event() + paths: list[str] = [] + + async def handle(request: httpx.Request) -> httpx.Response: + paths.append(request.url.path) + if request.url.path.endswith("/cancel"): + return httpx.Response(204) + if request.method == "POST": + return httpx.Response(200, json=PENDING) + entered.set() + await asyncio.Event().wait() + raise AssertionError("cancelled poll resumed") + + async with BuyerMethod( + ClientOptions(transport=httpx.MockTransport(handle)), + method="card", + merchant=MERCHANT, + ) as buyer: + task = asyncio.create_task(buyer.create_credential(to_pympp_challenge(CHALLENGE))) + await asyncio.wait_for(entered.wait(), 2) + await buyer.cleanup() + with pytest.raises(asyncio.CancelledError): + await task + assert paths == [ + "/v1/transactions/mpp", + "/v1/transactions/transaction/mpp", + "/v1/approvals/approval/cancel", + ] + + attempts = 0 + + def uncertain(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + raise httpx.ReadError("uncertain", request=request) + + async with BuyerMethod( + ClientOptions(transport=httpx.MockTransport(uncertain)), + method="card", + merchant=MERCHANT, + ) as buyer: + with pytest.raises(InflowApiError): + await buyer.create_credential(to_pympp_challenge(CHALLENGE)) + assert attempts == 1 diff --git a/tests/test_card_seller.py b/tests/test_card_seller.py new file mode 100644 index 0000000..4fff67d --- /dev/null +++ b/tests/test_card_seller.py @@ -0,0 +1,301 @@ +import json +from copy import deepcopy +from dataclasses import replace + +import httpx +import pytest +from fastapi import FastAPI, Request +from mpp import Challenge, Credential, Receipt +from mpp.errors import InvalidChallengeError +from mpp.server.decorator import pay +from starlette.responses import JSONResponse + +from inflowpay.mpp import MppCodecError, WireObject, decode +from inflowpay.mpp._requests import validate_payload, validate_request +from inflowpay.mpp.seller import MppSellerConfigurationError, Seller +from test_mpp_seller import CONFIG, PROBLEM, RECEIPT, Platform, options + +KEY: WireObject = { + "kty": "RSA", + "alg": "RSA-OAEP-256", + "use": "enc", + "kid": "test-key", + "n": "test_modulus", + "e": "AQAB", +} +DETAILS: WireObject = { + "merchantName": "Test Seller", + "acceptedNetworks": ["visa"], + "encryptionJwk": KEY, +} +TERMS: WireObject = { + "amount": "125", + "currency": "usd", + "recipient": "test-recipient", + "methodDetails": DETAILS, +} +PAYLOAD: WireObject = { + "encryptedPayload": "opaque-test-only", + "network": "visa", + "panLastFour": "1234", + "panExpirationMonth": "12", + "panExpirationYear": "2030", + "billingAddress": {"line1": "", "extension": True}, + "cardholderFullName": "Tester", + "paymentAccountReference": "reference", + "extension": {"kept": True}, +} + + +def platform() -> Platform: + result = Platform() + config: WireObject = { + **deepcopy(CONFIG), + "supportedMethods": [ + { + "id": "card", + "supportedCurrencies": ["USD"], + "supportedIntents": ["charge"], + "methodDetails": {**deepcopy(DETAILS), "recipient": "test-recipient"}, + } + ], + } + result.config = config + return result + + +@pytest.mark.parametrize("billing", [None, False, True]) +async def test_offer_authority(billing: bool | None) -> None: + transport = platform() + async with await Seller.create(options(transport), method="card") as seller: + offer: WireObject = { + "amount": "1.25", + "currency": "eur", + "recipient": "other", + "methodDetails": {}, + "externalId": "", + "description": "Report", + } + if billing is not None: + offer["billingRequired"] = billing + before = deepcopy(offer) + result = seller.card_request(offer) + details = deepcopy(DETAILS) + if billing is not None: + details["billingRequired"] = billing + assert result == { + **TERMS, + "methodDetails": details, + "externalId": "", + "description": "Report", + } + assert offer == before + result["methodDetails"] = {} + assert seller.card_request({"amount": "1.25"}) == TERMS + with pytest.raises(MppSellerConfigurationError, match="card_request"): + seller.charge_request({"amount": "1.25"}) + + +async def test_wrong_seller() -> None: + async with await Seller.create(options(Platform())) as seller: + with pytest.raises(MppSellerConfigurationError): + seller.card_request({"amount": "1"}) + + +@pytest.mark.parametrize( + "field,value", + [ + ("supportedCurrencies", None), + ("supportedCurrencies", []), + ("supportedIntents", None), + ("supportedIntents", []), + ("methodDetails", None), + ("id", "other"), + ], +) +async def test_missing_capability(field: str, value: object) -> None: + transport = platform() + config = json.loads(json.dumps(transport.config)) + config["supportedMethods"][0][field] = value + transport.config = config + with pytest.raises(MppSellerConfigurationError): + await Seller.create(options(transport), method="card") + assert transport.closed + + +@pytest.mark.parametrize( + "path,value", + [ + (("amount",), "49"), + (("amount",), "1.25"), + (("currency",), "eur"), + (("recipient",), "x" * 256), + (("recipient",), ""), + (("externalId",), "x" * 256), + (("externalId",), False), + (("description",), 3), + (("methodDetails", "merchantName"), "x" * 256), + (("methodDetails", "acceptedNetworks"), None), + (("methodDetails", "acceptedNetworks"), []), + (("methodDetails", "acceptedNetworks"), ["other"]), + (("methodDetails", "billingRequired"), 1), + *[ + (("methodDetails", "encryptionJwk", field), value) + for field, value in [ + ("kty", "EC"), + ("alg", "RSA"), + ("use", "sig"), + ("kid", ""), + ("n", "bad!"), + ("e", "bad!"), + ] + ], + ], +) +def test_request_rejection(path: tuple[str, ...], value: object) -> None: + terms = json.loads(json.dumps(TERMS)) + target = terms + for part in path[:-1]: + target = target[part] + target[path[-1]] = value + with pytest.raises(MppCodecError): + validate_request("card", "charge", terms) + + +@pytest.mark.parametrize( + "field,value", + [ + ("encryptedPayload", ""), + ("encryptedPayload", "x" * 16385), + ("network", "other"), + ("panLastFour", "123"), + ("panExpirationMonth", "13"), + ("panExpirationYear", "30"), + ("cardholderFullName", False), + ("paymentAccountReference", 3), + ("billingAddress", {"city": 3}), + ("billingAddress", None), + ], +) +async def test_payload_rejection(field: str, value: object) -> None: + payload = json.loads(json.dumps(PAYLOAD)) + payload[field] = value + transport = platform() + async with await Seller.create(options(transport), method="card") as seller: + challenge = Challenge.create( + secret_key="test", realm="seller", method="card", intent="charge", request=TERMS + ) + with pytest.raises(InvalidChallengeError): + await seller.validate(Credential(challenge=challenge.to_echo(), payload=payload), TERMS) + assert len(transport.requests) == 1 + + +def test_payload_optional_fields() -> None: + payload = { + key: value + for key, value in PAYLOAD.items() + if key not in ("billingAddress", "cardholderFullName", "paymentAccountReference") + } + assert validate_payload("card", payload) == payload + + +@pytest.mark.parametrize( + "outcome", + [ + "success", + "validation", + "pending", + "method", + "challenge", + "missing", + "signature", + "expiry", + "amount", + "billing", + "reference", + ], +) +async def test_route(outcome: str) -> None: + transport = platform() + async with await Seller.create(options(transport), method="card") as seller: + terms = seller.card_request({"amount": "1.25", "description": "Report"}) + app = FastAPI() + delivered = [] + + @app.get("/paid") + @pay( + intent=seller, + method="card", + request=lambda _: terms, + description="Report", + realm="seller", + secret_key="test-secret", + ) + async def paid(request: Request, credential: Credential, receipt: Receipt) -> JSONResponse: + delivered.append(True) + return JSONResponse( + {"ok": True}, headers={"Payment-Receipt": receipt.to_payment_receipt()} + ) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app), base_url="https://seller" + ) as client: + initial = await client.get("/paid") + challenge = Challenge.from_www_authenticate(initial.headers["www-authenticate"]) + assert challenge.description == "Report" + if outcome == "expiry": + challenge = Challenge.create( + secret_key="test-secret", + realm="seller", + method="card", + intent="charge", + request=json.loads(json.dumps(decode(challenge.request_b64))), + description="Report", + expires="2020-01-01T00:00:00Z", + ) + credential = Credential(challenge=challenge.to_echo(), payload=deepcopy(PAYLOAD)) + receipt = {**RECEIPT, "method": "card", "challengeId": challenge.id} + if outcome == "validation": + transport.validation = {"success": False, "problem": PROBLEM} + if outcome == "pending": + transport.result = {"problem": PROBLEM} + else: + if outcome == "method": + receipt["method"] = "stripe" + if outcome == "challenge": + receipt["challengeId"] = "other" + if outcome == "missing": + del receipt["challengeId"] + transport.result = {"receipt": receipt} + if outcome == "signature": + credential = replace( + credential, challenge=replace(credential.challenge, id="tampered") + ) + if outcome in ("amount", "billing", "reference"): + terms = seller.card_request( + { + "amount": "2" if outcome == "amount" else "1.25", + "billingRequired": outcome == "billing", + "externalId": "other" if outcome == "reference" else "", + } + ) + response = await client.get( + "/paid", headers={"Authorization": credential.to_authorization()} + ) + assert response.status_code == (200 if outcome == "success" else 402) + assert delivered == ([True] if outcome == "success" else []) + if outcome == "success": + assert decode(response.headers["payment-receipt"]) == receipt + wire = json.loads(transport.requests[1].content)["credential"] + assert wire["payload"] == PAYLOAD + assert wire["source"] == "" + else: + assert "payment-receipt" not in response.headers + expected = ( + 1 + if outcome in ("signature", "expiry", "amount", "billing", "reference") + else 2 + if outcome == "validation" + else 3 + ) + assert len(transport.requests) == expected diff --git a/tests/test_examples.py b/tests/test_examples.py index 8daa180..186bfd9 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -15,8 +15,13 @@ from inflowpay import ClientOptions from inflowpay.mpp import encode +from test_card_buyer import MERCHANT +from test_card_seller import PAYLOAD as CARD_PAYLOAD +from test_card_seller import platform as card_platform +from test_instrument_parity import card_config from test_mpp_seller import ID, PROBLEM from test_mpp_seller import Platform as MppPlatform +from test_stripe_seller import platform as stripe_platform from test_x402_seller import Platform as X402Platform MODULES = (mpp_buyer, mpp_seller, x402_buyer, x402_seller) @@ -24,7 +29,18 @@ @pytest.fixture(autouse=True) def example_environment(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: - for name in ("INFLOW_API_KEY", "MPP_SECRET_KEY", "TARGET_URL", "INFLOW_BASE_URL"): + for name in ( + "INFLOW_API_KEY", + "MPP_SECRET_KEY", + "TARGET_URL", + "INFLOW_BASE_URL", + "MPP_METHOD", + "X402_SCHEME", + "INFLOW_INSTRUMENT_ID", + "CARD_MERCHANT_NAME", + "CARD_MERCHANT_URL", + "CARD_MERCHANT_COUNTRY", + ): monkeypatch.delenv(name, raising=False) yield @@ -56,7 +72,7 @@ async def run() -> None: "protocol,outcome", [ (protocol, outcome) - for protocol in ("mpp", "x402") + for protocol in ("mpp", "x402", "card", "instrument") for outcome in ("paid", "free", "rejected", "missing", "malformed") ] + [("x402", "settle-failed"), ("x402", "legacy-receipt")], @@ -64,7 +80,23 @@ async def run() -> None: async def test_example_pair( protocol: str, outcome: str, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: + mode = protocol + protocol = "mpp" if mode == "card" else "x402" if mode == "instrument" else protocol platform = MppPlatform() if protocol == "mpp" else X402Platform() + if mode == "card": + platform = card_platform() + monkeypatch.setenv("MPP_METHOD", "card") + monkeypatch.setenv("CARD_MERCHANT_NAME", "Test Seller") + monkeypatch.setenv("CARD_MERCHANT_URL", "https://seller.example") + monkeypatch.setenv("CARD_MERCHANT_COUNTRY", "US") + if mode == "instrument": + assert isinstance(platform, X402Platform) + platform.config = card_config().model_dump(by_alias=True) + platform.supported = { + "kinds": [{"scheme": "instrument", "network": "inflow:1", "x402Version": 2}] + } + monkeypatch.setenv("X402_SCHEME", "instrument") + monkeypatch.setenv("INFLOW_INSTRUMENT_ID", ID) if outcome == "rejected": if isinstance(platform, MppPlatform): platform.validation = {"success": False, "problem": PROBLEM} @@ -91,7 +123,7 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response: if protocol == "mpp": credential = { "challenge": value["challenge"], - "payload": {"transactionId": ID}, + "payload": CARD_PAYLOAD if mode == "card" else {"transactionId": ID}, } return httpx.Response( 200, json={"state": "ready", "credential": encode(credential)} @@ -111,6 +143,18 @@ async def handle_async_request(self, request: httpx.Request) -> httpx.Response: "paymentPayload": payment, }, ) + if mode == "card" and path.endswith("/broadcast"): + assert isinstance(platform, MppPlatform) + challenge_id = json.loads(request.content)["credential"]["challenge"]["id"] + platform.result = { + "receipt": { + "method": "card", + "status": "success", + "challengeId": challenge_id, + "reference": "card-test", + "timestamp": "2026-10-08T00:00:00Z", + } + } return await platform.handle_async_request(request) assert "x-api-key" not in request.headers merchant_requests.append(request) @@ -159,9 +203,11 @@ def transport(*args: object, **kwargs: object) -> RoutingTransport: environment="sandbox", api_key="secret", base_url="https://platform.test" ) application = ( - mpp_seller.application(options, "test-secret") + mpp_seller.application( + options, "test-secret", method="card" if mode == "card" else "inflow" + ) if protocol == "mpp" - else x402_seller.application(options) + else x402_seller.application(options, instrument=mode == "instrument") ) async with application as app, httpx.ASGITransport(app) as app_transport: buyer = mpp_buyer if protocol == "mpp" else x402_buyer @@ -174,6 +220,11 @@ def transport(*args: object, **kwargs: object) -> RoutingTransport: else: await buyer.run() assert len(created) == (0 if outcome == "free" else 1) + if created and mode == "card": + assert created[0]["options"] == {"merchant": MERCHANT} + if created and mode == "instrument": + assert created[0]["instrumentId"] == ID + assert created[0]["accept"]["scheme"] == "instrument" assert len(merchant_requests) == (1 if outcome == "free" else 2) assert all(isinstance(value, RoutingTransport) and value.closed for value in owned) if outcome == "rejected": @@ -222,7 +273,7 @@ async def test_x402_seller_rejects_empty_offers(monkeypatch: pytest.MonkeyPatch) platform.config["paymentMethods"] = [] monkeypatch.setattr(httpx, "AsyncHTTPTransport", lambda **kwargs: platform) monkeypatch.setattr(httpx._client, "AsyncHTTPTransport", lambda **kwargs: platform) - with pytest.raises(ValueError, match="no USDC"): + with pytest.raises(ValueError, match="no matching"): async with x402_seller.application(ClientOptions(api_key="secret")): pytest.fail("empty offers must not start a server") assert platform.closed @@ -232,3 +283,52 @@ async def test_mpp_seller_requires_challenge_secret(monkeypatch: pytest.MonkeyPa monkeypatch.setenv("INFLOW_API_KEY", "secret") with pytest.raises(ValueError, match="MPP_SECRET_KEY"): await mpp_seller.run() + + +@pytest.mark.parametrize("module", MODULES) +async def test_invalid_example_method(module: Any, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("INFLOW_API_KEY", "test-key") + monkeypatch.setenv("MPP_SECRET_KEY", "test-secret") + monkeypatch.setenv("MPP_METHOD", "unsupported") + monkeypatch.setenv("X402_SCHEME", "unsupported") + with pytest.raises(ValueError, match="must be"): + await module.run() + + +@pytest.mark.parametrize("mode", ["stripe", "card", "instrument"]) +async def test_card_seller_startup(mode: str, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("INFLOW_API_KEY", "secret") + monkeypatch.setenv("MPP_SECRET_KEY", "test-secret") + if mode == "instrument": + platform: MppPlatform | X402Platform = X402Platform() + assert isinstance(platform, X402Platform) + platform.config = card_config().model_dump(by_alias=True) + platform.supported = { + "kinds": [{"scheme": "instrument", "network": "inflow:1", "x402Version": 2}] + } + monkeypatch.setenv("X402_SCHEME", mode) + else: + platform = stripe_platform() if mode == "stripe" else card_platform() + monkeypatch.setenv("MPP_METHOD", mode) + monkeypatch.setattr(httpx, "AsyncHTTPTransport", lambda **kwargs: platform) + monkeypatch.setattr(httpx._client, "AsyncHTTPTransport", lambda **kwargs: platform) + called = [] + + async def serve(server: uvicorn.Server, sockets: object = None) -> None: + called.append(True) + assert isinstance(server.config.app, FastAPI) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(server.config.app), base_url="http://localhost" + ) as client: + response = await client.get("/api/widgets") + assert response.status_code == 402 + if mode != "instrument": + from mpp import Challenge + + challenge = Challenge.from_www_authenticate(response.headers["www-authenticate"]) + assert challenge.method == mode + assert challenge.request["amount"] == "125" + + monkeypatch.setattr(uvicorn.Server, "serve", serve) + await (x402_seller if mode == "instrument" else mpp_seller).run() + assert called == [True] and platform.closed diff --git a/tests/test_instrument_parity.py b/tests/test_instrument_parity.py new file mode 100644 index 0000000..7056d47 --- /dev/null +++ b/tests/test_instrument_parity.py @@ -0,0 +1,203 @@ +import json +from copy import deepcopy + +import httpx +import pytest +from fastapi import FastAPI, Request +from mpp import Challenge, Credential, Receipt +from mpp.server.decorator import pay +from starlette.responses import JSONResponse +from x402.schemas import AssetAmount + +from inflowpay import ClientOptions, InflowApiError +from inflowpay.mpp import to_pympp_challenge +from inflowpay.mpp.buyer import BuyerMethod, MppPaymentFailedError +from inflowpay.mpp.seller import MppCredentialProblemError, Seller +from inflowpay.x402._seller import SellerConfig, build_offers +from inflowpay.x402.buyer import Buyer +from test_mpp_buyer import WIRE +from test_mpp_seller import RECEIPT, Platform, options +from test_x402_buyer import REQUIREMENT, RESOURCE +from test_x402_buyer import Platform as BuyerPlatform + + +@pytest.mark.parametrize("scheme", ["instrument", "balance", "exact"]) +@pytest.mark.parametrize("selected", [None, "selected-card"]) +@pytest.mark.parametrize("extension", [False, True]) +async def test_buyer_instrument_selection( + scheme: str, selected: str | None, extension: bool +) -> None: + platform = BuyerPlatform() + platform.supported = {"kinds": [{"scheme": scheme, "network": "inflow:1", "x402Version": 2}]} + requirement = REQUIREMENT.model_copy(update={"scheme": scheme}) + extra: dict[str, object] = {"custom": {"keep": True}} + if extension: + extra["instrumentId"] = "extension-card" + before = deepcopy(extra) + async with await Buyer.create( + ClientOptions(api_key="test-only", transport=platform), instrument_id=selected + ) as buyer: + await buyer.prepare(requirement, RESOURCE, transaction_request_extensions=extra) + body = json.loads(platform.requests[1].content) + assert body.get("instrumentId") == ( + selected if scheme == "instrument" and selected else "extension-card" if extension else None + ) + assert body["accept"]["scheme"] == scheme + assert extra == before + + +async def test_rejected_instrument_does_not_fallback() -> None: + creates = 0 + + def handle(request: httpx.Request) -> httpx.Response: + nonlocal creates + if request.method == "GET": + return httpx.Response( + 200, + json={"kinds": [{"scheme": "instrument", "network": "inflow:1", "x402Version": 2}]}, + ) + creates += 1 + assert json.loads(request.content)["instrumentId"] == "selected-card" + return httpx.Response( + 400, json={"errors": [{"code": "PARAMETER_INVALID", "message": "Card unavailable"}]} + ) + + async with await Buyer.create( + ClientOptions(transport=httpx.MockTransport(handle)), instrument_id="selected-card" + ) as buyer: + with pytest.raises(InflowApiError): + await buyer.prepare(REQUIREMENT.model_copy(update={"scheme": "instrument"}), RESOURCE) + assert creates == 1 + + +def card_config() -> SellerConfig: + return SellerConfig.model_validate( + { + "sellerId": "seller", + "assets": [], + "wallets": [], + "supported": [], + "paymentMethods": [ + { + "scheme": "instrument", + "network": "inflow:1", + "payTo": "seller", + "decimals": 18, + "extra": {"keep": True}, + } + ], + } + ) + + +@pytest.mark.parametrize( + "price,cents", [("$0.50", 50), ("$1.2500", 125), ("$92233720368547758.07", 9223372036854775807)] +) +def test_instrument_offers(price: str, cents: int) -> None: + config = card_config() + before = config.model_dump() + assert build_offers(config, price) == [] + assert build_offers(config, price, schemes=["instrument"], networks=["other"]) == [] + assert build_offers(config, "1 USDC", schemes=["instrument"]) == [] + offer = build_offers(config, price, schemes=["instrument"])[0] + assert isinstance(offer.price, AssetAmount) + assert offer.price.amount == str(cents * 10**16) + assert offer.price.asset == "USD" + assert offer.extra == {"keep": True, "assetName": "USD"} + assert config.model_dump() == before + + +@pytest.mark.parametrize("price", ["$0.49", "$0", "$1.001", "$92233720368547758.08"]) +def test_instrument_invalid_amount(price: str) -> None: + with pytest.raises(ValueError): + build_offers(card_config(), price, schemes=["instrument"]) + + +@pytest.mark.parametrize("mode", ["success", "missing", "id", "method"]) +async def test_instrument_receipt_binding(mode: str) -> None: + platform = Platform() + challenge = Challenge.create( + secret_key="test-only", + realm="seller.example", + method="inflow", + intent="charge", + request={"amount": "1", "currency": "USD", "methodDetails": {"rail": "instrument"}}, + ) + receipt = deepcopy(RECEIPT) + if mode != "missing": + receipt["challengeId"] = "other" if mode == "id" else challenge.id + if mode == "method": + receipt["method"] = "tempo" + platform.result = {"receipt": receipt} + credential = Credential(challenge=challenge.to_echo(), payload={"transactionId": "test"}) + async with await Seller.create(options(platform)) as seller: + if mode == "success": + result = await seller.broadcast(credential, challenge.request) + assert result.method == "inflow" + else: + with pytest.raises(MppCredentialProblemError): + await seller.broadcast(credential, challenge.request) + assert [r.url.path for r in platform.requests] == ["/v1/mpp/config", "/v1/mpp/broadcast"] + + +@pytest.mark.parametrize("identifier", [None, "original-transaction"]) +async def test_failed_payment_keeps_response_identifier(identifier: str | None) -> None: + def handle(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={"state": "failed", "transactionId": identifier, "problem": {"title": "Declined"}}, + ) + + async with BuyerMethod(ClientOptions(transport=httpx.MockTransport(handle))) as buyer: + with pytest.raises(MppPaymentFailedError) as result: + await buyer.create_credential(to_pympp_challenge(WIRE)) + assert result.value.transaction_id == identifier + assert result.value.problem == {"title": "Declined"} + + +@pytest.mark.parametrize("matches", [True, False]) +async def test_instrument_route_requires_matching_receipt(matches: bool) -> None: + platform = Platform() + served = 0 + async with await Seller.create(options(platform)) as seller: + app = FastAPI() + + @app.get("/paid") + @pay( + intent=seller, + request=seller.charge_request({"amount": "1", "currency": "USD"}), + method="inflow", + realm="seller.example", + secret_key="test-only-secret", + ) + async def resource( + request: Request, credential: Credential, receipt: Receipt + ) -> JSONResponse: + nonlocal served + served += 1 + return JSONResponse({"ok": True}) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app), base_url="https://seller.example" + ) as client: + first = await client.get("/paid") + challenge = Challenge.from_www_authenticate(first.headers["www-authenticate"]) + platform.result = { + "receipt": {**RECEIPT, "challengeId": challenge.id if matches else "another"} + } + credential = Credential( + challenge=challenge.to_echo(), + payload={"transactionId": "test", "type": "instrument"}, + ) + result = await client.get( + "/paid", headers={"Authorization": credential.to_authorization()} + ) + assert result.status_code == (200 if matches else 402) + assert served == int(matches) + if not matches: + assert "payment-receipt" not in result.headers + assert [r.url.path for r in platform.requests] == [ + "/v1/mpp/config", + "/v1/mpp/validate", + "/v1/mpp/broadcast", + ] diff --git a/tests/test_payment_status.py b/tests/test_payment_status.py new file mode 100644 index 0000000..8adc877 --- /dev/null +++ b/tests/test_payment_status.py @@ -0,0 +1,154 @@ +import asyncio +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import httpx +import pytest + +from inflowpay import ClientOptions, InflowApiError +from inflowpay.mpp.buyer import BuyerMethod +from inflowpay.x402.buyer import Buyer +from test_runtime_native import native_server + + +@asynccontextmanager +async def client( + protocol: str, transport: httpx.AsyncBaseTransport +) -> AsyncIterator[BuyerMethod | Buyer]: + options = ClientOptions(api_key="test-only", transport=transport) + buyer = BuyerMethod(options) if protocol == "mpp" else await Buyer.create(options) + async with buyer: + yield buyer + + +class Platform(httpx.AsyncBaseTransport): + def __init__(self, responses: list[httpx.Response]) -> None: + self.responses = responses + self.requests: list[httpx.Request] = [] + self.started = asyncio.Event() + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("x402-supported"): + return httpx.Response(200, json={"kinds": []}) + self.requests.append(request) + self.started.set() + assert request.method == "GET" + assert request.headers["X-API-KEY"] == "test-only" + if not self.responses: + await asyncio.Event().wait() + return self.responses.pop(0) + + +@pytest.mark.parametrize("protocol", ["mpp", "x402"]) +async def test_fresh_status_preserves_action_and_unknown_states(protocol: str) -> None: + action = {"type": "authenticate_card", "url": "https://dashboard.example/verify/"} + snapshots = [ + {"transactionId": "original", "status": "PENDING", "nextAction": action}, + {"transactionId": "original", "status": "GENERAL_ERROR"}, + {"transactionId": "original", "status": "future-status"}, + ] + platform = Platform([httpx.Response(200, json=value) for value in snapshots]) + async with client(protocol, platform) as buyer: + for expected in snapshots: + assert await buyer.get_payment_status("original") == expected + assert len(platform.requests) == 3 + assert all(request.url.path == "/v1/transactions/original" for request in platform.requests) + with pytest.raises(RuntimeError, match="closed"): + await buyer.get_payment_status("original") + + +@pytest.mark.parametrize("protocol", ["mpp", "x402"]) +@pytest.mark.parametrize("status", [302, 400, 401, 404, 503]) +async def test_failure_is_not_retried_or_replaced(protocol: str, status: int) -> None: + platform = Platform([httpx.Response(status, headers={"Location": "https://other.example/"})]) + async with client(protocol, platform) as buyer: + with pytest.raises(InflowApiError) as caught: + await buyer.get_payment_status("original") + assert caught.value.http_status == status + assert len(platform.requests) == 1 + + +@pytest.mark.parametrize("protocol", ["mpp", "x402"]) +async def test_encoded_identifier_and_explicit_retry(protocol: str) -> None: + snapshot = {"transactionId": "original", "status": "COMPLETED"} + platform = Platform([httpx.Response(503), httpx.Response(200, json=snapshot)]) + async with client(protocol, platform) as buyer: + assert await buyer.get_payment_status("a/b?c#d", retries=1) == snapshot + assert len(platform.requests) == 2 + assert all( + request.url.raw_path == b"/v1/transactions/a%2Fb%3Fc%23d" for request in platform.requests + ) + + +@pytest.mark.parametrize("protocol", ["mpp", "x402"]) +async def test_cancel_read_does_not_cancel_payment(protocol: str) -> None: + platform = Platform([]) + async with client(protocol, platform) as buyer: + task = asyncio.create_task(buyer.get_payment_status("original")) + await platform.started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert len(platform.requests) == 1 + + +@pytest.mark.parametrize("protocol", ["mpp", "x402"]) +async def test_non_object_response(protocol: str) -> None: + platform = Platform([httpx.Response(200, json=[])]) + async with client(protocol, platform) as buyer: + with pytest.raises(ValueError, match="Payment status response must be an object"): + await buyer.get_payment_status("original") + + +@pytest.mark.parametrize("protocol", ["mpp", "x402"]) +@pytest.mark.parametrize("bearer", [False, True]) +async def test_redirect_never_sends_credentials_to_second_origin( + protocol: str, bearer: bool +) -> None: + received: list[dict[str, str]] = [] + original: list[str] = [] + + async def destination( + path: str, headers: dict[str, str], body: bytes, writer: asyncio.StreamWriter + ) -> None: + received.append(headers) + writer.write(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}") + + async def token() -> str: + return "test-only" + + async with native_server(destination) as other: + + async def redirect( + path: str, headers: dict[str, str], body: bytes, writer: asyncio.StreamWriter + ) -> None: + assert headers["authorization" if bearer else "x-api-key"] == ( + "Bearer test-only" if bearer else "test-only" + ) + if path.endswith("x402-supported"): + writer.write( + b"HTTP/1.1 200 OK\r\nContent-Length: 12\r\n" + b'Connection: close\r\n\r\n{"kinds":[]}' + ) + return + original.append(path) + writer.write( + ( + f"HTTP/1.1 302 Found\r\nLocation: {other}/leak\r\n" + "Content-Length: 0\r\nConnection: close\r\n\r\n" + ).encode() + ) + + async with native_server(redirect) as base: + options = ClientOptions( + base_url=base, + api_key=None if bearer else "test-only", + access_token=token if bearer else None, + ) + buyer = BuyerMethod(options) if protocol == "mpp" else await Buyer.create(options) + async with buyer: + with pytest.raises(InflowApiError) as caught: + await buyer.get_payment_status("original") + assert caught.value.http_status == 302 + assert original == ["/v1/transactions/original"] + assert received == [] diff --git a/tests/test_stripe_seller.py b/tests/test_stripe_seller.py new file mode 100644 index 0000000..277408c --- /dev/null +++ b/tests/test_stripe_seller.py @@ -0,0 +1,312 @@ +import json +from copy import deepcopy +from dataclasses import replace + +import httpx +import pytest +import uvicorn +from examples import mpp_seller +from fastapi import FastAPI, Request +from mpp import Challenge, Credential, Receipt +from mpp.errors import InvalidChallengeError +from mpp.server.decorator import pay +from starlette.responses import JSONResponse + +from inflowpay.mpp import MppCodecError, WireObject, decode +from inflowpay.mpp.seller import MppSellerConfigurationError, Seller +from test_mpp_seller import CONFIG, PROBLEM, RECEIPT, Platform, options + + +def platform() -> Platform: + result = Platform() + config: WireObject = { + **deepcopy(CONFIG), + "supportedMethods": [ + { + "id": "stripe", + "supportedCurrencies": ["USD"], + "supportedIntents": ["charge"], + "methodDetails": { + "networkId": "profile_test", + "paymentMethodTypes": ["card", "link"], + }, + } + ], + } + result.config = config + return result + + +@pytest.mark.parametrize( + "amount,cents", [("0.50", "50"), ("1", "100"), ("1.25", "125"), ("999999.99", "99999999")] +) +async def test_prepare(amount: str, cents: str) -> None: + transport = platform() + request: WireObject = { + "amount": amount, + "currency": "eur", + "decimals": 18, + "networkId": "untrusted", + "paymentMethodTypes": ["other"], + "externalId": "", + "recipient": "seller", + "description": "Report", + "metadata": {"purpose": ""}, + } + before = deepcopy(request) + async with await Seller.create(options(transport), method="stripe") as seller: + prepared = seller.stripe_request(request) + assert prepared == { + "amount": cents, + "currency": "usd", + "externalId": "", + "recipient": "seller", + "description": "Report", + "methodDetails": { + "networkId": "profile_test", + "paymentMethodTypes": ["card", "link"], + "metadata": {"purpose": ""}, + }, + } + assert request == before + prepared["methodDetails"] = {} + assert seller.stripe_request({"amount": amount})["methodDetails"] != {} + with pytest.raises(MppSellerConfigurationError, match="stripe_request"): + seller.charge_request({"amount": "125"}) + assert transport.closed + + +async def test_wrong_method() -> None: + async with await Seller.create(options(Platform())) as seller: + with pytest.raises(MppSellerConfigurationError, match="Stripe Seller"): + seller.stripe_request({"amount": "1"}) + + +@pytest.mark.parametrize("payload", [{}, {"spt": 3}, {"spt": "spt_test", "externalId": None}]) +async def test_invalid_payload(payload: dict[str, object]) -> None: + transport = platform() + async with await Seller.create(options(transport), method="stripe") as seller: + terms = seller.stripe_request({"amount": "1"}) + challenge = Challenge.create( + secret_key="test-only", realm="test", method="stripe", intent="charge", request=terms + ) + credential = Credential(challenge=challenge.to_echo(), payload=payload) + with pytest.raises(InvalidChallengeError): + await seller.validate(credential, terms) + assert len(transport.requests) == 1 + + +async def test_stripe_example() -> None: + transport = platform() + async with ( + mpp_seller.application(options(transport), "test-only", method="stripe") as app, + httpx.AsyncClient( + transport=httpx.ASGITransport(app), base_url="https://seller.example" + ) as client, + ): + response = await client.get("/api/widgets") + challenge = Challenge.from_www_authenticate(response.headers["www-authenticate"]) + assert challenge.method == "stripe" + terms = decode(challenge.request_b64) + assert isinstance(terms, dict) + assert terms["amount"] == "125" + + +@pytest.mark.parametrize("method", ["stripe", "unknown"]) +async def test_example_startup(method: str, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MPP_METHOD", method) + monkeypatch.setenv("INFLOW_API_KEY", "secret") + monkeypatch.setenv("MPP_SECRET_KEY", "test-only") + transport = platform() + monkeypatch.setattr(httpx._client, "AsyncHTTPTransport", lambda **kwargs: transport) + served = [] + + async def serve(server: uvicorn.Server, sockets: object = None) -> None: + served.append(server.config.port) + + monkeypatch.setattr(uvicorn.Server, "serve", serve) + if method == "unknown": + with pytest.raises(ValueError, match="MPP_METHOD"): + await mpp_seller.run() + assert not transport.requests + else: + await mpp_seller.run() + assert served == [3000] + assert transport.closed + + +@pytest.mark.parametrize( + "offer", + [ + {}, + {"amount": 1}, + {"amount": "NaN"}, + {"amount": "1.001"}, + {"amount": "00.50"}, + {"amount": "0"}, + {"amount": "0.49"}, + {"amount": "1000000"}, + {"amount": "1", "metadata": []}, + {"amount": "1", "metadata": {str(i): "v" for i in range(46)}}, + *[ + {"amount": "1", "metadata": {key: "v"}} + for key in ( + " ", + "k" * 41, + "bad[", + "bad]", + "externalId", + "inflowMppTransactionId", + "mppChallengeId", + "mppIntent", + "mppMethod", + "stripeNetworkProfile", + ) + ], + {"amount": "1", "metadata": {"k": 3}}, + {"amount": "1", "metadata": {"k": "v" * 501}}, + {"amount": "1", "externalId": "x" * 256}, + {"amount": "1", "externalId": 3}, + {"amount": "1", "description": None}, + {"amount": "1", "recipient": False}, + ], +) +async def test_bad_offer(offer: WireObject) -> None: + transport = platform() + async with await Seller.create(options(transport), method="stripe") as seller: + with pytest.raises(MppCodecError): + seller.stripe_request(offer) + assert len(transport.requests) == 1 + + +@pytest.mark.parametrize( + "field,value", + [ + ("supportedCurrencies", None), + ("supportedCurrencies", ["EUR"]), + ("supportedIntents", None), + ("supportedIntents", ["subscription"]), + ("methodDetails", None), + ("networkId", None), + ("networkId", " "), + ("paymentMethodTypes", None), + ("paymentMethodTypes", []), + ("paymentMethodTypes", [" "]), + ("paymentMethodTypes", [3]), + ("id", "other"), + ], +) +async def test_unavailable_configuration(field: str, value: object) -> None: + transport = platform() + config = json.loads(json.dumps(transport.config)) + entry = config["supportedMethods"][0] + if field in ("networkId", "paymentMethodTypes"): + entry["methodDetails"][field] = value + else: + entry[field] = value + transport.config = config + with pytest.raises(MppSellerConfigurationError): + await Seller.create(options(transport), method="stripe") + assert transport.closed + + +@pytest.mark.parametrize( + "outcome", + [ + "success", + "validation", + "pending", + "method", + "challenge", + "missing", + "reference", + "signature", + "expiry", + "route", + ], +) +async def test_protected_route(outcome: str) -> None: + transport = platform() + async with await Seller.create(options(transport), method="stripe") as seller: + app = FastAPI() + delivered = [] + terms = seller.stripe_request({"amount": "1.25", "externalId": "order"}) + + @app.get("/paid") + @pay( + intent=seller, + method="stripe", + request=lambda _: terms, + realm="seller.example", + secret_key="test-only-secret", + ) + async def paid(request: Request, credential: Credential, receipt: Receipt) -> JSONResponse: + delivered.append(True) + return JSONResponse( + {"ok": True}, headers={"Payment-Receipt": receipt.to_payment_receipt()} + ) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app), base_url="https://seller.example" + ) as client: + initial = await client.get("/paid") + challenge = Challenge.from_www_authenticate(initial.headers["www-authenticate"]) + if outcome == "expiry": + challenge = Challenge.create( + secret_key="test-only-secret", + realm="seller.example", + method="stripe", + intent="charge", + request=json.loads(json.dumps(decode(challenge.request_b64))), + expires="2020-01-01T00:00:00Z", + ) + credential = Credential( + challenge=challenge.to_echo(), payload={"spt": "spt_test", "externalId": "order"} + ) + receipt = { + **RECEIPT, + "method": "stripe", + "challengeId": challenge.id, + "reference": "pi_test", + "settlement": {"amount": "125", "currency": "usd"}, + } + if outcome == "validation": + transport.validation = {"success": False, "problem": PROBLEM} + if outcome == "pending": + transport.result = {"problem": PROBLEM} + else: + if outcome == "method": + receipt["method"] = "inflow" + if outcome == "challenge": + receipt["challengeId"] = "other" + if outcome == "missing": + del receipt["challengeId"] + transport.result = {"receipt": receipt} + if outcome == "reference": + credential = replace(credential, payload={"spt": "spt_test", "externalId": "other"}) + if outcome == "signature": + credential = replace( + credential, challenge=replace(credential.challenge, id="tampered") + ) + if outcome == "route": + terms = seller.stripe_request({"amount": "2", "externalId": "order"}) + response = await client.get( + "/paid", headers={"Authorization": credential.to_authorization()} + ) + assert response.status_code == (200 if outcome == "success" else 402) + assert delivered == ([True] if outcome == "success" else []) + if outcome == "success": + assert decode(response.headers["payment-receipt"]) == receipt + wire = json.loads(transport.requests[1].content)["credential"] + assert wire["source"] == "" + assert wire["payload"]["spt"] == "spt_test" + else: + assert "payment-receipt" not in response.headers + expected = ( + 1 + if outcome in ("reference", "signature", "expiry", "route") + else 2 + if outcome == "validation" + else 3 + ) + assert len(transport.requests) == expected