From 3132196ad6e21ad5df8e92ccb9538c6bbec51b15 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sat, 16 May 2026 07:30:46 +0200 Subject: [PATCH 1/3] =?UTF-8?q?Refactor:=20Code-Qualit=C3=A4t,=20Sicherhei?= =?UTF-8?q?t=20und=20Konsistenz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Changes.md | 15 ++ docs/funktionen.md | 15 +- src/Auth.php | 23 ++ src/Constants.php | 22 ++ src/Controllers/AccountController.php | 11 +- src/Controllers/AssetController.php | 11 +- src/Controllers/BankController.php | 14 +- src/Controllers/BaseController.php | 16 ++ src/Controllers/BelegController.php | 37 +-- src/Controllers/BookingController.php | 11 +- src/Controllers/CustomerController.php | 11 +- src/Controllers/EuerController.php | 11 +- src/Controllers/InvoiceController.php | 11 +- src/Controllers/SettingsController.php | 23 +- src/Controllers/SupplierController.php | 11 +- src/Controllers/UserAdminController.php | 11 +- src/Controllers/UstvaController.php | 11 +- src/Database.php | 18 +- src/Models/Booking.php | 293 +++++++++++++----------- src/Router.php | 7 + src/Services/ImageCropService.php | 8 +- src/View.php | 9 + src/Views/layout/base.php | 27 +++ src/Views/login.php | 1 + src/version.txt | 2 +- tests/32_BankTransactionTest.php | 125 ++++++++++ tests/33_BankImportParserTest.php | 123 ++++++++++ 27 files changed, 609 insertions(+), 268 deletions(-) create mode 100644 src/Constants.php create mode 100644 src/Controllers/BaseController.php create mode 100644 tests/32_BankTransactionTest.php create mode 100644 tests/33_BankImportParserTest.php diff --git a/Changes.md b/Changes.md index 0a9fc3e..d472723 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,21 @@ --- +## 2026-05-16 – Refactor: Code-Qualität, Sicherheit und Konsistenz + +- **CSRF-Schutz**: `Auth::generateCsrfToken()` / `validateCsrfToken()` neu; Router validiert Token bei allen POST/PUT/DELETE-Requests; base.php injiziert Token in Formulare und fetch-Aufrufe via JS; login.php erhält hidden Input +- **Path Traversal** in `BelegController::serveFile()` abgesichert (`realpath()` + Prefix-Check gegen Upload-Basisverzeichnis) +- **`mime_content_type()`** (deprecated) durch `finfo_file(finfo_open(FILEINFO_MIME_TYPE), ...)` in `BelegController`, `SettingsController`, `BankController` und `ImageCropService` ersetzt +- **XSS** in `topbarActions`: `View::buildQuery()` (neuer Helper) statt rohem `http_build_query($_GET)` +- **`@`-Unterdrückung** in `SettingsController` entfernt (ersetzt durch `file_exists()`-Prüfung) +- **BaseController**: gemeinsame `renderView()`-Methode zentralisiert; alle 13 Controller erben davon; lokale Duplikate entfernt +- **DB-Transaktionen**: `Booking::create()` und `Booking::reverse()` laufen jetzt in atomaren Transaktionen; `Database::beginTransaction/commit/rollBack` unterstützt verschachtelte Aufrufe via Tiefenzähler (TestCase-Isolation bleibt intakt) +- **`src/Constants.php`**: Buchungstypen und Steuerkontonummern (SKR03) als benannte Konstanten; Magic Strings in `Booking.php` ersetzt +- **Dokumentation**: `docs/funktionen.md` – Abschnitt „Bank buchen" ergänzt; UStVA-Kennzahlen (81, 89, 66, 61, 83) korrigiert; Eigenanteil-Konten (4920/4925) korrigiert +- **Tests**: `32_BankTransactionTest` (linkBeleg, unlinkBeleg, recalcStatus, findDuplicates) und `33_BankImportParserTest` (CSV-Parsing, BOM, Tausendertrenner, Fehlerfall) neu + +--- + ## 2026-05-15 – Feature: Bankbuchung auf mehrere Belege aufteilen - Migration 053: Junction-Tabelle `bank_transaction_belege` (bank_tx_id, beleg_id, betrag); `bank_transactions.beleg_id` entfernt; Status-ENUM um `teilweise` erweitert; bestehende Verknüpfungen automatisch migriert diff --git a/docs/funktionen.md b/docs/funktionen.md index 9a02da2..0845f8a 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -25,7 +25,7 @@ EÜR-Buchungsmodell (Einnahme/Ausgabe) mit Kategorie- und Zahlungskonto. - **Steuerarten** für Auslandsumsätze: Inland, steuerfreie ig. Lieferung (Kz 41), steuerfreie Ausfuhr (Kz 44), ig. Erwerb 19 % (Kz 89/91), ig. Erwerb 7 % (Kz 93/94) - **Bewirtungsausgaben**: automatischer Split 70 %/30 % bei Konten 4650, 4651, 4652 -- **Eigenanteil** (30 %) bei Telefon/Internet (Konto 4940): optional zubuchbar +- **Eigenanteil** (30 %) bei Telefon/Internet (Konten 4920/4925): optional zubuchbar - **Stornobuchungen**: GoBD-konforme Gegenbuchung statt Löschung - **GoBD-Schutz**: MariaDB `BEFORE UPDATE/DELETE`-Trigger verhindern nachträgliche Manipulation gebuchter Sätze - **Journal**: gefilterte Übersicht aller Buchungen mit CSV-Export @@ -62,9 +62,20 @@ Anlagenspiegel für abnutzbare Wirtschaftsgüter – siehe [anlagevermögen.md]( --- +## Bank buchen + +- Import von Kontoauszügen: CSV, TXT und XLSX (konfigurierbare Spalten- und Datumsformate je Bank) +- **Dublettenprüfung**: beim Import werden Zeilen, die bereits in der DB vorhanden sind, als Duplikate markiert und nicht erneut importiert +- **Beleg-Zuordnung**: jede Banktransaktion kann einem oder mehreren Belegen zugeordnet werden; der zugeordnete Betrag wird separat erfasst +- **Teilzuordnung**: eine Transaktion kann schrittweise mehreren Belegen anteilig zugeordnet werden +- **Status-Logik**: `offen` → keine Zuordnung, `teilweise` → Restsaldo > 0,01 €, `gebucht` → vollständig zugeordnet +- Direkter Beleg-Upload aus der Transaktionsansicht heraus (erzeugt neuen Beleg und verknüpft ihn) + +--- + ## UStVA (Umsatzsteuervoranmeldung) -- Monatliche Kennzahlen-Berechnung aus Buchungen (Kz 81, 83, 86, 84, 35, 41, 44, 61, 62, 67, 66, 69) +- Monatliche Kennzahlen-Berechnung aus Buchungen (Kz 81, 89, 66, 61, 83) - ELSTER XML-Export - Drill-down: Klick auf eine Kennzahl zeigt alle zugehörigen Buchungszeilen diff --git a/src/Auth.php b/src/Auth.php index a00e8d6..43e7700 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -52,4 +52,27 @@ class Auth { return isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null; } + + public static function generateCsrfToken(): string + { + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } + if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + return $_SESSION['csrf_token']; + } + + public static function validateCsrfToken(string $token): void + { + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } + $stored = $_SESSION['csrf_token'] ?? ''; + if ($stored === '' || !hash_equals($stored, $token)) { + http_response_code(403); + exit('CSRF-Token ungültig.'); + } + } } diff --git a/src/Constants.php b/src/Constants.php new file mode 100644 index 0000000..1f5d4e8 --- /dev/null +++ b/src/Constants.php @@ -0,0 +1,22 @@ + 'xml', ]; - $mime = mime_content_type($file['tmp_name']) ?: ''; + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = (string)(finfo_file($finfo, $file['tmp_name']) ?: ''); + finfo_close($finfo); if (!isset($allowedMime[$mime])) { View::redirect("/bank/$id", 'Nicht erlaubtes Dateiformat.', 'error'); } @@ -303,12 +305,4 @@ class BankController View::redirect("/bank/$id", 'Beleg hochgeladen – bitte jetzt verknüpfen.'); } - private function renderView(string $name, array $data = []): string - { - $viewFile = dirname(__DIR__) . '/Views/' . $name . '.php'; - extract($data, EXTR_SKIP); - ob_start(); - include $viewFile; - return ob_get_clean(); - } } diff --git a/src/Controllers/BaseController.php b/src/Controllers/BaseController.php new file mode 100644 index 0000000..7d22093 --- /dev/null +++ b/src/Controllers/BaseController.php @@ -0,0 +1,16 @@ + 'pdf', @@ -45,7 +45,7 @@ class BelegController 'page' => $page, 'filter' => $filter, ]), - 'topbarActions' => 'CSV Export + Beleg hochladen', + 'topbarActions' => 'CSV Export + Beleg hochladen', ]); } @@ -114,8 +114,10 @@ class BelegController $errors['file'] = 'Keine gültige Datei übermittelt.'; } else { $file = $_FILES['beleg_file']; - $mime = mime_content_type($file['tmp_name']); - $mimeKey = strtolower($mime); + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = finfo_file($finfo, $file['tmp_name']); + finfo_close($finfo); + $mimeKey = strtolower((string)$mime); if (!isset(self::ALLOWED_MIME[$mimeKey])) { $errors['file'] = 'Nicht erlaubter Dateityp. Erlaubt: PDF, PNG, JPEG, TIFF, XML.'; @@ -141,7 +143,9 @@ class BelegController } $file = $_FILES['beleg_file']; - $mime = mime_content_type($file['tmp_name']); + $finfo2 = finfo_open(FILEINFO_MIME_TYPE); + $mime = (string)finfo_file($finfo2, $file['tmp_name']); + finfo_close($finfo2); $ext = self::ALLOWED_MIME[strtolower($mime)]; $belegNr = Beleg::generateBelegNr(); $year = !empty($_POST['beleg_datum']) @@ -300,13 +304,20 @@ class BelegController exit; } - $absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path']; - if (!file_exists($absPath)) { http_response_code(404); return; } + $basePath = (string)realpath(dirname(__DIR__, 2) . '/public/static/uploads'); + $absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path']; + $realPath = realpath($absPath); + + if ($realPath === false || !str_starts_with($realPath, $basePath)) { + http_response_code(403); + return; + } + if (!file_exists($realPath)) { http_response_code(404); return; } header('Content-Type: ' . $beleg['mime_type']); header('Content-Disposition: inline; filename="' . $beleg['filename'] . '"'); - header('Content-Length: ' . filesize($absPath)); - readfile($absPath); + header('Content-Length: ' . filesize($realPath)); + readfile($realPath); } public function updateMeta(array $params): void @@ -440,12 +451,4 @@ class BelegController } return $value; } - - private function renderView(string $template, array $data): string - { - extract($data, EXTR_SKIP); - ob_start(); - include dirname(__DIR__) . '/Views/' . $template . '.php'; - return ob_get_clean(); - } } diff --git a/src/Controllers/BookingController.php b/src/Controllers/BookingController.php index d3c8cbe..fa1d4e8 100644 --- a/src/Controllers/BookingController.php +++ b/src/Controllers/BookingController.php @@ -12,7 +12,7 @@ use Steuerkram\Models\FiscalYear; use Steuerkram\Models\Setting; use Steuerkram\View; -class BookingController +class BookingController extends BaseController { public function journal(array $params): void { @@ -324,12 +324,5 @@ class BookingController } return $value; } - - private function renderView(string $template, array $data): string - { - extract($data, EXTR_SKIP); - ob_start(); - include dirname(__DIR__) . '/Views/' . $template . '.php'; - return ob_get_clean(); - } } + diff --git a/src/Controllers/CustomerController.php b/src/Controllers/CustomerController.php index 705d4e4..183fa86 100644 --- a/src/Controllers/CustomerController.php +++ b/src/Controllers/CustomerController.php @@ -7,7 +7,7 @@ namespace Steuerkram\Controllers; use Steuerkram\Models\Customer; use Steuerkram\View; -class CustomerController +class CustomerController extends BaseController { public function index(array $params): void { @@ -137,12 +137,5 @@ class CustomerController if (empty($data['ort'])) $errors['ort'] = 'Pflichtfeld'; return $errors; } - - private function renderView(string $template, array $data): string - { - extract($data, EXTR_SKIP); - ob_start(); - include dirname(__DIR__) . '/Views/' . $template . '.php'; - return ob_get_clean(); - } } + diff --git a/src/Controllers/EuerController.php b/src/Controllers/EuerController.php index d7da4b8..11d90c8 100644 --- a/src/Controllers/EuerController.php +++ b/src/Controllers/EuerController.php @@ -10,7 +10,7 @@ use Steuerkram\Services\EuerCalculator; use Steuerkram\Services\PdfEuerService; use Steuerkram\View; -class EuerController +class EuerController extends BaseController { public function index(array $params): void { @@ -70,12 +70,5 @@ class EuerController $svc = new PdfEuerService(); $svc->render($euer, $settings); } - - private function renderView(string $template, array $data): string - { - extract($data, EXTR_SKIP); - ob_start(); - include dirname(__DIR__) . '/Views/' . $template . '.php'; - return ob_get_clean(); - } } + diff --git a/src/Controllers/InvoiceController.php b/src/Controllers/InvoiceController.php index bcd84e2..3c8408a 100644 --- a/src/Controllers/InvoiceController.php +++ b/src/Controllers/InvoiceController.php @@ -14,7 +14,7 @@ use Steuerkram\Services\EmailService; use Steuerkram\Services\PdfInvoiceService; use Steuerkram\View; -class InvoiceController +class InvoiceController extends BaseController { public function index(array $params): void { @@ -581,12 +581,5 @@ class InvoiceController } return $errors; } - - private function renderView(string $template, array $data): string - { - extract($data, EXTR_SKIP); - ob_start(); - include dirname(__DIR__) . '/Views/' . $template . '.php'; - return ob_get_clean(); - } } + diff --git a/src/Controllers/SettingsController.php b/src/Controllers/SettingsController.php index 56aaaf2..863cf5d 100644 --- a/src/Controllers/SettingsController.php +++ b/src/Controllers/SettingsController.php @@ -8,12 +8,13 @@ use Steuerkram\Models\Setting; use Steuerkram\Services\EmailService; use Steuerkram\View; -class SettingsController +class SettingsController extends BaseController { public function show(array $params): void { - $settings = Setting::get(); - $version = trim(@file_get_contents(dirname(__DIR__) . '/version.txt') ?: ''); + $settings = Setting::get(); + $versionFile = dirname(__DIR__) . '/version.txt'; + $version = file_exists($versionFile) ? trim(file_get_contents($versionFile)) : ''; $view = new View(); $view->render('layout/base.php', [ @@ -38,7 +39,8 @@ class SettingsController if (empty($data['ort'])) $errors['ort'] = 'Pflichtfeld'; if ($errors) { - $version = trim(@file_get_contents(dirname(__DIR__) . '/version.txt') ?: ''); + $vf = dirname(__DIR__) . '/version.txt'; + $version = file_exists($vf) ? trim(file_get_contents($vf)) : ''; $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Einstellungen', @@ -62,8 +64,10 @@ class SettingsController return; } - $file = $_FILES['logo']; - $mime = mime_content_type($file['tmp_name']); + $file = $_FILES['logo']; + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = (string)finfo_file($finfo, $file['tmp_name']); + finfo_close($finfo); if (!in_array($mime, ['image/png', 'image/jpeg'])) { View::redirect('/settings', 'Logo muss PNG oder JPEG sein.', 'error'); @@ -134,11 +138,4 @@ class SettingsController View::redirect('/settings', 'Logo gelöscht.'); } - private function renderView(string $template, array $data): string - { - extract($data, EXTR_SKIP); - ob_start(); - include dirname(__DIR__) . '/Views/' . $template . '.php'; - return ob_get_clean(); - } } diff --git a/src/Controllers/SupplierController.php b/src/Controllers/SupplierController.php index df1ab2f..5fb0cda 100644 --- a/src/Controllers/SupplierController.php +++ b/src/Controllers/SupplierController.php @@ -7,7 +7,7 @@ namespace Steuerkram\Controllers; use Steuerkram\Models\Supplier; use Steuerkram\View; -class SupplierController +class SupplierController extends BaseController { public function index(array $params): void { @@ -133,12 +133,5 @@ class SupplierController header('Content-Type: application/json'); echo json_encode(array_map(fn($s) => ['id' => $s['id'], 'name' => $s['name']], $suppliers)); } - - private function renderView(string $template, array $data): string - { - extract($data, EXTR_SKIP); - ob_start(); - include dirname(__DIR__) . '/Views/' . $template . '.php'; - return ob_get_clean(); - } } + diff --git a/src/Controllers/UserAdminController.php b/src/Controllers/UserAdminController.php index a44072d..9dbc2a3 100644 --- a/src/Controllers/UserAdminController.php +++ b/src/Controllers/UserAdminController.php @@ -7,7 +7,7 @@ namespace Steuerkram\Controllers; use Steuerkram\Models\User; use Steuerkram\View; -class UserAdminController +class UserAdminController extends BaseController { public function index(array $params): void { @@ -140,12 +140,5 @@ class UserAdminController $msg = $newState ? 'Benutzer aktiviert.' : 'Benutzer deaktiviert.'; View::redirect('/useradmin', $msg); } - - private function renderView(string $template, array $data): string - { - extract($data, EXTR_SKIP); - ob_start(); - include dirname(__DIR__) . '/Views/' . $template . '.php'; - return ob_get_clean(); - } } + diff --git a/src/Controllers/UstvaController.php b/src/Controllers/UstvaController.php index 1671a75..28d0ade 100644 --- a/src/Controllers/UstvaController.php +++ b/src/Controllers/UstvaController.php @@ -10,7 +10,7 @@ use Steuerkram\Services\UstvaCalculator; use Steuerkram\Services\ElsterXmlService; use Steuerkram\View; -class UstvaController +class UstvaController extends BaseController { public function index(array $params): void { @@ -148,12 +148,5 @@ class UstvaController header('Content-Length: ' . strlen($xml)); echo $xml; } - - private function renderView(string $template, array $data): string - { - extract($data, EXTR_SKIP); - ob_start(); - include dirname(__DIR__) . '/Views/' . $template . '.php'; - return ob_get_clean(); - } } + diff --git a/src/Database.php b/src/Database.php index fad5d65..85f706a 100644 --- a/src/Database.php +++ b/src/Database.php @@ -10,6 +10,7 @@ use PDOException; class Database { private static ?PDO $instance = null; + private static int $txDepth = 0; public static function get(): PDO { @@ -62,16 +63,27 @@ class Database public static function beginTransaction(): void { - self::get()->beginTransaction(); + if (self::$txDepth === 0) { + self::get()->beginTransaction(); + } + self::$txDepth++; } public static function commit(): void { - self::get()->commit(); + if (self::$txDepth > 0) { + self::$txDepth--; + } + if (self::$txDepth === 0 && self::get()->inTransaction()) { + self::get()->commit(); + } } public static function rollBack(): void { - self::get()->rollBack(); + self::$txDepth = 0; + if (self::get()->inTransaction()) { + self::get()->rollBack(); + } } } diff --git a/src/Models/Booking.php b/src/Models/Booking.php index 5b18989..0f1011f 100644 --- a/src/Models/Booking.php +++ b/src/Models/Booking.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Steuerkram\Models; +use Steuerkram\Constants; use Steuerkram\Database; class Booking @@ -112,88 +113,52 @@ class Booking $assetId = isset($data['asset_id']) && $data['asset_id'] !== '' ? (int)$data['asset_id'] : null; - Database::execute( - 'INSERT INTO bookings (buchungs_nr, beleg_id, asset_id, buchungs_datum, buchungstyp, - kategorie_konto_id, zahlungskonto_id, - betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, - buchungstext, fiscal_year_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - [ - $nr, - (int)$data['beleg_id'], - $assetId, - $data['buchungs_datum'], - $data['buchungstyp'], - (int)$data['kategorie_konto_id'], - (int)$data['zahlungskonto_id'], - $netto, - (float)($data['ust_satz'] ?? 0), - $steuerart, - $ust, - $brutto, - $data['buchungstext'], - $fy['id'], - ] - ); + Database::beginTransaction(); + try { + Database::execute( + 'INSERT INTO bookings (buchungs_nr, beleg_id, asset_id, buchungs_datum, buchungstyp, + kategorie_konto_id, zahlungskonto_id, + betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, + buchungstext, fiscal_year_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + $nr, + (int)$data['beleg_id'], + $assetId, + $data['buchungs_datum'], + $data['buchungstyp'], + (int)$data['kategorie_konto_id'], + (int)$data['zahlungskonto_id'], + $netto, + (float)($data['ust_satz'] ?? 0), + $steuerart, + $ust, + $brutto, + $data['buchungstext'], + $fy['id'], + ] + ); - $mainId = (int)Database::lastInsertId(); + $mainId = (int)Database::lastInsertId(); - // VSt-/USt-Buchungszeile für normale Buchungen mit 7 % oder 19 % anlegen - $ustSatzInt = (int)round((float)($data['ust_satz'] ?? 0)); - if (in_array($data['buchungstyp'], ['Ausgabe', 'Einnahme'], true) - && in_array($ustSatzInt, [7, 19], true) - && $steuerart === 'normal' - && abs($ust) > 0.0 - ) { - $isAusgabe = $data['buchungstyp'] === 'Ausgabe'; - if ($isAusgabe) { - $steuerKontonummer = $ustSatzInt === 19 ? '1576' : '1571'; - $steuerText = 'Vorsteuer zu ' . $nr; - } else { - $steuerKontonummer = $ustSatzInt === 19 ? '1776' : '1771'; - $steuerText = 'USt zu ' . $nr; - } - $steuerAcc = Account::findByKontonummer($steuerKontonummer); - if ($steuerAcc) { - $steuerNr = self::generateBuchungsNr(); - Database::execute( - 'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp, - kategorie_konto_id, zahlungskonto_id, - betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, - buchungstext, fiscal_year_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - [ - $steuerNr, - (int)$data['beleg_id'], - $mainId, - $data['buchungs_datum'], - $data['buchungstyp'], - (int)$steuerAcc['id'], - (int)$data['zahlungskonto_id'], - $ust, - 0.0, - 'normal', - 0.0, - $ust, - $steuerText, - $fy['id'], - ] - ); - } - } - - // ig. Erwerb 19 % ReverseCharge: Kindzeilen auf 1574 (VSt, Kz 67) und 1774 (USt, Kz 91) - if ($data['buchungstyp'] === 'Ausgabe' - && $steuerart === 'ig_erwerb_19' - && abs($netto) > 0.0 - ) { - $igeUst = round($netto * 0.19, 2); - foreach ([ - ['1574', 'VSt ig.E. zu '], - ['1774', 'USt ig.E. zu '], - ] as [$kontonr, $prefix]) { - $igeAcc = Account::findByKontonummer($kontonr); - if ($igeAcc) { + // VSt-/USt-Buchungszeile für normale Buchungen mit 7 % oder 19 % anlegen + $ustSatzInt = (int)round((float)($data['ust_satz'] ?? 0)); + if (in_array($data['buchungstyp'], [Constants::BUCHUNGSTYP_AUSGABE, Constants::BUCHUNGSTYP_EINNAHME], true) + && in_array($ustSatzInt, [7, 19], true) + && $steuerart === 'normal' + && abs($ust) > 0.0 + ) { + $isAusgabe = $data['buchungstyp'] === Constants::BUCHUNGSTYP_AUSGABE; + if ($isAusgabe) { + $steuerKontonummer = $ustSatzInt === 19 ? Constants::KONTO_VST_19 : Constants::KONTO_VST_7; + $steuerText = 'Vorsteuer zu ' . $nr; + } else { + $steuerKontonummer = $ustSatzInt === 19 ? Constants::KONTO_UST_19 : Constants::KONTO_UST_7; + $steuerText = 'USt zu ' . $nr; + } + $steuerAcc = Account::findByKontonummer($steuerKontonummer); + if ($steuerAcc) { + $steuerNr = self::generateBuchungsNr(); Database::execute( 'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp, kategorie_konto_id, zahlungskonto_id, @@ -201,24 +166,68 @@ class Booking buchungstext, fiscal_year_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ - self::generateBuchungsNr(), + $steuerNr, (int)$data['beleg_id'], $mainId, $data['buchungs_datum'], - 'Ausgabe', - (int)$igeAcc['id'], + $data['buchungstyp'], + (int)$steuerAcc['id'], (int)$data['zahlungskonto_id'], - $igeUst, + $ust, 0.0, 'normal', 0.0, - $igeUst, - $prefix . $nr, + $ust, + $steuerText, $fy['id'], ] ); } } + + // ig. Erwerb 19 % ReverseCharge: Kindzeilen auf VST_IGE_19 (Kz 67) und UST_IGE_19 (Kz 91) + if ($data['buchungstyp'] === Constants::BUCHUNGSTYP_AUSGABE + && $steuerart === 'ig_erwerb_19' + && abs($netto) > 0.0 + ) { + $igeUst = round($netto * 0.19, 2); + foreach ([ + [Constants::KONTO_VST_IGE_19, 'VSt ig.E. zu '], + [Constants::KONTO_UST_IGE_19, 'USt ig.E. zu '], + ] as [$kontonr, $prefix]) { + $igeAcc = Account::findByKontonummer($kontonr); + if ($igeAcc) { + Database::execute( + 'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp, + kategorie_konto_id, zahlungskonto_id, + betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, + buchungstext, fiscal_year_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + self::generateBuchungsNr(), + (int)$data['beleg_id'], + $mainId, + $data['buchungs_datum'], + Constants::BUCHUNGSTYP_AUSGABE, + (int)$igeAcc['id'], + (int)$data['zahlungskonto_id'], + $igeUst, + 0.0, + 'normal', + 0.0, + $igeUst, + $prefix . $nr, + $fy['id'], + ] + ); + } + } + } + + Database::commit(); + } catch (\Throwable $e) { + Database::rollBack(); + throw $e; } return $mainId; @@ -246,65 +255,71 @@ class Booking $nr = self::generateBuchungsNr(); - Database::execute( - 'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, buchungstyp, - kategorie_konto_id, zahlungskonto_id, - betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, buchungstext, is_reversal, fiscal_year_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)', - [ - $nr, - $reversalData['beleg_id'], - $reversalData['buchungs_datum'], - $reversalData['buchungstyp'], - $reversalData['kategorie_konto_id'], - $reversalData['zahlungskonto_id'], - -1 * (float)$orig['betrag_netto'], - $orig['ust_satz'], - $orig['steuerart'], - -1 * (float)$orig['betrag_ust'], - -1 * (float)$orig['betrag_brutto'], - $reversalData['buchungstext'], - $fy['id'], - ] - ); - - $reversalId = (int)Database::lastInsertId(); - - // Link original to reversal - Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$reversalId, $id]); - - // Verknüpfte VSt-Buchungszeilen ebenfalls stornieren - $vstBookings = Database::fetchAll( - 'SELECT * FROM bookings WHERE parent_booking_id = ? AND reversed_by IS NULL', - [$id] - ); - foreach ($vstBookings as $vst) { - $vstNr = self::generateBuchungsNr(); + Database::beginTransaction(); + try { Database::execute( - 'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp, + 'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, buchungstyp, kategorie_konto_id, zahlungskonto_id, - betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, - buchungstext, is_reversal, fiscal_year_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)', + betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, buchungstext, is_reversal, fiscal_year_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)', [ - $vstNr, - $vst['beleg_id'], - $reversalId, - date('Y-m-d'), - $vst['buchungstyp'], - $vst['kategorie_konto_id'], - $vst['zahlungskonto_id'], - -1 * (float)$vst['betrag_netto'], - $vst['ust_satz'], - $vst['steuerart'], - -1 * (float)$vst['betrag_ust'], - -1 * (float)$vst['betrag_brutto'], - 'STORNO zu ' . $vst['buchungs_nr'] . ': ' . $reason, + $nr, + $reversalData['beleg_id'], + $reversalData['buchungs_datum'], + $reversalData['buchungstyp'], + $reversalData['kategorie_konto_id'], + $reversalData['zahlungskonto_id'], + -1 * (float)$orig['betrag_netto'], + $orig['ust_satz'], + $orig['steuerart'], + -1 * (float)$orig['betrag_ust'], + -1 * (float)$orig['betrag_brutto'], + $reversalData['buchungstext'], $fy['id'], ] ); - $vstReversalId = (int)Database::lastInsertId(); - Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$vstReversalId, $vst['id']]); + + $reversalId = (int)Database::lastInsertId(); + + Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$reversalId, $id]); + + $vstBookings = Database::fetchAll( + 'SELECT * FROM bookings WHERE parent_booking_id = ? AND reversed_by IS NULL', + [$id] + ); + foreach ($vstBookings as $vst) { + $vstNr = self::generateBuchungsNr(); + Database::execute( + 'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp, + kategorie_konto_id, zahlungskonto_id, + betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, + buchungstext, is_reversal, fiscal_year_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)', + [ + $vstNr, + $vst['beleg_id'], + $reversalId, + date('Y-m-d'), + $vst['buchungstyp'], + $vst['kategorie_konto_id'], + $vst['zahlungskonto_id'], + -1 * (float)$vst['betrag_netto'], + $vst['ust_satz'], + $vst['steuerart'], + -1 * (float)$vst['betrag_ust'], + -1 * (float)$vst['betrag_brutto'], + 'STORNO zu ' . $vst['buchungs_nr'] . ': ' . $reason, + $fy['id'], + ] + ); + $vstReversalId = (int)Database::lastInsertId(); + Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$vstReversalId, $vst['id']]); + } + + Database::commit(); + } catch (\Throwable $e) { + Database::rollBack(); + throw $e; } return $reversalId; diff --git a/src/Router.php b/src/Router.php index f1f8d51..5c1db29 100644 --- a/src/Router.php +++ b/src/Router.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Steuerkram; +use Steuerkram\Auth; + class Router { private array $routes = []; @@ -52,6 +54,11 @@ class Router $uri = '/' . trim($uri, '/'); if ($uri === '') $uri = '/'; + if (in_array($method, ['POST', 'PUT', 'DELETE', 'PATCH'], true)) { + $token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; + Auth::validateCsrfToken($token); + } + foreach ($this->routes as $route) { if ($route['method'] !== $method) continue; diff --git a/src/Services/ImageCropService.php b/src/Services/ImageCropService.php index a1eb095..91e08ff 100644 --- a/src/Services/ImageCropService.php +++ b/src/Services/ImageCropService.php @@ -18,7 +18,9 @@ class ImageCropService */ public function cropToRect(string $path, int $x, int $y, int $w, int $h): bool { - $mime = mime_content_type($path); + $finfo1 = finfo_open(FILEINFO_MIME_TYPE); + $mime = (string)finfo_file($finfo1, $path); + finfo_close($finfo1); $img = match ($mime) { 'image/jpeg' => imagecreatefromjpeg($path), @@ -65,7 +67,9 @@ class ImageCropService */ public function cropWhitespace(string $path): bool { - $mime = mime_content_type($path); + $finfo2 = finfo_open(FILEINFO_MIME_TYPE); + $mime = (string)finfo_file($finfo2, $path); + finfo_close($finfo2); $img = match ($mime) { 'image/jpeg' => imagecreatefromjpeg($path), diff --git a/src/View.php b/src/View.php index 4020ced..8bf9dbe 100644 --- a/src/View.php +++ b/src/View.php @@ -60,4 +60,13 @@ class View $d = \DateTime::createFromFormat('Y-m-d', $date); return $d ? $d->format('d.m.Y') : $date; } + + public static function buildQuery(array $params): string + { + $safe = []; + foreach ($params as $k => $v) { + $safe[rawurlencode((string)$k)] = rawurlencode((string)$v); + } + return http_build_query($safe); + } } diff --git a/src/Views/layout/base.php b/src/Views/layout/base.php index 6b64eb8..584b7ce 100644 --- a/src/Views/layout/base.php +++ b/src/Views/layout/base.php @@ -11,6 +11,7 @@ +
@@ -48,5 +49,31 @@
+ diff --git a/src/Views/login.php b/src/Views/login.php index 078ca50..13bba01 100644 --- a/src/Views/login.php +++ b/src/Views/login.php @@ -54,6 +54,7 @@
+
assert(false, 'Kein bank_import_format vorhanden – Test übersprungen'); + return; + } + $this->assert(true, 'bank_import_format vorhanden'); + + // ── Hilfsfunktionen ──────────────────────────────────────────── + $batchId = \Steuerkram\Models\BankTransaction::createBatch($formatId, 'test-32.csv', 1); + $this->assertGreaterThan(0, $batchId, 'Batch angelegt (id > 0)'); + + $date = date('Y-m-d'); + \Steuerkram\Models\BankTransaction::insertRow($batchId, [ + 'buchungstag' => $date, + 'wertstellung' => $date, + 'vorgang' => 'Lastschrift', + 'buchungstext' => 'Test-Lieferant Rechnung 32', + 'wert' => '-119.00', + 'raw_line' => null, + ]); + + $tx = \Steuerkram\Database::fetchOne( + 'SELECT id FROM bank_transactions WHERE batch_id = ? LIMIT 1', + [$batchId] + ); + $this->assert($tx !== false, 'Bankbuchung in DB gefunden'); + $txId = (int)$tx['id']; + + // ── Status initial: offen ────────────────────────────────────── + \Steuerkram\Models\BankTransaction::recalcStatus($txId); + $row = \Steuerkram\Database::fetchOne('SELECT status FROM bank_transactions WHERE id = ?', [$txId]); + $this->assertEquals('offen', $row['status'], 'Status nach Anlage: offen'); + + // ── Beleg anlegen ────────────────────────────────────────────── + $belegId = \Steuerkram\Models\Beleg::create([ + 'beleg_nr' => 'BT32-' . uniqid(), + 'filename' => 'bt32.pdf', + 'storage_path' => '', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', 'bt32-' . uniqid()), + 'beleg_datum' => $date, + 'belegtyp' => 'Eingangsrechnung', + ]); + + // ── Teilzuordnung (60 €) → Status: teilweise ───────────────── + \Steuerkram\Models\BankTransaction::linkBeleg($txId, $belegId, 60.00); + $row = \Steuerkram\Database::fetchOne('SELECT status FROM bank_transactions WHERE id = ?', [$txId]); + $this->assertEquals('teilweise', $row['status'], 'Status nach Teilzuordnung (60/119): teilweise'); + + $remaining = \Steuerkram\Models\BankTransaction::getRemainingBetrag($txId); + $this->assertEquals(59.00, $remaining, 'Restbetrag nach 60-€-Zuordnung = 59,00'); + + // ── Vollzuordnung (restliche 59 €) → Status: gebucht ───────── + $belegId2 = \Steuerkram\Models\Beleg::create([ + 'beleg_nr' => 'BT32B-' . uniqid(), + 'filename' => 'bt32b.pdf', + 'storage_path' => '', + 'mime_type' => 'application/pdf', + 'file_hash' => hash('sha256', 'bt32b-' . uniqid()), + 'beleg_datum' => $date, + 'belegtyp' => 'Eingangsrechnung', + ]); + \Steuerkram\Models\BankTransaction::linkBeleg($txId, $belegId2, 59.00); + $row = \Steuerkram\Database::fetchOne('SELECT status FROM bank_transactions WHERE id = ?', [$txId]); + $this->assertEquals('gebucht', $row['status'], 'Status nach Vollzuordnung (60+59=119): gebucht'); + + // ── Verknüpfte Belege prüfen ─────────────────────────────────── + $linked = \Steuerkram\Models\BankTransaction::getLinkedBelege($txId); + $this->assertEquals(2, count($linked), 'Anzahl verknüpfter Belege = 2'); + + // ── Zuordnung aufheben → Status: teilweise ──────────────────── + \Steuerkram\Models\BankTransaction::unlinkBeleg($txId, $belegId2); + $row = \Steuerkram\Database::fetchOne('SELECT status FROM bank_transactions WHERE id = ?', [$txId]); + $this->assertEquals('teilweise', $row['status'], 'Status nach unlinkBeleg: teilweise'); + + // ── Alle Zuordnungen aufheben → Status: offen ───────────────── + \Steuerkram\Models\BankTransaction::unlinkBeleg($txId, $belegId); + $row = \Steuerkram\Database::fetchOne('SELECT status FROM bank_transactions WHERE id = ?', [$txId]); + $this->assertEquals('offen', $row['status'], 'Status nach vollständigem Unlink: offen'); + + // ── findDuplicates: leere Eingabe ───────────────────────────── + $result = \Steuerkram\Models\BankTransaction::findDuplicates([]); + $this->assertEquals(0, count($result['new']), 'findDuplicates([]) → new leer'); + $this->assertEquals(0, count($result['dup']), 'findDuplicates([]) → dup leer'); + + // ── findDuplicates: identische Buchung ist Duplikat ─────────── + $dupRow = [ + 'buchungstag' => $date, + 'wertstellung' => $date, + 'vorgang' => 'Lastschrift', + 'buchungstext' => 'Test-Lieferant Rechnung 32', + 'wert' => '-119.00', + ]; + $result2 = \Steuerkram\Models\BankTransaction::findDuplicates([$dupRow]); + $this->assertEquals(1, count($result2['dup']), 'Identische Buchung als Duplikat erkannt'); + $this->assertEquals(0, count($result2['new']), 'Keine neue Buchung bei Duplikat'); + + // ── findDuplicates: neue Buchung ist kein Duplikat ──────────── + $newRow = [ + 'buchungstag' => $date, + 'wertstellung' => $date, + 'vorgang' => 'Gutschrift', + 'buchungstext' => 'Einnahme Test 32', + 'wert' => '500.00', + ]; + $result3 = \Steuerkram\Models\BankTransaction::findDuplicates([$newRow]); + $this->assertEquals(1, count($result3['new']), 'Neue Buchung korrekt als neu erkannt'); + $this->assertEquals(0, count($result3['dup']), 'Keine Duplikate bei neuer Buchung'); + } +} diff --git a/tests/33_BankImportParserTest.php b/tests/33_BankImportParserTest.php new file mode 100644 index 0000000..f773705 --- /dev/null +++ b/tests/33_BankImportParserTest.php @@ -0,0 +1,123 @@ + 3, + 'has_header_row' => 1, + 'delimiter' => ';', + 'enclosure' => '"', + 'col_buchungstag' => 0, + 'col_wertstellung' => 1, + 'col_vorgang' => 2, + 'col_buchungstext' => 3, + 'col_wert' => 4, + 'date_format' => 'd.m.Y', + 'decimal_separator' => ',', + 'thousands_separator' => '.', + ]; + } + + public function run(): void + { + $parser = new \Steuerkram\Services\BankImportParser(); + $fmt = $this->comdirectFormat(); + + // ── Normales CSV: 2 Buchungen ───────────────────────────────── + $csv = "Zeile1\n" + . "Zeile2\n" + . "Zeile3\n" + . "Buchungstag;Wertstellung;Vorgang;Buchungstext;Betrag\n" + . "15.05.2026;15.05.2026;Lastschrift;Lieferant A;\"-119,00\"\n" + . "14.05.2026;14.05.2026;Gutschrift;Kunde B;\"500,00\"\n" + . "\n"; + + $tmp = $this->makeTempCsv($csv); + $rows = $parser->parse($tmp, 'csv', $fmt); + unlink($tmp); + + $this->assertEquals(2, count($rows), 'Normales CSV: 2 Buchungen geparsed'); + $this->assertEquals('2026-05-15', $rows[0]['buchungstag'], 'Buchungstag korrekt geparst'); + $this->assertEquals('2026-05-15', $rows[0]['wertstellung'], 'Wertstellung korrekt geparst'); + $this->assertEquals('Lastschrift', $rows[0]['vorgang'], 'Vorgang korrekt geparst'); + $this->assertEquals(-119.00, $rows[0]['wert'], 'Betrag -119,00 korrekt geparst'); + $this->assertEquals(500.00, $rows[1]['wert'], 'Betrag 500,00 korrekt geparst'); + + // ── Leerzeilen werden übersprungen ──────────────────────────── + $csvMitLeeren = "Z1\nZ2\nZ3\n" + . "Buchungstag;Wertstellung;Vorgang;Buchungstext;Betrag\n" + . "15.05.2026;15.05.2026;Lastschrift;Lieferant;\"-50,00\"\n" + . ";;;\n" + . "\n" + . "14.05.2026;14.05.2026;Gutschrift;Kunde;\"200,00\"\n"; + + $tmp2 = $this->makeTempCsv($csvMitLeeren); + $rows2 = $parser->parse($tmp2, 'csv', $fmt); + unlink($tmp2); + + $this->assertEquals(2, count($rows2), 'Leerzeilen werden übersprungen (2 von 3 Zeilen)'); + + // ── Leere Datei gibt leeres Array zurück ────────────────────── + $csvLeer = "Z1\nZ2\nZ3\n" + . "Buchungstag;Wertstellung;Vorgang;Buchungstext;Betrag\n" + . "\n"; + + $tmp3 = $this->makeTempCsv($csvLeer); + $rows3 = $parser->parse($tmp3, 'csv', $fmt); + unlink($tmp3); + + $this->assertEquals(0, count($rows3), 'Nur-Header-CSV: leeres Ergebnis'); + + // ── Ungültiger Dateipfad wirft RuntimeException ─────────────── + $exceptionThrown = false; + try { + $parser->parse('/tmp/nicht_vorhanden_xyz_33.csv', 'csv', $fmt); + } catch (\RuntimeException $e) { + $exceptionThrown = true; + } + $this->assert($exceptionThrown, 'Nicht-vorhandene Datei wirft RuntimeException'); + + // ── UTF-8 BOM wird korrekt entfernt ─────────────────────────── + $csvBom = "\xEF\xBB\xBFZ1\nZ2\nZ3\n" + . "Buchungstag;Wertstellung;Vorgang;Buchungstext;Betrag\n" + . "13.05.2026;13.05.2026;Lastschrift;BOM-Test;\"-99,00\"\n"; + + $tmp4 = $this->makeTempCsv($csvBom); + $rows4 = $parser->parse($tmp4, 'csv', $fmt); + unlink($tmp4); + + $this->assertEquals(1, count($rows4), 'CSV mit UTF-8-BOM: 1 Buchung geparst'); + $this->assertEquals(-99.00, $rows4[0]['wert'], 'Betrag trotz BOM korrekt geparst'); + + // ── Tausendertrennzeichen korrekt entfernt ──────────────────── + $csvTausend = "Z1\nZ2\nZ3\n" + . "Buchungstag;Wertstellung;Vorgang;Buchungstext;Betrag\n" + . "12.05.2026;12.05.2026;Gutschrift;Grosskunde;\"-1.234,56\"\n"; + + $tmp5 = $this->makeTempCsv($csvTausend); + $rows5 = $parser->parse($tmp5, 'csv', $fmt); + unlink($tmp5); + + $this->assertEquals(1, count($rows5), 'CSV mit Tausendertrenner: 1 Buchung'); + $this->assertEquals(-1234.56, $rows5[0]['wert'], 'Betrag -1.234,56 korrekt geparst'); + } +} From f78353626d8ec8e30fd674c26347429a6e6da68a Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sat, 16 May 2026 08:30:59 +0200 Subject: [PATCH 2/3] =?UTF-8?q?Fix:=20Timezone=20Europe/Berlin=20f=C3=BCr?= =?UTF-8?q?=20Container=20und=20PHP=20gesetzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 2 ++ php/uploads.ini | 1 + 2 files changed, 3 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 139dfef..92736dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,7 @@ services: - ./tests:/var/www/html/tests - ./scripts:/var/www/html/scripts environment: + TZ: Europe/Berlin DB_HOST: db DB_NAME: steuerkram DB_USER: steuerkram @@ -29,6 +30,7 @@ services: db: image: mariadb:11 environment: + TZ: Europe/Berlin MARIADB_ROOT_PASSWORD: rootpass MARIADB_DATABASE: steuerkram MARIADB_USER: steuerkram diff --git a/php/uploads.ini b/php/uploads.ini index ddc3ee6..8836416 100644 --- a/php/uploads.ini +++ b/php/uploads.ini @@ -1,2 +1,3 @@ upload_max_filesize = 20M post_max_size = 20M +date.timezone = Europe/Berlin From aa9ccd120727e72367e82cdc9d06898f3bf1d15f Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 17 May 2026 13:47:16 +0200 Subject: [PATCH 3/3] =?UTF-8?q?Feature:=20Bank-Import=20mit=20konfigurierb?= =?UTF-8?q?aren=20Formaten,=20Buchungstext-Templates=20und=20Dublettenpr?= =?UTF-8?q?=C3=BCfung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix: Dublettenprüfung verwendet nur (buchungstag, wertstellung, wert) ohne buchungstext, da Vorlagenänderungen sonst zu falschen Duplikaten führen. --- Changes.md | 28 + docs/funktionen.md | 4 + migrations/054_bank_import_format_dkb.sql | 20 + migrations/055_buchungstext_template.sql | 13 + .../056_comdirect_buchungstext_template.sql | 5 + migrations/057_bank_format_kuerzel.sql | 5 + migrations/058_fix_dkb_date_format.sql | 13 + migrations/059_bank_format_farbe.sql | 5 + migrations/060_fix_comdirect_skip_rows.sql | 9 + migrations/061_bank_format_nullable.sql | 7 + public/index.php | 7 + public/static/css/app.css | 11 + src/Controllers/AccountController.php | 43 +- src/Controllers/BankController.php | 77 +- src/Controllers/SettingsController.php | 247 ++++++ src/Models/BankImportFormat.php | 49 ++ src/Models/BankTransaction.php | 37 +- src/Services/BankImportParser.php | 196 ++++- src/Views/bank/index.php | 89 +- src/Views/bank/show.php | 13 +- src/Views/bank/upload.php | 260 +++++- src/Views/belege/detail.php | 21 +- src/Views/dashboard.php | 15 + src/Views/layout/nav.php | 4 + src/Views/settings/bankimport.php | 784 ++++++++++++++++++ src/version.txt | 2 +- tests/34_BankImportDkbTest.php | 145 ++++ tests/35_BankImportSuggestTest.php | 71 ++ 28 files changed, 2125 insertions(+), 55 deletions(-) create mode 100644 migrations/054_bank_import_format_dkb.sql create mode 100644 migrations/055_buchungstext_template.sql create mode 100644 migrations/056_comdirect_buchungstext_template.sql create mode 100644 migrations/057_bank_format_kuerzel.sql create mode 100644 migrations/058_fix_dkb_date_format.sql create mode 100644 migrations/059_bank_format_farbe.sql create mode 100644 migrations/060_fix_comdirect_skip_rows.sql create mode 100644 migrations/061_bank_format_nullable.sql create mode 100644 src/Views/settings/bankimport.php create mode 100644 tests/34_BankImportDkbTest.php create mode 100644 tests/35_BankImportSuggestTest.php diff --git a/Changes.md b/Changes.md index d472723..08eb7e0 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,34 @@ --- +## 2026-05-17 – Feature: Automatischer Format-Vorschlag beim Bank-Import + +- **Format-Vorschlag**: Wenn eine hochgeladene CSV keinem bekannten Format eindeutig entspricht, analysiert `BankImportParser::suggestFormat()` die Datei automatisch (Headerzeilen-Erkennung bei skip_rows 0–10, Delimiter-Erkennung, Keyword-basiertes Spalten-Mapping) +- **Session-gestützter Vorschlags-Flow**: Klick auf „Formate bearbeiten" speichert den Vorschlag via AJAX in der Session und öffnet die Einstellungsseite +- **Vorschlags-Card**: Auf `/settings/bankimport` erscheint der Vorschlag oberhalb bestehender Formate als ausfüllbares Formular mit vorausgefüllten Feldern (skip_rows, Delimiter, Spalten-Mapping mit erkannten Headernamen) +- **Explizites Speichern**: Neues Format wird erst nach Klick auf „Speichern" in der DB angelegt (POST `/settings/bankimport/create`); Verwerfen löscht die Session-Suggestion +- Neue Routen: `POST /settings/bankimport/suggest`, `POST /settings/bankimport/create`, `POST /settings/bankimport/dismiss-suggestion` +- Neues Model: `BankImportFormat::create()`; neuer Test 35 + +--- + +## 2026-05-17 – Feature: Automatische Format-Erkennung beim Bank-Import + +- **Format-Erkennung**: Hochgeladene Datei wird automatisch gegen alle gespeicherten Formate geprüft; bei eindeutigem Match wird sofort importiert +- **Vorschau-Panel**: Wenn kein Format passt, zeigt die Upload-Seite Überschriften-Zeile, erste Werte-Zeile und die Spalten-Zuordnung (Wertstellung, Buchungstext, Wert) für das gewählte Format an; wechselt live beim Umschalten der Formatauswahl +- **„Formate bearbeiten"-Button** oben rechts verlinkt direkt auf die Einstellungsseite für Bankimport-Formate +- Neuer AJAX-Endpunkt `POST /bank/detect-format` in `BankController`; neue Methoden `peekRows()` und `isFormatMatch()` in `BankImportParser` + +--- + +## 2026-05-17 – Feature: DKB-Importformat für Bankbuchungen + +- **Neues Format „DKB Bank"**: 4 Skip-Zeilen, Semikolon-getrennt, Anführungszeichen-eingeschlossen +- Buchungstext wird konditionell zusammengesetzt: bei negativem Betrag aus Zahlungsempfänger + Verwendungszweck, sonst aus Zahlungspflichtiger + Verwendungszweck +- Migration 054 ergänzt drei neue Nullable-Spalten in `bank_import_formats` (`col_zahlungspflichtiger`, `col_zahlungsempfaenger`, `col_verwendungszweck`) + +--- + ## 2026-05-16 – Refactor: Code-Qualität, Sicherheit und Konsistenz - **CSRF-Schutz**: `Auth::generateCsrfToken()` / `validateCsrfToken()` neu; Router validiert Token bei allen POST/PUT/DELETE-Requests; base.php injiziert Token in Formulare und fetch-Aufrufe via JS; login.php erhält hidden Input diff --git a/docs/funktionen.md b/docs/funktionen.md index 0845f8a..477c0fc 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -65,6 +65,10 @@ Anlagenspiegel für abnutzbare Wirtschaftsgüter – siehe [anlagevermögen.md]( ## Bank buchen - Import von Kontoauszügen: CSV, TXT und XLSX (konfigurierbare Spalten- und Datumsformate je Bank) +- **Unterstützte Importformate**: Comdirect, DKB Bank +- **Automatische Format-Erkennung**: beim Datei-Upload wird das Format anhand von Datumsfeldern und Betragsspalten automatisch erkannt; bei eindeutigem Treffer wird der Import direkt gestartet +- **Format-Vorschau**: bei unbekanntem Format zeigt die Upload-Seite Überschriften- und erste Werte-Zeile sowie die konkrete Spalten-Zuordnung für das gewählte Format an (wechselt live) +- **Automatischer Format-Vorschlag**: wenn kein gespeichertes Format passt, analysiert das System die CSV automatisch (Headerzeilen-Erkennung, Keyword-Matching); über „Formate bearbeiten" öffnet sich die Einstellungsseite mit einem vorausgefüllten Vorschlags-Formular – das neue Format wird erst nach explizitem Klick auf „Speichern" angelegt - **Dublettenprüfung**: beim Import werden Zeilen, die bereits in der DB vorhanden sind, als Duplikate markiert und nicht erneut importiert - **Beleg-Zuordnung**: jede Banktransaktion kann einem oder mehreren Belegen zugeordnet werden; der zugeordnete Betrag wird separat erfasst - **Teilzuordnung**: eine Transaktion kann schrittweise mehreren Belegen anteilig zugeordnet werden diff --git a/migrations/054_bank_import_format_dkb.sql b/migrations/054_bank_import_format_dkb.sql new file mode 100644 index 0000000..82f048a --- /dev/null +++ b/migrations/054_bank_import_format_dkb.sql @@ -0,0 +1,20 @@ +-- DKB-Importformat: drei neue Spalten für konditionelle Buchungstext-Zusammensetzung +ALTER TABLE bank_import_formats + ADD COLUMN IF NOT EXISTS col_zahlungspflichtiger INT NULL DEFAULT NULL, + ADD COLUMN IF NOT EXISTS col_zahlungsempfaenger INT NULL DEFAULT NULL, + ADD COLUMN IF NOT EXISTS col_verwendungszweck INT NULL DEFAULT NULL; + +-- DKB Bank: 4 Skip-Zeilen, dann Headerzeile +-- "Buchungsdatum";"Wertstellung";"Status";"Zahlungspflichtige*r";"Zahlungsempfänger*in"; +-- "Verwendungszweck";"Umsatztyp";"IBAN";"Betrag (€)";"Gläubiger-ID";"Mandatsreferenz";"Kundenreferenz" +INSERT INTO bank_import_formats + (name, skip_rows, has_header_row, delimiter, enclosure, + col_buchungstag, col_wertstellung, col_vorgang, col_buchungstext, col_wert, + col_zahlungspflichtiger, col_zahlungsempfaenger, col_verwendungszweck, + date_format, decimal_separator, thousands_separator) +VALUES + ('DKB Bank', 4, 1, ';', '"', + 0, 1, 6, 5, 8, + 3, 4, 5, + 'd.m.Y', ',', '.') +ON DUPLICATE KEY UPDATE name = name; diff --git a/migrations/055_buchungstext_template.sql b/migrations/055_buchungstext_template.sql new file mode 100644 index 0000000..60df755 --- /dev/null +++ b/migrations/055_buchungstext_template.sql @@ -0,0 +1,13 @@ +-- Buchungstext-Templates: konfigurierbare Zusammensetzung per {SpaltenüberschriftName} +ALTER TABLE bank_import_formats + ADD COLUMN IF NOT EXISTS buchungstext_template_debit VARCHAR(500) NULL DEFAULT NULL, + ADD COLUMN IF NOT EXISTS buchungstext_template_credit VARCHAR(500) NULL DEFAULT NULL; + +-- DKB Bank auf Templates umstellen; DKB-spezifische Spaltenindices werden nicht mehr benötigt +UPDATE bank_import_formats SET + buchungstext_template_debit = 'Auftraggeber: {Zahlungsempfänger*in} Buchungstext: {Verwendungszweck}', + buchungstext_template_credit = 'Auftraggeber: {Zahlungspflichtige*r} Buchungstext: {Verwendungszweck}', + col_zahlungspflichtiger = NULL, + col_zahlungsempfaenger = NULL, + col_verwendungszweck = NULL +WHERE name = 'DKB Bank'; diff --git a/migrations/056_comdirect_buchungstext_template.sql b/migrations/056_comdirect_buchungstext_template.sql new file mode 100644 index 0000000..6777a97 --- /dev/null +++ b/migrations/056_comdirect_buchungstext_template.sql @@ -0,0 +1,5 @@ +-- Comdirect: Buchungstext-Templates setzen (CSV-Spalte via Platzhalter {Buchungstext}) +UPDATE bank_import_formats SET + buchungstext_template_debit = '{Buchungstext}', + buchungstext_template_credit = '{Buchungstext}' +WHERE name = 'Comdirect'; diff --git a/migrations/057_bank_format_kuerzel.sql b/migrations/057_bank_format_kuerzel.sql new file mode 100644 index 0000000..1693543 --- /dev/null +++ b/migrations/057_bank_format_kuerzel.sql @@ -0,0 +1,5 @@ +ALTER TABLE bank_import_formats + ADD COLUMN IF NOT EXISTS kuerzel VARCHAR(10) NOT NULL DEFAULT '' AFTER name; + +UPDATE bank_import_formats SET kuerzel = 'C' WHERE name = 'Comdirect'; +UPDATE bank_import_formats SET kuerzel = 'DKB' WHERE name = 'DKB Bank'; diff --git a/migrations/058_fix_dkb_date_format.sql b/migrations/058_fix_dkb_date_format.sql new file mode 100644 index 0000000..ded2c72 --- /dev/null +++ b/migrations/058_fix_dkb_date_format.sql @@ -0,0 +1,13 @@ +-- DKB exportiert Datumsfelder zweistellig (dd.mm.yy), nicht vierstellig +UPDATE bank_import_formats +SET date_format = 'd.m.y' +WHERE name = 'DKB Bank'; + +-- Bereits importierte DKB-Buchungen mit Jahr 0026 auf 2026 korrigieren +UPDATE bank_transactions bt +JOIN bank_import_batches bib ON bib.id = bt.batch_id +JOIN bank_import_formats bif ON bif.id = bib.format_id +SET bt.wertstellung = DATE_ADD(bt.wertstellung, INTERVAL 2000 YEAR), + bt.buchungstag = DATE_ADD(bt.buchungstag, INTERVAL 2000 YEAR) +WHERE bif.name = 'DKB Bank' + AND YEAR(bt.wertstellung) < 100; diff --git a/migrations/059_bank_format_farbe.sql b/migrations/059_bank_format_farbe.sql new file mode 100644 index 0000000..c2ef89c --- /dev/null +++ b/migrations/059_bank_format_farbe.sql @@ -0,0 +1,5 @@ +ALTER TABLE bank_import_formats + ADD COLUMN IF NOT EXISTS farbe VARCHAR(7) NOT NULL DEFAULT '#6366f1' AFTER kuerzel; + +UPDATE bank_import_formats SET farbe = '#f59e0b' WHERE name = 'Comdirect'; +UPDATE bank_import_formats SET farbe = '#dc2626' WHERE name = 'DKB Bank'; diff --git a/migrations/060_fix_comdirect_skip_rows.sql b/migrations/060_fix_comdirect_skip_rows.sql new file mode 100644 index 0000000..343193f --- /dev/null +++ b/migrations/060_fix_comdirect_skip_rows.sql @@ -0,0 +1,9 @@ +-- Comdirect CSV hat eine Leerzeile zwischen Metadaten und Headerzeile. +-- Mit skip_rows=3 wurde die Leerzeile als Header gelesen → headerMap leer +-- → Template {Buchungstext} konnte nicht auflösen → leerer buchungstext. +-- Fix: skip_rows auf 4 setzen und Templates entfernen (col_buchungstext=3 genügt direkt). +UPDATE bank_import_formats +SET skip_rows = 4, + buchungstext_template_debit = NULL, + buchungstext_template_credit = NULL +WHERE name = 'Comdirect'; diff --git a/migrations/061_bank_format_nullable.sql b/migrations/061_bank_format_nullable.sql new file mode 100644 index 0000000..c525bb5 --- /dev/null +++ b/migrations/061_bank_format_nullable.sql @@ -0,0 +1,7 @@ +-- format_id in bank_import_batches nullable machen. +-- Beim Löschen eines Formats werden vorhandene Batches behalten; format_id wird NULL gesetzt. +ALTER TABLE bank_import_batches DROP FOREIGN KEY bank_import_batches_ibfk_1; +ALTER TABLE bank_import_batches MODIFY format_id INT UNSIGNED NULL; +ALTER TABLE bank_import_batches + ADD CONSTRAINT fk_bib_format_id + FOREIGN KEY (format_id) REFERENCES bank_import_formats(id) ON DELETE SET NULL; diff --git a/public/index.php b/public/index.php index 70ec656..2449aca 100644 --- a/public/index.php +++ b/public/index.php @@ -68,6 +68,7 @@ $router->post('/belege/{id}/vorschlag/reset', fn($p) => (new BelegController( $router->get('/bank', fn($p) => (new BankController())->index($p)); $router->get('/bank/upload', fn($p) => (new BankController())->uploadForm($p)); $router->post('/bank/upload', fn($p) => (new BankController())->upload($p)); +$router->post('/bank/detect-format', fn($p) => (new BankController())->detectFormat($p)); $router->post('/bank/upload/confirm', fn($p) => (new BankController())->uploadConfirm($p)); $router->get('/bank/{id}', fn($p) => (new BankController())->show($p)); $router->post('/bank/{id}/status', fn($p) => (new BankController())->updateStatus($p)); @@ -150,6 +151,12 @@ $router->post('/settings/mail-template', fn($p) => (new SettingsController())->m $router->post('/settings/mail-test', fn($p) => (new SettingsController())->mailTest($p)); $router->post('/settings/logo', fn($p) => (new SettingsController())->uploadLogo($p)); $router->post('/settings/logo/delete', fn($p) => (new SettingsController())->deleteLogo($p)); +$router->get('/settings/bankimport', fn($p) => (new SettingsController())->bankImport($p)); +$router->post('/settings/bankimport/suggest', fn($p) => (new SettingsController())->storeSuggestion($p)); +$router->post('/settings/bankimport/create', fn($p) => (new SettingsController())->createBankImportFormat($p)); +$router->post('/settings/bankimport/dismiss-suggestion', fn($p) => (new SettingsController())->dismissSuggestion($p)); +$router->post('/settings/bankimport/{id}/field', fn($p) => (new SettingsController())->updateBankImportField($p)); +$router->post('/settings/bankimport/{id}/delete', fn($p) => (new SettingsController())->deleteBankImportFormat($p)); // ── Login / Logout ──────────────────────────────────────────── $router->get('/login', fn($p) => (new LoginController())->showLogin($p)); diff --git a/public/static/css/app.css b/public/static/css/app.css index da471b5..cf3c0f3 100644 --- a/public/static/css/app.css +++ b/public/static/css/app.css @@ -316,6 +316,17 @@ td { padding: .65rem 1rem; vertical-align: middle; } .badge-gray { background: #f1f5f9; color: #475569; } .badge-purple { background: #ede9fe; color: #6d28d9; } +.kuerzel-badge { + display: inline-block; + padding: .15rem .6rem; + border-radius: 999px; + font-size: .72rem; + font-weight: 600; + white-space: nowrap; + flex-shrink: 0; + vertical-align: middle; +} + /* ── Toolbar / Filter bar ────────────────────────────────────── */ .toolbar { display: flex; diff --git a/src/Controllers/AccountController.php b/src/Controllers/AccountController.php index 43fa8f7..cf93e3f 100644 --- a/src/Controllers/AccountController.php +++ b/src/Controllers/AccountController.php @@ -13,7 +13,11 @@ class AccountController extends BaseController { public function dashboard(array $params): void { - $year = (int)date('Y'); + $year = (int)date('Y'); + $month = (int)date('m'); + $prevMonth = $month === 1 ? 12 : $month - 1; + $prevYear = $month === 1 ? $year - 1 : $year; + $monthNames = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']; // Quick stats $stats = []; @@ -28,6 +32,20 @@ class AccountController extends BaseController ); $stats['umsatz'] = (float)($umsatz['total'] ?? 0); + $r = \Steuerkram\Database::fetchOne( + 'SELECT COALESCE(SUM(betrag_netto),0) as total FROM bookings + WHERE buchungstyp=\'Einnahme\' AND is_reversal=0 AND YEAR(buchungs_datum)=? AND MONTH(buchungs_datum)=?', + [$year, $month] + ); + $stats['umsatz_monat'] = (float)($r['total'] ?? 0); + + $r = \Steuerkram\Database::fetchOne( + 'SELECT COALESCE(SUM(betrag_netto),0) as total FROM bookings + WHERE buchungstyp=\'Einnahme\' AND is_reversal=0 AND YEAR(buchungs_datum)=? AND MONTH(buchungs_datum)=?', + [$prevYear, $prevMonth] + ); + $stats['umsatz_vormonat'] = (float)($r['total'] ?? 0); + // Ausgaben $ausgaben = \Steuerkram\Database::fetchOne( 'SELECT COALESCE(SUM(bk.betrag_netto),0) as total @@ -37,8 +55,27 @@ class AccountController extends BaseController [$year] ); $stats['ausgaben'] = (float)($ausgaben['total'] ?? 0); - $stats['gewinn'] = $stats['umsatz'] - $stats['ausgaben']; - $stats['year'] = $year; + + $r = \Steuerkram\Database::fetchOne( + 'SELECT COALESCE(SUM(betrag_netto),0) as total FROM bookings + WHERE buchungstyp=\'Ausgabe\' AND is_reversal=0 AND YEAR(buchungs_datum)=? AND MONTH(buchungs_datum)=?', + [$year, $month] + ); + $stats['ausgaben_monat'] = (float)($r['total'] ?? 0); + + $r = \Steuerkram\Database::fetchOne( + 'SELECT COALESCE(SUM(betrag_netto),0) as total FROM bookings + WHERE buchungstyp=\'Ausgabe\' AND is_reversal=0 AND YEAR(buchungs_datum)=? AND MONTH(buchungs_datum)=?', + [$prevYear, $prevMonth] + ); + $stats['ausgaben_vormonat'] = (float)($r['total'] ?? 0); + + $stats['gewinn'] = $stats['umsatz'] - $stats['ausgaben']; + $stats['gewinn_monat'] = $stats['umsatz_monat'] - $stats['ausgaben_monat']; + $stats['gewinn_vormonat'] = $stats['umsatz_vormonat'] - $stats['ausgaben_vormonat']; + $stats['year'] = $year; + $stats['month_label'] = $monthNames[$month - 1]; + $stats['vormonat_label'] = $monthNames[$prevMonth - 1]; // Offene Rechnungen $offen = \Steuerkram\Database::fetchOne( diff --git a/src/Controllers/BankController.php b/src/Controllers/BankController.php index c1ab473..464f7c8 100644 --- a/src/Controllers/BankController.php +++ b/src/Controllers/BankController.php @@ -24,18 +24,20 @@ class BankController extends BaseController $filter['status'] = 'pending'; } - $page = max(1, (int)($_GET['page'] ?? 1)); - $result = BankTransaction::all($filter, $page); + $page = max(1, (int)($_GET['page'] ?? 1)); + $result = BankTransaction::all($filter, $page); + $formats = BankImportFormat::all(); $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Bank buchen', 'content' => $this->renderView('bank/index', [ - 'rows' => $result['rows'], - 'total' => $result['total'], - 'pages' => $result['pages'], - 'page' => $page, - 'filter' => $filter, + 'rows' => $result['rows'], + 'total' => $result['total'], + 'pages' => $result['pages'], + 'page' => $page, + 'filter' => $filter, + 'formats' => $formats, ]), 'topbarActions' => 'Upload', ]); @@ -46,14 +48,71 @@ class BankController extends BaseController $formats = BankImportFormat::all(); $view = new View(); $view->render('layout/base.php', [ - 'pageTitle' => 'Bank-Buchungen importieren', - 'content' => $this->renderView('bank/upload', [ + 'pageTitle' => 'Bank-Buchungen importieren', + 'topbarActions' => 'Formate bearbeiten', + 'content' => $this->renderView('bank/upload', [ 'formats' => $formats, 'errors' => [], ]), ]); } + public function detectFormat(array $params): void + { + header('Content-Type: application/json'); + + $file = $_FILES['bank_file'] ?? null; + if (empty($file['tmp_name']) || $file['error'] !== UPLOAD_ERR_OK) { + echo json_encode(['error' => 'Upload fehlgeschlagen.']); + return; + } + + $origName = $file['name'] ?? 'upload'; + $ext = strtolower(pathinfo($origName, PATHINFO_EXTENSION)); + + if (!in_array($ext, self::ALLOWED_EXT, true)) { + echo json_encode(['error' => 'Nicht erlaubtes Dateiformat.']); + return; + } + + $formats = BankImportFormat::all(); + $parser = new BankImportParser(); + + $formatsData = []; + $matchedIds = []; + + foreach ($formats as $format) { + $id = (int)$format['id']; + $peek = $parser->peekRows($file['tmp_name'], $ext, $format); + $isMatch = $parser->isFormatMatch($file['tmp_name'], $ext, $format); + + if ($isMatch) { + $matchedIds[] = $id; + } + + $formatsData[$id] = [ + 'format' => $format, + 'header_row' => $peek['header'], + 'first_data' => $peek['first_data'], + 'is_match' => $isMatch, + ]; + } + + $matchedFormatId = count($matchedIds) === 1 ? $matchedIds[0] : null; + + $suggestedFormat = null; + if ($matchedFormatId === null) { + $suggestedFormat = $parser->suggestFormat($file['tmp_name'], $ext); + } + + echo json_encode([ + 'matched_format_id' => $matchedFormatId, + 'formats_data' => $formatsData, + 'filename' => $origName, + 'suggested_format' => $suggestedFormat, + ], JSON_UNESCAPED_UNICODE); + } + public function upload(array $params): void { $formatId = (int)($_POST['format_id'] ?? 0); diff --git a/src/Controllers/SettingsController.php b/src/Controllers/SettingsController.php index 863cf5d..a5395b1 100644 --- a/src/Controllers/SettingsController.php +++ b/src/Controllers/SettingsController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Steuerkram\Controllers; +use Steuerkram\Models\BankImportFormat; use Steuerkram\Models\Setting; use Steuerkram\Services\EmailService; use Steuerkram\View; @@ -138,4 +139,250 @@ class SettingsController extends BaseController View::redirect('/settings', 'Logo gelöscht.'); } + public function bankImport(array $params): void + { + $formats = BankImportFormat::all(); + $suggestion = $_SESSION['bank_import_suggestion'] ?? null; + + $view = new View(); + $view->render('layout/base.php', [ + 'pageTitle' => 'Bankimport-Formate', + 'content' => $this->renderView('settings/bankimport', [ + 'formats' => $formats, + 'suggestion' => $suggestion, + ]), + ]); + } + + public function deleteBankImportFormat(array $params): void + { + header('Content-Type: application/json'); + + $id = (int)($params['id'] ?? 0); + $fmt = BankImportFormat::find($id); + if (!$fmt) { + http_response_code(404); + echo json_encode(['error' => 'Format nicht gefunden.']); + return; + } + + BankImportFormat::delete($id); + echo json_encode(['ok' => true]); + } + + public function storeSuggestion(array $params): void + { + header('Content-Type: application/json'); + $data = json_decode(file_get_contents('php://input'), true); + if (is_array($data)) { + $_SESSION['bank_import_suggestion'] = $data; + echo json_encode(['ok' => true]); + } else { + http_response_code(400); + echo json_encode(['error' => 'Keine Daten.']); + } + } + + public function dismissSuggestion(array $params): void + { + header('Content-Type: application/json'); + unset($_SESSION['bank_import_suggestion']); + echo json_encode(['ok' => true]); + } + + public function createBankImportFormat(array $params): void + { + $dateFormats = ['d.m.Y', 'd.m.y', 'Y-m-d', 'd/m/Y', 'm/d/Y', 'Y.m.d', 'd-m-Y']; + + $name = trim($_POST['name'] ?? ''); + $kuerzel = trim($_POST['kuerzel'] ?? ''); + $farbe = $_POST['farbe'] ?? '#6366f1'; + $skipRows = (int)($_POST['skip_rows'] ?? 0); + $hasHeader = in_array($_POST['has_header_row'] ?? '1', ['0', '1'], true) + ? (int)$_POST['has_header_row'] : 1; + $delimiter = $_POST['delimiter'] ?? ';'; + $enclosure = $_POST['enclosure'] ?? '"'; + $dateFmt = $_POST['date_format'] ?? 'd.m.Y'; + $decSep = $_POST['decimal_separator'] ?? ','; + $thouSep = $_POST['thousands_separator'] ?? '.'; + + $errors = []; + if ($name === '' || mb_strlen($name) > 100) { + $errors[] = 'Name darf nicht leer sein (max. 100 Zeichen).'; + } + if ($kuerzel === '' || mb_strlen($kuerzel) > 10) { + $errors[] = 'Kürzel darf nicht leer sein (max. 10 Zeichen).'; + } + if (!preg_match('/^#[0-9a-fA-F]{6}$/', $farbe)) { + $farbe = '#6366f1'; + } + if ($skipRows < 0 || $skipRows > 99) { + $skipRows = 0; + } + if (mb_strlen($delimiter) !== 1) { + $errors[] = 'Trennzeichen muss genau ein Zeichen sein.'; + } + if (mb_strlen($enclosure) !== 1) { + $errors[] = 'Anführungszeichen muss genau ein Zeichen sein.'; + } + if (!in_array($dateFmt, $dateFormats, true)) { + $dateFmt = 'd.m.Y'; + } + if (mb_strlen($decSep) !== 1) { + $errors[] = 'Dezimaltrennzeichen muss genau ein Zeichen sein.'; + } + if (mb_strlen($thouSep) > 1) { + $thouSep = ''; + } + + if (!empty($errors)) { + // Bei Validierungsfehler zurück mit Fehlermeldung + $suggestion = $_SESSION['bank_import_suggestion'] ?? null; + $formats = BankImportFormat::all(); + $view = new View(); + $view->render('layout/base.php', [ + 'pageTitle' => 'Bankimport-Formate', + 'content' => $this->renderView('settings/bankimport', [ + 'formats' => $formats, + 'suggestion' => $suggestion, + 'errors' => $errors, + ]), + ]); + return; + } + + $tplDebit = mb_substr(trim($_POST['buchungstext_template_debit'] ?? ''), 0, 500); + $tplCredit = mb_substr(trim($_POST['buchungstext_template_credit'] ?? ''), 0, 500); + + $nullInt = function (string $key): ?int { + $v = $_POST[$key] ?? ''; + if ($v === '') return null; + return (int)$v; + }; + $intCol = function (string $key, int $default = 0): int { + $v = $_POST[$key] ?? ''; + return $v !== '' ? (int)$v : $default; + }; + + $id = BankImportFormat::create([ + 'name' => $name, + 'kuerzel' => $kuerzel, + 'farbe' => $farbe, + 'skip_rows' => $skipRows, + 'has_header_row' => $hasHeader, + 'delimiter' => $delimiter, + 'enclosure' => $enclosure, + 'date_format' => $dateFmt, + 'decimal_separator' => $decSep, + 'thousands_separator' => $thouSep, + 'col_buchungstag' => $intCol('col_buchungstag', 0), + 'col_wertstellung' => $intCol('col_wertstellung', 1), + 'col_vorgang' => $intCol('col_vorgang', 2), + 'col_buchungstext' => 0, + 'col_wert' => $intCol('col_wert', 4), + 'col_zahlungspflichtiger' => $nullInt('col_zahlungspflichtiger'), + 'col_zahlungsempfaenger' => $nullInt('col_zahlungsempfaenger'), + 'col_verwendungszweck' => $nullInt('col_verwendungszweck'), + 'buchungstext_template_debit' => $tplDebit !== '' ? $tplDebit : null, + 'buchungstext_template_credit' => $tplCredit !== '' ? $tplCredit : null, + ]); + + unset($_SESSION['bank_import_suggestion']); + + header('Location: /settings/bankimport#format-' . $id); + exit; + } + + public function updateBankImportField(array $params): void + { + header('Content-Type: application/json'); + + $id = (int)($params['id'] ?? 0); + $fmt = BankImportFormat::find($id); + if (!$fmt) { + http_response_code(404); + echo json_encode(['error' => 'Format nicht gefunden.']); + return; + } + + $field = $_POST['field'] ?? ''; + $raw = $_POST['value'] ?? ''; + + $intCols = ['col_buchungstag', 'col_wertstellung', 'col_vorgang', 'col_buchungstext', 'col_wert']; + $nullIntCols = ['col_zahlungspflichtiger', 'col_zahlungsempfaenger', 'col_verwendungszweck']; + $charCols = ['delimiter', 'enclosure', 'decimal_separator']; + $dateFormats = ['d.m.Y', 'd.m.y', 'Y-m-d', 'd/m/Y', 'm/d/Y', 'Y.m.d', 'd-m-Y']; + $tplCols = ['buchungstext_template_debit', 'buchungstext_template_credit']; + + [$ok, $value, $err] = match (true) { + $field === 'name' + => (trim($raw) !== '' && mb_strlen(trim($raw)) <= 100) + ? [true, trim($raw), ''] + : [false, null, 'Name darf nicht leer sein (max. 100 Zeichen).'], + + $field === 'kuerzel' + => (trim($raw) !== '' && mb_strlen(trim($raw)) <= 10) + ? [true, trim($raw), ''] + : [false, null, 'Kürzel darf nicht leer sein (max. 10 Zeichen).'], + + $field === 'farbe' + => (bool)preg_match('/^#[0-9a-fA-F]{6}$/', $raw) + ? [true, $raw, ''] + : [false, null, 'Ungültige Farbe (Format: #RRGGBB).'], + + $field === 'skip_rows' + => (ctype_digit($raw) && (int)$raw >= 0 && (int)$raw <= 99) + ? [true, (int)$raw, ''] + : [false, null, 'Übersprungene Zeilen: ganze Zahl 0–99 erforderlich.'], + + $field === 'has_header_row' + => in_array($raw, ['0', '1'], true) + ? [true, (int)$raw, ''] + : [false, null, 'Ungültiger Wert.'], + + in_array($field, $charCols, true) + => (mb_strlen($raw) === 1) + ? [true, $raw, ''] + : [false, null, 'Genau ein Zeichen erforderlich.'], + + $field === 'thousands_separator' + => (mb_strlen($raw) <= 1) + ? [true, $raw, ''] + : [false, null, 'Maximal ein Zeichen.'], + + in_array($field, $intCols, true) + => (ctype_digit($raw) && (int)$raw >= 0 && (int)$raw <= 99) + ? [true, (int)$raw, ''] + : [false, null, 'Gültige Spaltennummer (0–99) erforderlich.'], + + in_array($field, $nullIntCols, true) + => ($raw === '' || $raw === null) + ? [true, null, ''] + : (ctype_digit($raw) && (int)$raw >= 0 && (int)$raw <= 99 + ? [true, (int)$raw, ''] + : [false, null, 'Gültige Spaltennummer (0–99) oder leer für nicht gesetzt.']), + + $field === 'date_format' + => in_array($raw, $dateFormats, true) + ? [true, $raw, ''] + : [false, null, 'Unbekanntes Datumsformat.'], + + in_array($field, $tplCols, true) + => (mb_strlen($raw) <= 500) + ? [true, $raw === '' ? null : $raw, ''] + : [false, null, 'Template zu lang (max. 500 Zeichen).'], + + default => [false, null, 'Unbekanntes Feld.'], + }; + + if (!$ok) { + http_response_code(422); + echo json_encode(['error' => $err]); + return; + } + + BankImportFormat::updateField($id, $field, $value); + echo json_encode(['ok' => true]); + } + } diff --git a/src/Models/BankImportFormat.php b/src/Models/BankImportFormat.php index accfc57..d5bcf72 100644 --- a/src/Models/BankImportFormat.php +++ b/src/Models/BankImportFormat.php @@ -18,4 +18,53 @@ class BankImportFormat $row = Database::fetchOne('SELECT * FROM bank_import_formats WHERE id = ?', [$id]); return $row ?: null; } + + public static function updateField(int $id, string $field, mixed $value): void + { + Database::execute( + "UPDATE bank_import_formats SET `{$field}` = ? WHERE id = ?", + [$value, $id] + ); + } + + public static function delete(int $id): void + { + Database::execute('DELETE FROM bank_import_formats WHERE id = ?', [$id]); + } + + public static function create(array $data): int + { + Database::execute( + 'INSERT INTO bank_import_formats + (name, kuerzel, farbe, skip_rows, has_header_row, delimiter, enclosure, + date_format, decimal_separator, thousands_separator, + col_buchungstag, col_wertstellung, col_vorgang, col_buchungstext, col_wert, + col_zahlungspflichtiger, col_zahlungsempfaenger, col_verwendungszweck, + buchungstext_template_debit, buchungstext_template_credit) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + $data['name'], + $data['kuerzel'], + $data['farbe'], + $data['skip_rows'], + $data['has_header_row'], + $data['delimiter'], + $data['enclosure'], + $data['date_format'], + $data['decimal_separator'], + $data['thousands_separator'], + $data['col_buchungstag'], + $data['col_wertstellung'], + $data['col_vorgang'], + $data['col_buchungstext'], + $data['col_wert'], + $data['col_zahlungspflichtiger'], + $data['col_zahlungsempfaenger'], + $data['col_verwendungszweck'], + $data['buchungstext_template_debit'] ?? null, + $data['buchungstext_template_credit'] ?? null, + ] + ); + return (int)Database::lastInsertId(); + } } diff --git a/src/Models/BankTransaction.php b/src/Models/BankTransaction.php index ec8d55f..738ac86 100644 --- a/src/Models/BankTransaction.php +++ b/src/Models/BankTransaction.php @@ -42,22 +42,39 @@ class BankTransaction $params[] = $s; $params[] = $s; } + if (!empty($filter['format_id'])) { + $where[] = 'bib.format_id = ?'; + $params[] = (int)$filter['format_id']; + } + + $sortMap = [ + 'wertstellung' => 'bt.wertstellung', + 'buchungstag' => 'bt.buchungstag', + 'betrag' => 'bt.wert', + 'buchungstext' => 'bt.buchungstext', + 'status' => 'bt.status', + ]; + $sortCol = $sortMap[$filter['sort'] ?? ''] ?? 'bt.wertstellung'; + $sortDir = ($filter['dir'] ?? 'desc') === 'asc' ? 'ASC' : 'DESC'; $whereStr = implode(' AND ', $where); $offset = ($page - 1) * $perPage; $total = (int)(Database::fetchOne( - "SELECT COUNT(*) AS n FROM bank_transactions bt WHERE $whereStr", + "SELECT COUNT(*) AS n + FROM bank_transactions bt + JOIN bank_import_batches bib ON bt.batch_id = bib.id + WHERE $whereStr", $params )['n'] ?? 0); $rows = Database::fetchAll( - "SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name, + "SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name, bif.kuerzel, bif.farbe, btb_agg.beleg_id AS beleg_id, btb_agg.beleg_count, b.beleg_nr FROM bank_transactions bt JOIN bank_import_batches bib ON bt.batch_id = bib.id - JOIN bank_import_formats bif ON bib.format_id = bif.id + LEFT JOIN bank_import_formats bif ON bib.format_id = bif.id LEFT JOIN ( SELECT bank_tx_id, MIN(beleg_id) AS beleg_id, COUNT(*) AS beleg_count FROM bank_transaction_belege @@ -65,7 +82,7 @@ class BankTransaction ) btb_agg ON btb_agg.bank_tx_id = bt.id LEFT JOIN belege b ON b.id = btb_agg.beleg_id WHERE $whereStr - ORDER BY bt.wertstellung DESC, bt.id DESC + ORDER BY $sortCol $sortDir, bt.wertstellung $sortDir, bt.buchungstag $sortDir, bt.id $sortDir LIMIT ? OFFSET ?", array_merge($params, [$perPage, $offset]) ); @@ -80,7 +97,7 @@ class BankTransaction public static function find(int $id): ?array { $row = Database::fetchOne( - 'SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name + 'SELECT bt.*, bib.filename AS batch_filename, bif.name AS format_name, bif.kuerzel, bif.farbe FROM bank_transactions bt JOIN bank_import_batches bib ON bt.batch_id = bib.id JOIN bank_import_formats bif ON bib.format_id = bif.id @@ -184,10 +201,11 @@ class BankTransaction public static function findByBelegId(int $belegId): ?array { $row = Database::fetchOne( - 'SELECT bt.*, bib.filename AS batch_filename, btb.betrag AS alloc_betrag + 'SELECT bt.*, bib.filename AS batch_filename, bif.kuerzel, bif.farbe, btb.betrag AS alloc_betrag FROM bank_transaction_belege btb JOIN bank_transactions bt ON bt.id = btb.bank_tx_id JOIN bank_import_batches bib ON bib.id = bt.batch_id + JOIN bank_import_formats bif ON bif.id = bib.format_id WHERE btb.beleg_id = ? LIMIT 1', [$belegId] @@ -213,7 +231,7 @@ class BankTransaction } $existing = Database::fetchAll( - "SELECT buchungstag, wertstellung, CAST(wert AS CHAR) AS wert, LEFT(buchungstext,200) AS btext + "SELECT buchungstag, wertstellung, CAST(wert AS CHAR) AS wert FROM bank_transactions WHERE (buchungstag, wertstellung, CAST(wert AS CHAR)) IN ($placeholders)", $params @@ -221,7 +239,7 @@ class BankTransaction $lookup = []; foreach ($existing as $e) { - $lookup[$e['buchungstag'] . '|' . $e['wertstellung'] . '|' . $e['wert'] . '|' . $e['btext']] = true; + $lookup[$e['buchungstag'] . '|' . $e['wertstellung'] . '|' . $e['wert']] = true; } $new = []; @@ -229,8 +247,7 @@ class BankTransaction foreach ($rows as $row) { $key = $row['buchungstag'] . '|' . $row['wertstellung'] . '|' - . (string)$row['wert'] . '|' - . mb_substr($row['buchungstext'], 0, 200); + . (string)$row['wert']; if (isset($lookup[$key])) { $dup[] = $row; } else { diff --git a/src/Services/BankImportParser.php b/src/Services/BankImportParser.php index ec4203f..5496a3c 100644 --- a/src/Services/BankImportParser.php +++ b/src/Services/BankImportParser.php @@ -22,10 +22,16 @@ class BankImportParser $lines = $this->readCsv($filePath, $format['delimiter'], $format['enclosure']); } - // Übersprungene Zeilen und optionale Headerzeile entfernen + // Übersprungene Zeilen entfernen $skipRows = (int)$format['skip_rows']; $lines = array_slice($lines, $skipRows); - if (!empty($format['has_header_row'])) { + + // Headerzeile auslesen (vor dem Entfernen), um {Überschrift}-Templates aufzulösen + $headerMap = []; + if (!empty($format['has_header_row']) && isset($lines[0])) { + foreach ($lines[0] as $idx => $header) { + $headerMap[trim($header)] = $idx; + } $lines = array_slice($lines, 1); } @@ -35,6 +41,9 @@ class BankImportParser $colBuchungstext = (int)$format['col_buchungstext']; $colWert = (int)$format['col_wert']; + $tplDebit = !empty($format['buchungstext_template_debit']) ? $format['buchungstext_template_debit'] : null; + $tplCredit = !empty($format['buchungstext_template_credit']) ? $format['buchungstext_template_credit'] : null; + $result = []; foreach ($lines as $i => $cells) { // Leerzeilen überspringen @@ -55,11 +64,21 @@ class BankImportParser continue; } + $activeTpl = $wert < 0 ? $tplDebit : $tplCredit; + if ($activeTpl !== null) { + $buchungstext = preg_replace_callback('/\{([^}]+)\}/', function ($m) use ($headerMap, $cells) { + $idx = $headerMap[$m[1]] ?? null; + return $idx !== null ? trim($cells[$idx] ?? '') : ''; + }, $activeTpl); + } else { + $buchungstext = trim($cells[$colBuchungstext] ?? ''); + } + $result[] = [ 'buchungstag' => $buchungstag, 'wertstellung' => $wertstellung, - 'vorgang' => trim($cells[$colVorgang] ?? ''), - 'buchungstext' => trim($cells[$colBuchungstext] ?? ''), + 'vorgang' => trim($cells[$colVorgang] ?? ''), + 'buchungstext' => $buchungstext, 'wert' => $wert, 'raw_line' => implode($format['delimiter'], $cells), ]; @@ -222,6 +241,175 @@ class BankImportParser return [$col - 1, (int)($m[2] ?? 1) - 1]; } + /** + * Liest Überschriften- und erste Datenzeile aus einer Datei gemäß Format-Konfiguration. + * Gibt ['header' => [...], 'first_data' => [...]] zurück. + */ + public function peekRows(string $filePath, string $ext, array $format): array + { + try { + $fmtExt = strtolower($ext); + if ($fmtExt === 'xlsx') { + $lines = $this->readXlsx($filePath); + } else { + $lines = $this->readCsv($filePath, $format['delimiter'], $format['enclosure']); + } + + $skipRows = (int)$format['skip_rows']; + $lines = array_slice($lines, $skipRows); + + // Führende Leerzeilen überspringen + while (!empty($lines) && trim(implode('', $lines[0])) === '') { + array_shift($lines); + } + + $header = []; + if (!empty($format['has_header_row']) && isset($lines[0])) { + $header = array_map('trim', $lines[0]); + $lines = array_slice($lines, 1); + } + + $firstData = []; + foreach ($lines as $line) { + if (trim(implode('', $line)) !== '') { + $firstData = $line; + break; + } + } + + return ['header' => $header, 'first_data' => $firstData]; + } catch (\Throwable) { + return ['header' => [], 'first_data' => []]; + } + } + + /** + * Analysiert eine unbekannte CSV und versucht, die Headerzeile sowie passende + * Spaltenzuordnungen zu erkennen. Gibt null zurück, wenn keine Headerzeile gefunden. + */ + public function suggestFormat(string $filePath, string $ext): ?array + { + if (strtolower($ext) === 'xlsx') { + return null; + } + + $patterns = [ + 'col_buchungstag' => ['buchungstag', 'buchungsdatum', 'datum'], + 'col_wertstellung' => ['wertstellung'], + 'col_vorgang' => ['vorgang', 'umsatztyp'], + 'col_buchungstext' => ['buchungstext'], + 'col_wert' => ['betrag', 'umsatz'], + 'col_zahlungspflichtiger' => ['zahlungspflichtige', 'auftraggeber'], + 'col_zahlungsempfaenger' => ['zahlungsempf', 'empfänger'], + 'col_verwendungszweck' => ['verwendungszweck'], + ]; + + foreach ([';', ',', "\t"] as $delimiter) { + try { + $lines = $this->readCsv($filePath, $delimiter, '"'); + } catch (\Throwable) { + continue; + } + + if (count($lines) < 2) { + continue; + } + + for ($skipRows = 0; $skipRows <= 10; $skipRows++) { + $candidate = $lines[$skipRows] ?? null; + if ($candidate === null) { + break; + } + + $cells = array_map('trim', $candidate); + $nonEmpty = array_filter($cells, fn($c) => $c !== ''); + + if (count($nonEmpty) < 3) { + continue; + } + + // Zeile gilt als Header wenn Zellen Text enthalten (keine reinen Zahlen/Daten) + $textCells = array_filter($nonEmpty, function (string $c): bool { + if (is_numeric($c)) return false; + if (preg_match('/^\d{1,2}[.\-\/]\d{1,2}[.\-\/]\d{2,4}$/', $c)) return false; + return true; + }); + + if (count($textCells) < 2) { + continue; + } + + // Mindestens ein Banking-Keyword muss vorkommen + $cellsLower = array_map('mb_strtolower', $cells); + $hasKeyword = false; + foreach ($patterns as $keywords) { + foreach ($keywords as $kw) { + foreach ($cellsLower as $cell) { + if (str_contains($cell, $kw)) { + $hasKeyword = true; + break 3; + } + } + } + } + + if (!$hasKeyword) { + continue; + } + + // Spalten-Mapping aufbauen + $mapping = []; + $used = []; + foreach ($patterns as $field => $keywords) { + foreach ($keywords as $kw) { + foreach ($cellsLower as $idx => $cell) { + if (!in_array($idx, $used, true) && str_contains($cell, $kw)) { + $mapping[$field] = $idx; + $used[] = $idx; + break 2; + } + } + } + } + + // Erste Datenzeile nach dem Header ermitteln + $firstData = []; + for ($di = $skipRows + 1; $di < count($lines); $di++) { + $row = array_map('trim', $lines[$di]); + if (trim(implode('', $row)) !== '') { + $firstData = $row; + break; + } + } + + return [ + 'skip_rows' => $skipRows, + 'delimiter' => $delimiter, + 'enclosure' => '"', + 'has_header_row' => 1, + 'header' => $cells, + 'first_data' => $firstData, + 'mapping' => $mapping, + ]; + } + } + + return null; + } + + /** + * Prüft, ob eine Datei mit dem angegebenen Format mindestens eine gültige Zeile liefert. + * Delegiert an parse(), damit Detection und Import immer identisch beurteilen. + */ + public function isFormatMatch(string $filePath, string $ext, array $format): bool + { + try { + return count($this->parse($filePath, $ext, $format)) > 0; + } catch (\Throwable) { + return false; + } + } + private function parseDate(string $value, string $format): ?string { $value = trim($value); diff --git a/src/Views/bank/index.php b/src/Views/bank/index.php index e1e5d06..9400de1 100644 --- a/src/Views/bank/index.php +++ b/src/Views/bank/index.php @@ -1,7 +1,8 @@ View::h($v); -$fmt = fn($v) => View::formatMoney($v); +$h = fn($v) => View::h($v); +$fmt = fn($v) => View::formatMoney($v); +$kTextClr = fn(string $hex): string => (0.299*hexdec(substr($hex,1,2))+0.587*hexdec(substr($hex,3,2))+0.114*hexdec(substr($hex,5,2))) > 128 ? '#1e293b' : '#ffffff'; $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']; ?> @@ -30,6 +31,39 @@ $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','D + +
+ + + +
+ + + + + Zurücksetzen
@@ -92,7 +139,13 @@ $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','D -
+
+ + + + + +
@@ -149,3 +202,33 @@ $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','D + + diff --git a/src/Views/bank/show.php b/src/Views/bank/show.php index 9919174..875ea63 100644 --- a/src/Views/bank/show.php +++ b/src/Views/bank/show.php @@ -1,8 +1,9 @@ View::h($v); -$fmt = fn($v) => View::formatMoney($v); +$h = fn($v) => View::h($v); +$fmt = fn($v) => View::formatMoney($v); +$kTextClr = fn(string $hex): string => (0.299*hexdec(substr($hex,1,2))+0.587*hexdec(substr($hex,3,2))+0.114*hexdec(substr($hex,5,2))) > 128 ? '#1e293b' : '#ffffff'; $wert = (float)$transaction['wert']; $isPlus = $wert >= 0; @@ -34,7 +35,13 @@ $restFormatted = number_format($remaining, 2, ',', '.');
Buchungstext
- + + + + + + +
Gesamt
diff --git a/src/Views/bank/upload.php b/src/Views/bank/upload.php index 81112cf..a4ca827 100644 --- a/src/Views/bank/upload.php +++ b/src/Views/bank/upload.php @@ -1,6 +1,6 @@ View::h($v); ?> -
+
@@ -25,18 +25,83 @@
- -
- - -
Format der hochgeladenen Datei
+ + + + + + + + -
+
Abbrechen
@@ -46,28 +111,172 @@ diff --git a/src/Views/belege/detail.php b/src/Views/belege/detail.php index 68b7d87..74fad95 100644 --- a/src/Views/belege/detail.php +++ b/src/Views/belege/detail.php @@ -3,6 +3,7 @@ use Steuerkram\View; use Steuerkram\Models\Setting; $h = fn($v) => View::h($v); $fmt = fn($v) => View::formatMoney($v); +$kTextClr = fn(string $hex): string => (0.299*hexdec(substr($hex,1,2))+0.587*hexdec(substr($hex,3,2))+0.114*hexdec(substr($hex,5,2))) > 128 ? '#1e293b' : '#ffffff'; $testMode = Setting::isTestMode(); $suppliers = $suppliers ?? []; ?> @@ -307,8 +308,14 @@ $suppliers = $suppliers ?? []; - + + + + + + + = 0 ? '+' : '' ?> € @@ -337,8 +344,14 @@ $suppliers = $suppliers ?? []; Bankbuchung - + + + + + + + = 0 ? '+' : '' ?> € diff --git a/src/Views/dashboard.php b/src/Views/dashboard.php index 4ee1de6..4022aa5 100644 --- a/src/Views/dashboard.php +++ b/src/Views/dashboard.php @@ -9,11 +9,21 @@ $fmt = fn($v) => View::formatMoney($v);
Umsatz
Betriebseinnahmen (netto)
+
+ : +  ·  + : +
Ausgaben
Betriebsausgaben (netto)
+
+ : +  ·  + : +
Gewinn / Verlust
@@ -21,6 +31,11 @@ $fmt = fn($v) => View::formatMoney($v);
EÜR-Ergebnis
+
+ : +  ·  + : +
Offene Rechnungen
diff --git a/src/Views/layout/nav.php b/src/Views/layout/nav.php index c9e7b70..b772a59 100644 --- a/src/Views/layout/nav.php +++ b/src/Views/layout/nav.php @@ -93,6 +93,10 @@ function navActive(string $prefix, string $current): string { Kontenrahmen + + + Bankimport +
diff --git a/src/Views/settings/bankimport.php b/src/Views/settings/bankimport.php new file mode 100644 index 0000000..7313702 --- /dev/null +++ b/src/Views/settings/bankimport.php @@ -0,0 +1,784 @@ + View::h($v); ?> + + (0.299*hexdec(substr($hex,1,2))+0.587*hexdec(substr($hex,3,2))+0.114*hexdec(substr($hex,5,2))) > 128 ? '#1e293b' : '#ffffff'; + +$dbLabels = [ + 'buchungstag' => 'Buchungstag', + 'wertstellung' => 'Wertstellung', + 'vorgang' => 'Vorgang / Umsatztyp', + 'buchungstext' => 'Buchungstext', + 'wert' => 'Betrag', + 'zahlungspflichtiger' => 'Zahlungspflichtiger', + 'zahlungsempfaenger' => 'Zahlungsempfänger', + 'verwendungszweck' => 'Verwendungszweck', +]; + +$knownHeaders = [ + 'Comdirect' => [0=>'Buchungstag',1=>'Wertstellung',2=>'Vorgang',3=>'Buchungstext',4=>'Betrag'], + 'DKB Bank' => [0=>'Buchungsdatum',1=>'Wertstellung',2=>'Status',3=>'Zahlungspflichtige*r', + 4=>'Zahlungsempfänger*in',5=>'Verwendungszweck',6=>'Umsatztyp',7=>'IBAN', + 8=>'Betrag (€)',9=>'Gläubiger-ID',10=>'Mandatsreferenz',11=>'Kundenreferenz'], +]; + +$dateFormatOptions = [ + 'd.m.Y' => '15.05.2026', + 'd.m.y' => '15.05.26', + 'Y-m-d' => '2026-05-15', + 'd/m/Y' => '15/05/2026', + 'm/d/Y' => '05/15/2026', + 'Y.m.d' => '2026.05.15', + 'd-m-Y' => '15-05-2026', +]; +?> + + +
+ +
+ +
+ + + + 'Buchungstag', + 'wertstellung' => 'Wertstellung', + 'vorgang' => 'Vorgang / Umsatztyp', + 'wert' => 'Betrag', + 'zahlungspflichtiger' => 'Zahlungspflichtiger', + 'zahlungsempfaenger' => 'Zahlungsempfänger', + 'verwendungszweck' => 'Verwendungszweck', +]; +?> +
+
+
Neues Format (automatischer Vorschlag)
+ Nicht gespeichert +
+
+ + +

+ Das Programm hat die Headerzeile in der CSV gefunden und Spalten automatisch zugeordnet. + Bitte prüfe die Einstellungen und klicke auf Speichern, um das neue Format anzulegen. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name
Anzeige-Kürzel
Übersprungene Zeilen
Headerzeile vorhanden + +
Trennzeichen
Anführungszeichen" maxlength="1" required style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace">
Datumsformat + +
Dezimaltrennzeichen
Tausendertrennzeichen
+ + + +
+ $col): ?> + : | + +
+ +
Erste Datenzeile
+
+ $val): ?> + : | + +
+ + + + + + + + + + + + + + $label): + $colKey = 'col_' . $field; + $colIdx = $sugMapping[$colKey] ?? null; + $headerVal = ($colIdx !== null && isset($sugHeader[$colIdx])) ? $sugHeader[$colIdx] : ''; + $isNullInt = in_array($field, ['zahlungspflichtiger', 'zahlungsempfaenger', 'verwendungszweck'], true); + ?> + + + + + + + + + + + + + + + + + + + + +
DatenbankfeldSpalte (0-basiert)Erkannte CSV-Überschrift
+ + style="width:65px;padding:.25rem .4rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"> + + + + + (Sp.) + + + – + +
Buchungstext (Betrag < 0) + +
Buchungstext (Betrag > 0) + +
+ Platzhalter {CSV-Überschrift} werden durch den Wert der jeweiligen Spalte ersetzt. + Optional: Zahlungspflichtiger, Zahlungsempfänger, Verwendungszweck (für Templates). +
+ +
+ + +
+ + +
+
+ + + +
+
Keine Importformate in der Datenbank vorhanden.
+
+ + +

+ Klick auf einen Wert zum Bearbeiten — Enter oder Klick außerhalb speichert, Escape bricht ab. +

+ + + +
+
+
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Anzeige-Kürzel + +
+ + + +
+
Übersprungene Zeilen
Headerzeile vorhanden + +
Trennzeichen
Anführungszeichen
Datumsformat + + + → + +
Dezimaltrennzeichen
Tausendertrennzeichen + + + + keines + +
+ + + + + + + + + + + + + + + + + + + + + + + + (Betrag < 0)', $fmt['buchungstext_template_debit'] ?? ''], + ['buchungstext_template_credit', 'Buchungstext (Betrag ≥ 0)', $fmt['buchungstext_template_credit'] ?? ''], + ] as [$tplField, $tplLabel, $tplValue]): + ?> + + + + + + + + + + + + + + +
DatenbankfeldSpalte (0-basiert)CSV-Überschrift
+ + + + + – + + + + + + + – + +
+ + + + + nicht gesetzt + + +
+ Platzhalter {CSV-Überschrift} werden durch den Wert der jeweiligen Spalte ersetzt. +
+ +
+
+ + + + + + diff --git a/src/version.txt b/src/version.txt index d768cc0..ac0ef50 100644 --- a/src/version.txt +++ b/src/version.txt @@ -1 +1 @@ -1-260516 +1-260517 diff --git a/tests/34_BankImportDkbTest.php b/tests/34_BankImportDkbTest.php new file mode 100644 index 0000000..92537e9 --- /dev/null +++ b/tests/34_BankImportDkbTest.php @@ -0,0 +1,145 @@ + 4, + 'has_header_row' => 1, + 'delimiter' => ';', + 'enclosure' => '"', + 'col_buchungstag' => 0, + 'col_wertstellung' => 1, + 'col_vorgang' => 6, + 'col_buchungstext' => 5, + 'col_wert' => 8, + 'buchungstext_template_debit' => 'Auftraggeber: {Zahlungsempfänger*in} Buchungstext: {Verwendungszweck}', + 'buchungstext_template_credit' => 'Auftraggeber: {Zahlungspflichtige*r} Buchungstext: {Verwendungszweck}', + 'date_format' => 'd.m.Y', + 'decimal_separator' => ',', + 'thousands_separator' => '.', + ]; + } + + private function dkbHeader(): string + { + return '"Buchungsdatum";"Wertstellung";"Status";"Zahlungspflichtige*r";' + . '"Zahlungsempfänger*in";"Verwendungszweck";"Umsatztyp";"IBAN";"Betrag (€)";' + . '"Gläubiger-ID";"Mandatsreferenz";"Kundenreferenz"'; + } + + public function run(): void + { + $parser = new \Steuerkram\Services\BankImportParser(); + $fmt = $this->dkbFormat(); + + // ── Negativer Betrag: Zahlungsempfänger*in im Buchungstext ─────── + $csv = "Skip1\nSkip2\nSkip3\nSkip4\n" + . $this->dkbHeader() . "\n" + . '"15.05.2026";"15.05.2026";"Gebucht";"Max Muster";"Amazon GmbH";"Abo Monatsbeitrag";"Lastschrift";"DE123";"- 9,99";"GL01";"MR01";"KR01"' . "\n"; + + $tmp = $this->makeTempCsv($csv); + $rows = $parser->parse($tmp, 'csv', $fmt); + unlink($tmp); + + $this->assertEquals(1, count($rows), 'DKB negativer Betrag: 1 Buchung geparst'); + $this->assertEquals('2026-05-15', $rows[0]['buchungstag'], 'Buchungstag korrekt'); + $this->assertEquals('2026-05-15', $rows[0]['wertstellung'], 'Wertstellung korrekt'); + $this->assertEquals('Lastschrift', $rows[0]['vorgang'], 'Umsatztyp als vorgang'); + $this->assertEquals(-9.99, $rows[0]['wert'], 'Betrag -9,99 korrekt'); + $this->assertEquals( + 'Auftraggeber: Amazon GmbH Buchungstext: Abo Monatsbeitrag', + $rows[0]['buchungstext'], + 'Negativer Betrag: Template debit mit Zahlungsempfänger*in' + ); + + // ── Positiver Betrag: Zahlungspflichtige*r im Buchungstext ─────── + $csv2 = "Skip1\nSkip2\nSkip3\nSkip4\n" + . $this->dkbHeader() . "\n" + . '"14.05.2026";"14.05.2026";"Gebucht";"Kunde AG";"Eigenkonto";"Rechnung 2026-042";"Gutschrift";"DE456";"1.500,00";"";"";"KR02"' . "\n"; + + $tmp2 = $this->makeTempCsv($csv2); + $rows2 = $parser->parse($tmp2, 'csv', $fmt); + unlink($tmp2); + + $this->assertEquals(1, count($rows2), 'DKB positiver Betrag: 1 Buchung geparst'); + $this->assertEquals(1500.00, $rows2[0]['wert'], 'Betrag 1.500,00 korrekt'); + $this->assertEquals( + 'Auftraggeber: Kunde AG Buchungstext: Rechnung 2026-042', + $rows2[0]['buchungstext'], + 'Positiver Betrag: Template credit mit Zahlungspflichtige*r' + ); + + // ── Betrag 0: credit-Template (>= 0) ──────────────────────────── + $csv3 = "Skip1\nSkip2\nSkip3\nSkip4\n" + . $this->dkbHeader() . "\n" + . '"13.05.2026";"13.05.2026";"Gebucht";"Nullbetrag GmbH";"Gegenkonto";"Storno";"Umbuchung";"DE789";"0,00";"";"";"KR03"' . "\n"; + + $tmp3 = $this->makeTempCsv($csv3); + $rows3 = $parser->parse($tmp3, 'csv', $fmt); + unlink($tmp3); + + $this->assertEquals(1, count($rows3), 'DKB Betrag 0: 1 Buchung geparst'); + $this->assertEquals( + 'Auftraggeber: Nullbetrag GmbH Buchungstext: Storno', + $rows3[0]['buchungstext'], + 'Betrag 0: credit-Template genutzt' + ); + + // ── Unbekannte Placeholder werden als leer aufgelöst ───────────── + $fmtUnknown = $fmt; + $fmtUnknown['buchungstext_template_debit'] = 'Test: {NichtVorhanden} Ende'; + $csv5 = "Skip1\nSkip2\nSkip3\nSkip4\n" + . $this->dkbHeader() . "\n" + . '"12.05.2026";"12.05.2026";"Gebucht";"A";"B";"C";"Lastschrift";"DE";"- 1,00";"";"";"KR05"' . "\n"; + + $tmp5 = $this->makeTempCsv($csv5); + $rows5 = $parser->parse($tmp5, 'csv', $fmtUnknown); + unlink($tmp5); + + $this->assertEquals('Test: Ende', $rows5[0]['buchungstext'], + 'Unbekannter Placeholder wird als leerer String aufgelöst'); + + // ── Format ohne Template: col_buchungstext wird direkt genutzt ─── + $fmtStd = [ + 'skip_rows' => 3, + 'has_header_row' => 1, + 'delimiter' => ';', + 'enclosure' => '"', + 'col_buchungstag' => 0, + 'col_wertstellung' => 1, + 'col_vorgang' => 2, + 'col_buchungstext' => 3, + 'col_wert' => 4, + 'date_format' => 'd.m.Y', + 'decimal_separator' => ',', + 'thousands_separator' => '.', + ]; + + $csv4 = "Z1\nZ2\nZ3\n" + . "Buchungstag;Wertstellung;Vorgang;Buchungstext;Betrag\n" + . "15.05.2026;15.05.2026;Lastschrift;Normaler Text;\"-50,00\"\n"; + + $tmp4 = $this->makeTempCsv($csv4); + $rows4 = $parser->parse($tmp4, 'csv', $fmtStd); + unlink($tmp4); + + $this->assertEquals('Normaler Text', $rows4[0]['buchungstext'], + 'Format ohne Template: col_buchungstext direkt genutzt'); + } +} diff --git a/tests/35_BankImportSuggestTest.php b/tests/35_BankImportSuggestTest.php new file mode 100644 index 0000000..aeeae35 --- /dev/null +++ b/tests/35_BankImportSuggestTest.php @@ -0,0 +1,71 @@ +makeTempCsv($csv); + $result = $parser->suggestFormat($file, 'csv'); + + $this->assert($result !== null, 'suggestFormat() gibt ein Ergebnis zurück'); + $this->assertEquals(4, $result['skip_rows'], 'skip_rows korrekt erkannt (4)'); + $this->assertEquals(';', $result['delimiter'], 'Delimiter korrekt erkannt (;)'); + $this->assertEquals(1, $result['has_header_row'], 'has_header_row gesetzt'); + $this->assertNotEmpty($result['header'], 'header-Array nicht leer'); + $this->assertNotEmpty($result['mapping'], 'mapping-Array nicht leer'); + $this->assert(isset($result['mapping']['col_buchungstag']), 'col_buchungstag gemappt'); + $this->assert(isset($result['mapping']['col_wertstellung']), 'col_wertstellung gemappt'); + $this->assert(isset($result['mapping']['col_wert']), 'col_wert gemappt (Betrag)'); + + unlink($file); + + // CSV ohne Banking-Keywords → kein Vorschlag + $csvOhneKeywords = implode("\n", [ + 'Foo;Bar;Baz', + '1;2;3', + ]); + $file2 = $this->makeTempCsv($csvOhneKeywords); + $result2 = $parser->suggestFormat($file2, 'csv'); + $this->assert($result2 === null, 'suggestFormat() gibt null zurück wenn kein Banking-Header erkannt'); + unlink($file2); + + // Comdirect-ähnliche CSV (kein skip_rows) + $csvComdirect = implode("\n", [ + '"Buchungstag";"Wertstellung";"Vorgang";"Buchungstext";"Betrag (€)"', + '"10.01.2026";"10.01.2026";"Lastschrift";"Amazon";"−19,99"', + ]); + $file3 = $this->makeTempCsv($csvComdirect); + $result3 = $parser->suggestFormat($file3, 'csv'); + $this->assert($result3 !== null, 'suggestFormat() erkennt Comdirect-ähnliche CSV'); + $this->assertEquals(0, $result3['skip_rows'], 'skip_rows=0 bei direktem Header'); + unlink($file3); + } +}