participant = $request->participant; $this->refunds = new ParticipantRefundRepository(); } public function execute(): ReleaseRefundResponse { $response = new ReleaseRefundResponse(); $rejection = $this->reject(); if ($rejection !== null) { $response->message = $rejection; return $response; } // Liegt die Bankverbindung schon vor, entsteht in einem Zug auch die Abrechnung. Scheitert die, // soll keine halbe Freigabe zurückbleiben -- deshalb beides in einer Transaktion. $refund = DB::transaction(function (): ParticipantRefund { $refund = ParticipantRefund::create([ 'tenant' => $this->participant->tenant, 'event_id' => $this->participant->event_id, 'event_participant_id' => $this->participant->id, 'token' => Str::random(32), 'status' => ParticipantRefund::STATUS_PENDING, 'amount' => $this->request->amount, 'reason' => $this->request->reason, 'reason_note' => $this->reasonNote(), // Was beim Verband bleibt, wird hier festgeschrieben: Nach dem Einreichen führt // `amount_paid` bereits diesen Rest, eine spätere Differenz wäre falsch. 'retained_amount' => $this->request->retainedAmount(), 'retention_reason' => $this->retentionReason(), 'retention_reason_note' => $this->retentionReasonNote(), // Kennt die Zahlungsart das Konto, von dem der Beitrag kam, steht es von Anfang an // fest. Der Teili entscheidet dann nur noch: auszahlen oder spenden. 'account_owner' => $this->knownAccountOwner(), 'account_iban' => $this->knownAccountIban(), 'released_by' => currentUser()?->id, 'released_at' => now(), ]); if ($this->request->submitsDirectly()) { $this->submitDirectly($refund); } return $refund; }); if (!$this->request->submitsDirectly()) { $this->notify($refund); } $response->success = true; $response->refund = $refund->fresh(); $response->message = match (true) { $this->request->donation => 'Die Spende wurde eingereicht. Der Teili hat den Beleg per E-Mail erhalten.', $this->request->hasBankDetails() => 'Die Erstattung wurde eingereicht. Der Teili hat den Beleg per E-Mail erhalten.', default => 'Die Erstattung wurde freigegeben. Der Teili wurde per E-Mail informiert.', }; return $response; } /** * Das Konto, das die Zahlungsart kennt -- bei der Überweisung das des Zahlungseingangs. * * Lazy und einmalig, weil der Aufruf über die Event-Relation des Teilnehmers läuft * ({@see EventParticipant::paymentConfiguration()}) und in einem Vorgang mehrfach gebraucht wird. */ private function knownRefundData(): GetRefundDataResponse { return $this->knownRefundData ??= $this->participant->refundData(); } /** * Bei der Sofort-Einreichung und bei der Spende bleibt das Feld leer: Dort setzt der * {@see AcceptRefundCommand} es -- aus der Eingabe der Aktionsleitung bzw. auf `null`. */ private function knownAccountOwner(): ?string { return $this->request->submitsDirectly() ? null : ($this->knownRefundData()->accountOwner); } private function knownAccountIban(): ?string { return $this->request->submitsDirectly() ? null : ($this->knownRefundData()->accountIban); } /** * Reicht die Erstattung sofort ein, ohne den Umweg über den Teili. * * Über denselben Command, den sonst der Bestätigungslink auslöst: Beleg, Abrechnung, das Nullstellen * des gezahlten Beitrags und die Mail mit dem Beleg laufen dadurch in beiden Wegen identisch ab. */ private function submitDirectly(ParticipantRefund $refund): void { $acceptResponse = new AcceptRefundCommand(new AcceptRefundRequest( refund: $refund, accountOwner: (string) $this->request->accountOwner, accountIban: (string) $this->request->accountIban, donation: $this->request->donation, // Niemand kreuzt hier eine Erklärung an; wer die Angaben aufgenommen hat, hält `captured_by` // fest, und der Beleg weist es aus. capturedBy: currentUser()?->id, ))->execute(); if (!$acceptResponse->success) { // Rollt die Freigabe zurück -- die Aktionsleitung soll den Fehler sehen und nicht einen // Vorgang vorfinden, der nirgends eingereicht ist. throw new RuntimeException($acceptResponse->message ?? 'Die Erstattung konnte nicht eingereicht werden.'); } } /** * Alle Gründe, aus denen eine Freigabe nicht zulässig ist. * * @return string|null Meldung, oder null wenn nichts dagegen spricht. */ private function reject(): ?string { if ($this->participant->unregistered_at === null) { return 'Eine Erstattung ist nur für abgemeldete Teilis möglich.'; } if ($this->refunds->openFor($this->participant) !== null) { return 'Für diese Anmeldung läuft bereits eine Erstattung.'; } $amount = $this->request->amount->getAmount(); if ($amount <= 0) { return 'Der Erstattungsbetrag muss größer als 0 sein.'; } // Mehr zurückgeben als eingegangen ist wäre keine Erstattung mehr. Die halbe Cent-Toleranz // fängt die Rundung des gespeicherten Floats ab. $paid = $this->participant->amount_paid?->getAmount() ?? 0.0; if ($amount > $paid + 0.005) { return 'Der Erstattungsbetrag darf den gezahlten Beitrag nicht übersteigen.'; } $reason = RefundReason::find($this->request->reason); if ($reason === null) { return 'Bitte wähle einen Erstattungsgrund aus.'; } if ($reason->requires_note && trim((string) $this->request->reasonNote) === '') { return 'Für diesen Grund ist eine Erläuterung erforderlich.'; } return $this->rejectRetention() ?? $this->rejectBankDetails(); } /** * Prüfungen zum einbehaltenen Teil. * * Sicherheitsnetz hinter der Oberfläche: Dort erscheint der Absende-Knopf erst, wenn ein Grund * gewählt ist. Über einen direkten Aufruf ginge das sonst vorbei, und ein einbehaltener Betrag ohne * Begründung ist in der Buchhaltung nicht haltbar. */ private function rejectRetention(): ?string { if (!$this->request->hasRetention()) { return null; } $reason = RetentionReason::find($this->request->retentionReason); if ($reason === null) { return 'Bitte gib an, warum ein Teil des Beitrags einbehalten wird.'; } if ($reason->requires_note && trim((string) $this->request->retentionReasonNote) === '') { return 'Für diesen Einbehaltungsgrund ist eine Erläuterung erforderlich.'; } return null; } /** * Prüfungen, die nur den Direktweg betreffen -- die Erstattung wird dabei sofort eingereicht, es gibt * also keine zweite Gelegenheit, Angaben zu berichtigen. */ private function rejectBankDetails(): ?string { // Eine Spende wird nicht ausgezahlt. Kämen beide Angaben zusammen, wäre unklar, was gilt -- // lieber nachfragen als das eine stillschweigend gegen das andere entscheiden. if ($this->request->donation && (filled($this->request->accountOwner) || filled($this->request->accountIban))) { return 'Eine Spende braucht keine Bankverbindung.'; } // Halb ausgefüllt ist keine Absicht: entweder beides oder der Weg über den Teili. if (!$this->request->donation && filled($this->request->accountOwner) !== filled($this->request->accountIban)) { return 'Für die sofortige Erstattung werden Kontoinhaber*in und IBAN benötigt.'; } if (!$this->request->submitsDirectly()) { return null; } if ($this->request->hasBankDetails() && !Iban::isValid((string) $this->request->accountIban)) { return 'Diese IBAN stimmt nicht. Bitte prüfe die Eingabe.'; } // Ohne Kostenstelle ließe sich die Abrechnung nicht anlegen -- auch die Spende braucht eine, sie // wird ja gebucht. Hier abfangen und nicht erst in der Transaktion, damit die Aktionsleitung eine // verständliche Meldung sieht. if ($this->participant->event->cost_unit_id === null) { return 'Die Veranstaltung hat keine Kostenstelle -- die Erstattung kann nicht eingereicht werden.'; } return null; } /** Der Freitext gehört nur zu Gründen, die ihn verlangen -- sonst stünde er ungenutzt in der DB. */ private function reasonNote(): ?string { $reason = RefundReason::find($this->request->reason); if ($reason === null || !$reason->requires_note) { return null; } return trim((string) $this->request->reasonNote); } /** * Der Einbehaltungsgrund -- nur, wenn tatsächlich etwas beim Verband bleibt. * * Bei voller Erstattung wird ein mitgeschickter Grund verworfen: In der Oberfläche ist das Feld dann * gar nicht sichtbar, und ein Wert ohne Bezug hätte in der Datenbank nichts zu suchen. */ private function retentionReason(): ?string { return $this->request->hasRetention() ? $this->request->retentionReason : null; } /** Der Freitext dazu -- wie beim Erstattungsgrund nur bei Gründen, die ihn verlangen. */ private function retentionReasonNote(): ?string { if (!$this->request->hasRetention()) { return null; } $reason = RetentionReason::find($this->request->retentionReason); if ($reason === null || !$reason->requires_note) { return null; } return trim((string) $this->request->retentionReasonNote); } /** * Teili und Kontaktperson bekommen je eine eigene Mail -- dasselbe Muster wie bei der Abmeldung * (siehe SetParticipationStateCommand). */ private function notify(ParticipantRefund $refund): void { $recipients = [$this->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($this->participant->email_2)) { $recipients[] = $this->participant->email_2; } foreach ($recipients as $recipient) { Mail::to($recipient)->send(new RefundReleasedMail( participant: $this->participant, refund: $refund, )); } } }