From fb24ec24e4ac86d4a873182eb2305c305c24b959 Mon Sep 17 00:00:00 2001 From: Livio Bieri Date: Mon, 4 Dec 2023 17:05:10 +0100 Subject: [PATCH] feat: vv-de vv-fr vv-it API support --- server/config/settings/base.py | 2 +- server/vbv_lernwelt/shop/README.md | 37 +++++++++++-------- server/vbv_lernwelt/shop/const.py | 4 ++ server/vbv_lernwelt/shop/models.py | 2 - server/vbv_lernwelt/shop/services.py | 11 +++--- .../shop/tests/test_checkout_api.py | 23 ++++++++---- .../shop/tests/test_datatrans_webhook.py | 29 ++++++++++----- server/vbv_lernwelt/shop/views.py | 37 +++++++++++-------- 8 files changed, 89 insertions(+), 56 deletions(-) create mode 100644 server/vbv_lernwelt/shop/const.py diff --git a/server/config/settings/base.py b/server/config/settings/base.py index 9ea12edb..94b1dcb4 100644 --- a/server/config/settings/base.py +++ b/server/config/settings/base.py @@ -564,7 +564,7 @@ ALLOWED_HOSTS = env.list( "localhost", "0.0.0.0", "127.0.0.1", - "e124-2a02-21b4-9679-d800-9c5-c205-e72c-82f2.ngrok-free.app", + "2375-2a02-21b4-9679-d800-8151-6008-2b6c-7a32.ngrok-free.app", ], ) diff --git a/server/vbv_lernwelt/shop/README.md b/server/vbv_lernwelt/shop/README.md index 65e39c9d..e5949c04 100644 --- a/server/vbv_lernwelt/shop/README.md +++ b/server/vbv_lernwelt/shop/README.md @@ -1,28 +1,35 @@ -# Setup +# Setup steps for Production ## Shop Product -- In Django Shop App, create a new product (Products model). -- `SKU` must be `VV`, Price 30000 (300_00 -> 300.00 CHF), name & description can be anything. -- Done for staging but not yet for production! +In Django Shop App, create new products (Products model) that should be available in the shop. +Products: + +- `vv-de` Price 30000 (300_00 -> 300.00 CHF), name & description can be anything. + - ONLY if `COURSE_VERSICHERUNGSVERMITTLERIN_ID` exists! +- `vv-fr` Price 30000 (300_00 -> 300.00 CHF), name & description can be anything. + - ONLY if `COURSE_VERSICHERUNGSVERMITTLERIN_ID_FR` exists! +- `vv-it` Price 30000 (300_00 -> 300.00 CHF), name & description can be anything. + - ONLY if `COURSE_VERSICHERUNGSVERMITTLERIN_ID_IT` exists! ## Datatrans - Set `DATATRANS_BASIC_AUTH_KEY`: - - https://admin.sandbox.datatrans.com/MenuDispatch.jsp?main=1&sub=4 - - `echo -n "{merchantid}:{password}" | base64` + - https://admin.sandbox.datatrans.com/MenuDispatch.jsp?main=1&sub=4 + - `echo -n "{merchantid}:{password}" | base64` - Set `DATATRANS_HMAC_KEY`: - - https://admin.sandbox.datatrans.com/MerchSecurAdmin.jsp + - https://admin.sandbox.datatrans.com/MerchSecurAdmin.jsp - Ensure that the webhook is set up correctly by Datatrans: - - Be default transitions from `initialized` to `failed` do not trigger the webhook. - - Edgecase: When user starts a datatrans payment and then closes the browser, the payment will be in `initialized` - state forever. -> That's why we need the webhook for `initialized` -> `failed` transitions. - - This can and needs to be enabled by datatrans (according to Mario from datatrans). - - Livio 21.11.23: Mario promised to enable it, - - Livio 27.11.23. Not yet enabled for the sandbox. -> Followed up! - - Livio: TODO still not enabled. Follow up again! + - Be default transitions from `initialized` to `failed` do not trigger the webhook. + - Edgecase: When user starts a datatrans payment and then closes the browser, the payment will be + in `initialized` + state forever. -> That's why we need the webhook for `initialized` -> `failed` transitions. + - This can and needs to be enabled by datatrans (according to Mario from datatrans). + - Livio 21.11.23: Mario promised to enable it, + - Livio 27.11.23. Not yet enabled for the sandbox. -> Followed up! + - Livio: TODO still not enabled. Follow up again! ### Production / "going live" @@ -57,7 +64,7 @@ Make sure that the following env vars are set: ### Frontend: - Update `VITE_OAUTH_API_BASE_URL` in `caprover_deploy.sh` for production. - - NEEDS to be updated! Should be the SSO Prod one from Lernnetz -> Lookup from Metadata URL + - Should be the SSO Prod one from Lernnetz. ### Cleanup diff --git a/server/vbv_lernwelt/shop/const.py b/server/vbv_lernwelt/shop/const.py new file mode 100644 index 00000000..01eb648b --- /dev/null +++ b/server/vbv_lernwelt/shop/const.py @@ -0,0 +1,4 @@ +# available products for VV +VV_DE_PRODUCT_SKU = "vv-de" +VV_FR_PRODUCT_SKU = "vv-fr" +VV_IT_PRODUCT_SKU = "vv-it" diff --git a/server/vbv_lernwelt/shop/models.py b/server/vbv_lernwelt/shop/models.py index 21bfb0f0..f3587830 100644 --- a/server/vbv_lernwelt/shop/models.py +++ b/server/vbv_lernwelt/shop/models.py @@ -1,7 +1,5 @@ from django.db import models -VV_PRODUCT_SKU = "VV" - class Country(models.Model): country_id = models.IntegerField(primary_key=True) diff --git a/server/vbv_lernwelt/shop/services.py b/server/vbv_lernwelt/shop/services.py index 3c815c6a..b7c47587 100644 --- a/server/vbv_lernwelt/shop/services.py +++ b/server/vbv_lernwelt/shop/services.py @@ -22,8 +22,7 @@ def is_signature_valid( hmac_key: str = settings.DATATRANS_HMAC_KEY, ): """ - See the docs: - https://docs.datatrans.ch/docs/additional-security + See the docs: https://docs.datatrans.ch/docs/additional-security """ try: @@ -126,13 +125,13 @@ def get_payment_url(transaction_id: str): def datatrans_state_to_checkout_state( - transaction_state: str, + datatrans_transaction_state: str, ) -> CheckoutState: - if transaction_state in ["settled", "transmitted"]: + if datatrans_transaction_state in ["settled", "transmitted"]: return CheckoutState.PAID - elif transaction_state == "failed": + elif datatrans_transaction_state == "failed": return CheckoutState.FAILED - elif transaction_state == "canceled": + elif datatrans_transaction_state == "canceled": return CheckoutState.CANCELED else: # An intermediate state such as "initialized", "challenge_ongoing", etc. diff --git a/server/vbv_lernwelt/shop/tests/test_checkout_api.py b/server/vbv_lernwelt/shop/tests/test_checkout_api.py index e349fbab..633a937b 100644 --- a/server/vbv_lernwelt/shop/tests/test_checkout_api.py +++ b/server/vbv_lernwelt/shop/tests/test_checkout_api.py @@ -5,11 +5,11 @@ from rest_framework import status from rest_framework.test import APITestCase from vbv_lernwelt.core.admin import User +from vbv_lernwelt.shop.const import VV_DE_PRODUCT_SKU from vbv_lernwelt.shop.models import ( CheckoutInformation, CheckoutState, Product, - VV_PRODUCT_SKU, ) from vbv_lernwelt.shop.services import InitTransactionException @@ -39,7 +39,7 @@ REDIRECT_URL = "http://testserver/redirect-url" class CheckoutAPITestCase(APITestCase): def setUp(self) -> None: Product.objects.create( - sku=VV_PRODUCT_SKU, + sku=VV_DE_PRODUCT_SKU, price=300_00, description="VV", name="VV", @@ -65,6 +65,7 @@ class CheckoutAPITestCase(APITestCase): format="json", data={ "redirect_url": REDIRECT_URL, + "product": VV_DE_PRODUCT_SKU, "address": TEST_ADDRESS_DATA, }, ) @@ -79,7 +80,7 @@ class CheckoutAPITestCase(APITestCase): self.assertTrue( CheckoutInformation.objects.filter( user=self.user, - product_sku=VV_PRODUCT_SKU, + product_sku=VV_DE_PRODUCT_SKU, state=CheckoutState.INITIALIZED.value, ).exists() ) @@ -105,6 +106,7 @@ class CheckoutAPITestCase(APITestCase): format="json", data={ "redirect_url": REDIRECT_URL, + "product": VV_DE_PRODUCT_SKU, "address": TEST_ADDRESS_DATA, }, ) @@ -114,7 +116,7 @@ class CheckoutAPITestCase(APITestCase): expected = ( f"{REDIRECT_URL}/onboarding/vv/checkout/address?error&" - f"message=vv_product_does_not_exist_needs_to_be_created" + f"message=vv-de_product_sku_does_not_exist_needs_to_be_created_first" ) self.assertEqual(expected, response.json()["next_step_url"]) @@ -132,6 +134,7 @@ class CheckoutAPITestCase(APITestCase): format="json", data={ "redirect_url": REDIRECT_URL, + "product": VV_DE_PRODUCT_SKU, "address": TEST_ADDRESS_DATA, }, ) @@ -152,7 +155,7 @@ class CheckoutAPITestCase(APITestCase): # GIVEN CheckoutInformation.objects.create( user=self.user, - product_sku=VV_PRODUCT_SKU, + product_sku=VV_DE_PRODUCT_SKU, product_price=0, state=CheckoutState.PAID.value, ) @@ -163,6 +166,7 @@ class CheckoutAPITestCase(APITestCase): format="json", data={ "redirect_url": REDIRECT_URL, + "product": VV_DE_PRODUCT_SKU, "address": TEST_ADDRESS_DATA, }, ) @@ -180,7 +184,7 @@ class CheckoutAPITestCase(APITestCase): CheckoutInformation.objects.create( user=self.user, - product_sku=VV_PRODUCT_SKU, + product_sku=VV_DE_PRODUCT_SKU, product_price=0, state=CheckoutState.INITIALIZED.value, transaction_id=transaction_id, @@ -192,6 +196,7 @@ class CheckoutAPITestCase(APITestCase): format="json", data={ "redirect_url": REDIRECT_URL, + "product": VV_DE_PRODUCT_SKU, "address": TEST_ADDRESS_DATA, }, ) @@ -212,7 +217,7 @@ class CheckoutAPITestCase(APITestCase): CheckoutInformation.objects.create( user=self.user, - product_sku=VV_PRODUCT_SKU, + product_sku=VV_DE_PRODUCT_SKU, product_price=0, state=state, transaction_id="0000000000", @@ -224,6 +229,7 @@ class CheckoutAPITestCase(APITestCase): format="json", data={ "redirect_url": REDIRECT_URL, + "product": VV_DE_PRODUCT_SKU, "address": TEST_ADDRESS_DATA, }, ) @@ -244,7 +250,7 @@ class CheckoutAPITestCase(APITestCase): CheckoutInformation.objects.create( user=self.user, - product_sku=VV_PRODUCT_SKU, + product_sku=VV_DE_PRODUCT_SKU, product_price=0, state=state, transaction_id="1111111111", @@ -256,6 +262,7 @@ class CheckoutAPITestCase(APITestCase): format="json", data={ "redirect_url": REDIRECT_URL, + "product": VV_DE_PRODUCT_SKU, "address": TEST_ADDRESS_DATA, }, ) diff --git a/server/vbv_lernwelt/shop/tests/test_datatrans_webhook.py b/server/vbv_lernwelt/shop/tests/test_datatrans_webhook.py index c3cb3da1..4f30264f 100644 --- a/server/vbv_lernwelt/shop/tests/test_datatrans_webhook.py +++ b/server/vbv_lernwelt/shop/tests/test_datatrans_webhook.py @@ -5,13 +5,14 @@ from rest_framework import status from rest_framework.test import APITestCase from vbv_lernwelt.core.admin import User +from vbv_lernwelt.course.consts import COURSE_VERSICHERUNGSVERMITTLERIN_ID from vbv_lernwelt.course.creators.test_utils import create_course, create_course_session from vbv_lernwelt.course.models import CourseSessionUser +from vbv_lernwelt.shop.const import VV_DE_PRODUCT_SKU from vbv_lernwelt.shop.models import ( CheckoutInformation, CheckoutState, Product, - VV_PRODUCT_SKU, ) @@ -23,7 +24,7 @@ def create_checkout_information( return CheckoutInformation.objects.create( user=user, transaction_id=transaction_id, - product_sku=VV_PRODUCT_SKU, + product_sku=VV_DE_PRODUCT_SKU, product_price=300_00, state=state.value, ) @@ -31,8 +32,13 @@ def create_checkout_information( class DatatransWebhookTestCase(APITestCase): def setUp(self) -> None: - course, _ = create_course(title="VV") - create_course_session(course=course, title="Versicherungsvermittler/-in") + course, _ = create_course( + title="VV_in_DE", + # needed for VV_DE_PRODUCT_SKU + _id=COURSE_VERSICHERUNGSVERMITTLERIN_ID, + ) + + create_course_session(course=course, title="Versicherungsvermittler/-in DE") self.user = User.objects.create_user( username="testuser", @@ -42,7 +48,7 @@ class DatatransWebhookTestCase(APITestCase): ) self.product = Product.objects.create( - sku=VV_PRODUCT_SKU, + sku=VV_DE_PRODUCT_SKU, price=300_00, description="VV", name="VV", @@ -110,12 +116,12 @@ class DatatransWebhookTestCase(APITestCase): ) # THEN - self.assertEqual(response_settled.status_code, status.HTTP_200_OK) - self.assertEqual(response_transmitted.status_code, status.HTTP_200_OK) + self.assertEqual(status.HTTP_200_OK, response_settled.status_code) + self.assertEqual(status.HTTP_200_OK, response_transmitted.status_code) self.assertEqual( - CheckoutInformation.objects.get(transaction_id=transaction_id).state, CheckoutState.PAID.value, + CheckoutInformation.objects.get(transaction_id=transaction_id).state, ) self.assertEqual( @@ -124,8 +130,13 @@ class DatatransWebhookTestCase(APITestCase): ) self.assertEqual( - CourseSessionUser.objects.first().user, self.user, + CourseSessionUser.objects.first().user, + ) + + self.assertEqual( + COURSE_VERSICHERUNGSVERMITTLERIN_ID, + CourseSessionUser.objects.first().course_session.course.id, ) @patch("vbv_lernwelt.shop.views.is_signature_valid") diff --git a/server/vbv_lernwelt/shop/views.py b/server/vbv_lernwelt/shop/views.py index afffd7b7..319606f0 100644 --- a/server/vbv_lernwelt/shop/views.py +++ b/server/vbv_lernwelt/shop/views.py @@ -5,13 +5,22 @@ from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from vbv_lernwelt.course.consts import ( + COURSE_VERSICHERUNGSVERMITTLERIN_ID, + COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID, + COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID, +) from vbv_lernwelt.course.models import CourseSession, CourseSessionUser +from vbv_lernwelt.shop.const import ( + VV_DE_PRODUCT_SKU, + VV_FR_PRODUCT_SKU, + VV_IT_PRODUCT_SKU, +) from vbv_lernwelt.shop.models import ( BillingAddress, CheckoutInformation, CheckoutState, Product, - VV_PRODUCT_SKU, ) from vbv_lernwelt.shop.serializers import BillingAddressSerializer from vbv_lernwelt.shop.services import ( @@ -24,7 +33,11 @@ from vbv_lernwelt.shop.services import ( logger = structlog.get_logger(__name__) -COURSE_SESSION_TITLE_VV = "Versicherungsvermittler/-in" +PRODUCT_SKU_TO_COURSE = { + VV_DE_PRODUCT_SKU: COURSE_VERSICHERUNGSVERMITTLERIN_ID, + VV_FR_PRODUCT_SKU: COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID, + VV_IT_PRODUCT_SKU: COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID, +} @api_view(["GET"]) @@ -62,11 +75,6 @@ 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( @@ -99,22 +107,20 @@ def transaction_webhook(request): @permission_classes([IsAuthenticated]) def checkout_vv(request): """ - Checkout for the Versicherungsvermittler product (VV). - - VV_PRODUCT_SKU: The one and only product, thus hardcoded - Expected to be created in the admin interface first. + Checkout for the Versicherungsvermittler products (vv-de, vv-fr, vv-it) """ - sku = VV_PRODUCT_SKU - logger.info(f"Checkout requested: sku={sku}", user_id=request.user.id) + sku = request.data["product"] base_redirect_url = request.data["redirect_url"] + logger.info(f"Checkout requested: sku={sku}", user_id=request.user.id) + try: product = Product.objects.get(sku=sku) except Product.DoesNotExist: return next_step_response( url=checkout_error_url( base_url=base_redirect_url, - message="vv_product_does_not_exist_needs_to_be_created", + message=f"{sku}_product_sku_does_not_exist_needs_to_be_created_first", ), ) @@ -172,11 +178,12 @@ def update_checkout_state(checkout_info: CheckoutInformation, state: CheckoutSta 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 + course_id=PRODUCT_SKU_TO_COURSE[checkout_info.product_sku] ).first(), )