Zahlungsparser

This commit is contained in:
2026-09-09 16:55:08 +02:00
parent ae13841699
commit 651b6147bf
38 changed files with 3308 additions and 22 deletions
@@ -7,16 +7,34 @@ use App\EventPaymentModules\DTO\CreateInvoiceRequest;
use App\EventPaymentModules\DTO\RegistrationRenderContext;
use App\EventPaymentModules\DTO\RegistrationSummaryRequest;
use App\EventPaymentModules\DTO\RegistrationSummaryResponse;
use App\EventPaymentModules\DTO\TransactionMatch;
use App\EventPaymentModules\ProvidesGiroCode;
use App\EventPaymentModules\ProvidesStatementRuleset;
use App\EventPaymentModules\ReadsBankStatements;
use App\Models\EventParticipant;
use App\Models\PaymentMethod;
use App\Providers\GiroCodeProvider;
use App\Support\Iban;
use App\ValueObjects\Amount;
use App\ValueObjects\BankStatementRuleset;
use App\ValueObjects\BankTransaction;
use Illuminate\Support\Collection;
/**
* Überweisung auf das Veranstaltungskonto -- die Zahlung erfolgt manuell durch die teilnehmende Person.
*/
class AccountTransferPaymentModule extends AbstractEventPaymentModule implements ProvidesGiroCode
class AccountTransferPaymentModule extends AbstractEventPaymentModule implements
ProvidesGiroCode,
ProvidesStatementRuleset,
ReadsBankStatements
{
/** Admin-Option, unter der das Kontoauszug-Format des Mandanten liegt. */
public const string OPTION_STATEMENT_RULESET = 'statement_ruleset';
/** Teilnehmer-Optionen, die der Zahlungsimport nachträgt (nicht im Anmeldeformular). */
public const string OPTION_PAYER_ACCOUNT_OWNER = 'payer_account_owner';
public const string OPTION_PAYER_IBAN = 'payer_iban';
public static function slug(): string
{
return PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION;
@@ -40,18 +58,228 @@ class AccountTransferPaymentModule extends AbstractEventPaymentModule implements
['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<int, EventParticipant> $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<int, EventParticipant> $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 = clone $participant->amount;
if ($participant->amount_paid !== null) {
$amountLeft->subtractAmount($participant->amount_paid);
}
$amountLeft = $this->amountLeft($participant);
$hasPaymentInformation = $amountLeft->getAmount() > 0;
@@ -85,10 +313,7 @@ class AccountTransferPaymentModule extends AbstractEventPaymentModule implements
public function giroCode(EventParticipant $participant, array $configuration): ?string
{
$amountLeft = clone $participant->amount;
if ($participant->amount_paid !== null) {
$amountLeft->subtractAmount($participant->amount_paid);
}
$amountLeft = $this->amountLeft($participant);
if ($amountLeft->getAmount() <= 0) {
return null;