Files
mareike/app/Domains/ParticipantRefund/Actions/CreateRefundDocument/CreateRefundDocumentCommand.php
T

386 lines
16 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\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() . '&nbsp;&euro;';
}
}