Merged in feature/cembra (pull request #345)

Feature/cembra Zahlung auf Rechnung
This commit is contained in:
Daniel Egger 2024-07-10 17:08:26 +00:00
commit 32274108db
45 changed files with 1523 additions and 139 deletions

View File

@ -15,6 +15,7 @@
"@urql/exchange-graphcache": "^6.3.2",
"@urql/introspection": "^1.0.2",
"@urql/vue": "^1.1.2",
"@vuepic/vue-datepicker": "^8.8.1",
"@vueuse/core": "^10.9.0",
"@vueuse/router": "^10.9.0",
"cypress": "^12.14.0",
@ -8212,6 +8213,26 @@
"@vue/language-core": "1.8.1"
}
},
"node_modules/@vuepic/vue-datepicker": {
"version": "8.8.1",
"resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-8.8.1.tgz",
"integrity": "sha512-8ehfUz1m69Vuc16Pm4ukgb3Mg1VT14x4EsG1ag4O/qbSNRWztTo+pUV4JnFt0FGLl5gGb6NXlxIvR7EjLgD7Gg==",
"dependencies": {
"date-fns": "^3.6.0"
},
"peerDependencies": {
"vue": ">=3.2.0"
}
},
"node_modules/@vuepic/vue-datepicker/node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/@vueuse/core": {
"version": "10.9.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.9.0.tgz",
@ -26717,6 +26738,21 @@
"@vue/language-core": "1.8.1"
}
},
"@vuepic/vue-datepicker": {
"version": "8.8.1",
"resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-8.8.1.tgz",
"integrity": "sha512-8ehfUz1m69Vuc16Pm4ukgb3Mg1VT14x4EsG1ag4O/qbSNRWztTo+pUV4JnFt0FGLl5gGb6NXlxIvR7EjLgD7Gg==",
"requires": {
"date-fns": "^3.6.0"
},
"dependencies": {
"date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="
}
}
},
"@vueuse/core": {
"version": "10.9.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.9.0.tgz",

View File

@ -27,6 +27,7 @@
"@urql/exchange-graphcache": "^6.3.2",
"@urql/introspection": "^1.0.2",
"@urql/vue": "^1.1.2",
"@vuepic/vue-datepicker": "^8.8.1",
"@vueuse/core": "^10.9.0",
"@vueuse/router": "^10.9.0",
"cypress": "^12.14.0",

View File

@ -24,7 +24,9 @@ const user = useUserStore();
<p class="mb-4">
{{ $t("start.vvDescription") }}
</p>
<a href="/start/vv" class="btn-primary">{{ $t("a.Mehr erfahren") }}</a>
<a href="/start/vv" class="btn-primary" data-cy="start-vv">
{{ $t("a.Mehr erfahren") }}
</a>
</div>
<div>
@ -39,6 +41,7 @@ const user = useUserStore();
<router-link
:to="{ name: 'accountProfile', params: { courseType: 'uk' } }"
class="btn-primary"
data-cy="start-uk"
>
{{ $t("a.Jetzt mit Lehrgang starten") }}
</router-link>

View File

@ -0,0 +1,46 @@
<template>
<div ref="scriptContainer"></div>
<noscript>
<iframe
:src="iframeSrc"
style="width: 100px; height: 100px; border: 0; position: absolute; top: -5000px"
></iframe>
</noscript>
</template>
<script>
import { ref, computed, onMounted } from "vue";
import { getLocalSessionKey } from "@/statistics";
export default {
// code needed for Datatrans/Cembra device fingerprinting
setup() {
const scriptContainer = ref(null);
const sessionId = getLocalSessionKey();
const iframeSrc = computed(() => {
return `https://h.online-metrix.net/tags?org_id=lq866c5i&session_id=${sessionId}`;
});
const insertScript = () => {
const script = document.createElement("script");
script.type = "text/javascript";
script.src = `https://h.online-metrix.net/fp/tags.js?org_id=lq866c5i&session_id=${sessionId}`;
if (scriptContainer.value) {
scriptContainer.value.appendChild(script);
}
};
onMounted(() => {
insertScript();
});
return {
scriptContainer,
iframeSrc,
};
},
};
</script>
<style scoped></style>

View File

@ -1,6 +1,10 @@
<script setup lang="ts">
import { computed } from "vue";
import { useEntities } from "@/services/entities";
import VueDatePicker from "@vuepic/vue-datepicker";
import "@vuepic/vue-datepicker/dist/main.css";
import { t } from "i18next";
import { useUserStore } from "@/stores/user";
const props = defineProps<{
modelValue: {
@ -11,12 +15,29 @@ const props = defineProps<{
postal_code: string;
city: string;
country_code: string;
payment_method: string;
phone_number: string;
birth_date: string;
};
}>();
const emit = defineEmits(["update:modelValue"]);
const { countries } = useEntities();
const userStore = useUserStore();
const paymentMethods = [
{ value: "credit_card", label: t("a.Debit-/Kreditkarte/Twint") },
{ value: "cembra_byjuno", label: t("a.Rechnung") },
];
// TODO: remove after cembra is ready for production
const appEnv = import.meta.env.VITE_APP_ENVIRONMENT || "local";
if (appEnv.startsWith("prod")) {
paymentMethods.splice(1, 1);
}
// END TODO
const address = computed({
get() {
@ -163,5 +184,90 @@ const address = computed({
</select>
</div>
</div>
<div class="col-span-full">
<label
for="paymentMethod"
class="block text-sm font-medium leading-6 text-gray-900"
>
{{ $t("a.Zahlungsart") }}
</label>
<div class="mt-2">
<select
id="paymentMethod"
v-model="address.payment_method"
required
name="paymentMethod"
class="block w-full border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
>
<option v-for="pm in paymentMethods" :key="pm.value" :value="pm.value">
{{ pm.label }}
</option>
</select>
</div>
</div>
<div v-if="address.payment_method === 'cembra_byjuno'" class="col-span-full">
<p class="mt-4">
{{ $t("shop.paymentCembraByjunoMessage") }}
</p>
</div>
<div v-if="address.payment_method === 'cembra_byjuno'" 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 v-if="address.payment_method === 'cembra_byjuno'" 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">
<VueDatePicker
v-model="address.birth_date"
format="dd.MM.yyyy"
no-today
model-type="yyyy-MM-dd"
name="birth-date"
max-date="2007-01-01"
prevent-min-max-navigation
required
:enable-time-picker="false"
text-input
placeholder="15.06.1982"
start-date="1982-01-01"
:locale="userStore.language"
:cancel-text="$t('a.Abbrechen')"
:select-text="$t('a.Auswählen')"
></VueDatePicker>
</div>
</div>
</div>
</template>
<style>
/* Theming for date picker */
.dp__theme_light {
--dp-text-color: #585f63;
--dp-primary-color: #41b5fa;
--dp-border-color-focus: #3d6dcc;
}
:root {
--dp-font-family: "Buenos Aires" sans-serif;
--dp-border-radius: none;
--dp-font-size: 0.875rem;
--dp-cell-border-radius: none;
}
</style>

View File

@ -1,4 +1,5 @@
import { i18nextInit } from "@/i18nextWrapper";
import { generateLocalSessionKey } from "@/statistics";
import * as Sentry from "@sentry/vue";
import i18next from "i18next";
import I18NextVue from "i18next-vue";
@ -24,6 +25,8 @@ if (appEnv.startsWith("prod")) {
log.setLevel("trace");
}
generateLocalSessionKey();
const commit = "VBV_VERSION_BUILD_NUMBER_VBV";
log.warn(`application started appEnv=${appEnv}, build=${commit}`);

View File

@ -9,7 +9,9 @@ const userStore = useUserStore();
<template>
<WizardPage :step="0.5">
<template #content>
<h2 class="my-10">{{ $t("a.Konto erstellen") }}</h2>
<h2 class="my-10" data-cy="account-confirm-title">
{{ $t("a.Konto erstellen") }}
</h2>
<InfoBox color="text-green-500" icon="it-icon-check">
<template #content>
<p class="text-lg font-bold">
@ -27,7 +29,11 @@ const userStore = useUserStore();
</template>
<template #footer>
<router-link :to="{ name: 'accountProfile' }" class="btn-blue flex items-center">
<router-link
:to="{ name: 'accountProfile' }"
class="btn-blue flex items-center"
data-cy="continue-button"
>
{{ $t("general.next") }}
<it-icon-arrow-right class="it-icon ml-2 h-6 w-6" />
</router-link>

View File

@ -70,7 +70,9 @@ const nextRoute = computed(() => {
<template>
<WizardPage :step="1">
<template #content>
<h2 class="my-10">{{ $t("a.Profil ergänzen") }}</h2>
<h2 class="my-10" data-cy="account-profile-title">
{{ $t("a.Profil ergänzen") }}
</h2>
<h3 class="mb-3">{{ $t("a.Gesellschaft") }}</h3>
@ -120,6 +122,7 @@ const nextRoute = computed(() => {
:disabled="!validOrganisation"
class="btn-blue flex items-center"
role="link"
data-cy="continue-button"
@click="navigate"
>
{{ $t("general.next") }}

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import WizardPage from "@/components/onboarding/WizardPage.vue";
import { computed, ref } from "vue";
import { computed, ref, watch } from "vue";
import { type User, useUserStore } from "@/stores/user";
import PersonalAddress from "@/components/onboarding/PersonalAddress.vue";
import OrganisationAddress from "@/components/onboarding/OrganisationAddress.vue";
@ -9,6 +9,10 @@ import { useEntities } from "@/services/entities";
import { useRoute } from "vue-router";
import { useTranslation } from "i18next-vue";
import { getVVCourseName } from "./composables";
import DatatransCembraDeviceFingerprint from "@/components/onboarding/DatatransCembraDeviceFingerprint.vue";
import { getLocalSessionKey } from "@/statistics";
import log from "loglevel";
import { normalizeSwissPhoneNumber, validatePhoneNumber } from "@/utils/phone";
const props = defineProps({
courseType: {
@ -50,6 +54,12 @@ const address = ref({
postal_code: user.postal_code,
city: user.city,
country_code: user.country?.country_code ?? "CH",
payment_method: "credit_card",
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 +69,22 @@ 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";
};
watch(
() => address.value.payment_method,
(newValue) => {
if (newValue === "cembra_byjuno") {
setWithCompanyAddress(false);
}
}
);
type FormErrors = {
personal: string[];
company: string[];
@ -110,7 +129,25 @@ function validateAddress() {
formErrors.value.personal.push(t("a.Land"));
}
if (useCompanyAddress.value) {
if (address.value.phone_number) {
const normalizedPhoneNumber = normalizeSwissPhoneNumber(address.value.phone_number);
log.debug("normalizedPhoneNumber", normalizedPhoneNumber);
if (!validatePhoneNumber(normalizedPhoneNumber)) {
formErrors.value.personal.push(t("a.Telefonnummer hat das falsche Format"));
}
}
if (address.value.payment_method === "cembra_byjuno") {
if (!address.value.phone_number) {
formErrors.value.personal.push(t("a.Telefonnummer"));
}
if (!address.value.birth_date) {
formErrors.value.personal.push(t("a.Geburtsdatum"));
}
}
if (withCompanyAddress.value) {
if (!address.value.organisation_detail_name) {
formErrors.value.company.push(t("a.Name"));
}
@ -138,7 +175,8 @@ function validateAddress() {
}
async function saveAddress() {
const { country_code, organisation_country_code, ...profileData } = address.value;
const { country_code, organisation_country_code, phone_number, ...profileData } =
address.value;
const typedProfileData: Partial<User> = { ...profileData };
typedProfileData.country = countries.value.find(
@ -148,6 +186,10 @@ async function saveAddress() {
(c) => c.country_code === organisation_country_code
);
if (phone_number) {
typedProfileData.phone_number = normalizeSwissPhoneNumber(phone_number);
}
await user.updateUserProfile(typedProfileData);
}
@ -166,10 +208,20 @@ const executePayment = async () => {
// anyway, so it seems fine to do it here.
const fullHost = `${window.location.protocol}//${window.location.host}`;
if (address.value.phone_number) {
address.value.phone_number = normalizeSwissPhoneNumber(address.value.phone_number);
}
const addressData = Object.assign({}, address.value);
// @ts-ignore
delete addressData.payment_method;
itPost("/api/shop/vv/checkout/", {
redirect_url: fullHost,
address: address.value,
address: addressData,
product: props.courseType,
with_cembra_byjuno_invoice: address.value.payment_method === "cembra_byjuno",
device_fingerprint_session_key: getLocalSessionKey(),
}).then((res: any) => {
console.log("Going to next page", res.next_step_url);
window.location.href = res.next_step_url;
@ -180,7 +232,12 @@ const executePayment = async () => {
<template>
<WizardPage :step="2">
<template #content>
<h2 class="my-10">{{ $t("a.Lehrgang kaufen") }}</h2>
<DatatransCembraDeviceFingerprint
v-if="address.payment_method === 'cembra_byjuno'"
/>
<h2 class="my-10" data-cy="account-checkout-title">
{{ $t("a.Lehrgang kaufen") }}
</h2>
<p class="mb-4">
<i18next
:translation="$t('a.Der Preis für den Lehrgang {course} beträgt {price}.')"
@ -196,9 +253,6 @@ const executePayment = async () => {
$t("a.Mit dem Kauf erhältst du Zugang auf den gesamten Kurs (inkl. Prüfung).")
}}
</p>
<p>
{{ $t("a.Hier kannst du ausschliesslich mit einer Kreditkarte bezahlen.") }}
</p>
<p v-if="paymentError" class="text-bold mt-12 text-lg text-red-700">
{{
@ -231,50 +285,55 @@ const executePayment = async () => {
{{ formErrors.personal.join(", ") }}
</p>
<button
v-if="!useCompanyAddress"
class="underline"
@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>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-y-95"
enter-to-class="transform opacity-100 scale-y-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-y-100"
leave-to-class="transform opacity-0 scale-y-95"
>
<div v-if="useCompanyAddress">
<div class="flex items-center justify-between">
<h3 v-if="userOrganisationName">
<section v-if="address.payment_method !== 'cembra_byjuno'">
<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}", {
$t("a.Rechnungsadresse von {organisation} hinzufügen", {
organisation: userOrganisationName,
})
}}
</h3>
<h3 v-else>{{ $t("a.Rechnungsadresse") }}</h3>
<button class="underline" @click="setUseCompanyAddress(false)">
{{ $t("a.Entfernen") }}
</button>
</div>
<OrganisationAddress v-model="address" />
<p v-if="formErrors.company.length" class="text-red-700">
{{ $t("a.Bitte folgende Felder ausfüllen") }}:
{{ formErrors.company.join(", ") }}
</p>
</template>
<template v-else>{{ $t("a.Rechnungsadresse hinzufügen") }}</template>
</button>
</div>
</transition>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-y-95"
enter-to-class="transform opacity-100 scale-y-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-y-100"
leave-to-class="transform opacity-0 scale-y-95"
>
<div v-if="withCompanyAddress">
<div class="flex items-center justify-between">
<h3 v-if="userOrganisationName">
{{
$t("a.Rechnungsadresse von {organisation}", {
organisation: userOrganisationName,
})
}}
</h3>
<h3 v-else>{{ $t("a.Rechnungsadresse") }}</h3>
<button class="underline" @click="setWithCompanyAddress(false)">
{{ $t("a.Entfernen") }}
</button>
</div>
<OrganisationAddress v-model="address" />
<p v-if="formErrors.company.length" class="text-red-700">
{{ $t("a.Bitte folgende Felder ausfüllen") }}:
{{ formErrors.company.join(", ") }}
</p>
</div>
</transition>
</section>
</template>
<template #footer>
@ -285,8 +344,17 @@ 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" @click="executePayment">
{{ $t("a.Mit Kreditkarte bezahlen") }}
<button
class="btn-blue flex items-center"
data-cy="continue-pay"
@click="executePayment"
>
<template v-if="address.payment_method === 'cembra_byjuno'">
{{ $t("a.Mit Rechnung bezahlen") }}
</template>
<template v-else>
{{ $t("a.Mit Kreditkarte bezahlen") }}
</template>
<it-icon-arrow-right class="it-icon ml-2 h-6 w-6" />
</button>
</template>

View File

@ -6,7 +6,7 @@ const user = useUserStore();
<template>
<div class="flex flex-grow flex-col items-center gap-y-8 p-16">
<h1 class="my-10">{{ $t("a.Gratuliere!") }}</h1>
<h1 class="my-10" data-cy="checkout-success-title">{{ $t("a.Gratuliere!") }}</h1>
<svg
xmlns="http://www.w3.org/2000/svg"
width="472"
@ -43,7 +43,7 @@ const user = useUserStore();
</template>
</i18next>
</p>
<router-link to="/" class="btn-blue flex items-center">
<router-link to="/" class="btn-blue flex items-center" data-cy="start-vv-button">
{{ $t("a.Jetzt mit Lehrgang starten") }}
</router-link>
</div>

46
client/src/statistics.ts Normal file
View File

@ -0,0 +1,46 @@
let statisticsLocalSessionKey = "";
let statisticsLocalSessionRef = "";
export function uuidv4() {
// copied from https://stackoverflow.com/a/2117523/669561
// @ts-ignore
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) =>
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)
);
}
export function getLocalSessionKey() {
return (
window.sessionStorage.getItem("statisticsLocalSessionKey") ||
statisticsLocalSessionKey ||
""
);
}
export function generateLocalSessionKey() {
let localSessionKey =
window.sessionStorage.getItem("statisticsLocalSessionKey") ||
statisticsLocalSessionKey;
if (!localSessionKey) {
localSessionKey = uuidv4();
}
statisticsLocalSessionKey = localSessionKey;
window.sessionStorage.setItem("statisticsLocalSessionKey", localSessionKey);
return localSessionKey;
}
export function getLocalSessionRef() {
return (
window.sessionStorage.getItem("statisticsLocalSessionRef") ||
statisticsLocalSessionRef ||
""
);
}
export function setLocalSessionRef(sessionRef: string) {
if (sessionRef) {
statisticsLocalSessionRef = sessionRef;
window.sessionStorage.setItem("statisticsLocalSessionRef", sessionRef);
}
}

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;
@ -79,6 +81,8 @@ const initialUserState: User = {
postal_code: "",
city: "",
country: null,
birth_date: "",
phone_number: "",
organisation_detail_name: "",
organisation_street: "",
organisation_street_number: "",

View File

@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { normalizeSwissPhoneNumber, validatePhoneNumber } from "../phone";
describe("normalizeSwissPhoneNumber", () => {
it("should normalize a Swiss phone number", () => {
expect(normalizeSwissPhoneNumber("079 123 45 67")).toBe("+41791234567");
expect(normalizeSwissPhoneNumber("00 41 79 123 45 67")).toBe("+41791234567");
expect(normalizeSwissPhoneNumber("+41 (0)79 201 85 86")).toBe("+41792018586");
expect(normalizeSwissPhoneNumber("+41 79 201 85 86")).toBe("+41792018586");
});
it("should normalize remove spaces and special chars from foreign numbers", () => {
expect(normalizeSwissPhoneNumber("+49 30 12345678")).toBe("+493012345678");
expect(normalizeSwissPhoneNumber("+49-30-12345678")).toBe("+493012345678");
expect(normalizeSwissPhoneNumber("+33 1 23 45 67 89")).toBe("+33123456789");
expect(normalizeSwissPhoneNumber("+33-1-23-45-67-89")).toBe("+33123456789");
expect(normalizeSwissPhoneNumber("+39 06 12345678")).toBe("+390612345678");
expect(normalizeSwissPhoneNumber("+43 1 2345678")).toBe("+4312345678");
expect(normalizeSwissPhoneNumber("+423 234 5678")).toBe("+4232345678");
});
});
describe("validatePhoneNumber", () => {
it("should validate a Swiss phone number", () => {
expect(validatePhoneNumber("079 123 45 67")).toBe(true);
expect(validatePhoneNumber("00 41 79 123 45 67")).toBe(true);
expect(validatePhoneNumber("+41 (0)79 201 85 86")).toBe(true);
expect(validatePhoneNumber("+41 79 201 85 86")).toBe(true);
expect(validatePhoneNumber("026 418 01 31")).toBe(true);
expect(validatePhoneNumber("+41 79 201 85 86 8")).toBe(false);
expect(validatePhoneNumber("079 201 85 8")).toBe(false);
expect(validatePhoneNumber("aaa aaa aaa aaa")).toBe(false);
});
it("should validate a foreign phone number", () => {
expect(validatePhoneNumber("+49 30 12345678")).toBe(true);
expect(validatePhoneNumber("+49 30 1234567")).toBe(false);
expect(validatePhoneNumber("+49 30 123456789")).toBe(false);
expect(validatePhoneNumber("+33 1 23 45 67 89")).toBe(true);
expect(validatePhoneNumber("+33 1 23 45 67 89 9")).toBe(false);
expect(validatePhoneNumber("+39 06 12345678")).toBe(true);
expect(validatePhoneNumber("+39 06 1234567")).toBe(false);
expect(validatePhoneNumber("+43 1 2345678")).toBe(true);
expect(validatePhoneNumber("+43 1 23456789")).toBe(false);
expect(validatePhoneNumber("+423 235 09 09")).toBe(true);
expect(validatePhoneNumber("+423 235 09 09 8")).toBe(false);
expect(validatePhoneNumber("+354 123 4567")).toBe(true);
expect(validatePhoneNumber("+55 12 34567 8901")).toBe(true);
});
});

69
client/src/utils/phone.ts Normal file
View File

@ -0,0 +1,69 @@
export function normalizeSwissPhoneNumber(input: string) {
return input
.replace(/\s+/g, "")
.replace("(0)", "")
.replaceAll("-", "")
.replaceAll("/", "")
.replaceAll("(", "")
.replaceAll(")", "")
.replace(/^0041/, "+41")
.replace(/^\+410/, "+41")
.replace(/^0/, "+41");
}
export function validatePhoneNumber(input: string) {
const normalized = normalizeSwissPhoneNumber(input);
if (
!normalized.startsWith("+") ||
isNaN(Number(normalized.slice(1))) ||
normalized[1] === "0"
) {
// phone number can only start with a + and must be followed by numbers
return false;
}
if (["+41", "+43", "+49", "+39", "+33", "+42"].includes(normalized.slice(0, 3))) {
if (
// Swiss and French phone numbers
(normalized.startsWith("+41") || normalized.startsWith("+33")) &&
normalized.length === 12
) {
return true;
} else if (
// German and Italian phone numbers
(normalized.startsWith("+49") || normalized.startsWith("+39")) &&
normalized.length === 13
) {
return true;
} else if (
// Austrian and Liechtenstein phone numbers
(normalized.startsWith("+43") || normalized.startsWith("+423")) &&
normalized.length === 11
) {
return true;
}
return false;
}
// every other country
if (normalized.length >= 10 || normalized.length <= 13) {
return true;
}
return false;
}
export function displaySwissPhoneNumber(input: string) {
if (input && input.length === 12 && input.startsWith("+41")) {
input = input.replace("+41", "0");
let result = "";
result += input.substring(0, 3) + " ";
result += input.substring(3, 6) + " ";
result += input.substring(6, 8) + " ";
result += input.substring(8);
return result;
}
return input;
}

View File

@ -1,8 +1,16 @@
// ids for cypress test data
export const ADMIN_USER_ID = "872efd96-3bd7-4a1e-a239-2d72cad9f604";
export const TEST_TRAINER1_USER_ID = "b9e71f59-c44f-4290-b93a-9b3151e9a2fc";
export const TEST_STUDENT1_USER_ID = "65c73ad0-6d53-43a9-a4a4-64143f27b03a";
export const TEST_STUDENT2_USER_ID = "19c40d94-15cc-4198-aaad-ef707c4b0900";
export const ADMIN_USER_ID = "872efd96-3bd7-4a1e-a239-2d72cad9f604"
export const TEST_SUPERVISOR1_USER_ID = "a9a8b741-f115-4521-af2d-7dfef673b8c5"
export const TEST_TRAINER1_USER_ID = "b9e71f59-c44f-4290-b93a-9b3151e9a2fc"
export const TEST_TRAINER2_USER_ID = "299941ae-1e4b-4f45-8180-876c3ad340b4"
export const TEST_STUDENT1_USER_ID = "65c73ad0-6d53-43a9-a4a4-64143f27b03a"
export const TEST_STUDENT2_USER_ID = "19c40d94-15cc-4198-aaad-ef707c4b0900"
export const TEST_STUDENT3_USER_ID = "bcf94dba-53bc-474b-a22d-e4af39aa042b"
export const TEST_MENTOR1_USER_ID = "d1f5f5a9-5b0a-4e1a-9e1a-9e9b5b5e1b1b"
export const TEST_STUDENT1_VV_USER_ID = "5ff59857-8de5-415e-a387-4449f9a0337a"
export const TEST_STUDENT2_VV_AND_VV_MENTOR_USER_ID = "7e8ebf0b-e6e2-4022-88f4-6e663ba0a9db"
export const TEST_USER_EMPTY_ID = "daecbabe-4ab9-4edf-a71f-4119042ccb02"
export const TEST_COURSE_SESSION_BERN_ID = -1;
export const TEST_COURSE_SESSION_ZURICH_ID = -2;

View File

@ -0,0 +1,187 @@
import { TEST_USER_EMPTY_ID } from "../../consts";
import { login } from "../helpers";
describe("checkout.cy.js", () => {
beforeEach(() => {
cy.manageCommand("cypress_reset");
login("empty@example.com", "test");
cy.visit("/");
});
it("can checkout and buy Versicherungsvermittlerin with credit card", () => {
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('[data-cy="add-company-address"]').click();
cy.get("#company-name").type("Iterativ GmbH");
cy.get("#company-street-address").type("Brückfeldstrasse");
cy.get("#company-street-number").type("16");
cy.get("#company-postal-code").type("3012");
cy.get("#company-city").type("Bern");
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.invoice_address).to.equal("org");
expect(ci.organisation_detail_name).to.equal("Iterativ GmbH");
expect(ci.organisation_street).to.equal("Brückfeldstrasse");
expect(ci.organisation_street_number).to.equal("16");
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_price).to.equal(32400);
expect(ci.state).to.equal("ongoing");
});
// pay
cy.get('[data-cy="pay-button"]').click();
cy.get('[data-cy="checkout-success-title"]').should(
"contain",
"Gratuliere"
);
// wait for payment callback
cy.wait(3000);
cy.get('[data-cy="start-vv-button"]').click();
// back on dashboard page
cy.get('[data-cy="db-course-title"]').should(
"contain",
"Versicherungsvermittler"
);
cy.loadCheckoutInformation("user_id", TEST_USER_EMPTY_ID).then((ci) => {
expect(ci.state).to.equal("paid");
});
});
it("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('#paymentMethod').select('cembra_byjuno');
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("079 201 85 86");
cy.get('[data-test="dp-input"]').type("09.06.1982{enter}");
cy.get('[data-cy="continue-pay"]').click();
cy.loadExternalApiRequestLog("request_username", "empty@example.com").then((entry) => {
// ends with "/v1/transactions""
expect(entry.api_url).to.contain("/v1/transactions");
expect(entry.request_username).to.contain("empty@example.com");
expect(entry.api_request_data.amount).to.equal(32400);
expect(entry.api_request_data.currency).to.equal("CHF");
expect(entry.api_request_data.autoSettle).to.equal(true);
expect(entry.api_request_data.customer.firstName).to.equal("Flasche");
expect(entry.api_request_data.customer.lastName).to.equal("Leer");
expect(entry.api_request_data.customer.street).to.equal("Eggersmatt 32");
expect(entry.api_request_data.customer.zipCode).to.equal("1719");
expect(entry.api_request_data.customer.city).to.equal("Zumholz");
expect(entry.api_request_data.customer.country).to.equal("CH");
expect(entry.api_request_data.customer.type).to.equal("P");
expect(entry.api_request_data.customer.phone).to.equal("+41792018586");
expect(entry.api_request_data.customer.birthDate).to.equal("1982-06-09");
expect(entry.api_request_data.INT.repaymentType).to.equal(3);
expect(entry.api_request_data.INT.riskOwner).to.equal("IJ");
expect(entry.api_request_data.INT.subtype).to.equal("INVOICE");
expect(entry.api_request_data.INT.deviceFingerprintId).to.not.be.empty;
expect(true).to.be.true;
});
// 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("+41792018586");
expect(ci.birth_date).to.equal("1982-06-09");
expect(ci.cembra_byjuno_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");
expect(ci.ip_address).to.not.be.empty;
expect(ci.device_fingerprint_session_key).to.not.be.empty;
});
});
});

View File

@ -133,7 +133,27 @@ Cypress.Commands.add("loadAssignmentCompletion", (key, value) => {
key,
value,
"vbv_lernwelt.assignment.models.AssignmentCompletion",
"vbv_lernwelt.assignment.serializers.AssignmentCompletionSerializer",
"vbv_lernwelt.assignment.serializers.CypressAssignmentCompletionSerializer",
true
);
});
Cypress.Commands.add("loadSecurityRequestResponseLog", (key, value) => {
return loadObjectJson(
key,
value,
"vbv_lernwelt.core.models.SecurityRequestResponseLog",
"vbv_lernwelt.core.serializers.CypressSecurityRequestResponseLogSerializer",
true
);
});
Cypress.Commands.add("loadExternalApiRequestLog", (key, value) => {
return loadObjectJson(
key,
value,
"vbv_lernwelt.core.models.ExternalApiRequestLog",
"vbv_lernwelt.core.serializers.CypressExternalApiRequestLogSerializer",
true
);
});
@ -148,6 +168,16 @@ Cypress.Commands.add("loadFeedbackResponse", (key, value) => {
);
});
Cypress.Commands.add("loadCheckoutInformation", (key, value) => {
return loadObjectJson(
key,
value,
"vbv_lernwelt.shop.models.CheckoutInformation",
"vbv_lernwelt.shop.serializers.CypressCheckoutInformationSerializer",
true
);
});
Cypress.Commands.add("makeSelfEvaluation", (answers) => {
for (let i = 0; i < answers.length; i++) {
const answer = answers[i];

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

@ -69,7 +69,7 @@ from vbv_lernwelt.importer.views import (
from vbv_lernwelt.media_files.views import user_image
from vbv_lernwelt.notify.views import email_notification_settings
from vbv_lernwelt.shop.datatrans_fake_server import (
from vbv_lernwelt.shop.datatrans.datatrans_fake_server import (
fake_datatrans_api_view,
fake_datatrans_pay_view,
)

View File

@ -596,6 +596,10 @@ typing-extensions==4.12.2
# ufmt
# uvicorn
# wagtail-localize
typing-inspect==0.9.0
# via libcst
ua-parser==0.18.0
# via -r requirements.in
ufmt==2.7.0
# via -r requirements-dev.in
uritemplate==4.1.1

View File

@ -8,6 +8,7 @@ redis # https://github.com/redis/redis-py
uvicorn[standard] # https://github.com/encode/uvicorn
environs
click
ua-parser
# Django
# ------------------------------------------------------------------------------

View File

@ -360,6 +360,8 @@ typing-extensions==4.12.2
# jwcrypto
# uvicorn
# wagtail-localize
ua-parser==0.18.0
# via -r requirements.in
uritemplate==4.1.1
# via drf-spectacular
urllib3==2.2.2

View File

@ -43,7 +43,8 @@ class ProfileViewTest(APITestCase):
# THEN
self.assertEqual(response.status_code, status.HTTP_200_OK)
profile = response.data
self.assertEqual(
self.maxDiff = None
self.assertDictEqual(
profile,
{
"id": str(self.user.id),
@ -62,6 +63,8 @@ class ProfileViewTest(APITestCase):
"postal_code": "",
"city": "",
"country": None,
"phone_number": "",
"birth_date": None,
"organisation_detail_name": "",
"organisation_street": "",
"organisation_street_number": "",

View File

@ -3,7 +3,7 @@ from rest_framework import serializers
from vbv_lernwelt.assignment.models import AssignmentCompletion
class AssignmentCompletionSerializer(serializers.ModelSerializer):
class CypressAssignmentCompletionSerializer(serializers.ModelSerializer):
class Meta:
model = AssignmentCompletion
fields = [

View File

@ -2,7 +2,13 @@ from django.contrib import admin
from django.contrib.auth import admin as auth_admin, get_user_model
from django.utils.translation import gettext_lazy as _
from vbv_lernwelt.core.models import Country, JobLog, Organisation
from vbv_lernwelt.core.models import (
Country,
ExternalApiRequestLog,
JobLog,
Organisation,
SecurityRequestResponseLog,
)
from vbv_lernwelt.core.utils import pretty_print_json
User = get_user_model()
@ -54,6 +60,8 @@ class UserAdmin(auth_admin.UserAdmin):
"postal_code",
"city",
"country",
"birth_date",
"phone_number",
"invoice_address",
)
},
@ -109,6 +117,95 @@ class JobLogAdmin(LogAdmin):
return None
@admin.register(SecurityRequestResponseLog)
class SecurityRequestResponseLogAdmin(LogAdmin):
date_hierarchy = "created"
list_display = (
"created",
"label",
"type",
"action",
"ref",
"request_username",
"request_full_path",
"request_method",
"response_status_code",
"request_client_ip",
"request_elapse_time",
"request_trace_id",
"session_key",
"user_agent_os",
"user_agent_browser",
)
list_filter = [
"label",
"type",
"action",
"request_full_path",
"request_method",
"response_status_code",
"user_agent_os",
"user_agent_browser",
]
search_fields = [
"request_username",
"request_full_path",
"request_client_ip",
"request_trace_id",
"additional_json_data",
]
def additional_json_data_pretty(self, instance):
return self.pretty_print_json(instance.additional_json_data)
def get_readonly_fields(self, request, obj=None):
return super().get_readonly_fields(request, obj) + [
"additional_json_data_pretty"
]
@admin.register(ExternalApiRequestLog)
class ExternalApiRequestLogAdmin(LogAdmin):
date_hierarchy = "created"
list_display = (
"created",
"api_request_verb",
"api_url",
"api_response_status_code",
"elapsed_time",
"request_trace_id",
"request_username",
)
search_fields = [
"request_username",
"request_trace_id",
"api_request_data",
"api_response_data",
"additional_json_data",
]
list_filter = [
"api_response_status_code",
"api_request_verb",
"api_url",
]
def api_request_data_pretty(self, instance):
return self.pretty_print_json(instance.api_request_data)
def api_response_data_pretty(self, instance):
return self.pretty_print_json(instance.api_response_data)
def additional_data_pretty(self, instance):
return self.pretty_print_json(instance.additional_data)
def get_readonly_fields(self, request, obj=None):
return super().get_readonly_fields(request, obj) + [
"api_request_data_pretty",
"api_response_data_pretty",
"additional_data_pretty",
]
@admin.register(Organisation)
class OrganisationAdmin(admin.ModelAdmin):
list_display = (

View File

@ -0,0 +1,85 @@
import time
from functools import wraps
import structlog
from vbv_lernwelt.core.models import ExternalApiRequestLog
logger = structlog.get_logger(__name__)
class ExternalApiRequestLogDecorator:
def __init__(self, base_url, store_request_data=True, store_response_data=True):
self.base_url = base_url
self.store_request_data = store_request_data
self.store_response_data = store_response_data
def __call__(self, fn):
@wraps(fn)
def wrapper(*args, **kwargs):
store_request_data = self.store_request_data
store_response_data = self.store_response_data
request_data = kwargs.get("json", None)
if not request_data:
request_data = kwargs.get("data", None)
if not request_data:
request_data = kwargs.get("params", None)
if not request_data:
request_data = args[2] if len(args) > 2 else {}
url = args[1]
context_data = kwargs.get("context_data", {}) or {}
if not store_request_data:
request_data = {"hidden": True}
logger.debug(
"try to call subhub external api",
request_url=args[1],
request_data=request_data,
base_url=self.base_url,
label="subhub_external_communication",
)
log = ExternalApiRequestLog()
log.api_url = f"{self.base_url}{url}"
log.api_request_data = request_data
log.request_trace_id = context_data.get("request_trace_id", "")
log.request_username = context_data.get("request_username", "")
log.additional_json_data = context_data
# user = get_request_user()
# if user:
# log.user = user.username
log.save()
start = time.time()
api_response = fn(*args, **kwargs)
log.elapsed_time = round(time.time() - start, 3)
log.api_request_verb = str(api_response.request.method)
log.api_response_status_code = api_response.status_code
if store_response_data:
log.api_response_data = str(api_response.text)
else:
log.api_response_data = "hidden"
log.save()
logger.info(
"call to subhub external api successful",
api_request_verb=log.api_request_verb,
api_url=log.api_url,
api_response_status_code=log.api_response_status_code,
api_time=log.elapsed_time,
api_request_data=log.api_request_data,
api_response_data=log.api_response_data,
label="subhub_external_communication",
)
return api_response
return wrapper

View File

@ -3,6 +3,7 @@ from datetime import datetime
import djclick as click
from dateutil.relativedelta import relativedelta, TU
from django.contrib.auth.hashers import make_password
from django.db import connection
from django.utils import timezone
from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletion
@ -158,6 +159,11 @@ def command(
password=make_password("test"),
)
cursor = connection.cursor()
cursor.execute("truncate core_securityrequestresponselog;")
cursor.execute("truncate core_externalapirequestlog;")
cursor.execute("truncate django_cache_table;")
if create_assignment_completion or create_assignment_evaluation:
print("create assignment completion data for test course")
create_test_assignment_submitted_data(

View File

@ -1,12 +1,13 @@
import time
import uuid
import structlog
from django.core.exceptions import PermissionDenied
from django.http import Http404
from ipware import get_client_ip
from structlog.threadlocal import bind_threadlocal, clear_threadlocal
from ua_parser import user_agent_parser
from vbv_lernwelt.core.models import SecurityRequestResponseLog
from vbv_lernwelt.importer.utils import try_parse_int
logger = structlog.get_logger(__name__)
@ -30,30 +31,68 @@ class SecurityRequestResponseLoggingMiddleware:
def create_logging_threadlocalbind(self, request):
request_username = request.user.username if hasattr(request, "user") else ""
request_trace_id = uuid.uuid4().hex
bind_threadlocal(
request_method=request.method,
request_full_path=request.get_full_path(),
request_username=request_username,
request_client_ip=request.META.get("REMOTE_ADDR"),
request_trace_id=uuid.uuid4().hex,
request_trace_id=request_trace_id,
)
def create_database_security_request_response_log(self, request, response):
return request_trace_id
def create_database_security_request_response_log(
self, request, response, elapsed_time, request_trace_id=""
):
try:
entry = SecurityRequestResponseLog()
entry.label = getattr(request, "security_request_logging", "")
entry.type = getattr(request, "type", "")
entry.request_method = request.method
entry.request_full_path = request.get_full_path()[:255]
entry.request_full_path = request.get_full_path()[0:250]
entry.request_username = (
request.user.username if hasattr(request, "user") else ""
)
entry.request_client_ip = request.META.get("REMOTE_ADDR")
entry.request_scn = getattr(request, "scn", "")
entry.response_status_code = response.status_code
entry.additional_json_data = getattr(
request, "log_additional_json_data", {}
)
entry.request_trace_id = request_trace_id
entry.request_elapse_time = elapsed_time
entry.save()
# save predefined fields
additional_json_data = getattr(request, "log_additional_json_data", {})
entry.category = additional_json_data.pop("category", "")[0:255].strip()
entry.action = additional_json_data.pop("action", "")[0:255].strip()
entry.name = additional_json_data.pop("name", "")[0:255].strip()
entry.ref = additional_json_data.pop("ref", "")[0:255].strip()
entry.local_url = additional_json_data.pop("local_url", "")[0:255].strip()
entry.session_key = additional_json_data.pop("session_key", "")[
0:255
].strip()
_, value = try_parse_int(additional_json_data.pop("value", 0), 0)
entry.value = value
entry.save()
user_agent = request.headers.get("User-Agent", "")
entry.user_agent = user_agent[0:4096].strip()
try:
ua_parsed = user_agent_parser.Parse(user_agent)
entry.user_agent_parsed = user_agent_parser.Parse(user_agent)
entry.user_agent_os = ua_parsed.get("os").get("family")
entry.user_agent_browser = ua_parsed.get("user_agent").get("family")
# pylint: disable=broad-except
except Exception as e:
logger.warning(
"error while parsing user agent",
label="analytics",
user_agent=user_agent,
error=str(e),
)
entry.additional_json_data = additional_json_data
entry.save()
@ -63,18 +102,32 @@ class SecurityRequestResponseLoggingMiddleware:
def log_request_response(self, request):
clear_threadlocal()
self.create_logging_threadlocalbind(request)
request_trace_id = self.create_logging_threadlocalbind(request)
request.request_trace_id = request_trace_id
logger.info(
"url access initialized",
label="security",
)
start_time = time.time()
response = self.get_response(request)
elapsed_time = round(time.time() - start_time, 3)
security_request_logging = getattr(request, "security_request_logging", None)
if security_request_logging:
self.create_database_security_request_response_log(request, response)
try:
security_request_logging = getattr(
request, "security_request_logging", None
)
if security_request_logging:
self.create_database_security_request_response_log(
request,
response,
elapsed_time=elapsed_time,
request_trace_id=request_trace_id,
)
# pylint: disable=broad-except
except Exception:
logger.warn("could not create db entry", label="security", exc_info=True)
logger.info(
"url access finished",
@ -82,6 +135,7 @@ class SecurityRequestResponseLoggingMiddleware:
response_status_code=response.status_code,
request_ratelimited=getattr(request, "limited", False),
request_finished=True,
elapsed_time=elapsed_time,
)
clear_threadlocal()
@ -90,18 +144,3 @@ class SecurityRequestResponseLoggingMiddleware:
def __call__(self, request):
return self.log_request_response(request)
def process_exception(self, request, exception):
if isinstance(exception, (Http404, PermissionDenied)):
# We don't log an exception here, and we don't set that we handled
# an error as we want the standard `request_finished` log message
# to be emitted.
return
self._raised_exception = True
logger.exception(
"request_failed",
label="security",
response_status_code=500,
)

View File

@ -0,0 +1,22 @@
# 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

@ -0,0 +1,15 @@
# Generated by Django 3.2.20 on 2024-06-21 13:10
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0010_auto_20240620_1625"),
]
operations = [
migrations.DeleteModel(
name="SecurityRequestResponseLog",
),
]

View File

@ -0,0 +1,126 @@
# Generated by Django 3.2.20 on 2024-06-21 14:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0011_delete_securityrequestresponselog"),
]
operations = [
migrations.CreateModel(
name="ExternalApiRequestLog",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created", models.DateTimeField(auto_now_add=True)),
("api_url", models.TextField(blank=True, default="")),
("api_request_data", models.JSONField(blank=True, default=dict)),
(
"api_request_verb",
models.CharField(blank=True, default="", max_length=255),
),
("api_response_status_code", models.IntegerField(default=0)),
("api_response_data", models.TextField(blank=True, default="")),
(
"request_username",
models.CharField(blank=True, default="", max_length=255),
),
(
"request_trace_id",
models.CharField(blank=True, default="", max_length=255),
),
("elapsed_time", models.FloatField(default=0)),
("additional_json_data", models.JSONField(blank=True, default=dict)),
],
),
migrations.CreateModel(
name="SecurityRequestResponseLog",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created", models.DateTimeField(auto_now_add=True)),
("label", models.CharField(blank=True, default="", max_length=255)),
("type", models.CharField(blank=True, default="", max_length=255)),
(
"request_trace_id",
models.CharField(blank=True, default="", max_length=255),
),
(
"request_method",
models.CharField(blank=True, default="", max_length=255),
),
(
"request_full_path",
models.CharField(blank=True, default="", max_length=255),
),
(
"request_username",
models.CharField(blank=True, default="", max_length=255),
),
(
"request_client_ip",
models.CharField(blank=True, default="", max_length=255),
),
("request_elapse_time", models.FloatField(default=0)),
(
"response_status_code",
models.CharField(blank=True, default="", max_length=255),
),
("category", models.CharField(blank=True, default="", max_length=255)),
("action", models.CharField(blank=True, default="", max_length=255)),
("name", models.CharField(blank=True, default="", max_length=255)),
("ref", models.CharField(blank=True, default="", max_length=255)),
("value", models.IntegerField(default=0)),
("local_url", models.CharField(blank=True, default="", max_length=255)),
(
"session_key",
models.CharField(blank=True, default="", max_length=255),
),
("user_agent", models.TextField(blank=True, default="")),
("user_agent_parsed", models.JSONField(blank=True, default=dict)),
(
"user_agent_os",
models.CharField(blank=True, default="", max_length=255),
),
(
"user_agent_browser",
models.CharField(blank=True, default="", max_length=255),
),
("additional_json_data", models.JSONField(blank=True, default=dict)),
],
),
migrations.AddIndex(
model_name="securityrequestresponselog",
index=models.Index(fields=["type"], name="core_securi_type_009727_idx"),
),
migrations.AddIndex(
model_name="securityrequestresponselog",
index=models.Index(fields=["label"], name="core_securi_label_dd1821_idx"),
),
migrations.AddIndex(
model_name="securityrequestresponselog",
index=models.Index(
fields=["category"], name="core_securi_categor_1b776c_idx"
),
),
migrations.AddIndex(
model_name="securityrequestresponselog",
index=models.Index(fields=["action"], name="core_securi_action_5df089_idx"),
),
]

View File

@ -114,10 +114,14 @@ 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)
def set_increment_abacus_debitor_number(self):
def set_increment_abacus_debitor_number(self, disable_save=False):
if self.abacus_debitor_number:
return self
@ -129,7 +133,8 @@ class User(AbstractUser):
current_max if current_max is not None else 60_000_000
) + 1
self.abacus_debitor_number = new_debitor_number
self.save()
if not disable_save:
self.save()
return self
def create_avatar_url(self, size=400):
@ -163,16 +168,63 @@ class User(AbstractUser):
class SecurityRequestResponseLog(models.Model):
label = models.CharField(max_length=255, blank=True, default="")
created = models.DateTimeField(auto_now_add=True)
label = models.CharField(max_length=255, blank=True, default="")
type = models.CharField(max_length=255, blank=True, default="")
request_trace_id = models.CharField(max_length=255, blank=True, default="")
request_method = models.CharField(max_length=255, blank=True, default="")
request_full_path = models.CharField(max_length=255, blank=True, default="")
request_username = models.CharField(max_length=255, blank=True, default="")
request_client_ip = models.CharField(max_length=255, blank=True, default="")
request_elapse_time = models.FloatField(default=0)
response_status_code = models.CharField(max_length=255, blank=True, default="")
additional_json_data = JSONField(default=dict, blank=True)
category = models.CharField(max_length=255, blank=True, default="")
action = models.CharField(max_length=255, blank=True, default="")
name = models.CharField(max_length=255, blank=True, default="")
ref = models.CharField(max_length=255, blank=True, default="")
value = models.IntegerField(default=0)
local_url = models.CharField(max_length=255, blank=True, default="")
session_key = models.CharField(max_length=255, blank=True, default="")
user_agent = models.TextField(blank=True, default="")
user_agent_parsed = models.JSONField(blank=True, default=dict)
user_agent_os = models.CharField(max_length=255, blank=True, default="")
user_agent_browser = models.CharField(max_length=255, blank=True, default="")
additional_json_data = models.JSONField(default=dict, blank=True)
class Meta:
indexes = [
models.Index(fields=["type"]),
models.Index(fields=["label"]),
models.Index(fields=["category"]),
models.Index(fields=["action"]),
]
class ExternalApiRequestLog(models.Model):
created = models.DateTimeField(auto_now_add=True)
api_url = models.TextField(blank=True, default="")
api_request_data = models.JSONField(default=dict, blank=True)
api_request_verb = models.CharField(max_length=255, blank=True, default="")
api_response_status_code = models.IntegerField(default=0)
api_response_data = models.TextField(blank=True, default="")
request_username = models.CharField(max_length=255, blank=True, default="")
request_trace_id = models.CharField(max_length=255, blank=True, default="")
elapsed_time = models.FloatField(default=0)
additional_json_data = models.JSONField(default=dict, blank=True)
def __str__(self):
return f"{self.api_request_verb} {self.api_response_status_code} {self.api_url}"
class JobLog(models.Model):

View File

@ -3,7 +3,13 @@ from typing import List
from rest_framework import serializers
from rest_framework.renderers import JSONRenderer
from vbv_lernwelt.core.models import Country, Organisation, User
from vbv_lernwelt.core.models import (
Country,
ExternalApiRequestLog,
Organisation,
SecurityRequestResponseLog,
User,
)
from vbv_lernwelt.course.models import CourseSessionUser
from vbv_lernwelt.course_session_group.models import CourseSessionGroup
@ -69,6 +75,8 @@ class UserSerializer(serializers.ModelSerializer):
"postal_code",
"city",
"country",
"phone_number",
"birth_date",
"organisation_detail_name",
"organisation_street",
"organisation_street_number",
@ -140,3 +148,15 @@ class OrganisationSerializer(serializers.ModelSerializer):
return obj.name_it
return obj.name_de
class CypressExternalApiRequestLogSerializer(serializers.ModelSerializer):
class Meta:
model = ExternalApiRequestLog
fields = "__all__"
class CypressSecurityRequestResponseLogSerializer(serializers.ModelSerializer):
class Meta:
model = SecurityRequestResponseLog
fields = "__all__"

View File

@ -2,3 +2,6 @@
VV_DE_PRODUCT_SKU = "vv-de"
VV_FR_PRODUCT_SKU = "vv-fr"
VV_IT_PRODUCT_SKU = "vv-it"
# VBV Abacus VV product number
VV_PRODUCT_NUMBER = "30202"

View File

@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
import requests
import structlog
from django.conf import settings
from vbv_lernwelt.core.external_request_logger import ExternalApiRequestLogDecorator
logger = structlog.getLogger(__name__)
class DatatransApiClient:
def __init__(self):
self.base_url = settings.DATATRANS_API_ENDPOINT
@ExternalApiRequestLogDecorator(
base_url=settings.DATATRANS_API_ENDPOINT,
)
def _get_request(self, url, params, headers=None, context_data=None):
complete_url = self.base_url + url
return requests.get(complete_url, params=params, headers=headers)
@ExternalApiRequestLogDecorator(
base_url=settings.DATATRANS_API_ENDPOINT,
)
def _post_request(self, url, json, headers=None, context_data=None):
complete_url = self.base_url + url
return requests.post(complete_url, json=json, headers=headers)
def post_initialize_transactions(
self, json_payload: dict, context_data: dict = None
):
url = "/v1/transactions"
return self._post_request(
url,
json=json_payload,
headers={
"Authorization": f"Basic {settings.DATATRANS_BASIC_AUTH_KEY}",
"Content-Type": "application/json",
},
context_data=context_data,
)

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(
@ -88,8 +96,8 @@ def fake_datatrans_pay_view(request, api_url=""):
<label>failed</label>
</div>
<div>
<button type="submit">
Pay with selected Status
<button type="submit" data-cy="pay-button">
Pay with selected status
</button>
</div>
</fieldset>

View File

@ -5,6 +5,7 @@ from xml.etree.ElementTree import Element, SubElement, tostring
import structlog
from vbv_lernwelt.shop.const import VV_PRODUCT_NUMBER
from vbv_lernwelt.shop.invoice.abacus_sftp_client import AbacusSftpClient
from vbv_lernwelt.shop.models import CheckoutInformation, CheckoutState
@ -165,7 +166,7 @@ def render_invoice_xml(
item_fields = SubElement(item_element, "ItemFields", mode="SAVE")
SubElement(item_fields, "DeliveryDate").text = order_date.isoformat()
SubElement(item_fields, "ItemNumber").text = "1"
SubElement(item_fields, "ProductNumber").text = "30202"
SubElement(item_fields, "ProductNumber").text = VV_PRODUCT_NUMBER
SubElement(item_fields, "QuantityOrdered").text = "1"
item_text = SubElement(item_element, "ItemText", mode="SAVE")

View File

@ -0,0 +1,47 @@
# Generated by Django 3.2.20 on 2024-06-21 09:54
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_byjuno_invoice",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="checkoutinformation",
name="device_fingerprint_session_key",
field=models.CharField(blank=True, default="", max_length=255),
),
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),
),
migrations.AddField(
model_name="checkoutinformation",
name="refno2",
field=models.CharField(blank=True, default="", max_length=255),
),
]

View File

@ -62,6 +62,7 @@ class CheckoutInformation(models.Model):
invoice_transmitted_at = models.DateTimeField(blank=True, null=True)
transaction_id = models.CharField(max_length=255)
refno2 = models.CharField(max_length=255)
# end user (required)
first_name = models.CharField(max_length=255)
@ -78,6 +79,16 @@ class CheckoutInformation(models.Model):
blank=True,
)
# optional fields for cembra payment
cembra_byjuno_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_session_key = models.CharField(
max_length=255, 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

@ -1 +1,9 @@
from rest_framework import serializers
from vbv_lernwelt.shop.models import CheckoutInformation
class CypressCheckoutInformationSerializer(serializers.ModelSerializer):
class Meta:
model = CheckoutInformation
fields = "__all__"

View File

@ -7,11 +7,37 @@ import structlog
from django.conf import settings
from vbv_lernwelt.core.admin import User
from vbv_lernwelt.shop.const import VV_PRODUCT_NUMBER
from vbv_lernwelt.shop.datatrans.datatrans_api_client import DatatransApiClient
from vbv_lernwelt.shop.models import CheckoutState
logger = structlog.get_logger(__name__)
def create_context_data_log(
request,
action: str,
):
request._request.security_request_logging = "shop_view"
request._request.type = "datatrans"
request_trace_id = getattr(request._request, "request_trace_id", "")
context_data = {
"action": action,
"session_key": (request.data.get("session_key", "") or "")[0:255].strip(),
"ref": (request.data.get("ref", "") or "")[0:255].strip(),
"request_trace_id": (request_trace_id or "")[0:255].strip(),
"request_username": (
request._request.user.username if hasattr(request._request, "user") else ""
),
}
request._request.log_additional_json_data = context_data
log = logger.bind(label="shop", **context_data)
return context_data, log
class InitTransactionException(Exception):
pass
@ -52,6 +78,11 @@ def init_datatrans_transaction(
redirect_url_error: str,
redirect_url_cancel: str,
webhook_url: str,
refno2: str,
datatrans_customer_data: dict = None,
datatrans_int_data: dict = None,
context_data: dict = None,
with_cembra_byjuno_invoice: bool = False,
):
payload = {
# We use autoSettle=True, so that we don't have to settle the transaction:
@ -61,6 +92,7 @@ def init_datatrans_transaction(
"currency": "CHF",
"language": user.language,
"refno": str(uuid.uuid4()),
"refno2": refno2,
"webhook": {"url": webhook_url},
"redirect": {
"successUrl": redirect_url_success,
@ -69,19 +101,23 @@ def init_datatrans_transaction(
},
}
# FIXME: test with working cembra byjuno invoice customer?
# if with_cembra_byjuno_invoice:
# payload["paymentMethods"] = ["INT"]
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)
logger.info("Initiating transaction", payload=payload)
response = requests.post(
url=f"{settings.DATATRANS_API_ENDPOINT}/v1/transactions",
json=payload,
headers={
"Authorization": f"Basic {settings.DATATRANS_BASIC_AUTH_KEY}",
"Content-Type": "application/json",
},
api_client = DatatransApiClient()
response = api_client.post_initialize_transactions(
json_payload=payload, context_data=context_data
)
if response.status_code == 201:

View File

@ -83,13 +83,22 @@ class CheckoutAPITestCase(APITestCase):
self.assertEqual(ci.state, "ongoing")
self.assertEqual(ci.transaction_id, "1234567890")
mock_init_transaction.assert_called_once_with(
user=self.user,
amount_chf_centimes=324_30,
redirect_url_success=f"{REDIRECT_URL}/onboarding/{VV_DE_PRODUCT_SKU}/checkout/complete",
redirect_url_error=f"{REDIRECT_URL}/onboarding/{VV_DE_PRODUCT_SKU}/checkout/address?error",
redirect_url_cancel=f"{REDIRECT_URL}/",
webhook_url=f"{REDIRECT_URL}/api/shop/transaction/webhook/",
mock_init_transaction.assert_called_once()
call_kwargs = mock_init_transaction.call_args[1]
print(call_kwargs)
self.assertEqual(call_kwargs["user"], self.user)
self.assertEqual(call_kwargs["amount_chf_centimes"], 324_30)
self.assertEqual(
call_kwargs["redirect_url_success"],
f"{REDIRECT_URL}/onboarding/{VV_DE_PRODUCT_SKU}/checkout/complete",
)
self.assertEqual(
call_kwargs["redirect_url_error"],
f"{REDIRECT_URL}/onboarding/{VV_DE_PRODUCT_SKU}/checkout/address?error",
)
self.assertEqual(call_kwargs["redirect_url_cancel"], f"{REDIRECT_URL}/")
self.assertEqual(
call_kwargs["webhook_url"], f"{REDIRECT_URL}/api/shop/transaction/webhook/"
)
@patch("vbv_lernwelt.shop.views.init_datatrans_transaction")

View File

@ -43,31 +43,17 @@ class DatatransServiceTest(TestCase):
redirect_url_error=f"{REDIRECT_URL}/error",
redirect_url_cancel=f"{REDIRECT_URL}/cancel",
webhook_url=f"{REDIRECT_URL}/webhook",
refno2="",
)
self.assertEqual(1234567890, transaction_id)
# THEN
mock_post.assert_called_once_with(
url="https://api.sandbox.datatrans.com/v1/transactions",
json={
"autoSettle": True,
"amount": 324_30,
"currency": "CHF",
"language": self.user.language,
"refno": str(mock_uuid()),
"webhook": {"url": f"{REDIRECT_URL}/webhook"},
"redirect": {
"successUrl": f"{REDIRECT_URL}/success",
"errorUrl": f"{REDIRECT_URL}/error",
"cancelUrl": f"{REDIRECT_URL}/cancel",
},
},
headers={
"Authorization": "Basic BASIC_AUTH_KEY",
"Content-Type": "application/json",
},
)
mock_post.assert_called_once()
call_kwargs = mock_post.call_args[1]
print(call_kwargs)
self.assertEqual(call_kwargs["json"]["autoSettle"], True)
self.assertEqual(call_kwargs["json"]["amount"], 324_30)
@patch("vbv_lernwelt.shop.services.requests.post")
def test_init_transaction_500(self, mock_post):
@ -83,6 +69,7 @@ class DatatransServiceTest(TestCase):
redirect_url_error=f"/error",
redirect_url_cancel=f"/cancel",
webhook_url=f"/webhook",
refno2="",
)
def test_get_payment_url(self):

View File

@ -1,3 +1,5 @@
from datetime import date
import structlog
from django.conf import settings
from django.http import JsonResponse
@ -11,9 +13,11 @@ from vbv_lernwelt.shop.const import (
VV_DE_PRODUCT_SKU,
VV_FR_PRODUCT_SKU,
VV_IT_PRODUCT_SKU,
VV_PRODUCT_NUMBER,
)
from vbv_lernwelt.shop.models import CheckoutInformation, CheckoutState, Product
from vbv_lernwelt.shop.services import (
create_context_data_log,
datatrans_state_to_checkout_state,
get_payment_url,
init_datatrans_transaction,
@ -37,6 +41,10 @@ def transaction_webhook(request):
logger.info("Webhook: Datatrans called transaction webhook", body=request.body)
request._request.security_request_logging = "datatrans_webhook"
request._request.type = "datatrans"
request._request.log_additional_json_data = request.data
if not is_signature_valid(
signature=request.headers.get("Datatrans-Signature", ""),
payload=request.body,
@ -80,10 +88,12 @@ def checkout_vv(request):
bei Browser Back redirections zu vermeiden."
"""
context_data, log = create_context_data_log(request, "checkout_vv")
sku = request.data["product"]
base_redirect_url = request.data["redirect_url"]
logger.info(f"Checkout requested: sku", user_id=request.user.id, sku=sku)
log.info(f"Checkout requested: sku", user_id=request.user.id, sku=sku)
try:
product = Product.objects.get(sku=sku)
@ -106,7 +116,42 @@ def checkout_vv(request):
if checkouts.filter(state=CheckoutState.PAID).exists():
return next_step_response(url="/")
with_cembra_byjuno_invoice = request.data.get("with_cembra_byjuno_invoice", False)
ip_address = request.META.get("REMOTE_ADDR")
email = request.user.email
request.user.set_increment_abacus_debitor_number(
disable_save="fakeapi" in settings.DATATRANS_API_ENDPOINT
)
refno2 = f"{request.user.abacus_debitor_number}_{VV_PRODUCT_NUMBER}"
try:
datatrans_customer_data = None
datatrans_int_data = None
if with_cembra_byjuno_invoice:
# 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": request.data["device_fingerprint_session_key"],
}
transaction_id = init_datatrans_transaction(
user=request.user,
amount_chf_centimes=product.price,
@ -118,9 +163,15 @@ def checkout_vv(request):
),
redirect_url_cancel=checkout_cancel_url(base_redirect_url),
webhook_url=webhook_url(base_redirect_url),
refno2=refno2,
datatrans_customer_data=datatrans_customer_data,
datatrans_int_data=datatrans_int_data,
context_data=context_data,
with_cembra_byjuno_invoice=with_cembra_byjuno_invoice,
)
except InitTransactionException as e:
if not settings.DEBUG:
log.error("Transaction initiation failed", exc_info=True, error=str(e))
capture_exception(e)
return next_step_response(
url=checkout_error_url(
@ -138,6 +189,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 +201,12 @@ def checkout_vv(request):
product_price=product.price,
product_name=product.name,
product_description=product.description,
email=email,
ip_address=ip_address,
cembra_byjuno_invoice=with_cembra_byjuno_invoice,
device_fingerprint_session_key=request.data.get(
"device_fingerprint_session_key", ""
),
# address
**request.data["address"],
)