* * @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 */ 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 * * @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 */ 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 $fields * @param array $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'); } }