From 15690b1da9c38b5364f626b85400911fffbdb745 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20G=C3=BCnrher?=
Date: Fri, 4 Sep 2026 02:11:10 +0200
Subject: [PATCH] =?UTF-8?q?Abrechnungsgr=C3=BCnde=20nun=20immer=20erforder?=
=?UTF-8?q?lich?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../CreateIncomeSurplusStatementCommand.php | 224 ++++++++++++++++++
.../CreateIncomeSurplusStatementRequest.php | 12 +
.../CreateIncomeSurplusStatementResponse.php | 33 +++
.../IncomeSurplusStatementController.php | 33 +++
app/Domains/Event/Routes/web.php | 5 +
app/Domains/Event/Views/Partials/Overview.vue | 8 +
6 files changed, 315 insertions(+)
create mode 100644 app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementCommand.php
create mode 100644 app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementRequest.php
create mode 100644 app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementResponse.php
create mode 100644 app/Domains/Event/Controllers/IncomeSurplusStatementController.php
diff --git a/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementCommand.php b/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementCommand.php
new file mode 100644
index 0000000..987f089
--- /dev/null
+++ b/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementCommand.php
@@ -0,0 +1,224 @@
+event = $request->event;
+ $this->costUnits = new CostUnitRepository();
+ }
+
+ public function execute(): CreateIncomeSurplusStatementResponse
+ {
+ $response = new CreateIncomeSurplusStatementResponse();
+
+ $costUnit = $this->event->costUnit()->first();
+
+ if (!$costUnit instanceof CostUnit) {
+ $response->message = 'Der Veranstaltung ist keine Kostenstelle zugeordnet.';
+
+ return $response;
+ }
+
+ // Der Pauschalbetrag wird vor dem Resource-Aufruf gelesen: `EventResource::calculateSupportPerPerson()`
+ // multipliziert das Amount-Objekt von `support_per_person` in place. Auf `support_flat` wirkt das
+ // zwar nicht, aber der gesamte Zugriff auf Beträge des Models ist danach nicht mehr vertrauenswürdig.
+ $otherIncome = $this->event->support_flat->getAmount();
+
+ $eventData = $this->event->toResource()->toArray(new Request());
+
+ $income = $this->buildIncome($eventData, $otherIncome);
+ $expenses = $this->buildExpenses($costUnit);
+
+ $result = new Amount($income['total']->getAmount() - $expenses['total']->getAmount(), 'Euro');
+
+ $html = view('pdfs.income-surplus-statement', [
+ 'event' => $this->event,
+ 'createdAt' => new \DateTime()->format('d.m.Y'),
+ 'income' => $income,
+ 'expenses' => $expenses,
+ 'result' => $result,
+ 'money' => self::money(...),
+ ])->render();
+
+ $response->success = true;
+ $response->filename = 'EUER-' . $this->event->identifier . '.pdf';
+ $response->income = $income;
+ $response->expenses = $expenses;
+ $response->result = $result;
+ $response->pdfContent = PdfGenerateAndDownloadProvider::fromHtml($html, 'portrait');
+
+ return $response;
+ }
+
+ /**
+ * Die Einnahmenseite in zwei Ober-Kategorien.
+ *
+ * Alle Zahlen stammen aus {@see \App\Resources\EventResource} -- derselben Quelle wie die
+ * Veranstaltungsübersicht am Bildschirm. Eine eigene Rechnung daneben würde über kurz oder lang von
+ * der Übersicht abweichen, und dann glaubt niemand mehr einer der beiden Zahlen.
+ *
+ * @param array $eventData
+ *
+ * @return array{categories: array}>, total: Amount}
+ */
+ private function buildIncome(array $eventData, float $otherIncome): array
+ {
+ // Beiträge aller Teilnahmearten in einer Zeile: Für die Mittelverwendung zählt, was an Beiträgen
+ // hereingekommen ist, nicht von wem.
+ $participationFees = new Amount(0, 'Euro');
+ foreach ([
+ ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
+ ParticipationType::PARTICIPATION_TYPE_TEAM,
+ ParticipationType::PARTICIPATION_TYPE_VOLUNTEER,
+ ParticipationType::PARTICIPATION_TYPE_OTHER,
+ ] as $participationType) {
+ $participationFees->addAmount(
+ new Amount((float) $eventData['participants'][$participationType]['amount']['paid']['value'], 'Euro')
+ );
+ }
+
+ $ownFunds = [
+ ['name' => 'Teilnahmebeiträge', 'amount' => $participationFees],
+ ['name' => 'Weitere Einnahmen', 'amount' => new Amount($otherIncome, 'Euro')],
+ [
+ 'name' => 'Einbehaltene Einnahmen aus Abmeldungen',
+ 'amount' => new Amount((float) $eventData['retainedFromUnregistered']['value'], 'Euro'),
+ ],
+ ];
+
+ $supportRate = new Amount((float) $eventData['supportPersonValue'], 'Euro');
+ $funding = [
+ [
+ 'name' => 'Fördermittel (' . self::money($supportRate) . ' € p.P./Tag)',
+ 'amount' => new Amount($eventData['supportPerson']['amount']->getAmount(), 'Euro'),
+ ],
+ ];
+
+ $categories = [
+ ['name' => 'Eigenmittel', 'entries' => $ownFunds, 'total' => self::sum($ownFunds)],
+ ['name' => 'Förderungen', 'entries' => $funding, 'total' => self::sum($funding)],
+ ];
+
+ return [
+ 'categories' => $categories,
+ 'total' => self::sum($categories),
+ ];
+ }
+
+ /**
+ * Die Ausgabenseite: eine Zeile je Ausgabentyp, dazu die Belege für die Anlage.
+ *
+ * @return array{groups: array}>, total: Amount}
+ */
+ private function buildExpenses(CostUnit $costUnit): array
+ {
+ $groups = [];
+ $total = new Amount(0, 'Euro');
+
+ foreach ($this->costUnits->groupExpensesByType($costUnit) as $group) {
+ $rows = [];
+
+ foreach ($group['invoices'] as $invoice) {
+ $rows[] = [
+ 'number' => (string) $invoice->invoice_number,
+ 'date' => $invoice->created_at?->format('d.m.Y') ?? '',
+ 'purpose' => $this->purpose($invoice->type_other, $invoice->comment),
+ 'amount' => Amount::fromString($invoice->amount),
+ ];
+ }
+
+ $groups[] = [
+ 'name' => $group['type']->name,
+ 'sum' => $group['sum'],
+ 'rows' => $rows,
+ ];
+
+ $total->addAmount($group['sum']);
+ }
+
+ return ['groups' => $groups, 'total' => $total];
+ }
+
+ /**
+ * Wofür der Beleg steht.
+ *
+ * `type_other` trägt seit der Pflichtangabe "Was wurde eingekauft" zu jeder Abrechnung den Zweck,
+ * nicht mehr nur bei "Sonstige Kosten". Ältere Belege haben das Feld leer -- dann bleibt die
+ * Anmerkung, und fehlt auch die, bleibt die Zelle leer. Ein Platzhalter wie "--" würde in der
+ * Belegliste nur Platz kosten.
+ */
+ private function purpose(?string $typeOther, ?string $comment): string
+ {
+ $parts = [];
+
+ foreach ([$typeOther, $comment] as $part) {
+ if (trim((string) $part) !== '') {
+ $parts[] = trim((string) $part);
+ }
+ }
+
+ return implode(' — ', $parts);
+ }
+
+ /**
+ * Ein Betrag in deutscher Schreibweise: Punkt als Tausender-, Komma als Dezimaltrennzeichen.
+ *
+ * Bewusst nicht {@see Amount::getFormattedAmount()}: Die Methode ersetzt nach `number_format` jeden
+ * Punkt durch ein Komma und macht aus 1.487,50 damit "1,487,50". Auf einer Aufstellung, in der
+ * vierstellige Beträge die Regel sind, wäre das nicht lesbar. Der Fehler steckt im Value Object und
+ * wirkt überall, wo Beträge angezeigt werden -- ihn dort zu beheben ist eine eigene Änderung.
+ *
+ * Öffentlich, weil die Vorlage sie als Callable bekommt und weil sie für sich prüfbar sein soll.
+ */
+ public static function money(Amount $amount): string
+ {
+ return number_format(round($amount->getAmount(), 2), 2, ',', '.');
+ }
+
+ /**
+ * Summiert Zeilen, die je ein `amount` oder `total` tragen.
+ *
+ * Über ein frisches Amount-Objekt, weil `Amount::addAmount()` den Empfänger verändert -- die
+ * Einzelbeträge sollen unangetastet bleiben, sie werden anschließend gedruckt.
+ *
+ * @param array> $rows
+ */
+ private static function sum(array $rows): Amount
+ {
+ $sum = new Amount(0, 'Euro');
+
+ foreach ($rows as $row) {
+ /** @var Amount $amount */
+ $amount = $row['amount'] ?? $row['total'];
+ $sum->addAmount($amount);
+ }
+
+ return $sum;
+ }
+}
diff --git a/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementRequest.php b/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementRequest.php
new file mode 100644
index 0000000..36d9c71
--- /dev/null
+++ b/app/Domains/Event/Actions/CreateIncomeSurplusStatement/CreateIncomeSurplusStatementRequest.php
@@ -0,0 +1,12 @@
+}>, total: Amount}|array{}
+ */
+ public array $income = [];
+
+ /**
+ * @var array{groups: array>}>, total: Amount}|array{}
+ */
+ public array $expenses = [];
+
+ public ?Amount $result = null;
+}
diff --git a/app/Domains/Event/Controllers/IncomeSurplusStatementController.php b/app/Domains/Event/Controllers/IncomeSurplusStatementController.php
new file mode 100644
index 0000000..9a8ca3f
--- /dev/null
+++ b/app/Domains/Event/Controllers/IncomeSurplusStatementController.php
@@ -0,0 +1,33 @@
+events->getByIdentifier($eventId);
+
+ if ($event === null) {
+ abort(403, 'Zugriff verweigert.');
+ }
+
+ $statementRequest = new CreateIncomeSurplusStatementRequest($event);
+ $statementCommand = new CreateIncomeSurplusStatementCommand($statementRequest);
+ $statementResponse = $statementCommand->execute();
+
+ if (!$statementResponse->success) {
+ abort(422, $statementResponse->message ?? 'Die EÜR konnte nicht erstellt werden.');
+ }
+
+ return response($statementResponse->pdfContent, 200, [
+ 'Content-Type' => 'application/pdf',
+ 'Content-Disposition' => 'attachment; filename="' . $statementResponse->filename . '"',
+ ]);
+ }
+}
diff --git a/app/Domains/Event/Routes/web.php b/app/Domains/Event/Routes/web.php
index 3e13f62..283af58 100644
--- a/app/Domains/Event/Routes/web.php
+++ b/app/Domains/Event/Routes/web.php
@@ -4,6 +4,7 @@ use App\Domains\Event\Controllers\ArchivedEventsController;
use App\Domains\Event\Controllers\AvailableEventsController;
use App\Domains\Event\Controllers\CreateController;
use App\Domains\Event\Controllers\DetailsController;
+use App\Domains\Event\Controllers\IncomeSurplusStatementController;
use App\Domains\Event\Controllers\SignupController;
use App\Middleware\IdentifyTenant;
use Illuminate\Support\Facades\Route;
@@ -18,6 +19,10 @@ Route::middleware(IdentifyTenant::class)->group(function () {
Route::middleware(['auth'])->group(function () {
Route::get('/details/{eventId}', DetailsController::class);
+
+ // Vor der Wildcard darunter: Sonst greift `downloadPdfList()` und sucht ein Blade namens
+ // `income-surplus-statement` mit Teilnehmendendaten, die die EÜR gar nicht braucht.
+ Route::get('/details/{eventId}/pdf/income-surplus-statement', IncomeSurplusStatementController::class);
Route::get('/details/{eventId}/pdf/{listType}', [DetailsController::class, 'downloadPdfList']);
Route::get('/details/{eventId}/csv/{listType}', [DetailsController::class, 'downloadCsvList']);
});
diff --git a/app/Domains/Event/Views/Partials/Overview.vue b/app/Domains/Event/Views/Partials/Overview.vue
index 0ea576d..870365f 100644
--- a/app/Domains/Event/Views/Partials/Overview.vue
+++ b/app/Domains/Event/Views/Partials/Overview.vue
@@ -106,6 +106,10 @@ async function showEventAddons() {
+
+
+
+
@@ -143,6 +147,10 @@ async function showEventAddons() {
+
+
+
+