386 lines
16 KiB
PHP
386 lines
16 KiB
PHP
<?php
|
||
|
||
namespace App\Domains\ParticipantRefund\Actions\CreateRefundDocument;
|
||
|
||
use App\Enumerations\RefundAccountSource;
|
||
use App\Models\DocumentTemplate;
|
||
use App\Models\Event;
|
||
use App\Models\EventParticipant;
|
||
use App\Models\PageText;
|
||
use App\Models\ParticipantRefund;
|
||
use App\Models\Tenant;
|
||
use App\Providers\DocumentTemplateRenderProvider;
|
||
use App\Providers\PdfGenerateAndDownloadProvider;
|
||
use App\ValueObjects\Amount;
|
||
|
||
/**
|
||
* Erzeugt den Beleg über die erstattete Teilnahmegebühr als PDF.
|
||
*
|
||
* Wie bei der Teilnahmerechnung wird nichts gespeichert: die Belegnummer leitet sich aus Veranstaltung
|
||
* und Position des Teilis ab, der Inhalt aus dem Erstattungsvorgang. Da ein bestätigter Vorgang nicht
|
||
* mehr verändert wird, liefert ein erneuter Abruf denselben Beleg.
|
||
*
|
||
* Keine Umsatzsteuer: eine Erstattung ist keine Rechnung. Ausgewiesen wird der Betrag, den der Teili
|
||
* zurückbekommt. Eine Stornorechnung mit USt-Ausweis wäre eine eigene Dokumentart.
|
||
*/
|
||
class CreateRefundDocumentCommand
|
||
{
|
||
/** Name des `page_texts`-Eintrags mit der Erklärung -- dieselbe Quelle wie die Bestätigungsseite. */
|
||
public const string DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND';
|
||
|
||
/** Die zweite Erklärung des Auszahlungswegs: dass es das Konto der Ursprungszahlung ist. */
|
||
public const string ACCOUNT_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT';
|
||
|
||
/**
|
||
* Ihre Fassung für Zahlungsarten ohne Ursprungskonto (Barzahlung): Dort gab es kein Konto, von dem
|
||
* der Beitrag kam -- erklärt wird stattdessen, dass das angegebene auf den eigenen Namen läuft.
|
||
*/
|
||
public const string OWN_ACCOUNT_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_ACCOUNT_OWN';
|
||
|
||
/** Tritt im Spendenweg an die Stelle beider anderen -- dort gibt es kein Konto. */
|
||
public const string DONATION_DECLARATION_TEXT = 'CONFIRMATION_PARTICIPANT_REFUND_DONATION';
|
||
|
||
private ParticipantRefund $refund;
|
||
|
||
private EventParticipant $participant;
|
||
|
||
private Event $event;
|
||
|
||
/** Der Aussteller. Gehört zum Mandanten der Veranstaltung, nicht zum gerade aktiven. */
|
||
private ?Tenant $sender;
|
||
|
||
public function __construct(private readonly CreateRefundDocumentRequest $request)
|
||
{
|
||
$this->refund = $request->refund;
|
||
$this->participant = $request->refund->participant;
|
||
$this->event = $request->refund->event;
|
||
|
||
// Wie in CreateParticipantInvoiceCommand: `$event->tenant` liefert das Slug-Attribut, nicht die
|
||
// Relation. Der Aussteller wird live gelesen, damit eine Korrektur an Name oder Anschrift auch
|
||
// auf bestehende Veranstaltungen wirkt.
|
||
$this->sender = $this->event->tenant()->first();
|
||
}
|
||
|
||
public function execute(): CreateRefundDocumentResponse
|
||
{
|
||
$response = new CreateRefundDocumentResponse();
|
||
|
||
if ($this->event->invoice_key === null || $this->participant->invoice_sequence === null) {
|
||
$response->message = 'Für diese Anmeldung lässt sich keine Belegnummer bilden.';
|
||
|
||
return $response;
|
||
}
|
||
|
||
if (!$this->refund->isAccepted()) {
|
||
$response->message = 'Der Beleg entsteht erst, wenn die Erstattung bestätigt wurde.';
|
||
|
||
return $response;
|
||
}
|
||
|
||
$documentNumber = $this->documentNumber();
|
||
|
||
$html = new DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_REFUND)
|
||
->render($this->buildTokens($documentNumber));
|
||
|
||
$response->success = true;
|
||
$response->documentNumber = $documentNumber;
|
||
$response->filename = 'Rueckerstattung-' . $documentNumber . '.pdf';
|
||
$response->pdfContent = PdfGenerateAndDownloadProvider::fromHtml($html, 'portrait');
|
||
|
||
return $response;
|
||
}
|
||
|
||
/**
|
||
* Dieselbe Nummer wie die Rechnung, mit angehängtem `-R`. Kein zweiter Nummernkreis: der Beleg
|
||
* gehört zu genau einer Anmeldung, und so ist auf einen Blick erkennbar, zu welcher Rechnung.
|
||
*/
|
||
private function documentNumber(): string
|
||
{
|
||
return $this->invoiceNumber() . '-R';
|
||
}
|
||
|
||
/** Die Nummer der Teilnahmerechnung -- der Beleg weist sie aus, damit die Zahlung auffindbar ist. */
|
||
private function invoiceNumber(): string
|
||
{
|
||
return sprintf(
|
||
'%s-%s',
|
||
$this->event->invoice_key,
|
||
str_pad((string) $this->participant->invoice_sequence, 4, '0', STR_PAD_LEFT)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Der Erklärungssatz aus `page_texts` -- derselbe, den der Teili auf der Bestätigungsseite gelesen
|
||
* und angekreuzt hat.
|
||
*
|
||
* Mit Rückfallwert: fehlt die Zeile in der Datenbank, soll der Beleg trotzdem entstehen. Ohne den
|
||
* Fallback stünde hier ein Fatal Error auf `null` -- so steht es heute im Deckblatt-Code der
|
||
* Auslagenerstattung, und daran soll sich der Beleg kein Beispiel nehmen.
|
||
*/
|
||
/**
|
||
* Der Hinweis, warum ein Teil des Beitrags beim Verband bleibt -- leer bei voller Erstattung.
|
||
*
|
||
* Der Beleg wandert in die Buchhaltung und ins Archiv; dort muss die Differenz zwischen gezahltem
|
||
* und erstattetem Betrag ohne Rückfrage erklärt sein.
|
||
*/
|
||
private function retentionNote(): string
|
||
{
|
||
if (!$this->refund->hasRetention()) {
|
||
return '';
|
||
}
|
||
|
||
$text = trim($this->refund->retentionReasonText());
|
||
$label = $this->refund->retentionReasonLabel();
|
||
|
||
return $text !== '' && $text !== $label
|
||
? sprintf('%s (%s)', $label, $text)
|
||
: $label;
|
||
}
|
||
|
||
/**
|
||
* Der Vermerk, wenn die Aktionsleitung die Angaben aufgenommen hat.
|
||
*
|
||
* Er nennt Name und Datum, weil in diesem Fall niemand die Erklärung darüber angekreuzt hat: Wer den
|
||
* Beleg prüft, soll erkennen, dass dort eine aufgenommene Angabe steht und keine Bestätigung des
|
||
* Teilis selbst. Beim gewöhnlichen Weg bleibt der Platzhalter leer und der Block fällt weg.
|
||
*/
|
||
private function captureNote(): string
|
||
{
|
||
if (!$this->refund->wasCapturedByManagement()) {
|
||
return '';
|
||
}
|
||
|
||
$name = $this->refund->capturedBy()->first()?->getOfficialName();
|
||
|
||
return sprintf(
|
||
'Angaben aufgenommen durch %s am %s.',
|
||
trim((string) $name) !== '' ? $name : 'die Aktionsleitung',
|
||
$this->refund->accepted_at?->format('d.m.Y') ?? ''
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Die Herkunft des Erstattungskontos, abgeleitet aus der Zahlungsart der Anmeldung.
|
||
*
|
||
* Der Beleg wird bei jedem Abruf neu gerendert und nicht gespeichert. Änderte jemand nachträglich
|
||
* die Zahlungsart, zeigte ein Nachdruck die jeweils andere Kontoerklärung. Praktisch passiert das
|
||
* nicht -- eine eigene Spalte am Vorgang wäre dafür unverhältnismäßig.
|
||
*/
|
||
private function accountSource(): RefundAccountSource
|
||
{
|
||
return $this->participant->refundData()->source;
|
||
}
|
||
|
||
private function declarationText(): string
|
||
{
|
||
if ($this->request->donation) {
|
||
return $this->pageText(
|
||
self::DONATION_DECLARATION_TEXT,
|
||
'Ich verzichte auf die Auszahlung des genannten Betrags und spende ihn an den Verband. '
|
||
. 'Mir ist bewusst, dass dieser Verzicht nicht rückgängig gemacht werden kann.'
|
||
);
|
||
}
|
||
|
||
// Beide Sätze, weil die Person beide angekreuzt hat -- der Beleg schreibt ihr nur zu, was sie
|
||
// gelesen hat, und die Kontoerklärung ist der Grund, warum die Auszahlung zulässig ist.
|
||
//
|
||
// Welche der beiden Kontoerklärungen gilt, sagt die Zahlungsart: Wer bar gezahlt hat, kann
|
||
// nicht bestätigen, dass das Konto dasselbe ist -- es gab keines.
|
||
$accountSource = $this->accountSource();
|
||
|
||
return $this->pageText(
|
||
self::DECLARATION_TEXT,
|
||
'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht anderweitig '
|
||
. 'zurückerstattet bekomme.'
|
||
) . '<br /><br />' . $this->pageText(
|
||
$accountSource->accountDeclarationText(),
|
||
$accountSource === RefundAccountSource::None
|
||
? 'Ich bestätige, dass das angegebene Konto auf meinen Namen läuft oder ich über dieses '
|
||
. 'Konto verfügungsberechtigt bin.'
|
||
: 'Ich bestätige, dass das angegebene Konto dasselbe ist, von dem der Teilnahmebeitrag '
|
||
. 'gezahlt wurde.'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Ein Seitentext mit Rückfallwert: Fehlt die Zeile in der Datenbank, soll der Beleg trotzdem
|
||
* entstehen. Ohne den Fallback stünde hier ein Fatal Error auf `null` -- so steht es heute im
|
||
* Deckblatt-Code der Auslagenerstattung, und daran soll sich der Beleg kein Beispiel nehmen.
|
||
*/
|
||
private function pageText(string $name, string $fallback): string
|
||
{
|
||
$text = PageText::where('name', $name)->first()?->content;
|
||
|
||
return trim((string) $text) !== '' ? (string) $text : $fallback;
|
||
}
|
||
|
||
/**
|
||
* Der Einleitungssatz des Belegs, passend zum gewählten Weg.
|
||
*
|
||
* Als Platzhalter und nicht fest in der Vorlage, weil er sich zwischen Auszahlung und Spende
|
||
* unterscheidet -- `{if:…}` kennt keine Verneinung, mit der eine Vorlage den einen Satz gegen den
|
||
* anderen tauschen könnte. Ältere, bereits installierte Vorlagen tragen den festen Satz weiter; dass
|
||
* gespendet wurde, steht dort in der Angabentabelle und in der Erklärung.
|
||
*/
|
||
private function introText(): string
|
||
{
|
||
return $this->request->donation
|
||
? 'Ich konnte an der Veranstaltung nicht oder nur teilweise teilnehmen. Auf die Auszahlung '
|
||
. 'des erstattungsfähigen Betrags verzichte ich und spende ihn an den Verband:'
|
||
: 'Ich konnte an der Veranstaltung nicht oder nur teilweise teilnehmen und bitte um die '
|
||
. 'Rückerstattung wie folgt:';
|
||
}
|
||
|
||
/**
|
||
* 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'));
|
||
}
|
||
|
||
/**
|
||
* @return array<string, string>
|
||
*/
|
||
private function buildTokens(string $documentNumber): array
|
||
{
|
||
$participant = $this->participant;
|
||
$sender = $this->sender;
|
||
$refund = $this->refund;
|
||
|
||
return [
|
||
'document_title' => 'Rückerstattung ' . $documentNumber,
|
||
|
||
'document_number' => $documentNumber,
|
||
// Belegdatum ist der Tag, an dem der Teili bestätigt hat -- da stand der Vorgang fest.
|
||
'document_date' => $refund->accepted_at?->format('d.m.Y') ?? '',
|
||
'event_name' => (string) $this->event->name,
|
||
'service_period' => $this->servicePeriod(),
|
||
'unregistered_at' => $participant->unregistered_at?->format('d.m.Y') ?? '',
|
||
|
||
'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,
|
||
|
||
'paid_amount' => $this->money($participant->amount_paid?->getAmount() ?? 0.0),
|
||
'invoice_number' => $this->invoiceNumber(),
|
||
'refund_amount' => $this->money($refund->amount?->getAmount() ?? 0.0),
|
||
'refund_reason' => $refund->reasonLabel(),
|
||
'refund_reason_text' => $refund->reasonText(),
|
||
'account_owner' => (string) $refund->account_owner,
|
||
'account_iban' => $this->formatIban((string) $refund->account_iban),
|
||
|
||
'retained_amount' => $this->money($refund->retained_amount?->getAmount() ?? 0.0),
|
||
'retention_note' => $this->retentionNote(),
|
||
|
||
'intro_text' => $this->introText(),
|
||
'declaration_text' => $this->declarationText(),
|
||
'capture_note' => $this->captureNote(),
|
||
|
||
'details_table' => $this->renderDetails(),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Der generierte Block: wer erklärt, worauf sich die Erstattung bezieht, warum, und auf welches
|
||
* Konto sie geht.
|
||
*
|
||
* Alles, was die Person erklärt, steht in dieser einen Tabelle -- auch die Begründung, die früher als
|
||
* Fließtext darunter hing. Was daneben steht (Anschrift im Briefkopf, Veranstaltung im Betreff),
|
||
* beschreibt den Vorgang, gehört aber nicht zur Erklärung selbst.
|
||
*/
|
||
private function renderDetails(): string
|
||
{
|
||
$refund = $this->refund;
|
||
$participant = $this->participant;
|
||
|
||
$rows = [
|
||
// Der Name steht voran: die Tabelle trägt alles, was die Person erklärt, und die Anschrift
|
||
// allein im Briefkopf würde den Bezug lösen, sobald der Beleg als Anlage hinter einem
|
||
// Deckblatt liegt. Kontoinhaber*in weiter unten kann eine andere Person sein -- etwa ein
|
||
// Elternteil.
|
||
['Name', e($participant->getOfficialName())],
|
||
|
||
// Der gezahlte Beitrag ist die Bezugsgröße. Ohne ihn lässt sich bei einer Teilerstattung
|
||
// nicht erkennen, warum nur ein Teil zurückgeht -- und die Zusicherung „ich habe den Betrag
|
||
// beglichen" bliebe unbelegt, obwohl mareike ihn kennt.
|
||
['Gezahlter Teilnahmebeitrag', $this->money($participant->amount_paid?->getAmount() ?? 0.0)],
|
||
['Rechnung', e($this->invoiceNumber())],
|
||
['Erstattungsbetrag', $this->money($refund->amount?->getAmount() ?? 0.0)],
|
||
['Grund', e($refund->reasonLabel())],
|
||
];
|
||
|
||
// Bei einem Freitext-Grund ist die Begründung der Text der Aktionsleitung, sonst der des
|
||
// Katalogs. Fehlt beides, entfällt die Zeile -- eine Beschriftung ohne Wert sieht nach Fehler aus.
|
||
$reasonText = trim($refund->reasonText());
|
||
if ($reasonText !== '') {
|
||
$rows[] = ['Begründung', e($reasonText)];
|
||
}
|
||
|
||
// Nur bei einer Teilerstattung: Ohne diese Zeile bliebe die Differenz zwischen gezahltem und
|
||
// erstattetem Betrag im Beleg unerklärt.
|
||
if ($refund->hasRetention()) {
|
||
$rows[] = ['Einbehalten', $this->money($refund->retained_amount?->getAmount() ?? 0.0)];
|
||
$rows[] = ['Grund der Einbehaltung', e($this->retentionNote())];
|
||
}
|
||
|
||
// Bei einer Spende gibt es keine Bankverbindung. Statt zwei leerer Zeilen steht dort, warum --
|
||
// der Beleg wandert in die Buchhaltung, und "keine IBAN" allein sähe nach einer Lücke aus.
|
||
if ($this->request->donation) {
|
||
$rows[] = [
|
||
'Auszahlung',
|
||
'Auf die Auszahlung wird verzichtet; der Betrag verbleibt als Spende beim Verband.',
|
||
];
|
||
} else {
|
||
$rows[] = ['Kontoinhaber*in', e((string) $refund->account_owner)];
|
||
$rows[] = ['IBAN', e($this->formatIban((string) $refund->account_iban))];
|
||
}
|
||
|
||
$html = '';
|
||
foreach ($rows as [$key, $value]) {
|
||
$html .= sprintf(
|
||
'<tr><td class="detail-key">%s</td><td class="detail-val">%s</td></tr>',
|
||
e($key),
|
||
$value
|
||
);
|
||
}
|
||
|
||
return '<table class="detail-table">' . $html . '</table>';
|
||
}
|
||
|
||
/** IBAN in Vierergruppen -- so steht sie auf jedem Beleg und lässt sich abtippen. */
|
||
private function formatIban(string $iban): string
|
||
{
|
||
return trim(chunk_split($iban, 4, ' '));
|
||
}
|
||
|
||
private function money(float $value): string
|
||
{
|
||
return new Amount($value, 'Euro')->getFormattedAmount() . ' €';
|
||
}
|
||
}
|