Calculation errors for refunded amounts
This commit is contained in:
@@ -388,6 +388,14 @@ function saveParticipant() {
|
||||
<small v-else-if="props.participant.refund.status === 'accepted'">
|
||||
bestätigt am {{ props.participant.refund.acceptedAt }}
|
||||
</small>
|
||||
|
||||
<!-- Was beim Verband geblieben ist und warum. -->
|
||||
<small v-if="props.participant.refund.hasRetention" class="retention-note">
|
||||
<br />Einbehalten: {{ props.participant.refund.retainedAmount }} –
|
||||
{{ props.participant.refund.retentionReasonLabel }}<template
|
||||
v-if="props.participant.refund.retentionReasonNote"
|
||||
> ({{ props.participant.refund.retentionReasonNote }})</template>
|
||||
</small>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -541,4 +549,8 @@ textarea {
|
||||
select {
|
||||
width: 262px;
|
||||
}
|
||||
|
||||
.retention-note {
|
||||
color: #8a6d00;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -51,15 +51,82 @@ const openRefundDialogSwitch = ref(false);
|
||||
// Der Erstattungsdialog. `captureMode` steuert den Weg: 'participant' schickt dem Teili einen Link, über
|
||||
// den er seine Bankverbindung selbst einträgt; 'management' heißt, sie liegt der Aktionsleitung bereits
|
||||
// vor -- dann wird die Erstattung sofort eingereicht.
|
||||
const refundForm = reactive({amount: '', reason: '', reasonNote: '', captureMode: 'participant', accountOwner: '', accountIban: ''});
|
||||
const refundForm = reactive({
|
||||
amount: '', reason: '', reasonNote: '',
|
||||
captureMode: 'participant', accountOwner: '', accountIban: '',
|
||||
retentionReason: '', retentionReasonNote: '',
|
||||
});
|
||||
const refundErrors = reactive({amount: '', reason: '', reasonNote: '', accountOwner: '', accountIban: ''});
|
||||
const refundReasons = ref([]);
|
||||
const retentionReasons = ref([]);
|
||||
const refundSaving = ref(false);
|
||||
|
||||
const selectedRefundReason = computed(
|
||||
() => refundReasons.value.find(r => r.value === refundForm.reason) ?? null
|
||||
);
|
||||
|
||||
const selectedRetentionReason = computed(
|
||||
() => retentionReasons.value.find(r => r.value === refundForm.retentionReason) ?? null
|
||||
);
|
||||
|
||||
/** Was nach der Erstattung beim Verband bleibt -- die Grundlage für den Einbehaltungsblock. */
|
||||
const retainedAmount = computed(() => {
|
||||
const paid = Number(showParticipant.value?.amountPaidValue ?? 0);
|
||||
const refunded = Number((refundForm.amount ?? '').replace(',', '.'));
|
||||
|
||||
if (!Number.isFinite(refunded)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const remaining = Math.round((paid - refunded) * 100) / 100;
|
||||
|
||||
return remaining > 0.005 ? remaining : 0;
|
||||
});
|
||||
|
||||
const hasRetention = computed(() => retainedAmount.value > 0);
|
||||
|
||||
const retainedAmountReadable = computed(
|
||||
() => retainedAmount.value.toFixed(2).replace('.', ',') + ' Euro'
|
||||
);
|
||||
|
||||
/**
|
||||
* Ob abgesendet werden kann. Der Knopf erscheint erst dann -- was noch fehlt, soll die Aktionsleitung
|
||||
* sehen, bevor sie klickt, statt danach eine Fehlermeldung zu lesen.
|
||||
*/
|
||||
const refundFormComplete = computed(() => {
|
||||
const amount = Number((refundForm.amount ?? '').replace(',', '.'));
|
||||
const paid = Number(showParticipant.value?.amountPaidValue ?? 0);
|
||||
|
||||
if (!refundForm.amount || !(amount > 0) || amount > paid + 0.005) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!refundForm.reason) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (selectedRefundReason.value?.requiresNote && !refundForm.reasonNote.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Bleibt etwas beim Verband, muss begründet sein, warum.
|
||||
if (hasRetention.value) {
|
||||
if (!refundForm.retentionReason) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (selectedRetentionReason.value?.requiresNote && !refundForm.retentionReasonNote.trim()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (refundForm.captureMode === 'management') {
|
||||
return refundForm.accountOwner.trim() !== '' && refundForm.accountIban.trim() !== '';
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
defineEmits(['showParticipantDetails', 'markCocExisting', 'paymentComplete'])
|
||||
|
||||
function openParticipantDetails(input) {
|
||||
@@ -323,6 +390,8 @@ async function openRefundDialog(participant) {
|
||||
refundForm.captureMode = 'participant';
|
||||
refundForm.accountOwner = '';
|
||||
refundForm.accountIban = '';
|
||||
refundForm.retentionReason = '';
|
||||
refundForm.retentionReasonNote = '';
|
||||
|
||||
Object.keys(refundErrors).forEach(key => refundErrors[key] = '');
|
||||
|
||||
@@ -331,6 +400,11 @@ async function openRefundDialog(participant) {
|
||||
refundReasons.value = reasons ?? [];
|
||||
}
|
||||
|
||||
if (retentionReasons.value.length === 0) {
|
||||
const reasons = await request('/api/v1/core/retrieve-retention-reasons', {method: 'GET'});
|
||||
retentionReasons.value = reasons ?? [];
|
||||
}
|
||||
|
||||
openRefundDialogSwitch.value = true;
|
||||
}
|
||||
|
||||
@@ -384,6 +458,9 @@ async function execRefund() {
|
||||
// Leer beim Weg über den Teili -- dann verschickt der Server nur den Link.
|
||||
accountOwner: refundForm.captureMode === 'management' ? refundForm.accountOwner : '',
|
||||
accountIban: refundForm.captureMode === 'management' ? refundForm.accountIban : '',
|
||||
// Leer bei voller Erstattung -- dann gibt es nichts zu begründen.
|
||||
retentionReason: hasRetention.value ? refundForm.retentionReason : '',
|
||||
retentionReasonNote: hasRetention.value ? refundForm.retentionReasonNote : '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -479,6 +556,15 @@ function mailToGroup(groupKey) {
|
||||
<td class="pl-amount" :id="'participant-' + participant.identifier +'-payment'" :class="participant.amount_left_value != 0 && !participant.unregistered ? 'not-paid' : ''">
|
||||
Gezahlt: <label :id="'participant-' + participant.identifier + '-paid'">{{ participant?.amountPaid.readable }}</label> /<br />
|
||||
Gesamt: <label :id="'participant-' + participant.identifier + '-expected'">{{ participant?.amountExpected.readable }}</label>
|
||||
|
||||
<!-- Warum ein Teil des Beitrags beim Verband geblieben ist. -->
|
||||
<span v-if="participant.refund?.hasRetention" class="retention-note">
|
||||
Einbehalten: {{ participant.refund.retainedAmount }}<br />
|
||||
{{ participant.refund.retentionReasonLabel }}<template
|
||||
v-if="participant.refund.retentionReasonNote"
|
||||
> – {{ participant.refund.retentionReasonNote }}</template>
|
||||
</span>
|
||||
|
||||
<br /><br />
|
||||
<span v-if="participant.amount_left_value != 0 && !participant.unregistered" :id="'participant-' + participant.identifier + '-actions'">
|
||||
<span class="link" style="font-size:10pt;" @click="paymentComplete(participant)">Zahlung buchen</span>
|
||||
@@ -655,6 +741,36 @@ function mailToGroup(groupKey) {
|
||||
<ErrorText :message="refundErrors.reasonNote" />
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Bleibt ein Teil beim Verband, muss begründet sein, warum -- ein einbehaltener Betrag ohne
|
||||
Grund ist in der Buchhaltung nicht haltbar. Bei voller Erstattung gibt es nichts zu zeigen.
|
||||
-->
|
||||
<template v-if="hasRetention">
|
||||
<p class="refund-hint">
|
||||
<strong>{{ retainedAmountReadable }}</strong> verbleiben beim Verband.
|
||||
</p>
|
||||
|
||||
<div class="refund-field">
|
||||
<label for="refund_retention_reason">Grund der Einbehaltung</label>
|
||||
<select id="refund_retention_reason" v-model="refundForm.retentionReason" class="form-input">
|
||||
<option value="">Bitte auswählen …</option>
|
||||
<option v-for="reason in retentionReasons" :key="reason.value" :value="reason.value">
|
||||
{{ reason.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedRetentionReason?.requiresNote" class="refund-field">
|
||||
<label for="refund_retention_note">Erläuterung zur Einbehaltung</label>
|
||||
<textarea
|
||||
id="refund_retention_note"
|
||||
v-model="refundForm.retentionReasonNote"
|
||||
class="form-input"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
Liegt die Bankverbindung schon vor, entfällt der Umweg über den Teili: die Erstattung wird
|
||||
sofort eingereicht. Er bekommt den Beleg trotzdem.
|
||||
@@ -694,7 +810,13 @@ function mailToGroup(groupKey) {
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<button class="button" :disabled="refundSaving" @click="execRefund()">
|
||||
<!-- Erscheint erst, wenn alles ausgefüllt ist; während des Speicherns gesperrt statt weg. -->
|
||||
<button
|
||||
v-if="refundFormComplete"
|
||||
class="button"
|
||||
:disabled="refundSaving"
|
||||
@click="execRefund()"
|
||||
>
|
||||
<template v-if="refundSaving">Wird gespeichert…</template>
|
||||
<template v-else-if="refundForm.captureMode === 'management'">Erstattung einreichen</template>
|
||||
<template v-else>Erstattung freigeben</template>
|
||||
@@ -749,6 +871,14 @@ function mailToGroup(groupKey) {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.retention-note {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 10pt;
|
||||
color: #ca5a0a;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.refund-hint {
|
||||
margin-bottom: 14px;
|
||||
padding: 8px 10px;
|
||||
|
||||
@@ -68,6 +68,17 @@ const props = defineProps({
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!--
|
||||
Beiträge, die trotz Abmeldung beim Verband geblieben sind. Eigene Zeile, weil die
|
||||
Zeilen darüber nur aktive Anmeldungen führen.
|
||||
-->
|
||||
<tr v-if="props.event.retainedFromUnregistered.value > 0">
|
||||
<th style="padding-bottom: 20px" colspan="2">Einbehalten von Abmeldungen</th>
|
||||
<td style="padding-bottom: 20px" colspan="2">
|
||||
{{ props.event.retainedFromUnregistered.readable }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th colspan="2" style="border-width: 1px; border-top-style: solid">Gesamt</th>
|
||||
<td style="font-weight: bold; border-width: 1px; border-top-style: solid">
|
||||
|
||||
@@ -120,8 +120,8 @@ class AcceptRefundCommand
|
||||
$invoice = $this->createInvoice($refund, $costUnit, $document);
|
||||
|
||||
// Erst jetzt, nicht früher: Beleg und Anmerkung der Abrechnung weisen den gezahlten Beitrag
|
||||
// aus und läsen sonst bereits die 0.
|
||||
$this->clearAmountPaid($refund);
|
||||
// aus und läsen sonst bereits den verrechneten Stand.
|
||||
$this->settleAmountPaid($refund);
|
||||
|
||||
$refund->invoice_id = $invoice->id;
|
||||
$refund->save();
|
||||
@@ -255,16 +255,23 @@ class AcceptRefundCommand
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt den gezahlten Beitrag des Teilis auf 0.
|
||||
* Zieht den erstatteten Betrag vom gezahlten Beitrag ab.
|
||||
*
|
||||
* Mit der eingereichten Abrechnung ist der Beitrag nicht mehr beim Verband, sondern auf dem Weg
|
||||
* zurück -- die Zahlungsübersichten der Aktionsleitung sollen ihn nicht länger als offen führen. Der
|
||||
* ursprüngliche Betrag steht zur Kontrolle in der Anmerkung der Abrechnung und auf dem Beleg.
|
||||
* Danach führt `amount_paid` genau das, was beim Verband geblieben ist -- bei voller Erstattung also
|
||||
* 0, bei einer Teilerstattung den einbehaltenen Rest. Auf diesem Feld baut die Einnahmenrechnung der
|
||||
* Veranstaltung auf; es muss deshalb den tatsächlichen Bestand abbilden und nicht die Zahlung von
|
||||
* einst. Der ursprüngliche Betrag steht zur Kontrolle in der Anmerkung der Abrechnung und auf dem
|
||||
* Beleg.
|
||||
*/
|
||||
private function clearAmountPaid(ParticipantRefund $refund): void
|
||||
private function settleAmountPaid(ParticipantRefund $refund): void
|
||||
{
|
||||
$participant = $refund->participant;
|
||||
$participant->amount_paid = new Amount(0.0, 'Euro');
|
||||
|
||||
$paid = $participant->amount_paid?->getAmount() ?? 0.0;
|
||||
$refunded = $refund->amount?->getAmount() ?? 0.0;
|
||||
|
||||
// `max` gegen Rundungsreste: Ein negativer gezahlter Betrag wäre in jeder Auswertung Unsinn.
|
||||
$participant->amount_paid = new Amount(max(0.0, round($paid - $refunded, 2)), 'Euro');
|
||||
$participant->save();
|
||||
}
|
||||
|
||||
|
||||
+30
@@ -104,6 +104,26 @@ class CreateRefundDocumentCommand
|
||||
* Fallback stünde hier ein Fatal Error auf `null` -- so steht es heute im Deckblatt-Code der
|
||||
* Auslagenerstattung, und daran soll sich der Beleg kein Beispiel nehmen.
|
||||
*/
|
||||
/**
|
||||
* Der Hinweis, warum ein Teil des Beitrags beim Verband bleibt -- leer bei voller Erstattung.
|
||||
*
|
||||
* Der Beleg wandert in die Buchhaltung und ins Archiv; dort muss die Differenz zwischen gezahltem
|
||||
* und erstattetem Betrag ohne Rückfrage erklärt sein.
|
||||
*/
|
||||
private function retentionNote(): string
|
||||
{
|
||||
if (!$this->refund->hasRetention()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$text = trim($this->refund->retentionReasonText());
|
||||
$label = $this->refund->retentionReasonLabel();
|
||||
|
||||
return $text !== '' && $text !== $label
|
||||
? sprintf('%s (%s)', $label, $text)
|
||||
: $label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Vermerk, wenn die Aktionsleitung die Angaben aufgenommen hat.
|
||||
*
|
||||
@@ -199,6 +219,9 @@ class CreateRefundDocumentCommand
|
||||
'account_owner' => (string) $refund->account_owner,
|
||||
'account_iban' => $this->formatIban((string) $refund->account_iban),
|
||||
|
||||
'retained_amount' => $this->money($refund->retained_amount?->getAmount() ?? 0.0),
|
||||
'retention_note' => $this->retentionNote(),
|
||||
|
||||
'declaration_text' => $this->declarationText(),
|
||||
'capture_note' => $this->captureNote(),
|
||||
|
||||
@@ -242,6 +265,13 @@ class CreateRefundDocumentCommand
|
||||
$rows[] = ['Begründung', e($reasonText)];
|
||||
}
|
||||
|
||||
// Nur bei einer Teilerstattung: Ohne diese Zeile bliebe die Differenz zwischen gezahltem und
|
||||
// erstattetem Betrag im Beleg unerklärt.
|
||||
if ($refund->hasRetention()) {
|
||||
$rows[] = ['Einbehalten', $this->money($refund->retained_amount?->getAmount() ?? 0.0)];
|
||||
$rows[] = ['Grund der Einbehaltung', e($this->retentionNote())];
|
||||
}
|
||||
|
||||
$rows[] = ['Kontoinhaber*in', e((string) $refund->account_owner)];
|
||||
$rows[] = ['IBAN', e($this->formatIban((string) $refund->account_iban))];
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Domains\ParticipantRefund\Actions\ReleaseRefund;
|
||||
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundCommand;
|
||||
use App\Domains\ParticipantRefund\Actions\AcceptRefund\AcceptRefundRequest;
|
||||
use App\Enumerations\RefundReason;
|
||||
use App\Enumerations\RetentionReason;
|
||||
use App\Mail\ParticipantRefundMails\RefundReleasedMail;
|
||||
use App\Models\EventParticipant;
|
||||
use App\Models\ParticipantRefund;
|
||||
@@ -57,6 +58,11 @@ class ReleaseRefundCommand
|
||||
'amount' => $this->request->amount,
|
||||
'reason' => $this->request->reason,
|
||||
'reason_note' => $this->reasonNote(),
|
||||
// Was beim Verband bleibt, wird hier festgeschrieben: Nach dem Einreichen führt
|
||||
// `amount_paid` bereits diesen Rest, eine spätere Differenz wäre falsch.
|
||||
'retained_amount' => $this->request->retainedAmount(),
|
||||
'retention_reason' => $this->retentionReason(),
|
||||
'retention_reason_note' => $this->retentionReasonNote(),
|
||||
'released_by' => auth()->id(),
|
||||
'released_at' => now(),
|
||||
]);
|
||||
@@ -141,7 +147,32 @@ class ReleaseRefundCommand
|
||||
return 'Für diesen Grund ist eine Erläuterung erforderlich.';
|
||||
}
|
||||
|
||||
return $this->rejectBankDetails();
|
||||
return $this->rejectRetention() ?? $this->rejectBankDetails();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüfungen zum einbehaltenen Teil.
|
||||
*
|
||||
* Sicherheitsnetz hinter der Oberfläche: Dort erscheint der Absende-Knopf erst, wenn ein Grund
|
||||
* gewählt ist. Über einen direkten Aufruf ginge das sonst vorbei, und ein einbehaltener Betrag ohne
|
||||
* Begründung ist in der Buchhaltung nicht haltbar.
|
||||
*/
|
||||
private function rejectRetention(): ?string
|
||||
{
|
||||
if (!$this->request->hasRetention()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$reason = RetentionReason::find($this->request->retentionReason);
|
||||
if ($reason === null) {
|
||||
return 'Bitte gib an, warum ein Teil des Beitrags einbehalten wird.';
|
||||
}
|
||||
|
||||
if ($reason->requires_note && trim((string) $this->request->retentionReasonNote) === '') {
|
||||
return 'Für diesen Einbehaltungsgrund ist eine Erläuterung erforderlich.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,6 +215,33 @@ class ReleaseRefundCommand
|
||||
return trim((string) $this->request->reasonNote);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Einbehaltungsgrund -- nur, wenn tatsächlich etwas beim Verband bleibt.
|
||||
*
|
||||
* Bei voller Erstattung wird ein mitgeschickter Grund verworfen: In der Oberfläche ist das Feld dann
|
||||
* gar nicht sichtbar, und ein Wert ohne Bezug hätte in der Datenbank nichts zu suchen.
|
||||
*/
|
||||
private function retentionReason(): ?string
|
||||
{
|
||||
return $this->request->hasRetention() ? $this->request->retentionReason : null;
|
||||
}
|
||||
|
||||
/** Der Freitext dazu -- wie beim Erstattungsgrund nur bei Gründen, die ihn verlangen. */
|
||||
private function retentionReasonNote(): ?string
|
||||
{
|
||||
if (!$this->request->hasRetention()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$reason = RetentionReason::find($this->request->retentionReason);
|
||||
|
||||
if ($reason === null || !$reason->requires_note) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim((string) $this->request->retentionReasonNote);
|
||||
}
|
||||
|
||||
/**
|
||||
* Teili und Kontaktperson bekommen je eine eigene Mail -- dasselbe Muster wie bei der Abmeldung
|
||||
* (siehe SetParticipationStateCommand).
|
||||
|
||||
@@ -20,6 +20,14 @@ class ReleaseRefundRequest
|
||||
*/
|
||||
public readonly ?string $accountOwner = null,
|
||||
public readonly ?string $accountIban = null,
|
||||
/**
|
||||
* Warum ein Teil des Beitrags beim Verband bleibt.
|
||||
*
|
||||
* Pflicht, sobald weniger erstattet wird als gezahlt wurde: Ein einbehaltener Betrag ohne Grund
|
||||
* ist in der Buchhaltung nicht haltbar.
|
||||
*/
|
||||
public readonly ?string $retentionReason = null,
|
||||
public readonly ?string $retentionReasonNote = null,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -28,4 +36,23 @@ class ReleaseRefundRequest
|
||||
{
|
||||
return filled($this->accountOwner) && filled($this->accountIban);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Betrag, der beim Verband bleibt.
|
||||
*
|
||||
* Die halbe Cent-Toleranz fängt die Rundung des gespeicherten Floats ab -- ohne sie entstünden
|
||||
* Restbeträge von Bruchteilen eines Cents, die eine Begründung verlangen würden.
|
||||
*/
|
||||
public function retainedAmount(): float
|
||||
{
|
||||
$paid = $this->participant->amount_paid?->getAmount() ?? 0.0;
|
||||
$remaining = round($paid - $this->amount->getAmount(), 2);
|
||||
|
||||
return $remaining > 0.005 ? $remaining : 0.0;
|
||||
}
|
||||
|
||||
public function hasRetention(): bool
|
||||
{
|
||||
return $this->retainedAmount() > 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ class ReleaseRefundController extends CommonController
|
||||
// Leer, wenn der Teili die Bankverbindung selbst eintragen soll.
|
||||
accountOwner: Text::nullIfBlank($request->input('accountOwner')),
|
||||
accountIban: Text::nullIfBlank($request->input('accountIban')),
|
||||
// Leer, wenn der volle Beitrag erstattet wird -- dann gibt es nichts zu begründen.
|
||||
retentionReason: Text::nullIfBlank($request->input('retentionReason')),
|
||||
retentionReasonNote: Text::nullIfBlank($request->input('retentionReasonNote')),
|
||||
);
|
||||
|
||||
$response = new ReleaseRefundCommand($refundRequest)->execute();
|
||||
|
||||
@@ -61,6 +61,8 @@ final class ParticipantRefundTokens
|
||||
'paid_amount' => ['description' => 'Bereits gezahlter Teilnahmebeitrag', 'sample' => '300,00 €'],
|
||||
'invoice_number' => ['description' => 'Nummer der Teilnahmerechnung', 'sample' => 'WM-V-20260701-0005'],
|
||||
'refund_amount' => ['description' => 'Erstattungsbetrag', 'sample' => '220,00 €'],
|
||||
'retained_amount' => ['description' => 'Betrag, der beim Verband bleibt — 0,00 € bei voller Erstattung', 'sample' => '80,00 €'],
|
||||
'retention_note' => ['description' => 'Grund der Einbehaltung — leer bei voller Erstattung', 'sample' => 'Stornogebühr laut Ausschreibung'],
|
||||
'refund_reason' => ['description' => 'Bezeichnung des Grundes', 'sample' => 'Krankheitsbedingte Absage'],
|
||||
'refund_reason_text' => ['description' => 'Erläuterung des Grundes (bei „Sonstiger Grund" der Freitext)', 'sample' => 'Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.'],
|
||||
'account_owner' => ['description' => 'Kontoinhaber*in', 'sample' => 'Mika Muster'],
|
||||
@@ -111,6 +113,8 @@ final class ParticipantRefundTokens
|
||||
. '<tr><td class="detail-key">Erstattungsbetrag</td><td class="detail-val">220,00 €</td></tr>'
|
||||
. '<tr><td class="detail-key">Grund</td><td class="detail-val">Krankheitsbedingte Absage</td></tr>'
|
||||
. '<tr><td class="detail-key">Begründung</td><td class="detail-val">Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.</td></tr>'
|
||||
. '<tr><td class="detail-key">Einbehalten</td><td class="detail-val">80,00 €</td></tr>'
|
||||
. '<tr><td class="detail-key">Grund der Einbehaltung</td><td class="detail-val">Stornogebühr laut Ausschreibung</td></tr>'
|
||||
. '<tr><td class="detail-key">Kontoinhaber*in</td><td class="detail-val">Mika Muster</td></tr>'
|
||||
. '<tr><td class="detail-key">IBAN</td><td class="detail-val">DE02 1203 0000 0000 2020 51</td></tr>'
|
||||
. '</table>';
|
||||
|
||||
@@ -32,11 +32,13 @@ class InvoiceType extends CommonModel {
|
||||
'name',
|
||||
'sort_order',
|
||||
'selectable',
|
||||
'counts_as_expense',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sort_order' => 'integer',
|
||||
'selectable' => 'boolean',
|
||||
'counts_as_expense' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -51,4 +53,18 @@ class InvoiceType extends CommonModel {
|
||||
{
|
||||
return self::where('selectable', true)->orderBy('sort_order')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Typen, die in der Ausgabenrechnung einer Veranstaltung zählen -- nach Sortierung.
|
||||
*
|
||||
* Ausgenommen ist, was fachlich keine Ausgabe ist, sondern die Rücknahme einer Einnahme: Eine
|
||||
* Beitragserstattung mindert bereits die Einnahmenseite, weil der abgemeldete Teili dort herausfällt.
|
||||
* Als Ausgabe gezählt, ginge derselbe Vorgang ein zweites Mal in die Bilanz.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, self>
|
||||
*/
|
||||
public static function countingAsExpense(): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return self::where('counts_as_expense', true)->orderBy('sort_order')->get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enumerations;
|
||||
|
||||
use App\Scopes\CommonModel;
|
||||
|
||||
/**
|
||||
* Gründe, aus denen ein Teil des gezahlten Teilnahmebeitrags beim Verband bleibt.
|
||||
*
|
||||
* Gegenstück zu {@see RefundReason}: Jener sagt, warum erstattet wird, dieser, warum nicht alles.
|
||||
*
|
||||
* @property string $slug
|
||||
* @property string $name
|
||||
* @property string|null $document_text
|
||||
* @property bool $requires_note
|
||||
* @property int $sort_order
|
||||
*/
|
||||
class RetentionReason extends CommonModel
|
||||
{
|
||||
public const string CANCELLATION_FEE = 'cancellation_fee';
|
||||
public const string INCURRED_COSTS = 'incurred_costs';
|
||||
public const string MATERIAL = 'material';
|
||||
public const string CUSTOM = 'custom';
|
||||
|
||||
protected $table = 'retention_reasons';
|
||||
protected $primaryKey = 'slug';
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
'name',
|
||||
'document_text',
|
||||
'requires_note',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'requires_note' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* Der Text, der auf dem Beleg unter „Einbehalten" steht. Bei einem Grund, der einen Freitext
|
||||
* verlangt, ist es der Text der Aktionsleitung.
|
||||
*/
|
||||
public function documentText(?string $note = null): string
|
||||
{
|
||||
return $this->requires_note ? trim((string) $note) : (string) $this->document_text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionen für das Frontend. `requiresNote` steuert dort das Freitextfeld.
|
||||
*
|
||||
* @return array<int, array{value: string, label: string, requiresNote: bool}>
|
||||
*/
|
||||
public static function options(): array
|
||||
{
|
||||
return self::orderBy('sort_order')->get()
|
||||
->map(static fn (self $reason): array => [
|
||||
'value' => $reason->slug,
|
||||
'label' => $reason->name,
|
||||
'requiresNote' => $reason->requires_note,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,11 @@ class RefundAcceptedMail extends Mailable
|
||||
'accountIban' => Iban::format((string) $this->refund->account_iban),
|
||||
'hasDocument' => $this->pdfContent !== null,
|
||||
'invoiceNumber' => $invoice?->invoice_number,
|
||||
// Wird nur ein Teil erstattet, soll der Teili nicht rätseln, wo der Rest geblieben ist.
|
||||
'hasRetention' => $this->refund->hasRetention(),
|
||||
'retainedAmount' => $this->refund->retained_amount?->toString() ?? '0,00 Euro',
|
||||
'retentionReason' => $this->refund->retentionReasonLabel(),
|
||||
'retentionReasonNote' => $this->refund->retention_reason_note,
|
||||
// Hat die Aktionsleitung die Bankverbindung aufgenommen, hat der Teili selbst nichts
|
||||
// eingetragen -- dann darf die Mail sich nicht für seine Angaben bedanken.
|
||||
'capturedByManagement' => $this->refund->wasCapturedByManagement(),
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use App\Casts\AmountCast;
|
||||
use App\Enumerations\RefundReason;
|
||||
use App\Enumerations\RetentionReason;
|
||||
use App\Scopes\InstancedModel;
|
||||
use App\ValueObjects\Amount;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -20,6 +21,9 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
* @property Amount|null $amount
|
||||
* @property string|null $reason
|
||||
* @property string|null $reason_note
|
||||
* @property string|null $retention_reason
|
||||
* @property string|null $retention_reason_note
|
||||
* @property Amount|null $retained_amount
|
||||
* @property string|null $account_owner
|
||||
* @property string|null $account_iban
|
||||
* @property int|null $captured_by
|
||||
@@ -51,6 +55,9 @@ class ParticipantRefund extends InstancedModel
|
||||
'amount',
|
||||
'reason',
|
||||
'reason_note',
|
||||
'retention_reason',
|
||||
'retention_reason_note',
|
||||
'retained_amount',
|
||||
'account_owner',
|
||||
'account_iban',
|
||||
'captured_by',
|
||||
@@ -63,6 +70,7 @@ class ParticipantRefund extends InstancedModel
|
||||
|
||||
protected $casts = [
|
||||
'amount' => AmountCast::class,
|
||||
'retained_amount' => AmountCast::class,
|
||||
'released_at' => 'datetime',
|
||||
'accepted_at' => 'datetime',
|
||||
'cancelled_at' => 'datetime',
|
||||
@@ -129,4 +137,32 @@ class ParticipantRefund extends InstancedModel
|
||||
{
|
||||
return (string) ($this->reasonRelation()->first()?->name ?? '');
|
||||
}
|
||||
|
||||
/** Der Einbehaltungsgrund als Stammdatensatz -- leer, wenn voll erstattet wurde. */
|
||||
public function retentionReasonRelation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(RetentionReason::class, 'retention_reason', 'slug');
|
||||
}
|
||||
|
||||
public function retentionReasonLabel(): string
|
||||
{
|
||||
return (string) ($this->retentionReasonRelation()->first()?->name ?? '');
|
||||
}
|
||||
|
||||
/** Der auf dem Beleg auszuweisende Text zur Einbehaltung. */
|
||||
public function retentionReasonText(): string
|
||||
{
|
||||
return $this->retentionReasonRelation()->first()?->documentText($this->retention_reason_note) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ob etwas beim Verband bleibt -- die halbe Cent-Toleranz fängt die Float-Rundung ab.
|
||||
*
|
||||
* Der Betrag steht in `retained_amount` und wird beim Einreichen festgeschrieben. Ihn zur Laufzeit
|
||||
* aus `amount_paid` zu rechnen ginge schief: Danach führt das Feld bereits den Rest.
|
||||
*/
|
||||
public function hasRetention(): bool
|
||||
{
|
||||
return ($this->retained_amount?->getAmount() ?? 0.0) > 0.005;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Providers;
|
||||
use App\Enumerations\EatingHabit;
|
||||
use App\Enumerations\InvoiceType;
|
||||
use App\Enumerations\RefundReason;
|
||||
use App\Enumerations\RetentionReason;
|
||||
use App\Enumerations\UserRole;
|
||||
use App\Models\AvailablePaymentMethod;
|
||||
use App\Models\Tenant;
|
||||
@@ -202,6 +203,11 @@ class GlobalDataProvider {
|
||||
return response()->json(RefundReason::options());
|
||||
}
|
||||
|
||||
/** Auswahl der Gründe, aus denen ein Teil des Beitrags beim Verband bleibt. */
|
||||
public function getRetentionReasons() : JsonResponse {
|
||||
return response()->json(RetentionReason::options());
|
||||
}
|
||||
|
||||
public function getEventSettingData(Request $request) : JsonResponse {
|
||||
return response()->json(
|
||||
[
|
||||
|
||||
@@ -32,7 +32,10 @@ class CostUnitResource {
|
||||
$amounts = [];
|
||||
$overAllAmount = new Amount(0, 'Euro');
|
||||
$overAllEstimatedAmount = new Amount(0, 'Euro');
|
||||
foreach (InvoiceType::orderBy('sort_order')->get() as $invoiceType) {
|
||||
// Nur echte Aufwandsarten: Eine Beitragserstattung ist die Rücknahme einer Einnahme und wird auf
|
||||
// der Einnahmenseite bereits berücksichtigt -- hier gezählt, stünde sie ein zweites Mal in der
|
||||
// Bilanz. `totalAmount` weiter oben bleibt davon unberührt, das ist die Kassensicht.
|
||||
foreach (InvoiceType::countingAsExpense() as $invoiceType) {
|
||||
$overAllAmount->addAmount($costUnitRepository->sumupByInvoiceType($this->costUnit, $invoiceType));
|
||||
$overAllEstimatedAmount->addAmount($costUnitRepository->sumupEstimatedByInvoiceType($this->costUnit, $invoiceType));
|
||||
$amounts[$invoiceType->slug]['string'] = $costUnitRepository->sumupByInvoiceType($this->costUnit, $invoiceType)->toString();
|
||||
|
||||
@@ -96,6 +96,14 @@ class EventResource extends JsonResource{
|
||||
|
||||
$returnArray['income'] = $this->calculateIncomes($returnArray['participants'], $returnArray['supportPerson']['amount']);
|
||||
|
||||
// Eigene Zeile in der Übersicht: In den Zeilen je Teilnahmeart hätte der Betrag nichts zu suchen,
|
||||
// dort stehen nur aktive Anmeldungen.
|
||||
$retainedFromUnregistered = $this->sumPaidOfUnregistered();
|
||||
$returnArray['retainedFromUnregistered'] = [
|
||||
'value' => $retainedFromUnregistered->getAmount(),
|
||||
'readable' => $retainedFromUnregistered->toString(),
|
||||
];
|
||||
|
||||
|
||||
$totalBalanceReal = new Amount(0, 'Euro');
|
||||
$totalBalanceExpected = new Amount(0, 'Euro');
|
||||
@@ -272,6 +280,13 @@ class EventResource extends JsonResource{
|
||||
$realAmount->addAmount(new Amount($participantData['amount']['paid']['value'], 'Euro'));
|
||||
}
|
||||
|
||||
// Was abgemeldete Teilis gezahlt haben und nicht zurückbekommen, gehört in beide Spalten: Das
|
||||
// Geld liegt beim Verband (real) und fließt nicht mehr ab (erwartet). Ohne diese Zeile stünde
|
||||
// jede Veranstaltung mit Abmeldungen dauerhaft schlechter da, als sie ist.
|
||||
$retained = $this->sumPaidOfUnregistered();
|
||||
$realAmount->addAmount($retained);
|
||||
$expectedAmount->addAmount($retained);
|
||||
|
||||
return ['real' => [
|
||||
'amount' => $realAmount,
|
||||
'readable' => $realAmount->toString()
|
||||
@@ -283,6 +298,28 @@ class EventResource extends JsonResource{
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Was von abgemeldeten Teilis beim Verband geblieben ist.
|
||||
*
|
||||
* `amount_paid` führt nach einer Erstattung genau den einbehaltenen Rest; wurde nie erstattet, steht
|
||||
* dort der volle gezahlte Beitrag. Beides ist Geld, das der Veranstaltung zusteht.
|
||||
*
|
||||
* Bewusst eine direkte Abfrage wie in {@see self::getParticipants()} nebenan -- ein einzelner
|
||||
* Repository-Aufruf zwischen den Inline-Queries dieser Klasse würde sie uneinheitlicher machen.
|
||||
*/
|
||||
public function sumPaidOfUnregistered() : Amount
|
||||
{
|
||||
$sum = new Amount(0, 'Euro');
|
||||
|
||||
foreach ($this->event->participants()->whereNotNull('unregistered_at')->get() as $participant) {
|
||||
if ($participant->amount_paid !== null) {
|
||||
$sum->addAmount($participant->amount_paid);
|
||||
}
|
||||
}
|
||||
|
||||
return $sum;
|
||||
}
|
||||
|
||||
public function getParticipants(string $participationType) : array {
|
||||
$returnData = [];
|
||||
$returnData['amount'] = [
|
||||
|
||||
@@ -29,6 +29,12 @@ class ParticipantRefundResource extends JsonResource
|
||||
'reason' => $this->resource->reason,
|
||||
'reasonLabel' => $this->resource->reasonLabel(),
|
||||
'reasonNote' => $this->resource->reason_note,
|
||||
// Was beim Verband bleibt. `hasRetention` erspart dem Frontend den Betragsvergleich samt
|
||||
// Rundungsfrage -- es soll nur entscheiden, ob der Hinweis angezeigt wird.
|
||||
'hasRetention' => $this->resource->hasRetention(),
|
||||
'retainedAmount' => $this->resource->retained_amount?->toString() ?? '0,00 Euro',
|
||||
'retentionReasonLabel' => $this->resource->retentionReasonLabel(),
|
||||
'retentionReasonNote' => $this->resource->retention_reason_note,
|
||||
'releasedAt' => $this->resource->released_at?->format('d.m.Y'),
|
||||
'acceptedAt' => $this->resource->accepted_at?->format('d.m.Y'),
|
||||
'cancelledAt' => $this->resource->cancelled_at?->format('d.m.Y'),
|
||||
|
||||
Reference in New Issue
Block a user