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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 154 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
97 changes: 78 additions & 19 deletions conformance/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.",
Expand All @@ -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 = (
Expand All @@ -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:
Expand Down Expand Up @@ -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:])
Expand All @@ -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))
Expand All @@ -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"],
Expand All @@ -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(
Expand All @@ -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()}
)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading