Refactor: Code-Qualität, Sicherheit und Konsistenz
This commit is contained in:
parent
93d6aacbae
commit
3132196ad6
27 changed files with 609 additions and 268 deletions
15
Changes.md
15
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
23
src/Auth.php
23
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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
src/Constants.php
Normal file
22
src/Constants.php
Normal 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 %
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ use Steuerkram\Models\Booking;
|
|||
use Steuerkram\Models\Beleg;
|
||||
use Steuerkram\View;
|
||||
|
||||
class AccountController
|
||||
class AccountController extends BaseController
|
||||
{
|
||||
public function dashboard(array $params): void
|
||||
{
|
||||
|
|
@ -268,12 +268,5 @@ class AccountController
|
|||
if (empty($data['kontenart'])) $errors['kontenart'] = '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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use Steuerkram\Models\Asset;
|
|||
use Steuerkram\Models\AssetDepreciation;
|
||||
use Steuerkram\View;
|
||||
|
||||
class AssetController
|
||||
class AssetController extends BaseController
|
||||
{
|
||||
public function index(array $params): void
|
||||
{
|
||||
|
|
@ -241,12 +241,5 @@ class AssetController
|
|||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use Steuerkram\Models\Invoice;
|
|||
use Steuerkram\Services\BankImportParser;
|
||||
use Steuerkram\View;
|
||||
|
||||
class BankController
|
||||
class BankController extends BaseController
|
||||
{
|
||||
private const ALLOWED_EXT = ['csv', 'txt', 'xlsx'];
|
||||
|
||||
|
|
@ -273,7 +273,9 @@ class BankController
|
|||
'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])) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
16
src/Controllers/BaseController.php
Normal file
16
src/Controllers/BaseController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ use Steuerkram\Services\ImageCropService;
|
|||
use Steuerkram\Models\BankTransaction;
|
||||
use Steuerkram\Services\KontierungsvorschlagService;
|
||||
|
||||
class BelegController
|
||||
class BelegController extends BaseController
|
||||
{
|
||||
private const ALLOWED_MIME = [
|
||||
'application/pdf' => 'pdf',
|
||||
|
|
@ -45,7 +45,7 @@ class BelegController
|
|||
'page' => $page,
|
||||
'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.';
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
<link rel="apple-touch-icon" sizes="180x180" href="/static/favicon/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Steuerkram" />
|
||||
<link rel="manifest" href="/static/favicon/site.webmanifest" />
|
||||
<meta name="csrf-token" content="<?= \Steuerkram\View::h(\Steuerkram\Auth::generateCsrfToken()) ?>">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
|
|
@ -48,5 +49,31 @@
|
|||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
<?php endif; ?>
|
||||
|
||||
<form method="POST" action="/login">
|
||||
<input type="hidden" name="csrf_token" value="<?= \Steuerkram\View::h(\Steuerkram\Auth::generateCsrfToken()) ?>">
|
||||
<div class="form-group">
|
||||
<label for="username">Benutzername</label>
|
||||
<input type="text" id="username" name="username"
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
1-260515
|
||||
1-260516
|
||||
|
|
|
|||
125
tests/32_BankTransactionTest.php
Normal file
125
tests/32_BankTransactionTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
123
tests/33_BankImportParserTest.php
Normal file
123
tests/33_BankImportParserTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue