Teilnahmerechnungen erstellen
This commit is contained in:
+418
@@ -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 %%</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() . ' €';
|
||||
}
|
||||
|
||||
/** 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, ',', '.');
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\ParticipantInvoice\Actions\CreateParticipantInvoice;
|
||||
|
||||
use App\Models\EventParticipant;
|
||||
|
||||
class CreateParticipantInvoiceRequest
|
||||
{
|
||||
public function __construct(
|
||||
public readonly EventParticipant $participant,
|
||||
) {
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\ParticipantInvoice\Actions\CreateParticipantInvoice;
|
||||
|
||||
class CreateParticipantInvoiceResponse
|
||||
{
|
||||
public bool $success = false;
|
||||
|
||||
public string $invoiceNumber = '';
|
||||
|
||||
public string $filename = '';
|
||||
|
||||
public string $pdfContent = '';
|
||||
|
||||
public ?string $message = null;
|
||||
}
|
||||
Reference in New Issue
Block a user