From 43c9cac89e223245e86136b19da2ea37542e5a15 Mon Sep 17 00:00:00 2001 From: Ben Sandler Date: Fri, 28 Aug 2026 15:02:27 -0400 Subject: [PATCH 1/3] feat: add Stripe machine payments facade - Configure SPT and Tempo offers from one initialized Stripe client. - Record settled Tempo payments in Stripe without making recording part of settlement. - Apply Stripe minimums and attributed preview API requests. Related mppx work: - [Stripe machine-payments facade](https://github.com/wevm/mppx/pull/764) - [Offer availability](https://github.com/wevm/mppx/pull/776) - [Graceful default methods](https://github.com/wevm/mppx/pull/782) - [Remove obsolete MPP metadata](https://github.com/wevm/mppx/pull/816) Committed-By-Agent: codex Co-authored-by: codex Committed-By-Agent: codex Co-authored-by: codex Committed-By-Agent: codex Co-authored-by: codex --- .changelog/stripe-machine-payments.md | 5 + .github/workflows/ci.yml | 22 ++- pyproject.toml | 7 + src/mpp/methods/stripe/__init__.py | 3 + src/mpp/methods/stripe/_defaults.py | 2 + src/mpp/methods/stripe/client.py | 6 + src/mpp/methods/stripe/intents.py | 32 ++-- src/mpp/methods/stripe/machine_payments.py | 171 ++++++++++++++++++++ tests/test_stripe.py | 12 +- tests/test_stripe_machine_payments.py | 177 +++++++++++++++++++++ tests/typecheck/compose_consumer.py | 14 ++ 11 files changed, 426 insertions(+), 25 deletions(-) create mode 100644 .changelog/stripe-machine-payments.md create mode 100644 src/mpp/methods/stripe/machine_payments.py create mode 100644 tests/test_stripe_machine_payments.py diff --git a/.changelog/stripe-machine-payments.md b/.changelog/stripe-machine-payments.md new file mode 100644 index 00000000..649bbff6 --- /dev/null +++ b/.changelog/stripe-machine-payments.md @@ -0,0 +1,5 @@ +--- +pympp: patch +--- + +Added a small SPT/Tempo Stripe facade with minimum-aware offers and pinned, attributed Stripe requests. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e09b3787..3afe2b87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,7 +160,7 @@ jobs: strategy: fail-fast: false matrix: - profile: [base, tempo, mcp] + profile: [base, tempo, stripe, mcp] steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -197,6 +197,9 @@ jobs: tempo) INSTALL_TARGET="${WHEEL}[tempo]" ;; + stripe) + INSTALL_TARGET="${WHEEL}[stripe]" + ;; mcp) INSTALL_TARGET="${WHEEL}[mcp]" ;; @@ -228,16 +231,19 @@ jobs: print("base install smoke test passed") PY - - name: Smoke test built wheel (tempo) - if: matrix.profile == 'tempo' + - name: Smoke test built wheel (Tempo dependencies) + if: matrix.profile == 'tempo' || matrix.profile == 'stripe' + env: + PROFILE: ${{ matrix.profile }} run: | .smoke-venv/bin/python - <<'PY' + import os from mpp.methods.tempo import ChargeIntent, TempoAccount - - assert ChargeIntent.__name__ == "ChargeIntent" - assert TempoAccount.__name__ == "TempoAccount" - - print("tempo install smoke test passed") + if os.environ["PROFILE"] == "stripe": + from stripe import StripeClient + from mpp.methods.stripe import create + payments = create(network_id="bn_test", livemode=False, client=StripeClient("sk_test"), deposit_addresses={"tempo": "0x" + "1" * 40}) + assert [method.name for method in payments.default_methods()] == ["tempo", "stripe"] PY - name: Smoke test built wheel (mcp) diff --git a/pyproject.toml b/pyproject.toml index 2cead951..4ff56275 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,13 @@ tempo = [ ] stripe = [ "pydantic>=2.0", + "pytempo>=0.2.1", + "eth-account>=0.11", + "eth-abi>=5.0,<6", + "eth-hash[pycryptodome]>=0.7", + "attrs>=23.0", + "rlp>=4.0", + "stripe>=8.0.0", ] server = ["pydantic>=2.0"] redis = ["redis>=5.0"] diff --git a/src/mpp/methods/stripe/__init__.py b/src/mpp/methods/stripe/__init__.py index 11d5b260..c90b1beb 100644 --- a/src/mpp/methods/stripe/__init__.py +++ b/src/mpp/methods/stripe/__init__.py @@ -38,4 +38,7 @@ async def create_spt(params): from mpp.methods.stripe.client import StripeMethod, stripe from mpp.methods.stripe.intents import ChargeIntent +from mpp.methods.stripe.machine_payments import DepositAddresses as DepositAddresses +from mpp.methods.stripe.machine_payments import MachinePayments as MachinePayments +from mpp.methods.stripe.machine_payments import create as create from mpp.methods.stripe.schemas import ChargeRequest, StripeCredentialPayload diff --git a/src/mpp/methods/stripe/_defaults.py b/src/mpp/methods/stripe/_defaults.py index 588fe398..33cd4df2 100644 --- a/src/mpp/methods/stripe/_defaults.py +++ b/src/mpp/methods/stripe/_defaults.py @@ -1,3 +1,5 @@ """Default constants for Stripe payment method.""" STRIPE_API_BASE = "https://api.stripe.com/v1" +MACHINE_PAYMENTS_API_VERSION = "2026-07-29.preview" +STRIPE_REQUEST_SOURCE = 'service="pympp"; project="machine_payments"' diff --git a/src/mpp/methods/stripe/client.py b/src/mpp/methods/stripe/client.py index 5e138c0a..244ed0cd 100644 --- a/src/mpp/methods/stripe/client.py +++ b/src/mpp/methods/stripe/client.py @@ -70,6 +70,7 @@ class StripeMethod: _intents: dict[str, Intent | VerifiableIntent] = field(default_factory=dict) can_offer: CanOfferFn | None = field(default=None, kw_only=True) on_payment_success: PaymentSuccessHandler | None = field(default=None, kw_only=True) + metadata: dict[str, str] | None = field(default=None, kw_only=True) @property def intents(self) -> dict[str, Intent | VerifiableIntent]: @@ -97,6 +98,8 @@ def transform_request( ) if self.payment_method_types: method_details["paymentMethodTypes"] = self.payment_method_types + if self.metadata is not None: + method_details["metadata"] = self.metadata request = {**request, "methodDetails": method_details} if self.external_id and "externalId" not in request: request["externalId"] = self.external_id @@ -199,6 +202,7 @@ def stripe( payment_method_types: list[str] | None = None, can_offer: CanOfferFn | None = None, on_payment_success: PaymentSuccessHandler | None = None, + metadata: dict[str, str] | None = None, ) -> StripeMethod: """Create a Stripe payment method. @@ -219,6 +223,7 @@ def stripe( Included in challenge ``methodDetails.paymentMethodTypes``. can_offer: Optional callback that filters this method's composed offers. on_payment_success: Optional callback invoked after successful verification. + metadata: Optional Stripe metadata included in the challenge request. Returns: A configured :class:`StripeMethod` instance. @@ -253,6 +258,7 @@ def stripe( payment_method_types=payment_method_types or ["card"], can_offer=can_offer, on_payment_success=on_payment_success, + metadata=metadata, ) method._intents = dict(intents) return method diff --git a/src/mpp/methods/stripe/intents.py b/src/mpp/methods/stripe/intents.py index cda519b8..9051a7f9 100644 --- a/src/mpp/methods/stripe/intents.py +++ b/src/mpp/methods/stripe/intents.py @@ -10,6 +10,7 @@ from datetime import UTC, datetime from typing import Any, cast +import mpp.methods.stripe._defaults as stripe_defaults from mpp import Credential, Receipt from mpp._defaults import DEFAULT_TIMEOUT from mpp.errors import ( @@ -17,7 +18,6 @@ PaymentExpiredError, VerificationFailedError, ) -from mpp.methods.stripe._defaults import STRIPE_API_BASE from mpp.methods.stripe.schemas import ChargeRequest, StripeCredentialPayload @@ -27,9 +27,7 @@ def _build_analytics(credential: Credential) -> dict[str, str]: analytics: dict[str, str] = { "mpp_challenge_id": challenge.id, "mpp_intent": challenge.intent, - "mpp_is_mpp": "true", "mpp_server_id": challenge.realm, - "mpp_version": "1", } if credential.source: analytics["mpp_client_id"] = credential.source @@ -53,6 +51,14 @@ def _resolve_payment_intents(client: Any) -> Any: raise TypeError("Unsupported Stripe client: expected .v1.payment_intents or .payment_intents") +async def _create_payment_intent(client: Any, body: dict[str, Any], options: dict[str, Any]) -> Any: + """Create through either an asynchronous or synchronous Stripe client.""" + payment_intents = _resolve_payment_intents(client) + if callable(create_async := getattr(payment_intents, "create_async", None)): + return await cast(Any, create_async)(body, options=options) + return await asyncio.to_thread(payment_intents.create, body, options=options) + + class ChargeIntent: """Stripe charge intent for one-time payments via SPTs. @@ -86,8 +92,7 @@ def __init__( Args: client: Pre-configured Stripe SDK instance (duck-typed). - Supports both ``StripeClient`` (v8+, ``client.v1.payment_intents``) - and legacy clients (``client.payment_intents``). + Supports ``client.payment_intents`` and ``client.v1.payment_intents``. secret_key: Stripe secret API key for raw HTTP verification. Used only when ``client`` is not provided. http_client: Optional httpx client for raw HTTP calls. @@ -218,7 +223,6 @@ async def _create_with_client( ) -> dict[str, str]: """Create a PaymentIntent using the Stripe SDK client.""" try: - payment_intents = _resolve_payment_intents(client) body = { "amount": int(request.amount), "confirm": True, @@ -227,13 +231,13 @@ async def _create_with_client( "payment_method_types": list(request.methodDetails.paymentMethodTypes), "shared_payment_granted_token": spt, } - options = {"idempotency_key": f"mpp_{challenge_id}_{spt}"} + options = { + "headers": {"X-Request-Source": stripe_defaults.STRIPE_REQUEST_SOURCE}, + "idempotency_key": f"mpp_{challenge_id}_{spt}", + "stripe_version": stripe_defaults.MACHINE_PAYMENTS_API_VERSION, + } - create_async = getattr(payment_intents, "create_async", None) - if callable(create_async): - result = await cast(Any, create_async)(body, options=options) - else: - result = await asyncio.to_thread(payment_intents.create, body, options=options) + result = await _create_payment_intent(client, body, options) return {"id": result.id, "status": result.status} except (VerificationFailedError, TypeError): raise @@ -265,11 +269,13 @@ async def _create_with_secret_key( body[f"metadata[{key}]"] = value response = await http_client.post( - f"{STRIPE_API_BASE}/payment_intents", + f"{stripe_defaults.STRIPE_API_BASE}/payment_intents", headers={ "Authorization": f"Basic {auth_value}", "Content-Type": "application/x-www-form-urlencoded", "Idempotency-Key": f"mpp_{challenge_id}_{spt}", + "Stripe-Version": stripe_defaults.MACHINE_PAYMENTS_API_VERSION, + "X-Request-Source": stripe_defaults.STRIPE_REQUEST_SOURCE, }, data=body, ) diff --git a/src/mpp/methods/stripe/machine_payments.py b/src/mpp/methods/stripe/machine_payments.py new file mode 100644 index 00000000..20b8d7f6 --- /dev/null +++ b/src/mpp/methods/stripe/machine_payments.py @@ -0,0 +1,171 @@ +"""Opinionated Stripe machine payments for SPT and Tempo.""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypedDict + +import mpp.methods.stripe._defaults as stripe_defaults +import mpp.methods.stripe.intents as stripe_intents +from mpp.events import ServerPaymentSuccessPayload +from mpp.methods import CanOfferFn +from mpp.methods.stripe.client import StripeMethod, stripe +from mpp.methods.tempo._defaults import CHAIN_ID, TESTNET_CHAIN_ID + +if TYPE_CHECKING: + from stripe import StripeClient + + from mpp.methods.tempo.client import TempoMethod + +logger = logging.getLogger(__name__) + +_SPT_MINIMUM, _RAW_UNITS_PER_CENT, _CENT_ROUNDING = 50, 10_000, 5_000 + + +class DepositAddresses(TypedDict, total=False): + """Static deposit addresses understood by Stripe machine payments.""" + + tempo: str + + +def _minimum_amount(minimum: int) -> CanOfferFn: + def can_offer(request: dict[str, Any]) -> bool: + try: + return int(request["amount"]) >= minimum + except (KeyError, TypeError, ValueError): + return False + + return can_offer + + +class SptPayments: + """Build configured Stripe SPT charge methods.""" + + def __init__( + self, network_id: str, client: StripeClient, metadata: dict[str, str] | None + ) -> None: + self._network_id = network_id + self._client = client + self._metadata = metadata + + def charge(self) -> StripeMethod: + return stripe( + intents={"charge": stripe_intents.ChargeIntent(client=self._client)}, + currency="usd", + recipient=self._network_id, + network_id=self._network_id, + payment_method_types=["card", "link"], + can_offer=_minimum_amount(_SPT_MINIMUM), + metadata=self._metadata, + ) + + +class TempoPayments: + """Build configured Tempo charge methods recorded in Stripe.""" + + def __init__( + self, + livemode: bool, + client: StripeClient, + recipient: str | None, + metadata: dict[str, str] | None, + ) -> None: + self._livemode = livemode + self._client = client + self._recipient = recipient + self._metadata = metadata + + def charge(self) -> TempoMethod: + if self._recipient is None: + raise ValueError("deposit_addresses['tempo'] is required for Tempo payments") + from mpp.methods.tempo import ChargeIntent as TempoChargeIntent + from mpp.methods.tempo import tempo + + return tempo( + intents={"charge": TempoChargeIntent()}, + chain_id=CHAIN_ID if self._livemode else TESTNET_CHAIN_ID, + recipient=self._recipient, + can_offer=_minimum_amount(_RAW_UNITS_PER_CENT), + on_payment_success=self._record_payment, + ) + + async def _record_payment(self, payload: ServerPaymentSuccessPayload) -> None: + """Record a verified Tempo payment as a Stripe PaymentIntent.""" + reference = payload["receipt"].reference + amount_cents = (int(payload["request"]["amount"]) + _CENT_ROUNDING) // _RAW_UNITS_PER_CENT + if amount_cents < 1: + return + params: dict[str, Any] = { + "amount": amount_cents, + "currency": "usd", + "confirm": True, + "metadata": {**(self._metadata or {}), "machine_payment": "true"}, + "payment_method_data": {"type": "crypto"}, + "payment_method_types": ["crypto"], + "payment_method_options": { + "crypto": { + "mode": "transaction_verification", + "transaction_verification_options": { + "network": "tempo", + "transaction_hash": reference, + }, + } + }, + } + try: + await stripe_intents._create_payment_intent( + self._client, + params, + { + "headers": {"X-Request-Source": stripe_defaults.STRIPE_REQUEST_SOURCE}, + "idempotency_key": reference, + "stripe_version": stripe_defaults.MACHINE_PAYMENTS_API_VERSION, + }, + ) + except Exception as error: + logger.warning("[stripe] Tempo PI recording failed for %r: %s", reference, error) + + +class MachinePayments: + """Configure Stripe SPT and optional Tempo payment methods.""" + + def __init__( + self, + *, + network_id: str, + livemode: bool, + client: StripeClient, + deposit_addresses: DepositAddresses | None = None, + metadata: Mapping[str, str] | None = None, + ) -> None: + stripe_intents._resolve_payment_intents(client) + tempo_address = deposit_addresses.get("tempo") if deposit_addresses is not None else None + + resolved_metadata = dict(metadata) if metadata is not None else None + self._tempo_address = tempo_address + self.spt = SptPayments(network_id, client, resolved_metadata) + self.tempo = TempoPayments(livemode, client, tempo_address, resolved_metadata) + + def default_methods(self) -> list[StripeMethod | TempoMethod]: + """Return configured methods in preferred negotiation order.""" + spt: list[StripeMethod | TempoMethod] = [self.spt.charge()] + return [self.tempo.charge(), *spt] if self._tempo_address is not None else spt + + +def create( + *, + network_id: str, + livemode: bool, + client: StripeClient, + deposit_addresses: DepositAddresses | None = None, + metadata: Mapping[str, str] | None = None, +) -> MachinePayments: + """Create machine payments from an initialized StripeClient.""" + return MachinePayments( + network_id=network_id, + livemode=livemode, + client=client, + deposit_addresses=deposit_addresses, + metadata=metadata, + ) diff --git a/tests/test_stripe.py b/tests/test_stripe.py index 2e3fcb9a..a04b3d74 100644 --- a/tests/test_stripe.py +++ b/tests/test_stripe.py @@ -21,6 +21,7 @@ VerificationFailedError, ) from mpp.methods.stripe import ChargeIntent, stripe +from mpp.methods.stripe._defaults import MACHINE_PAYMENTS_API_VERSION from mpp.methods.stripe.client import OnChallengeParameters from mpp.methods.stripe.intents import _resolve_payment_intents from mpp.methods.stripe.schemas import ChargeRequest, StripeCredentialPayload @@ -644,8 +645,6 @@ class CapturingClient: params = captured[0][0][0] metadata = params["metadata"] - assert metadata["mpp_version"] == "1" - assert metadata["mpp_is_mpp"] == "true" assert metadata["mpp_intent"] == "charge" assert metadata["mpp_challenge_id"] == "test-challenge-id" assert metadata["mpp_server_id"] == "api.example.com" @@ -671,6 +670,10 @@ class CapturingClient: options = captured[0][1]["options"] assert options["idempotency_key"] == "mpp_test-challenge-id_spt_test_xyz" + assert options["headers"] == { + "X-Request-Source": 'service="pympp"; project="machine_payments"' + } + assert options["stripe_version"] == MACHINE_PAYMENTS_API_VERSION @pytest.mark.asyncio async def test_client_request_body_is_first_positional_arg(self): @@ -763,6 +766,8 @@ async def test_verify_with_secret_key_success(self): expected_auth = base64.b64encode(b"sk_test_raw:").decode() assert headers["Authorization"] == f"Basic {expected_auth}" assert headers["Idempotency-Key"] == "mpp_test-challenge-id_spt_test_abc" + assert headers["Stripe-Version"] == MACHINE_PAYMENTS_API_VERSION + assert headers["X-Request-Source"] == 'service="pympp"; project="machine_payments"' data = call_kwargs.kwargs["data"] assert data["amount"] == "150" @@ -841,8 +846,7 @@ async def test_verify_with_secret_key_metadata_in_form(self): data = mock_client.post.call_args.kwargs["data"] assert data["metadata[machine_payment]"] == "true" - assert data["metadata[mpp_is_mpp]"] == "true" - assert data["metadata[mpp_version]"] == "1" + assert data["metadata[mpp_intent]"] == "charge" # ────────────────────────────────────────────────────────────────── diff --git a/tests/test_stripe_machine_payments.py b/tests/test_stripe_machine_payments.py new file mode 100644 index 00000000..d1e55c75 --- /dev/null +++ b/tests/test_stripe_machine_payments.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, cast + +import pytest + +from mpp import Challenge, Credential, Receipt +from mpp.events import ServerPaymentSuccessPayload +from mpp.methods.stripe import MachinePayments, create +from mpp.methods.stripe import _defaults as stripe_defaults +from mpp.methods.tempo._defaults import CHAIN_ID, PATH_USD, TESTNET_CHAIN_ID, USDC + +TEMPO_ADDRESS = "0x" + "1" * 40 + + +class FakePaymentIntents: + def __init__(self, *, sync: bool = False) -> None: + self.calls: list[tuple[dict[str, Any], dict[str, Any]]] = [] + self.error: Exception | None = None + if sync: + self.create_async = None # type: ignore[assignment] + + async def create_async(self, params: dict[str, Any], *, options: dict[str, Any]) -> Any: + if self.error is not None: + raise self.error + return self.create(params, options=options) + + def create(self, params: dict[str, Any], *, options: dict[str, Any]) -> Any: + self.calls.append((params, options)) + return SimpleNamespace(id="pi_test", status="succeeded") + + +class FakeStripeClient: + def __init__(self, *, top_level_only: bool = False, sync: bool = False) -> None: + self.payment_intents = FakePaymentIntents(sync=sync) + if not top_level_only: + self.v1 = type("V1", (), {"payment_intents": self.payment_intents})() + + +def make_payments(**overrides: Any) -> tuple[FakeStripeClient, MachinePayments]: + client = overrides.pop("client", FakeStripeClient()) + return client, create( + network_id=overrides.pop("network_id", "bn_test"), + livemode=overrides.pop("livemode", False), + client=cast(Any, client), + **overrides, + ) + + +def test_defaults_are_spt_only_and_include_configured_metadata() -> None: + _, payments = make_payments(metadata={"order": "123"}) + + methods = payments.default_methods() + spt = payments.spt.charge() + request = spt.transform_request({"amount": "50", "currency": "usd"}, None) + + assert [method.name for method in methods] == ["stripe"] + assert methods[0].recipient == "bn_test" + assert request["methodDetails"] == { + "metadata": {"order": "123"}, + "networkId": "bn_test", + "paymentMethodTypes": ["card", "link"], + } + + +def test_static_tempo_is_preferred_and_uses_network_defaults() -> None: + _, test_payments = make_payments(deposit_addresses={"tempo": TEMPO_ADDRESS}) + _, live_payments = make_payments(livemode=True, deposit_addresses={"tempo": TEMPO_ADDRESS}) + + methods = test_payments.default_methods() + test_tempo = test_payments.tempo.charge() + live_tempo = live_payments.tempo.charge() + + assert [method.name for method in methods] == ["tempo", "stripe"] + assert (test_tempo.recipient, test_tempo.chain_id) == (TEMPO_ADDRESS, TESTNET_CHAIN_ID) + assert test_tempo.currency == PATH_USD + assert (live_tempo.chain_id, live_tempo.currency) == (CHAIN_ID, USDC) + + +def test_methods_filter_amounts_below_stripe_minima() -> None: + _, payments = make_payments(deposit_addresses={"tempo": TEMPO_ADDRESS}) + spt_offer = payments.spt.charge().can_offer + tempo_offer = payments.tempo.charge().can_offer + assert spt_offer is not None and tempo_offer is not None + + assert not spt_offer({"amount": "49"}) + assert spt_offer({"amount": "50"}) + assert not tempo_offer({"amount": "9999"}) + assert tempo_offer({"amount": "10000"}) + + +def test_configuration_boundary_errors() -> None: + with pytest.raises(TypeError, match="Unsupported Stripe client"): + make_payments(client=object()) + with pytest.raises(ValueError, match=r"deposit_addresses\['tempo'\]"): + make_payments()[1].tempo.charge() + + +@pytest.mark.asyncio +async def test_spt_uses_pinned_explicit_request_shape() -> None: + client, payments = make_payments(metadata={"order": "123"}) + method = payments.spt.charge() + request = method.transform_request( + {"amount": "100", "currency": "usd", "recipient": "bn_test"}, None + ) + challenge = Challenge.create( + secret_key="secret", + realm="api.example.com", + method="stripe", + intent="charge", + request=request, + ) + + await cast(Any, method.intents["charge"]).verify( + Credential(challenge=challenge.to_echo(), payload={"spt": "spt_test"}), request + ) + + params, options = client.payment_intents.calls[0] + assert params["shared_payment_granted_token"] == "spt_test" + assert params["payment_method_types"] == ["card", "link"] + assert "automatic_payment_methods" not in params + assert params["metadata"]["order"] == "123" + assert options == { + "headers": {"X-Request-Source": stripe_defaults.STRIPE_REQUEST_SOURCE}, + "idempotency_key": f"mpp_{challenge.id}_spt_test", + "stripe_version": stripe_defaults.MACHINE_PAYMENTS_API_VERSION, + } + + +def success_payload(reference: str, amount: int) -> ServerPaymentSuccessPayload: + receipt = Receipt.success(reference, method="tempo") + return cast(Any, {"receipt": receipt, "request": {"amount": str(amount)}}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("top_level_only", "sync"), [(False, False), (True, False), (True, True)]) +async def test_tempo_hook_records_verified_payment_and_metadata( + top_level_only: bool, sync: bool +) -> None: + client, payments = make_payments( + client=FakeStripeClient(top_level_only=top_level_only, sync=sync), + deposit_addresses={"tempo": TEMPO_ADDRESS}, + metadata={"order": "123"}, + ) + handler = payments.tempo.charge().on_payment_success + assert handler is not None + + for amount in (4_999, 5_000, 15_000): + await cast(Any, handler)(success_payload(f"0x{amount}", amount)) + + assert [params["amount"] for params, _ in client.payment_intents.calls] == [1, 2] + params, options = client.payment_intents.calls[-1] + assert params["metadata"] == {"machine_payment": "true", "order": "123"} + assert params["payment_method_options"]["crypto"] == { + "mode": "transaction_verification", + "transaction_verification_options": { + "network": "tempo", + "transaction_hash": "0x15000", + }, + } + assert options == { + "headers": {"X-Request-Source": stripe_defaults.STRIPE_REQUEST_SOURCE}, + "idempotency_key": "0x15000", + "stripe_version": stripe_defaults.MACHINE_PAYMENTS_API_VERSION, + } + + +@pytest.mark.asyncio +async def test_tempo_recording_is_best_effort(caplog: pytest.LogCaptureFixture) -> None: + client, payments = make_payments(deposit_addresses={"tempo": TEMPO_ADDRESS}) + client.payment_intents.error = RuntimeError("unavailable") + handler = payments.tempo.charge().on_payment_success + + await cast(Any, handler)(success_payload("0xfailure", 10_000)) + + assert "Tempo PI recording failed" in caplog.text and "0xfailure" in caplog.text diff --git a/tests/typecheck/compose_consumer.py b/tests/typecheck/compose_consumer.py index fa6a9725..f03f8eac 100644 --- a/tests/typecheck/compose_consumer.py +++ b/tests/typecheck/compose_consumer.py @@ -3,10 +3,13 @@ from typing import assert_type +from stripe import StripeClient + import mpp.server as server_api from mpp import Challenge, Credential, Receipt from mpp.events import ServerPaymentSuccessPayload from mpp.methods import CanOfferFn, PaymentSuccessHandler +from mpp.methods.stripe import DepositAddresses, create from mpp.methods.tempo import ChargeIntent, tempo @@ -44,3 +47,14 @@ async def check_result() -> None: server.compose((method, {"amount": 1})) # pyright: ignore[reportArgumentType] server_api.Mpp.create() # pyright: ignore[reportCallIssue] server_api.Mpp.create(method=method, methods=[method]) # pyright: ignore[reportCallIssue] + +stripe_client = StripeClient("sk_test") +deposit_addresses: DepositAddresses = {"tempo": "0x" + "1" * 40} +machine_payments = create( + network_id="bn_test", livemode=False, client=stripe_client, deposit_addresses=deposit_addresses +) +machine_payments.spt.charge() +machine_payments.tempo.charge() +server_api.Mpp.create( + methods=machine_payments.default_methods(), realm="example.com", secret_key="secret" +) From 7d56bd624fdf80dfe02a7a2f82cba8b371c73384 Mon Sep 17 00:00:00 2001 From: Ben Sandler Date: Mon, 31 Aug 2026 12:52:28 -0400 Subject: [PATCH 2/3] feat(stripe): prefer spt factory - Expose `spt()` as the canonical Shared Payment Token factory. - Keep `stripe()` compatible while warning callers to migrate. Related mppx work: - [Stripe machine-payments facade](https://github.com/wevm/mppx/pull/764) Committed-By-Agent: codex Co-authored-by: codex Committed-By-Agent: codex Co-authored-by: codex --- .changelog/stripe-machine-payments.md | 2 +- examples/stripe/client.py | 4 +- examples/stripe/server.py | 4 +- src/mpp/methods/stripe/__init__.py | 19 ++++---- src/mpp/methods/stripe/client.py | 33 ++++++++++---- src/mpp/methods/stripe/machine_payments.py | 4 +- src/mpp/methods/stripe/schemas.py | 2 +- tests/test_method_hooks.py | 4 +- tests/test_stripe.py | 51 ++++++++++++++-------- tests/typecheck/compose_consumer.py | 10 ++++- 10 files changed, 86 insertions(+), 47 deletions(-) diff --git a/.changelog/stripe-machine-payments.md b/.changelog/stripe-machine-payments.md index 649bbff6..62ebd218 100644 --- a/.changelog/stripe-machine-payments.md +++ b/.changelog/stripe-machine-payments.md @@ -2,4 +2,4 @@ pympp: patch --- -Added a small SPT/Tempo Stripe facade with minimum-aware offers and pinned, attributed Stripe requests. +Added a small SPT/Tempo Stripe facade with minimum-aware offers and pinned, attributed Stripe requests, made `spt()` the preferred SPT factory, and deprecated the `stripe()` compatibility name. diff --git a/examples/stripe/client.py b/examples/stripe/client.py index ddbf9dcc..d7fb6eb9 100644 --- a/examples/stripe/client.py +++ b/examples/stripe/client.py @@ -15,7 +15,7 @@ import httpx from mpp.client import Client -from mpp.methods.stripe import stripe +from mpp.methods.stripe import spt def parse_args() -> argparse.Namespace: @@ -55,7 +55,7 @@ async def create_token(params): response.raise_for_status() return response.json()["spt"] - method = stripe( + method = spt( create_token=create_token, payment_method="pm_card_visa", intents={}, diff --git a/examples/stripe/server.py b/examples/stripe/server.py index e6b1fc9e..9fe33d79 100644 --- a/examples/stripe/server.py +++ b/examples/stripe/server.py @@ -15,7 +15,7 @@ from fastapi.responses import JSONResponse from mpp import Challenge -from mpp.methods.stripe import ChargeIntent, stripe +from mpp.methods.stripe import ChargeIntent, spt from mpp.server import Mpp app = FastAPI(title="Stripe Fortune Server") @@ -23,7 +23,7 @@ SECRET_KEY = os.environ["STRIPE_SECRET_KEY"] server = Mpp.create( - method=stripe( + method=spt( network_id=os.environ.get("STRIPE_NETWORK_ID", "internal"), payment_method_types=["card"], currency="usd", diff --git a/src/mpp/methods/stripe/__init__.py b/src/mpp/methods/stripe/__init__.py index c90b1beb..7ffe6395 100644 --- a/src/mpp/methods/stripe/__init__.py +++ b/src/mpp/methods/stripe/__init__.py @@ -1,11 +1,11 @@ """Stripe payment method for HTTP 402 authentication. -Uses Stripe's Shared Payment Token (SPT) flow for one-time charges. +Prefer ``spt`` for Shared Payment Tokens; ``stripe`` is a deprecated compatibility name. Example: # Client-side from mpp.client import get - from mpp.methods.stripe import stripe, ChargeIntent + from mpp.methods.stripe import spt, ChargeIntent async def create_spt(params): # Proxy to your server endpoint that creates an SPT @@ -14,7 +14,7 @@ async def create_spt(params): response = await get( "https://api.example.com/resource", - methods=[stripe( + methods=[spt( create_token=create_spt, payment_method="pm_card_visa", intents={}, @@ -23,10 +23,10 @@ async def create_spt(params): # Server-side from mpp.server import Mpp - from mpp.methods.stripe import stripe, ChargeIntent + from mpp.methods.stripe import spt, ChargeIntent server = Mpp.create( - method=stripe( + method=spt( network_id="bn_...", payment_method_types=["card"], currency="usd", @@ -36,9 +36,12 @@ async def create_spt(params): ) """ -from mpp.methods.stripe.client import StripeMethod, stripe -from mpp.methods.stripe.intents import ChargeIntent +from mpp.methods.stripe.client import StripeMethod as StripeMethod +from mpp.methods.stripe.client import spt as spt +from mpp.methods.stripe.client import stripe as stripe +from mpp.methods.stripe.intents import ChargeIntent as ChargeIntent from mpp.methods.stripe.machine_payments import DepositAddresses as DepositAddresses from mpp.methods.stripe.machine_payments import MachinePayments as MachinePayments from mpp.methods.stripe.machine_payments import create as create -from mpp.methods.stripe.schemas import ChargeRequest, StripeCredentialPayload +from mpp.methods.stripe.schemas import ChargeRequest as ChargeRequest +from mpp.methods.stripe.schemas import StripeCredentialPayload as StripeCredentialPayload diff --git a/src/mpp/methods/stripe/client.py b/src/mpp/methods/stripe/client.py index 244ed0cd..a159ff29 100644 --- a/src/mpp/methods/stripe/client.py +++ b/src/mpp/methods/stripe/client.py @@ -9,7 +9,9 @@ import time from collections.abc import Awaitable, Callable from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any +from functools import wraps +from typing import TYPE_CHECKING, Any, ParamSpec +from warnings import warn from mpp import Challenge, Credential from mpp.methods import CanOfferFn, PaymentSuccessHandler @@ -88,13 +90,13 @@ def transform_request( method_details = dict(request.get("methodDetails", {})) if self.network_id and "networkId" in method_details: if method_details["networkId"] != self.network_id: - raise ValueError("networkId does not match configured stripe() network_id") + raise ValueError("networkId does not match configured spt() network_id") if self.network_id: method_details["networkId"] = self.network_id if self.payment_method_types and "paymentMethodTypes" in method_details: if method_details["paymentMethodTypes"] != self.payment_method_types: raise ValueError( - "paymentMethodTypes does not match configured stripe() payment_method_types" + "paymentMethodTypes does not match configured spt() payment_method_types" ) if self.payment_method_types: method_details["paymentMethodTypes"] = self.payment_method_types @@ -128,7 +130,7 @@ async def create_credential(self, challenge: Challenge) -> Credential: payment_method = self.payment_method if not payment_method: - raise ValueError("payment_method is required (pass to stripe() or via context)") + raise ValueError("payment_method is required (pass to spt() or via context)") amount = str(request.get("amount", "")) currency = str(request.get("currency", "")) @@ -190,7 +192,7 @@ def _parse_iso_timestamp(iso_str: str) -> float: # ────────────────────────────────────────────────────────────────── -def stripe( +def spt( intents: Mapping[str, Intent | VerifiableIntent], create_token: CreateTokenFn | None = None, payment_method: str | None = None, @@ -229,10 +231,10 @@ def stripe( A configured :class:`StripeMethod` instance. Example: - from mpp.methods.stripe import stripe, ChargeIntent + from mpp.methods.stripe import spt, ChargeIntent # Server - method = stripe( + method = spt( network_id="bn_...", payment_method_types=["card"], currency="usd", @@ -241,7 +243,7 @@ def stripe( ) # Client - method = stripe( + method = spt( create_token=my_spt_proxy, payment_method="pm_card_visa", intents={"charge": ChargeIntent(secret_key="sk_...")}, @@ -262,3 +264,18 @@ def stripe( ) method._intents = dict(intents) return method + + +_P = ParamSpec("_P") + + +def _deprecated_alias(func: Callable[_P, StripeMethod]) -> Callable[_P, StripeMethod]: + @wraps(func) + def alias(*args: _P.args, **kwargs: _P.kwargs) -> StripeMethod: + warn("stripe() is deprecated; use spt()", DeprecationWarning, stacklevel=2) + return func(*args, **kwargs) + + return alias + + +stripe = _deprecated_alias(spt) diff --git a/src/mpp/methods/stripe/machine_payments.py b/src/mpp/methods/stripe/machine_payments.py index 20b8d7f6..377912ad 100644 --- a/src/mpp/methods/stripe/machine_payments.py +++ b/src/mpp/methods/stripe/machine_payments.py @@ -10,7 +10,7 @@ import mpp.methods.stripe.intents as stripe_intents from mpp.events import ServerPaymentSuccessPayload from mpp.methods import CanOfferFn -from mpp.methods.stripe.client import StripeMethod, stripe +from mpp.methods.stripe.client import StripeMethod, spt from mpp.methods.tempo._defaults import CHAIN_ID, TESTNET_CHAIN_ID if TYPE_CHECKING: @@ -50,7 +50,7 @@ def __init__( self._metadata = metadata def charge(self) -> StripeMethod: - return stripe( + return spt( intents={"charge": stripe_intents.ChargeIntent(client=self._client)}, currency="usd", recipient=self._network_id, diff --git a/src/mpp/methods/stripe/schemas.py b/src/mpp/methods/stripe/schemas.py index 55bf9994..549c5f4c 100644 --- a/src/mpp/methods/stripe/schemas.py +++ b/src/mpp/methods/stripe/schemas.py @@ -16,7 +16,7 @@ class StripeMethodDetails(BaseModel): class ChargeRequest(BaseModel): """Request schema for the Stripe charge intent. - After the transform in ``stripe()``, ``amount`` is in the smallest + After the transform in ``spt()``, ``amount`` is in the smallest currency unit (e.g. cents for USD) and ``decimals`` is removed. """ diff --git a/tests/test_method_hooks.py b/tests/test_method_hooks.py index 7a707f36..943a956d 100644 --- a/tests/test_method_hooks.py +++ b/tests/test_method_hooks.py @@ -9,7 +9,7 @@ from mpp import Challenge, Credential, Receipt from mpp.events import ServerPaymentSuccessPayload from mpp.methods import CanOfferFn, PaymentSuccessHandler -from mpp.methods.stripe import stripe +from mpp.methods.stripe import spt from mpp.methods.tempo import tempo from mpp.server import ComposedChallenges, Mpp, compose, intent from tests import MockRequest @@ -237,7 +237,7 @@ def can_offer(_request: dict[str, Any]) -> bool: def on_payment_success(_payload: ServerPaymentSuccessPayload) -> None: pass - stripe_method = stripe( + stripe_method = spt( intents={}, currency="usd", recipient="acct_123", diff --git a/tests/test_stripe.py b/tests/test_stripe.py index a04b3d74..faac57ac 100644 --- a/tests/test_stripe.py +++ b/tests/test_stripe.py @@ -3,8 +3,10 @@ from __future__ import annotations import base64 +import inspect import math import time +import warnings from dataclasses import dataclass from datetime import UTC, datetime, timedelta from typing import Any @@ -20,7 +22,7 @@ PaymentExpiredError, VerificationFailedError, ) -from mpp.methods.stripe import ChargeIntent, stripe +from mpp.methods.stripe import ChargeIntent, StripeMethod, spt, stripe from mpp.methods.stripe._defaults import MACHINE_PAYMENTS_API_VERSION from mpp.methods.stripe.client import OnChallengeParameters from mpp.methods.stripe.intents import _resolve_payment_intents @@ -107,6 +109,17 @@ def _make_challenge(**overrides: Any) -> Challenge: return Challenge(**defaults) +def test_stripe_is_deprecated_signature_preserving_alias(): + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + spt(intents={}) + with pytest.warns(DeprecationWarning, match=r"use spt\(\)"): + method = stripe(intents={}) + assert spt.__name__ == "spt" + assert isinstance(method, StripeMethod) + assert inspect.signature(stripe) == inspect.signature(spt) + + class TestStripeMethod: @pytest.mark.asyncio async def test_create_credential(self): @@ -119,7 +132,7 @@ async def fake_create_token(params: OnChallengeParameters) -> str: assert params.payment_method == "pm_card_visa" return "spt_test_abc" - method = stripe( + method = spt( create_token=fake_create_token, payment_method="pm_card_visa", intents={"charge": ChargeIntent(secret_key="sk_test_123")}, @@ -138,7 +151,7 @@ async def fake_create_token(params: OnChallengeParameters) -> str: assert params.external_id == "order-42" return "spt_test_abc" - method = stripe( + method = spt( create_token=fake_create_token, payment_method="pm_card_visa", intents={"charge": ChargeIntent(secret_key="sk_test_123")}, @@ -162,7 +175,7 @@ async def fake_create_token(params: OnChallengeParameters) -> str: @pytest.mark.asyncio async def test_create_credential_no_create_token_raises(self): - method = stripe( + method = spt( payment_method="pm_card_visa", intents={"charge": ChargeIntent(secret_key="sk_test_123")}, ) @@ -176,7 +189,7 @@ async def test_create_credential_no_payment_method_raises(self): async def fake_create_token(params: OnChallengeParameters) -> str: return "spt_test_abc" - method = stripe( + method = spt( create_token=fake_create_token, intents={"charge": ChargeIntent(secret_key="sk_test_123")}, ) @@ -192,7 +205,7 @@ async def test_create_credential_missing_network_id_raises(self): async def fake_create_token(params: OnChallengeParameters) -> str: return "spt_test_abc" - method = stripe( + method = spt( create_token=fake_create_token, payment_method="pm_card_visa", intents={"charge": ChargeIntent(secret_key="sk_test_123")}, @@ -215,7 +228,7 @@ async def test_create_credential_missing_payment_method_types_raises(self): async def fake_create_token(params: OnChallengeParameters) -> str: return "spt_test_abc" - method = stripe( + method = spt( create_token=fake_create_token, payment_method="pm_card_visa", intents={"charge": ChargeIntent(secret_key="sk_test_123")}, @@ -241,7 +254,7 @@ async def test_create_credential_rejects_metadata_external_id(self): async def fake_create_token(params: OnChallengeParameters) -> str: return "spt_test_abc" - method = stripe( + method = spt( create_token=fake_create_token, payment_method="pm_card_visa", intents={"charge": ChargeIntent(secret_key="sk_test_123")}, @@ -262,7 +275,7 @@ async def fake_create_token(params: OnChallengeParameters) -> str: await method.create_credential(challenge) def test_transform_request(self): - method = stripe( + method = spt( external_id="order-42", network_id="bn_test", payment_method_types=["card"], @@ -277,7 +290,7 @@ def test_transform_request(self): assert result["methodDetails"]["paymentMethodTypes"] == ["card"] def test_transform_request_rejects_network_id_override(self): - method = stripe( + method = spt( network_id="bn_default", payment_method_types=["card"], intents={"charge": ChargeIntent(secret_key="sk_test_123")}, @@ -293,7 +306,7 @@ def test_transform_request_rejects_network_id_override(self): method.transform_request(request, None) def test_transform_request_rejects_payment_method_types_override(self): - method = stripe( + method = spt( network_id="bn_test", payment_method_types=["card"], intents={"charge": ChargeIntent(secret_key="sk_test_123")}, @@ -309,14 +322,14 @@ def test_transform_request_rejects_payment_method_types_override(self): method.transform_request(request, None) def test_method_name(self): - method = stripe( + method = spt( intents={"charge": ChargeIntent(secret_key="sk_test_123")}, ) assert method.name == "stripe" def test_intents(self): intent = ChargeIntent(secret_key="sk_test_123") - method = stripe(intents={"charge": intent}) + method = spt(intents={"charge": intent}) assert method.intents["charge"] is intent @pytest.mark.asyncio @@ -328,7 +341,7 @@ async def fake_create_token(params: OnChallengeParameters) -> str: recorded_params.append(params) return "spt_test" - method = stripe( + method = spt( create_token=fake_create_token, payment_method="pm_card_visa", intents={"charge": ChargeIntent(secret_key="sk_test_123")}, @@ -350,7 +363,7 @@ async def fake_create_token(params: OnChallengeParameters) -> str: recorded_params.append(params) return "spt_test" - method = stripe( + method = spt( create_token=fake_create_token, payment_method="pm_card_visa", intents={"charge": ChargeIntent(secret_key="sk_test_123")}, @@ -893,13 +906,13 @@ async def test_context_manager_closes_owned_client(self): # ────────────────────────────────────────────────────────────────── -# Integration: stripe() factory +# Integration: spt() factory # ────────────────────────────────────────────────────────────────── class TestStripeFactory: def test_defaults(self): - method = stripe( + method = spt( intents={"charge": ChargeIntent(secret_key="sk_test")}, ) assert method.name == "stripe" @@ -908,7 +921,7 @@ def test_defaults(self): assert method.currency is None def test_custom_params(self): - method = stripe( + method = spt( intents={"charge": ChargeIntent(secret_key="sk_test")}, currency="eur", decimals=0, @@ -925,7 +938,7 @@ def test_custom_params(self): def test_no_secret_key_param(self): """Factory no longer accepts secret_key (removed per review).""" with pytest.raises(TypeError): - stripe( + spt( intents={"charge": ChargeIntent(secret_key="sk_test")}, secret_key="sk_test", # type: ignore[call-arg] ) diff --git a/tests/typecheck/compose_consumer.py b/tests/typecheck/compose_consumer.py index f03f8eac..11f6f60a 100644 --- a/tests/typecheck/compose_consumer.py +++ b/tests/typecheck/compose_consumer.py @@ -1,4 +1,4 @@ -# pyright: reportUnnecessaryTypeIgnoreComment=error +# pyright: reportPrivateImportUsage=error, reportUnnecessaryTypeIgnoreComment=error """Consumer-facing type probes for payment composition.""" from typing import assert_type @@ -9,7 +9,8 @@ from mpp import Challenge, Credential, Receipt from mpp.events import ServerPaymentSuccessPayload from mpp.methods import CanOfferFn, PaymentSuccessHandler -from mpp.methods.stripe import DepositAddresses, create +from mpp.methods import stripe as stripe_module +from mpp.methods.stripe import DepositAddresses, MachinePayments, create, spt, stripe from mpp.methods.tempo import ChargeIntent, tempo @@ -53,8 +54,13 @@ async def check_result() -> None: machine_payments = create( network_id="bn_test", livemode=False, client=stripe_client, deposit_addresses=deposit_addresses ) +assert_type(machine_payments, MachinePayments) machine_payments.spt.charge() machine_payments.tempo.charge() +spt(intents={}) +stripe(intents={}) +stripe_module.create(network_id="bn_test", livemode=False, client=stripe_client) +stripe(intents={}, unknown=True) # pyright: ignore[reportCallIssue] server_api.Mpp.create( methods=machine_payments.default_methods(), realm="example.com", secret_key="secret" ) From ad5fbc5712e370e310483f3ce7cfe111bd39b5e9 Mon Sep 17 00:00:00 2001 From: Ben Sandler Date: Wed, 2 Sep 2026 17:27:08 -0400 Subject: [PATCH 3/3] fix: enforce offer availability for implicit methods Route methods= through composition even with one method so offer availability hooks apply consistently. Preserve direct method= behavior and multi-method result shapes. Committed-By-Agent: codex Co-authored-by: codex --- src/mpp/server/mpp.py | 22 +++++++++++++++++----- tests/test_method_hooks.py | 13 ++++++++++++- tests/test_stripe_machine_payments.py | 27 +++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/mpp/server/mpp.py b/src/mpp/server/mpp.py index 7a445af0..0ebe1296 100644 --- a/src/mpp/server/mpp.py +++ b/src/mpp/server/mpp.py @@ -24,7 +24,13 @@ Unsubscribe, ) from mpp.server._defaults import detect_realm, detect_secret_key -from mpp.server.compose import ComposedHandler, ComposedResult, ComposeEntry, ComposeOptions +from mpp.server.compose import ( + ComposedChallenges, + ComposedHandler, + ComposedResult, + ComposeEntry, + ComposeOptions, +) from mpp.server.decorator import ( BodyParamsType, bind_framework_scope, @@ -123,6 +129,7 @@ def __init__( self.defaults = defaults or {} self.requires_auth = requires_auth self.credential_header = PAYMENT_AUTHORIZATION_HEADER if requires_auth else None + self._compose_implicit_methods = False self._events = EventDispatcher() self._register_method_payment_success_handler(method) @@ -453,8 +460,10 @@ def create( store=store, requires_auth=requires_auth, ) - if len(configured) > 1: + server._compose_implicit_methods = methods is not None + if methods is not None: server.methods = configured + if len(configured) > 1: if store is not None: server._wire_store(store) for configured_method in configured[1:]: @@ -541,11 +550,14 @@ async def charge( "chain_id": chain_id, "extra": extra, } - if len(methods) > 1: - return await self.compose( + if self._compose_implicit_methods: + result = await self.compose( *((method, options) for method in methods), body=body, ).verify(self.payment_credential_value(authorization, payment_authorization)) + if len(methods) == 1 and isinstance(result, ComposedChallenges): + return result.challenges[0] + return result intent, request, challenge_expires = self._build_offer_request( methods[0], "charge", options, None, api_name="charge" @@ -626,7 +638,7 @@ async def session_handler(request, credential, receipt): "chain_id": chain_id, "extra": extra, } - if len(methods) > 1: + if self._compose_implicit_methods: return self.compose( *((f"{method.name}/{intent}", options) for method in methods), body=body, diff --git a/tests/test_method_hooks.py b/tests/test_method_hooks.py index 943a956d..362c0b31 100644 --- a/tests/test_method_hooks.py +++ b/tests/test_method_hooks.py @@ -107,7 +107,7 @@ def can_offer(request: dict[str, Any]) -> bool: return request["amount"] == "200" method = ThirdPartyMethod("only", can_offer=can_offer) - server = create_server(method) + server = Mpp.create(method=method, realm="api.example.com", secret_key="secret") direct = await server.charge(None, "1.00") assert isinstance(direct, Challenge) @@ -128,6 +128,17 @@ def can_offer(request: dict[str, Any]) -> bool: assert amounts == ["100", "200"] +@pytest.mark.asyncio +async def test_implicit_charge_keeps_composed_result_when_one_method_is_filtered() -> None: + first = ThirdPartyMethod("first", can_offer=lambda _request: False) + second = ThirdPartyMethod("second", can_offer=lambda _request: True) + + result = await create_server(first, second).charge(None, "1.00") + + assert isinstance(result, ComposedChallenges) + assert [challenge.method for challenge in result.challenges] == ["second"] + + @pytest.mark.asyncio async def test_can_offer_rejects_invalid_results_and_propagates_errors() -> None: non_callable = ThirdPartyMethod("non-callable", can_offer=cast(CanOfferFn, object())) diff --git a/tests/test_stripe_machine_payments.py b/tests/test_stripe_machine_payments.py index d1e55c75..4533bd19 100644 --- a/tests/test_stripe_machine_payments.py +++ b/tests/test_stripe_machine_payments.py @@ -10,6 +10,8 @@ from mpp.methods.stripe import MachinePayments, create from mpp.methods.stripe import _defaults as stripe_defaults from mpp.methods.tempo._defaults import CHAIN_ID, PATH_USD, TESTNET_CHAIN_ID, USDC +from mpp.server import Mpp +from tests import MockRequest TEMPO_ADDRESS = "0x" + "1" * 40 @@ -90,6 +92,31 @@ def test_methods_filter_amounts_below_stripe_minima() -> None: assert tempo_offer({"amount": "10000"}) +@pytest.mark.asyncio +async def test_spt_only_defaults_enforce_minimum_through_implicit_handlers() -> None: + _, payments = make_payments() + server = Mpp.create( + methods=payments.default_methods(), + realm="api.example.com", + secret_key="secret", + ) + + with pytest.raises(ValueError, match="No payment offers"): + await server.charge(None, "0.49") + assert isinstance(await server.charge(None, "0.50"), Challenge) + + @server.pay(amount="0.49") + async def endpoint( + _request: MockRequest, + _credential: Credential, + _receipt: Receipt, + ) -> None: + return None + + with pytest.raises(ValueError, match="No payment offers"): + await endpoint(MockRequest(path="/paid")) + + def test_configuration_boundary_errors() -> None: with pytest.raises(TypeError, match="Unsupported Stripe client"): make_payments(client=object())