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") }}
-
+
{{ $t("a.Mit Kreditkarte bezahlen") }}
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"],
)