request->refund; if ($refund === null || !$refund->isPending()) { $response->message = $refund?->isAccepted() === true ? 'Deine Angaben liegen uns bereits vor.' : self::NO_OPEN_REFUND; return $response; } $owner = trim($this->request->accountOwner); $iban = Iban::normalize($this->request->accountIban); // Serverseitig und nicht nur im Formular: die Erklärung ist der einzige Grund, warum der Beleg // als Eigenbeleg etwas wert ist. Ließe sie sich mit einem direkten Aufruf übergehen, stünde auf // dem PDF eine Zusicherung, die niemand abgegeben hat. // // Nimmt die Aktionsleitung die Angaben auf, kreuzt naturgemäß niemand etwas an. Nachvollziehbar // bleibt es trotzdem: `captured_by` hält fest, wer sie aufgenommen hat, und der Beleg weist es aus. if (!$this->request->declarationAccepted && $this->request->capturedBy === null) { $response->errorTypes['declaration'] = 'Bitte bestätige die Erklärung, damit wir erstatten können.'; } if ($owner === '') { $response->errorTypes['accountOwner'] = 'Bitte gib an, wem das Konto gehört.'; } if ($iban === '') { $response->errorTypes['accountIban'] = 'Bitte gib die IBAN des Kontos ein.'; } elseif (!Iban::isValid($iban)) { $response->errorTypes['accountIban'] = 'Diese IBAN stimmt nicht. Bitte prüfe deine Eingabe.'; } if ($response->errorTypes !== []) { $response->message = 'Bitte prüfe deine Angaben.'; return $response; } // Ohne Kostenstelle gibt es nichts, worauf gebucht werden könnte. Lieber hier abbrechen, als den // Vorgang zu bestätigen und die Auszahlung stillschweigend nirgends einzureichen. $costUnit = $this->costUnit($refund); if ($costUnit === null) { $response->message = 'Die Erstattung kann gerade nicht bearbeitet werden. ' . 'Bitte wende dich an die Aktionsleitung.'; Log::error('Beitragserstattung: Veranstaltung ohne Kostenstelle, Abrechnung nicht möglich.', [ 'refund_id' => $refund->id, 'event_id' => $refund->event_id, ]); return $response; } // Der Beleg entsteht in der Transaktion, weil er den bestätigten Stand abbildet; scheitert das // Einreichen, soll auch kein Beleg gelten. $document = DB::transaction(function () use ($refund, $owner, $iban, $costUnit) { $refund->account_owner = $owner; $refund->account_iban = $iban; $refund->captured_by = $this->request->capturedBy; $refund->status = ParticipantRefund::STATUS_ACCEPTED; $refund->accepted_at = now(); $refund->save(); $document = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund))->execute(); $invoice = $this->createInvoice($refund, $costUnit, $document); // Erst jetzt, nicht früher: Beleg und Anmerkung der Abrechnung weisen den gezahlten Beitrag // aus und läsen sonst bereits den verrechneten Stand. $this->settleAmountPaid($refund); $refund->invoice_id = $invoice->id; $refund->save(); return $document; }); $this->notify($refund, $document); $response->success = true; $response->message = 'Vielen Dank. Deine Angaben liegen uns vor.'; return $response; } /** * Die Kostenstelle der Veranstaltung. * * Ohne Zugriffsprüfung, weil hier niemand angemeldet ist -- der Teili bestätigt über seinen Token. * Der Repository-Check greift sonst auf `auth()->user()->id` zu und liefe in einen Fehler. * * Bewusst ohne Prüfung auf `allow_new`/`archived`: Eine Erstattung fällt oft erst nach dem Ende der * Veranstaltung an, wenn die Kostenstelle längst geschlossen ist. Sie gehört trotzdem dorthin -- und * der reguläre Weg über SaveInvoiceController prüft das ebenso wenig. */ private function costUnit(ParticipantRefund $refund): ?CostUnit { if ($refund->event->cost_unit_id === null) { return null; } return new CostUnitRepository()->getById($refund->event->cost_unit_id, true); } /** * Reicht die Erstattung als gewöhnliche Auslagenabrechnung ein. * * Über denselben Command wie jede von Hand erfasste Abrechnung: damit stimmen Nummernkreis, Status * `new`, die Bestätigungsmail an den Teili und die Benachrichtigung der Kassenwart*innen mit dem * überein, was die Buchhaltung kennt. */ private function createInvoice( ParticipantRefund $refund, CostUnit $costUnit, CreateRefundDocumentResponse $document, ): Invoice { $participant = $refund->participant; $invoiceRequest = new CreateInvoiceRequest( costUnit: $costUnit, // getOfficialName() und nicht getFullName(): letzteres enthält HTML für die Oberfläche. contactName: $participant->getOfficialName(), invoiceType: InvoiceType::INVOICE_TYPE_PARTICIPATION_REFUND, totalAmount: $refund->amount?->getAmount() ?? 0.0, receiptFile: $this->storeReceipt($costUnit, $document), isDonation: false, userId: $participant->user_id, contactEmail: $participant->email_1, contactPhone: $participant->phone_1, // Die Bankverbindung stammt aus dem Vorgang, nicht vom Teilnehmer: das Konto kann einem // Elternteil gehören. accountOwner: $refund->account_owner, accountIban: $refund->account_iban, // Die folgenden vier gehören zu Reisekosten und Freitext-Typen und sind hier leer. Sie // müssen trotzdem stehen: `transportations` hat als einziger Parameter keinen Vorgabewert, // und PHP macht damit auch alle optionalen Parameter davor zu Pflichtangaben. invoiceTypeExtended: null, travelRoute: null, distance: null, passengers: null, transportations: null, // MUSS null bleiben (nicht ''): CreateInvoiceCommand verwirft die user_id, sobald hier etwas // steht -- der Teili fände seine Abrechnung dann nicht unter "Meine Abrechnungen". paymentPurpose: null, notices: $this->notice($refund), ); $invoiceResponse = new CreateInvoiceCommand($invoiceRequest)->execute(); if (!$invoiceResponse->success || $invoiceResponse->invoice === null) { // Rollt die Transaktion zurück -- der Vorgang bleibt offen, der Teili kann es erneut versuchen. throw new RuntimeException('Die Abrechnung zur Beitragserstattung konnte nicht angelegt werden.'); } return $invoiceResponse->invoice; } /** * Legt den Eigenbeleg dort ab, wo auch hochgeladene Belege liegen, und verpackt ihn für die * Abrechnung. `CreateInvoiceCommand` speichert nur den Pfad und schreibt selbst keine Dateien. */ private function storeReceipt(CostUnit $costUnit, CreateRefundDocumentResponse $document): ?InvoiceFile { if (!$document->success) { return null; } $path = UploadFileProvider::directoryFor($costUnit) . '/' . $document->filename; new FileWriteProvider($path, $document->pdfContent)->writeToFile(); $receipt = new InvoiceFile(); // Beide Eigenschaften sind typisiert und ohne Vorbelegung; gespeichert wird nur `fullPath`. $receipt->filename = $document->filename; $receipt->fullPath = $path; return $receipt; } /** * Die Anmerkung auf der Abrechnung. * * Sie nennt den gezahlten Beitrag, weil er am Teilnehmer gleich auf 0 gesetzt wird * ({@see self::clearAmountPaid()}) -- die Schatzmeisterei kann den Vorgang so nachvollziehen, ohne * den vorherigen Stand irgendwo suchen zu müssen. * * Gekürzt wird nur der vordere, freie Teil: Veranstaltungsname und Grund sind beliebig lang, der * Betrag darf nie abgeschnitten werden. */ private function notice(ParticipantRefund $refund): string { $paid = $refund->participant->amount_paid?->toString() ?? '0,00 Euro'; return Str::limit(sprintf( 'Rückerstattung Teilnahmebeitrag %s – %s', $refund->event->name, $refund->reasonLabel() ), 180) . sprintf(' | Gezahlter Beitrag vor Erstattung: %s', $paid); } /** * Zieht den erstatteten Betrag vom gezahlten Beitrag ab. * * Danach führt `amount_paid` genau das, was beim Verband geblieben ist -- bei voller Erstattung also * 0, bei einer Teilerstattung den einbehaltenen Rest. Auf diesem Feld baut die Einnahmenrechnung der * Veranstaltung auf; es muss deshalb den tatsächlichen Bestand abbilden und nicht die Zahlung von * einst. Der ursprüngliche Betrag steht zur Kontrolle in der Anmerkung der Abrechnung und auf dem * Beleg. */ private function settleAmountPaid(ParticipantRefund $refund): void { $participant = $refund->participant; $paid = $participant->amount_paid?->getAmount() ?? 0.0; $refunded = $refund->amount?->getAmount() ?? 0.0; // `max` gegen Rundungsreste: Ein negativer gezahlter Betrag wäre in jeder Auswertung Unsinn. $participant->amount_paid = new Amount(max(0.0, round($paid - $refunded, 2)), 'Euro'); $participant->save(); } /** * Die eigene Bestätigung mit dem Beleg im Anhang, an Teili und Kontaktperson. * * Sie kommt zusätzlich zu der, die CreateInvoiceCommand verschickt: diese trägt den Beleg, jene ist * die Quittung des Abrechnungssystems. Erst nach der Transaktion, damit nichts verschickt wird, was * anschließend zurückgerollt würde. * * Scheitert die Belegerzeugung, geht die Mail ohne Anhang raus statt gar nicht. */ private function notify(ParticipantRefund $refund, CreateRefundDocumentResponse $document): void { $pdf = $document->success ? $document->pdfContent : null; $filename = $document->success ? $document->filename : null; $participant = $refund->participant; $recipients = [$participant->email_1]; // `filled()` und nicht `!== null`: Der Anmeldewizard überspringt den Schritt "Kontaktperson" bei // Volljährigen und legt das Feld als Leerstring an -- `Mail::to('')` liefe ins Leere. if (filled($participant->email_2)) { $recipients[] = $participant->email_2; } foreach ($recipients as $recipient) { Mail::to($recipient)->send(new RefundAcceptedMail( participant: $participant, refund: $refund, pdfContent: $pdf, pdfFilename: $filename, )); } } }