From be160f5fa738145f63d69929c96860a7313e6186 Mon Sep 17 00:00:00 2001 From: Livio Bieri Date: Fri, 17 Nov 2023 01:28:18 +0100 Subject: [PATCH] wip: walking skeleton for datatrans --- .../pages/onboarding/vv/CheckoutAddress.vue | 36 +++- server/config/urls.py | 17 +- server/vbv_lernwelt/shop/views.py | 154 +++++++++++++++++- 3 files changed, 196 insertions(+), 11 deletions(-) diff --git a/client/src/pages/onboarding/vv/CheckoutAddress.vue b/client/src/pages/onboarding/vv/CheckoutAddress.vue index 9098403c..4e60ace6 100644 --- a/client/src/pages/onboarding/vv/CheckoutAddress.vue +++ b/client/src/pages/onboarding/vv/CheckoutAddress.vue @@ -1,11 +1,12 @@ @@ -110,7 +136,7 @@ const executePayment = () => { class="underline" @click="useCompanyAddress = true" > - Rechnungsadresse von Axa hinzufügen + {{ `Rechnungsadresse von ${userOrganisationName} hinzufügen` }} { >
-

Rechnungsaddresse von Axa

+

{{ `Rechnungsaddresse von ${userOrganisationName}` }}

diff --git a/server/config/urls.py b/server/config/urls.py index 32a91864..39f024f8 100644 --- a/server/config/urls.py +++ b/server/config/urls.py @@ -10,6 +10,9 @@ from django.views import defaults as default_views from django.views.decorators.csrf import csrf_exempt from django_ratelimit.exceptions import Ratelimited from graphene_django.views import GraphQLView +from wagtail import urls as wagtail_urls +from wagtail.admin import urls as wagtailadmin_urls +from wagtail.documents import urls as wagtaildocs_urls from vbv_lernwelt.api.directory import list_entities from vbv_lernwelt.api.user import me_user_view @@ -55,10 +58,12 @@ from vbv_lernwelt.importer.views import ( t2l_sync, ) from vbv_lernwelt.notify.views import email_notification_settings -from vbv_lernwelt.shop.views import get_billing_address, update_billing_address -from wagtail import urls as wagtail_urls -from wagtail.admin import urls as wagtailadmin_urls -from wagtail.documents import urls as wagtaildocs_urls +from vbv_lernwelt.shop.views import ( + get_billing_address, + update_billing_address, + checkout_vv, + transaction_webhook, +) class SignedIntConverter(IntConverter): @@ -174,7 +179,9 @@ urlpatterns = [ # shop path(r'api/shop/billing-address/', get_billing_address, name='get-billing-address'), - path(r'api/shop/billing-address/update', update_billing_address, name='update-billing-address'), + path(r'api/shop/billing-address/update/', update_billing_address, name='update-billing-address'), + path(r'api/shop/vv/checkout/', checkout_vv, name='checkout-vv'), + path(r'api/shop/transaction/webhook/', django_view_authentication_exempt(transaction_webhook), name='shop-transaction-webhook'), # importer path( diff --git a/server/vbv_lernwelt/shop/views.py b/server/vbv_lernwelt/shop/views.py index 94755806..1ef6ac05 100644 --- a/server/vbv_lernwelt/shop/views.py +++ b/server/vbv_lernwelt/shop/views.py @@ -1,11 +1,41 @@ +import structlog +from django.http import JsonResponse from django.shortcuts import get_object_or_404 from rest_framework import status from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from vbv_lernwelt.shop.models import BillingAddress +from vbv_lernwelt.shop.models import ( + BillingAddress, + Product, + CheckoutInformation, + VV_PRODUCT_SKU, + CheckoutState, +) from vbv_lernwelt.shop.serializers import BillingAddressSerializer +from vbv_lernwelt.shop.services import DataTransPaymentProvider + +logger = structlog.get_logger(__name__) + +URL_CHECKOUT_COMPLETE = "/onboarding/vv/checkout/complete" +URL_CHECKOUT_ADDRESS = "/onboarding/vv/checkout/address" + + +def checkout_error_url(base_url): + return f"{base_url}/onboarding/vv/checkout/address?error=true" + + +def checkout_cancel_url(base_url): + return f"{base_url}/" + + +def checkout_success_url(base_url): + return f"{base_url}/onboarding/vv/checkout/complete" + + +def webhook_url(base_url): + return f"{base_url}/api/shop/transaction/webhook" @api_view(["GET"]) @@ -34,3 +64,125 @@ def update_billing_address(request): return Response(serializer.data) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +@api_view(["POST"]) +def transaction_webhook(request): + """Webhook endpoint for Datatrans to notify about transaction state changes.""" + logger.info("Transaction Webhook Received", body=request.body) + + datatrans = DataTransPaymentProvider() + + # FIXME verify signature, not working yet + # if not datatrans.is_webhook_request_signature_valid( + # request=request, + # ): + # logger.error("Invalid webhook signature") + # return JsonResponse({"status": "error"}, status=status.HTTP_400_BAD_REQUEST) + + transaction = request.data + transaction_id = transaction["transactionId"] + transaction_status = transaction["status"] + + # FIXME just pass the checkout_id as sequence number? + 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"]) + + if transaction_status == ["settled", "transmitted"]: + # FIXME create CSU + ... + + return JsonResponse({"status": "ok"}) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def checkout_vv(request): + """ + Checkout for the Versicherungsvermittler product (VV). + """ + + # The one and only product, thus hardcoded + # Expected to be created in the admin interface first. + sku = VV_PRODUCT_SKU + + logger.info("Checkout requested", sku=sku, user_id=request.user.id) + + try: + product = Product.objects.get(sku=sku) + except Product.DoesNotExist: + raise Exception( + f"Required Product not found: {sku} must be created in the admin interface first.", + ) + + checkouts = CheckoutInformation.objects.filter( + user=request.user, + product_sku=sku, + ) + + datatrans = DataTransPaymentProvider() + + # already purchased (settled or transmitted) + if checkouts.filter( + state__in=[CheckoutState.SETTLED, CheckoutState.TRANSMITTED] + ).exists(): + return JsonResponse({"next_step_url": URL_CHECKOUT_COMPLETE}) + + # FIXME: Re-using seems not to work -> just create a new one for now + # Also check if failed transactions get notified via webhook + # already initialized -> redirect to payment page again + # if checkout := checkouts.filter(state=CheckoutState.INITIALIZED).first(): + # return JsonResponse( + # { + # "next_step_url": datatrans.get_payment_url( + # transaction_id=checkout.transaction_id + # ) + # } + # ) + + address = request.data["address"] + organization = request.data["organisation_address"] + base_redirect_url = request.data["redirect_url"] + + # not yet initialized at all or canceled/failed + transaction_id = datatrans.init_transaction( + user=request.user, + amount_chf_centimes=product.price, + redirect_url_success=checkout_success_url(base_redirect_url), + redirect_url_error=checkout_error_url(base_redirect_url), + redirect_url_cancel=checkout_cancel_url(base_redirect_url), + webhook_url=webhook_url(base_redirect_url), + ) + + # immutable snapshot of data at time of purchase + CheckoutInformation.objects.create( + user=request.user, + state="initialized", + transaction_id=transaction_id, + # product + product_sku=sku, + product_price=product.price, + product_name=product.name, + product_description=product.description, + # address + first_name=address["firstName"], + last_name=address["lastName"], + street=address["street"], + street_number=address["streetNumber"], + postal_code=address["postalCode"], + city=address["city"], + country=address["country"], + # organization + company_name=organization.get("name"), + company_street=organization.get("street"), + company_street_number=organization.get("streetNumber"), + company_postal_code=organization.get("postalCode"), + company_city=organization.get("city"), + company_country=organization.get("country"), + ) + + return JsonResponse( + {"next_step_url": datatrans.get_payment_url(transaction_id=transaction_id)} + )