From efee20c16b44a490f2e46f70f93daca2dd2d4229 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCnrher?= Date: Thu, 3 Sep 2026 23:08:41 +0200 Subject: [PATCH] Approven von Beitragserstattungen --- .../Event/Views/Partials/ParticipantsList.vue | 3 + .../AcceptRefund/AcceptRefundCommand.php | 219 ++++++++- .../ReleaseRefund/ReleaseRefundCommand.php | 15 +- app/Enumerations/InvoiceType.php | 26 ++ .../RefundAcceptedMail.php | 5 + app/Models/ParticipantRefund.php | 11 + app/Providers/GlobalDataProvider.php | 11 +- app/Providers/UploadFileProvider.php | 18 +- app/Resources/ParticipantRefundResource.php | 2 + ..._add_participation_refund_invoice_type.php | 57 +++ ..._add_invoice_id_to_participant_refunds.php | 32 ++ .../emails/events/refund_accepted.blade.php | 23 +- tests/Feature/InvoiceTypeSelectionTest.php | 93 ++++ tests/Feature/ParticipantRefundTest.php | 91 +++- tests/Feature/RefundInvoiceTest.php | 427 ++++++++++++++++++ 15 files changed, 994 insertions(+), 39 deletions(-) create mode 100644 database/migrations/2026_09_05_140010_add_participation_refund_invoice_type.php create mode 100644 database/migrations/2026_09_05_140020_add_invoice_id_to_participant_refunds.php create mode 100644 tests/Feature/InvoiceTypeSelectionTest.php create mode 100644 tests/Feature/RefundInvoiceTest.php diff --git a/app/Domains/Event/Views/Partials/ParticipantsList.vue b/app/Domains/Event/Views/Partials/ParticipantsList.vue index 65d0199..00333b0 100644 --- a/app/Domains/Event/Views/Partials/ParticipantsList.vue +++ b/app/Domains/Event/Views/Partials/ParticipantsList.vue @@ -524,6 +524,9 @@ function mailToGroup(groupKey) { diff --git a/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundCommand.php b/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundCommand.php index f38c5b1..31825b7 100644 --- a/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundCommand.php +++ b/app/Domains/ParticipantRefund/Actions/AcceptRefund/AcceptRefundCommand.php @@ -2,12 +2,27 @@ namespace App\Domains\ParticipantRefund\Actions\AcceptRefund; +use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceCommand; +use App\Domains\Invoice\Actions\CreateInvoice\CreateInvoiceRequest; use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentCommand; use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentRequest; +use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocumentResponse; +use App\Enumerations\InvoiceType; use App\Mail\ParticipantRefundMails\RefundAcceptedMail; +use App\Models\CostUnit; +use App\Models\Invoice; use App\Models\ParticipantRefund; +use App\Providers\FileWriteProvider; +use App\Providers\UploadFileProvider; +use App\Repositories\CostUnitRepository; use App\Support\Iban; +use App\ValueObjects\Amount; +use App\ValueObjects\InvoiceFile; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Str; +use RuntimeException; /** * Der Teili bestätigt die Erstattung und hinterlegt seine Bankverbindung. @@ -72,13 +87,45 @@ class AcceptRefundCommand return $response; } - $refund->account_owner = $owner; - $refund->account_iban = $iban; - $refund->status = ParticipantRefund::STATUS_ACCEPTED; - $refund->accepted_at = now(); - $refund->save(); + // Ohne Kostenstelle gibt es nichts, worauf gebucht werden könnte. Lieber hier abbrechen, als den + // Vorgang zu bestätigen und die Auszahlung stillschweigend nirgends einzureichen. + $costUnit = $this->costUnit($refund); + if ($costUnit === null) { + $response->message = 'Die Erstattung kann gerade nicht bearbeitet werden. ' + . 'Bitte wende dich an die Aktionsleitung.'; - $this->notify($refund); + Log::error('Beitragserstattung: Veranstaltung ohne Kostenstelle, Abrechnung nicht möglich.', [ + 'refund_id' => $refund->id, + 'event_id' => $refund->event_id, + ]); + + return $response; + } + + // Der Beleg entsteht in der Transaktion, weil er den bestätigten Stand abbildet; scheitert das + // Einreichen, soll auch kein Beleg gelten. + $document = DB::transaction(function () use ($refund, $owner, $iban, $costUnit) { + $refund->account_owner = $owner; + $refund->account_iban = $iban; + $refund->status = ParticipantRefund::STATUS_ACCEPTED; + $refund->accepted_at = now(); + $refund->save(); + + $document = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund))->execute(); + + $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); + + $refund->invoice_id = $invoice->id; + $refund->save(); + + return $document; + }); + + $this->notify($refund, $document); $response->success = true; $response->message = 'Vielen Dank. Deine Angaben liegen uns vor.'; @@ -87,30 +134,162 @@ class AcceptRefundCommand } /** - * Bestätigung mit dem Beleg im Anhang, an Teili und Kontaktperson. + * Die Kostenstelle der Veranstaltung. * - * Scheitert die Belegerzeugung, geht die Mail trotzdem raus -- der Vorgang ist gespeichert, und die - * Aktionsleitung kann den Beleg jederzeit erneut abrufen. Ein Fehler hier darf nicht dazu führen, - * dass der Teili gar nichts hört. + * Ohne Zugriffsprüfung, weil hier niemand angemeldet ist -- der Teili bestätigt über seinen Token. + * Der Repository-Check greift sonst auf `auth()->user()->id` zu und liefe in einen Fehler. + * + * Bewusst ohne Prüfung auf `allow_new`/`archived`: Eine Erstattung fällt oft erst nach dem Ende der + * Veranstaltung an, wenn die Kostenstelle längst geschlossen ist. Sie gehört trotzdem dorthin -- und + * der reguläre Weg über SaveInvoiceController prüft das ebenso wenig. */ - private function notify(ParticipantRefund $refund): void + private function costUnit(ParticipantRefund $refund): ?CostUnit { - $document = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund))->execute(); + if ($refund->event->cost_unit_id === null) { + return null; + } + return new CostUnitRepository()->getById($refund->event->cost_unit_id, true); + } + + /** + * Reicht die Erstattung als gewöhnliche Auslagenabrechnung ein. + * + * Über denselben Command wie jede von Hand erfasste Abrechnung: damit stimmen Nummernkreis, Status + * `new`, die Bestätigungsmail an den Teili und die Benachrichtigung der Kassenwart*innen mit dem + * überein, was die Buchhaltung kennt. + */ + private function createInvoice( + ParticipantRefund $refund, + CostUnit $costUnit, + CreateRefundDocumentResponse $document, + ): Invoice { + $participant = $refund->participant; + + $invoiceRequest = new CreateInvoiceRequest( + costUnit: $costUnit, + // getOfficialName() und nicht getFullName(): letzteres enthält HTML für die Oberfläche. + contactName: $participant->getOfficialName(), + invoiceType: InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, + totalAmount: $refund->amount?->getAmount() ?? 0.0, + receiptFile: $this->storeReceipt($costUnit, $document), + isDonation: false, + userId: $participant->user_id, + contactEmail: $participant->email_1, + contactPhone: $participant->phone_1, + // Die Bankverbindung stammt aus dem Vorgang, nicht vom Teilnehmer: das Konto kann einem + // Elternteil gehören. + accountOwner: $refund->account_owner, + accountIban: $refund->account_iban, + + // Die folgenden vier gehören zu Reisekosten und Freitext-Typen und sind hier leer. Sie + // müssen trotzdem stehen: `transportations` hat als einziger Parameter keinen Vorgabewert, + // und PHP macht damit auch alle optionalen Parameter davor zu Pflichtangaben. + invoiceTypeExtended: null, + travelRoute: null, + distance: null, + passengers: null, + transportations: null, + + // MUSS null bleiben (nicht ''): CreateInvoiceCommand verwirft die user_id, sobald hier etwas + // steht -- der Teili fände seine Abrechnung dann nicht unter "Meine Abrechnungen". + paymentPurpose: null, + notices: $this->notice($refund), + ); + + $invoiceResponse = new CreateInvoiceCommand($invoiceRequest)->execute(); + + if (!$invoiceResponse->success || $invoiceResponse->invoice === null) { + // Rollt die Transaktion zurück -- der Vorgang bleibt offen, der Teili kann es erneut versuchen. + throw new RuntimeException('Die Abrechnung zur Beitragserstattung konnte nicht angelegt werden.'); + } + + return $invoiceResponse->invoice; + } + + /** + * Legt den Eigenbeleg dort ab, wo auch hochgeladene Belege liegen, und verpackt ihn für die + * Abrechnung. `CreateInvoiceCommand` speichert nur den Pfad und schreibt selbst keine Dateien. + */ + private function storeReceipt(CostUnit $costUnit, CreateRefundDocumentResponse $document): ?InvoiceFile + { + if (!$document->success) { + return null; + } + + $path = UploadFileProvider::directoryFor($costUnit) . '/' . $document->filename; + + new FileWriteProvider($path, $document->pdfContent)->writeToFile(); + + $receipt = new InvoiceFile(); + // Beide Eigenschaften sind typisiert und ohne Vorbelegung; gespeichert wird nur `fullPath`. + $receipt->filename = $document->filename; + $receipt->fullPath = $path; + + return $receipt; + } + + /** + * Die Anmerkung auf der Abrechnung. + * + * Sie nennt den gezahlten Beitrag, weil er am Teilnehmer gleich auf 0 gesetzt wird + * ({@see self::clearAmountPaid()}) -- die Schatzmeisterei kann den Vorgang so nachvollziehen, ohne + * den vorherigen Stand irgendwo suchen zu müssen. + * + * Gekürzt wird nur der vordere, freie Teil: Veranstaltungsname und Grund sind beliebig lang, der + * Betrag darf nie abgeschnitten werden. + */ + private function notice(ParticipantRefund $refund): string + { + $paid = $refund->participant->amount_paid?->toString() ?? '0,00 Euro'; + + return Str::limit(sprintf( + 'Rückerstattung Teilnahmebeitrag %s – %s', + $refund->event->name, + $refund->reasonLabel() + ), 180) . sprintf(' | Gezahlter Beitrag vor Erstattung: %s', $paid); + } + + /** + * Setzt den gezahlten Beitrag des Teilis auf 0. + * + * 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. + */ + private function clearAmountPaid(ParticipantRefund $refund): void + { + $participant = $refund->participant; + $participant->amount_paid = new Amount(0.0, 'Euro'); + $participant->save(); + } + + /** + * Die eigene Bestätigung mit dem Beleg im Anhang, an Teili und Kontaktperson. + * + * Sie kommt zusätzlich zu der, die CreateInvoiceCommand verschickt: diese trägt den Beleg, jene ist + * die Quittung des Abrechnungssystems. Erst nach der Transaktion, damit nichts verschickt wird, was + * anschließend zurückgerollt würde. + * + * Scheitert die Belegerzeugung, geht die Mail ohne Anhang raus statt gar nicht. + */ + private function notify(ParticipantRefund $refund, CreateRefundDocumentResponse $document): void + { $pdf = $document->success ? $document->pdfContent : null; $filename = $document->success ? $document->filename : null; $participant = $refund->participant; - Mail::to($participant->email_1)->send(new RefundAcceptedMail( - participant: $participant, - refund: $refund, - pdfContent: $pdf, - pdfFilename: $filename, - )); + $recipients = [$participant->email_1]; - if ($participant->email_2 !== null) { - Mail::to($participant->email_2)->send(new RefundAcceptedMail( + // `filled()` und nicht `!== null`: Der Anmeldewizard überspringt den Schritt "Kontaktperson" bei + // Volljährigen und legt das Feld als Leerstring an -- `Mail::to('')` liefe ins Leere. + if (filled($participant->email_2)) { + $recipients[] = $participant->email_2; + } + + foreach ($recipients as $recipient) { + Mail::to($recipient)->send(new RefundAcceptedMail( participant: $participant, refund: $refund, pdfContent: $pdf, diff --git a/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundCommand.php b/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundCommand.php index d64be2e..83c5382 100644 --- a/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundCommand.php +++ b/app/Domains/ParticipantRefund/Actions/ReleaseRefund/ReleaseRefundCommand.php @@ -119,13 +119,16 @@ class ReleaseRefundCommand */ private function notify(ParticipantRefund $refund): void { - Mail::to($this->participant->email_1)->send(new RefundReleasedMail( - participant: $this->participant, - refund: $refund, - )); + $recipients = [$this->participant->email_1]; - if ($this->participant->email_2 !== null) { - Mail::to($this->participant->email_2)->send(new RefundReleasedMail( + // `filled()` und nicht `!== null`: Der Anmeldewizard überspringt den Schritt "Kontaktperson" bei + // Volljährigen und legt das Feld als Leerstring an -- `Mail::to('')` liefe ins Leere. + if (filled($this->participant->email_2)) { + $recipients[] = $this->participant->email_2; + } + + foreach ($recipients as $recipient) { + Mail::to($recipient)->send(new RefundReleasedMail( participant: $this->participant, refund: $refund, )); diff --git a/app/Enumerations/InvoiceType.php b/app/Enumerations/InvoiceType.php index 9fd0b44..782768f 100644 --- a/app/Enumerations/InvoiceType.php +++ b/app/Enumerations/InvoiceType.php @@ -21,8 +21,34 @@ class InvoiceType extends CommonModel { public const INVOICE_TYPE_MANAGEMENT = 'management'; + /** + * Erstattung eines Teilnahmebeitrags. Entsteht ausschließlich aus einem bestätigten + * Erstattungsvorgang und ist deshalb nicht von Hand wählbar ({@see self::selectable()}). + */ + public const INVOICE_TYPE_PARTICIPATION_REFUND = 'participation_refund'; + protected $fillable = [ 'slug', 'name', + 'sort_order', + 'selectable', ]; + + protected $casts = [ + 'sort_order' => 'integer', + 'selectable' => 'boolean', + ]; + + /** + * Die Typen, die in einem Formular zur Auswahl stehen dürfen -- nach Sortierung. + * + * Automatisch vergebene Typen bleiben außen vor: Ihre Abrechnungen entstehen aus einem Vorgang, der + * die Daten mitbringt; von Hand gewählt stünde ein leerer Rahmen ohne diesen Vorgang da. + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public static function selectable(): \Illuminate\Database\Eloquent\Collection + { + return self::where('selectable', true)->orderBy('sort_order')->get(); + } } diff --git a/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php b/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php index fa5ad5d..3b12655 100644 --- a/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php +++ b/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php @@ -40,6 +40,7 @@ class RefundAcceptedMail extends Mailable public function content(): Content { $event = $this->participant->event()->first(); + $invoice = $this->refund->invoice()->first(); return new Content( view: 'emails.events.refund_accepted', @@ -52,6 +53,10 @@ class RefundAcceptedMail extends Mailable 'accountOwner' => $this->refund->account_owner, 'accountIban' => Iban::format((string) $this->refund->account_iban), 'hasDocument' => $this->pdfContent !== null, + 'invoiceNumber' => $invoice?->invoice_number, + // Der Hinweis auf "Meine Abrechnungen" nur, wenn die Anmeldung an einem Konto hängt -- + // die Seite filtert über die Nutzer-Verknüpfung und bliebe sonst leer. + 'myInvoicesUrl' => $invoice?->user_id !== null ? url('/invoice/my-invoices/new') : null, ], ); } diff --git a/app/Models/ParticipantRefund.php b/app/Models/ParticipantRefund.php index 89190ac..4084543 100644 --- a/app/Models/ParticipantRefund.php +++ b/app/Models/ParticipantRefund.php @@ -22,6 +22,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property string|null $reason_note * @property string|null $account_owner * @property string|null $account_iban + * @property int|null $invoice_id * @property int|null $released_by * @property \Illuminate\Support\Carbon|null $released_at * @property \Illuminate\Support\Carbon|null $accepted_at @@ -51,6 +52,7 @@ class ParticipantRefund extends InstancedModel 'reason_note', 'account_owner', 'account_iban', + 'invoice_id', 'released_by', 'released_at', 'accepted_at', @@ -82,6 +84,15 @@ class ParticipantRefund extends InstancedModel return $this->belongsTo(RefundReason::class, 'reason', 'slug'); } + /** + * Die Abrechnung, die aus diesem Vorgang entstanden ist. Der Auszahlungsstand steht dort und wird + * hier nicht gedoppelt. + */ + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class); + } + public function isPending(): bool { return $this->status === self::STATUS_PENDING; diff --git a/app/Providers/GlobalDataProvider.php b/app/Providers/GlobalDataProvider.php index c9618a1..e70831b 100644 --- a/app/Providers/GlobalDataProvider.php +++ b/app/Providers/GlobalDataProvider.php @@ -46,9 +46,10 @@ class GlobalDataProvider { ]); } + /** Die Typen für die Neuanlage durch Nutzer*innen. Reisekosten haben ein eigenes Formular. */ public function getInvoiceTypes() : JsonResponse { $invoiceTypes = []; - foreach (InvoiceType::orderBy('sort_order')->get() as $invoiceType) { + foreach (InvoiceType::selectable() as $invoiceType) { if ( $invoiceType->slug === InvoiceType::INVOICE_TYPE_TRAVELLING ) { @@ -110,9 +111,15 @@ class GlobalDataProvider { ]; } + /** + * Die Typen zum Umbuchen durch die Kassenwart*innen -- „Sonstige Kosten" ans Ende. + * + * Auch hier nur wählbare Typen: sonst ließe sich eine beliebige Abrechnung nachträglich zu einer + * Beitragserstattung machen, ohne dass ein Erstattungsvorgang dahinterstünde. + */ public function getAllInvoiceTypes() : JsonResponse { $invoiceTypes = []; - foreach (InvoiceType::orderBy('sort_order')->get() as $invoiceType) { + foreach (InvoiceType::selectable() as $invoiceType) { if ( $invoiceType->slug === InvoiceType::INVOICE_TYPE_OTHER ) { diff --git a/app/Providers/UploadFileProvider.php b/app/Providers/UploadFileProvider.php index cf10802..f6f9de0 100644 --- a/app/Providers/UploadFileProvider.php +++ b/app/Providers/UploadFileProvider.php @@ -16,13 +16,21 @@ class UploadFileProvider { $this->costUnit = $costUnit; } + /** + * Das Ablageverzeichnis der Belege einer Kostenstelle, relativ zur Disk `local` + * (Wurzel `storage/app/private`). + * + * Öffentlich, weil Belege nicht nur aus einem Upload entstehen: Eine Beitragserstattung erzeugt ihren + * Eigenbeleg im Speicher und legt ihn über den FileWriteProvider ab -- landen soll er trotzdem dort, + * wo alle anderen Belege liegen. + */ + public static function directoryFor(CostUnit $costUnit) : string { + return sprintf('%1$s/invoices/%2$s', app('tenant')->slug, $costUnit->id); + } + public function saveUploadedFile() : ?InvoiceFile { try { - $directory = sprintf( - '%1$s/invoices/%2$s', - app('tenant')->slug, - $this->costUnit->id - ); + $directory = self::directoryFor($this->costUnit); $filename = $this->normalizeFilename($this->file->getClientOriginalName()); diff --git a/app/Resources/ParticipantRefundResource.php b/app/Resources/ParticipantRefundResource.php index 433be0e..714ac1b 100644 --- a/app/Resources/ParticipantRefundResource.php +++ b/app/Resources/ParticipantRefundResource.php @@ -32,6 +32,8 @@ class ParticipantRefundResource extends JsonResource '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'), + // Die Abrechnung, über die ausgezahlt wird -- ihr Status ist der Auszahlungsstand. + 'invoiceNumber' => $this->resource->invoice()->first()?->invoice_number, ]; } } diff --git a/database/migrations/2026_09_05_140010_add_participation_refund_invoice_type.php b/database/migrations/2026_09_05_140010_add_participation_refund_invoice_type.php new file mode 100644 index 0000000..28b072c --- /dev/null +++ b/database/migrations/2026_09_05_140010_add_participation_refund_invoice_type.php @@ -0,0 +1,57 @@ +boolean('selectable')->default(true)->after('sort_order'); + }); + + if (DB::table('invoice_types')->where('slug', self::SLUG)->exists()) { + return; + } + + DB::table('invoice_types')->insert([ + 'slug' => self::SLUG, + 'name' => 'Beitragserstattung', + // Hinter "Verwaltung" (2) und vor "Sonstige Kosten" (3) wäre die Reihenfolge unklar; der Typ + // taucht ohnehin nur in Summen und Auswertungen auf. + 'sort_order' => 4, + 'selectable' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function down(): void + { + // Nur löschen, wenn keine Abrechnung daran hängt -- der Fremdschlüssel würde es sonst verhindern, + // und ein Rollback soll nicht an fremden Daten scheitern. + if (!DB::table('invoices')->where('type', self::SLUG)->exists()) { + DB::table('invoice_types')->where('slug', self::SLUG)->delete(); + } + + Schema::table('invoice_types', function (Blueprint $table) { + $table->dropColumn('selectable'); + }); + } +}; diff --git a/database/migrations/2026_09_05_140020_add_invoice_id_to_participant_refunds.php b/database/migrations/2026_09_05_140020_add_invoice_id_to_participant_refunds.php new file mode 100644 index 0000000..7108180 --- /dev/null +++ b/database/migrations/2026_09_05_140020_add_invoice_id_to_participant_refunds.php @@ -0,0 +1,32 @@ +foreignId('invoice_id')->nullable()->after('account_iban') + ->constrained('invoices', 'id')->nullOnDelete()->cascadeOnUpdate(); + }); + } + + public function down(): void + { + Schema::table('participant_refunds', function (Blueprint $table) { + $table->dropForeign(['invoice_id']); + $table->dropColumn('invoice_id'); + }); + } +}; diff --git a/resources/views/emails/events/refund_accepted.blade.php b/resources/views/emails/events/refund_accepted.blade.php index 6b2804b..26eb892 100644 --- a/resources/views/emails/events/refund_accepted.blade.php +++ b/resources/views/emails/events/refund_accepted.blade.php @@ -4,7 +4,8 @@

Hallo {{$name}}!

vielen Dank – deine Angaben zur Rückerstattung für die Veranstaltung "{{$eventTitle}}" liegen - uns vor. + uns vor. Du musst nichts weiter tun: Die Erstattung ist bereits als Abrechnung + eingereicht und wird nun bearbeitet.

@@ -16,6 +17,12 @@ + @if ($invoiceNumber) + + + + + @endif @@ -28,13 +35,21 @@ @if ($hasDocument)

- Im Anhang findest du den Beleg über die Rückerstattung als PDF. + Im Anhang findest du deinen Beleg über die Rückerstattung als PDF – nur zu deiner + Information, eingereicht ist er bereits. +

+@endif + +@if ($myInvoicesUrl) +

+ Den Stand deiner Abrechnung kannst du jederzeit selbst nachsehen: Melde dich in mareike an und + öffne Meine Abrechnungen.

@endif

- Die Überweisung veranlasst die Aktionsleitung. Stimmt etwas an den obenstehenden Angaben nicht, melde - dich bitte umgehend bei ihr. + Sobald die Abrechnung bearbeitet wurde, wird der Betrag auf das oben genannte Konto überwiesen. + Stimmt etwas an den Angaben nicht, melde dich bitte umgehend bei der Aktionsleitung.

diff --git a/tests/Feature/InvoiceTypeSelectionTest.php b/tests/Feature/InvoiceTypeSelectionTest.php new file mode 100644 index 0000000..f5ddf4c --- /dev/null +++ b/tests/Feature/InvoiceTypeSelectionTest.php @@ -0,0 +1,93 @@ + 'wm', + 'name' => 'Wilde Möhre', + 'email' => 'wm@example.com', + 'email_finance' => 'wm-f@example.com', + 'url' => parse_url(config('app.url'), PHP_URL_HOST), + 'account_name' => 'Wilde Möhre e.V.', + 'account_iban' => 'DE00', + 'account_bic' => 'XY', + 'city' => 'Stadt', + 'postcode' => '00000', + 'is_active_local_group' => true, + 'has_active_instance' => true, + ]); + + app()->instance('tenant', $tenant); + + // Zwei wählbare Typen neben der Beitragserstattung, die aus der Migration kommt. + DB::table('invoice_types')->insert([ + ['slug' => InvoiceType::INVOICE_TYPE_PROGRAM, 'name' => 'Programmkosten', 'sort_order' => 1, 'selectable' => true], + ['slug' => InvoiceType::INVOICE_TYPE_OTHER, 'name' => 'Sonstige Kosten', 'sort_order' => 3, 'selectable' => true], + ]); + } + + public function test_the_refund_type_exists_but_is_not_selectable(): void + { + // `where` und nicht `find`: InvoiceType deklariert keinen Primärschlüssel, `find` suchte nach `id`. + $type = InvoiceType::where('slug', InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND)->first(); + + $this->assertNotNull($type, 'Der Typ muss existieren -- invoices.type ist ein Fremdschlüssel darauf.'); + $this->assertFalse($type->selectable); + } + + public function test_it_is_missing_from_the_new_invoice_form(): void + { + $response = $this->getJson('/api/v1/core/retrieve-invoice-types'); + + $response->assertOk(); + $slugs = array_column($response->json('invoiceTypes'), 'slug'); + + $this->assertNotContains(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, $slugs); + $this->assertContains(InvoiceType::INVOICE_TYPE_PROGRAM, $slugs); + $this->assertContains(InvoiceType::INVOICE_TYPE_OTHER, $slugs); + } + + public function test_it_is_missing_when_a_treasurer_rebooks(): void + { + // Ohne diese Sperre ließe sich jede beliebige Abrechnung nachträglich zu einer + // Beitragserstattung machen, ohne dass ein Erstattungsvorgang dahinterstünde. + $response = $this->getJson('/api/v1/core/retrieve-invoice-types-all'); + + $response->assertOk(); + $slugs = array_column($response->json('invoiceTypes'), 'slug'); + + $this->assertNotContains(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, $slugs); + $this->assertContains(InvoiceType::INVOICE_TYPE_PROGRAM, $slugs); + // "Sonstige Kosten" steht dort bewusst am Ende. + $this->assertSame(InvoiceType::INVOICE_TYPE_OTHER, end($slugs)); + } + + public function test_selectable_returns_only_choosable_types_in_order(): void + { + $this->assertSame( + [InvoiceType::INVOICE_TYPE_PROGRAM, InvoiceType::INVOICE_TYPE_OTHER], + InvoiceType::selectable()->pluck('slug')->all() + ); + } +} diff --git a/tests/Feature/ParticipantRefundTest.php b/tests/Feature/ParticipantRefundTest.php index 819dabe..ee70f38 100644 --- a/tests/Feature/ParticipantRefundTest.php +++ b/tests/Feature/ParticipantRefundTest.php @@ -10,10 +10,14 @@ use App\Domains\ParticipantRefund\Actions\CreateRefundDocument\CreateRefundDocum use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundCommand; use App\Domains\ParticipantRefund\Actions\ReleaseRefund\ReleaseRefundRequest; use App\Enumerations\EfzStatus; +use App\Enumerations\CostUnitType; +use App\Enumerations\InvoiceStatus; +use App\Enumerations\InvoiceType; use App\Enumerations\RefundReason; use App\Enumerations\UserRole; use App\Mail\ParticipantRefundMails\RefundAcceptedMail; use App\Mail\ParticipantRefundMails\RefundReleasedMail; +use App\Models\CostUnit; use App\Models\DocumentTemplate; use App\Models\Event; use App\Models\EventParticipant; @@ -67,6 +71,10 @@ class ParticipantRefundTest extends TestCase DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']); DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']); + // Lookup-Tabellen mit Fremdschlüsselzwang -- ohne sie scheitert schon das Anlegen. + // `invoice_types.participation_refund` bringt die Migration bereits mit. + DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']); + DB::table('invoice_status')->insert(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]); PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]); PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_NOT_DEFINED]); EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']); @@ -112,6 +120,20 @@ class ParticipantRefundTest extends TestCase ]); } + /** Die Kostenstelle, auf die die Erstattung gebucht wird -- beim Anlegen einer Veranstaltung entsteht sie mit. */ + private function makeCostUnit(array $attributes = []): CostUnit + { + return CostUnit::create(array_merge([ + 'tenant' => $this->tenant->slug, + 'name' => 'Sommerlager', + 'type' => CostUnitType::COST_UNIT_TYPE_EVENT, + 'distance_allowance' => 0.25, + 'mail_on_new' => false, + 'allow_new' => true, + 'archived' => false, + ], $attributes)); + } + private function makeEvent(array $attributes = []): Event { $fee = EventParticipationFee::create([ @@ -125,6 +147,7 @@ class ParticipantRefundTest extends TestCase ]); return Event::create(array_merge([ + 'cost_unit_id' => $this->makeCostUnit()->id, 'tenant' => $this->tenant->slug, 'name' => 'Sommerlager', 'identifier' => 'evt-' . uniqid(), @@ -532,18 +555,32 @@ class ParticipantRefundTest extends TestCase |-------------------------------------------------------------------------- */ - public function test_amount_paid_is_untouched_through_the_whole_process(): void + public function test_amount_paid_survives_the_release_and_falls_with_the_confirmation(): void { $participant = $this->makeParticipant($this->makeEvent()); + // Die Freigabe allein ändert nichts -- solange der Teili nicht bestätigt hat, ist der Beitrag + // unverändert beim Verband. $refund = $this->release($participant)->refund; $this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001); $this->accept($refund); - $this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001); + $this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001); + + // Der Sollbetrag bleibt: was der Teili hätte zahlen müssen, ändert die Erstattung nicht. $this->assertEqualsWithDelta(300.0, $participant->fresh()->amount->getAmount(), 0.001); } + public function test_a_cancelled_release_leaves_amount_paid_alone(): void + { + $participant = $this->makeParticipant($this->makeEvent()); + $refund = $this->release($participant)->refund; + + new CancelRefundCommand(new CancelRefundRequest($refund))->execute(); + + $this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001); + } + /* |-------------------------------------------------------------------------- | Über HTTP: Routen, Zugriffsschutz und die öffentliche Seite @@ -565,6 +602,22 @@ class ParticipantRefundTest extends TestCase ]); } + /** Das Konto, an dem eine Anmeldung hängen kann -- entscheidet über den Login-Hinweis in der Mail. */ + private function makeParticipantUser(): User + { + return User::create([ + 'username' => 'teili-' . uniqid() . '@example.com', + 'email' => 'teili-' . uniqid() . '@example.com', + 'firstname' => 'Mika', + 'lastname' => 'Muster', + 'password' => bcrypt('secret'), + 'local_group' => $this->tenant->slug, + 'user_role_main' => UserRole::USER_ROLE_USER, + 'user_role_local_group' => UserRole::USER_ROLE_USER, + 'active' => true, + ]); + } + public function test_release_over_http_creates_the_refund(): void { $this->actingAs($this->makeAdmin()); @@ -753,6 +806,40 @@ class ParticipantRefundTest extends TestCase $this->assertStringContainsString('Anhang', $html); } + public function test_the_acceptance_mail_points_at_the_invoice(): void + { + $participant = $this->makeParticipant($this->makeEvent(), ['user_id' => $this->makeParticipantUser()->id]); + $refund = $this->release($participant)->refund; + $this->accept($refund); + + $html = new RefundAcceptedMail($participant, $refund->fresh())->render(); + + // Die Mail ist nur noch informativ -- eingereicht ist die Erstattung bereits. + $this->assertStringContainsString('musst nichts weiter tun', $html); + $this->assertStringContainsString($refund->fresh()->invoice()->first()->invoice_number, $html); + $this->assertStringContainsString('/invoice/my-invoices/new', $html); + } + + public function test_the_acceptance_mail_omits_the_login_hint_without_an_account(): void + { + // Ohne Nutzerkonto bliebe "Meine Abrechnungen" leer -- die Seite filtert über die Verknüpfung. + $participant = $this->makeParticipant($this->makeEvent(), ['user_id' => null]); + $refund = $this->release($participant)->refund; + $this->accept($refund); + + $html = new RefundAcceptedMail($participant, $refund->fresh())->render(); + + $this->assertStringNotContainsString('/invoice/my-invoices/new', $html); + } + + public function test_an_empty_second_address_gets_no_mail(): void + { + // Der Anmeldewizard legt das Feld bei Volljährigen als Leerstring an, nicht als NULL. + $this->release($this->makeParticipant($this->makeEvent(), ['email_2' => ''])); + + Mail::assertSent(RefundReleasedMail::class, 1); + } + public function test_the_acceptance_mail_renders_without_a_document(): void { $participant = $this->makeParticipant($this->makeEvent()); diff --git a/tests/Feature/RefundInvoiceTest.php b/tests/Feature/RefundInvoiceTest.php new file mode 100644 index 0000000..2f05fa5 --- /dev/null +++ b/tests/Feature/RefundInvoiceTest.php @@ -0,0 +1,427 @@ +tenant = Tenant::create([ + 'slug' => 'wm', + 'name' => 'Wilde Möhre', + 'address_1' => 'Musterweg 1', + 'email' => 't@example.com', + 'email_finance' => 'finance@example.com', + 'url' => parse_url(config('app.url'), PHP_URL_HOST), + 'account_name' => 'Test e.V.', + 'account_iban' => 'DE00', + 'account_bic' => 'XY', + 'city' => 'Stadt', + 'postcode' => '00000', + 'invoice_prefix' => 'WM', + 'is_active_local_group' => true, + 'has_active_instance' => true, + ]); + + app()->instance('tenant', $this->tenant); + + DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']); + DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']); + DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']); + DB::table('invoice_status')->insert(['slug' => InvoiceStatus::INVOICE_STATUS_NEW]); + PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]); + EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']); + + foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) { + UserRole::create(['slug' => $role, 'name' => $role]); + } + + $this->seedTemplate(); + + // Der Beleg wird als Datei abgelegt -- ohne Fake landete er im echten Storage. + Storage::fake('local'); + Mail::fake(); + } + + private function seedTemplate(): void + { + DocumentTemplate::create([ + 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, + 'block' => DocumentTemplate::BLOCK_LAYOUT, + 'content' => '

{block:body}
', + 'sort_order' => 10, + ]); + + DocumentTemplate::create([ + 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, + 'block' => DocumentTemplate::BLOCK_BODY, + 'content' => '
{recipient_name}
{details_table}', + 'sort_order' => 20, + ]); + } + + private function makeCostUnit(array $attributes = []): CostUnit + { + return CostUnit::create(array_merge([ + 'tenant' => $this->tenant->slug, + 'name' => 'Sommerlager', + 'type' => CostUnitType::COST_UNIT_TYPE_EVENT, + 'distance_allowance' => 0.25, + 'mail_on_new' => false, + 'allow_new' => true, + 'archived' => false, + ], $attributes)); + } + + private function makeEvent(array $attributes = []): Event + { + $fee = EventParticipationFee::create([ + 'tenant' => $this->tenant->slug, + 'type' => 'participant', + 'name' => 'Sippe', + 'description' => null, + 'amount_standard' => 60.0, + 'amount_reduced' => null, + 'amount_solidarity' => null, + ]); + + return Event::create(array_merge([ + 'cost_unit_id' => $this->makeCostUnit()->id, + 'tenant' => $this->tenant->slug, + 'name' => 'Sommerlager', + 'identifier' => 'evt-' . uniqid(), + 'location' => 'Ort', + 'postal_code' => '00000', + 'email' => 'e@example.com', + 'start_date' => '2026-07-16', + 'end_date' => '2026-07-20', + 'early_bird_end' => '2026-06-20', + 'registration_final_end' => '2026-07-01', + 'early_bird_end_amount_increase' => 0, + 'account_owner' => 'Owner', + 'account_iban' => 'DE00', + 'participation_fee_type' => 'fixed', + 'participation_fee_1' => $fee->id, + 'pay_per_day' => true, + 'pay_direct' => false, + 'tax_liable' => false, + 'vat_rate' => 0, + 'vat_pricing_mode' => 'inclusive', + 'invoice_key' => 'WM-V-20260701', + ], $attributes)); + } + + private function makeUser(): User + { + return User::create([ + 'username' => 'teili-' . uniqid() . '@example.com', + 'email' => 'teili-' . uniqid() . '@example.com', + 'firstname' => 'Mika', + 'lastname' => 'Muster', + 'password' => bcrypt('secret'), + 'local_group' => $this->tenant->slug, + 'user_role_main' => UserRole::USER_ROLE_USER, + 'user_role_local_group' => UserRole::USER_ROLE_USER, + 'active' => true, + ]); + } + + private function makeParticipant(Event $event, array $attributes = []): EventParticipant + { + $this->sequence++; + + return $event->participants()->create(array_merge([ + 'tenant' => $this->tenant->slug, + 'identifier' => 'p-' . uniqid(), + 'invoice_sequence' => $this->sequence, + 'user_id' => $this->makeUser()->id, + 'firstname' => 'Mika', + 'lastname' => 'Muster', + 'participation_type' => 'participant', + 'fee_type' => 'standard', + 'sibling_reduction' => false, + 'local_group' => $this->tenant->slug, + 'birthday' => '2000-01-01', + 'address_1' => 'Beispielstraße 3', + 'postcode' => '11111', + 'city' => 'Beispielstadt', + 'email_1' => 'mika@example.com', + 'phone_1' => '0170 0000000', + 'arrival_date' => '2026-07-16', + 'departure_date' => '2026-07-20', + 'arrival_eating' => 1, + 'departure_eating' => 1, + 'amount' => 300.0, + 'amount_paid' => 300.0, + 'unregistered_at' => '2026-06-12', + 'payment_purpose' => 'Sommerlager', + 'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION, + 'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, + ], $attributes)); + } + + /** Der ganze Ablauf: Freigabe durch die Aktionsleitung, Bestätigung durch den Teili. */ + private function runRefund( + ?EventParticipant $participant = null, + float $amount = 220.0, + string $reason = RefundReason::SICKNESS, + ): ParticipantRefund { + $participant ??= $this->makeParticipant($this->makeEvent()); + + $refund = new ReleaseRefundCommand(new ReleaseRefundRequest( + participant: $participant, + amount: new Amount($amount, 'Euro'), + reason: $reason, + ))->execute()->refund; + + new AcceptRefundCommand(new AcceptRefundRequest( + refund: $refund, + accountOwner: 'Mika Muster', + accountIban: 'DE02120300000000202051', + declarationAccepted: true, + ))->execute(); + + return $refund->fresh(); + } + + /* + |-------------------------------------------------------------------------- + | Die Abrechnung entsteht + |-------------------------------------------------------------------------- + */ + + public function test_confirming_creates_an_invoice_on_the_events_cost_unit(): void + { + $event = $this->makeEvent(); + $this->runRefund($this->makeParticipant($event)); + + $invoice = Invoice::first(); + + $this->assertNotNull($invoice); + $this->assertSame(InvoiceStatus::INVOICE_STATUS_NEW, $invoice->status); + $this->assertSame(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, $invoice->type); + $this->assertSame($event->cost_unit_id, $invoice->cost_unit_id); + $this->assertEqualsWithDelta(220.0, $invoice->amount, 0.001); + $this->assertFalse((bool) $invoice->donation); + } + + public function test_contact_details_come_from_the_participant(): void + { + $this->runRefund(); + + $invoice = Invoice::first(); + + $this->assertSame('Mika Muster', $invoice->contact_name); + $this->assertSame('mika@example.com', $invoice->contact_email); + $this->assertSame('0170 0000000', $invoice->contact_phone); + } + + public function test_bank_details_come_from_the_refund_not_the_participant(): void + { + $this->runRefund(); + + $invoice = Invoice::first(); + + $this->assertSame('Mika Muster', $invoice->contact_bank_owner); + $this->assertSame('DE02120300000000202051', $invoice->contact_bank_iban); + } + + public function test_the_invoice_belongs_to_the_participants_user_account(): void + { + $participant = $this->makeParticipant($this->makeEvent()); + $this->runRefund($participant); + + $invoice = Invoice::first(); + + $this->assertSame($participant->user_id, $invoice->user_id); + // Ohne das bliebe die Verknüpfung wirkungslos: CreateInvoiceCommand verwirft die user_id, + // sobald ein Verwendungszweck gesetzt ist. + $this->assertNull($invoice->payment_purpose); + } + + public function test_an_anonymous_participant_still_gets_an_invoice(): void + { + $participant = $this->makeParticipant($this->makeEvent(), ['user_id' => null]); + $this->runRefund($participant); + + $invoice = Invoice::first(); + + $this->assertNotNull($invoice); + $this->assertNull($invoice->user_id); + } + + public function test_the_notice_names_event_reason_and_the_amount_paid(): void + { + $this->runRefund(); + + // Der gezahlte Beitrag gehört in die Anmerkung, weil er am Teilnehmer gleich auf 0 gesetzt wird -- + // sonst könnte die Schatzmeisterei den Vorgang nicht mehr nachvollziehen. + $this->assertSame( + 'Rückerstattung Teilnahmebeitrag Sommerlager – Krankheitsbedingte Absage' + . ' | Gezahlter Beitrag vor Erstattung: 300,00 Euro', + Invoice::first()->comment + ); + } + + public function test_a_long_notice_keeps_the_amount_paid(): void + { + $event = $this->makeEvent(['name' => str_repeat('Sehr langer Veranstaltungsname ', 12)]); + $this->runRefund($this->makeParticipant($event)); + + $comment = Invoice::first()->comment; + + // Gekürzt wird nur der freie Teil -- der Betrag darf nie wegfallen. + $this->assertStringContainsString('Gezahlter Beitrag vor Erstattung: 300,00 Euro', $comment); + $this->assertLessThanOrEqual(255, strlen($comment), 'Die Spalte `comment` fasst 255 Zeichen.'); + } + + /* + |-------------------------------------------------------------------------- + | Der Eigenbeleg hängt an der Abrechnung + |-------------------------------------------------------------------------- + */ + + public function test_the_receipt_is_attached_and_stored(): void + { + $event = $this->makeEvent(); + $this->runRefund($this->makeParticipant($event)); + + $invoice = Invoice::first(); + + $this->assertNotNull($invoice->document_filename); + // Dasselbe Ablagemuster wie bei hochgeladenen Belegen. + $this->assertStringStartsWith('wm/invoices/' . $event->cost_unit_id . '/', $invoice->document_filename); + $this->assertStringEndsWith('.pdf', $invoice->document_filename); + + Storage::disk('local')->assertExists($invoice->document_filename); + $this->assertStringStartsWith('%PDF', Storage::disk('local')->get($invoice->document_filename)); + } + + public function test_the_refund_points_at_its_invoice(): void + { + $refund = $this->runRefund(); + + $this->assertSame(Invoice::first()->id, $refund->invoice_id); + $this->assertSame(Invoice::first()->invoice_number, $refund->invoice()->first()->invoice_number); + } + + /* + |-------------------------------------------------------------------------- + | Die Kostenstelle + |-------------------------------------------------------------------------- + */ + + public function test_without_a_cost_unit_nothing_is_stored(): void + { + $participant = $this->makeParticipant($this->makeEvent(['cost_unit_id' => null])); + + $refund = new ReleaseRefundCommand(new ReleaseRefundRequest( + participant: $participant, + amount: new Amount(220.0, 'Euro'), + reason: RefundReason::SICKNESS, + ))->execute()->refund; + + $response = new AcceptRefundCommand(new AcceptRefundRequest( + refund: $refund, + accountOwner: 'Mika Muster', + accountIban: 'DE02120300000000202051', + declarationAccepted: true, + ))->execute(); + + $this->assertFalse($response->success); + $this->assertStringContainsString('Aktionsleitung', $response->message); + + // Der Vorgang bleibt offen, damit die Aktionsleitung nachbessern kann. + $refund->refresh(); + $this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->status); + $this->assertNull($refund->account_iban); + $this->assertSame(0, Invoice::count()); + } + + public function test_a_closed_cost_unit_is_still_booked(): void + { + // Erstattungen fallen oft erst an, wenn die Abrechnungsfrist der Veranstaltung längst durch ist. + $costUnit = $this->makeCostUnit(['allow_new' => false]); + $event = $this->makeEvent(['cost_unit_id' => $costUnit->id]); + + $this->runRefund($this->makeParticipant($event)); + + $this->assertSame($costUnit->id, Invoice::first()->cost_unit_id); + } + + /* + |-------------------------------------------------------------------------- + | Der reguläre Weg läuft mit + |-------------------------------------------------------------------------- + */ + + public function test_the_standard_confirmation_mail_is_sent(): void + { + $this->runRefund(); + + // Die Quittung des Abrechnungssystems -- zusätzlich zu unserer Mail mit dem Beleg. + Mail::assertSent(InvoiceMailsSubmittedConfirmationMail::class); + } + + public function test_amount_paid_is_cleared_once_the_invoice_exists(): void + { + $participant = $this->makeParticipant($this->makeEvent()); + $this->runRefund($participant); + + // Mit der eingereichten Abrechnung ist der Beitrag auf dem Weg zurück und darf in den + // Zahlungsübersichten nicht länger als eingegangen stehen. + $this->assertEqualsWithDelta(0.0, $participant->fresh()->amount_paid->getAmount(), 0.001); + $this->assertEqualsWithDelta(300.0, $participant->fresh()->amount->getAmount(), 0.001); + } + + public function test_the_receipt_is_written_before_the_amount_is_cleared(): void + { + $refund = $this->runRefund(); + + // Reihenfolge-Falle: Beleg und Anmerkung weisen den gezahlten Beitrag aus. Wird zu früh genullt, + // stünde dort 0,00 €. Der Beleg selbst liegt als PDF vor; nachprüfbar ist die Reihenfolge an der + // Anmerkung, die im selben Schritt und aus derselben Quelle entsteht. + $this->assertStringContainsString('300,00 Euro', Invoice::first()->comment); + $this->assertEqualsWithDelta(0.0, $refund->participant->fresh()->amount_paid->getAmount(), 0.001); + } +}
Grund: {{$reason}}
Abrechnungsnummer:{{$invoiceNumber}}
Kontoinhaber*in: {{$accountOwner}}