Files

604 lines
24 KiB
Vue

<script setup>
import {computed, reactive, ref} from 'vue'
import {toast} from 'vue3-toastify'
import AppLayout from '../../../../resources/js/layouts/AppLayout.vue'
import ShadowedBox from '../../../Views/Components/ShadowedBox.vue'
import ErrorText from '../../../Views/Components/ErrorText.vue'
import IbanInput from '../../../Views/Components/IbanInput.vue'
import TextResource from '../../../Views/Components/TextResource.vue'
import {useAjax} from '../../../../resources/js/components/ajaxHandler.js'
const {request} = useAjax()
/**
* `state` steuert die ganze Seite:
* open -- Formular für die Bankverbindung
* accepted -- Angaben liegen vor, nichts mehr zu tun
* unavailable -- Token unbekannt oder Erstattung abgebrochen
*/
const props = defineProps({
state: String,
message: String,
token: String,
name: String,
eventTitle: String,
eventEmail: String,
amount: String,
reason: String,
reasonNote: String,
acceptedAt: String,
donation: Boolean,
/*
* Das Konto aus dem Zahlungseingang, falls bekannt: {owner, ibanMasked}.
*
* Ist es gesetzt, wird es nicht mehr abgefragt -- es steht bereits am Vorgang und der Server
* nimmt ohnehin nur dieses. Die IBAN kommt maskiert an: zum Wiedererkennen genug, zum
* Mitschreiben zu wenig.
*/
knownAccount: {type: Object, default: null},
/*
* Woher das Erstattungskonto kommt -- entschieden von der Zahlungsart, nicht hier:
* known -- liegt vor, wird nur bestätigt
* origin -- es gab ein Ursprungskonto, wir fragen danach
* none -- es gab nie eines (Barzahlung); dann ist die Herkunftsfrage sinnlos
*/
accountSource: {type: String, default: 'origin'},
/** Name des page_texts-Eintrags mit der zweiten Erklärung -- der Server wählt ihn. */
accountDeclarationText: {type: String, default: 'CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT'},
})
const hasKnownAccount = computed(() => props.accountSource === 'known')
/**
* Ob nach dem Ursprungskonto gefragt wird.
*
* Nur wenn es eines gab: Wer bar gezahlt hat, könnte die Frage nicht sinnvoll beantworten -- und landete
* bei „Nein" in einer Sackgasse, die ihn auffordert, das Konto zu nennen, von dem überwiesen wurde.
*/
const asksForOrigin = computed(() => props.accountSource === 'origin')
// Der Zustand wandert in ein ref, damit die Seite nach dem Absenden umschalten kann, ohne neu zu laden.
const state = ref(props.state)
// Ob gespendet wurde -- für den Abschlusstext. Nach dem Absenden setzt submit() den Wert selbst, ein
// Neuladen holt ihn vom Controller.
const donated = ref(props.donation === true)
/**
* Der Weg durch die Seite, nach dem Muster der Auslagenabrechnung (refund-data.vue): erst die
* Entscheidung, dann die Angaben dazu. Nichts wird gefragt, was für den gewählten Weg keine Rolle spielt.
*
* decision -- '' (noch nichts gewählt) | 'donation' (spenden) | 'payout' (auszahlen lassen)
* sameAccount -- nur bei 'payout': ob es das Konto der Ursprungszahlung ist
*/
const decision = ref('')
const sameAccount = ref(null)
const form = reactive({
accountOwner: '',
accountIban: '',
declarationAccepted: false,
accountDeclarationAccepted: false,
})
const errors = reactive({accountOwner: '', accountIban: '', declaration: '', accountDeclaration: ''})
const saving = ref(false)
/**
* Die Erklärung erscheint erst, wenn beide Kontoangaben stehen -- man bestätigt nichts, bevor man weiß,
* worüber. Dasselbe Vorgehen wie beim Erfassen einer Abrechnung (refund-data.vue).
*
* Dort wird zusätzlich auf `iban.length === 27` geprüft, also auf die Länge einer formatierten deutschen
* IBAN. Das bleibt hier bewusst weg: eine österreichische oder schweizerische käme sonst nie durch. Ob
* die IBAN stimmt, prüft der Server mit Prüfziffer und länderabhängiger Länge.
*/
const accountComplete = computed(
() => form.accountOwner.trim() !== '' && form.accountIban.trim() !== ''
)
const isDonation = computed(() => decision.value === 'donation')
/**
* Wann die Erklärungen des Auszahlungswegs erscheinen.
*
* Bekanntes Konto: sofort -- es steht ja schon da. Sonst erst, wenn die Kontoangaben vollständig sind;
* wo nach dem Ursprungskonto gefragt wird, zusätzlich erst nach einem „Ja".
*/
const showDeclarations = computed(() => {
if (decision.value !== 'payout') return false
if (hasKnownAccount.value) return true
if (!accountComplete.value) return false
return asksForOrigin.value ? sameAccount.value === true : true
})
/**
* Wann nach Kontoinhaber*in und IBAN gefragt wird.
*
* Überall dort, wo das Konto nicht schon feststeht -- beim Ursprungskonto-Weg allerdings erst, nachdem
* die Herkunftsfrage bejaht wurde: Sonst tippt jemand ein Konto ab, das wir anschließend ablehnen müssen.
*/
const showAccountFields = computed(() => {
if (decision.value !== 'payout' || hasKnownAccount.value) return false
return asksForOrigin.value ? sameAccount.value === true : true
})
/**
* Wechselt den Weg und nimmt dabei jedes Kreuz zurück.
*
* Ohne das Zurücksetzen stünde eine Erklärung als bestätigt da, die in diesem Weg gar nicht gezeigt
* wurde -- wer erst die Auszahlung ankreuzt und dann zur Spende wechselt, hätte den Verzicht nie gelesen.
*/
function choose(next) {
decision.value = next
sameAccount.value = null
form.declarationAccepted = false
form.accountDeclarationAccepted = false
Object.keys(errors).forEach((key) => (errors[key] = ''))
}
/** Zurück zur ersten Frage -- aus der Sackgasse „anderes Konto" führt der Weg zur Spende. */
function reset() {
choose('')
}
function validate() {
Object.keys(errors).forEach((key) => (errors[key] = ''))
errors.declaration = form.declarationAccepted ? '' : 'Bitte bestätige die Erklärung.'
if (!isDonation.value) {
// Beim bekannten Konto gibt es nichts einzugeben und damit auch nichts zu prüfen -- nur die
// Bestätigung, dass es stimmt.
if (!hasKnownAccount.value) {
errors.accountOwner = form.accountOwner.trim() ? '' : 'Bitte gib an, wem das Konto gehört.'
errors.accountIban = form.accountIban.trim() ? '' : 'Bitte gib die IBAN des Kontos ein.'
}
errors.accountDeclaration = form.accountDeclarationAccepted
? ''
: 'Bitte bestätige, dass es das Konto ist, von dem der Beitrag gezahlt wurde.'
}
return Object.values(errors).every((message) => message === '')
}
async function submit() {
if (!validate() || saving.value) return
saving.value = true
// Bei einer Spende geht keine Bankverbindung mit -- es gibt keine, und der Server erwartet auch keine.
// Beim bekannten Konto gehen ebenfalls keine Kontofelder mit: Der Server nimmt ausschließlich,
// was am Vorgang steht, alles Mitgeschickte würde er verwerfen.
const body = isDonation.value
? {donation: true, declarationAccepted: form.declarationAccepted}
: hasKnownAccount.value
? {
donation: false,
declarationAccepted: form.declarationAccepted,
accountDeclarationAccepted: form.accountDeclarationAccepted,
}
: {
donation: false,
accountOwner: form.accountOwner,
accountIban: form.accountIban,
declarationAccepted: form.declarationAccepted,
accountDeclarationAccepted: form.accountDeclarationAccepted,
}
try {
const response = await request('/api/v1/participant-refund/' + props.token + '/accept', {
method: 'POST',
body,
})
if (!response) {
toast.error('Deine Angaben konnten nicht gespeichert werden. Bitte versuche es später erneut.')
return
}
// Feldbezogene Fehler kommen mit Status 200 als `error_types` zurück -- der HttpClient des
// Projekts verwirft Antworten mit Fehlerstatus.
if (response.status !== 'success') {
Object.keys(response.error_types ?? {}).forEach((key) => {
if (key in errors) errors[key] = response.error_types[key]
})
toast.error(response.message ?? 'Bitte prüfe deine Angaben.')
return
}
toast.success(response.message)
donated.value = isDonation.value
state.value = 'accepted'
} finally {
saving.value = false
}
}
</script>
<template>
<AppLayout title="Rückerstattung">
<shadowed-box style="max-width: 640px; margin: 60px auto; padding: 24px;">
<template v-if="state === 'unavailable'">
<h2>Rückerstattung</h2>
<p class="hint">{{ props.message }}</p>
</template>
<template v-else>
<h2>Rückerstattung deines Teilnahmebeitrags</h2>
<p>
Hallo {{ props.name }}, für deine Abmeldung von der Veranstaltung
<strong>{{ props.eventTitle }}</strong> wurde eine Rückerstattung freigegeben.
</p>
<table class="summary">
<tr>
<th>Betrag</th>
<td><strong>{{ props.amount }}</strong></td>
</tr>
<tr>
<th>Grund</th>
<td>{{ props.reason }}</td>
</tr>
<tr v-if="props.reasonNote">
<th>Anmerkung</th>
<td>{{ props.reasonNote }}</td>
</tr>
</table>
<template v-if="state === 'accepted'">
<p v-if="donated" class="hint">
Du hast den Betrag gespendet<span v-if="props.acceptedAt"> (am {{ props.acceptedAt }})</span>
&ndash; vielen Dank. Es gibt nichts weiter zu tun. Stimmt etwas nicht, wende dich
bitte an die Aktionsleitung: {{ props.eventEmail }}
</p>
<p v-else class="hint">
Deine Angaben liegen uns vor<span v-if="props.acceptedAt"> (seit {{ props.acceptedAt }})</span>.
Die Überweisung veranlasst die Aktionsleitung. Stimmt etwas nicht, wende dich bitte
an sie: {{ props.eventEmail }}
</p>
</template>
<template v-else>
<p class="hint">
Der Betrag steht fest und lässt sich hier nicht ändern. Hast du dazu Fragen, wende
dich bitte an die Aktionsleitung: {{ props.eventEmail }}
</p>
<form @submit.prevent="submit">
<!--
Erste Frage: Wer spendet, braucht kein Konto anzugeben. Deshalb steht sie
vor allem anderen.
-->
<h3>Möchtest du den Betrag stattdessen spenden?</h3>
<p class="choice-hint">
Spendest du, verbleibt der Betrag beim Verband und kommt unserer Arbeit
zugute. Wir brauchen dann keine Bankverbindung von dir.
</p>
<div class="choices">
<button
type="button"
class="choice"
:class="{active: decision === 'donation'}"
@click="choose('donation')"
>Ja, spenden</button>
<button
type="button"
class="choice"
:class="{active: decision === 'payout'}"
@click="choose('payout')"
>Nein, bitte erstatten</button>
</div>
<!-- Spendenweg: nur der Verzicht, keine Kontoangaben. -->
<template v-if="decision === 'donation'">
<div class="declaration">
<input
id="refund-donation-declaration"
v-model="form.declarationAccepted"
type="checkbox"
/>
<TextResource
text-name="CONFIRMATION_PARTICIPANT_REFUND_DONATION"
belongs-to="refund-donation-declaration"
/>
</div>
<ErrorText :message="errors.declaration" />
<button
v-if="form.declarationAccepted"
class="button"
type="submit"
:disabled="saving"
>
{{ saving ? 'Wird gespeichert…' : 'Betrag spenden' }}
</button>
</template>
<!--
Auszahlungsweg: Erstattet wird ausschließlich auf das Konto, von dem der
Beitrag kam. Das wird gefragt, bevor überhaupt eine IBAN eingegeben wird --
sonst tippt jemand ein Konto ab, das wir anschließend ablehnen müssen.
-->
<template v-else-if="decision === 'payout'">
<!--
Konto bekannt: Es steht schon am Vorgang und stammt aus dem
Zahlungseingang. Die Frage danach erübrigt sich, eingegeben wird nichts
mehr -- gezeigt wird es trotzdem, damit ein falsch zugeordneter
Zahlungseingang hier auffällt und nicht erst beim Rückläufer der Bank.
-->
<template v-if="hasKnownAccount">
<h3>Wir erstatten auf das Konto, von dem dein Beitrag kam</h3>
<div class="known-account">
<span class="known-account__owner">{{ knownAccount.owner }}</span>
<span class="known-account__iban">{{ knownAccount.ibanMasked }}</span>
</div>
<p class="hint">
Aus Sicherheitsgründen zeigen wir die IBAN nur teilweise. Gehört das
Konto nicht zu deiner Zahlung, wende dich bitte an die Aktionsleitung:
{{ props.eventEmail }}
</p>
</template>
<!--
Kein Ursprungskonto (bar gezahlt): Die Herkunftsfrage entfällt. Sonst
landete der Teili bei Nein" in einer Sackgasse, die ihn nach dem Konto
fragt, von dem überwiesen wurde -- es gab keines. Erstattet wird auf ein
Konto, das ihm gehört.
-->
<template v-else-if="!asksForOrigin">
<h3>Auf welches Konto sollen wir erstatten?</h3>
<p class="choice-hint">
Deinen Beitrag hast du nicht überwiesen, deshalb brauchen wir eine
Bankverbindung von dir. Bitte gib ein Konto an, das auf deinen Namen
läuft oder über das du verfügen darfst.
</p>
</template>
<template v-else>
<h3>Soll der Betrag auf das Konto erstattet werden, von dem der Beitrag
gezahlt wurde?</h3>
<div class="choices">
<button
type="button"
class="choice"
:class="{active: sameAccount === true}"
@click="sameAccount = true"
>Ja</button>
<button
type="button"
class="choice"
:class="{active: sameAccount === false}"
@click="sameAccount = false"
>Nein</button>
</div>
<template v-if="sameAccount === false">
<p class="hint">
So können wir den Betrag leider nicht erstatten: Zurück geht er nur auf
das Konto, von dem der Teilnahmebeitrag gezahlt wurde. Wurde er von
einem anderen Konto überwiesen &ndash; etwa dem eines Elternteils
&ndash;, gib bitte dieses an. Hilft das nicht weiter, wende dich bitte
an die Aktionsleitung: {{ props.eventEmail }}
</p>
<button type="button" class="button" @click="reset()">
Zurück zur Auswahl
</button>
</template>
</template>
<!--
Die Kontoangaben -- gleich in beiden Wegen, in denen sie erfragt werden:
nach einem „Ja" auf die Herkunftsfrage und bei einer Zahlungsart ohne
Ursprungskonto. Nur beim bereits bekannten Konto entfallen sie.
-->
<template v-if="showAccountFields">
<div class="field">
<label for="account-owner">Kontoinhaber*in</label>
<input
id="account-owner"
v-model="form.accountOwner"
type="text"
class="form-input"
autocomplete="name"
/>
<ErrorText :message="errors.accountOwner" />
</div>
<div class="field">
<label for="account-iban">IBAN</label>
<IbanInput id="account-iban" v-model="form.accountIban" class="form-input" />
<ErrorText :message="errors.accountIban" />
</div>
</template>
<!--
Die Erklärungen, die anschließend auf dem Eigenbeleg stehen. Sie
müssen hier gelesen und angekreuzt werden -- sonst schriebe der Beleg
dem Teili Zusicherungen zu, die er nie abgegeben hat.
In beiden Wegen dieselben: Beim bekannten Konto bestätigt der Teili
Angezeigtes statt Getipptes -- der Beleg bleibt dadurch unverändert.
-->
<template v-if="showDeclarations">
<div class="declaration">
<input
id="refund-declaration"
v-model="form.declarationAccepted"
type="checkbox"
/>
<TextResource
text-name="CONFIRMATION_PARTICIPANT_REFUND"
belongs-to="refund-declaration"
/>
</div>
<ErrorText :message="errors.declaration" />
<div class="declaration">
<input
id="refund-account-declaration"
v-model="form.accountDeclarationAccepted"
type="checkbox"
/>
<TextResource
:text-name="accountDeclarationText"
belongs-to="refund-account-declaration"
/>
</div>
<ErrorText :message="errors.accountDeclaration" />
<!-- Beim Speichern gesperrt statt ausgeblendet, sonst verschwände der
Knopf unter dem Finger. -->
<button
v-if="form.declarationAccepted && form.accountDeclarationAccepted"
class="button"
type="submit"
:disabled="saving"
>
{{ saving ? 'Wird gespeichert…' : 'Angaben absenden' }}
</button>
</template>
</template>
</form>
</template>
</template>
</shadowed-box>
</AppLayout>
</template>
<style scoped>
h2 {
margin-bottom: 16px;
}
h3 {
margin: 24px 0 12px;
}
.hint {
margin: 16px 0;
padding: 10px 12px;
border-left: 3px solid #f5c400;
background-color: #fffef5;
font-size: 0.9rem;
color: #4b5563;
}
.summary {
border-collapse: collapse;
margin: 20px 0;
}
.summary th {
text-align: left;
padding: 6px 24px 6px 0;
color: #555;
font-weight: normal;
white-space: nowrap;
}
.summary td {
padding: 6px 0;
}
.choice-hint {
margin-bottom: 12px;
font-size: 0.9rem;
color: #4b5563;
}
.choices {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.choice {
flex: 1;
padding: 10px 12px;
border: 1px solid #d8dde6;
border-radius: 0;
background: #ffffff;
font-size: 0.95rem;
cursor: pointer;
}
.choice.active {
border-color: #1a4799;
background: #eef2fa;
font-weight: bold;
}
/* Das bekannte Konto -- Anzeige, kein Feld. Bewusst wie ein Beleg gesetzt und nicht wie ein Formular,
damit gar nicht erst der Eindruck entsteht, hier ließe sich etwas ändern. */
.known-account {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
background-color: #f9fafb;
margin-bottom: 4px;
}
.known-account__owner {
font-weight: bold;
color: #374151;
}
.known-account__iban {
font-family: monospace;
font-size: 1.05rem;
letter-spacing: 0.05em;
color: #4b5563;
}
.field {
margin-bottom: 16px;
}
.field label {
display: block;
margin-bottom: 4px;
font-size: 0.9rem;
color: #4b5563;
}
.field .form-input {
width: 100%;
}
.declaration {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 20px 0 8px;
font-size: 0.9rem;
line-height: 1.5;
}
.declaration input {
margin-top: 3px;
flex-shrink: 0;
}
.declaration :deep(label) {
cursor: pointer;
}
</style>