Refactor: Code-Qualität, Sicherheit und Konsistenz

This commit is contained in:
fds2610-bot 2026-05-16 07:30:46 +02:00
parent 93d6aacbae
commit 3132196ad6
27 changed files with 609 additions and 268 deletions

View file

@ -2,6 +2,21 @@
--- ---
## 2026-05-16 – Refactor: Code-Qualität, Sicherheit und Konsistenz
- **CSRF-Schutz**: `Auth::generateCsrfToken()` / `validateCsrfToken()` neu; Router validiert Token bei allen POST/PUT/DELETE-Requests; base.php injiziert Token in Formulare und fetch-Aufrufe via JS; login.php erhält hidden Input
- **Path Traversal** in `BelegController::serveFile()` abgesichert (`realpath()` + Prefix-Check gegen Upload-Basisverzeichnis)
- **`mime_content_type()`** (deprecated) durch `finfo_file(finfo_open(FILEINFO_MIME_TYPE), ...)` in `BelegController`, `SettingsController`, `BankController` und `ImageCropService` ersetzt
- **XSS** in `topbarActions`: `View::buildQuery()` (neuer Helper) statt rohem `http_build_query($_GET)`
- **`@`-Unterdrückung** in `SettingsController` entfernt (ersetzt durch `file_exists()`-Prüfung)
- **BaseController**: gemeinsame `renderView()`-Methode zentralisiert; alle 13 Controller erben davon; lokale Duplikate entfernt
- **DB-Transaktionen**: `Booking::create()` und `Booking::reverse()` laufen jetzt in atomaren Transaktionen; `Database::beginTransaction/commit/rollBack` unterstützt verschachtelte Aufrufe via Tiefenzähler (TestCase-Isolation bleibt intakt)
- **`src/Constants.php`**: Buchungstypen und Steuerkontonummern (SKR03) als benannte Konstanten; Magic Strings in `Booking.php` ersetzt
- **Dokumentation**: `docs/funktionen.md` – Abschnitt „Bank buchen" ergänzt; UStVA-Kennzahlen (81, 89, 66, 61, 83) korrigiert; Eigenanteil-Konten (4920/4925) korrigiert
- **Tests**: `32_BankTransactionTest` (linkBeleg, unlinkBeleg, recalcStatus, findDuplicates) und `33_BankImportParserTest` (CSV-Parsing, BOM, Tausendertrenner, Fehlerfall) neu
---
## 2026-05-15 – Feature: Bankbuchung auf mehrere Belege aufteilen ## 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

@ -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,20 @@ Anlagenspiegel für abnutzbare Wirtschaftsgüter – siehe [anlagevermögen.md](
--- ---
## Bank buchen
- Import von Kontoauszügen: CSV, TXT und XLSX (konfigurierbare Spalten- und Datumsformate je Bank)
- **Dublettenprüfung**: beim Import werden Zeilen, die bereits in der DB vorhanden sind, als Duplikate markiert und nicht erneut importiert
- **Beleg-Zuordnung**: jede Banktransaktion kann einem oder mehreren Belegen zugeordnet werden; der zugeordnete Betrag wird separat erfasst
- **Teilzuordnung**: eine Transaktion kann schrittweise mehreren Belegen anteilig zugeordnet werden
- **Status-Logik**: `offen` → keine Zuordnung, `teilweise` → Restsaldo > 0,01 €, `gebucht` → vollständig zugeordnet
- Direkter Beleg-Upload aus der Transaktionsansicht heraus (erzeugt neuen Beleg und verknüpft ihn)
---
## UStVA (Umsatzsteuervoranmeldung) ## 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

@ -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,7 +9,7 @@ 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
{ {
@ -268,12 +268,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'];
@ -273,7 +273,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 +305,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

@ -8,12 +8,13 @@ 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 +39,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 +64,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 +138,4 @@ class SettingsController
View::redirect('/settings', 'Logo gelöscht.'); View::redirect('/settings', 'Logo gelöscht.');
} }
private function renderView(string $template, array $data): string
{
extract($data, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/Views/' . $template . '.php';
return ob_get_clean();
}
} }

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

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

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

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

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

@ -1 +1 @@
1-260515 1-260516

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');
}
}