'building-columns']; } public function getOptions(): array { return [ ['name' => 'account_owner', 'label' => 'Kontoinhaber', 'type' => 'string', 'required' => true], ['name' => 'iban', 'label' => 'IBAN', 'type' => 'string', 'required' => true], ['name' => 'bic', 'label' => 'BIC', 'type' => 'string', 'required' => false], ['name' => 'summary_confirmation_text', 'label' => 'Bestätigung durch Teilnehmende (Zusammenfassung, {amount} als Platzhalter)', 'type' => 'string', 'required' => false], ['name' => 'icon', 'label' => 'Symbol', 'type' => 'icon', 'required' => false], [ 'name' => self::OPTION_STATEMENT_RULESET, 'label' => 'Format des Kontoauszugs (CSV)', 'type' => 'bank-ruleset', 'required' => false, 'scope' => 'tenant', 'hint' => 'Nur ausfüllen, wenn eure Bank ein anderes Format liefert als der App-Standard. ' . 'Die Einstellung gilt für alle Aktionen -- auch für bereits laufende.', ], ]; } /** * Beim Zahlungsimport nachgetragen, nicht beim Anmelden abgefragt: `system => true` blendet die * Felder im Anmeldeformular aus. Im Schema müssen sie trotzdem stehen, sonst verwirft * {@see sanitizeParticipantOptions()} sie als unbekannte Schlüssel. */ public function getParticipantOptions(): array { return [ [ 'name' => self::OPTION_PAYER_ACCOUNT_OWNER, 'label' => 'Kontoinhaber*in der Zahlung', 'type' => 'string', 'required' => false, 'system' => true, ], [ 'name' => self::OPTION_PAYER_IBAN, 'label' => 'IBAN der Zahlung', 'type' => 'string', 'required' => false, 'system' => true, ], ]; } public function statementRuleset(array $configuration): BankStatementRuleset { $override = $configuration[self::OPTION_STATEMENT_RULESET] ?? null; // Das Frontend schickt das Ruleset als JSON-String durch das generische Options-Formular. if (is_string($override)) { $decoded = json_decode($override, true); $override = is_array($decoded) ? $decoded : null; } return BankStatementRuleset::fromConfiguration(is_array($override) ? $override : null); } /** Bei der Überweisung zählen Gutschriften -- Belastungen sind Ausgaben der Aktion. */ public function isRelevantTransaction(BankTransaction $transaction): bool { return $transaction->isCredit(); } /** * Erkennt die Anmeldung zu einem Zahlungseingang. * * Die Regeln greifen in fester Reihenfolge und liefern nur dann etwas, wenn **genau eine** * Anmeldung passt. Bei zwei Treffern -- zwei Geschwister mit gleichem Nachnamen, zwei * Namensgleiche im selben Lager -- gibt es bewusst keinen Vorschlag: die falsche Zuordnung wäre * in der Prüfansicht nicht zu erkennen und würde durchgewinkt. * * @param Collection $candidates */ public function matchTransaction(BankTransaction $transaction, Collection $candidates): ?TransactionMatch { $purpose = $this->normalize($transaction->purpose); $payerName = $this->normalize($transaction->payerName); $payerIban = Iban::normalize($transaction->payerIban); // 1) Der beim Anmelden erzeugte Verwendungszweck steht unverändert im Auszug -- der Normalfall. $match = $this->onlyOne($candidates, function (EventParticipant $participant) use ($purpose): bool { $reference = $this->normalize((string) $participant->payment_purpose); return $reference !== '' && str_contains($purpose, $reference); }); if ($match !== null) { return new TransactionMatch($match); } // 2) Verwendungszweck abgetippt oder gekürzt, aber beide Namen sind noch drin. $match = $this->onlyOne($candidates, function (EventParticipant $participant) use ($purpose): bool { $firstname = $this->normalize((string) $participant->firstname); $lastname = $this->normalize((string) $participant->lastname); return $firstname !== '' && $lastname !== '' && str_contains($purpose, $firstname) && str_contains($purpose, $lastname); }); if ($match !== null) { return new TransactionMatch($match); } // 3) Folgezahlung: Von diesem Konto kam bereits ein Beitrag für genau eine Anmeldung. if ($payerIban !== '') { $match = $this->onlyOne($candidates, static function (EventParticipant $participant) use ($payerIban): bool { $known = (string) (($participant->payment_options ?? [])[self::OPTION_PAYER_IBAN] ?? ''); return $known !== '' && Iban::normalize($known) === $payerIban; }); if ($match !== null) { return new TransactionMatch($match); } } // 4) Nur der Nachname im Zweck, aber der Betrag trifft den offenen Rest auf den Cent. $match = $this->onlyOne($candidates, function (EventParticipant $participant) use ($purpose, $transaction): bool { $lastname = $this->normalize((string) $participant->lastname); if ($lastname === '' || !str_contains($purpose, $lastname)) { return false; } return round($this->amountLeft($participant)->getAmount(), 2) === round($transaction->amount->getAmount(), 2); }); if ($match !== null) { return new TransactionMatch($match); } // 5) Der Zweck sagt nichts, aber das Konto läuft auf den Namen der Anmeldung. Das trägt oft, // liegt aber bei Eltern- und Gemeinschaftskonten auch daneben -- deshalb nur „unsicher". if ($payerName !== '') { $match = $this->onlyOne($candidates, function (EventParticipant $participant) use ($payerName): bool { $firstname = $this->normalize((string) $participant->firstname); $lastname = $this->normalize((string) $participant->lastname); return $lastname !== '' && $payerName === $firstname . $lastname; }); if ($match !== null) { return new TransactionMatch($match, TransactionMatch::CONFIDENCE_UNCERTAIN); } } return null; } /** * Hält fest, von welchem Konto der Beitrag kam. * * Das ist mehr als eine Notiz: Erstattet wird ausschließlich auf das Konto, von dem der Beitrag * gezahlt wurde. Bisher muss die teilnehmende Person das selbst zusichern, weil die App es nicht * weiß -- mit `refund_data` weiß sie es. * * Eine IBAN, die die Prüfziffer nicht besteht, wird nicht übernommen: Auf ein Konto mit * Zahlendreher zu erstatten hieße, das Geld an eine fremde Person zu überweisen. */ public function recordTransaction(EventParticipant $participant, BankTransaction $transaction): void { $iban = Iban::normalize($transaction->payerIban); $owner = trim($transaction->payerName); if ($iban === '' || !Iban::isValid($iban)) { return; } $options = $participant->payment_options ?? []; // Die jüngste Zahlung gewinnt: Erstattet wird auf das Konto, von dem der Beitrag kam -- // zahlt jemand die zweite Rate von einem anderen Konto, ist das nun dieses. $options[self::OPTION_PAYER_IBAN] = $iban; // Einen bereits bekannten Kontoinhaber nicht durch einen leeren Namen ersetzen: Manche Banken // lassen das Feld bei Folgezahlungen leer, und ohne Inhaber ist die IBAN für die Erstattung // wertlos. if ($owner !== '') { $options[self::OPTION_PAYER_ACCOUNT_OWNER] = $owner; } $participant->payment_options = $options; // Nur hochsetzen, nie zurück: Was wir einmal wissen, wissen wir. if (($options[self::OPTION_PAYER_ACCOUNT_OWNER] ?? '') !== '') { $participant->refund_data = true; } } /** * Genau ein Treffer oder nichts. * * @param Collection $candidates * @param callable(EventParticipant): bool $matches */ private function onlyOne(Collection $candidates, callable $matches): ?EventParticipant { $hits = $candidates->filter($matches); return $hits->count() === 1 ? $hits->first() : null; } /** Noch offener Beitrag. `clone`, weil Amount seine Rechenoperationen auf sich selbst ausführt. */ private function amountLeft(EventParticipant $participant): Amount { $amountLeft = clone $participant->amount; if ($participant->amount_paid !== null) { $amountLeft->subtractAmount($participant->amount_paid); } return $amountLeft; } /** Kleinschreibung, Umlaute aufgelöst, alles außer [a-z0-9] raus. */ private function normalize(string $value): string { $value = mb_strtolower(trim($value)); $value = strtr($value, [ 'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss', 'á' => 'a', 'à' => 'a', 'â' => 'a', 'é' => 'e', 'è' => 'e', 'ê' => 'e', 'í' => 'i', 'ì' => 'i', 'ó' => 'o', 'ò' => 'o', 'ô' => 'o', 'ú' => 'u', 'ù' => 'u', 'ç' => 'c', 'ñ' => 'n', ]); return preg_replace('/[^a-z0-9]/', '', $value) ?? ''; } public function registrationSummary(RegistrationSummaryRequest $request): RegistrationSummaryResponse { $participant = $request->participant; $config = $request->configuration; $amountLeft = $this->amountLeft($participant); $hasPaymentInformation = $amountLeft->getAmount() > 0; // GiroCode nur bei offenem Betrag; Bildquelle je Render-Kontext (Web-Endpoint vs. Mail-CID). $giroCodeSrc = null; if ($hasPaymentInformation) { $giroCodeSrc = $request->context === RegistrationRenderContext::Mail ? 'cid:girocode.png' : '/print-girocode/' . $participant->identifier; } // Medien-gerechtes Template je Render-Kontext (Web: SPA-Klassen, Mail: E-Mail-tauglich). $view = $request->context === RegistrationRenderContext::Mail ? 'payment-modules.account-transfer.mail' : 'payment-modules.account-transfer.web'; $response = new RegistrationSummaryResponse(); $response->hasPaymentInformation = $hasPaymentInformation; $response->html = view($view, [ 'accountOwner' => (string)($config['account_owner'] ?? ''), 'iban' => (string)($config['iban'] ?? ''), 'paymentPurpose' => (string)$participant->payment_purpose, 'amount' => $amountLeft->toString(), 'giroCodeSrc' => $giroCodeSrc, 'reminderUrl' => url('/api/v1/event/participant/' . $participant->identifier . '/ical-payment'), 'paymentDeadline' => $participant->event?->registration_final_end?->format('d.m.Y'), ])->render(); return $response; } public function giroCode(EventParticipant $participant, array $configuration): ?string { $amountLeft = $this->amountLeft($participant); if ($amountLeft->getAmount() <= 0) { return null; } $provider = new GiroCodeProvider( (string)($configuration['account_owner'] ?? ''), (string)($configuration['iban'] ?? ''), $amountLeft->getAmount(), (string)$participant->payment_purpose, ); return (string)$provider->create(); } protected function invoiceClosingStatement(CreateInvoiceRequest $request): string { $config = $request->configuration; $deadline = $request->event->registration_final_end?->format('d.m.Y'); $sentence = $deadline !== null ? sprintf('Bitte überweise %s bis zum %s auf folgendes Konto:', $request->amount->toString(), $deadline) : sprintf('Bitte überweise %s auf folgendes Konto:', $request->amount->toString()); $lines = [ 'Kontoinhaber*in: ' . (string) ($config['account_owner'] ?? ''), 'IBAN: ' . (string) ($config['iban'] ?? ''), ]; if (($config['bic'] ?? '') !== '') { $lines[] = 'BIC: ' . (string) $config['bic']; } $lines[] = 'Verwendungszweck: ' . (string) $request->participant->payment_purpose; return $sentence . '
' . implode('
', array_map(e(...), $lines)); } }