213 lines
7.4 KiB
PHP
213 lines
7.4 KiB
PHP
<?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');
|
|
}
|
|
}
|