Zahlungsparser
This commit is contained in:
+142
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Actions\BookBankStatementPayments;
|
||||
|
||||
use App\Domains\Event\Actions\ParticipantPayment\ParticipantPaymentCommand;
|
||||
use App\Domains\Event\Actions\ParticipantPayment\ParticipantPaymentRequest;
|
||||
use App\EventPaymentModules\EventPaymentModuleRegistry;
|
||||
use App\EventPaymentModules\ReadsBankStatements;
|
||||
use App\Models\EventParticipant;
|
||||
use App\Models\PaymentMethod;
|
||||
use App\Repositories\EventParticipantRepository;
|
||||
use App\ValueObjects\Amount;
|
||||
use App\ValueObjects\BankTransaction;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
/**
|
||||
* Bucht die in der Prüfansicht bestätigten Zahlungseingänge -- alle in einem Durchgang.
|
||||
*
|
||||
* Bewusst **ohne** umspannende Transaktion: Gebucht wird über {@see ParticipantPaymentCommand}, und der
|
||||
* verschickt die Zahlungsmail gleich mit. Das Projekt kennt keine Queue, die Mails gehen also synchron
|
||||
* raus; eine offene Transaktion über achtzig Mailversände wäre die schlechtere Wahl. Jede Zeile ist
|
||||
* für sich vollständig, und das Wasserzeichen schützt sie gegen eine Wiederholung.
|
||||
*/
|
||||
class BookBankStatementPaymentsCommand
|
||||
{
|
||||
public function __construct(private readonly BookBankStatementPaymentsRequest $request)
|
||||
{
|
||||
}
|
||||
|
||||
public function execute(): BookBankStatementPaymentsResponse
|
||||
{
|
||||
$response = new BookBankStatementPaymentsResponse();
|
||||
|
||||
if ($this->request->bookings === []) {
|
||||
$response->message = 'Es wurden keine Zahlungen zum Buchen ausgewählt.';
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
$module = EventPaymentModuleRegistry::forSlug(PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION);
|
||||
$participants = new EventParticipantRepository();
|
||||
|
||||
foreach ($this->request->bookings as $booking) {
|
||||
$participant = $participants->findInEventByIdentifier(
|
||||
$this->request->event,
|
||||
(string) ($booking['participantIdentifier'] ?? ''),
|
||||
);
|
||||
|
||||
$transaction = $this->toTransaction($booking);
|
||||
|
||||
if ($participant === null || $transaction === null) {
|
||||
$response->failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Zweite Prüfung, nicht bloß Gürtel und Hosenträger: In der Prüfansicht lässt sich eine
|
||||
// Zeile von Hand einer anderen Person zuordnen, und deren Wasserzeichen hat der erste
|
||||
// Durchlauf nie gesehen.
|
||||
if ($this->isBeforeWatermark($participant, $transaction)) {
|
||||
$response->skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($module instanceof ReadsBankStatements) {
|
||||
$module->recordTransaction($participant, $transaction);
|
||||
}
|
||||
|
||||
$participant->last_payment_date = $transaction->paymentDate;
|
||||
|
||||
$this->book($participant, $transaction->amount);
|
||||
$response->booked++;
|
||||
}
|
||||
|
||||
$response->success = true;
|
||||
$response->message = $this->summaryMessage($response);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucht den Betrag **zusätzlich** zum bereits Gezahlten.
|
||||
*
|
||||
* Die manuelle Erfassung setzt `amount_paid` absolut, weil dort jemand den Gesamtstand eintippt.
|
||||
* Hier kommt eine einzelne Zahlung an -- eine zweite Rate darf die erste nicht überschreiben.
|
||||
* Der {@see ParticipantPaymentCommand} speichert dabei die zuvor gesetzten Felder mit und schickt
|
||||
* die passende Mail (bezahlt / fehlt noch / überzahlt).
|
||||
*/
|
||||
private function book(EventParticipant $participant, Amount $amount): void
|
||||
{
|
||||
$total = new Amount(
|
||||
round(($participant->amount_paid?->getAmount() ?? 0.0) + $amount->getAmount(), 2),
|
||||
'Euro',
|
||||
);
|
||||
|
||||
new ParticipantPaymentCommand(new ParticipantPaymentRequest($participant, $total))->execute();
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $booking */
|
||||
private function toTransaction(array $booking): ?BankTransaction
|
||||
{
|
||||
$date = CarbonImmutable::createFromFormat('!Y-m-d', (string) ($booking['paymentDate'] ?? ''));
|
||||
$amount = $booking['amount'] ?? null;
|
||||
|
||||
if ($date === false || !is_numeric($amount) || (float) $amount <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new BankTransaction(
|
||||
paymentDate: $date,
|
||||
amount: new Amount(round((float) $amount, 2), 'Euro'),
|
||||
purpose: (string) ($booking['purpose'] ?? ''),
|
||||
payerName: (string) ($booking['payerName'] ?? ''),
|
||||
payerIban: (string) ($booking['payerIban'] ?? ''),
|
||||
rowNumber: (int) ($booking['rowNumber'] ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
private function isBeforeWatermark(EventParticipant $participant, BankTransaction $transaction): bool
|
||||
{
|
||||
$watermark = $participant->last_payment_date;
|
||||
|
||||
return $watermark !== null && $transaction->paymentDate->startOfDay()->lt($watermark->startOfDay());
|
||||
}
|
||||
|
||||
private function summaryMessage(BookBankStatementPaymentsResponse $response): string
|
||||
{
|
||||
$message = sprintf('%d Zahlungen wurden gebucht.', $response->booked);
|
||||
|
||||
if ($response->skipped > 0) {
|
||||
$message .= sprintf(
|
||||
' %d wurden übersprungen, weil sie älter sind als die zuletzt erfasste Zahlung der Person.',
|
||||
$response->skipped,
|
||||
);
|
||||
}
|
||||
|
||||
if ($response->failed > 0) {
|
||||
$message .= sprintf(' %d konnten nicht zugeordnet werden.', $response->failed);
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Actions\BookBankStatementPayments;
|
||||
|
||||
use App\Models\Event;
|
||||
|
||||
class BookBankStatementPaymentsRequest
|
||||
{
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $bookings Je Eintrag die bestätigte Zeile der Prüfansicht:
|
||||
* participantIdentifier, paymentDate, amount,
|
||||
* payerName, payerIban, purpose.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Event $event,
|
||||
public readonly array $bookings,
|
||||
) {
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Actions\BookBankStatementPayments;
|
||||
|
||||
class BookBankStatementPaymentsResponse
|
||||
{
|
||||
public bool $success = false;
|
||||
public string $message = '';
|
||||
|
||||
public int $booked = 0;
|
||||
|
||||
/** Übersprungen, weil älter als die zuletzt erfasste Zahlung der Person. */
|
||||
public int $skipped = 0;
|
||||
|
||||
/** Nicht verarbeitbar (unbekannte Anmeldung, unbrauchbarer Betrag). */
|
||||
public int $failed = 0;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -64,7 +64,7 @@ class SetPaymentMethodsCommand
|
||||
// Bestehende Zuweisung: Config nur bei explizitem Override anpassen, sonst unverändert lassen.
|
||||
if ($override !== null) {
|
||||
$row = $existing[$slug];
|
||||
$row->configuration = PaymentMethod::sanitizeConfiguration($slug, $override);
|
||||
$row->configuration = $this->eventConfiguration($slug, $override);
|
||||
$row->save();
|
||||
}
|
||||
continue;
|
||||
@@ -75,8 +75,27 @@ class SetPaymentMethodsCommand
|
||||
EventPaymentMethods::create([
|
||||
'event_id' => $event->id,
|
||||
'slug' => $slug,
|
||||
'configuration' => PaymentMethod::sanitizeConfiguration($slug, $snapshot ?? []),
|
||||
'configuration' => $this->eventConfiguration($slug, $snapshot ?? []),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Was von einer Konfiguration am Event gespeichert wird.
|
||||
*
|
||||
* Neben dem Zuschnitt aufs Schema fallen hier die tenant-weiten Optionen heraus. Das
|
||||
* Kontoauszug-Format etwa beschreibt die Bank, nicht die Zusage an die Teilnehmenden -- eingefroren
|
||||
* ließe sich nach einem Bankwechsel für laufende Aktionen nichts mehr importieren. IBAN und
|
||||
* Kontoinhaber frieren dagegen weiterhin pro Aktion ein.
|
||||
*
|
||||
* @param array<string, mixed> $configuration
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function eventConfiguration(string $slug, array $configuration): array
|
||||
{
|
||||
return PaymentMethod::stripTenantScopedOptions(
|
||||
$slug,
|
||||
PaymentMethod::sanitizeConfiguration($slug, $configuration),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user