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() {
+ + +
+