Calculation errors for refunded amounts
This commit is contained in:
@@ -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>';
|
||||
|
||||
Reference in New Issue
Block a user