From a9056a008bb6502dedbbda03504e2119871b33ea Mon Sep 17 00:00:00 2001 From: Livio Bieri Date: Sun, 26 Nov 2023 22:28:45 +0100 Subject: [PATCH] feat: datatrans cleanup & tests --- .../0010_alter_checkoutinformation_state.py | 25 ++ server/vbv_lernwelt/shop/models.py | 6 +- server/vbv_lernwelt/shop/services.py | 1 - .../shop/tests/test_checkout_api.py | 119 +++++++++ ...gnature.py => test_datatrans_signature.py} | 8 +- .../shop/tests/test_datatrans_webhook.py | 252 ++++++++++++++++++ server/vbv_lernwelt/shop/views.py | 97 ++++--- 7 files changed, 467 insertions(+), 41 deletions(-) create mode 100644 server/vbv_lernwelt/shop/migrations/0010_alter_checkoutinformation_state.py rename server/vbv_lernwelt/shop/tests/{test_create_signature.py => test_datatrans_signature.py} (89%) create mode 100644 server/vbv_lernwelt/shop/tests/test_datatrans_webhook.py diff --git a/server/vbv_lernwelt/shop/migrations/0010_alter_checkoutinformation_state.py b/server/vbv_lernwelt/shop/migrations/0010_alter_checkoutinformation_state.py new file mode 100644 index 00000000..bbaf9b47 --- /dev/null +++ b/server/vbv_lernwelt/shop/migrations/0010_alter_checkoutinformation_state.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.20 on 2023-11-26 19:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("shop", "0009_alter_checkoutinformation_product_price"), + ] + + operations = [ + migrations.AlterField( + model_name="checkoutinformation", + name="state", + field=models.CharField( + choices=[ + ("initialized", "Initialized"), + ("paid", "Paid"), + ("canceled", "Canceled"), + ("failed", "Failed"), + ], + max_length=50, + ), + ), + ] diff --git a/server/vbv_lernwelt/shop/models.py b/server/vbv_lernwelt/shop/models.py index f577b13a..21bfb0f0 100644 --- a/server/vbv_lernwelt/shop/models.py +++ b/server/vbv_lernwelt/shop/models.py @@ -58,6 +58,9 @@ class CheckoutState(models.TextChoices): """ The state of a checkout process transaction. + PAID: Datatrans transaction settled/transmitted. + Rest of the states are self-explanatory, same as in Datatrans docs. + 1) We use the `autoSettle` feature of DataTrans! Therefore, there are less possible states: -> https://docs.datatrans.ch/docs/after-the-payment -> https://api-reference.datatrans.ch/#tag/v1transactions/operation/status @@ -69,8 +72,7 @@ class CheckoutState(models.TextChoices): """ INITIALIZED = "initialized" - SETTLED = "settled" - TRANSMITTED = "transmitted" + PAID = "paid" CANCELED = "canceled" FAILED = "failed" diff --git a/server/vbv_lernwelt/shop/services.py b/server/vbv_lernwelt/shop/services.py index 20a16c46..2c751ec4 100644 --- a/server/vbv_lernwelt/shop/services.py +++ b/server/vbv_lernwelt/shop/services.py @@ -64,7 +64,6 @@ def init_transaction( payload = { # We use autoSettle=True, so that we don't have to settle the transaction: # -> Be aware that autoSettle has implications of the possible transaction states - # -> see CheckoutState docstring. "autoSettle": True, "amount": amount_chf_centimes, "currency": "CHF", diff --git a/server/vbv_lernwelt/shop/tests/test_checkout_api.py b/server/vbv_lernwelt/shop/tests/test_checkout_api.py index ccdd42d9..e349fbab 100644 --- a/server/vbv_lernwelt/shop/tests/test_checkout_api.py +++ b/server/vbv_lernwelt/shop/tests/test_checkout_api.py @@ -147,3 +147,122 @@ class CheckoutAPITestCase(APITestCase): 0, CheckoutInformation.objects.count(), ) + + def test_checkout_already_paid(self): + # GIVEN + CheckoutInformation.objects.create( + user=self.user, + product_sku=VV_PRODUCT_SKU, + product_price=0, + state=CheckoutState.PAID.value, + ) + + # WHEN + response = self.client.post( + path=reverse("checkout-vv"), + format="json", + data={ + "redirect_url": REDIRECT_URL, + "address": TEST_ADDRESS_DATA, + }, + ) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + "/", + response.json()["next_step_url"], + ) + + def test_checkout_double_checkout(self): + # GIVEN + transaction_id = "1234567890" + + CheckoutInformation.objects.create( + user=self.user, + product_sku=VV_PRODUCT_SKU, + product_price=0, + state=CheckoutState.INITIALIZED.value, + transaction_id=transaction_id, + ) + + # WHEN + response = self.client.post( + path=reverse("checkout-vv"), + format="json", + data={ + "redirect_url": REDIRECT_URL, + "address": TEST_ADDRESS_DATA, + }, + ) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + f"https://pay.sandbox.datatrans.com/v1/start/{transaction_id}", + response.json()["next_step_url"], + ) + + @patch("vbv_lernwelt.shop.views.init_transaction") + def test_checkout_failed_creates_new(self, mock_init_transaction): + # GIVEN + state = CheckoutState.FAILED.value + transaction_id = "1234567890" + mock_init_transaction.return_value = transaction_id + + CheckoutInformation.objects.create( + user=self.user, + product_sku=VV_PRODUCT_SKU, + product_price=0, + state=state, + transaction_id="0000000000", + ) + + # WHEN + response = self.client.post( + path=reverse("checkout-vv"), + format="json", + data={ + "redirect_url": REDIRECT_URL, + "address": TEST_ADDRESS_DATA, + }, + ) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + f"https://pay.sandbox.datatrans.com/v1/start/{transaction_id}", + response.json()["next_step_url"], + ) + + @patch("vbv_lernwelt.shop.views.init_transaction") + def test_checkout_cancelled_creates_new(self, mock_init_transaction): + # GIVEN + state = CheckoutState.CANCELED.value + transaction_id = "1234567899" + mock_init_transaction.return_value = transaction_id + + CheckoutInformation.objects.create( + user=self.user, + product_sku=VV_PRODUCT_SKU, + product_price=0, + state=state, + transaction_id="1111111111", + ) + + # WHEN + response = self.client.post( + path=reverse("checkout-vv"), + format="json", + data={ + "redirect_url": REDIRECT_URL, + "address": TEST_ADDRESS_DATA, + }, + ) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + f"https://pay.sandbox.datatrans.com/v1/start/{transaction_id}", + response.json()["next_step_url"], + ) diff --git a/server/vbv_lernwelt/shop/tests/test_create_signature.py b/server/vbv_lernwelt/shop/tests/test_datatrans_signature.py similarity index 89% rename from server/vbv_lernwelt/shop/tests/test_create_signature.py rename to server/vbv_lernwelt/shop/tests/test_datatrans_signature.py index ac7297d6..45f89ddc 100644 --- a/server/vbv_lernwelt/shop/tests/test_create_signature.py +++ b/server/vbv_lernwelt/shop/tests/test_datatrans_signature.py @@ -3,8 +3,12 @@ from unittest import TestCase from vbv_lernwelt.shop.services import is_signature_valid -class DatatransWebhookSigningTestCase(TestCase): - # Key is from their example in the docs, not ours! :D +class DatatransSigningTestCase(TestCase): + """ + Test based on the example from the docs. + Key is from their example, not ours! + """ + HMAC_KEY_FROM_THE_DOCS_NOT_HAZARDOUS = ( "861bbfc01e089259091927d6ad7f71c8" "b46b7ee13499574e83c633b74cdc29e3" diff --git a/server/vbv_lernwelt/shop/tests/test_datatrans_webhook.py b/server/vbv_lernwelt/shop/tests/test_datatrans_webhook.py new file mode 100644 index 00000000..c5c51621 --- /dev/null +++ b/server/vbv_lernwelt/shop/tests/test_datatrans_webhook.py @@ -0,0 +1,252 @@ +from unittest.mock import patch + +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from vbv_lernwelt.core.admin import User +from vbv_lernwelt.course.creators.test_utils import create_course, create_course_session +from vbv_lernwelt.course.models import CourseSessionUser +from vbv_lernwelt.shop.models import ( + CheckoutInformation, + CheckoutState, + Product, + VV_PRODUCT_SKU, +) + + +def create_checkout_information( + user: User, + transaction_id: str, + state: CheckoutState, +) -> CheckoutInformation: + return CheckoutInformation.objects.create( + user=user, + transaction_id=transaction_id, + product_sku=VV_PRODUCT_SKU, + product_price=300_00, + state=state.value, + ) + + +class DatatransWebhookTestCase(APITestCase): + def setUp(self) -> None: + course, _ = create_course(title="VV") + create_course_session(course=course, title="Versicherungsvermittler/-in") + + self.user = User.objects.create_user( + username="testuser", + email="test@user.com", + password="testpassword", + is_active=True, + ) + + self.product = Product.objects.create( + sku=VV_PRODUCT_SKU, + price=300_00, + description="VV", + name="VV", + ) + + def test_webhook_unsigned_payload(self): + # GIVEN + payload = { + "transactionId": "1234567890", + "status": "settled", + } + + headers = {"Datatrans-Signature": "t=1605697463367,s0=guessed"} + + # WHEN + response = self.client.post( + path=reverse("shop-transaction-webhook"), + format="json", + data=payload, + headers=headers, + ) + + # THEN + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"status": "invalid signature"}, + ) + + @patch("vbv_lernwelt.shop.views.is_signature_valid") + def test_webhook_creates_course_session_user(self, mock_is_signature_valid): + # GIVEN + transaction_id = "1234567890" + + create_checkout_information( + user=self.user, + transaction_id=transaction_id, + state=CheckoutState.INITIALIZED, + ) + + mock_is_signature_valid.return_value = True + + # WHEN + + # ~immediately after successful payment + response_settled = self.client.post( + path=reverse("shop-transaction-webhook"), + format="json", + headers={"Datatrans-Signature": ""}, + data={ + "status": "settled", + "transactionId": transaction_id, + }, + ) + + # ~24h later + response_transmitted = self.client.post( + path=reverse("shop-transaction-webhook"), + format="json", + headers={"Datatrans-Signature": ""}, + data={ + "status": "transmitted", + "transactionId": transaction_id, + }, + ) + + # THEN + self.assertEqual(response_settled.status_code, status.HTTP_200_OK) + self.assertEqual(response_transmitted.status_code, status.HTTP_200_OK) + + self.assertEqual( + CheckoutInformation.objects.get(transaction_id=transaction_id).state, + CheckoutState.PAID.value, + ) + + self.assertEqual( + 1, + CourseSessionUser.objects.count(), + ) + + self.assertEqual( + CourseSessionUser.objects.first().user, + self.user, + ) + + @patch("vbv_lernwelt.shop.views.is_signature_valid") + def test_webhook_updates_webhook_history(self, mock_is_signature_valid): + # GIVEN + transaction_id = "1234567890" + + create_checkout_information( + user=self.user, + transaction_id=transaction_id, + state=CheckoutState.INITIALIZED, + ) + + mock_is_signature_valid.return_value = True + + # WHEN + response_1 = self.client.post( + path=reverse("shop-transaction-webhook"), + format="json", + headers={"Datatrans-Signature": ""}, + data={ + "transactionId": transaction_id, + "status": "failed", + "whatever": "1", + }, + ) + + response_2 = self.client.post( + path=reverse("shop-transaction-webhook"), + format="json", + headers={"Datatrans-Signature": ""}, + data={ + "transactionId": transaction_id, + "status": "failed", + "whatever": "2", + }, + ) + + self.assertEqual(response_1.status_code, status.HTTP_200_OK) + self.assertEqual(response_2.status_code, status.HTTP_200_OK) + + # THEN + + self.assertEqual( + CheckoutInformation.objects.get( + transaction_id=transaction_id + ).webhook_history, + [ + { + "transactionId": transaction_id, + "status": "failed", + "whatever": "1", + }, + { + "transactionId": transaction_id, + "status": "failed", + "whatever": "2", + }, + ], + ) + + @patch("vbv_lernwelt.shop.views.is_signature_valid") + def test_webhook_failed(self, mock_is_signature_valid): + # GIVEN + transaction_id = "1234567890" + state_received = "failed" + + create_checkout_information( + user=self.user, + transaction_id=transaction_id, + state=CheckoutState.INITIALIZED, + ) + + mock_is_signature_valid.return_value = True + + # WHEN + response = self.client.post( + path=reverse("shop-transaction-webhook"), + format="json", + headers={"Datatrans-Signature": ""}, + data={ + "transactionId": transaction_id, + "status": state_received, + }, + ) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + CheckoutInformation.objects.get(transaction_id=transaction_id).state, + CheckoutState.FAILED.value, + ) + + @patch("vbv_lernwelt.shop.views.is_signature_valid") + def test_webhook_cancelled(self, mock_is_signature_valid): + # GIVEN + transaction_id = "1234567890" + state_received = "canceled" + + create_checkout_information( # noqa + user=self.user, + transaction_id=transaction_id, + state=CheckoutState.INITIALIZED, + ) + + mock_is_signature_valid.return_value = True + + # WHEN + response = self.client.post( + path=reverse("shop-transaction-webhook"), + format="json", + headers={"Datatrans-Signature": ""}, + data={ + "transactionId": transaction_id, + "status": state_received, + }, + ) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + CheckoutInformation.objects.get(transaction_id=transaction_id).state, + CheckoutState.CANCELED.value, + ) diff --git a/server/vbv_lernwelt/shop/views.py b/server/vbv_lernwelt/shop/views.py index e9d1cc59..0f2d1c5f 100644 --- a/server/vbv_lernwelt/shop/views.py +++ b/server/vbv_lernwelt/shop/views.py @@ -5,7 +5,6 @@ from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from vbv_lernwelt.core.admin import User from vbv_lernwelt.course.models import CourseSession, CourseSessionUser from vbv_lernwelt.shop.models import ( BillingAddress, @@ -24,6 +23,8 @@ from vbv_lernwelt.shop.services import ( logger = structlog.get_logger(__name__) +COURSE_SESSION_TITLE_VV = "Versicherungsvermittler/-in" + @api_view(["GET"]) @permission_classes([IsAuthenticated]) @@ -60,6 +61,12 @@ def update_billing_address(request): @api_view(["POST"]) def transaction_webhook(request): + """ + VERY IMPORTANT: Datatrans had/has to set up stuff manually on their side: + Otherwise, this webhook was/will not be called for "initialized" -> "failed" state changes + For timed out transactions (cleaned up in 15 minute intervals, after 30 minutes by them), + """ + logger.info("Webhook: Datatrans called transaction webhook", body=request.body) if not is_signature_valid( @@ -73,25 +80,23 @@ def transaction_webhook(request): transaction_id = transaction["transactionId"] transaction_status = transaction["status"] + # keep webhook history for debugging checkout_info = CheckoutInformation.objects.get(transaction_id=transaction_id) - checkout_info.state = transaction_status checkout_info.webhook_history.append(transaction) - checkout_info.save(update_fields=["state", "webhook_history"]) + checkout_info.save(update_fields=["webhook_history"]) + # be aware autoSettle has implications on possible transaction states we get! + # See https://api-reference.datatrans.ch/#tag/v1transactions/operation/status if transaction_status in ["settled", "transmitted"]: - logger.info( - "Webhook: Create course session user", transaction_id=transaction_id - ) - - # FIXME make this "better" - cs_vv = CourseSession.objects.filter( - title="Versicherungsvermittler/-in" - ).first() - - CourseSessionUser.objects.get_or_create( - course_session=cs_vv, - user=User.objects.get(id=checkout_info.user_id), - role=CourseSessionUser.Role.MEMBER, + update_checkout_state(checkout_info=checkout_info, state=CheckoutState.PAID) + create_vv_course_session_user(checkout_info=checkout_info) + elif transaction_status == "failed": + update_checkout_state(checkout_info=checkout_info, state=CheckoutState.FAILED) + elif transaction_status == "canceled": + update_checkout_state(checkout_info=checkout_info, state=CheckoutState.CANCELED) + else: + logger.warning( + "Unhandled transaction status", transaction_status=transaction_status ) return JsonResponse({"status": "ok"}) @@ -113,13 +118,11 @@ def checkout_vv(request): try: product = Product.objects.get(sku=sku) except Product.DoesNotExist: - return JsonResponse( - { - "next_step_url": checkout_error_url( - base_url=base_redirect_url, - message="vv_product_does_not_exist_needs_to_be_created", - ) - }, + return next_step_response( + url=checkout_error_url( + base_url=base_redirect_url, + message="vv_product_does_not_exist_needs_to_be_created", + ), ) checkouts = CheckoutInformation.objects.filter( @@ -127,15 +130,13 @@ def checkout_vv(request): product_sku=sku, ) - # already purchased (settled or transmitted) - if checkouts.filter( - state__in=[CheckoutState.SETTLED, CheckoutState.TRANSMITTED] - ).exists(): - return JsonResponse({"next_step_url": "/"}) + # already paid (successfully)-> redirect to home + if checkouts.filter(state__in=[CheckoutState.PAID]).exists(): + return next_step_response(url="/") # already initialized -> redirect to payment page again if checkout := checkouts.filter(state=CheckoutState.INITIALIZED).first(): - return JsonResponse({"next_step_url": get_payment_url(checkout.transaction_id)}) + return next_step_response(url=get_payment_url(checkout.transaction_id)) # not yet initialized at all, or canceled/failed # -> create new transaction and checkout @@ -149,12 +150,10 @@ def checkout_vv(request): webhook_url=webhook_url(base_redirect_url), ) except InitTransactionException as e: - return JsonResponse( - { - "next_step_url": checkout_error_url( - base_url=base_redirect_url, - ) - }, + return next_step_response( + url=checkout_error_url( + base_url=base_redirect_url, + ), ) CheckoutInformation.objects.create( @@ -170,7 +169,33 @@ def checkout_vv(request): **request.data["address"], ) - return JsonResponse({"next_step_url": get_payment_url(transaction_id)}) + return next_step_response(url=get_payment_url(transaction_id)) + + +def update_checkout_state(checkout_info: CheckoutInformation, state: CheckoutState): + checkout_info.state = state.value + checkout_info.save(update_fields=["state"]) + + +def create_vv_course_session_user(checkout_info: CheckoutInformation): + logger.info("Creating VV course session user", user_id=checkout_info.user_id) + CourseSessionUser.objects.get_or_create( + user=checkout_info.user, + role=CourseSessionUser.Role.MEMBER, + course_session=CourseSession.objects.filter( + title=COURSE_SESSION_TITLE_VV + ).first(), + ) + + +def next_step_response( + url: str, +) -> JsonResponse: + return JsonResponse( + { + "next_step_url": url, + }, + ) def webhook_url(base_url: str) -> str: