';', 'enclosure' => '', 'charset' => 'Windows-1252', 'has_header' => true, 'date_format' => 'd.m.Y', 'decimal_separator' => ',', 'thousands_separator' => '.', 'columns' => [ 'payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag', 'payer_name' => 'Name Zahlungsbeteiligter', 'payer_iban' => 'IBAN Zahlungsbeteiligter', ], ]; return new BankStatementRuleset( delimiter: $overrides['delimiter'] ?? $base['delimiter'], enclosure: $overrides['enclosure'] ?? $base['enclosure'], charset: $overrides['charset'] ?? $base['charset'], hasHeader: $overrides['has_header'] ?? $base['has_header'], dateFormat: $overrides['date_format'] ?? $base['date_format'], decimalSeparator: $overrides['decimal_separator'] ?? $base['decimal_separator'], thousandsSeparator: $overrides['thousands_separator'] ?? $base['thousands_separator'], columns: $overrides['columns'] ?? $base['columns'], ); } /** Das Format, das die GLS heute liefert: Semikolon, keine Anführungszeichen, CRLF. */ public function test_reads_the_semicolon_export_without_enclosures(): void { $csv = "Buchungstag;Valutadatum;Name Zahlungsbeteiligter;IBAN Zahlungsbeteiligter;Verwendungszweck;Betrag\r\n" . "09.09.2026;09.09.2026;Max Meier;DE02120300000000202051;Sommerlager - Beitrag Max Meier;120,00\r\n"; $transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset()); $this->assertCount(1, $transactions); $this->assertSame('2026-09-09', $transactions[0]->paymentDate->format('Y-m-d')); $this->assertSame(120.0, $transactions[0]->amount->getAmount()); $this->assertSame('Max Meier', $transactions[0]->payerName); $this->assertSame('DE02120300000000202051', $transactions[0]->payerIban); $this->assertSame('Sommerlager - Beitrag Max Meier', $transactions[0]->purpose); // Kopfzeile ist Zeile 1. $this->assertSame(2, $transactions[0]->rowNumber); } public function test_reads_comma_separated_export_with_enclosures(): void { $csv = "\"Buchungstag\",\"Verwendungszweck\",\"Betrag\"\n" . "\"09.09.2026\",\"Beitrag, erste Rate\",\"85,50\"\n"; $transactions = new BankStatementParseProvider()->parse( $csv, $this->ruleset([ 'delimiter' => ',', 'enclosure' => '"', 'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'], ]), ); $this->assertCount(1, $transactions); // Das Komma im Zweck darf die Zeile nicht zerreißen. $this->assertSame('Beitrag, erste Rate', $transactions[0]->purpose); $this->assertSame(85.5, $transactions[0]->amount->getAmount()); } public function test_converts_windows_1252_umlauts(): void { $csv = mb_convert_encoding( "Buchungstag;Verwendungszweck;Betrag\r\n09.09.2026;Beitrag Jörg Müller;60,00\r\n", 'Windows-1252', 'UTF-8', ); $transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset([ 'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'], ])); $this->assertSame('Beitrag Jörg Müller', $transactions[0]->purpose); } /** * Der Schutz gegen Doppel-Kodierung: Liefert die Bank entgegen der Einstellung UTF-8, darf aus * „Müller" kein „Müller" werden. Die Konvertierung würde technisch gelingen -- der Fehler fiele * erst auf der Rechnung auf. */ public function test_does_not_double_encode_utf8_content(): void { $csv = "Buchungstag;Verwendungszweck;Betrag\r\n09.09.2026;Beitrag Jörg Müller;60,00\r\n"; $transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset([ 'charset' => 'Windows-1252', 'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'], ])); $this->assertSame('Beitrag Jörg Müller', $transactions[0]->purpose); } public function test_parses_german_thousands_and_negative_amounts(): void { $csv = "Buchungstag;Verwendungszweck;Betrag\n" . "09.09.2026;Grosse Zahlung;1.234,56\n" . "10.09.2026;Rueckbuchung;-56,00\n"; $transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset([ 'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'], ])); // Der Parser filtert nicht -- ob eine Belastung zählt, entscheidet das Zahlungsmodul. $this->assertCount(2, $transactions); $this->assertSame(1234.56, $transactions[0]->amount->getAmount()); $this->assertSame(-56.0, $transactions[1]->amount->getAmount()); $this->assertTrue($transactions[0]->isCredit()); $this->assertFalse($transactions[1]->isCredit()); } /** Summenzeilen und Fußnoten sind keine Umsätze und dürfen den Import nicht abbrechen. */ public function test_skips_rows_without_date_or_amount(): void { $csv = "Buchungstag;Verwendungszweck;Betrag\n" . "09.09.2026;Beitrag;120,00\n" . ";Summe;;\n" . "10.09.2026;Ohne Betrag;\n"; $transactions = new BankStatementParseProvider()->parse($csv, $this->ruleset([ 'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'], ])); $this->assertCount(1, $transactions); } /** Der Normalfall bei falsch eingestelltem Ruleset -- die Meldung muss die Spalte nennen. */ public function test_names_the_missing_column(): void { $csv = "Buchungstag;Verwendungszweck\n09.09.2026;Beitrag\n"; $this->expectException(BankStatementParseException::class); $this->expectExceptionMessageMatches('/Betrag/'); new BankStatementParseProvider()->parse($csv, $this->ruleset([ 'columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck', 'amount' => 'Betrag'], ])); } public function test_rejects_a_ruleset_without_required_mapping(): void { $this->expectException(BankStatementParseException::class); $this->expectExceptionMessageMatches('/amount/'); new BankStatementParseProvider()->parse( "Buchungstag;Verwendungszweck\n", $this->ruleset(['columns' => ['payment_date' => 'Buchungstag', 'purpose' => 'Verwendungszweck']]), ); } }