diff --git a/Changes.md b/Changes.md index 0a9fc3e..d472723 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,21 @@ --- +## 2026-05-16 – Refactor: Code-Qualität, Sicherheit und Konsistenz + +- **CSRF-Schutz**: `Auth::generateCsrfToken()` / `validateCsrfToken()` neu; Router validiert Token bei allen POST/PUT/DELETE-Requests; base.php injiziert Token in Formulare und fetch-Aufrufe via JS; login.php erhält hidden Input +- **Path Traversal** in `BelegController::serveFile()` abgesichert (`realpath()` + Prefix-Check gegen Upload-Basisverzeichnis) +- **`mime_content_type()`** (deprecated) durch `finfo_file(finfo_open(FILEINFO_MIME_TYPE), ...)` in `BelegController`, `SettingsController`, `BankController` und `ImageCropService` ersetzt +- **XSS** in `topbarActions`: `View::buildQuery()` (neuer Helper) statt rohem `http_build_query($_GET)` +- **`@`-Unterdrückung** in `SettingsController` entfernt (ersetzt durch `file_exists()`-Prüfung) +- **BaseController**: gemeinsame `renderView()`-Methode zentralisiert; alle 13 Controller erben davon; lokale Duplikate entfernt +- **DB-Transaktionen**: `Booking::create()` und `Booking::reverse()` laufen jetzt in atomaren Transaktionen; `Database::beginTransaction/commit/rollBack` unterstützt verschachtelte Aufrufe via Tiefenzähler (TestCase-Isolation bleibt intakt) +- **`src/Constants.php`**: Buchungstypen und Steuerkontonummern (SKR03) als benannte Konstanten; Magic Strings in `Booking.php` ersetzt +- **Dokumentation**: `docs/funktionen.md` – Abschnitt „Bank buchen" ergänzt; UStVA-Kennzahlen (81, 89, 66, 61, 83) korrigiert; Eigenanteil-Konten (4920/4925) korrigiert +- **Tests**: `32_BankTransactionTest` (linkBeleg, unlinkBeleg, recalcStatus, findDuplicates) und `33_BankImportParserTest` (CSV-Parsing, BOM, Tausendertrenner, Fehlerfall) neu + +--- + ## 2026-05-15 – Feature: Bankbuchung auf mehrere Belege aufteilen - Migration 053: Junction-Tabelle `bank_transaction_belege` (bank_tx_id, beleg_id, betrag); `bank_transactions.beleg_id` entfernt; Status-ENUM um `teilweise` erweitert; bestehende Verknüpfungen automatisch migriert diff --git a/docs/funktionen.md b/docs/funktionen.md index 9a02da2..0845f8a 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -25,7 +25,7 @@ EÜR-Buchungsmodell (Einnahme/Ausgabe) mit Kategorie- und Zahlungskonto. - **Steuerarten** für Auslandsumsätze: Inland, steuerfreie ig. Lieferung (Kz 41), steuerfreie Ausfuhr (Kz 44), ig. Erwerb 19 % (Kz 89/91), ig. Erwerb 7 % (Kz 93/94) - **Bewirtungsausgaben**: automatischer Split 70 %/30 % bei Konten 4650, 4651, 4652 -- **Eigenanteil** (30 %) bei Telefon/Internet (Konto 4940): optional zubuchbar +- **Eigenanteil** (30 %) bei Telefon/Internet (Konten 4920/4925): optional zubuchbar - **Stornobuchungen**: GoBD-konforme Gegenbuchung statt Löschung - **GoBD-Schutz**: MariaDB `BEFORE UPDATE/DELETE`-Trigger verhindern nachträgliche Manipulation gebuchter Sätze - **Journal**: gefilterte Übersicht aller Buchungen mit CSV-Export @@ -62,9 +62,20 @@ Anlagenspiegel für abnutzbare Wirtschaftsgüter – siehe [anlagevermögen.md]( --- +## Bank buchen + +- Import von Kontoauszügen: CSV, TXT und XLSX (konfigurierbare Spalten- und Datumsformate je Bank) +- **Dublettenprüfung**: beim Import werden Zeilen, die bereits in der DB vorhanden sind, als Duplikate markiert und nicht erneut importiert +- **Beleg-Zuordnung**: jede Banktransaktion kann einem oder mehreren Belegen zugeordnet werden; der zugeordnete Betrag wird separat erfasst +- **Teilzuordnung**: eine Transaktion kann schrittweise mehreren Belegen anteilig zugeordnet werden +- **Status-Logik**: `offen` → keine Zuordnung, `teilweise` → Restsaldo > 0,01 €, `gebucht` → vollständig zugeordnet +- Direkter Beleg-Upload aus der Transaktionsansicht heraus (erzeugt neuen Beleg und verknüpft ihn) + +--- + ## UStVA (Umsatzsteuervoranmeldung) -- Monatliche Kennzahlen-Berechnung aus Buchungen (Kz 81, 83, 86, 84, 35, 41, 44, 61, 62, 67, 66, 69) +- Monatliche Kennzahlen-Berechnung aus Buchungen (Kz 81, 89, 66, 61, 83) - ELSTER XML-Export - Drill-down: Klick auf eine Kennzahl zeigt alle zugehörigen Buchungszeilen diff --git a/src/Auth.php b/src/Auth.php index a00e8d6..43e7700 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -52,4 +52,27 @@ class Auth { return isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null; } + + public static function generateCsrfToken(): string + { + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } + if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + return $_SESSION['csrf_token']; + } + + public static function validateCsrfToken(string $token): void + { + if (session_status() === PHP_SESSION_NONE) { + session_start(); + } + $stored = $_SESSION['csrf_token'] ?? ''; + if ($stored === '' || !hash_equals($stored, $token)) { + http_response_code(403); + exit('CSRF-Token ungültig.'); + } + } } diff --git a/src/Constants.php b/src/Constants.php new file mode 100644 index 0000000..1f5d4e8 --- /dev/null +++ b/src/Constants.php @@ -0,0 +1,22 @@ + 'xml', ]; - $mime = mime_content_type($file['tmp_name']) ?: ''; + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = (string)(finfo_file($finfo, $file['tmp_name']) ?: ''); + finfo_close($finfo); if (!isset($allowedMime[$mime])) { View::redirect("/bank/$id", 'Nicht erlaubtes Dateiformat.', 'error'); } @@ -303,12 +305,4 @@ class BankController View::redirect("/bank/$id", 'Beleg hochgeladen – bitte jetzt verknüpfen.'); } - private function renderView(string $name, array $data = []): string - { - $viewFile = dirname(__DIR__) . '/Views/' . $name . '.php'; - extract($data, EXTR_SKIP); - ob_start(); - include $viewFile; - return ob_get_clean(); - } } diff --git a/src/Controllers/BaseController.php b/src/Controllers/BaseController.php new file mode 100644 index 0000000..7d22093 --- /dev/null +++ b/src/Controllers/BaseController.php @@ -0,0 +1,16 @@ + 'pdf', @@ -45,7 +45,7 @@ class BelegController 'page' => $page, 'filter' => $filter, ]), - 'topbarActions' => 'CSV Export + Beleg hochladen', + 'topbarActions' => 'CSV Export + Beleg hochladen', ]); } @@ -114,8 +114,10 @@ class BelegController $errors['file'] = 'Keine gültige Datei übermittelt.'; } else { $file = $_FILES['beleg_file']; - $mime = mime_content_type($file['tmp_name']); - $mimeKey = strtolower($mime); + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = finfo_file($finfo, $file['tmp_name']); + finfo_close($finfo); + $mimeKey = strtolower((string)$mime); if (!isset(self::ALLOWED_MIME[$mimeKey])) { $errors['file'] = 'Nicht erlaubter Dateityp. Erlaubt: PDF, PNG, JPEG, TIFF, XML.'; @@ -141,7 +143,9 @@ class BelegController } $file = $_FILES['beleg_file']; - $mime = mime_content_type($file['tmp_name']); + $finfo2 = finfo_open(FILEINFO_MIME_TYPE); + $mime = (string)finfo_file($finfo2, $file['tmp_name']); + finfo_close($finfo2); $ext = self::ALLOWED_MIME[strtolower($mime)]; $belegNr = Beleg::generateBelegNr(); $year = !empty($_POST['beleg_datum']) @@ -300,13 +304,20 @@ class BelegController exit; } - $absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path']; - if (!file_exists($absPath)) { http_response_code(404); return; } + $basePath = (string)realpath(dirname(__DIR__, 2) . '/public/static/uploads'); + $absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path']; + $realPath = realpath($absPath); + + if ($realPath === false || !str_starts_with($realPath, $basePath)) { + http_response_code(403); + return; + } + if (!file_exists($realPath)) { http_response_code(404); return; } header('Content-Type: ' . $beleg['mime_type']); header('Content-Disposition: inline; filename="' . $beleg['filename'] . '"'); - header('Content-Length: ' . filesize($absPath)); - readfile($absPath); + header('Content-Length: ' . filesize($realPath)); + readfile($realPath); } public function updateMeta(array $params): void @@ -440,12 +451,4 @@ class BelegController } return $value; } - - private function renderView(string $template, array $data): string - { - extract($data, EXTR_SKIP); - ob_start(); - include dirname(__DIR__) . '/Views/' . $template . '.php'; - return ob_get_clean(); - } } diff --git a/src/Controllers/BookingController.php b/src/Controllers/BookingController.php index d3c8cbe..fa1d4e8 100644 --- a/src/Controllers/BookingController.php +++ b/src/Controllers/BookingController.php @@ -12,7 +12,7 @@ use Steuerkram\Models\FiscalYear; use Steuerkram\Models\Setting; use Steuerkram\View; -class BookingController +class BookingController extends BaseController { public function journal(array $params): void { @@ -324,12 +324,5 @@ class BookingController } return $value; } - - private function renderView(string $template, array $data): string - { - extract($data, EXTR_SKIP); - ob_start(); - include dirname(__DIR__) . '/Views/' . $template . '.php'; - return ob_get_clean(); - } } + diff --git a/src/Controllers/CustomerController.php b/src/Controllers/CustomerController.php index 705d4e4..183fa86 100644 --- a/src/Controllers/CustomerController.php +++ b/src/Controllers/CustomerController.php @@ -7,7 +7,7 @@ namespace Steuerkram\Controllers; use Steuerkram\Models\Customer; use Steuerkram\View; -class CustomerController +class CustomerController extends BaseController { public function index(array $params): void { @@ -137,12 +137,5 @@ class CustomerController if (empty($data['ort'])) $errors['ort'] = 'Pflichtfeld'; return $errors; } - - private function renderView(string $template, array $data): string - { - extract($data, EXTR_SKIP); - ob_start(); - include dirname(__DIR__) . '/Views/' . $template . '.php'; - return ob_get_clean(); - } } + diff --git a/src/Controllers/EuerController.php b/src/Controllers/EuerController.php index d7da4b8..11d90c8 100644 --- a/src/Controllers/EuerController.php +++ b/src/Controllers/EuerController.php @@ -10,7 +10,7 @@ use Steuerkram\Services\EuerCalculator; use Steuerkram\Services\PdfEuerService; use Steuerkram\View; -class EuerController +class EuerController extends BaseController { public function index(array $params): void { @@ -70,12 +70,5 @@ class EuerController $svc = new PdfEuerService(); $svc->render($euer, $settings); } - - private function renderView(string $template, array $data): string - { - extract($data, EXTR_SKIP); - ob_start(); - include dirname(__DIR__) . '/Views/' . $template . '.php'; - return ob_get_clean(); - } } + diff --git a/src/Controllers/InvoiceController.php b/src/Controllers/InvoiceController.php index bcd84e2..3c8408a 100644 --- a/src/Controllers/InvoiceController.php +++ b/src/Controllers/InvoiceController.php @@ -14,7 +14,7 @@ use Steuerkram\Services\EmailService; use Steuerkram\Services\PdfInvoiceService; use Steuerkram\View; -class InvoiceController +class InvoiceController extends BaseController { public function index(array $params): void { @@ -581,12 +581,5 @@ class InvoiceController } return $errors; } - - private function renderView(string $template, array $data): string - { - extract($data, EXTR_SKIP); - ob_start(); - include dirname(__DIR__) . '/Views/' . $template . '.php'; - return ob_get_clean(); - } } + diff --git a/src/Controllers/SettingsController.php b/src/Controllers/SettingsController.php index 56aaaf2..863cf5d 100644 --- a/src/Controllers/SettingsController.php +++ b/src/Controllers/SettingsController.php @@ -8,12 +8,13 @@ use Steuerkram\Models\Setting; use Steuerkram\Services\EmailService; use Steuerkram\View; -class SettingsController +class SettingsController extends BaseController { public function show(array $params): void { - $settings = Setting::get(); - $version = trim(@file_get_contents(dirname(__DIR__) . '/version.txt') ?: ''); + $settings = Setting::get(); + $versionFile = dirname(__DIR__) . '/version.txt'; + $version = file_exists($versionFile) ? trim(file_get_contents($versionFile)) : ''; $view = new View(); $view->render('layout/base.php', [ @@ -38,7 +39,8 @@ class SettingsController if (empty($data['ort'])) $errors['ort'] = 'Pflichtfeld'; if ($errors) { - $version = trim(@file_get_contents(dirname(__DIR__) . '/version.txt') ?: ''); + $vf = dirname(__DIR__) . '/version.txt'; + $version = file_exists($vf) ? trim(file_get_contents($vf)) : ''; $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Einstellungen', @@ -62,8 +64,10 @@ class SettingsController return; } - $file = $_FILES['logo']; - $mime = mime_content_type($file['tmp_name']); + $file = $_FILES['logo']; + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = (string)finfo_file($finfo, $file['tmp_name']); + finfo_close($finfo); if (!in_array($mime, ['image/png', 'image/jpeg'])) { View::redirect('/settings', 'Logo muss PNG oder JPEG sein.', 'error'); @@ -134,11 +138,4 @@ class SettingsController View::redirect('/settings', 'Logo gelöscht.'); } - private function renderView(string $template, array $data): string - { - extract($data, EXTR_SKIP); - ob_start(); - include dirname(__DIR__) . '/Views/' . $template . '.php'; - return ob_get_clean(); - } } diff --git a/src/Controllers/SupplierController.php b/src/Controllers/SupplierController.php index df1ab2f..5fb0cda 100644 --- a/src/Controllers/SupplierController.php +++ b/src/Controllers/SupplierController.php @@ -7,7 +7,7 @@ namespace Steuerkram\Controllers; use Steuerkram\Models\Supplier; use Steuerkram\View; -class SupplierController +class SupplierController extends BaseController { public function index(array $params): void { @@ -133,12 +133,5 @@ class SupplierController header('Content-Type: application/json'); echo json_encode(array_map(fn($s) => ['id' => $s['id'], 'name' => $s['name']], $suppliers)); } - - private function renderView(string $template, array $data): string - { - extract($data, EXTR_SKIP); - ob_start(); - include dirname(__DIR__) . '/Views/' . $template . '.php'; - return ob_get_clean(); - } } + diff --git a/src/Controllers/UserAdminController.php b/src/Controllers/UserAdminController.php index a44072d..9dbc2a3 100644 --- a/src/Controllers/UserAdminController.php +++ b/src/Controllers/UserAdminController.php @@ -7,7 +7,7 @@ namespace Steuerkram\Controllers; use Steuerkram\Models\User; use Steuerkram\View; -class UserAdminController +class UserAdminController extends BaseController { public function index(array $params): void { @@ -140,12 +140,5 @@ class UserAdminController $msg = $newState ? 'Benutzer aktiviert.' : 'Benutzer deaktiviert.'; View::redirect('/useradmin', $msg); } - - private function renderView(string $template, array $data): string - { - extract($data, EXTR_SKIP); - ob_start(); - include dirname(__DIR__) . '/Views/' . $template . '.php'; - return ob_get_clean(); - } } + diff --git a/src/Controllers/UstvaController.php b/src/Controllers/UstvaController.php index 1671a75..28d0ade 100644 --- a/src/Controllers/UstvaController.php +++ b/src/Controllers/UstvaController.php @@ -10,7 +10,7 @@ use Steuerkram\Services\UstvaCalculator; use Steuerkram\Services\ElsterXmlService; use Steuerkram\View; -class UstvaController +class UstvaController extends BaseController { public function index(array $params): void { @@ -148,12 +148,5 @@ class UstvaController header('Content-Length: ' . strlen($xml)); echo $xml; } - - private function renderView(string $template, array $data): string - { - extract($data, EXTR_SKIP); - ob_start(); - include dirname(__DIR__) . '/Views/' . $template . '.php'; - return ob_get_clean(); - } } + diff --git a/src/Database.php b/src/Database.php index fad5d65..85f706a 100644 --- a/src/Database.php +++ b/src/Database.php @@ -10,6 +10,7 @@ use PDOException; class Database { private static ?PDO $instance = null; + private static int $txDepth = 0; public static function get(): PDO { @@ -62,16 +63,27 @@ class Database public static function beginTransaction(): void { - self::get()->beginTransaction(); + if (self::$txDepth === 0) { + self::get()->beginTransaction(); + } + self::$txDepth++; } public static function commit(): void { - self::get()->commit(); + if (self::$txDepth > 0) { + self::$txDepth--; + } + if (self::$txDepth === 0 && self::get()->inTransaction()) { + self::get()->commit(); + } } public static function rollBack(): void { - self::get()->rollBack(); + self::$txDepth = 0; + if (self::get()->inTransaction()) { + self::get()->rollBack(); + } } } diff --git a/src/Models/Booking.php b/src/Models/Booking.php index 5b18989..0f1011f 100644 --- a/src/Models/Booking.php +++ b/src/Models/Booking.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Steuerkram\Models; +use Steuerkram\Constants; use Steuerkram\Database; class Booking @@ -112,88 +113,52 @@ class Booking $assetId = isset($data['asset_id']) && $data['asset_id'] !== '' ? (int)$data['asset_id'] : null; - Database::execute( - 'INSERT INTO bookings (buchungs_nr, beleg_id, asset_id, buchungs_datum, buchungstyp, - kategorie_konto_id, zahlungskonto_id, - betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, - buchungstext, fiscal_year_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - [ - $nr, - (int)$data['beleg_id'], - $assetId, - $data['buchungs_datum'], - $data['buchungstyp'], - (int)$data['kategorie_konto_id'], - (int)$data['zahlungskonto_id'], - $netto, - (float)($data['ust_satz'] ?? 0), - $steuerart, - $ust, - $brutto, - $data['buchungstext'], - $fy['id'], - ] - ); + Database::beginTransaction(); + try { + Database::execute( + 'INSERT INTO bookings (buchungs_nr, beleg_id, asset_id, buchungs_datum, buchungstyp, + kategorie_konto_id, zahlungskonto_id, + betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, + buchungstext, fiscal_year_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + $nr, + (int)$data['beleg_id'], + $assetId, + $data['buchungs_datum'], + $data['buchungstyp'], + (int)$data['kategorie_konto_id'], + (int)$data['zahlungskonto_id'], + $netto, + (float)($data['ust_satz'] ?? 0), + $steuerart, + $ust, + $brutto, + $data['buchungstext'], + $fy['id'], + ] + ); - $mainId = (int)Database::lastInsertId(); + $mainId = (int)Database::lastInsertId(); - // VSt-/USt-Buchungszeile für normale Buchungen mit 7 % oder 19 % anlegen - $ustSatzInt = (int)round((float)($data['ust_satz'] ?? 0)); - if (in_array($data['buchungstyp'], ['Ausgabe', 'Einnahme'], true) - && in_array($ustSatzInt, [7, 19], true) - && $steuerart === 'normal' - && abs($ust) > 0.0 - ) { - $isAusgabe = $data['buchungstyp'] === 'Ausgabe'; - if ($isAusgabe) { - $steuerKontonummer = $ustSatzInt === 19 ? '1576' : '1571'; - $steuerText = 'Vorsteuer zu ' . $nr; - } else { - $steuerKontonummer = $ustSatzInt === 19 ? '1776' : '1771'; - $steuerText = 'USt zu ' . $nr; - } - $steuerAcc = Account::findByKontonummer($steuerKontonummer); - if ($steuerAcc) { - $steuerNr = self::generateBuchungsNr(); - Database::execute( - 'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp, - kategorie_konto_id, zahlungskonto_id, - betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, - buchungstext, fiscal_year_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - [ - $steuerNr, - (int)$data['beleg_id'], - $mainId, - $data['buchungs_datum'], - $data['buchungstyp'], - (int)$steuerAcc['id'], - (int)$data['zahlungskonto_id'], - $ust, - 0.0, - 'normal', - 0.0, - $ust, - $steuerText, - $fy['id'], - ] - ); - } - } - - // ig. Erwerb 19 % ReverseCharge: Kindzeilen auf 1574 (VSt, Kz 67) und 1774 (USt, Kz 91) - if ($data['buchungstyp'] === 'Ausgabe' - && $steuerart === 'ig_erwerb_19' - && abs($netto) > 0.0 - ) { - $igeUst = round($netto * 0.19, 2); - foreach ([ - ['1574', 'VSt ig.E. zu '], - ['1774', 'USt ig.E. zu '], - ] as [$kontonr, $prefix]) { - $igeAcc = Account::findByKontonummer($kontonr); - if ($igeAcc) { + // VSt-/USt-Buchungszeile für normale Buchungen mit 7 % oder 19 % anlegen + $ustSatzInt = (int)round((float)($data['ust_satz'] ?? 0)); + if (in_array($data['buchungstyp'], [Constants::BUCHUNGSTYP_AUSGABE, Constants::BUCHUNGSTYP_EINNAHME], true) + && in_array($ustSatzInt, [7, 19], true) + && $steuerart === 'normal' + && abs($ust) > 0.0 + ) { + $isAusgabe = $data['buchungstyp'] === Constants::BUCHUNGSTYP_AUSGABE; + if ($isAusgabe) { + $steuerKontonummer = $ustSatzInt === 19 ? Constants::KONTO_VST_19 : Constants::KONTO_VST_7; + $steuerText = 'Vorsteuer zu ' . $nr; + } else { + $steuerKontonummer = $ustSatzInt === 19 ? Constants::KONTO_UST_19 : Constants::KONTO_UST_7; + $steuerText = 'USt zu ' . $nr; + } + $steuerAcc = Account::findByKontonummer($steuerKontonummer); + if ($steuerAcc) { + $steuerNr = self::generateBuchungsNr(); Database::execute( 'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp, kategorie_konto_id, zahlungskonto_id, @@ -201,24 +166,68 @@ class Booking buchungstext, fiscal_year_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ - self::generateBuchungsNr(), + $steuerNr, (int)$data['beleg_id'], $mainId, $data['buchungs_datum'], - 'Ausgabe', - (int)$igeAcc['id'], + $data['buchungstyp'], + (int)$steuerAcc['id'], (int)$data['zahlungskonto_id'], - $igeUst, + $ust, 0.0, 'normal', 0.0, - $igeUst, - $prefix . $nr, + $ust, + $steuerText, $fy['id'], ] ); } } + + // ig. Erwerb 19 % ReverseCharge: Kindzeilen auf VST_IGE_19 (Kz 67) und UST_IGE_19 (Kz 91) + if ($data['buchungstyp'] === Constants::BUCHUNGSTYP_AUSGABE + && $steuerart === 'ig_erwerb_19' + && abs($netto) > 0.0 + ) { + $igeUst = round($netto * 0.19, 2); + foreach ([ + [Constants::KONTO_VST_IGE_19, 'VSt ig.E. zu '], + [Constants::KONTO_UST_IGE_19, 'USt ig.E. zu '], + ] as [$kontonr, $prefix]) { + $igeAcc = Account::findByKontonummer($kontonr); + if ($igeAcc) { + Database::execute( + 'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp, + kategorie_konto_id, zahlungskonto_id, + betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, + buchungstext, fiscal_year_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + self::generateBuchungsNr(), + (int)$data['beleg_id'], + $mainId, + $data['buchungs_datum'], + Constants::BUCHUNGSTYP_AUSGABE, + (int)$igeAcc['id'], + (int)$data['zahlungskonto_id'], + $igeUst, + 0.0, + 'normal', + 0.0, + $igeUst, + $prefix . $nr, + $fy['id'], + ] + ); + } + } + } + + Database::commit(); + } catch (\Throwable $e) { + Database::rollBack(); + throw $e; } return $mainId; @@ -246,65 +255,71 @@ class Booking $nr = self::generateBuchungsNr(); - Database::execute( - 'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, buchungstyp, - kategorie_konto_id, zahlungskonto_id, - betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, buchungstext, is_reversal, fiscal_year_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)', - [ - $nr, - $reversalData['beleg_id'], - $reversalData['buchungs_datum'], - $reversalData['buchungstyp'], - $reversalData['kategorie_konto_id'], - $reversalData['zahlungskonto_id'], - -1 * (float)$orig['betrag_netto'], - $orig['ust_satz'], - $orig['steuerart'], - -1 * (float)$orig['betrag_ust'], - -1 * (float)$orig['betrag_brutto'], - $reversalData['buchungstext'], - $fy['id'], - ] - ); - - $reversalId = (int)Database::lastInsertId(); - - // Link original to reversal - Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$reversalId, $id]); - - // Verknüpfte VSt-Buchungszeilen ebenfalls stornieren - $vstBookings = Database::fetchAll( - 'SELECT * FROM bookings WHERE parent_booking_id = ? AND reversed_by IS NULL', - [$id] - ); - foreach ($vstBookings as $vst) { - $vstNr = self::generateBuchungsNr(); + Database::beginTransaction(); + try { Database::execute( - 'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp, + 'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, buchungstyp, kategorie_konto_id, zahlungskonto_id, - betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, - buchungstext, is_reversal, fiscal_year_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)', + betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, buchungstext, is_reversal, fiscal_year_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)', [ - $vstNr, - $vst['beleg_id'], - $reversalId, - date('Y-m-d'), - $vst['buchungstyp'], - $vst['kategorie_konto_id'], - $vst['zahlungskonto_id'], - -1 * (float)$vst['betrag_netto'], - $vst['ust_satz'], - $vst['steuerart'], - -1 * (float)$vst['betrag_ust'], - -1 * (float)$vst['betrag_brutto'], - 'STORNO zu ' . $vst['buchungs_nr'] . ': ' . $reason, + $nr, + $reversalData['beleg_id'], + $reversalData['buchungs_datum'], + $reversalData['buchungstyp'], + $reversalData['kategorie_konto_id'], + $reversalData['zahlungskonto_id'], + -1 * (float)$orig['betrag_netto'], + $orig['ust_satz'], + $orig['steuerart'], + -1 * (float)$orig['betrag_ust'], + -1 * (float)$orig['betrag_brutto'], + $reversalData['buchungstext'], $fy['id'], ] ); - $vstReversalId = (int)Database::lastInsertId(); - Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$vstReversalId, $vst['id']]); + + $reversalId = (int)Database::lastInsertId(); + + Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$reversalId, $id]); + + $vstBookings = Database::fetchAll( + 'SELECT * FROM bookings WHERE parent_booking_id = ? AND reversed_by IS NULL', + [$id] + ); + foreach ($vstBookings as $vst) { + $vstNr = self::generateBuchungsNr(); + Database::execute( + 'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp, + kategorie_konto_id, zahlungskonto_id, + betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, + buchungstext, is_reversal, fiscal_year_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)', + [ + $vstNr, + $vst['beleg_id'], + $reversalId, + date('Y-m-d'), + $vst['buchungstyp'], + $vst['kategorie_konto_id'], + $vst['zahlungskonto_id'], + -1 * (float)$vst['betrag_netto'], + $vst['ust_satz'], + $vst['steuerart'], + -1 * (float)$vst['betrag_ust'], + -1 * (float)$vst['betrag_brutto'], + 'STORNO zu ' . $vst['buchungs_nr'] . ': ' . $reason, + $fy['id'], + ] + ); + $vstReversalId = (int)Database::lastInsertId(); + Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$vstReversalId, $vst['id']]); + } + + Database::commit(); + } catch (\Throwable $e) { + Database::rollBack(); + throw $e; } return $reversalId; diff --git a/src/Router.php b/src/Router.php index f1f8d51..5c1db29 100644 --- a/src/Router.php +++ b/src/Router.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Steuerkram; +use Steuerkram\Auth; + class Router { private array $routes = []; @@ -52,6 +54,11 @@ class Router $uri = '/' . trim($uri, '/'); if ($uri === '') $uri = '/'; + if (in_array($method, ['POST', 'PUT', 'DELETE', 'PATCH'], true)) { + $token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; + Auth::validateCsrfToken($token); + } + foreach ($this->routes as $route) { if ($route['method'] !== $method) continue; diff --git a/src/Services/ImageCropService.php b/src/Services/ImageCropService.php index a1eb095..91e08ff 100644 --- a/src/Services/ImageCropService.php +++ b/src/Services/ImageCropService.php @@ -18,7 +18,9 @@ class ImageCropService */ public function cropToRect(string $path, int $x, int $y, int $w, int $h): bool { - $mime = mime_content_type($path); + $finfo1 = finfo_open(FILEINFO_MIME_TYPE); + $mime = (string)finfo_file($finfo1, $path); + finfo_close($finfo1); $img = match ($mime) { 'image/jpeg' => imagecreatefromjpeg($path), @@ -65,7 +67,9 @@ class ImageCropService */ public function cropWhitespace(string $path): bool { - $mime = mime_content_type($path); + $finfo2 = finfo_open(FILEINFO_MIME_TYPE); + $mime = (string)finfo_file($finfo2, $path); + finfo_close($finfo2); $img = match ($mime) { 'image/jpeg' => imagecreatefromjpeg($path), diff --git a/src/View.php b/src/View.php index 4020ced..8bf9dbe 100644 --- a/src/View.php +++ b/src/View.php @@ -60,4 +60,13 @@ class View $d = \DateTime::createFromFormat('Y-m-d', $date); return $d ? $d->format('d.m.Y') : $date; } + + public static function buildQuery(array $params): string + { + $safe = []; + foreach ($params as $k => $v) { + $safe[rawurlencode((string)$k)] = rawurlencode((string)$v); + } + return http_build_query($safe); + } } diff --git a/src/Views/layout/base.php b/src/Views/layout/base.php index 6b64eb8..584b7ce 100644 --- a/src/Views/layout/base.php +++ b/src/Views/layout/base.php @@ -11,6 +11,7 @@ +