88
99All money-moving calls pass an idempotency key so retries never double-charge.
1010"""
11+ import hashlib
12+ import json
1113import logging
1214import os
1315
@@ -93,6 +95,43 @@ def _application_fee_minor(amount_minor: int) -> int:
9395 return (amount_minor * settings .PLATFORM_FEE_BPS ) // 10000
9496
9597
98+ def _checkout_idempotency_key (
99+ donation : Donation ,
100+ * ,
101+ amount_minor : int ,
102+ currency : str ,
103+ destination : str ,
104+ fee_minor : int ,
105+ locale : str ,
106+ success_url : str ,
107+ cancel_url : str ,
108+ ) -> str :
109+ """
110+ Stripe idempotency keys are scoped to the exact request body.
111+
112+ Using only donation.pk collides in CI: e2e_prepare imports a fixed CSV so each
113+ run often creates donation id=28, while Stripe remembers checkout-donation-28
114+ from an earlier job with different session params (locale, URLs, fees).
115+ """
116+ payload = {
117+ "donation_id" : donation .pk ,
118+ "created_at" : donation .created_at .isoformat () if donation .created_at else "" ,
119+ "amount_minor" : amount_minor ,
120+ "currency" : currency ,
121+ "charity_id" : donation .charity_id ,
122+ "destination" : destination ,
123+ "fee_minor" : fee_minor ,
124+ "locale" : locale ,
125+ "success_url" : success_url ,
126+ "cancel_url" : cancel_url ,
127+ "customer_email" : donation .donor_email or "" ,
128+ }
129+ digest = hashlib .sha256 (
130+ json .dumps (payload , sort_keys = True , separators = ("," , ":" )).encode ()
131+ ).hexdigest ()[:24 ]
132+ return f"checkout-donation-{ donation .pk } -{ digest } "
133+
134+
96135def _resolve_payout_for_checkout (charity : Charity ) -> PayoutAccount :
97136 """Return a payout row that exists in Stripe and can receive destination charges."""
98137 payout = getattr (charity , "payout_account" , None )
@@ -166,11 +205,25 @@ def create_checkout_session(donation: Donation) -> str:
166205 if fee_minor > 0 :
167206 payment_intent_data ["application_fee_amount" ] = fee_minor
168207
208+ locale = os .environ .get ("STRIPE_CHECKOUT_LOCALE" , "en" )
209+ success_url = f"{ settings .FRONTEND_URL } /confirmation?session_id={{CHECKOUT_SESSION_ID}}"
210+ cancel_url = f"{ settings .FRONTEND_URL } /donate?canceled=1"
211+ idempotency_key = _checkout_idempotency_key (
212+ donation ,
213+ amount_minor = amount_minor ,
214+ currency = currency ,
215+ destination = payout .stripe_account_id ,
216+ fee_minor = fee_minor ,
217+ locale = locale ,
218+ success_url = success_url ,
219+ cancel_url = cancel_url ,
220+ )
221+
169222 session = s .checkout .Session .create (
170223 mode = "payment" ,
171- locale = os . environ . get ( "STRIPE_CHECKOUT_LOCALE" , "en" ) ,
172- success_url = f" { settings . FRONTEND_URL } /confirmation?session_id={{CHECKOUT_SESSION_ID}}" ,
173- cancel_url = f" { settings . FRONTEND_URL } /donate?canceled=1" ,
224+ locale = locale ,
225+ success_url = success_url ,
226+ cancel_url = cancel_url ,
174227 customer_email = donation .donor_email or None ,
175228 line_items = [{
176229 "quantity" : 1 ,
@@ -185,6 +238,6 @@ def create_checkout_session(donation: Donation) -> str:
185238 }],
186239 payment_intent_data = payment_intent_data ,
187240 metadata = {"donation_id" : str (donation .id ), "campaign" : donation .campaign .slug if donation .campaign else "" },
188- idempotency_key = f"checkout-donation- { donation . id } " ,
241+ idempotency_key = idempotency_key ,
189242 )
190243 return session .url
0 commit comments