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