Zahlungsparser
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user