Skip to content
Open
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
9 changes: 7 additions & 2 deletions src/mpp/methods/stripe/intents.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,12 @@ async def _create_with_client(
"payment_method_types": list(request.methodDetails.paymentMethodTypes),
"shared_payment_granted_token": spt,
}
options = {"idempotency_key": f"mppx_{challenge_id}_{spt}"}
# Key idempotency on the challenge alone. The challenge id is HMAC-bound
# to the charge parameters (amount/currency/recipient/realm), so it is the
# stable identity of the charge. Including the (single-use, regenerated)
# SPT would make every retry a fresh key and let Stripe create a second
# PaymentIntent for the same logical charge -> double charge.
options = {"idempotency_key": f"mppx_{challenge_id}"}

create_async = getattr(payment_intents, "create_async", None)
if callable(create_async):
Expand Down Expand Up @@ -265,7 +270,7 @@ async def _create_with_secret_key(
headers={
"Authorization": f"Basic {auth_value}",
"Content-Type": "application/x-www-form-urlencoded",
"Idempotency-Key": f"mppx_{challenge_id}_{spt}",
"Idempotency-Key": f"mppx_{challenge_id}",
},
data=body,
)
Expand Down
10 changes: 7 additions & 3 deletions tests/test_stripe.py
Original file line number Diff line number Diff line change
Expand Up @@ -653,7 +653,11 @@ class CapturingClient:

@pytest.mark.asyncio
async def test_idempotency_key(self):
"""Verify idempotency key format matches mppx."""
"""Idempotency key is bound to the challenge id only, not the SPT.

The SPT is single-use and regenerated on retry; folding it into the
key would defeat idempotency and allow a duplicate charge.
"""
captured: list[tuple[tuple, dict]] = []

class CapturingIntents:
Expand All @@ -669,7 +673,7 @@ class CapturingClient:
await intent.verify(credential, SAMPLE_REQUEST)

options = captured[0][1]["options"]
assert options["idempotency_key"] == "mppx_test-challenge-id_spt_test_xyz"
assert options["idempotency_key"] == "mppx_test-challenge-id"

@pytest.mark.asyncio
async def test_client_request_body_is_first_positional_arg(self):
Expand Down Expand Up @@ -757,7 +761,7 @@ async def test_verify_with_secret_key_success(self):
headers = call_kwargs.kwargs["headers"]
expected_auth = base64.b64encode(b"sk_test_raw:").decode()
assert headers["Authorization"] == f"Basic {expected_auth}"
assert headers["Idempotency-Key"] == "mppx_test-challenge-id_spt_test_abc"
assert headers["Idempotency-Key"] == "mppx_test-challenge-id"

data = call_kwargs.kwargs["data"]
assert data["amount"] == "150"
Expand Down