From e776103eb72fc15d1717d1c7a8eab19dfaa23458 Mon Sep 17 00:00:00 2001 From: Daniel Egger Date: Thu, 20 Jun 2024 16:25:28 +0200 Subject: [PATCH] Add new fields for cembra pay --- .../components/onboarding/PersonalAddress.vue | 35 +++++++++ .../pages/onboarding/vv/CheckoutAddress.vue | 72 ++++++++++++------- client/src/stores/user.ts | 2 + cypress/e2e/checkout-vv/checkout.cy.js | 63 +++++++++++++++- .../config/settings/test_cypress_datatrans.py | 26 +++++++ .../migrations/0010_auto_20240620_1625.py | 23 ++++++ server/vbv_lernwelt/core/models.py | 4 ++ server/vbv_lernwelt/core/serializers.py | 2 + .../shop/datatrans_fake_server.py | 8 +++ .../migrations/0015_auto_cembra_fields.py | 43 +++++++++++ server/vbv_lernwelt/shop/models.py | 8 +++ server/vbv_lernwelt/shop/services.py | 7 ++ server/vbv_lernwelt/shop/views.py | 43 +++++++++++ 13 files changed, 308 insertions(+), 28 deletions(-) create mode 100644 server/config/settings/test_cypress_datatrans.py create mode 100644 server/vbv_lernwelt/core/migrations/0010_auto_20240620_1625.py create mode 100644 server/vbv_lernwelt/shop/migrations/0015_auto_cembra_fields.py diff --git a/client/src/components/onboarding/PersonalAddress.vue b/client/src/components/onboarding/PersonalAddress.vue index dfa85eac..233391a0 100644 --- a/client/src/components/onboarding/PersonalAddress.vue +++ b/client/src/components/onboarding/PersonalAddress.vue @@ -11,6 +11,9 @@ const props = defineProps<{ postal_code: string; city: string; country_code: string; + + phone_number: string; + birth_date: string; }; }>(); @@ -163,5 +166,37 @@ const address = computed({ + +
+ +
+ +
+
+ +
+ +
+ +
+
diff --git a/client/src/pages/onboarding/vv/CheckoutAddress.vue b/client/src/pages/onboarding/vv/CheckoutAddress.vue index 6cc9cdc8..3b5b8049 100644 --- a/client/src/pages/onboarding/vv/CheckoutAddress.vue +++ b/client/src/pages/onboarding/vv/CheckoutAddress.vue @@ -9,6 +9,7 @@ import { useEntities } from "@/services/entities"; import { useRoute } from "vue-router"; import { useTranslation } from "i18next-vue"; import { getVVCourseName } from "./composables"; +import ItToggleSwitch from "@/components/ui/ItToggleSwitch.vue"; const props = defineProps({ courseType: { @@ -50,6 +51,10 @@ const address = ref({ postal_code: user.postal_code, city: user.city, country_code: user.country?.country_code ?? "CH", + + phone_number: user.phone_number, + birth_date: user.birth_date, + organisation_detail_name: user.organisation_detail_name, organisation_street: user.organisation_street, organisation_street_number: user.organisation_street_number, @@ -59,13 +64,15 @@ const address = ref({ invoice_address: user.invoice_address ?? "prv", }); -const useCompanyAddress = ref(user.invoice_address === "org"); +const withCompanyAddress = ref(user.invoice_address === "org"); -const setUseCompanyAddress = (value: boolean) => { - useCompanyAddress.value = value; +const setWithCompanyAddress = (value: boolean) => { + withCompanyAddress.value = value; address.value.invoice_address = value ? "org" : "prv"; }; +const withCembraInvoice = ref(false); + type FormErrors = { personal: string[]; company: string[]; @@ -110,7 +117,7 @@ function validateAddress() { formErrors.value.personal.push(t("a.Land")); } - if (useCompanyAddress.value) { + if (withCompanyAddress.value) { if (!address.value.organisation_detail_name) { formErrors.value.company.push(t("a.Name")); } @@ -170,6 +177,7 @@ const executePayment = async () => { redirect_url: fullHost, address: address.value, product: props.courseType, + with_cembra_invoice: withCembraInvoice.value, }).then((res: any) => { console.log("Going to next page", res.next_step_url); window.location.href = res.next_step_url; @@ -228,26 +236,34 @@ const executePayment = async () => {

-

- {{ $t("a.Bitte folgende Felder ausfüllen") }}: - {{ formErrors.personal.join(", ") }} -

+
+ + + "TODO: Zahlung auf Rechnung/Cembra" + +
- +
+ +
{ leave-from-class="transform opacity-100 scale-y-100" leave-to-class="transform opacity-0 scale-y-95" > -
+

{{ @@ -267,7 +283,7 @@ const executePayment = async () => { }}

{{ $t("a.Rechnungsadresse") }}

-
@@ -288,7 +304,11 @@ const executePayment = async () => { {{ $t("general.back") }} - diff --git a/client/src/stores/user.ts b/client/src/stores/user.ts index 306ed152..4436c7b6 100644 --- a/client/src/stores/user.ts +++ b/client/src/stores/user.ts @@ -36,6 +36,8 @@ export interface User { postal_code: string; city: string; country: Country | null; + birth_date: string; + phone_number: string; organisation_detail_name: string; organisation_street: string; organisation_street_number: string; diff --git a/cypress/e2e/checkout-vv/checkout.cy.js b/cypress/e2e/checkout-vv/checkout.cy.js index 45c93c5f..41006853 100644 --- a/cypress/e2e/checkout-vv/checkout.cy.js +++ b/cypress/e2e/checkout-vv/checkout.cy.js @@ -9,7 +9,7 @@ describe("checkout.cy.js", () => { cy.visit("/"); }); - it("can register and buy Versicherungsvermittlerin with credit card", () => { + it("can checkout and buy Versicherungsvermittlerin with credit card", () => { cy.get('[data-cy="start-vv"]').click(); // wähle "Deutsch" @@ -66,7 +66,6 @@ describe("checkout.cy.js", () => { expect(ci.organisation_postal_code).to.equal("3012"); expect(ci.organisation_city).to.equal("Bern"); - expect(ci.product_name).to.equal("Versicherungsvermittler/-in VBV"); expect(ci.product_name).to.equal("Versicherungsvermittler/-in VBV"); expect(ci.product_price).to.equal(32400); @@ -94,4 +93,64 @@ describe("checkout.cy.js", () => { expect(ci.state).to.equal("paid"); }); }); + + it.only("can checkout and pay Versicherungsvermittlerin with Cembra invoice", () => { + cy.get('[data-cy="start-vv"]').click(); + + // wähle "Deutsch" + cy.get('[href="/onboarding/vv-de/account/create"]').click(); + + cy.get('[data-cy="account-confirm-title"]').should( + "contain", + "Konto erstellen" + ); + cy.get('[data-cy="continue-button"]').click(); + + cy.get('[data-cy="account-profile-title"]').should( + "contain", + "Profil ergänzen" + ); + cy.get('[data-cy="dropdown-select"]').click(); + cy.get('[data-cy="dropdown-select-option-Baloise"]').click(); + cy.get('[data-cy="continue-button"]').click(); + + // Adressdaten ausfüllen + cy.get('[data-cy="account-checkout-title"]').should( + "contain", + "Lehrgang kaufen" + ); + cy.get("#street-address").type("Eggersmatt"); + cy.get("#street-number").type("32"); + cy.get("#postal-code").type("1719"); + cy.get("#city").type("Zumholz"); + + cy.get("#phone").type("+41 79 201 85 86"); + cy.get("#birth-date").type("1982-06-09"); + + cy.get('[data-cy="cembra-switch"]').click(); + + cy.get('[data-cy="continue-pay"]').click(); + + // check that results are stored on server + cy.loadCheckoutInformation("user_id", TEST_USER_EMPTY_ID).then((ci) => { + expect(ci.first_name).to.equal("Flasche"); + expect(ci.last_name).to.equal("Leer"); + + expect(ci.street).to.equal("Eggersmatt"); + expect(ci.street_number).to.equal("32"); + expect(ci.postal_code).to.equal("1719"); + expect(ci.city).to.equal("Zumholz"); + expect(ci.country).to.equal("CH"); + expect(ci.phone_number).to.equal("+41 79 201 85 86"); + expect(ci.birth_date).to.equal("1982-06-09"); + expect(ci.cembra_invoice).to.be.true; + + expect(ci.invoice_address).to.equal("prv"); + + expect(ci.product_name).to.equal("Versicherungsvermittler/-in VBV"); + expect(ci.product_price).to.equal(32400); + + expect(ci.state).to.equal("ongoing"); + }); + }); }); diff --git a/server/config/settings/test_cypress_datatrans.py b/server/config/settings/test_cypress_datatrans.py new file mode 100644 index 00000000..c87bf278 --- /dev/null +++ b/server/config/settings/test_cypress_datatrans.py @@ -0,0 +1,26 @@ +# pylint: disable=unused-wildcard-import,wildcard-import,wrong-import-position +import os + +from dotenv import dotenv_values + +script_path = os.path.abspath(__file__) +script_dir = os.path.dirname(script_path) + +dev_env = dotenv_values(f"{script_dir}/../../../env_secrets/caprover_vbv-develop.env") + +os.environ["IT_APP_ENVIRONMENT"] = "local" + +os.environ["AWS_S3_SECRET_ACCESS_KEY"] = dev_env.get("AWS_S3_SECRET_ACCESS_KEY") +os.environ["DATATRANS_BASIC_AUTH_KEY"] = dev_env.get("DATATRANS_BASIC_AUTH_KEY") +os.environ["DATATRANS_HMAC_KEY"] = dev_env.get("DATATRANS_HMAC_KEY") + +os.environ["IT_APP_ENVIRONMENT"] = "local" +os.environ["AWS_S3_SECRET_ACCESS_KEY"] = os.environ.get( + "AWS_S3_SECRET_ACCESS_KEY", + "!!!default_for_quieting_cypress_within_pycharm!!!", +) + +from .test_cypress import * # noqa + +DATATRANS_API_ENDPOINT = "https://api.sandbox.datatrans.com" +DATATRANS_PAY_URL = "https://pay.sandbox.datatrans.com" diff --git a/server/vbv_lernwelt/core/migrations/0010_auto_20240620_1625.py b/server/vbv_lernwelt/core/migrations/0010_auto_20240620_1625.py new file mode 100644 index 00000000..dfafcf04 --- /dev/null +++ b/server/vbv_lernwelt/core/migrations/0010_auto_20240620_1625.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.20 on 2024-06-20 14:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0009_country_refactor'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='birth_date', + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name='user', + name='phone_number', + field=models.CharField(blank=True, default='', max_length=255), + ), + ] diff --git a/server/vbv_lernwelt/core/models.py b/server/vbv_lernwelt/core/models.py index 781affb5..d1af6853 100644 --- a/server/vbv_lernwelt/core/models.py +++ b/server/vbv_lernwelt/core/models.py @@ -111,6 +111,10 @@ class User(AbstractUser): blank=True, ) + # fields gathered from cembra pay form + birth_date = models.DateField(null=True, blank=True) + phone_number = models.CharField(max_length=255, blank=True, default="") + # is only set by abacus invoice export code abacus_debitor_number = models.BigIntegerField(unique=True, null=True, blank=True) diff --git a/server/vbv_lernwelt/core/serializers.py b/server/vbv_lernwelt/core/serializers.py index 435ed187..b1cd79df 100644 --- a/server/vbv_lernwelt/core/serializers.py +++ b/server/vbv_lernwelt/core/serializers.py @@ -69,6 +69,8 @@ class UserSerializer(serializers.ModelSerializer): "postal_code", "city", "country", + "phone_number", + "birth_date", "organisation_detail_name", "organisation_street", "organisation_street_number", diff --git a/server/vbv_lernwelt/shop/datatrans_fake_server.py b/server/vbv_lernwelt/shop/datatrans_fake_server.py index 61680c60..cbbd8146 100644 --- a/server/vbv_lernwelt/shop/datatrans_fake_server.py +++ b/server/vbv_lernwelt/shop/datatrans_fake_server.py @@ -6,6 +6,7 @@ import time import requests from django.conf import settings +from django.db import transaction from django.http import HttpResponse, JsonResponse from django.shortcuts import redirect from django.views.decorators.csrf import csrf_exempt @@ -16,12 +17,19 @@ from vbv_lernwelt.core.models import User @csrf_exempt @django_view_authentication_exempt +@transaction.non_atomic_requests def fake_datatrans_api_view(request, api_url=""): if api_url == "/v1/transactions" and request.method == "POST": data = json.loads(request.body.decode("utf-8")) user = User.objects.get(id=data["user_id"]) + + if "customer" in data and not user.abacus_debitor_number: + user.set_increment_abacus_debitor_number() + data["customer"]["id"] = user.abacus_debitor_number + user.additional_json_data["datatrans_transaction_payload"] = data user.save() + return JsonResponse({"transactionId": data["refno"]}, status=201) return HttpResponse( diff --git a/server/vbv_lernwelt/shop/migrations/0015_auto_cembra_fields.py b/server/vbv_lernwelt/shop/migrations/0015_auto_cembra_fields.py new file mode 100644 index 00000000..6de9f51a --- /dev/null +++ b/server/vbv_lernwelt/shop/migrations/0015_auto_cembra_fields.py @@ -0,0 +1,43 @@ +# Generated by Django 3.2.20 on 2024-06-21 08:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0014_checkoutinformation_abacus_ssh_upload_done'), + ] + + operations = [ + migrations.AddField( + model_name='checkoutinformation', + name='birth_date', + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name='checkoutinformation', + name='cembra_invoice', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='checkoutinformation', + name='device_fingerprint', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='checkoutinformation', + name='email', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AddField( + model_name='checkoutinformation', + name='ip_address', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AddField( + model_name='checkoutinformation', + name='phone_number', + field=models.CharField(blank=True, default='', max_length=255), + ), + ] diff --git a/server/vbv_lernwelt/shop/models.py b/server/vbv_lernwelt/shop/models.py index af987a6e..84bd177b 100644 --- a/server/vbv_lernwelt/shop/models.py +++ b/server/vbv_lernwelt/shop/models.py @@ -78,6 +78,14 @@ class CheckoutInformation(models.Model): blank=True, ) + # optional fields for cembra payment + cembra_invoice = models.BooleanField(default=False) + birth_date = models.DateField(null=True, blank=True) + phone_number = models.CharField(max_length=255, blank=True, default="") + email = models.CharField(max_length=255, blank=True, default="") + device_fingerprint = models.TextField(blank=True, default="") + ip_address = models.CharField(max_length=255, blank=True, default="") + invoice_address = models.CharField( max_length=3, choices=INVOICE_ADDRESS_CHOICES, default="prv" ) diff --git a/server/vbv_lernwelt/shop/services.py b/server/vbv_lernwelt/shop/services.py index a5aaef4d..20c1aee8 100644 --- a/server/vbv_lernwelt/shop/services.py +++ b/server/vbv_lernwelt/shop/services.py @@ -52,6 +52,8 @@ def init_datatrans_transaction( redirect_url_error: str, redirect_url_cancel: str, webhook_url: str, + datatrans_customer_data: dict = None, + datatrans_int_data: dict = None, ): payload = { # We use autoSettle=True, so that we don't have to settle the transaction: @@ -69,6 +71,11 @@ def init_datatrans_transaction( }, } + if datatrans_customer_data: + payload["customer"] = datatrans_customer_data + if datatrans_int_data: + payload["INT"] = datatrans_int_data + # add testing configuration data if "fakeapi" in settings.DATATRANS_API_ENDPOINT: payload["user_id"] = str(user.id) diff --git a/server/vbv_lernwelt/shop/views.py b/server/vbv_lernwelt/shop/views.py index e170cc5c..7d758e24 100644 --- a/server/vbv_lernwelt/shop/views.py +++ b/server/vbv_lernwelt/shop/views.py @@ -1,3 +1,5 @@ +from datetime import date + import structlog from django.conf import settings from django.http import JsonResponse @@ -106,7 +108,40 @@ def checkout_vv(request): if checkouts.filter(state=CheckoutState.PAID).exists(): return next_step_response(url="/") + with_cembra_invoice = request.data.get("with_cembra_invoice", False) + ip_address = request.META.get("REMOTE_ADDR") + email = request.user.email + try: + datatrans_customer_data = None + datatrans_int_data = None + if with_cembra_invoice: + + if "fakeapi" not in settings.DATATRANS_API_ENDPOINT: + request.user.set_increment_abacus_debitor_number() + + # see https://api-reference.datatrans.ch/#tag/v1transactions as reference + datatrans_customer_data = { + "firstName": request.user.first_name, + "lastName": request.user.last_name, + "id": request.user.abacus_debitor_number, + "street": f'{request.data["address"]["street"]} {request.data["address"]["street_number"]}', + "city": request.data["address"]["city"], + "zipCode": request.data["address"]["postal_code"], + "country": request.data["address"]["country_code"], + "phone": request.data["address"]["phone_number"], + "email": email, + "birthDate": request.data["address"]["birth_date"], + "language": request.user.language, + "ipAddress": ip_address, + "type": "P", + } + datatrans_int_data = { + "subtype": "INVOICE", + "riskOwner": "IJ", + "repaymentType": 3, + "deviceFingerprintId": "TODO", + } transaction_id = init_datatrans_transaction( user=request.user, amount_chf_centimes=product.price, @@ -118,6 +153,8 @@ def checkout_vv(request): ), redirect_url_cancel=checkout_cancel_url(base_redirect_url), webhook_url=webhook_url(base_redirect_url), + datatrans_customer_data=datatrans_customer_data, + datatrans_int_data=datatrans_int_data, ) except InitTransactionException as e: if not settings.DEBUG: @@ -138,6 +175,9 @@ def checkout_vv(request): organisation_country_code = address_data.pop("organisation_country_code") address_data["organisation_country_id"] = organisation_country_code + if "birth_date" in address_data and address_data["birth_date"]: + address_data["birth_date"] = date.fromisoformat(address_data["birth_date"]) + checkout_info = CheckoutInformation.objects.create( user=request.user, state=CheckoutState.ONGOING, @@ -147,6 +187,9 @@ def checkout_vv(request): product_price=product.price, product_name=product.name, product_description=product.description, + email=email, + ip_address=ip_address, + cembra_invoice=with_cembra_invoice, # address **request.data["address"], )