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;
city: string;
country_code: string;
phone_number: string;
birth_date: string;
};
}>();
@ -163,5 +166,37 @@ const address = computed({
</select>
</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>
</template>

View File

@ -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<boolean>(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 () => {
</p>
<PersonalAddress v-model="address" />
<p v-if="formErrors.personal.length" class="mb-10 text-red-700">
{{ $t("a.Bitte folgende Felder ausfüllen") }}:
{{ formErrors.personal.join(", ") }}
</p>
<div class="flex flex-row items-center space-x-2 text-sm">
<ItToggleSwitch
data-cy="cembra-switch"
:initially-switched-left="withCembraInvoice"
@click="withCembraInvoice = !withCembraInvoice"
></ItToggleSwitch>
<span :class="{ 'text-gray-700': !withCembraInvoice }">
"TODO: Zahlung auf Rechnung/Cembra"
</span>
</div>
<button
v-if="!useCompanyAddress"
class="underline"
data-cy="add-company-address"
@click="setUseCompanyAddress(true)"
>
<template v-if="userOrganisationName">
{{
$t("a.Rechnungsadresse von {organisation} hinzufügen", {
organisation: userOrganisationName,
})
}}
</template>
<template v-else>{{ $t("a.Rechnungsadresse hinzufügen") }}</template>
</button>
<div class="mt-4">
<button
v-if="!withCompanyAddress"
class="underline"
data-cy="add-company-address"
@click="setWithCompanyAddress(true)"
>
<template v-if="userOrganisationName">
{{
$t("a.Rechnungsadresse von {organisation} hinzufügen", {
organisation: userOrganisationName,
})
}}
</template>
<template v-else>{{ $t("a.Rechnungsadresse hinzufügen") }}</template>
</button>
</div>
<transition
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-to-class="transform opacity-0 scale-y-95"
>
<div v-if="useCompanyAddress">
<div v-if="withCompanyAddress">
<div class="flex items-center justify-between">
<h3 v-if="userOrganisationName">
{{
@ -267,7 +283,7 @@ const executePayment = async () => {
}}
</h3>
<h3 v-else>{{ $t("a.Rechnungsadresse") }}</h3>
<button class="underline" @click="setUseCompanyAddress(false)">
<button class="underline" @click="setWithCompanyAddress(false)">
{{ $t("a.Entfernen") }}
</button>
</div>
@ -288,7 +304,11 @@ const executePayment = async () => {
<it-icon-arrow-left class="it-icon mr-2 h-6 w-6" />
{{ $t("general.back") }}
</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") }}
<it-icon-arrow-right class="it-icon ml-2 h-6 w-6" />
</button>

View File

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

View File

@ -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");
});
});
});

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,
)
# 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)

View File

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

View File

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

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,
)
# 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"
)

View File

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

View File

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