184 lines
6.7 KiB
PHP
184 lines
6.7 KiB
PHP
<?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;
|
|
}
|
|
}
|