Zahlungsparser
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ValueObjects;
|
||||
|
||||
/**
|
||||
* Wie ist der CSV-Export der Bank aufgebaut? Trennzeichen, Anführungszeichen, Zeichensatz, Zahl- und
|
||||
* Datumsformat sowie die Zuordnung fachliches Feld -> Spaltenüberschrift.
|
||||
*
|
||||
* Zahlartneutral und damit bewusst außerhalb von {@see \App\EventPaymentModules}: Überweisung und
|
||||
* (künftig) SEPA-Lastschrift lesen denselben Kontoauszug, nur werten sie ihn verschieden aus.
|
||||
*
|
||||
* Ein Tenant überschreibt das Ruleset **ganz oder gar nicht** ({@see fromConfiguration()}). Ein
|
||||
* feldweiser Merge wäre die schlechtere Zusage: wer das Trennzeichen umstellt, weil seine Bank ein
|
||||
* anderes Format liefert, bekäme sonst weiterhin die Spaltennamen der GLS untergeschoben und suchte
|
||||
* den Fehler an der falschen Stelle.
|
||||
*/
|
||||
final readonly class BankStatementRuleset
|
||||
{
|
||||
/** Fachliche Felder, ohne die sich eine Zahlung nicht verarbeiten lässt. */
|
||||
public const array REQUIRED_COLUMNS = ['payment_date', 'purpose', 'amount'];
|
||||
|
||||
/** Auswählbare Zeichensätze; 'auto' probiert UTF-8 und fällt sonst auf Windows-1252 zurück. */
|
||||
public const array CHARSETS = ['auto', 'UTF-8', 'Windows-1252', 'ISO-8859-15'];
|
||||
|
||||
/**
|
||||
* @param array<string, string> $columns fachliches Feld => Spaltenüberschrift
|
||||
*/
|
||||
public function __construct(
|
||||
public string $delimiter,
|
||||
public string $enclosure,
|
||||
public string $charset,
|
||||
public bool $hasHeader,
|
||||
public string $dateFormat,
|
||||
public string $decimalSeparator,
|
||||
public string $thousandsSeparator,
|
||||
public array $columns,
|
||||
) {
|
||||
}
|
||||
|
||||
/** Der app-weite Standard aus config/bankStatement.php. */
|
||||
public static function default(): self
|
||||
{
|
||||
return self::fromArray((array) config('bankStatement.default_ruleset', []));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruleset aus einer Modul-Konfiguration. Leerer/fehlender Override -> App-Standard.
|
||||
*
|
||||
* @param array<string, mixed>|null $override
|
||||
*/
|
||||
public static function fromConfiguration(?array $override): self
|
||||
{
|
||||
if ($override === null || $override === []) {
|
||||
return self::default();
|
||||
}
|
||||
|
||||
return self::fromArray($override);
|
||||
}
|
||||
|
||||
/**
|
||||
* Einzelne fehlende Schlüssel werden aus dem Config-Standard ergänzt -- das ist kein Widerspruch
|
||||
* zum „ganz oder gar nicht": greift nur, wenn überhaupt ein Override existiert, und verhindert,
|
||||
* dass ein unvollständig gespeichertes Formular einen leeren Delimiter erzeugt.
|
||||
*
|
||||
* @param array<string, mixed> $values
|
||||
*/
|
||||
private static function fromArray(array $values): self
|
||||
{
|
||||
$fallback = (array) config('bankStatement.default_ruleset', []);
|
||||
|
||||
$columns = (array) ($values['columns'] ?? $fallback['columns'] ?? []);
|
||||
$columns = array_filter(
|
||||
array_map(static fn ($column): string => trim((string) $column), $columns),
|
||||
static fn (string $column): bool => $column !== '',
|
||||
);
|
||||
|
||||
$charset = (string) ($values['charset'] ?? $fallback['charset'] ?? 'auto');
|
||||
|
||||
return new self(
|
||||
delimiter: self::firstNonEmpty($values['delimiter'] ?? null, $fallback['delimiter'] ?? null, ';'),
|
||||
// Der Leerstring ist hier ein gültiger Wert (keine Anführungszeichen) und darf nicht
|
||||
// durch einen Fallback ersetzt werden.
|
||||
enclosure: (string) ($values['enclosure'] ?? $fallback['enclosure'] ?? ''),
|
||||
charset: in_array($charset, self::CHARSETS, true) ? $charset : 'auto',
|
||||
hasHeader: (bool) ($values['has_header'] ?? $fallback['has_header'] ?? true),
|
||||
dateFormat: self::firstNonEmpty($values['date_format'] ?? null, $fallback['date_format'] ?? null, 'd.m.Y'),
|
||||
decimalSeparator: self::firstNonEmpty($values['decimal_separator'] ?? null, $fallback['decimal_separator'] ?? null, ','),
|
||||
thousandsSeparator: (string) ($values['thousands_separator'] ?? $fallback['thousands_separator'] ?? '.'),
|
||||
columns: $columns,
|
||||
);
|
||||
}
|
||||
|
||||
private static function firstNonEmpty(mixed ...$candidates): string
|
||||
{
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($candidate !== null && (string) $candidate !== '') {
|
||||
return (string) $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Spaltenüberschrift für ein fachliches Feld, oder null wenn nicht gemappt. */
|
||||
public function column(string $field): ?string
|
||||
{
|
||||
return $this->columns[$field] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pflichtfelder, für die keine Spalte gemappt ist.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function missingRequiredColumns(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
self::REQUIRED_COLUMNS,
|
||||
fn (string $field): bool => $this->column($field) === null,
|
||||
));
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'delimiter' => $this->delimiter,
|
||||
'enclosure' => $this->enclosure,
|
||||
'charset' => $this->charset,
|
||||
'has_header' => $this->hasHeader,
|
||||
'date_format' => $this->dateFormat,
|
||||
'decimal_separator' => $this->decimalSeparator,
|
||||
'thousands_separator' => $this->thousandsSeparator,
|
||||
'columns' => $this->columns,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user