Merge pull request 'Feature: Bank-Import mit konfigurierbaren Formaten, Buchungstext-Templates und Dublettenprüfung' (#75) from fds2610-bot/Steuerkram:main into main

Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/75
This commit is contained in:
fds2610 2026-05-17 13:49:24 +02:00
commit d784e46af8
51 changed files with 2734 additions and 320 deletions

View file

@ -2,6 +2,49 @@
--- ---
## 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
- **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 ## 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 - 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

View file

@ -12,6 +12,7 @@ services:
- ./tests:/var/www/html/tests - ./tests:/var/www/html/tests
- ./scripts:/var/www/html/scripts - ./scripts:/var/www/html/scripts
environment: environment:
TZ: Europe/Berlin
DB_HOST: db DB_HOST: db
DB_NAME: steuerkram DB_NAME: steuerkram
DB_USER: steuerkram DB_USER: steuerkram
@ -29,6 +30,7 @@ services:
db: db:
image: mariadb:11 image: mariadb:11
environment: environment:
TZ: Europe/Berlin
MARIADB_ROOT_PASSWORD: rootpass MARIADB_ROOT_PASSWORD: rootpass
MARIADB_DATABASE: steuerkram MARIADB_DATABASE: steuerkram
MARIADB_USER: steuerkram MARIADB_USER: steuerkram

View file

@ -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) - **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 - **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 - **Stornobuchungen**: GoBD-konforme Gegenbuchung statt Löschung
- **GoBD-Schutz**: MariaDB `BEFORE UPDATE/DELETE`-Trigger verhindern nachträgliche Manipulation gebuchter Sätze - **GoBD-Schutz**: MariaDB `BEFORE UPDATE/DELETE`-Trigger verhindern nachträgliche Manipulation gebuchter Sätze
- **Journal**: gefilterte Übersicht aller Buchungen mit CSV-Export - **Journal**: gefilterte Übersicht aller Buchungen mit CSV-Export
@ -62,9 +62,24 @@ 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
- **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) ## 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 - ELSTER XML-Export
- Drill-down: Klick auf eine Kennzahl zeigt alle zugehörigen Buchungszeilen - Drill-down: Klick auf eine Kennzahl zeigt alle zugehörigen Buchungszeilen

View file

@ -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;

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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;

View file

@ -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';

View file

@ -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';

View file

@ -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;

View file

@ -1,2 +1,3 @@
upload_max_filesize = 20M upload_max_filesize = 20M
post_max_size = 20M post_max_size = 20M
date.timezone = Europe/Berlin

View file

@ -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', fn($p) => (new BankController())->index($p));
$router->get('/bank/upload', fn($p) => (new BankController())->uploadForm($p)); $router->get('/bank/upload', fn($p) => (new BankController())->uploadForm($p));
$router->post('/bank/upload', fn($p) => (new BankController())->upload($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->post('/bank/upload/confirm', fn($p) => (new BankController())->uploadConfirm($p));
$router->get('/bank/{id}', fn($p) => (new BankController())->show($p)); $router->get('/bank/{id}', fn($p) => (new BankController())->show($p));
$router->post('/bank/{id}/status', fn($p) => (new BankController())->updateStatus($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/mail-test', fn($p) => (new SettingsController())->mailTest($p));
$router->post('/settings/logo', fn($p) => (new SettingsController())->uploadLogo($p)); $router->post('/settings/logo', fn($p) => (new SettingsController())->uploadLogo($p));
$router->post('/settings/logo/delete', fn($p) => (new SettingsController())->deleteLogo($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 ──────────────────────────────────────────── // ── Login / Logout ────────────────────────────────────────────
$router->get('/login', fn($p) => (new LoginController())->showLogin($p)); $router->get('/login', fn($p) => (new LoginController())->showLogin($p));

View file

@ -316,6 +316,17 @@ td { padding: .65rem 1rem; vertical-align: middle; }
.badge-gray { background: #f1f5f9; color: #475569; } .badge-gray { background: #f1f5f9; color: #475569; }
.badge-purple { background: #ede9fe; color: #6d28d9; } .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 / Filter bar ────────────────────────────────────── */
.toolbar { .toolbar {
display: flex; display: flex;

View file

@ -52,4 +52,27 @@ class Auth
{ {
return isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null; 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.');
}
}
} }

22
src/Constants.php Normal file
View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Steuerkram;
class Constants
{
// Buchungstypen
const BUCHUNGSTYP_EINNAHME = 'Einnahme';
const BUCHUNGSTYP_AUSGABE = 'Ausgabe';
// Vorsteuer-Konten (SKR03)
const KONTO_VST_19 = '1576';
const KONTO_VST_7 = '1571';
const KONTO_VST_IGE_19 = '1574'; // Vorsteuer ig. Erwerb 19 %
// Umsatzsteuer-Konten (SKR03)
const KONTO_UST_19 = '1776';
const KONTO_UST_7 = '1771';
const KONTO_UST_IGE_19 = '1774'; // USt ig. Erwerb 19 %
}

View file

@ -9,11 +9,15 @@ use Steuerkram\Models\Booking;
use Steuerkram\Models\Beleg; use Steuerkram\Models\Beleg;
use Steuerkram\View; use Steuerkram\View;
class AccountController class AccountController extends BaseController
{ {
public function dashboard(array $params): void 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 // Quick stats
$stats = []; $stats = [];
@ -28,6 +32,20 @@ class AccountController
); );
$stats['umsatz'] = (float)($umsatz['total'] ?? 0); $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
$ausgaben = \Steuerkram\Database::fetchOne( $ausgaben = \Steuerkram\Database::fetchOne(
'SELECT COALESCE(SUM(bk.betrag_netto),0) as total 'SELECT COALESCE(SUM(bk.betrag_netto),0) as total
@ -37,8 +55,27 @@ class AccountController
[$year] [$year]
); );
$stats['ausgaben'] = (float)($ausgaben['total'] ?? 0); $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 // Offene Rechnungen
$offen = \Steuerkram\Database::fetchOne( $offen = \Steuerkram\Database::fetchOne(
@ -268,12 +305,5 @@ class AccountController
if (empty($data['kontenart'])) $errors['kontenart'] = 'Pflichtfeld'; if (empty($data['kontenart'])) $errors['kontenart'] = 'Pflichtfeld';
return $errors; 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();
}
} }

View file

@ -8,7 +8,7 @@ use Steuerkram\Models\Asset;
use Steuerkram\Models\AssetDepreciation; use Steuerkram\Models\AssetDepreciation;
use Steuerkram\View; use Steuerkram\View;
class AssetController class AssetController extends BaseController
{ {
public function index(array $params): void public function index(array $params): void
{ {
@ -241,12 +241,5 @@ class AssetController
return $errors; 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();
}
} }

View file

@ -13,7 +13,7 @@ use Steuerkram\Models\Invoice;
use Steuerkram\Services\BankImportParser; use Steuerkram\Services\BankImportParser;
use Steuerkram\View; use Steuerkram\View;
class BankController class BankController extends BaseController
{ {
private const ALLOWED_EXT = ['csv', 'txt', 'xlsx']; private const ALLOWED_EXT = ['csv', 'txt', 'xlsx'];
@ -24,18 +24,20 @@ class BankController
$filter['status'] = 'pending'; $filter['status'] = 'pending';
} }
$page = max(1, (int)($_GET['page'] ?? 1)); $page = max(1, (int)($_GET['page'] ?? 1));
$result = BankTransaction::all($filter, $page); $result = BankTransaction::all($filter, $page);
$formats = BankImportFormat::all();
$view = new View(); $view = new View();
$view->render('layout/base.php', [ $view->render('layout/base.php', [
'pageTitle' => 'Bank buchen', 'pageTitle' => 'Bank buchen',
'content' => $this->renderView('bank/index', [ 'content' => $this->renderView('bank/index', [
'rows' => $result['rows'], 'rows' => $result['rows'],
'total' => $result['total'], 'total' => $result['total'],
'pages' => $result['pages'], 'pages' => $result['pages'],
'page' => $page, 'page' => $page,
'filter' => $filter, 'filter' => $filter,
'formats' => $formats,
]), ]),
'topbarActions' => '<a href="/bank/upload" class="btn btn-primary">Upload</a>', 'topbarActions' => '<a href="/bank/upload" class="btn btn-primary">Upload</a>',
]); ]);
@ -46,14 +48,71 @@ class BankController
$formats = BankImportFormat::all(); $formats = BankImportFormat::all();
$view = new View(); $view = new View();
$view->render('layout/base.php', [ $view->render('layout/base.php', [
'pageTitle' => 'Bank-Buchungen importieren', 'pageTitle' => 'Bank-Buchungen importieren',
'content' => $this->renderView('bank/upload', [ 'topbarActions' => '<a href="/settings/bankimport" class="btn btn-secondary btn-sm">Formate bearbeiten</a>',
'content' => $this->renderView('bank/upload', [
'formats' => $formats, 'formats' => $formats,
'errors' => [], '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 public function upload(array $params): void
{ {
$formatId = (int)($_POST['format_id'] ?? 0); $formatId = (int)($_POST['format_id'] ?? 0);
@ -273,7 +332,9 @@ class BankController
'text/xml' => 'xml', 'text/xml' => '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])) { if (!isset($allowedMime[$mime])) {
View::redirect("/bank/$id", 'Nicht erlaubtes Dateiformat.', 'error'); View::redirect("/bank/$id", 'Nicht erlaubtes Dateiformat.', 'error');
} }
@ -303,12 +364,4 @@ class BankController
View::redirect("/bank/$id", 'Beleg hochgeladen – bitte jetzt verknüpfen.'); 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();
}
} }

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
class BaseController
{
protected function renderView(string $template, array $data): string
{
extract($data, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/Views/' . $template . '.php';
return ob_get_clean();
}
}

View file

@ -14,7 +14,7 @@ use Steuerkram\Services\ImageCropService;
use Steuerkram\Models\BankTransaction; use Steuerkram\Models\BankTransaction;
use Steuerkram\Services\KontierungsvorschlagService; use Steuerkram\Services\KontierungsvorschlagService;
class BelegController class BelegController extends BaseController
{ {
private const ALLOWED_MIME = [ private const ALLOWED_MIME = [
'application/pdf' => 'pdf', 'application/pdf' => 'pdf',
@ -45,7 +45,7 @@ class BelegController
'page' => $page, 'page' => $page,
'filter' => $filter, 'filter' => $filter,
]), ]),
'topbarActions' => '<a href="/belege/export?' . http_build_query($filter) . '" class="btn btn-secondary">CSV Export</a> <a href="/belege/upload" class="btn btn-primary">+ Beleg hochladen</a>', 'topbarActions' => '<a href="/belege/export?' . View::buildQuery($filter) . '" class="btn btn-secondary">CSV Export</a> <a href="/belege/upload" class="btn btn-primary">+ Beleg hochladen</a>',
]); ]);
} }
@ -114,8 +114,10 @@ class BelegController
$errors['file'] = 'Keine gültige Datei übermittelt.'; $errors['file'] = 'Keine gültige Datei übermittelt.';
} else { } else {
$file = $_FILES['beleg_file']; $file = $_FILES['beleg_file'];
$mime = mime_content_type($file['tmp_name']); $finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeKey = strtolower($mime); $mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
$mimeKey = strtolower((string)$mime);
if (!isset(self::ALLOWED_MIME[$mimeKey])) { if (!isset(self::ALLOWED_MIME[$mimeKey])) {
$errors['file'] = 'Nicht erlaubter Dateityp. Erlaubt: PDF, PNG, JPEG, TIFF, XML.'; $errors['file'] = 'Nicht erlaubter Dateityp. Erlaubt: PDF, PNG, JPEG, TIFF, XML.';
@ -141,7 +143,9 @@ class BelegController
} }
$file = $_FILES['beleg_file']; $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)]; $ext = self::ALLOWED_MIME[strtolower($mime)];
$belegNr = Beleg::generateBelegNr(); $belegNr = Beleg::generateBelegNr();
$year = !empty($_POST['beleg_datum']) $year = !empty($_POST['beleg_datum'])
@ -300,13 +304,20 @@ class BelegController
exit; exit;
} }
$absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path']; $basePath = (string)realpath(dirname(__DIR__, 2) . '/public/static/uploads');
if (!file_exists($absPath)) { http_response_code(404); return; } $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-Type: ' . $beleg['mime_type']);
header('Content-Disposition: inline; filename="' . $beleg['filename'] . '"'); header('Content-Disposition: inline; filename="' . $beleg['filename'] . '"');
header('Content-Length: ' . filesize($absPath)); header('Content-Length: ' . filesize($realPath));
readfile($absPath); readfile($realPath);
} }
public function updateMeta(array $params): void public function updateMeta(array $params): void
@ -440,12 +451,4 @@ class BelegController
} }
return $value; 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();
}
} }

View file

@ -12,7 +12,7 @@ use Steuerkram\Models\FiscalYear;
use Steuerkram\Models\Setting; use Steuerkram\Models\Setting;
use Steuerkram\View; use Steuerkram\View;
class BookingController class BookingController extends BaseController
{ {
public function journal(array $params): void public function journal(array $params): void
{ {
@ -324,12 +324,5 @@ class BookingController
} }
return $value; 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();
}
} }

View file

@ -7,7 +7,7 @@ namespace Steuerkram\Controllers;
use Steuerkram\Models\Customer; use Steuerkram\Models\Customer;
use Steuerkram\View; use Steuerkram\View;
class CustomerController class CustomerController extends BaseController
{ {
public function index(array $params): void public function index(array $params): void
{ {
@ -137,12 +137,5 @@ class CustomerController
if (empty($data['ort'])) $errors['ort'] = 'Pflichtfeld'; if (empty($data['ort'])) $errors['ort'] = 'Pflichtfeld';
return $errors; 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();
}
} }

View file

@ -10,7 +10,7 @@ use Steuerkram\Services\EuerCalculator;
use Steuerkram\Services\PdfEuerService; use Steuerkram\Services\PdfEuerService;
use Steuerkram\View; use Steuerkram\View;
class EuerController class EuerController extends BaseController
{ {
public function index(array $params): void public function index(array $params): void
{ {
@ -70,12 +70,5 @@ class EuerController
$svc = new PdfEuerService(); $svc = new PdfEuerService();
$svc->render($euer, $settings); $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();
}
} }

View file

@ -14,7 +14,7 @@ use Steuerkram\Services\EmailService;
use Steuerkram\Services\PdfInvoiceService; use Steuerkram\Services\PdfInvoiceService;
use Steuerkram\View; use Steuerkram\View;
class InvoiceController class InvoiceController extends BaseController
{ {
public function index(array $params): void public function index(array $params): void
{ {
@ -581,12 +581,5 @@ class InvoiceController
} }
return $errors; 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();
}
} }

View file

@ -4,16 +4,18 @@ declare(strict_types=1);
namespace Steuerkram\Controllers; namespace Steuerkram\Controllers;
use Steuerkram\Models\BankImportFormat;
use Steuerkram\Models\Setting; use Steuerkram\Models\Setting;
use Steuerkram\Services\EmailService; use Steuerkram\Services\EmailService;
use Steuerkram\View; use Steuerkram\View;
class SettingsController class SettingsController extends BaseController
{ {
public function show(array $params): void public function show(array $params): void
{ {
$settings = Setting::get(); $settings = Setting::get();
$version = trim(@file_get_contents(dirname(__DIR__) . '/version.txt') ?: ''); $versionFile = dirname(__DIR__) . '/version.txt';
$version = file_exists($versionFile) ? trim(file_get_contents($versionFile)) : '';
$view = new View(); $view = new View();
$view->render('layout/base.php', [ $view->render('layout/base.php', [
@ -38,7 +40,8 @@ class SettingsController
if (empty($data['ort'])) $errors['ort'] = 'Pflichtfeld'; if (empty($data['ort'])) $errors['ort'] = 'Pflichtfeld';
if ($errors) { 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 = new View();
$view->render('layout/base.php', [ $view->render('layout/base.php', [
'pageTitle' => 'Einstellungen', 'pageTitle' => 'Einstellungen',
@ -62,8 +65,10 @@ class SettingsController
return; return;
} }
$file = $_FILES['logo']; $file = $_FILES['logo'];
$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 (!in_array($mime, ['image/png', 'image/jpeg'])) { if (!in_array($mime, ['image/png', 'image/jpeg'])) {
View::redirect('/settings', 'Logo muss PNG oder JPEG sein.', 'error'); View::redirect('/settings', 'Logo muss PNG oder JPEG sein.', 'error');
@ -134,11 +139,250 @@ class SettingsController
View::redirect('/settings', 'Logo gelöscht.'); View::redirect('/settings', 'Logo gelöscht.');
} }
private function renderView(string $template, array $data): string public function bankImport(array $params): void
{ {
extract($data, EXTR_SKIP); $formats = BankImportFormat::all();
ob_start(); $suggestion = $_SESSION['bank_import_suggestion'] ?? null;
include dirname(__DIR__) . '/Views/' . $template . '.php';
return ob_get_clean(); $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]);
}
} }

View file

@ -7,7 +7,7 @@ namespace Steuerkram\Controllers;
use Steuerkram\Models\Supplier; use Steuerkram\Models\Supplier;
use Steuerkram\View; use Steuerkram\View;
class SupplierController class SupplierController extends BaseController
{ {
public function index(array $params): void public function index(array $params): void
{ {
@ -133,12 +133,5 @@ class SupplierController
header('Content-Type: application/json'); header('Content-Type: application/json');
echo json_encode(array_map(fn($s) => ['id' => $s['id'], 'name' => $s['name']], $suppliers)); 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();
}
} }

View file

@ -7,7 +7,7 @@ namespace Steuerkram\Controllers;
use Steuerkram\Models\User; use Steuerkram\Models\User;
use Steuerkram\View; use Steuerkram\View;
class UserAdminController class UserAdminController extends BaseController
{ {
public function index(array $params): void public function index(array $params): void
{ {
@ -140,12 +140,5 @@ class UserAdminController
$msg = $newState ? 'Benutzer aktiviert.' : 'Benutzer deaktiviert.'; $msg = $newState ? 'Benutzer aktiviert.' : 'Benutzer deaktiviert.';
View::redirect('/useradmin', $msg); 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();
}
} }

View file

@ -10,7 +10,7 @@ use Steuerkram\Services\UstvaCalculator;
use Steuerkram\Services\ElsterXmlService; use Steuerkram\Services\ElsterXmlService;
use Steuerkram\View; use Steuerkram\View;
class UstvaController class UstvaController extends BaseController
{ {
public function index(array $params): void public function index(array $params): void
{ {
@ -148,12 +148,5 @@ class UstvaController
header('Content-Length: ' . strlen($xml)); header('Content-Length: ' . strlen($xml));
echo $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();
}
} }

View file

@ -10,6 +10,7 @@ use PDOException;
class Database class Database
{ {
private static ?PDO $instance = null; private static ?PDO $instance = null;
private static int $txDepth = 0;
public static function get(): PDO public static function get(): PDO
{ {
@ -62,16 +63,27 @@ class Database
public static function beginTransaction(): void public static function beginTransaction(): void
{ {
self::get()->beginTransaction(); if (self::$txDepth === 0) {
self::get()->beginTransaction();
}
self::$txDepth++;
} }
public static function commit(): void 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 public static function rollBack(): void
{ {
self::get()->rollBack(); self::$txDepth = 0;
if (self::get()->inTransaction()) {
self::get()->rollBack();
}
} }
} }

View file

@ -18,4 +18,53 @@ class BankImportFormat
$row = Database::fetchOne('SELECT * FROM bank_import_formats WHERE id = ?', [$id]); $row = Database::fetchOne('SELECT * FROM bank_import_formats WHERE id = ?', [$id]);
return $row ?: null; 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();
}
} }

View file

@ -42,22 +42,39 @@ class BankTransaction
$params[] = $s; $params[] = $s;
$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); $whereStr = implode(' AND ', $where);
$offset = ($page - 1) * $perPage; $offset = ($page - 1) * $perPage;
$total = (int)(Database::fetchOne( $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 $params
)['n'] ?? 0); )['n'] ?? 0);
$rows = Database::fetchAll( $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, btb_agg.beleg_id AS beleg_id, btb_agg.beleg_count,
b.beleg_nr b.beleg_nr
FROM bank_transactions bt FROM bank_transactions bt
JOIN bank_import_batches bib ON bt.batch_id = bib.id 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 ( LEFT JOIN (
SELECT bank_tx_id, MIN(beleg_id) AS beleg_id, COUNT(*) AS beleg_count SELECT bank_tx_id, MIN(beleg_id) AS beleg_id, COUNT(*) AS beleg_count
FROM bank_transaction_belege FROM bank_transaction_belege
@ -65,7 +82,7 @@ class BankTransaction
) btb_agg ON btb_agg.bank_tx_id = bt.id ) btb_agg ON btb_agg.bank_tx_id = bt.id
LEFT JOIN belege b ON b.id = btb_agg.beleg_id LEFT JOIN belege b ON b.id = btb_agg.beleg_id
WHERE $whereStr 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 ?", LIMIT ? OFFSET ?",
array_merge($params, [$perPage, $offset]) array_merge($params, [$perPage, $offset])
); );
@ -80,7 +97,7 @@ class BankTransaction
public static function find(int $id): ?array public static function find(int $id): ?array
{ {
$row = Database::fetchOne( $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 FROM bank_transactions bt
JOIN bank_import_batches bib ON bt.batch_id = bib.id JOIN bank_import_batches bib ON bt.batch_id = bib.id
JOIN bank_import_formats bif ON bib.format_id = bif.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 public static function findByBelegId(int $belegId): ?array
{ {
$row = Database::fetchOne( $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 FROM bank_transaction_belege btb
JOIN bank_transactions bt ON bt.id = btb.bank_tx_id 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_batches bib ON bib.id = bt.batch_id
JOIN bank_import_formats bif ON bif.id = bib.format_id
WHERE btb.beleg_id = ? WHERE btb.beleg_id = ?
LIMIT 1', LIMIT 1',
[$belegId] [$belegId]
@ -213,7 +231,7 @@ class BankTransaction
} }
$existing = Database::fetchAll( $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 FROM bank_transactions
WHERE (buchungstag, wertstellung, CAST(wert AS CHAR)) IN ($placeholders)", WHERE (buchungstag, wertstellung, CAST(wert AS CHAR)) IN ($placeholders)",
$params $params
@ -221,7 +239,7 @@ class BankTransaction
$lookup = []; $lookup = [];
foreach ($existing as $e) { foreach ($existing as $e) {
$lookup[$e['buchungstag'] . '|' . $e['wertstellung'] . '|' . $e['wert'] . '|' . $e['btext']] = true; $lookup[$e['buchungstag'] . '|' . $e['wertstellung'] . '|' . $e['wert']] = true;
} }
$new = []; $new = [];
@ -229,8 +247,7 @@ class BankTransaction
foreach ($rows as $row) { foreach ($rows as $row) {
$key = $row['buchungstag'] . '|' $key = $row['buchungstag'] . '|'
. $row['wertstellung'] . '|' . $row['wertstellung'] . '|'
. (string)$row['wert'] . '|' . (string)$row['wert'];
. mb_substr($row['buchungstext'], 0, 200);
if (isset($lookup[$key])) { if (isset($lookup[$key])) {
$dup[] = $row; $dup[] = $row;
} else { } else {

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Steuerkram\Models; namespace Steuerkram\Models;
use Steuerkram\Constants;
use Steuerkram\Database; use Steuerkram\Database;
class Booking class Booking
@ -112,88 +113,52 @@ class Booking
$assetId = isset($data['asset_id']) && $data['asset_id'] !== '' ? (int)$data['asset_id'] : null; $assetId = isset($data['asset_id']) && $data['asset_id'] !== '' ? (int)$data['asset_id'] : null;
Database::execute( Database::beginTransaction();
'INSERT INTO bookings (buchungs_nr, beleg_id, asset_id, buchungs_datum, buchungstyp, try {
kategorie_konto_id, zahlungskonto_id, Database::execute(
betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, 'INSERT INTO bookings (buchungs_nr, beleg_id, asset_id, buchungs_datum, buchungstyp,
buchungstext, fiscal_year_id) kategorie_konto_id, zahlungskonto_id,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto,
[ buchungstext, fiscal_year_id)
$nr, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
(int)$data['beleg_id'], [
$assetId, $nr,
$data['buchungs_datum'], (int)$data['beleg_id'],
$data['buchungstyp'], $assetId,
(int)$data['kategorie_konto_id'], $data['buchungs_datum'],
(int)$data['zahlungskonto_id'], $data['buchungstyp'],
$netto, (int)$data['kategorie_konto_id'],
(float)($data['ust_satz'] ?? 0), (int)$data['zahlungskonto_id'],
$steuerart, $netto,
$ust, (float)($data['ust_satz'] ?? 0),
$brutto, $steuerart,
$data['buchungstext'], $ust,
$fy['id'], $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 // VSt-/USt-Buchungszeile für normale Buchungen mit 7 % oder 19 % anlegen
$ustSatzInt = (int)round((float)($data['ust_satz'] ?? 0)); $ustSatzInt = (int)round((float)($data['ust_satz'] ?? 0));
if (in_array($data['buchungstyp'], ['Ausgabe', 'Einnahme'], true) if (in_array($data['buchungstyp'], [Constants::BUCHUNGSTYP_AUSGABE, Constants::BUCHUNGSTYP_EINNAHME], true)
&& in_array($ustSatzInt, [7, 19], true) && in_array($ustSatzInt, [7, 19], true)
&& $steuerart === 'normal' && $steuerart === 'normal'
&& abs($ust) > 0.0 && abs($ust) > 0.0
) { ) {
$isAusgabe = $data['buchungstyp'] === 'Ausgabe'; $isAusgabe = $data['buchungstyp'] === Constants::BUCHUNGSTYP_AUSGABE;
if ($isAusgabe) { if ($isAusgabe) {
$steuerKontonummer = $ustSatzInt === 19 ? '1576' : '1571'; $steuerKontonummer = $ustSatzInt === 19 ? Constants::KONTO_VST_19 : Constants::KONTO_VST_7;
$steuerText = 'Vorsteuer zu ' . $nr; $steuerText = 'Vorsteuer zu ' . $nr;
} else { } else {
$steuerKontonummer = $ustSatzInt === 19 ? '1776' : '1771'; $steuerKontonummer = $ustSatzInt === 19 ? Constants::KONTO_UST_19 : Constants::KONTO_UST_7;
$steuerText = 'USt zu ' . $nr; $steuerText = 'USt zu ' . $nr;
} }
$steuerAcc = Account::findByKontonummer($steuerKontonummer); $steuerAcc = Account::findByKontonummer($steuerKontonummer);
if ($steuerAcc) { if ($steuerAcc) {
$steuerNr = self::generateBuchungsNr(); $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) {
Database::execute( Database::execute(
'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp, 'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp,
kategorie_konto_id, zahlungskonto_id, kategorie_konto_id, zahlungskonto_id,
@ -201,24 +166,68 @@ class Booking
buchungstext, fiscal_year_id) buchungstext, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[ [
self::generateBuchungsNr(), $steuerNr,
(int)$data['beleg_id'], (int)$data['beleg_id'],
$mainId, $mainId,
$data['buchungs_datum'], $data['buchungs_datum'],
'Ausgabe', $data['buchungstyp'],
(int)$igeAcc['id'], (int)$steuerAcc['id'],
(int)$data['zahlungskonto_id'], (int)$data['zahlungskonto_id'],
$igeUst, $ust,
0.0, 0.0,
'normal', 'normal',
0.0, 0.0,
$igeUst, $ust,
$prefix . $nr, $steuerText,
$fy['id'], $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; return $mainId;
@ -246,65 +255,71 @@ class Booking
$nr = self::generateBuchungsNr(); $nr = self::generateBuchungsNr();
Database::execute( Database::beginTransaction();
'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, buchungstyp, try {
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::execute( 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, kategorie_konto_id, zahlungskonto_id,
betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, buchungstext, is_reversal, fiscal_year_id)
buchungstext, is_reversal, fiscal_year_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)',
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)',
[ [
$vstNr, $nr,
$vst['beleg_id'], $reversalData['beleg_id'],
$reversalId, $reversalData['buchungs_datum'],
date('Y-m-d'), $reversalData['buchungstyp'],
$vst['buchungstyp'], $reversalData['kategorie_konto_id'],
$vst['kategorie_konto_id'], $reversalData['zahlungskonto_id'],
$vst['zahlungskonto_id'], -1 * (float)$orig['betrag_netto'],
-1 * (float)$vst['betrag_netto'], $orig['ust_satz'],
$vst['ust_satz'], $orig['steuerart'],
$vst['steuerart'], -1 * (float)$orig['betrag_ust'],
-1 * (float)$vst['betrag_ust'], -1 * (float)$orig['betrag_brutto'],
-1 * (float)$vst['betrag_brutto'], $reversalData['buchungstext'],
'STORNO zu ' . $vst['buchungs_nr'] . ': ' . $reason,
$fy['id'], $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; return $reversalId;

View file

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Steuerkram; namespace Steuerkram;
use Steuerkram\Auth;
class Router class Router
{ {
private array $routes = []; private array $routes = [];
@ -52,6 +54,11 @@ class Router
$uri = '/' . trim($uri, '/'); $uri = '/' . trim($uri, '/');
if ($uri === '') $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) { foreach ($this->routes as $route) {
if ($route['method'] !== $method) continue; if ($route['method'] !== $method) continue;

View file

@ -22,10 +22,16 @@ class BankImportParser
$lines = $this->readCsv($filePath, $format['delimiter'], $format['enclosure']); $lines = $this->readCsv($filePath, $format['delimiter'], $format['enclosure']);
} }
// Übersprungene Zeilen und optionale Headerzeile entfernen // Übersprungene Zeilen entfernen
$skipRows = (int)$format['skip_rows']; $skipRows = (int)$format['skip_rows'];
$lines = array_slice($lines, $skipRows); $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); $lines = array_slice($lines, 1);
} }
@ -35,6 +41,9 @@ class BankImportParser
$colBuchungstext = (int)$format['col_buchungstext']; $colBuchungstext = (int)$format['col_buchungstext'];
$colWert = (int)$format['col_wert']; $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 = []; $result = [];
foreach ($lines as $i => $cells) { foreach ($lines as $i => $cells) {
// Leerzeilen überspringen // Leerzeilen überspringen
@ -55,11 +64,21 @@ class BankImportParser
continue; 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[] = [ $result[] = [
'buchungstag' => $buchungstag, 'buchungstag' => $buchungstag,
'wertstellung' => $wertstellung, 'wertstellung' => $wertstellung,
'vorgang' => trim($cells[$colVorgang] ?? ''), 'vorgang' => trim($cells[$colVorgang] ?? ''),
'buchungstext' => trim($cells[$colBuchungstext] ?? ''), 'buchungstext' => $buchungstext,
'wert' => $wert, 'wert' => $wert,
'raw_line' => implode($format['delimiter'], $cells), 'raw_line' => implode($format['delimiter'], $cells),
]; ];
@ -222,6 +241,175 @@ class BankImportParser
return [$col - 1, (int)($m[2] ?? 1) - 1]; 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 private function parseDate(string $value, string $format): ?string
{ {
$value = trim($value); $value = trim($value);

View file

@ -18,7 +18,9 @@ class ImageCropService
*/ */
public function cropToRect(string $path, int $x, int $y, int $w, int $h): bool 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) { $img = match ($mime) {
'image/jpeg' => imagecreatefromjpeg($path), 'image/jpeg' => imagecreatefromjpeg($path),
@ -65,7 +67,9 @@ class ImageCropService
*/ */
public function cropWhitespace(string $path): bool 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) { $img = match ($mime) {
'image/jpeg' => imagecreatefromjpeg($path), 'image/jpeg' => imagecreatefromjpeg($path),

View file

@ -60,4 +60,13 @@ class View
$d = \DateTime::createFromFormat('Y-m-d', $date); $d = \DateTime::createFromFormat('Y-m-d', $date);
return $d ? $d->format('d.m.Y') : $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);
}
} }

View file

@ -1,7 +1,8 @@
<?php <?php
use Steuerkram\View; use Steuerkram\View;
$h = fn($v) => View::h($v); $h = fn($v) => View::h($v);
$fmt = fn($v) => View::formatMoney($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']; $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
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<?php
$selFmt = (int)($filter['format_id'] ?? 0);
$selFmtRow = null;
foreach ($formats as $f) { if ((int)$f['id'] === $selFmt) { $selFmtRow = $f; break; } }
?>
<div class="bank-fmt-filter" style="position:relative">
<input type="hidden" name="format_id" id="filter-format-id" value="<?= $selFmt ?: '' ?>">
<button type="button" id="filter-format-btn"
style="display:inline-flex;align-items:center;gap:.4rem;height:32px;padding:0 .6rem;border:1px solid var(--color-border);border-radius:var(--radius);background:#fff;cursor:pointer;white-space:nowrap;font-size:.875rem">
<?php if ($selFmtRow): ?>
<?php $fc = $selFmtRow['farbe'] ?? '#6366f1'; ?>
<span class="kuerzel-badge" style="background:<?= $h($fc) ?>;color:<?= $kTextClr($fc) ?>"><?= $h($selFmtRow['kuerzel']) ?></span>
<?= $h($selFmtRow['name']) ?>
<?php else: ?>
<span style="color:var(--color-muted)">Alle Konten</span>
<?php endif; ?>
</button>
<div id="filter-format-drop"
style="display:none;position:absolute;top:100%;left:0;margin-top:2px;z-index:200;background:#fff;border:1px solid var(--color-border);border-radius:var(--radius);box-shadow:0 4px 12px rgba(0,0,0,.08);min-width:180px;padding:.3rem 0">
<div class="fmt-opt" data-id="" style="display:flex;align-items:center;gap:.5rem;padding:.35rem .75rem;cursor:pointer;font-size:.875rem">
<span style="color:var(--color-muted)">Alle Konten</span>
</div>
<?php foreach ($formats as $f):
$fc = $f['farbe'] ?? '#6366f1'; ?>
<div class="fmt-opt" data-id="<?= (int)$f['id'] ?>"
style="display:flex;align-items:center;gap:.5rem;padding:.35rem .75rem;cursor:pointer;font-size:.875rem<?= $selFmt === (int)$f['id'] ? ';background:var(--color-bg)' : '' ?>">
<span class="kuerzel-badge" style="background:<?= $h($fc) ?>;color:<?= $kTextClr($fc) ?>"><?= $h($f['kuerzel']) ?></span>
<?= $h($f['name']) ?>
</div>
<?php endforeach; ?>
</div>
</div>
<select name="typ" style="width:110px"> <select name="typ" style="width:110px">
<option value="">Alle</option> <option value="">Alle</option>
<option value="einnahme"<?= ($filter['typ'] ?? '') === 'einnahme' ? ' selected' : '' ?>>Einnahmen</option> <option value="einnahme"<?= ($filter['typ'] ?? '') === 'einnahme' ? ' selected' : '' ?>>Einnahmen</option>
@ -45,6 +79,19 @@ $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','D
<option value=""<?= ($filter['status'] ?? 'pending') === '' ? ' selected' : '' ?>>Alle</option> <option value=""<?= ($filter['status'] ?? 'pending') === '' ? ' selected' : '' ?>>Alle</option>
</select> </select>
<select name="sort" style="width:140px">
<option value="wertstellung"<?= ($filter['sort'] ?? 'wertstellung') === 'wertstellung' ? ' selected' : '' ?>>Wertstellung</option>
<option value="buchungstag"<?= ($filter['sort'] ?? '') === 'buchungstag' ? ' selected' : '' ?>>Buchungstag</option>
<option value="betrag"<?= ($filter['sort'] ?? '') === 'betrag' ? ' selected' : '' ?>>Betrag</option>
<option value="buchungstext"<?= ($filter['sort'] ?? '') === 'buchungstext' ? ' selected' : '' ?>>Buchungstext</option>
<option value="status"<?= ($filter['sort'] ?? '') === 'status' ? ' selected' : '' ?>>Status</option>
</select>
<select name="dir" style="width:110px">
<option value="desc"<?= ($filter['dir'] ?? 'desc') === 'desc' ? ' selected' : '' ?>>↓ Absteigend</option>
<option value="asc"<?= ($filter['dir'] ?? 'desc') === 'asc' ? ' selected' : '' ?>>↑ Aufsteigend</option>
</select>
<button type="submit" class="btn btn-secondary btn-sm">Filtern</button> <button type="submit" class="btn btn-secondary btn-sm">Filtern</button>
<a href="/bank" class="btn btn-secondary btn-sm">Zurücksetzen</a> <a href="/bank" class="btn btn-secondary btn-sm">Zurücksetzen</a>
</div> </div>
@ -92,7 +139,13 @@ $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','D
<tr style="cursor:pointer" onclick="window.location='<?= $ziel ?>'"> <tr style="cursor:pointer" onclick="window.location='<?= $ziel ?>'">
<td class="td-mono"><?= $h(View::formatDate($row['wertstellung'])) ?></td> <td class="td-mono"><?= $h(View::formatDate($row['wertstellung'])) ?></td>
<td> <td>
<div><?= $h($row['buchungstext']) ?></div> <div style="display:flex;align-items:baseline;gap:.4rem;flex-wrap:wrap">
<?php if (!empty($row['kuerzel'])): ?>
<?php $kf = $row['farbe'] ?? '#6366f1'; ?>
<span class="kuerzel-badge" style="background:<?= $h($kf) ?>;color:<?= $kTextClr($kf) ?>"><?= $h($row['kuerzel']) ?></span>
<?php endif; ?>
<span><?= $h($row['buchungstext']) ?></span>
</div>
<?php if ($row['vorgang']): ?> <?php if ($row['vorgang']): ?>
<div class="text-muted text-sm"><?= $h($row['vorgang']) ?></div> <div class="text-muted text-sm"><?= $h($row['vorgang']) ?></div>
<?php endif; ?> <?php endif; ?>
@ -149,3 +202,33 @@ $months = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','D
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
<script>
(function () {
const btn = document.getElementById('filter-format-btn');
const drop = document.getElementById('filter-format-drop');
const input = document.getElementById('filter-format-id');
if (!btn || !drop) return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
drop.style.display = drop.style.display === 'none' ? 'block' : 'none';
});
drop.addEventListener('click', (e) => {
const opt = e.target.closest('.fmt-opt');
if (!opt) return;
input.value = opt.dataset.id || '';
btn.closest('form').submit();
});
document.addEventListener('click', () => { drop.style.display = 'none'; });
drop.querySelectorAll('.fmt-opt').forEach(opt => {
opt.addEventListener('mouseenter', () => opt.style.background = 'var(--color-bg)');
opt.addEventListener('mouseleave', () => {
opt.style.background = opt.dataset.id === input.value ? 'var(--color-bg)' : '';
});
});
})();
</script>

View file

@ -1,8 +1,9 @@
<?php <?php
use Steuerkram\View; use Steuerkram\View;
use Steuerkram\Models\Setting; use Steuerkram\Models\Setting;
$h = fn($v) => View::h($v); $h = fn($v) => View::h($v);
$fmt = fn($v) => View::formatMoney($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']; $wert = (float)$transaction['wert'];
$isPlus = $wert >= 0; $isPlus = $wert >= 0;
@ -34,7 +35,13 @@ $restFormatted = number_format($remaining, 2, ',', '.');
</div> </div>
<div style="flex:1;min-width:200px"> <div style="flex:1;min-width:200px">
<span class="text-muted text-sm">Buchungstext</span><br> <span class="text-muted text-sm">Buchungstext</span><br>
<span><?= $h($transaction['buchungstext']) ?></span> <span style="display:inline-flex;align-items:baseline;gap:.4rem;flex-wrap:wrap">
<?php if (!empty($transaction['kuerzel'])): ?>
<?php $kf = $transaction['farbe'] ?? '#6366f1'; ?>
<span class="kuerzel-badge" style="background:<?= $h($kf) ?>;color:<?= $kTextClr($kf) ?>"><?= $h($transaction['kuerzel']) ?></span>
<?php endif; ?>
<?= $h($transaction['buchungstext']) ?>
</span>
</div> </div>
<div> <div>
<span class="text-muted text-sm">Gesamt</span><br> <span class="text-muted text-sm">Gesamt</span><br>

View file

@ -1,6 +1,6 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?> <?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<div class="card" style="max-width:700px"> <div class="card">
<div class="card-body"> <div class="card-body">
<form method="post" action="/bank/upload" enctype="multipart/form-data" id="bank-upload-form"> <form method="post" action="/bank/upload" enctype="multipart/form-data" id="bank-upload-form">
@ -25,18 +25,83 @@
<div id="bank-dnd-status" style="margin-top:.75rem;min-height:1.5rem;pointer-events:none"></div> <div id="bank-dnd-status" style="margin-top:.75rem;min-height:1.5rem;pointer-events:none"></div>
</div> </div>
<!-- Format-Auswahl --> <!-- Format-Erkennung: Ergebnis-Bereich -->
<div class="form-group"> <div id="fmt-result" style="display:none">
<label for="format_id">Import-Format *</label>
<select id="format_id" name="format_id" required> <!-- Auto-erkannt -->
<?php foreach ($formats as $fmt): ?> <div id="fmt-matched" style="display:none;padding:.75rem 1rem;border-radius:var(--radius);background:#f0fdf4;border:1px solid #86efac;margin-bottom:1.25rem">
<option value="<?= (int)$fmt['id'] ?>"><?= $h($fmt['name']) ?></option> <div style="font-weight:600;color:#15803d">Format erkannt</div>
<?php endforeach; ?> <div id="fmt-matched-name" style="color:#166534;margin-top:.2rem"></div>
</select> </div>
<div class="form-hint">Format der hochgeladenen Datei</div>
<!-- Nicht eindeutig erkannt: Auswahl + Vorschau -->
<div id="fmt-picker" style="display:none">
<div style="padding:.6rem .9rem;border-radius:var(--radius);background:#fffbeb;border:1px solid #fcd34d;margin-bottom:1.25rem;font-size:.875rem;color:#92400e">
Das Format konnte nicht eindeutig erkannt werden. Bitte wählen Sie das passende Format:
</div>
<div class="form-group" style="margin-bottom:1.25rem">
<label for="fmt-select">Import-Format</label>
<select id="fmt-select" style="width:100%">
<?php foreach ($formats as $fmt): ?>
<option value="<?= (int)$fmt['id'] ?>"><?= $h($fmt['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<!-- Vorschau-Panel -->
<div id="fmt-preview-panel" style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius);padding:1rem;margin-bottom:1.25rem">
<div style="font-size:.8rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--color-muted);margin-bottom:.75rem">
Datei-Vorschau
</div>
<!-- Überschriften-Zeile -->
<div style="margin-bottom:.75rem">
<div style="font-size:.8rem;color:var(--color-muted);margin-bottom:.3rem">Überschriften-Zeile</div>
<div id="preview-header" style="font-size:.8rem;font-family:monospace;overflow-x:auto;white-space:nowrap;background:#f8fafc;padding:.4rem .6rem;border-radius:4px;border:1px solid var(--color-border)"></div>
</div>
<!-- Erste Werte-Zeile -->
<div style="margin-bottom:1rem">
<div style="font-size:.8rem;color:var(--color-muted);margin-bottom:.3rem">Erste Werte-Zeile</div>
<div id="preview-firstrow" style="font-size:.8rem;font-family:monospace;overflow-x:auto;white-space:nowrap;background:#f8fafc;padding:.4rem .6rem;border-radius:4px;border:1px solid var(--color-border)"></div>
</div>
<!-- Import-Zuordnung -->
<div style="font-size:.8rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--color-muted);margin-bottom:.5rem">
Importiert als
</div>
<table style="width:100%;border-collapse:collapse;font-size:.875rem">
<tbody>
<tr>
<td style="padding:.3rem .4rem .3rem 0;width:130px;color:var(--color-muted)">Wertstellung</td>
<td id="map-wertstellung" style="padding:.3rem 0;font-family:monospace"></td>
</tr>
<tr>
<td style="padding:.3rem .4rem .3rem 0;color:var(--color-muted)">Buchungstext</td>
<td id="map-buchungstext" style="padding:.3rem 0;font-family:monospace"></td>
</tr>
<tr>
<td style="padding:.3rem .4rem .3rem 0;color:var(--color-muted)">Wert (€)</td>
<td id="map-wert" style="padding:.3rem 0;font-family:monospace"></td>
</tr>
</tbody>
</table>
</div>
</div>
</div><!-- /fmt-result -->
<!-- Verstecktes Format-ID Feld -->
<input type="hidden" id="format_id" name="format_id" value="">
<!-- Erkennung läuft -->
<div id="fmt-detecting" style="display:none;color:var(--color-muted);font-size:.875rem;margin-bottom:1rem">
Format wird erkannt…
</div> </div>
<div style="display:flex;gap:.75rem;margin-top:1.5rem"> <div style="display:flex;gap:.75rem;margin-top:.5rem">
<button type="submit" class="btn btn-primary" id="bank-upload-btn" disabled>Importieren</button> <button type="submit" class="btn btn-primary" id="bank-upload-btn" disabled>Importieren</button>
<a href="/bank" class="btn btn-secondary">Abbrechen</a> <a href="/bank" class="btn btn-secondary">Abbrechen</a>
</div> </div>
@ -46,28 +111,172 @@
<script> <script>
(function () { (function () {
const zone = document.getElementById('bank-dnd-zone'); const zone = document.getElementById('bank-dnd-zone');
const input = document.getElementById('bank-dnd-input'); const input = document.getElementById('bank-dnd-input');
const status = document.getElementById('bank-dnd-status'); const status = document.getElementById('bank-dnd-status');
const btn = document.getElementById('bank-upload-btn'); const btn = document.getElementById('bank-upload-btn');
const fmtResult = document.getElementById('fmt-result');
const fmtMatched = document.getElementById('fmt-matched');
const fmtPicker = document.getElementById('fmt-picker');
const fmtDetect = document.getElementById('fmt-detecting');
const fmtSelect = document.getElementById('fmt-select');
const formatId = document.getElementById('format_id');
let formatsData = {};
let suggestedFormat = null;
function setActive(on) { function setActive(on) {
zone.style.borderColor = on ? 'var(--color-primary)' : 'var(--color-border)'; zone.style.borderColor = on ? 'var(--color-primary)' : 'var(--color-border)';
zone.style.background = on ? '#eff6ff' : 'var(--color-surface)'; zone.style.background = on ? '#eff6ff' : 'var(--color-surface)';
} }
function esc(str) {
return String(str ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function renderHeaderRow(headerRow, firstData) {
if (!headerRow || headerRow.length === 0) {
if (!firstData || firstData.length === 0) return '<span style="color:var(--color-muted)">–</span>';
// Keine Überschriften – zeige Spaltenindizes
return firstData.map((_, i) => `<span style="opacity:.5">Sp.${i}</span>`).join(' <span style="color:var(--color-border)">|</span> ');
}
return headerRow.map((h, i) => `<span title="Spalte ${i}">${esc(h)}</span>`).join(' <span style="color:var(--color-border)">|</span> ');
}
function renderDataRow(firstData, header) {
if (!firstData || firstData.length === 0) return '<span style="color:var(--color-muted)">–</span>';
return firstData.map((v, i) => {
const lbl = header && header[i] ? `title="Spalte ${i}: ${esc(header[i])}"` : `title="Spalte ${i}"`;
return `<span ${lbl}>${esc(v)}</span>`;
}).join(' <span style="color:var(--color-border)">|</span> ');
}
function resolveTemplate(tpl, header, firstData) {
if (!tpl) return null;
return tpl.replace(/\{([^}]+)\}/g, (_, key) => {
const idx = header ? header.indexOf(key) : -1;
return idx >= 0 ? (firstData[idx] || '') : `{${key}}`;
});
}
function getMappedValue(fmt, header, firstData, col) {
const idx = parseInt(fmt[col], 10);
if (isNaN(idx) || !firstData || idx >= firstData.length) return '–';
return firstData[idx] || '–';
}
function updatePreview(fmtId) {
const data = formatsData[fmtId];
if (!data) return;
const fmt = data.format;
const header = data.header_row;
const firstData = data.first_data;
document.getElementById('preview-header').innerHTML = renderHeaderRow(header, firstData);
document.getElementById('preview-firstrow').innerHTML = renderDataRow(firstData, header);
// Wertstellung
const wsIdx = parseInt(fmt.col_wertstellung, 10);
const wsVal = firstData && firstData[wsIdx] !== undefined ? firstData[wsIdx] : '–';
document.getElementById('map-wertstellung').textContent = `${wsVal} (Spalte ${wsIdx})`;
// Buchungstext – Template bevorzugen
const wertVal = firstData && firstData[parseInt(fmt.col_wert, 10)] ? firstData[parseInt(fmt.col_wert, 10)] : '0';
const isDebit = parseFloat(wertVal.replace(',', '.').replace(/[^\d.\-]/g, '')) < 0;
const tpl = isDebit ? (fmt.buchungstext_template_debit || '') : (fmt.buchungstext_template_credit || '');
let btVal;
if (tpl) {
btVal = resolveTemplate(tpl, header, firstData);
document.getElementById('map-buchungstext').textContent = `${btVal} (Template)`;
} else {
const btIdx = parseInt(fmt.col_buchungstext, 10);
btVal = firstData && firstData[btIdx] !== undefined ? firstData[btIdx] : '–';
document.getElementById('map-buchungstext').textContent = `${btVal} (Spalte ${btIdx})`;
}
// Wert
const wIdx = parseInt(fmt.col_wert, 10);
const wVal = firstData && firstData[wIdx] !== undefined ? firstData[wIdx] : '–';
document.getElementById('map-wert').textContent = `${wVal} (Spalte ${wIdx})`;
}
async function detectFormat(file) {
const fd = new FormData();
fd.append('bank_file', file);
fmtDetect.style.display = 'block';
fmtResult.style.display = 'none';
btn.disabled = true;
let json;
try {
const res = await fetch('/bank/detect-format', { method: 'POST', body: fd });
json = await res.json();
} catch (_) {
json = { error: 'Verbindungsfehler bei der Format-Erkennung.' };
}
fmtDetect.style.display = 'none';
if (json.error) {
status.innerHTML = `<span style="color:var(--color-danger)">${esc(json.error)}</span>`;
return;
}
formatsData = json.formats_data || {};
suggestedFormat = json.suggested_format || null;
fmtResult.style.display = 'block';
if (json.matched_format_id !== null && json.matched_format_id !== undefined) {
// Format eindeutig erkannt
const matched = formatsData[json.matched_format_id];
const name = matched ? matched.format.name : 'Unbekannt';
document.getElementById('fmt-matched-name').textContent = name;
formatId.value = json.matched_format_id;
fmtMatched.style.display = 'block';
fmtPicker.style.display = 'none';
btn.disabled = false;
} else {
// Nicht eindeutig – Auswahl zeigen
fmtMatched.style.display = 'none';
fmtPicker.style.display = 'block';
// Passendes Format vorauswählen (erstes mit is_match, sonst erstes)
let preselect = null;
for (const [id, d] of Object.entries(formatsData)) {
if (d.is_match) { preselect = id; break; }
}
if (!preselect && Object.keys(formatsData).length > 0) {
preselect = Object.keys(formatsData)[0];
}
if (preselect) {
fmtSelect.value = preselect;
formatId.value = preselect;
updatePreview(preselect);
}
btn.disabled = false;
}
}
function onFile(file) { function onFile(file) {
if (!file) return; if (!file) return;
const ext = file.name.split('.').pop().toLowerCase(); const ext = file.name.split('.').pop().toLowerCase();
if (!['csv', 'txt', 'xlsx'].includes(ext)) { if (!['csv', 'txt', 'xlsx'].includes(ext)) {
status.innerHTML = '<span style="color:var(--color-danger)">Nicht unterstütztes Format. Bitte CSV, TXT oder XLSX verwenden.</span>'; status.innerHTML = '<span style="color:var(--color-danger)">Nicht unterstütztes Format. Bitte CSV, TXT oder XLSX verwenden.</span>';
btn.disabled = true; btn.disabled = true;
fmtResult.style.display = 'none';
return; return;
} }
status.innerHTML = '<span style="color:var(--color-success)">✓ ' + file.name + ' (' + (file.size / 1024).toFixed(1) + ' KB)</span>'; status.innerHTML = `<span style="color:var(--color-success)">✓ ${esc(file.name)} (${(file.size / 1024).toFixed(1)} KB)</span>`;
btn.disabled = false; detectFormat(file);
} }
fmtSelect.addEventListener('change', () => {
formatId.value = fmtSelect.value;
updatePreview(fmtSelect.value);
});
zone.addEventListener('dragover', (e) => { e.preventDefault(); setActive(true); }); zone.addEventListener('dragover', (e) => { e.preventDefault(); setActive(true); });
zone.addEventListener('dragleave', (e) => { if (!zone.contains(e.relatedTarget)) setActive(false); }); zone.addEventListener('dragleave', (e) => { if (!zone.contains(e.relatedTarget)) setActive(false); });
zone.addEventListener('drop', (e) => { zone.addEventListener('drop', (e) => {
@ -75,7 +284,6 @@
setActive(false); setActive(false);
const file = e.dataTransfer.files[0]; const file = e.dataTransfer.files[0];
if (file) { if (file) {
// DataTransfer-Datei in Input-Element spiegeln ist nicht möglich – Form-Submit via DataTransfer
const dt = new DataTransfer(); const dt = new DataTransfer();
dt.items.add(file); dt.items.add(file);
input.files = dt.files; input.files = dt.files;
@ -83,5 +291,21 @@
} }
}); });
input.addEventListener('change', () => onFile(input.files[0])); input.addEventListener('change', () => onFile(input.files[0]));
// "Formate bearbeiten"-Link: Suggestion in Session speichern, dann navigieren
document.addEventListener('click', async (e) => {
const link = e.target.closest('a[href="/settings/bankimport"]');
if (!link || !suggestedFormat) return;
e.preventDefault();
try {
await fetch('/settings/bankimport/suggest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(suggestedFormat),
});
} catch (_) {}
window.location.href = '/settings/bankimport';
});
})(); })();
</script> </script>

View file

@ -3,6 +3,7 @@ use Steuerkram\View;
use Steuerkram\Models\Setting; use Steuerkram\Models\Setting;
$h = fn($v) => View::h($v); $h = fn($v) => View::h($v);
$fmt = fn($v) => View::formatMoney($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(); $testMode = Setting::isTestMode();
$suppliers = $suppliers ?? []; $suppliers = $suppliers ?? [];
?> ?>
@ -307,8 +308,14 @@ $suppliers = $suppliers ?? [];
<?php if ($lbt['vorgang']): ?> <?php if ($lbt['vorgang']): ?>
<span class="text-sm text-muted"><?= $h($lbt['vorgang']) ?></span> <span class="text-sm text-muted"><?= $h($lbt['vorgang']) ?></span>
<?php endif; ?> <?php endif; ?>
<span class="text-sm" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" <span class="text-sm" style="flex:1;min-width:0;display:inline-flex;align-items:baseline;gap:.35rem;overflow:hidden">
title="<?= $h($lbt['buchungstext']) ?>"><?= $h($lbt['buchungstext']) ?></span> <?php if (!empty($lbt['kuerzel'])): ?>
<?php $kf = $lbt['farbe'] ?? '#6366f1'; ?>
<span class="kuerzel-badge" style="background:<?= $h($kf) ?>;color:<?= $kTextClr($kf) ?>"><?= $h($lbt['kuerzel']) ?></span>
<?php endif; ?>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
title="<?= $h($lbt['buchungstext']) ?>"><?= $h($lbt['buchungstext']) ?></span>
</span>
<strong style="white-space:nowrap;color:<?= $lbwert >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>"> <strong style="white-space:nowrap;color:<?= $lbwert >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>">
<?= $lbwert >= 0 ? '+' : '' ?><?= number_format($lbwert, 2, ',', '.') ?> € <?= $lbwert >= 0 ? '+' : '' ?><?= number_format($lbwert, 2, ',', '.') ?> €
<?php if (!empty($lbt['alloc_betrag']) && (float)$lbt['alloc_betrag'] !== abs($lbwert)): ?> <?php if (!empty($lbt['alloc_betrag']) && (float)$lbt['alloc_betrag'] !== abs($lbwert)): ?>
@ -337,8 +344,14 @@ $suppliers = $suppliers ?? [];
<span class="badge badge-blue" style="font-size:.8rem">Bankbuchung</span> <span class="badge badge-blue" style="font-size:.8rem">Bankbuchung</span>
<span class="td-mono text-sm"><?= View::formatDate($bankTx['wertstellung']) ?></span> <span class="td-mono text-sm"><?= View::formatDate($bankTx['wertstellung']) ?></span>
<span class="text-sm"><?= $h($bankTx['vorgang']) ?></span> <span class="text-sm"><?= $h($bankTx['vorgang']) ?></span>
<span class="text-sm" style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" <span class="text-sm" style="flex:1;min-width:0;display:inline-flex;align-items:baseline;gap:.35rem;overflow:hidden">
title="<?= $h($bankTx['buchungstext']) ?>"><?= $h($bankTx['buchungstext']) ?></span> <?php if (!empty($bankTx['kuerzel'])): ?>
<?php $kf = $bankTx['farbe'] ?? '#6366f1'; ?>
<span class="kuerzel-badge" style="background:<?= $h($kf) ?>;color:<?= $kTextClr($kf) ?>"><?= $h($bankTx['kuerzel']) ?></span>
<?php endif; ?>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
title="<?= $h($bankTx['buchungstext']) ?>"><?= $h($bankTx['buchungstext']) ?></span>
</span>
<?php $bwert = (float)$bankTx['wert']; ?> <?php $bwert = (float)$bankTx['wert']; ?>
<strong style="color:<?= $bwert >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>;white-space:nowrap"> <strong style="color:<?= $bwert >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>;white-space:nowrap">
<?= $bwert >= 0 ? '+' : '' ?><?= number_format($bwert, 2, ',', '.') ?> € <?= $bwert >= 0 ? '+' : '' ?><?= number_format($bwert, 2, ',', '.') ?> €

View file

@ -9,11 +9,21 @@ $fmt = fn($v) => View::formatMoney($v);
<div class="stat-label">Umsatz <?= $h($stats['year']) ?></div> <div class="stat-label">Umsatz <?= $h($stats['year']) ?></div>
<div class="stat-value" style="color:var(--color-success)"><?= $fmt($stats['umsatz']) ?></div> <div class="stat-value" style="color:var(--color-success)"><?= $fmt($stats['umsatz']) ?></div>
<div class="stat-meta">Betriebseinnahmen (netto)</div> <div class="stat-meta">Betriebseinnahmen (netto)</div>
<div class="stat-meta" style="font-size:.75rem;margin-top:.25rem;color:var(--color-muted)">
<?= $h($stats['vormonat_label']) ?>: <?= $fmt($stats['umsatz_vormonat']) ?>
&nbsp;·&nbsp;
<?= $h($stats['month_label']) ?>: <?= $fmt($stats['umsatz_monat']) ?>
</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">Ausgaben <?= $h($stats['year']) ?></div> <div class="stat-label">Ausgaben <?= $h($stats['year']) ?></div>
<div class="stat-value" style="color:var(--color-danger)"><?= $fmt($stats['ausgaben']) ?></div> <div class="stat-value" style="color:var(--color-danger)"><?= $fmt($stats['ausgaben']) ?></div>
<div class="stat-meta">Betriebsausgaben (netto)</div> <div class="stat-meta">Betriebsausgaben (netto)</div>
<div class="stat-meta" style="font-size:.75rem;margin-top:.25rem;color:var(--color-muted)">
<?= $h($stats['vormonat_label']) ?>: <?= $fmt($stats['ausgaben_vormonat']) ?>
&nbsp;·&nbsp;
<?= $h($stats['month_label']) ?>: <?= $fmt($stats['ausgaben_monat']) ?>
</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">Gewinn / Verlust <?= $h($stats['year']) ?></div> <div class="stat-label">Gewinn / Verlust <?= $h($stats['year']) ?></div>
@ -21,6 +31,11 @@ $fmt = fn($v) => View::formatMoney($v);
<?= $fmt($stats['gewinn']) ?> <?= $fmt($stats['gewinn']) ?>
</div> </div>
<div class="stat-meta">EÜR-Ergebnis</div> <div class="stat-meta">EÜR-Ergebnis</div>
<div class="stat-meta" style="font-size:.75rem;margin-top:.25rem;color:var(--color-muted)">
<?= $h($stats['vormonat_label']) ?>: <span style="color:<?= $stats['gewinn_vormonat'] >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>"><?= $fmt($stats['gewinn_vormonat']) ?></span>
&nbsp;·&nbsp;
<?= $h($stats['month_label']) ?>: <span style="color:<?= $stats['gewinn_monat'] >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>"><?= $fmt($stats['gewinn_monat']) ?></span>
</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">Offene Rechnungen</div> <div class="stat-label">Offene Rechnungen</div>

View file

@ -11,6 +11,7 @@
<link rel="apple-touch-icon" sizes="180x180" href="/static/favicon/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/static/favicon/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Steuerkram" /> <meta name="apple-mobile-web-app-title" content="Steuerkram" />
<link rel="manifest" href="/static/favicon/site.webmanifest" /> <link rel="manifest" href="/static/favicon/site.webmanifest" />
<meta name="csrf-token" content="<?= \Steuerkram\View::h(\Steuerkram\Auth::generateCsrfToken()) ?>">
</head> </head>
<body> <body>
<div class="app-layout"> <div class="app-layout">
@ -48,5 +49,31 @@
</div> </div>
</div> </div>
<script src="/static/js/app.js?v=<?= defined('ASSET_VERSION') ? ASSET_VERSION : 'dev' ?>"></script> <script src="/static/js/app.js?v=<?= defined('ASSET_VERSION') ? ASSET_VERSION : 'dev' ?>"></script>
<script>
(function() {
var token = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
if (!token) return;
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('form').forEach(function(form) {
var m = (form.getAttribute('method') || '').toUpperCase();
if (m !== 'POST' && m !== 'PUT' && m !== 'DELETE') return;
if (!form.querySelector('[name="csrf_token"]')) {
var inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'csrf_token'; inp.value = token;
form.appendChild(inp);
}
});
});
var _fetch = window.fetch;
window.fetch = function(url, opts) {
opts = opts || {};
var method = (opts.method || 'GET').toUpperCase();
if (method !== 'GET' && method !== 'HEAD') {
opts.headers = Object.assign({'X-CSRF-Token': token}, opts.headers || {});
}
return _fetch(url, opts);
};
})();
</script>
</body> </body>
</html> </html>

View file

@ -93,6 +93,10 @@ function navActive(string $prefix, string $current): string {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 10h16M4 14h8"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 10h16M4 14h8"/></svg>
Kontenrahmen Kontenrahmen
</a> </a>
<a href="/settings/bankimport" class="<?= navActive('/settings/bankimport', $path) ?>" style="padding-left:2.25rem;font-size:.875rem">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/><line x1="6" y1="15" x2="10" y2="15"/></svg>
Bankimport
</a>
<?php endif; ?> <?php endif; ?>
</nav> </nav>
</div> </div>

View file

@ -54,6 +54,7 @@
<?php endif; ?> <?php endif; ?>
<form method="POST" action="/login"> <form method="POST" action="/login">
<input type="hidden" name="csrf_token" value="<?= \Steuerkram\View::h(\Steuerkram\Auth::generateCsrfToken()) ?>">
<div class="form-group"> <div class="form-group">
<label for="username">Benutzername</label> <label for="username">Benutzername</label>
<input type="text" id="username" name="username" <input type="text" id="username" name="username"

View file

@ -0,0 +1,784 @@
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
<?php
$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';
$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',
];
?>
<?php if (!empty($errors ?? [])): ?>
<div style="padding:.75rem 1rem;border-radius:var(--radius);background:#fee2e2;border:1px solid #fca5a5;margin-bottom:1rem;font-size:.875rem;color:#991b1b">
<?php foreach ($errors as $e): ?>
<div><?= $h($e) ?></div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($suggestion)): ?>
<?php
$sugHeader = $suggestion['header'] ?? [];
$sugMapping = $suggestion['mapping'] ?? [];
$allMappingFields = [
'buchungstag' => 'Buchungstag',
'wertstellung' => 'Wertstellung',
'vorgang' => 'Vorgang / Umsatztyp',
'wert' => 'Betrag',
'zahlungspflichtiger' => 'Zahlungspflichtiger',
'zahlungsempfaenger' => 'Zahlungsempfänger',
'verwendungszweck' => 'Verwendungszweck',
];
?>
<div class="card mb-2" id="suggestion-card" style="border:2px solid #6366f1">
<div class="card-header" style="background:#eef2ff;display:flex;align-items:center;gap:.75rem">
<div class="card-title" style="flex:1">Neues Format (automatischer Vorschlag)</div>
<span style="font-size:.75rem;font-weight:600;padding:.2rem .6rem;border-radius:9999px;background:#818cf8;color:#fff">Nicht gespeichert</span>
</div>
<div class="card-body">
<form method="post" action="/settings/bankimport/create">
<p style="font-size:.8rem;color:var(--color-muted);margin:0 0 1.25rem">
Das Programm hat die Headerzeile in der CSV gefunden und Spalten automatisch zugeordnet.
Bitte prüfe die Einstellungen und klicke auf <strong>Speichern</strong>, um das neue Format anzulegen.
</p>
<div class="bi-section-label">Format-Konfiguration</div>
<table class="bi-table" style="margin-bottom:1.5rem">
<tbody>
<tr>
<td class="bi-label">Name</td>
<td><input type="text" name="name" value="Neues Format" required maxlength="100" style="width:220px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)"></td>
</tr>
<tr>
<td class="bi-label">Anzeige-Kürzel</td>
<td><input type="text" name="kuerzel" value="NEU" required maxlength="10" style="width:80px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)"></td>
</tr>
<tr>
<td class="bi-label">Übersprungene Zeilen</td>
<td><input type="number" name="skip_rows" value="<?= (int)($suggestion['skip_rows'] ?? 0) ?>" min="0" max="99" style="width:70px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)"></td>
</tr>
<tr>
<td class="bi-label">Headerzeile vorhanden</td>
<td>
<select name="has_header_row" style="padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)">
<option value="1" <?= ($suggestion['has_header_row'] ?? 1) ? 'selected' : '' ?>>Ja</option>
<option value="0" <?= !($suggestion['has_header_row'] ?? 1) ? 'selected' : '' ?>>Nein</option>
</select>
</td>
</tr>
<tr>
<td class="bi-label">Trennzeichen</td>
<td><input type="text" name="delimiter" value="<?= $h($suggestion['delimiter'] ?? ';') ?>" maxlength="1" required style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"></td>
</tr>
<tr>
<td class="bi-label">Anführungszeichen</td>
<td><input type="text" name="enclosure" value="<?= $h($suggestion['enclosure'] ?? '"') ?>" maxlength="1" required style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"></td>
</tr>
<tr>
<td class="bi-label">Datumsformat</td>
<td>
<select name="date_format" style="padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius)">
<?php foreach ($dateFormatOptions as $fmt => $example): ?>
<option value="<?= $h($fmt) ?>"><?= $h($fmt) ?> → <?= $h($example) ?></option>
<?php endforeach; ?>
</select>
</td>
</tr>
<tr>
<td class="bi-label">Dezimaltrennzeichen</td>
<td><input type="text" name="decimal_separator" value="," maxlength="1" required style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"></td>
</tr>
<tr>
<td class="bi-label">Tausendertrennzeichen</td>
<td><input type="text" name="thousands_separator" value="." maxlength="1" placeholder="(leer)" style="width:50px;padding:.3rem .5rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace"></td>
</tr>
</tbody>
</table>
<?php
$sugFirstData = $suggestion['first_data'] ?? [];
if (!empty($sugHeader) || !empty($sugFirstData)):
?>
<div class="bi-section-label">Erkannte Headerzeile</div>
<div style="font-size:.8rem;font-family:monospace;background:#f8fafc;padding:.4rem .6rem;border-radius:4px;border:1px solid var(--color-border);overflow-x:auto;white-space:nowrap;margin-bottom:.5rem">
<?php foreach ($sugHeader as $i => $col): ?>
<span title="Spalte <?= $i ?>"><span style="opacity:.45"><?= $i ?>:</span><?= $h($col) ?></span><?php if ($i < count($sugHeader) - 1): ?> <span style="color:var(--color-border)">|</span> <?php endif; ?>
<?php endforeach; ?>
</div>
<?php if (!empty($sugFirstData)): ?>
<div style="font-size:.75rem;color:var(--color-muted);margin-bottom:.3rem">Erste Datenzeile</div>
<div style="font-size:.8rem;font-family:monospace;background:#f8fafc;padding:.4rem .6rem;border-radius:4px;border:1px solid var(--color-border);overflow-x:auto;white-space:nowrap;margin-bottom:1.5rem">
<?php foreach ($sugFirstData as $i => $val): ?>
<span title="Spalte <?= $i ?><?= isset($sugHeader[$i]) ? ': ' . htmlspecialchars($sugHeader[$i]) : '' ?>"><span style="opacity:.45"><?= $i ?>:</span><?= $h($val) ?></span><?php if ($i < count($sugFirstData) - 1): ?> <span style="color:var(--color-border)">|</span> <?php endif; ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php endif; ?>
<div class="bi-section-label">Feld-Zuordnungen</div>
<table class="bi-table" style="margin-bottom:1.5rem">
<thead>
<tr>
<th class="bi-label">Datenbankfeld</th>
<th style="width:100px">Spalte (0-basiert)</th>
<th style="color:var(--color-muted);font-weight:500">Erkannte CSV-Überschrift</th>
</tr>
</thead>
<tbody>
<?php foreach ($allMappingFields as $field => $label):
$colKey = 'col_' . $field;
$colIdx = $sugMapping[$colKey] ?? null;
$headerVal = ($colIdx !== null && isset($sugHeader[$colIdx])) ? $sugHeader[$colIdx] : '';
$isNullInt = in_array($field, ['zahlungspflichtiger', 'zahlungsempfaenger', 'verwendungszweck'], true);
?>
<tr>
<td class="bi-label"><?= $h($label) ?></td>
<td>
<input type="number" name="col_<?= $h($field) ?>"
value="<?= $colIdx !== null ? $colIdx : '' ?>"
min="0" max="99"
<?= $isNullInt ? '' : 'required' ?>
style="width:65px;padding:.25rem .4rem;border:1px solid var(--color-border);border-radius:var(--radius);font-family:monospace">
</td>
<td style="color:var(--color-muted);font-size:.875rem">
<?php if ($headerVal !== ''): ?>
<?= $h($headerVal) ?>
<?php if ($colIdx !== null): ?>
<span style="opacity:.5;font-size:.75rem"> (Sp.<?= $colIdx ?>)</span>
<?php endif; ?>
<?php else: ?>
<span style="font-style:italic">–</span>
<?php endif; ?>
</td>
</tr>
<?php if ($field === 'vorgang'): ?>
<tr>
<td class="bi-label">Buchungstext <small style="font-weight:400">(Betrag &lt; 0)</small></td>
<td colspan="2" style="padding-right:0">
<input type="text" name="buchungstext_template_debit" value=""
maxlength="500" placeholder="z.B. {Zahlungsempfänger} · {Verwendungszweck}"
style="width:100%;box-sizing:border-box;padding:.25rem .4rem;border:1px solid var(--color-border);border-radius:var(--radius)">
</td>
</tr>
<tr>
<td class="bi-label">Buchungstext <small style="font-weight:400">(Betrag &gt; 0)</small></td>
<td colspan="2" style="padding-right:0">
<input type="text" name="buchungstext_template_credit" value=""
maxlength="500" placeholder="z.B. {Zahlungspflichtiger} · {Verwendungszweck}"
style="width:100%;box-sizing:border-box;padding:.25rem .4rem;border:1px solid var(--color-border);border-radius:var(--radius)">
</td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
<tr>
<td colspan="3" style="padding:.4rem 0 0;font-size:.78rem;color:var(--color-muted)">
Platzhalter <code>{CSV-Überschrift}</code> werden durch den Wert der jeweiligen Spalte ersetzt.
Optional: Zahlungspflichtiger, Zahlungsempfänger, Verwendungszweck (für Templates).
</td>
</tr>
</tbody>
</table>
<div style="display:flex;gap:.75rem">
<button type="submit" class="btn btn-primary">Speichern</button>
<button type="button" id="suggestion-dismiss" class="btn btn-secondary">Verwerfen</button>
</div>
</form>
</div>
</div>
<?php endif; ?>
<?php if (empty($formats)): ?>
<div class="card">
<div class="card-body" style="color:var(--color-muted)">Keine Importformate in der Datenbank vorhanden.</div>
</div>
<?php else: ?>
<p style="font-size:.875rem;color:var(--color-muted);margin:0 0 1rem">
Klick auf einen Wert zum Bearbeiten — Enter oder Klick außerhalb speichert, Escape bricht ab.
</p>
<?php foreach ($formats as $fmt): ?>
<?php
$fmtId = (int)$fmt['id'];
$hdrs = $knownHeaders[$fmt['name']] ?? [];
$mappingRowsBefore = [
['buchungstag', $fmt['col_buchungstag'], 'int'],
['wertstellung', $fmt['col_wertstellung'], 'int'],
['vorgang', $fmt['col_vorgang'], 'int'],
];
$mappingRowsAfter = [
['wert', $fmt['col_wert'], 'int'],
];
?>
<div class="card mb-2" id="format-<?= $fmtId ?>" data-format-id="<?= $fmtId ?>">
<div class="card-header" style="display:flex;align-items:center;gap:.75rem">
<div class="card-title" style="flex:1">
<span class="bi-cell" data-field="name" data-type="text" data-value="<?= $h($fmt['name']) ?>"><?= $h($fmt['name']) ?></span>
</div>
<button type="button" class="btn btn-danger btn-sm bi-delete-format" data-format-id="<?= $fmtId ?>" data-format-name="<?= $h($fmt['name']) ?>">Löschen</button>
</div>
<div class="card-body">
<div class="bi-section-label">Format-Konfiguration</div>
<table class="bi-table" style="margin-bottom:1.5rem">
<tbody>
<tr>
<td class="bi-label">Anzeige-Kürzel</td>
<td>
<?php $farbe = $fmt['farbe'] ?? '#6366f1'; ?>
<div style="display:inline-flex;align-items:center;gap:.5rem">
<span class="kuerzel-badge bi-kuerzel-preview"
style="background:<?= $h($farbe) ?>;color:<?= $kTextClr($farbe) ?>"><?= $h($fmt['kuerzel'] ?? '') ?></span>
<span class="bi-cell" data-field="kuerzel" data-type="text"
data-value="<?= $h($fmt['kuerzel'] ?? '') ?>"><?= $h($fmt['kuerzel'] ?? '') ?></span>
<input type="color" class="bi-color" data-field="farbe"
value="<?= $h($farbe) ?>"
title="Tag-Farbe anpassen"
style="width:28px;height:28px;padding:2px;border:1px solid var(--color-border);border-radius:4px;cursor:pointer;background:none;vertical-align:middle">
</div>
</td>
</tr>
<tr>
<td class="bi-label">Übersprungene Zeilen</td>
<td><span class="bi-cell" data-field="skip_rows" data-type="number" data-min="0" data-max="99" data-value="<?= (int)$fmt['skip_rows'] ?>"><?= (int)$fmt['skip_rows'] ?></span></td>
</tr>
<tr>
<td class="bi-label">Headerzeile vorhanden</td>
<td>
<label class="bi-checkbox-label">
<input type="checkbox" class="bi-checkbox" data-field="has_header_row"
<?= $fmt['has_header_row'] ? 'checked' : '' ?>>
<span class="bi-checkbox-text"><?= $fmt['has_header_row'] ? 'Ja' : 'Nein' ?></span>
</label>
</td>
</tr>
<tr>
<td class="bi-label">Trennzeichen</td>
<td><span class="bi-cell" data-field="delimiter" data-type="char" data-value="<?= $h($fmt['delimiter']) ?>"><code><?= $h($fmt['delimiter']) ?></code></span></td>
</tr>
<tr>
<td class="bi-label">Anführungszeichen</td>
<td><span class="bi-cell" data-field="enclosure" data-type="char" data-value="<?= $h($fmt['enclosure']) ?>"><code><?= $h($fmt['enclosure']) ?></code></span></td>
</tr>
<tr>
<td class="bi-label">Datumsformat</td>
<td>
<span class="bi-cell" data-field="date_format" data-type="dateformat" data-value="<?= $h($fmt['date_format']) ?>">
<code><?= $h($fmt['date_format']) ?></code>
<span style="color:var(--color-muted);margin-left:.4rem">→ <?= $h($dateFormatOptions[$fmt['date_format']] ?? '?') ?></span>
</span>
</td>
</tr>
<tr>
<td class="bi-label">Dezimaltrennzeichen</td>
<td><span class="bi-cell" data-field="decimal_separator" data-type="char" data-value="<?= $h($fmt['decimal_separator']) ?>"><code><?= $h($fmt['decimal_separator']) ?></code></span></td>
</tr>
<tr>
<td class="bi-label">Tausendertrennzeichen</td>
<td><span class="bi-cell" data-field="thousands_separator" data-type="char0" data-value="<?= $h($fmt['thousands_separator']) ?>">
<?php if ($fmt['thousands_separator'] !== ''): ?>
<code><?= $h($fmt['thousands_separator']) ?></code>
<?php else: ?>
<span style="color:var(--color-muted);font-style:italic">keines</span>
<?php endif; ?>
</span></td>
</tr>
</tbody>
</table>
<div class="bi-section-label">Feld-Zuordnungen</div>
<table class="bi-table">
<thead>
<tr>
<th class="bi-label">Datenbankfeld</th>
<th style="width:100px">Spalte (0-basiert)</th>
<th style="color:var(--color-muted);font-weight:500">CSV-Überschrift</th>
</tr>
</thead>
<tbody>
<?php
$renderMappingRow = function (string $field, mixed $colIdx, string $colType) use ($h, $hdrs, $dbLabels): void {
$colVal = $colIdx !== null ? (int)$colIdx : null;
?>
<tr>
<td class="bi-label"><?= $h($dbLabels[$field] ?? $field) ?></td>
<td>
<span class="bi-cell"
data-field="col_<?= $h($field) ?>"
data-type="<?= $colType === 'nullint' ? 'nullint' : 'number' ?>"
data-min="0" data-max="99"
data-value="<?= $colVal !== null ? $colVal : '' ?>">
<?php if ($colVal !== null): ?>
<code><?= $colVal ?></code>
<?php else: ?>
<span style="color:var(--color-muted);font-style:italic">–</span>
<?php endif; ?>
</span>
</td>
<td style="color:var(--color-muted);font-size:.875rem">
<?php if ($colVal !== null && isset($hdrs[$colVal])): ?>
<?= $h($hdrs[$colVal]) ?>
<?php else: ?>
<span style="font-style:italic">–</span>
<?php endif; ?>
</td>
</tr>
<?php }; ?>
<?php foreach ($mappingRowsBefore as [$field, $colIdx, $colType]): ?>
<?php $renderMappingRow($field, $colIdx, $colType); ?>
<?php endforeach; ?>
<?php
// Buchungstext-Template-Zeilen (immer)
foreach ([
['buchungstext_template_debit', 'Buchungstext <small style="font-weight:400">(Betrag &lt; 0)</small>', $fmt['buchungstext_template_debit'] ?? ''],
['buchungstext_template_credit', 'Buchungstext <small style="font-weight:400">(Betrag ≥ 0)</small>', $fmt['buchungstext_template_credit'] ?? ''],
] as [$tplField, $tplLabel, $tplValue]):
?>
<tr>
<td class="bi-label"><?= $tplLabel ?></td>
<td colspan="2" style="padding-right:0">
<span class="bi-cell bi-cell--wide" data-field="<?= $h($tplField) ?>" data-type="template"
data-value="<?= $h($tplValue) ?>">
<?php if ($tplValue !== ''): ?>
<code style="word-break:break-all"><?= $h($tplValue) ?></code>
<?php else: ?>
<span style="color:var(--color-muted);font-style:italic">nicht gesetzt</span>
<?php endif; ?>
</span>
</td>
</tr>
<?php endforeach; ?>
<?php foreach ($mappingRowsAfter as [$field, $colIdx, $colType]): ?>
<?php $renderMappingRow($field, $colIdx, $colType); ?>
<?php endforeach; ?>
<tr>
<td colspan="3" style="padding:.4rem 0 0;font-size:.78rem;color:var(--color-muted)">
Platzhalter <code>{CSV-Überschrift}</code> werden durch den Wert der jeweiligen Spalte ersetzt.
</td>
</tr>
</tbody>
</table>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
<style>
.bi-section-label {
font-size: .8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--color-muted);
margin-bottom: .4rem;
}
.bi-table {
width: 100%;
border-collapse: collapse;
font-size: .875rem;
}
.bi-table th,
.bi-table td {
padding: .35rem .6rem .35rem 0;
border-bottom: 1px solid var(--color-border);
vertical-align: middle;
}
.bi-table thead th { color: var(--color-muted); font-weight: 500; }
.bi-table tbody tr:last-child td { border-bottom: none; }
.bi-label { color: var(--color-text); width: 38%; }
.bi-cell {
display: inline-flex;
align-items: center;
gap: .3rem;
padding: .15rem .3rem;
border-radius: var(--radius);
cursor: pointer;
border: 1px solid transparent;
transition: border-color .15s, background .15s;
min-height: 1.6rem;
min-width: 2rem;
}
.bi-cell:hover {
background: var(--color-bg);
border-color: var(--color-border);
}
.bi-cell::after {
content: '✎';
font-size: .7rem;
color: var(--color-muted);
opacity: 0;
transition: opacity .15s;
flex-shrink: 0;
}
.bi-cell:hover::after { opacity: 1; }
.bi-cell input,
.bi-cell select {
font-size: inherit;
font-family: inherit;
padding: 2px 5px;
border: 1px solid var(--color-primary);
border-radius: var(--radius);
background: #fff;
outline: none;
}
.bi-cell input[type="number"] { width: 70px; }
.bi-cell input[type="text"] { width: 60px; }
.bi-cell select { width: auto; min-width: 180px; }
.bi-cell--saving { opacity: .5; pointer-events: none; }
.bi-cell--error { border-color: var(--color-danger) !important; background: #fee2e2 !important; }
.bi-cell--wide { display: flex; width: 100%; box-sizing: border-box; }
.bi-checkbox-label {
display: inline-flex;
align-items: center;
gap: .4rem;
cursor: pointer;
padding: .15rem .3rem;
border-radius: var(--radius);
border: 1px solid transparent;
transition: border-color .15s, background .15s;
user-select: none;
}
.bi-checkbox-label:hover {
background: var(--color-bg);
border-color: var(--color-border);
}
.bi-checkbox { cursor: pointer; }
.bi-error-msg {
font-size: .8rem;
color: var(--color-danger);
margin-top: .2rem;
display: block;
}
</style>
<script>
(function () {
const DATE_FORMAT_OPTIONS = <?= json_encode($dateFormatOptions) ?>;
function badgeTextColor(hex) {
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
return (0.299*r + 0.587*g + 0.114*b) > 128 ? '#1e293b' : '#ffffff';
}
function updatePreview(colorInput, hex) {
const preview = colorInput.closest('div')?.querySelector('.bi-kuerzel-preview');
if (!preview) return;
preview.style.background = hex;
preview.style.color = badgeTextColor(hex);
}
// ── Hilfsfunktionen ──────────────────────────────────────────────
function getFormatId(cell) {
return cell.closest('[data-format-id]')?.dataset.formatId;
}
function showError(cell, msg) {
cell.classList.add('bi-cell--error');
let err = cell.parentElement.querySelector('.bi-error-msg');
if (!err) { err = document.createElement('span'); err.className = 'bi-error-msg'; cell.after(err); }
err.textContent = msg;
setTimeout(() => { cell.classList.remove('bi-cell--error'); err.remove(); }, 3000);
}
async function saveField(formatId, field, value) {
const body = new URLSearchParams({ field, value: value ?? '' });
const res = await fetch(`/settings/bankimport/${formatId}/field`, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
return res.json();
}
function displayForCell(type, value, field) {
if (type === 'number' || type === 'nullint') {
if (value === '' || value === null || value === undefined) {
return '<span style="color:var(--color-muted);font-style:italic">–</span>';
}
return `<code>${value}</code>`;
}
if (type === 'char' || type === 'char0') {
if (value === '') return '<span style="color:var(--color-muted);font-style:italic">keines</span>';
return `<code>${value.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</code>`;
}
if (type === 'dateformat') {
const example = DATE_FORMAT_OPTIONS[value] || '?';
return `<code>${value}</code><span style="color:var(--color-muted);margin-left:.4rem">→ ${example}</span>`;
}
if (type === 'template') {
if (!value) return '<span style="color:var(--color-muted);font-style:italic">nicht gesetzt</span>';
const esc = value.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
return `<code style="word-break:break-all">${esc}</code>`;
}
if (type === 'text') {
return value.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
return value;
}
// ── Klick-Handler für .bi-cell ───────────────────────────────────
document.addEventListener('click', (e) => {
const cell = e.target.closest('.bi-cell');
if (!cell || cell.querySelector('input,select')) return;
const field = cell.dataset.field;
const type = cell.dataset.type;
const rawValue = cell.dataset.value ?? '';
const formatId = getFormatId(cell);
if (!formatId) return;
const originalHTML = cell.innerHTML;
cell.innerHTML = '';
cell.classList.remove('bi-cell--error');
let el;
if (type === 'number' || type === 'nullint') {
el = document.createElement('input');
el.type = 'number';
el.min = cell.dataset.min ?? '0';
el.max = cell.dataset.max ?? '99';
el.value = rawValue;
} else if (type === 'char') {
el = document.createElement('input');
el.type = 'text';
el.maxLength = 1;
el.value = rawValue;
} else if (type === 'char0') {
el = document.createElement('input');
el.type = 'text';
el.maxLength = 1;
el.value = rawValue;
el.placeholder = '(leer)';
el.style.width = '70px';
} else if (type === 'dateformat') {
el = document.createElement('select');
for (const [fmt, example] of Object.entries(DATE_FORMAT_OPTIONS)) {
const opt = document.createElement('option');
opt.value = fmt;
opt.textContent = `${fmt} → ${example}`;
if (fmt === rawValue) opt.selected = true;
el.appendChild(opt);
}
} else if (type === 'template') {
el = document.createElement('input');
el.type = 'text';
el.value = rawValue;
el.style.width = '100%';
el.style.boxSizing = 'border-box';
} else {
el = document.createElement('input');
el.type = 'text';
el.value = rawValue;
el.style.width = '180px';
}
cell.appendChild(el);
el.focus();
if (el.tagName === 'INPUT' && el.type === 'text') el.select();
let done = false;
async function commit() {
if (done) return;
done = true;
const newVal = el.tagName === 'SELECT' ? el.value : el.value;
cell.classList.add('bi-cell--saving');
cell.innerHTML = '';
let json;
try {
json = await saveField(formatId, field, newVal);
} catch (_) {
json = { error: 'Verbindungsfehler.' };
}
cell.classList.remove('bi-cell--saving');
if (json.error) {
cell.innerHTML = originalHTML;
showError(cell, json.error);
return;
}
cell.dataset.value = newVal;
cell.innerHTML = displayForCell(type, newVal, field);
// Formatname im Card-Header synchronisieren
if (field === 'name') {
const title = cell.closest('.card')?.querySelector('.card-title');
if (title) title.dataset.value = newVal;
}
// Kürzel-Vorschau-Badge aktualisieren
if (field === 'kuerzel') {
const preview = cell.closest('div')?.querySelector('.bi-kuerzel-preview');
if (preview) preview.textContent = newVal;
}
}
function cancel() {
if (done) return;
done = true;
cell.innerHTML = originalHTML;
}
if (el.tagName === 'SELECT') {
el.addEventListener('change', commit);
el.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') { ev.stopPropagation(); cancel(); } });
el.addEventListener('blur', () => { if (!done) commit(); });
} else {
el.addEventListener('blur', commit);
el.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') { ev.preventDefault(); el.blur(); }
if (ev.key === 'Escape') { ev.preventDefault(); cancel(); }
});
}
});
// ── Colorpicker für farbe ────────────────────────────────────────
document.addEventListener('input', (e) => {
const ci = e.target.closest('.bi-color');
if (ci) updatePreview(ci, ci.value);
});
document.addEventListener('change', async (e) => {
const ci = e.target.closest('.bi-color');
if (!ci) return;
const formatId = getFormatId(ci);
const field = ci.dataset.field;
const value = ci.value;
ci.disabled = true;
let json;
try { json = await saveField(formatId, field, value); }
catch (_) { json = { error: 'Verbindungsfehler.' }; }
ci.disabled = false;
if (json.error) {
showError(ci, json.error);
} else {
updatePreview(ci, value);
}
});
// ── Format löschen ──────────────────────────────────────────────
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.bi-delete-format');
if (!btn) return;
const formatId = btn.dataset.formatId;
const formatName = btn.dataset.formatName;
if (!confirm(`Format „${formatName}" wirklich löschen?`)) return;
btn.disabled = true;
let json;
try {
const res = await fetch(`/settings/bankimport/${formatId}/delete`, { method: 'POST' });
json = await res.json();
} catch (_) {
json = { error: 'Verbindungsfehler.' };
}
if (json.error) {
alert(json.error);
btn.disabled = false;
return;
}
btn.closest('.card').remove();
});
// ── Vorschlags-Card verwerfen ────────────────────────────────────
const dismissBtn = document.getElementById('suggestion-dismiss');
if (dismissBtn) {
dismissBtn.addEventListener('click', async () => {
dismissBtn.disabled = true;
try {
await fetch('/settings/bankimport/dismiss-suggestion', { method: 'POST' });
} catch (_) {}
const card = document.getElementById('suggestion-card');
if (card) card.remove();
});
}
// ── Checkboxen für has_header_row ────────────────────────────────
document.addEventListener('change', async (e) => {
const cb = e.target.closest('.bi-checkbox');
if (!cb) return;
const label = cb.closest('.bi-checkbox-label');
const textSpan = label?.querySelector('.bi-checkbox-text');
const field = cb.dataset.field;
const formatId = getFormatId(cb);
if (!formatId) return;
const newVal = cb.checked ? '1' : '0';
cb.disabled = true;
let json;
try {
json = await saveField(formatId, field, newVal);
} catch (_) {
json = { error: 'Verbindungsfehler.' };
}
cb.disabled = false;
if (json.error) {
cb.checked = !cb.checked; // Revert
if (textSpan) textSpan.textContent = cb.checked ? 'Ja' : 'Nein';
alert(json.error);
return;
}
if (textSpan) textSpan.textContent = cb.checked ? 'Ja' : 'Nein';
});
})();
</script>

View file

@ -1 +1 @@
1-260515 1-260517

View file

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
/**
* Test 32: BankTransaction – linkBeleg, unlinkBeleg, recalcStatus, findDuplicates.
*
* Legt eine Test-Bankbuchung an und prüft die Zuordnungslogik komplett durch.
*/
class BankTransactionTest extends TestCase
{
protected string $testName = 'Test 32: BankTransaction – Zuordnungslogik';
public function run(): void
{
$formatId = (int)(\Steuerkram\Database::fetchOne('SELECT id FROM bank_import_formats LIMIT 1')['id'] ?? 0);
if (!$formatId) {
$this->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');
}
}

View file

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
/**
* Test 33: BankImportParser – CSV-Parsing, Leerzeilen, ungültiges Format.
*
* Testet den BankImportParser mit echten CSV-Strings (als temp. Datei),
* ohne eine Datei hochzuladen.
*/
class BankImportParserTest extends TestCase
{
protected string $testName = 'Test 33: BankImportParser – CSV-Parsing';
private function makeTempCsv(string $content): string
{
$path = sys_get_temp_dir() . '/test33_' . uniqid() . '.csv';
file_put_contents($path, $content);
return $path;
}
private function comdirectFormat(): array
{
return [
'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' => '.',
];
}
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');
}
}

View file

@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
/**
* Test 34: BankImportParser – DKB-Format mit Template-basiertem Buchungstext.
*/
class BankImportDkbTest extends TestCase
{
protected string $testName = 'Test 34: BankImportParser – DKB-Format (Template)';
private function makeTempCsv(string $content): string
{
$path = sys_get_temp_dir() . '/test34_' . uniqid() . '.csv';
file_put_contents($path, $content);
return $path;
}
private function dkbFormat(): array
{
return [
'skip_rows' => 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');
}
}

View file

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
use Steuerkram\Services\BankImportParser;
/**
* Test 35: BankImportParser::suggestFormat() – automatische Format-Erkennung.
*/
class BankImportSuggestTest extends TestCase
{
protected string $testName = 'Test 35: BankImportParser – automatische Format-Erkennung';
private function makeTempCsv(string $content): string
{
$path = sys_get_temp_dir() . '/test35_' . uniqid() . '.csv';
file_put_contents($path, $content);
return $path;
}
public function run(): void
{
$parser = new BankImportParser();
// CSV mit 2 Metazeilen vor dem Header (DKB-ähnlich)
$csv = implode("\n", [
'"Kontonummer:";"DE12 3456 7890 1234 5678 90";',
'"Von:";"01.01.2026";"Bis:";"31.01.2026";',
'"Kontostand vom 31.01.2026:";"1.234,56 EUR";',
'"";',
'"Buchungsdatum";"Wertstellung";"Status";"Zahlungspflichtige*r";"Zahlungsempfänger*in";"Verwendungszweck";"Umsatztyp";"IBAN";"Betrag (€)"',
'"15.01.2026";"15.01.2026";"Gebucht";"Max Mustermann";"REWE";"Lebensmittel";"Lastschrift";"DE11111";"−45,30"',
]);
$file = $this->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);
}
}