Skip to content

Commit 56da9b9

Browse files
committed
feat: implement idempotency key generation for Stripe checkout sessions to prevent duplicate charges
1 parent d317322 commit 56da9b9

2 files changed

Lines changed: 103 additions & 4 deletions

File tree

love_backend/apps/payments/services.py

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
99
All money-moving calls pass an idempotency key so retries never double-charge.
1010
"""
11+
import hashlib
12+
import json
1113
import logging
1214
import 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+
96135
def _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

love_backend/apps/payments/tests.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,52 @@ def test_checkout_rejects_empty_email(self, create_session):
507507
create_session.assert_not_called()
508508

509509

510+
class CheckoutIdempotencyKeyTests(TestCase):
511+
def setUp(self):
512+
charity = Charity.objects.create(
513+
name="C", slug="c-idem", verification_status=Charity.VERIFIED,
514+
)
515+
self.donation = Donation.objects.create(
516+
charity=charity,
517+
donor_name="D",
518+
donor_email="[email protected]",
519+
amount=Decimal("20.00"),
520+
message="hi",
521+
)
522+
523+
def _key(self, **overrides):
524+
defaults = dict(
525+
donation=self.donation,
526+
amount_minor=2000,
527+
currency="eur",
528+
destination="acct_test",
529+
fee_minor=0,
530+
locale="en",
531+
success_url="http://localhost:5173/confirmation?session_id={CHECKOUT_SESSION_ID}",
532+
cancel_url="http://localhost:5173/donate?canceled=1",
533+
)
534+
defaults.update(overrides)
535+
return services._checkout_idempotency_key(**defaults)
536+
537+
def test_same_params_same_key(self):
538+
self.assertEqual(self._key(), self._key())
539+
540+
def test_locale_change_changes_key(self):
541+
self.assertNotEqual(self._key(locale="en"), self._key(locale="es"))
542+
543+
def test_different_donation_rows_differ_even_if_pk_reused(self):
544+
other = Donation.objects.create(
545+
charity=self.donation.charity,
546+
donor_name="D2",
547+
donor_email="[email protected]",
548+
amount=Decimal("20.00"),
549+
)
550+
self.assertNotEqual(
551+
self._key(donation=self.donation),
552+
self._key(donation=other),
553+
)
554+
555+
510556
class ReconciliationTests(TestCase):
511557
def test_confirmed_without_ledger_flags_issue(self):
512558
donation = _make_donation()

0 commit comments

Comments
 (0)