Add new fields for cembra pay

This commit is contained in:
Daniel Egger 2024-06-20 16:25:28 +02:00
parent da5c6d07d2
commit e776103eb7
13 changed files with 308 additions and 28 deletions

View File

@ -11,6 +11,9 @@ const props = defineProps<{
postal_code: string; postal_code: string;
city: string; city: string;
country_code: string; country_code: string;
phone_number: string;
birth_date: string;
}; };
}>(); }>();
@ -163,5 +166,37 @@ const address = computed({
</select> </select>
</div> </div>
</div> </div>
<div class="col-span-full">
<label for="phone" class="block text-sm font-medium leading-6 text-gray-900">
{{ $t("a.Telefonnummer") }}
</label>
<div class="mt-2">
<input
id="phone"
v-model="address.phone_number"
type="text"
name="phone"
autocomplete="phone-number"
class="block w-full border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div class="col-span-full">
<label for="birth-date" class="block text-sm font-medium leading-6 text-gray-900">
{{ $t("a.Geburtsdatum") }}
</label>
<div class="mt-2">
<input
id="birth-date"
v-model="address.birth_date"
type="text"
name="phone"
autocomplete="birth-date"
class="block w-full border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
</div> </div>
</template> </template>

View File

@ -9,6 +9,7 @@ import { useEntities } from "@/services/entities";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { useTranslation } from "i18next-vue"; import { useTranslation } from "i18next-vue";
import { getVVCourseName } from "./composables"; import { getVVCourseName } from "./composables";
import ItToggleSwitch from "@/components/ui/ItToggleSwitch.vue";
const props = defineProps({ const props = defineProps({
courseType: { courseType: {
@ -50,6 +51,10 @@ const address = ref({
postal_code: user.postal_code, postal_code: user.postal_code,
city: user.city, city: user.city,
country_code: user.country?.country_code ?? "CH", 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_detail_name: user.organisation_detail_name,
organisation_street: user.organisation_street, organisation_street: user.organisation_street,
organisation_street_number: user.organisation_street_number, organisation_street_number: user.organisation_street_number,
@ -59,13 +64,15 @@ const address = ref({
invoice_address: user.invoice_address ?? "prv", invoice_address: user.invoice_address ?? "prv",
}); });
const useCompanyAddress = ref(user.invoice_address === "org"); const withCompanyAddress = ref(user.invoice_address === "org");
const setUseCompanyAddress = (value: boolean) => { const setWithCompanyAddress = (value: boolean) => {
useCompanyAddress.value = value; withCompanyAddress.value = value;
address.value.invoice_address = value ? "org" : "prv"; address.value.invoice_address = value ? "org" : "prv";
}; };
const withCembraInvoice = ref<boolean>(false);
type FormErrors = { type FormErrors = {
personal: string[]; personal: string[];
company: string[]; company: string[];
@ -110,7 +117,7 @@ function validateAddress() {
formErrors.value.personal.push(t("a.Land")); formErrors.value.personal.push(t("a.Land"));
} }
if (useCompanyAddress.value) { if (withCompanyAddress.value) {
if (!address.value.organisation_detail_name) { if (!address.value.organisation_detail_name) {
formErrors.value.company.push(t("a.Name")); formErrors.value.company.push(t("a.Name"));
} }
@ -170,6 +177,7 @@ const executePayment = async () => {
redirect_url: fullHost, redirect_url: fullHost,
address: address.value, address: address.value,
product: props.courseType, product: props.courseType,
with_cembra_invoice: withCembraInvoice.value,
}).then((res: any) => { }).then((res: any) => {
console.log("Going to next page", res.next_step_url); console.log("Going to next page", res.next_step_url);
window.location.href = res.next_step_url; window.location.href = res.next_step_url;
@ -228,16 +236,23 @@ const executePayment = async () => {
</p> </p>
<PersonalAddress v-model="address" /> <PersonalAddress v-model="address" />
<p v-if="formErrors.personal.length" class="mb-10 text-red-700"> <div class="flex flex-row items-center space-x-2 text-sm">
{{ $t("a.Bitte folgende Felder ausfüllen") }}: <ItToggleSwitch
{{ formErrors.personal.join(", ") }} data-cy="cembra-switch"
</p> :initially-switched-left="withCembraInvoice"
@click="withCembraInvoice = !withCembraInvoice"
></ItToggleSwitch>
<span :class="{ 'text-gray-700': !withCembraInvoice }">
"TODO: Zahlung auf Rechnung/Cembra"
</span>
</div>
<div class="mt-4">
<button <button
v-if="!useCompanyAddress" v-if="!withCompanyAddress"
class="underline" class="underline"
data-cy="add-company-address" data-cy="add-company-address"
@click="setUseCompanyAddress(true)" @click="setWithCompanyAddress(true)"
> >
<template v-if="userOrganisationName"> <template v-if="userOrganisationName">
{{ {{
@ -248,6 +263,7 @@ const executePayment = async () => {
</template> </template>
<template v-else>{{ $t("a.Rechnungsadresse hinzufügen") }}</template> <template v-else>{{ $t("a.Rechnungsadresse hinzufügen") }}</template>
</button> </button>
</div>
<transition <transition
enter-active-class="transition ease-out duration-100" enter-active-class="transition ease-out duration-100"
@ -257,7 +273,7 @@ const executePayment = async () => {
leave-from-class="transform opacity-100 scale-y-100" leave-from-class="transform opacity-100 scale-y-100"
leave-to-class="transform opacity-0 scale-y-95" leave-to-class="transform opacity-0 scale-y-95"
> >
<div v-if="useCompanyAddress"> <div v-if="withCompanyAddress">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 v-if="userOrganisationName"> <h3 v-if="userOrganisationName">
{{ {{
@ -267,7 +283,7 @@ const executePayment = async () => {
}} }}
</h3> </h3>
<h3 v-else>{{ $t("a.Rechnungsadresse") }}</h3> <h3 v-else>{{ $t("a.Rechnungsadresse") }}</h3>
<button class="underline" @click="setUseCompanyAddress(false)"> <button class="underline" @click="setWithCompanyAddress(false)">
{{ $t("a.Entfernen") }} {{ $t("a.Entfernen") }}
</button> </button>
</div> </div>
@ -288,7 +304,11 @@ const executePayment = async () => {
<it-icon-arrow-left class="it-icon mr-2 h-6 w-6" /> <it-icon-arrow-left class="it-icon mr-2 h-6 w-6" />
{{ $t("general.back") }} {{ $t("general.back") }}
</router-link> </router-link>
<button class="btn-blue flex items-center" data-cy="continue-pay" @click="executePayment"> <button
class="btn-blue flex items-center"
data-cy="continue-pay"
@click="executePayment"
>
{{ $t("a.Mit Kreditkarte bezahlen") }} {{ $t("a.Mit Kreditkarte bezahlen") }}
<it-icon-arrow-right class="it-icon ml-2 h-6 w-6" /> <it-icon-arrow-right class="it-icon ml-2 h-6 w-6" />
</button> </button>

View File

@ -36,6 +36,8 @@ export interface User {
postal_code: string; postal_code: string;
city: string; city: string;
country: Country | null; country: Country | null;
birth_date: string;
phone_number: string;
organisation_detail_name: string; organisation_detail_name: string;
organisation_street: string; organisation_street: string;
organisation_street_number: string; organisation_street_number: string;

View File

@ -9,7 +9,7 @@ describe("checkout.cy.js", () => {
cy.visit("/"); 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(); cy.get('[data-cy="start-vv"]').click();
// wähle "Deutsch" // wähle "Deutsch"
@ -66,7 +66,6 @@ describe("checkout.cy.js", () => {
expect(ci.organisation_postal_code).to.equal("3012"); expect(ci.organisation_postal_code).to.equal("3012");
expect(ci.organisation_city).to.equal("Bern"); 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_name).to.equal("Versicherungsvermittler/-in VBV");
expect(ci.product_price).to.equal(32400); expect(ci.product_price).to.equal(32400);
@ -94,4 +93,64 @@ describe("checkout.cy.js", () => {
expect(ci.state).to.equal("paid"); 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");
});
});
}); });

View File

@ -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"

View File

@ -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),
),
]

View File

@ -111,6 +111,10 @@ class User(AbstractUser):
blank=True, 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 # is only set by abacus invoice export code
abacus_debitor_number = models.BigIntegerField(unique=True, null=True, blank=True) abacus_debitor_number = models.BigIntegerField(unique=True, null=True, blank=True)

View File

@ -69,6 +69,8 @@ class UserSerializer(serializers.ModelSerializer):
"postal_code", "postal_code",
"city", "city",
"country", "country",
"phone_number",
"birth_date",
"organisation_detail_name", "organisation_detail_name",
"organisation_street", "organisation_street",
"organisation_street_number", "organisation_street_number",

View File

@ -6,6 +6,7 @@ import time
import requests import requests
from django.conf import settings from django.conf import settings
from django.db import transaction
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
@ -16,12 +17,19 @@ from vbv_lernwelt.core.models import User
@csrf_exempt @csrf_exempt
@django_view_authentication_exempt @django_view_authentication_exempt
@transaction.non_atomic_requests
def fake_datatrans_api_view(request, api_url=""): def fake_datatrans_api_view(request, api_url=""):
if api_url == "/v1/transactions" and request.method == "POST": if api_url == "/v1/transactions" and request.method == "POST":
data = json.loads(request.body.decode("utf-8")) data = json.loads(request.body.decode("utf-8"))
user = User.objects.get(id=data["user_id"]) 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.additional_json_data["datatrans_transaction_payload"] = data
user.save() user.save()
return JsonResponse({"transactionId": data["refno"]}, status=201) return JsonResponse({"transactionId": data["refno"]}, status=201)
return HttpResponse( return HttpResponse(

View File

@ -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),
),
]

View File

@ -78,6 +78,14 @@ class CheckoutInformation(models.Model):
blank=True, 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( invoice_address = models.CharField(
max_length=3, choices=INVOICE_ADDRESS_CHOICES, default="prv" max_length=3, choices=INVOICE_ADDRESS_CHOICES, default="prv"
) )

View File

@ -52,6 +52,8 @@ def init_datatrans_transaction(
redirect_url_error: str, redirect_url_error: str,
redirect_url_cancel: str, redirect_url_cancel: str,
webhook_url: str, webhook_url: str,
datatrans_customer_data: dict = None,
datatrans_int_data: dict = None,
): ):
payload = { payload = {
# We use autoSettle=True, so that we don't have to settle the transaction: # 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 # add testing configuration data
if "fakeapi" in settings.DATATRANS_API_ENDPOINT: if "fakeapi" in settings.DATATRANS_API_ENDPOINT:
payload["user_id"] = str(user.id) payload["user_id"] = str(user.id)

View File

@ -1,3 +1,5 @@
from datetime import date
import structlog import structlog
from django.conf import settings from django.conf import settings
from django.http import JsonResponse from django.http import JsonResponse
@ -106,7 +108,40 @@ def checkout_vv(request):
if checkouts.filter(state=CheckoutState.PAID).exists(): if checkouts.filter(state=CheckoutState.PAID).exists():
return next_step_response(url="/") 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: 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( transaction_id = init_datatrans_transaction(
user=request.user, user=request.user,
amount_chf_centimes=product.price, amount_chf_centimes=product.price,
@ -118,6 +153,8 @@ def checkout_vv(request):
), ),
redirect_url_cancel=checkout_cancel_url(base_redirect_url), redirect_url_cancel=checkout_cancel_url(base_redirect_url),
webhook_url=webhook_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: except InitTransactionException as e:
if not settings.DEBUG: if not settings.DEBUG:
@ -138,6 +175,9 @@ def checkout_vv(request):
organisation_country_code = address_data.pop("organisation_country_code") organisation_country_code = address_data.pop("organisation_country_code")
address_data["organisation_country_id"] = 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( checkout_info = CheckoutInformation.objects.create(
user=request.user, user=request.user,
state=CheckoutState.ONGOING, state=CheckoutState.ONGOING,
@ -147,6 +187,9 @@ def checkout_vv(request):
product_price=product.price, product_price=product.price,
product_name=product.name, product_name=product.name,
product_description=product.description, product_description=product.description,
email=email,
ip_address=ip_address,
cembra_invoice=with_cembra_invoice,
# address # address
**request.data["address"], **request.data["address"],
) )