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
@@ -0,0 +1,183 @@
<?php
namespace App\Domains\Event\Actions\ParseBankStatement;
use App\EventPaymentModules\EventPaymentModuleRegistry;
use App\EventPaymentModules\ProvidesStatementRuleset;
use App\EventPaymentModules\ReadsBankStatements;
use App\Models\EventParticipant;
use App\Models\PaymentMethod;
use App\Providers\BankStatementParseProvider;
use App\Repositories\EventParticipantRepository;
use App\Support\BankStatementParseException;
use App\ValueObjects\BankStatementRuleset;
use App\ValueObjects\BankTransaction;
/**
* Liest einen hochgeladenen Kontoauszug und schlägt je Zahlungseingang eine Anmeldung vor.
*
* Hier wird nichts gebucht -- das Ergebnis ist die Prüfansicht, in der die Aktionsleitung die
* Vorschläge bestätigt, korrigiert oder verwirft. Erst der zweite Schritt schreibt.
*
* Der Ablauf ist zahlartneutral: Was ein verwertbarer Umsatz ist und zu wem er gehört, entscheidet
* das Zahlungsmodul über {@see ReadsBankStatements}.
*/
class ParseBankStatementCommand
{
/** 8 MB -- ein Jahresauszug bleibt weit darunter, alles darüber ist die falsche Datei. */
private const int MAX_BYTES = 8 * 1024 * 1024;
private const array ALLOWED_EXTENSIONS = ['csv', 'txt'];
public function __construct(private readonly ParseBankStatementRequest $request)
{
}
public function execute(): ParseBankStatementResponse
{
$response = new ParseBankStatementResponse();
$module = EventPaymentModuleRegistry::forSlug(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION);
if (!$module instanceof ReadsBankStatements) {
$response->message = 'Für die Überweisung ist kein Kontoauszug-Import eingerichtet.';
return $response;
}
$contents = $this->readFile($response);
if ($contents === null) {
return $response;
}
$ruleset = $module instanceof ProvidesStatementRuleset
? $module->statementRuleset($this->request->configuration)
: BankStatementRuleset::default();
try {
$transactions = new BankStatementParseProvider()->parse($contents, $ruleset);
} catch (BankStatementParseException $exception) {
$response->message = $exception->getMessage();
return $response;
}
$candidates = new EventParticipantRepository()->getForPaymentMatching($this->request->event);
foreach ($transactions as $transaction) {
if (!$module->isRelevantTransaction($transaction)) {
continue;
}
$match = $module->matchTransaction($transaction, $candidates);
// Vor der letzten erfassten Zahlung dieser Person: schon gebucht, oder aus einem Auszug,
// der bereits verarbeitet wurde. Gar nicht erst anzeigen.
if ($match !== null && $this->isBeforeWatermark($match->participant, $transaction)) {
$response->skippedOlderThanWatermark++;
continue;
}
$response->rows[] = $transaction->toArray() + [
'suggestedIdentifier' => $match?->participant->identifier,
'confidence' => $match?->confidence,
];
}
$response->participants = $candidates
->map(fn (EventParticipant $participant): array => $this->participantOption($participant))
->values()
->all();
$response->success = true;
$response->message = $this->summaryMessage($response);
return $response;
}
private function readFile(ParseBankStatementResponse $response): ?string
{
$file = $this->request->file;
if ($file === null || !$file->isValid()) {
$response->message = 'Es wurde keine Datei hochgeladen.';
return null;
}
if ($file->getSize() > self::MAX_BYTES) {
$response->message = 'Die Datei ist zu groß (maximal 8 MB).';
return null;
}
if (!in_array(strtolower($file->getClientOriginalExtension()), self::ALLOWED_EXTENSIONS, true)) {
$response->message = 'Bitte den CSV-Export der Bank hochladen (.csv).';
return null;
}
$contents = file_get_contents($file->getRealPath());
if ($contents === false || trim($contents) === '') {
$response->message = 'Die Datei ließ sich nicht lesen oder ist leer.';
return null;
}
return $contents;
}
private function isBeforeWatermark(EventParticipant $participant, BankTransaction $transaction): bool
{
$watermark = $participant->last_payment_date;
// Echt kleiner, nicht kleiner-gleich: Zwei Zahlungen am selben Tag sollen beide ankommen.
return $watermark !== null && $transaction->paymentDate->startOfDay()->lt($watermark->startOfDay());
}
/** @return array<string, mixed> */
private function participantOption(EventParticipant $participant): array
{
$amountLeft = clone $participant->amount;
if ($participant->amount_paid !== null) {
$amountLeft->subtractAmount($participant->amount_paid);
}
return [
'identifier' => $participant->identifier,
'name' => $participant->lastname . ', ' . $participant->firstname,
'amount' => $participant->amount?->toString(),
'amountPaid' => $participant->amount_paid?->toString(),
'amountOpen' => $amountLeft->toString(),
'isSettled' => round($amountLeft->getAmount(), 2) <= 0,
'lastPaymentDate' => $participant->last_payment_date?->format('d.m.Y'),
// Zahlt jemand, der sich abgemeldet hat, wird die Zahlung trotzdem erfasst -- danach
// steht aber eine Erstattung an. Die Prüfansicht weist darauf hin.
'isSignedOff' => $participant->unregistered_at !== null,
'signedOffAt' => $participant->unregistered_at?->format('d.m.Y'),
];
}
private function summaryMessage(ParseBankStatementResponse $response): string
{
if ($response->rows === []) {
return $response->skippedOlderThanWatermark > 0
? 'Alle Zahlungseingänge dieser Datei wurden bereits erfasst.'
: 'In der Datei sind keine Zahlungseingänge zu dieser Aktion enthalten.';
}
$assigned = count(array_filter($response->rows, static fn (array $row): bool => $row['suggestedIdentifier'] !== null));
$message = sprintf(
'%d Zahlungseingänge gelesen, %d davon konnten zugeordnet werden.',
count($response->rows),
$assigned,
);
if ($response->skippedOlderThanWatermark > 0) {
$message .= sprintf(' %d bereits erfasste Zahlungen wurden übersprungen.', $response->skippedOlderThanWatermark);
}
return $message;
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Event\Actions\ParseBankStatement;
use App\Models\Event;
use Illuminate\Http\UploadedFile;
class ParseBankStatementRequest
{
/**
* @param array<string, mixed> $configuration Tenant-Konfiguration der Überweisung. Wird hereingereicht
* statt hier geholt -- den DB-Zugriff macht der Controller
* über das Repository.
*/
public function __construct(
public readonly Event $event,
public readonly ?UploadedFile $file,
public readonly array $configuration = [],
) {
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Event\Actions\ParseBankStatement;
class ParseBankStatementResponse
{
public bool $success = false;
public string $message = '';
/**
* Eine Zeile je verwertbarem Umsatz, fertig für die Prüfansicht.
*
* @var array<int, array<string, mixed>>
*/
public array $rows = [];
/**
* Alle zuordenbaren Anmeldungen der Aktion für die Auswahlliste.
*
* @var array<int, array<string, mixed>>
*/
public array $participants = [];
/**
* Umsätze, die vor der zuletzt erfassten Zahlung der erkannten Person liegen. Sie erscheinen gar
* nicht erst in der Prüfansicht -- gezählt werden sie trotzdem, sonst bliebe unerklärt, warum
* eine Datei mit 40 Zeilen nur 12 Vorschläge ergibt.
*/
public int $skippedOlderThanWatermark = 0;
}