diff --git a/src/mpp/methods/stripe/intents.py b/src/mpp/methods/stripe/intents.py index 1a04332..4e95632 100644 --- a/src/mpp/methods/stripe/intents.py +++ b/src/mpp/methods/stripe/intents.py @@ -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): @@ -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, ) diff --git a/tests/test_stripe.py b/tests/test_stripe.py index f27fcb4..7a28b49 100644 --- a/tests/test_stripe.py +++ b/tests/test_stripe.py @@ -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: @@ -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): @@ -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"