Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions .changelog/stripe-machine-payments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pympp: patch
---

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.
22 changes: 14 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -197,6 +197,9 @@ jobs:
tempo)
INSTALL_TARGET="${WHEEL}[tempo]"
;;
stripe)
INSTALL_TARGET="${WHEEL}[stripe]"
;;
mcp)
INSTALL_TARGET="${WHEEL}[mcp]"
;;
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions examples/stripe/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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={},
Expand Down
4 changes: 2 additions & 2 deletions examples/stripe/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@
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")

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",
Expand Down
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
22 changes: 14 additions & 8 deletions src/mpp/methods/stripe/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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={},
Expand All @@ -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",
Expand All @@ -36,6 +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.schemas import ChargeRequest, StripeCredentialPayload
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 as ChargeRequest
from mpp.methods.stripe.schemas import StripeCredentialPayload as StripeCredentialPayload
2 changes: 2 additions & 0 deletions src/mpp/methods/stripe/_defaults.py
Original file line number Diff line number Diff line change
@@ -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"'
39 changes: 31 additions & 8 deletions src/mpp/methods/stripe/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -70,6 +72,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]:
Expand All @@ -87,16 +90,18 @@ 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
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
Expand Down Expand Up @@ -125,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", ""))
Expand Down Expand Up @@ -187,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,
Expand All @@ -199,6 +204,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.

Expand All @@ -219,15 +225,16 @@ 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.

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",
Expand All @@ -236,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_...")},
Expand All @@ -253,6 +260,22 @@ 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


_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)
32 changes: 19 additions & 13 deletions src/mpp/methods/stripe/intents.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@
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 (
PaymentActionRequiredError,
PaymentExpiredError,
VerificationFailedError,
)
from mpp.methods.stripe._defaults import STRIPE_API_BASE
from mpp.methods.stripe.schemas import ChargeRequest, StripeCredentialPayload


Expand All @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Comment on lines +234 to +237

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Send attribution through a supported Stripe option

For installations allowed by the new stripe>=8.0.0 dependency, stripe-python's request options do not support a headers entry, so this value is ignored rather than emitted as X-Request-Source; the Tempo recording path uses the same unsupported option. The fake clients only assert that the dictionary was received and therefore miss that real SDK requests are not attributed. Use a Stripe SDK mechanism that actually forwards the custom header, or raise the minimum Stripe version to one that explicitly supports it.

Useful? React with 👍 / 👎.

}

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
Expand Down Expand Up @@ -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,
)
Expand Down
Loading
Loading