From ed9c8d91d1cb7279245213601e579cf6dd1e2934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A6var=20=C3=96fj=C3=B6r=C3=B0=20Magn=C3=BAsson?= Date: Wed, 14 Oct 2026 14:22:35 +0000 Subject: [PATCH] Create the payment in payment_changed if payment.created hasn't arrived When a stored card is charged it settles about a second after it is created, so the payment.changed webhook can be handled before payment.created has saved the local Payment. payment_changed then raised Payment.DoesNotExist, which also stopped any handlers registered after it. Use get_or_create like payment_created does, so whichever webhook comes first creates the row. Version 0.1.26. Co-Authored-By: Claude Opus 5.5 --- askell/webhook_handlers.py | 5 +++- release.sh | 2 +- setup.py | 2 +- test_project/test_app/tests.py | 42 ++++++++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/askell/webhook_handlers.py b/askell/webhook_handlers.py index 8a556ef..99487f1 100644 --- a/askell/webhook_handlers.py +++ b/askell/webhook_handlers.py @@ -11,7 +11,10 @@ def payment_created(request, event, data): def payment_changed(request, event, data): from .models import Payment if event == 'payment.changed': - payment = Payment.objects.get(uuid=data['uuid']) + # payment.changed can arrive before payment.created has been handled, + # e.g. when a stored card is charged and settles right away. + payment_data = {key: data[key] for key in Payment.KEYS_TO_COPY} + payment, _ = Payment.objects.get_or_create(uuid=data['uuid'], defaults=payment_data) for attr, val in data.items(): setattr(payment, attr, val) payment.save() diff --git a/release.sh b/release.sh index 4e5ce85..6dc3640 100755 --- a/release.sh +++ b/release.sh @@ -1,7 +1,7 @@ #!/usr/bin/env sh set -eu -VERSION="0.1.25" +VERSION="0.1.26" python3 -m twine check "dist/django_askell-${VERSION}"* python3 -m twine upload "dist/django_askell-${VERSION}"* diff --git a/setup.py b/setup.py index 2cb213b..1bed26a 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ setup( name='django-askell', - version='0.1.25', + version='0.1.26', packages=find_packages(exclude=['test_project', 'test_project.*']), include_package_data=True, license='MIT', diff --git a/test_project/test_app/tests.py b/test_project/test_app/tests.py index f311998..06291e0 100644 --- a/test_project/test_app/tests.py +++ b/test_project/test_app/tests.py @@ -1,6 +1,11 @@ +import base64 +import hashlib +import hmac import http.client +import json from unittest.mock import patch +from django.conf import settings from django.test import TestCase from django.test.client import Client from django.contrib.auth.models import User @@ -180,3 +185,40 @@ def test_webhook_payment_settled(self): self.assertEqual(payment_count_before, payment_count_after) payment.refresh_from_db() self.assertTrue(payment.state == "settled") + + def post_webhook(self, event, data): + body = json.dumps({'event': event, 'ref': None, 'sender': 'tumi@overcast.is', 'data': data}).encode() + digest = hmac.new(settings.ASKELL_WEBHOOK_SECRET.encode(), body, hashlib.sha512).digest() + headers = { + 'Hook-Hmac': base64.b64encode(digest).decode(), + 'Hook-Event': event, + } + return self.client.post('/askell/webhook/', data=body, headers=headers, content_type='application/json') + + def test_webhook_payment_changed_before_payment_created(self): + # A payment charged straight away can settle before its payment.created + # webhook has been handled, so payment.changed may arrive first. + data = { + "uuid": "5f0c1e2a-7d4b-4c55-9a51-3f0f5b0c2d11", + "amount": "2000.0000", + "currency": "ISK", + "description": "wesd", + "reference": "q324erdf2", + "state": "settled", + "created_at": "2024-02-16T12:52:36.334898Z", + "updated_at": "2024-02-16T12:52:37.450551Z", + "transactions": [], + } + response = self.post_webhook('payment.changed', data) + self.assertEqual(response.status_code, 200) + + payment = Payment.objects.get(uuid=data['uuid']) + self.assertEqual(payment.state, 'settled') + self.assertEqual(payment.reference, 'q324erdf2') + + # The late payment.created must not reset the payment to pending. + response = self.post_webhook('payment.created', dict(data, state='pending')) + self.assertEqual(response.status_code, 200) + + payment.refresh_from_db() + self.assertEqual(payment.state, 'settled')