participant = $request->participant; $this->event = $request->participant->event; // Über die Relation und nicht über currentTenant(): die Rechnung hängt an der Veranstaltung, // nicht am gerade aktiven Mandanten. `$event->tenant` liefert das Slug-Attribut, nicht die // Relation -- deshalb der ausdrückliche Aufruf. $this->sender = $this->event->tenant()->first(); } public function execute(): CreateParticipantInvoiceResponse { $response = new CreateParticipantInvoiceResponse(); if ($this->event->invoice_key === null || $this->participant->invoice_sequence === null) { $response->message = 'Für diese Anmeldung lässt sich keine Rechnungsnummer bilden.'; return $response; } $invoiceNumber = sprintf( '%s-%s', $this->event->invoice_key, str_pad((string) $this->participant->invoice_sequence, 4, '0', STR_PAD_LEFT) ); $lines = $this->buildLines(); $gross = round($this->participant->amount?->getAmount() ?? 0.0, 2); $html = new DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_INVOICE) ->render($this->buildTokens($invoiceNumber, $lines, $gross)); $response->success = true; $response->invoiceNumber = $invoiceNumber; $response->filename = 'Rechnung-' . $invoiceNumber . '.pdf'; $response->pdfContent = PdfGenerateAndDownloadProvider::fromHtml($html, 'portrait'); return $response; } /** * Die Rechnungspositionen. * * Der Beitragsanteil wird nicht neu berechnet, sondern aus dem gespeicherten Gesamtbetrag abgeleitet: * `amount` ist Beitrag + Zusätze (siehe SignupController), die Zusätze liegen als Snapshot-Zeilen vor. * Damit stimmt die Rechnung immer mit dem überein, was der Teili tatsächlich zahlen soll -- unabhängig * davon, ob die Beiträge der Veranstaltung inzwischen geändert wurden oder der Early-Bird-Stichtag * verstrichen ist. * * @return array */ private function buildLines(): array { $addons = $this->participant->selectedAddons; $addonTotal = 0.0; foreach ($addons as $addon) { $addonTotal += $addon->amount?->getAmount() ?? 0.0; } $fee = round(($this->participant->amount?->getAmount() ?? 0.0) - $addonTotal, 2); $days = $this->attendanceDays(); // Die Preiskette in EventResource::calculateAmount() besteht nur aus Multiplikationen; der Faktor // 0,5 der Geschwisterermäßigung wirkt damit auf den gesamten Endbetrag. Der Listenpreis ist also // exakt das Doppelte des gespeicherten Beitragsanteils, der Rabatt genau dieser Anteil. $listed = $this->participant->sibling_reduction ? round($fee * 2, 2) : $fee; $lines = [$this->line( $this->feeDescription(), $listed, $this->event->pay_per_day ? $days : 1 )]; if ($this->participant->sibling_reduction) { $lines[] = [ 'description' => 'Geschwisterermäßigung 50 %', 'quantity' => null, 'unit' => '', 'unitPrice' => null, 'amount' => -$fee, ]; } foreach ($addons as $addon) { $lines[] = $this->line( (string) $addon->title, $addon->amount?->getAmount() ?? 0.0, $addon->flat ? 1 : (int) $addon->days ); } return $lines; } /** * Eine Position mit Mengenaufteilung -- aber nur, wenn sie aufgeht. * * Der Endbetrag ist gerundet gespeichert; bei krummen Tagessätzen (etwa nach einem * Early-Bird-Aufschlag) ergäbe Menge × gerundeter Einzelpreis einen anderen Wert als der Betrag der * Zeile. Eine Rechnung, deren Positionen sich nicht nachrechnen lassen, ist unbrauchbar -- in dem * Fall entfällt die Aufteilung und die Position steht als Gesamtbetrag da. * * @return array{description: string, quantity: ?float, unit: string, unitPrice: ?float, amount: float} */ private function line(string $description, float $amount, int $quantity): array { $amount = round($amount, 2); if ($quantity > 1) { $unitPrice = round($amount / $quantity, 2); if (abs($unitPrice * $quantity - $amount) < 0.005) { return [ 'description' => $description, 'quantity' => (float) $quantity, 'unit' => 'Tage', 'unitPrice' => $unitPrice, 'amount' => $amount, ]; } } return [ 'description' => $description, 'quantity' => 1.0, 'unit' => '', 'unitPrice' => $amount, 'amount' => $amount, ]; } /** * Bezeichnung der Beitragsposition. Der Gruppenname stammt aus dem am Event hinterlegten Beitrag, * dessen `type` der Teilnahmeart entspricht -- dieselbe Auflösung wie in EventResource. */ private function feeDescription(): string { $group = $this->participationFee()?->name; $description = $group !== null && trim($group) !== '' ? sprintf('Teilnahme %s in Gruppe %s', $this->event->name, $group) : sprintf('Teilnahme %s', $this->event->name); $feeTypeLabel = match ($this->participant->fee_type) { 'standard' => 'Standardbeitrag', 'reduced' => 'Reduzierter Beitrag', 'solidarity' => 'Solidaritätsbeitrag', // Altanmeldungen vor Einführung von `fee_type` -- dann bleibt der Zusatz weg. default => null, }; return $feeTypeLabel === null ? $description : sprintf('%s (%s)', $description, $feeTypeLabel); } private function participationFee(): ?EventParticipationFee { return collect([ $this->event->participationFee1, $this->event->participationFee2, $this->event->participationFee3, $this->event->participationFee4, ]) ->filter(fn(?EventParticipationFee $fee) => $fee !== null) ->first(fn(EventParticipationFee $fee) => $fee->type === $this->participant->participation_type); } private function attendanceDays(): int { $arrival = $this->participant->arrival_date; $departure = $this->participant->departure_date; if ($arrival === null || $departure === null) { return 1; } return DateRange::inclusiveDays($arrival, $departure); } /** * Umsatzsteuer aus dem Brutto herausrechnen. Gespeichert ist immer der finale Brutto-Betrag -- beim * Preismodus `add_on` wurde die USt bereits bei der Anmeldung aufgeschlagen, bei `inclusive` war sie * von Anfang an enthalten. Für die Rechnung ist die Rechnung deshalb in beiden Fällen dieselbe, der * Preismodus spielt hier keine Rolle mehr. * * @return array{net: float, vat: float} */ private function splitVat(float $gross): array { if (!$this->event->tax_liable || $this->event->vat_rate <= 0) { return ['net' => $gross, 'vat' => 0.0]; } $vat = round($gross * $this->event->vat_rate / (100 + $this->event->vat_rate), 2); return ['net' => round($gross - $vat, 2), 'vat' => $vat]; } /** * Der je Zahlungsart variierende Schlusssatz, bezogen auf den noch offenen Betrag. */ private function closingStatement(): string { $module = $this->participant->paymentModule(); if ($module === null) { return ''; } $open = new Amount($this->participant->amount?->getAmount() ?? 0.0, 'Euro'); if ($this->participant->amount_paid !== null) { $open->subtractAmount($this->participant->amount_paid); } $result = $module->createInvoice(new CreateInvoiceRequest( $this->event, $this->participant, $open, $this->participant->paymentConfiguration(), )); return (string) ($result->closingStatement ?? ''); } /** * Leistungszeitraum: bei eintägigen Veranstaltungen nur ein Datum, sonst der Zeitraum. */ private function servicePeriod(): string { $start = $this->event->start_date; $end = $this->event->end_date; if ($start === null) { return ''; } if ($end === null || $start->isSameDay($end)) { return $start->format('d.m.Y'); } return sprintf('%s – %s', $start->format('d.m.Y'), $end->format('d.m.Y')); } /** * @param array> $lines * @return array */ private function buildTokens(string $invoiceNumber, array $lines, float $gross): array { $participant = $this->participant; $sender = $this->sender; return [ 'document_title' => 'Rechnung ' . $invoiceNumber, 'invoice_number' => $invoiceNumber, // Rechnungsdatum ist das Anmeldedatum, Leistungsdatum der Veranstaltungszeitraum. 'invoice_date' => $participant->created_at?->format('d.m.Y') ?? '', 'service_period' => $this->servicePeriod(), // Der Rechnungssteller wird live vom Mandanten gelesen, nicht auf der Veranstaltung // eingefroren: eine Korrektur an Name oder Anschrift soll auch auf bestehende // Veranstaltungen wirken. Eingefroren bleibt nur, was den Preis erklärt (Steuergrundlage) // und die Nummer (invoice_key). 'sender_name' => $sender?->invoiceSenderName() ?? '', 'sender_address_1' => (string) $sender?->address_1, 'sender_address_2' => (string) $sender?->address_2, 'sender_address_3' => (string) $sender?->address_3, 'sender_postcode' => (string) $sender?->postcode, 'sender_city' => (string) $sender?->city, 'sender_email' => (string) $sender?->email, 'sender_phone' => (string) $sender?->phone, 'sender_tax_number' => (string) $sender?->tax_number, 'sender_vat_id' => (string) $sender?->vat_id, 'recipient_name' => $participant->getOfficialName(), 'recipient_address_1' => (string) $participant->address_1, 'recipient_address_2' => (string) $participant->address_2, 'recipient_postcode' => (string) $participant->postcode, 'recipient_city' => (string) $participant->city, 'positions_table' => $this->renderPositions($lines), 'summary_table' => $this->renderSummary($gross), 'closing_statement' => $this->closingStatement(), ]; } /** @param array> $lines */ private function renderPositions(array $lines): string { $rows = ''; foreach ($lines as $index => $line) { $quantity = $line['quantity'] === null ? '' : trim($this->quantity((float) $line['quantity']) . ' ' . $line['unit']); $rows .= sprintf( '%d%s%s%s%s', $index + 1, e((string) $line['description']), e($quantity), $line['unitPrice'] === null ? '' : $this->money((float) $line['unitPrice']), $this->money((float) $line['amount']), ); } return '' . '' . '' . '' . '' . '' . '' . '' . '' . $rows . '' . '
Nr.BezeichnungMengeEinzelpreisGesamt
'; } /** * Summenblock. Bei Steuerpflicht wird die USt aus dem Brutto ausgewiesen, sonst steht statt der * USt-Zeile der Pflichthinweis nach § 14 Abs. 4 Nr. 8 UStG. */ private function renderSummary(float $gross): string { $rows = ''; if ($this->event->tax_liable && $this->event->vat_rate > 0) { $split = $this->splitVat($gross); $rows .= sprintf( 'Nettobetrag%s', $this->money($split['net']) ); $rows .= sprintf( 'enthaltene USt. %d %%%s', $this->event->vat_rate, $this->money($split['vat']) ); } $rows .= sprintf( 'Gesamtbetrag%s', $this->money($gross) ); $table = '
' . '' . $rows . '
' . '
'; return $table . $this->taxExemptionNote(); } /** Pflichthinweis auf den Grund der Steuerbefreiung, aus dem Event-Snapshot. */ private function taxExemptionNote(): string { if ($this->event->tax_liable) { return ''; } $reason = $this->event->tax_exemption_reason !== null ? TaxExemptionReason::find($this->event->tax_exemption_reason) : null; $text = $reason?->invoiceText($this->event->tax_exemption_note); if ($text === null || trim($text) === '') { return ''; } return '
' . e($text) . '
'; } private function money(float $value): string { return new Amount($value, 'Euro')->getFormattedAmount() . ' €'; } /** Mengen ohne Nachkommastellen, solange sie ganzzahlig sind ("3 Tage", nicht "3,00 Tage"). */ private function quantity(float $value): string { return abs($value - round($value)) < 0.005 ? (string) (int) round($value) : number_format($value, 2, ',', '.'); } }