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,212 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Support\BankStatementParseException;
use App\ValueObjects\Amount;
use App\ValueObjects\BankStatementRuleset;
use App\ValueObjects\BankTransaction;
use Carbon\CarbonImmutable;
/**
* Liest den CSV-Export der Bank in {@see BankTransaction}-Objekte.
*
* Zustandslos und zahlartneutral: hier wird nur gelesen, nicht bewertet. Ob ein Umsatz überhaupt
* interessiert, entscheidet das Zahlungsmodul über `isRelevantTransaction()` -- die Überweisung will
* Gutschriften, die SEPA-Lastschrift später genau andersherum Belastungen und Rücklastschriften.
*/
class BankStatementParseProvider
{
/**
* @return array<int, BankTransaction>
*
* @throws BankStatementParseException wenn eine Pflichtspalte im Export fehlt
*/
public function parse(string $contents, BankStatementRuleset $ruleset): array
{
$missing = $ruleset->missingRequiredColumns();
if ($missing !== []) {
throw new BankStatementParseException(
'Im Kontoauszug-Format fehlt die Zuordnung für: ' . implode(', ', $missing)
. '. Bitte die Spaltenzuordnung in den Zahlungsmethoden prüfen.'
);
}
$lines = $this->lines($this->toUtf8($contents, $ruleset->charset));
if ($lines === []) {
throw new BankStatementParseException('Die Datei enthält keine Daten.');
}
// Ohne Kopfzeile ließen sich die Spalten nur über ihre Position ansprechen -- dann bräche der
// Import stillschweigend, sobald die Bank eine Spalte einfügt. Lieber hier abbrechen.
if (!$ruleset->hasHeader) {
throw new BankStatementParseException('Der Export muss eine Kopfzeile mit den Spaltennamen enthalten.');
}
$indexes = $this->resolveColumnIndexes(array_shift($lines), $ruleset);
$transactions = [];
foreach ($lines as $offset => $line) {
$fields = $this->splitLine($line, $ruleset);
$date = $this->parseDate($this->field($fields, $indexes, 'payment_date'), $ruleset);
$amount = $this->parseAmount($this->field($fields, $indexes, 'amount'), $ruleset);
// Zeilen ohne lesbares Datum oder ohne Betrag sind keine Umsätze (Summenzeilen, Fußnoten,
// Leerzeilen mit Trennzeichen) -- die werden still übergangen statt den Import abzubrechen.
if ($date === null || $amount === null) {
continue;
}
$transactions[] = new BankTransaction(
paymentDate: $date,
amount: $amount,
purpose: $this->field($fields, $indexes, 'purpose'),
payerName: $this->field($fields, $indexes, 'payer_name'),
payerIban: $this->field($fields, $indexes, 'payer_iban'),
// +2: die Kopfzeile ist Zeile 1, $offset zählt ab 0 in der Restliste.
rowNumber: $offset + 2,
);
}
return $transactions;
}
/**
* Bringt den Inhalt nach UTF-8.
*
* Der Schutz gegen Doppel-Kodierung ist der wichtige Teil: liegt bereits gültiges UTF-8 mit
* Mehrbyte-Zeichen vor, wird nicht noch einmal konvertiert. Sonst würde aus „Müller" ein
* „Müller" -- und zwar unbemerkt, weil die Konvertierung technisch gelingt.
*/
private function toUtf8(string $contents, string $charset): string
{
$contents = preg_replace('/^\xEF\xBB\xBF/', '', $contents) ?? $contents;
$isUtf8 = mb_check_encoding($contents, 'UTF-8');
$hasMultiByte = strlen($contents) !== mb_strlen($contents, 'UTF-8');
if ($isUtf8 && $hasMultiByte) {
return $contents;
}
if ($charset === 'auto') {
return $isUtf8 ? $contents : (string) mb_convert_encoding($contents, 'UTF-8', 'Windows-1252');
}
if ($charset === 'UTF-8') {
return $contents;
}
return (string) mb_convert_encoding($contents, 'UTF-8', $charset);
}
/** @return array<int, string> */
private function lines(string $contents): array
{
$lines = preg_split('/\r\n|\r|\n/', $contents) ?: [];
return array_values(array_filter($lines, static fn (string $line): bool => trim($line) !== ''));
}
/**
* Fachliches Feld => Spaltenindex, aufgelöst über die Kopfzeile.
*
* @return array<string, int>
*
* @throws BankStatementParseException
*/
private function resolveColumnIndexes(string $headerLine, BankStatementRuleset $ruleset): array
{
$headers = array_map(
static fn (string $header): string => mb_strtolower(trim($header)),
$this->splitLine($headerLine, $ruleset),
);
$indexes = [];
$notFound = [];
foreach ($ruleset->columns as $field => $column) {
$index = array_search(mb_strtolower(trim($column)), $headers, true);
if ($index === false) {
if (in_array($field, BankStatementRuleset::REQUIRED_COLUMNS, true)) {
$notFound[] = $column;
}
continue;
}
$indexes[$field] = (int) $index;
}
if ($notFound !== []) {
throw new BankStatementParseException(
'Die Datei enthält keine Spalte "' . implode('", "', $notFound) . '". '
. 'Entweder wurde der falsche Export hochgeladen, oder das eingestellte '
. 'Kontoauszug-Format passt nicht zu dieser Bank.'
);
}
return $indexes;
}
/**
* @return array<int, string>
*/
private function splitLine(string $line, BankStatementRuleset $ruleset): array
{
// str_getcsv() wirft bei leerem Enclosure einen ValueError. Ohne Anführungszeichen -- so
// liefert es die GLS -- ist ein schlichtes explode() ohnehin das Richtige.
$fields = $ruleset->enclosure === ''
? explode($ruleset->delimiter, $line)
: str_getcsv($line, $ruleset->delimiter, $ruleset->enclosure, '\\');
return array_map(static fn ($field): string => trim((string) $field), $fields);
}
/**
* @param array<int, string> $fields
* @param array<string, int> $indexes
*/
private function field(array $fields, array $indexes, string $name): string
{
$index = $indexes[$name] ?? null;
return $index === null ? '' : ($fields[$index] ?? '');
}
private function parseDate(string $value, BankStatementRuleset $ruleset): ?CarbonImmutable
{
if ($value === '') {
return null;
}
$date = CarbonImmutable::createFromFormat('!' . $ruleset->dateFormat, $value);
return $date === false ? null : $date;
}
/** „1.234,56" bzw. „-56,00" -> Amount. */
private function parseAmount(string $value, BankStatementRuleset $ruleset): ?Amount
{
if ($value === '') {
return null;
}
$normalized = $value;
if ($ruleset->thousandsSeparator !== '') {
$normalized = str_replace($ruleset->thousandsSeparator, '', $normalized);
}
$normalized = str_replace($ruleset->decimalSeparator, '.', $normalized);
$normalized = str_replace(' ', '', $normalized);
if (!is_numeric($normalized)) {
return null;
}
return new Amount((float) $normalized, 'Euro');
}
}