Files
mareike/app/Domains/ParticipantInvoice/Actions/CreateParticipantInvoice/CreateParticipantInvoiceCommand.php
T

419 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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, ',', '.');
}
}