Teilnahmerechnungen erstellen

This commit is contained in:
2026-09-03 00:08:46 +02:00
parent a61344395a
commit 9c4c28e566
79 changed files with 4303 additions and 75 deletions
@@ -0,0 +1,418 @@
<?php
namespace App\Domains\ParticipantInvoice\Actions\CreateParticipantInvoice;
use App\Enumerations\TaxExemptionReason;
use App\EventPaymentModules\DTO\CreateInvoiceRequest;
use App\Models\DocumentTemplate;
use App\Models\Event;
use App\Models\EventParticipant;
use App\Models\Tenant;
use App\Providers\DocumentTemplateRenderProvider;
use App\Providers\PdfGenerateAndDownloadProvider;
use App\RelationModels\EventParticipationFee;
use App\Support\DateRange;
use App\ValueObjects\Amount;
/**
* Erzeugt die Rechnung über den Teilnahmebeitrag als PDF.
*
* Es wird nichts gespeichert: die Rechnungsnummer ergibt sich deterministisch aus Veranstaltung und
* Position des Teilis, der Inhalt aus dessen aktuellem Stand. Ein erneuter Abruf liefert unter derselben
* Nummer den dann gültigen Stand.
*/
class CreateParticipantInvoiceCommand
{
private EventParticipant $participant;
private Event $event;
/** Der Rechnungssteller. Gehört zum Mandanten, nicht zur Veranstaltung -- siehe buildTokens(). */
private ?Tenant $sender;
public function __construct(private readonly CreateParticipantInvoiceRequest $request)
{
$this->participant = $request->participant;
$this->event = $request->participant->event;
// Über die Relation und nicht über app('tenant'): 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<int, array{description: string, quantity: ?float, unit: string, unitPrice: ?float, amount: float}>
*/
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<int, array<string, mixed>> $lines
* @return array<string, string>
*/
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<int, array<string, mixed>> $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(
'<tr><td>%d</td><td>%s</td><td class="r">%s</td><td class="r">%s</td><td class="r">%s</td></tr>',
$index + 1,
e((string) $line['description']),
e($quantity),
$line['unitPrice'] === null ? '' : $this->money((float) $line['unitPrice']),
$this->money((float) $line['amount']),
);
}
return '<table class="pos-table">'
. '<thead><tr>'
. '<th style="width:8%;">Nr.</th>'
. '<th style="width:44%;">Bezeichnung</th>'
. '<th style="width:12%;" class="r">Menge</th>'
. '<th style="width:18%;" class="r">Einzelpreis</th>'
. '<th style="width:18%;" class="r">Gesamt</th>'
. '</tr></thead>'
. '<tbody>' . $rows . '</tbody>'
. '</table>';
}
/**
* 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(
'<tr><td class="sum-key">Nettobetrag</td><td class="sum-val">%s</td></tr>',
$this->money($split['net'])
);
$rows .= sprintf(
'<tr class="sum-line"><td class="sum-key">enthaltene USt. %d&nbsp;%%</td><td class="sum-val">%s</td></tr>',
$this->event->vat_rate,
$this->money($split['vat'])
);
}
$rows .= sprintf(
'<tr class="sum-total"><td class="sum-key">Gesamtbetrag</td><td class="sum-val">%s</td></tr>',
$this->money($gross)
);
$table = '<table class="sum-outer"><tr><td class="sum-spacer"></td><td>'
. '<table class="sum-inner">' . $rows . '</table>'
. '</td></tr></table>';
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 '<div class="tax-note">' . e($text) . '</div>';
}
private function money(float $value): string
{
return new Amount($value, 'Euro')->getFormattedAmount() . '&nbsp;&euro;';
}
/** 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, ',', '.');
}
}