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 @@