Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixed the atomic transaction issue #1768

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
770ae28
Fixed incorrect pluralization of LocalDjangoCommunity in admin - Issu…
arpan8925 Nov 16, 2024
b051a8d
Merge pull request #1 from arpan8925/arpan8925-issue1739
arpan8925 Nov 16, 2024
2b5ac70
Merge branch 'django:main' into main
arpan8925 Nov 20, 2024
6b3ec85
update
arpan8925 Nov 23, 2024
a44d056
atomic transaction issue - fixed
arpan8925 Nov 23, 2024
276b6fd
Update
arpan8925 Nov 23, 2024
656e5f7
Merge pull request #2 from arpan8925/Issue-1764
arpan8925 Nov 23, 2024
f3ab5ef
Merge branch 'django:main' into arpan8925---issue1764
arpan8925 Nov 23, 2024
f2f4632
Merge pull request #3 from arpan8925/arpan8925---issue1764
arpan8925 Nov 23, 2024
4075d2a
Merge branch 'main' of https://github.com/arpan8925/djangoproject.com
arpan8925 Nov 23, 2024
bd1d979
fix the atomic transaction issue
arpan8925 Nov 23, 2024
bcfc89d
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 23, 2024
e231f3e
Fixed Issue 1764
arpan8925 Nov 24, 2024
7bb55a7
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 24, 2024
0767339
test_webhook, Tested & Working
arpan8925 Nov 25, 2024
e428c56
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 25, 2024
26eb17e
Teseted tox locally
arpan8925 Nov 25, 2024
646ec8c
Merge branch 'arpan8925-issue1764' of https://github.com/arpan8925/dj…
arpan8925 Nov 25, 2024
ea2e7b8
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 25, 2024
2f7f069
Update views.py
arpan8925 Nov 25, 2024
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
134 changes: 134 additions & 0 deletions fundraising/tests/test_webhook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import json
from unittest.mock import patch

import stripe
from django.conf import settings
from django.core import mail
from django.test import TestCase
from django.urls import reverse
from django_hosts.resolvers import reverse as django_hosts_reverse
from stripe import util as stripe_util

from ..models import DjangoHero, Donation, Payment


class TestWebhooks(TestCase):
def setUp(self):
self.hero = DjangoHero.objects.create(email="[email protected]")
self.donation = Donation.objects.create(
donor=self.hero,
interval="monthly",
stripe_customer_id="cus_3MXPY5pvYMWTBf",
stripe_subscription_id="sub_3MXPaZGXvVZSrS",
)
self.url = reverse("fundraising:receive-webhook")

def stripe_data(self, filename):
file_path = settings.BASE_DIR.joinpath(f"fundraising/test_data/{filename}.json")
with file_path.open() as f:
data = json.load(f)
return stripe.util.convert_to_stripe_object(data, stripe.api_key, None)

def post_event(self):
return self.client.post(
self.url,
data='{"id": "evt_12345"}',
content_type="application/json",
)

@patch("stripe.Event.retrieve")
def test_record_payment(self, event):
event.return_value = self.stripe_data("invoice_succeeded")
response = self.post_event()
self.assertEqual(response.status_code, 201)
self.assertEqual(self.donation.payment_set.count(), 1)
payment = self.donation.payment_set.first()
self.assertEqual(payment.amount, 10)

@patch("stripe.Event.retrieve")
def test_subscription_cancelled(self, event):
event.return_value = self.stripe_data("subscription_cancelled")
self.post_event()
donation = Donation.objects.get(id=self.donation.id)
self.assertEqual(donation.stripe_subscription_id, "")
self.assertEqual(len(mail.outbox), 1)
expected_url = django_hosts_reverse("fundraising:index")
self.assertTrue(expected_url in mail.outbox[0].body)

@patch("stripe.Event.retrieve")
def test_payment_failed(self, event):
event.return_value = self.stripe_data("payment_failed")
self.post_event()
self.assertEqual(len(mail.outbox), 1)
expected_url = django_hosts_reverse(
"fundraising:manage-donations", kwargs={"hero": self.hero.id}
)
self.assertTrue(expected_url in mail.outbox[0].body)

@patch("stripe.Event.retrieve")
def test_no_such_event(self, event):
event.side_effect = stripe.error.InvalidRequestError(
message="No such event: evt_12345", param="id"
)
response = self.post_event()
self.assertEqual(response.status_code, 422)

@patch("stripe.Event.retrieve")
def test_empty_object(self, event):
event.return_value = self.stripe_data("empty_payment")
response = self.post_event()
self.assertEqual(response.status_code, 422)

@patch("stripe.Event.retrieve")
@patch("stripe.Customer.retrieve")
@patch("stripe.PaymentIntent.retrieve")
def test_checkout_session_completed_atomic(
self, mock_payment_intent, mock_customer, mock_event
):
session_data = {
"id": "cs_test_123",
"customer": "cus_123",
"amount_total": 5000, # $50.00
"payment_intent": "pi_123",
"mode": "payment",
"subscription": None,
}

# Create proper Stripe event structure
event_data = {
"type": "checkout.session.completed",
"data": {
"object": stripe_util.convert_to_stripe_object(
session_data, stripe.api_key, None
)
},
}
mock_event.return_value = stripe_util.convert_to_stripe_object(
event_data, stripe.api_key, None
)

mock_customer.return_value = stripe_util.convert_to_stripe_object(
{
"id": "cus_123",
"email": "[email protected]",
},
stripe.api_key,
None,
)

mock_payment_intent.return_value.charges.data = [
stripe_util.convert_to_stripe_object({"id": "ch_123"}, stripe.api_key, None)
]

# Mock Donation.objects.create to raise an exception
with patch("fundraising.models.Donation.objects.create") as mock_create:
mock_create.side_effect = Exception("Database error")

response = self.client.post(
self.url, data='{"id":"evt_123"}', content_type="application/json"
)

self.assertEqual(response.status_code, 500)
self.assertEqual(DjangoHero.objects.count(), 1) # Only the one from setUp
self.assertEqual(Donation.objects.count(), 1) # Only the one from setUp
self.assertEqual(Payment.objects.count(), 0)
115 changes: 67 additions & 48 deletions fundraising/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.conf import settings
from django.contrib import messages
from django.core.mail import send_mail
from django.db import IntegrityError, transaction
from django.forms.models import modelformset_factory
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
Expand Down Expand Up @@ -191,14 +192,13 @@ def receive_webhook(request):
try:
data = json.loads(request.body.decode())
except ValueError:
return HttpResponse(422)
return HttpResponse(status=422)

# For security, re-request the event object from Stripe.
# TODO: Verify shared secret here?
try:
event = stripe.Event.retrieve(data["id"])
except stripe.error.InvalidRequestError:
return HttpResponse(422)
return HttpResponse(status=422)

return WebhookHandler(event).handle()

Expand All @@ -217,7 +217,15 @@ def handle(self):
handler = handlers.get(self.event.type, lambda: HttpResponse(422))
if not self.event.data.object:
return HttpResponse(status=422)
return handler()
try:
with transaction.atomic():
return handler()
except IntegrityError as e:
logger.error(f"Integrity error in webhook handler: {e}")
return HttpResponse(status=500)
except Exception as e:
logger.error(f"Error in webhook handler: {e}")
return HttpResponse(status=500)

def payment_succeeded(self):
invoice = self.event.data.object
Expand Down Expand Up @@ -291,50 +299,61 @@ def get_donation_interval(self, session):
return "monthly"

def checkout_session_completed(self):
"""
> Occurs when a Checkout Session has been successfully completed.
https://stripe.com/docs/api/events/types#event_types-checkout.session.completed
"""
session = self.event.data.object
# TODO: remove stripe_version when updating account settings.
customer = stripe.Customer.retrieve(
session.customer, stripe_version="2020-08-27"
)
hero, _created = DjangoHero.objects.get_or_create(
stripe_customer_id=customer.id,
defaults={
"email": customer.email,
},
)
interval = self.get_donation_interval(session)
dollar_amount = decimal.Decimal(session.amount_total / 100).quantize(
decimal.Decimal(".01"), rounding=decimal.ROUND_HALF_UP
)
donation = Donation.objects.create(
donor=hero,
stripe_customer_id=customer.id,
receipt_email=customer.email,
subscription_amount=dollar_amount,
interval=interval,
stripe_subscription_id=session.subscription or "",
)
if interval == "onetime":
payment_intent = stripe.PaymentIntent.retrieve(session.payment_intent)
charge = payment_intent.charges.data[0]
donation.payment_set.create(
amount=dollar_amount,
stripe_charge_id=charge.id,
try:
"""
> Occurs when a Checkout Session has been successfully completed.
https://stripe.com/docs/api/events/types#event_types-checkout.session.completed

"""
session = self.event.data.object
if isinstance(session, dict):
session = stripe.util.convert_to_stripe_object(
session, stripe.api_key, None
)

# TODO: remove stripe_version when updating account settings.
customer = stripe.Customer.retrieve(
session.get("customer"), stripe_version="2020-08-27"
)
hero, _created = DjangoHero.objects.get_or_create(
stripe_customer_id=customer.id,
defaults={
"email": customer.email,
},
)
interval = self.get_donation_interval(session)
dollar_amount = decimal.Decimal(session.amount_total / 100).quantize(
decimal.Decimal(".01"), rounding=decimal.ROUND_HALF_UP
)
donation = Donation.objects.create(
donor=hero,
stripe_customer_id=customer.id,
receipt_email=customer.email,
subscription_amount=dollar_amount,
interval=interval,
stripe_subscription_id=session.subscription or "",
)
if interval == "onetime":
payment_intent = stripe.PaymentIntent.retrieve(session.payment_intent)
charge = payment_intent.charges.data[0]
donation.payment_set.create(
amount=dollar_amount,
stripe_charge_id=charge.id,
)

# Send an email message about managing your donation
message = render_to_string(
"fundraising/email/thank-you.html", {"donation": donation}
)
send_mail(
_("Thank you for your donation to the Django Software Foundation"),
message,
settings.FUNDRAISING_DEFAULT_FROM_EMAIL,
[donation.receipt_email],
)

# Send an email message about managing your donation
message = render_to_string(
"fundraising/email/thank-you.html", {"donation": donation}
)
send_mail(
_("Thank you for your donation to the Django Software Foundation"),
message,
settings.FUNDRAISING_DEFAULT_FROM_EMAIL,
[donation.receipt_email],
)
return HttpResponse(status=204)

return HttpResponse(status=204)
except IntegrityError as e:
logger.error(f"Integrity error during checkout session: {e}")
return HttpResponse(status=500)