Merged in feature/cembra (pull request #345)
Feature/cembra Zahlung auf Rechnung
This commit is contained in:
commit
32274108db
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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") }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ redis # https://github.com/redis/redis-py
|
|||
uvicorn[standard] # https://github.com/encode/uvicorn
|
||||
environs
|
||||
click
|
||||
ua-parser
|
||||
|
||||
# Django
|
||||
# ------------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
@ -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"),
|
||||
),
|
||||
]
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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__"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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__"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue