Files
mareike/app/ValueObjects/BankStatementRuleset.php
2026-09-09 16:55:08 +02:00

140 lines
5.3 KiB
PHP

<?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,
];
}
}