From bcebe2ebed65f41f37c62b1a4059956aafea792b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCnrher?= Date: Thu, 1 Oct 2026 13:40:51 +0200 Subject: [PATCH] =?UTF-8?q?Verbesserte=20E=C3=9CR-Abrechnung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreateIncomeSurplusStatementCommand.php | 38 ++++- .../CreateIncomeSurplusStatementResponse.php | 7 + app/Repositories/CostUnitRepository.php | 30 +++- .../pdfs/income-surplus-statement.blade.php | 52 +++---- .../pdfs/partials/receipt-groups.blade.php | 28 ++++ .../EventIncomeSurplusStatementTest.php | 142 +++++++++++++++++- version | 2 +- 7 files changed, 252 insertions(+), 47 deletions(-) create mode 100644 resources/views/pdfs/partials/receipt-groups.blade.php diff --git a/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementCommand.php b/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementCommand.php index 4d9d49c..cd77708 100644 --- a/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementCommand.php +++ b/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementCommand.php @@ -5,6 +5,7 @@ namespace App\Domains\Event\Actions\CreateIncomeSurplusStatement; use App\Enumerations\ParticipationType; use App\Models\CostUnit; use App\Models\Event; +use App\Models\Invoice; use App\Providers\PdfGenerateAndDownloadProvider; use App\Repositories\CostUnitRepository; use App\ValueObjects\Amount; @@ -52,8 +53,9 @@ class CreateIncomeSurplusStatementCommand $eventData = $this->event->toResource()->toArray(new Request()); - $income = $this->buildIncome($eventData, $otherIncome); $expenses = $this->buildExpenses($costUnit); + $donations = $this->buildExpenses($costUnit, donationsOnly: true); + $income = $this->buildIncome($eventData, $otherIncome, $donations['total']); $result = new Amount($income['total']->getAmount() - $expenses['total']->getAmount(), 'Euro'); @@ -62,6 +64,7 @@ class CreateIncomeSurplusStatementCommand 'createdAt' => new \DateTime()->format('d.m.Y'), 'income' => $income, 'expenses' => $expenses, + 'donations' => $donations, 'result' => $result, 'money' => self::money(...), ])->render(); @@ -70,6 +73,7 @@ class CreateIncomeSurplusStatementCommand $response->filename = 'EUER-' . $this->event->identifier . '.pdf'; $response->income = $income; $response->expenses = $expenses; + $response->donations = $donations; $response->result = $result; $response->pdfContent = PdfGenerateAndDownloadProvider::fromHtml($html, 'portrait'); @@ -87,7 +91,7 @@ class CreateIncomeSurplusStatementCommand * * @return array{categories: array}>, total: Amount} */ - private function buildIncome(array $eventData, float $otherIncome): array + private function buildIncome(array $eventData, float $otherIncome, Amount $donations): array { // Beiträge aller Teilnahmearten in einer Zeile: Für die Mittelverwendung zählt, was an Beiträgen // hereingekommen ist, nicht von wem. @@ -118,6 +122,8 @@ class CreateIncomeSurplusStatementCommand 'name' => 'Fördermittel (' . self::money($supportRate) . ' € p.P./Tag)', 'amount' => new Amount($eventData['supportPerson']['amount']->getAmount(), 'Euro'), ], + // Gespendete Auslagen: als Ausgabe ihres Typs gebucht, hier die Gegenbuchung. + ['name' => 'Spenden', 'amount' => $donations], ]; $categories = [ @@ -134,14 +140,17 @@ class CreateIncomeSurplusStatementCommand /** * Die Ausgabenseite: eine Zeile je Ausgabentyp, dazu die Belege für die Anlage. * + * Mit `$donationsOnly` nur die gespendeten Auslagen in derselben Gliederung -- für die Anlage „Spenden" + * und die Summe unter den Förderungen. + * * @return array{groups: array}>, total: Amount} */ - private function buildExpenses(CostUnit $costUnit): array + private function buildExpenses(CostUnit $costUnit, bool $donationsOnly = false): array { $groups = []; $total = new Amount(0, 'Euro'); - foreach ($this->costUnits->groupExpensesByType($costUnit) as $group) { + foreach ($this->costUnits->groupExpensesByType($costUnit, $donationsOnly) as $group) { $rows = []; foreach ($group['invoices'] as $invoice) { @@ -150,7 +159,7 @@ class CreateIncomeSurplusStatementCommand 'date' => $invoice->created_at?->format('d.m.Y') ?? '', // Ohne die Anmerkung: dort steht, was die Kassenwart*in beim Korrigieren notiert hat, // und das gehört auf den Beleg, nicht in den Zweck. - 'purpose' => $invoice->purposeText(), + 'purpose' => self::purpose($invoice), 'amount' => Amount::fromString($invoice->amount), ]; } @@ -167,6 +176,25 @@ class CreateIncomeSurplusStatementCommand return ['groups' => $groups, 'total' => $total]; } + /** + * Der Zweck eines Belegs für die Anlage. + * + * Erfasster oder abgeleiteter Zahlungsgrund wie in der Beleg-Übersicht. Nur wenn es keinen gibt -- + * ältere Abrechnungen ohne jede Zweckangabe --, steht dort ersatzweise, wem was erstattet wurde. Bewusst + * hier und nicht in {@see Invoice::purposeText()}: Dort würde der Ersatztext als erfasster Zahlungsgrund + * erscheinen und beim Korrigieren als Änderung protokolliert. + */ + private static function purpose(Invoice $invoice): string + { + $purpose = $invoice->purposeText(); + + if (trim($purpose) !== '') { + return $purpose; + } + + return trim('Auslagenerstattung ' . $invoice->contact_name); + } + /** * Ein Betrag in deutscher Schreibweise: Punkt als Tausender-, Komma als Dezimaltrennzeichen. * diff --git a/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementResponse.php b/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementResponse.php index 2503828..d759e39 100644 --- a/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementResponse.php +++ b/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementResponse.php @@ -29,5 +29,12 @@ class CreateIncomeSurplusStatementResponse */ public array $expenses = []; + /** + * Die gespendeten Auslagen, gegliedert wie die Ausgaben -- die Gegenbuchung zur Zeile „Spenden". + * + * @var array{groups: array>}>, total: Amount}|array{} + */ + public array $donations = []; + public ?Amount $result = null; } diff --git a/app/Repositories/CostUnitRepository.php b/app/Repositories/CostUnitRepository.php index 99fd4b5..efd262d 100644 --- a/app/Repositories/CostUnitRepository.php +++ b/app/Repositories/CostUnitRepository.php @@ -172,9 +172,13 @@ class CostUnitRepository { * damit immer dieselbe Gliederung, und eine fehlende Zeile lässt sich nicht mit einer vergessenen * verwechseln. * + * Gespendete Auslagen zählen hier mit: Für die Einnahmen-Überschuss-Rechnung ist die Spende eine Ausgabe + * ihres Typs und zugleich eine Einnahme. Mit `$donationsOnly` liefert die Methode genau diesen Spendenanteil + * in derselben Gliederung -- die Gegenbuchungen lassen sich so ihrer Ausgabenkategorie zuordnen. + * * @return array, sum: Amount}> */ - public function groupExpensesByType(CostUnit $costUnit) : array { + public function groupExpensesByType(CostUnit $costUnit, bool $donationsOnly = false) : array { $groups = []; foreach (InvoiceType::countingAsExpense() as $invoiceType) { @@ -186,7 +190,11 @@ class CostUnitRepository { } foreach ($costUnit->invoices()->orderBy('invoice_number')->get() as $invoice) { - if (!self::countsAsExpense($invoice) || !isset($groups[$invoice->type])) { + if ( + !self::isBooked($invoice) || + !isset($groups[$invoice->type]) || + ($donationsOnly && !$invoice->donation) + ) { continue; } @@ -201,14 +209,22 @@ class CostUnitRepository { * Ob ein Beleg als Ausgabe zählt. * * Abgelehnt und gelöscht sind keine Ausgabe, weil kein Geld fließt. Dasselbe gilt für eine gespendete - * Auslage: Auf die Auszahlung wurde verzichtet, die Kostenstelle gibt nichts aus. + * Auslage: Auf die Auszahlung wurde verzichtet, die Kostenstelle gibt nichts aus. Die Einnahmen-Überschuss- + * Rechnung sieht das anders und bucht die Spende als Ausgabe und Einnahme, siehe + * {@see self::groupExpensesByType()}. */ private static function countsAsExpense(Invoice $invoice) : bool { + return self::isBooked($invoice) && !$invoice->donation; + } + + /** + * Ob ein Beleg überhaupt gebucht ist -- abgelehnt und gelöscht sind es nicht. + */ + private static function isBooked(Invoice $invoice) : bool { return !in_array($invoice->status, [ - InvoiceStatus::INVOICE_STATUS_DENIED, - InvoiceStatus::INVOICE_STATUS_DELETED, - ], true) - && !$invoice->donation; + InvoiceStatus::INVOICE_STATUS_DENIED, + InvoiceStatus::INVOICE_STATUS_DELETED, + ], true); } public function sumupEstimatedByInvoiceType(CostUnit $costUnit, InvoiceType $invoiceType) : Amount { diff --git a/resources/views/pdfs/income-surplus-statement.blade.php b/resources/views/pdfs/income-surplus-statement.blade.php index 802910f..d725881 100644 --- a/resources/views/pdfs/income-surplus-statement.blade.php +++ b/resources/views/pdfs/income-surplus-statement.blade.php @@ -186,8 +186,8 @@
Berücksichtigt sind nur tatsächlich vereinnahmte Beiträge und erfasste Belege. Nicht enthalten sind - offene Beiträge, Budget- und Schätzwerte, erstattete Beiträge sowie Auslagen, auf deren Auszahlung - verzichtet wurde. + offene Beiträge, Budget- und Schätzwerte sowie erstattete Beiträge. Gespendete Auslagen sind bei den + Ausgaben enthalten und zugleich als Spenden unter den Förderungen ausgewiesen. @if($event->tax_liable)
Alle Beträge sind Bruttobeträge inklusive {{ $event->vat_rate }} % Umsatzsteuer. @endif @@ -203,33 +203,27 @@

Anlage: Belege

{{ $event->name }} · Stand: {{ $createdAt }}
- @foreach($documentedGroups as $group) -

{{ $group['name'] }}

- - - - - - - - - - - @foreach($group['rows'] as $row) - - - - - - - @endforeach - - - - - -
Beleg-Nr.DatumZweckBetrag
{{ $row['number'] }}{{ $row['date'] }}{{ $row['purpose'] }}{{ $money($row['amount']) }} €
Zwischensumme {{ $group['name'] }}{{ $money($group['sum']) }} €
- @endforeach + @include('pdfs.partials.receipt-groups', ['groups' => $documentedGroups]) +@endif + +@php + $donatedGroups = array_filter($donations['groups'], fn ($group) => count($group['rows']) > 0); +@endphp + +@if(count($donatedGroups) > 0) +
+ +

Anlage: Spenden

+
{{ $event->name }} · Stand: {{ $createdAt }}
+ + @include('pdfs.partials.receipt-groups', ['groups' => $donatedGroups]) + + + + + + +
Summe Spenden{{ $money($donations['total']) }} €
@endif diff --git a/resources/views/pdfs/partials/receipt-groups.blade.php b/resources/views/pdfs/partials/receipt-groups.blade.php new file mode 100644 index 0000000..739138a --- /dev/null +++ b/resources/views/pdfs/partials/receipt-groups.blade.php @@ -0,0 +1,28 @@ +{{-- Belege je Ausgabentyp mit Zwischensumme; nur Gruppen mit Belegen übergeben. --}} +@foreach($groups as $group) +

{{ $group['name'] }}

+ + + + + + + + + + + @foreach($group['rows'] as $row) + + + + + + + @endforeach + + + + + +
Beleg-Nr.DatumZweckBetrag
{{ $row['number'] }}{{ $row['date'] }}{{ $row['purpose'] }}{{ $money($row['amount']) }} €
Zwischensumme {{ $group['name'] }}{{ $money($group['sum']) }} €
+@endforeach diff --git a/tests/Feature/EventIncomeSurplusStatementTest.php b/tests/Feature/EventIncomeSurplusStatementTest.php index f06a8bb..b39e68a 100644 --- a/tests/Feature/EventIncomeSurplusStatementTest.php +++ b/tests/Feature/EventIncomeSurplusStatementTest.php @@ -202,11 +202,95 @@ class EventIncomeSurplusStatementTest extends TestCase // Kein Geldfluss, also keine Ausgabe: $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 999.0, InvoiceStatus::INVOICE_STATUS_DENIED); $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 888.0, InvoiceStatus::INVOICE_STATUS_DELETED); + + // Eine Spende ist Ausgabe ihres Typs (und zugleich Einnahme unter den Förderungen): $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 777.0, InvoiceStatus::INVOICE_STATUS_NEW, donation: true); - $this->assertEqualsWithDelta(150.0, $this->group('Programmkosten')['sum']->getAmount(), 0.001); + $this->assertEqualsWithDelta(927.0, $this->group('Programmkosten')['sum']->getAmount(), 0.001); $this->assertEqualsWithDelta(30.0, $this->group('Verpflegungskosten')['sum']->getAmount(), 0.001); - $this->assertEqualsWithDelta(180.0, $this->statement()->expenses['total']->getAmount(), 0.001); + $this->assertEqualsWithDelta(957.0, $this->statement()->expenses['total']->getAmount(), 0.001); + } + + /* + |-------------------------------------------------------------------------- + | Spenden + |-------------------------------------------------------------------------- + */ + + public function test_without_donations_the_donation_line_is_zero(): void + { + $this->makeEvent(); + $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW); + + $this->assertEqualsWithDelta(0.0, $this->entry('Spenden')->getAmount(), 0.001); + $this->assertEqualsWithDelta(0.0, $this->statement()->donations['total']->getAmount(), 0.001); + } + + public function test_a_donation_is_an_expense_of_its_type_and_funding_income(): void + { + $this->makeEvent(); + $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW); + $resultWithoutDonation = $this->statement()->result->getAmount(); + + $donated = $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 40.0, InvoiceStatus::INVOICE_STATUS_APPROVED, donation: true); + + $statement = $this->statement(); + $program = $this->group('Programmkosten'); + + $this->assertEqualsWithDelta(140.0, $program['sum']->getAmount(), 0.001); + $this->assertContains($donated->invoice_number, array_column($program['rows'], 'number')); + + // Gegenbuchung unter den Förderungen (ohne Anmeldung keine Fördermittel, also nur die Spende). + $this->assertSame('Förderungen', $statement->income['categories'][1]['name']); + $this->assertEqualsWithDelta(40.0, $this->entry('Spenden')->getAmount(), 0.001); + $this->assertEqualsWithDelta(40.0, $statement->income['categories'][1]['total']->getAmount(), 0.001); + + // Ausgabe und Einnahme heben sich auf. + $this->assertEqualsWithDelta($resultWithoutDonation, $statement->result->getAmount(), 0.001); + } + + public function test_donations_are_grouped_like_expenses(): void + { + $this->makeEvent(); + $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW); + $programDonation = $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 25.0, InvoiceStatus::INVOICE_STATUS_NEW, donation: true); + $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 15.0, InvoiceStatus::INVOICE_STATUS_EXPORTED, donation: true); + $this->makeInvoice(InvoiceType::INVOICE_TYPE_CATERING, 60.0, InvoiceStatus::INVOICE_STATUS_APPROVED, donation: true); + + $donations = $this->statement()->donations; + + // Dieselbe Gliederung wie die Ausgaben, auch Typen ohne Spende stehen darin. + $this->assertSame( + array_column($this->statement()->expenses['groups'], 'name'), + array_column($donations['groups'], 'name') + ); + + $this->assertEqualsWithDelta(40.0, $this->donationGroup('Programmkosten')['sum']->getAmount(), 0.001); + $this->assertEqualsWithDelta(60.0, $this->donationGroup('Verpflegungskosten')['sum']->getAmount(), 0.001); + $this->assertEqualsWithDelta(100.0, $donations['total']->getAmount(), 0.001); + $this->assertEqualsWithDelta(100.0, $this->entry('Spenden')->getAmount(), 0.001); + + // Derselbe Beleg steht als Ausgabe und als Spende. + $this->assertContains($programDonation->invoice_number, array_column($this->group('Programmkosten')['rows'], 'number')); + $this->assertContains($programDonation->invoice_number, array_column($this->donationGroup('Programmkosten')['rows'], 'number')); + + // Nicht gespendete Belege stehen nicht in der Spenden-Anlage. + $this->assertCount(2, $this->donationGroup('Programmkosten')['rows']); + } + + public function test_donations_list_only_booked_donations(): void + { + $this->makeEvent(); + $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 20.0, InvoiceStatus::INVOICE_STATUS_NEW, donation: true); + $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 999.0, InvoiceStatus::INVOICE_STATUS_DENIED, donation: true); + $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 888.0, InvoiceStatus::INVOICE_STATUS_DELETED, donation: true); + $this->makeInvoice(InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, 220.0, InvoiceStatus::INVOICE_STATUS_EXPORTED, donation: true); + + $donations = $this->statement()->donations; + + $this->assertEqualsWithDelta(20.0, $donations['total']->getAmount(), 0.001); + $this->assertCount(1, $this->donationGroup('Programmkosten')['rows']); + $this->assertNotContains('Beitragserstattung', array_column($donations['groups'], 'name')); } public function test_a_refund_is_no_expense(): void @@ -311,12 +395,34 @@ class EventIncomeSurplusStatementTest extends TestCase public function test_the_comment_is_no_longer_part_of_the_purpose(): void { // Die Anmerkung trägt, was die Kassenwart*in beim Korrigieren notiert hat -- sie steht auf dem - // Beleg-PDF und hat im Zweck nichts zu suchen. Bestandsbelege ohne erfassten Zahlungsgrund haben - // hier deshalb eine leere Zelle; nachtragen lässt er sich beim Korrigieren. + // Beleg-PDF und hat im Zweck nichts zu suchen. $this->makeEvent(); $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW); - $this->assertSame('', $this->group('Programmkosten')['rows'][0]['purpose']); + $this->assertStringNotContainsString('Materialkauf', $this->group('Programmkosten')['rows'][0]['purpose']); + } + + public function test_an_empty_purpose_falls_back_to_the_refund_of_the_contact(): void + { + // Ältere Abrechnungen ohne jede Zweckangabe: ersatzweise, wem erstattet wurde -- als Ausgabe wie als Spende. + $this->makeEvent(); + $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 100.0, InvoiceStatus::INVOICE_STATUS_NEW); + $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 30.0, InvoiceStatus::INVOICE_STATUS_NEW, donation: true); + $this->makeInvoice( + InvoiceType::INVOICE_TYPE_PROGRAM, + 50.0, + InvoiceStatus::INVOICE_STATUS_NEW, + purpose: 'Material für den Bastelnachmittag' + ); + + $this->assertSame( + ['Auslagenerstattung Mika Muster', 'Auslagenerstattung Mika Muster', 'Material für den Bastelnachmittag'], + array_column($this->group('Programmkosten')['rows'], 'purpose') + ); + $this->assertSame( + ['Auslagenerstattung Mika Muster'], + array_column($this->donationGroup('Programmkosten')['rows'], 'purpose') + ); } /* @@ -388,6 +494,20 @@ class EventIncomeSurplusStatementTest extends TestCase ); } + public function test_a_donation_still_does_not_count_in_the_expense_overview(): void + { + // Die Bildschirm-Übersicht kennt keine Spenden-Einnahme -- dort bleibt die Spende außen vor. + $this->makeEvent(); + $this->makeInvoice(InvoiceType::INVOICE_TYPE_PROGRAM, 777.0, InvoiceStatus::INVOICE_STATUS_NEW, donation: true); + + $amount = new CostUnitRepository()->sumupByInvoiceType( + $this->costUnit->fresh(), + InvoiceType::where('slug', InvoiceType::INVOICE_TYPE_PROGRAM)->first() + ); + + $this->assertEqualsWithDelta(0.0, $amount->getAmount(), 0.001); + } + /* |-------------------------------------------------------------------------- | Helfer @@ -435,6 +555,18 @@ class EventIncomeSurplusStatementTest extends TestCase $this->fail('Ausgaben-Gruppe "' . $name . '" fehlt.'); } + /** @return array{name: string, sum: Amount, rows: array>} */ + private function donationGroup(string $name): array + { + foreach ($this->statement()->donations['groups'] as $group) { + if ($group['name'] === $name) { + return $group; + } + } + + $this->fail('Spenden-Gruppe "' . $name . '" fehlt.'); + } + private function makeEvent(): Event { $fee = EventParticipationFee::create([ diff --git a/version b/version index ad96464..0216ba3 100644 --- a/version +++ b/version @@ -1 +1 @@ -4.10.1 +4.10.2