Fix #159: Überlange Methoden zerlegt (FinTsFetchService::run, Booking::create/reverse, Bank-/InvoiceController)

This commit is contained in:
fds2610-bot 2026-08-11 07:17:41 +02:00
parent 7a8f1ad8c3
commit 74b9f0cc17
7 changed files with 967 additions and 450 deletions

View file

@ -2,6 +2,16 @@
--- ---
## 2026-08-11 – Überlange Methoden zerlegt: FinTsFetchService::run(), Booking::create()/reverse(), Bank-/InvoiceController (#159)
- Rein strukturelle Änderung, keine Verhaltensänderung: bestehende Codeblöcke wurden in benannte Methoden verschoben, Bedingungen, Fehlermeldungen und Ablaufreihenfolge blieben unverändert.
- **`src/Services/FinTsFetchService.php`:** `run()` war mit 274 Zeilen der größte Ausreißer im Projekt und dabei von außen nicht testbar, weil sie eine echte Bankverbindung voraussetzt. Aufgeteilt in `validateConfig()`, `assertUrlSafe()`, `connect()`, `selectTan()` (mit `selectTanMode()`/`selectTanMedium()`), `login()`, `resolveAccounts()` (mit `fetchAccounts()`/`applyAccountFilter()`), `computeFetchRange()` sowie `fetchAllStatements()`/`fetchStatementsForAccount()`/`fetchAccountBalance()`/`importTransactions()`. `run()` liegt jetzt bei 50 Zeilen. Die doppelte SSRF-Prüfung aus Issue #130 (DNS-Rebinding-Fenster zwischen Validierung und Verbindungsaufbau) ist unverändert erhalten, der erklärende Kommentar sitzt weiterhin direkt am zweiten `assertUrlSafe()`-Aufruf in `run()`.
- **`src/Models/Booking.php`:** `create()` (144 Zeilen) und `reverse()` (95 Zeilen) enthielten mehrere Schreibpfade im selben Transaktionsblock. Die tatsächlichen Pfade in `create()` sind Hauptbuchung, eine VSt-/USt-Korrekturzeile für normale Buchungen mit 7/19 % (nicht „Eigenanteil“, wie im Issue-Text vermutet – dieser Begriff bezeichnet im Projekt die 30-%-Bewirtungskürzung aus `upsertEigenanteil30()`, ein unabhängiger Pfad außerhalb von `create()`) sowie die ig.-Erwerb-19-%-ReverseCharge-Kindzeilen (kein „Split“ im Sinne von `bucheBewirtungssplit()`). Extrahiert nach `insertHauptbuchung()`, `insertVorsteuerUmsatzsteuerZeile()`, `insertIgErwerbReverseCharge()`; `reverse()` entsprechend nach `insertHauptstorno()`/`insertKindstornos()`. Die Transaktionsklammer bleibt jeweils in `create()`/`reverse()`.
- **`src/Controllers/BankController.php`:** `direktStore()` (111 Zeilen) aufgeteilt in `storeCsvFormat()`, `resolveProviderId()`, `storeBankSource()`; `direktUpdate()` (70 Zeilen) in `buildUpdateData()`, `buildApiUpdateData()`, `buildFintsUpdateData()`.
- **`src/Controllers/InvoiceController.php`:** `storeBooking()` (84 Zeilen, drei identische Render-Blöcke für Validierungs-/PDO-/allgemeine Fehler) aufgeteilt in `validateBookingInput()`, `renderBookForm()` (bündelt die vormals dreifach kopierte View-Rendering-Logik), `persistBooking()`.
- **`tests/88_FinTsHilfsmethodenTest.php`** (neu): `validateConfig()` gegen fehlende BLZ/Benutzerkennung/PIN/URL, `applyAccountFilter()` mit leerer/passender/nicht passender Filterliste, `accountId()` für Konten mit/ohne IBAN und mit/ohne Unterkonto – alles ohne echte Bankverbindung über Reflection auf die privaten Methoden.
- **`tests/89_BookingCreatePfadeTest.php`** (neu): Deckt die drei Schreibpfade von `Booking::create()` ab – reine Hauptbuchung, Hauptbuchung mit VSt-Korrekturzeile (19 %), ig.-Erwerb-19-%-Buchung mit zwei Kindzeilen.
## 2026-08-11 – Duplizierte View-Logik gebündelt: Kontengruppierung, Kontrastfarbe, Helper-Boilerplate (#158) ## 2026-08-11 – Duplizierte View-Logik gebündelt: Kontengruppierung, Kontrastfarbe, Helper-Boilerplate (#158)
- **Kontengruppierung** (`invoices/book.php`, `invoices/show.php`, `belege/_buchungsform.php`) war dreimal kopiert und bereits divergiert: Nur `belege/_buchungsform.php` (per `include` in `detail.php`) filterte Zahlungskonten auf den Nummernbereich 1200–1299, die beiden Rechnungs-Views nicht. Neue Methode `View::gruppiereKonten(array $accounts, bool $nurZahlungskonten1200 = false)` bündelt beide Varianten; alle drei Views wenden jetzt den Filter an (Parameter `true`). - **Kontengruppierung** (`invoices/book.php`, `invoices/show.php`, `belege/_buchungsform.php`) war dreimal kopiert und bereits divergiert: Nur `belege/_buchungsform.php` (per `include` in `detail.php`) filterte Zahlungskonten auf den Nummernbereich 1200–1299, die beiden Rechnungs-Views nicht. Neue Methode `View::gruppiereKonten(array $accounts, bool $nurZahlungskonten1200 = false)` bündelt beide Varianten; alle drei Views wenden jetzt den Filter an (Parameter `true`).

View file

@ -405,6 +405,26 @@ class BankController extends BaseController
// Provider entfällt hier (provider_id = NULL); Spaltenzuordnung erfolgt // Provider entfällt hier (provider_id = NULL); Spaltenzuordnung erfolgt
// anschließend unter /settings/bankimport. // anschließend unter /settings/bankimport.
if ($providerRaw === '__csv__') { if ($providerRaw === '__csv__') {
$this->storeCsvFormat($bezeichnung);
return;
}
$providerId = $this->resolveProviderId($providerRaw);
if ($providerId === null) {
return;
}
$provider = BankProvider::find($providerId);
if (!$provider) {
View::redirect('/bank/direkt', 'Unbekannter Anbieter.', 'error');
return;
}
$this->storeBankSource($provider, $providerId, $bezeichnung);
}
private function storeCsvFormat(string $bezeichnung): void
{
$kuerzel = trim($_POST['kuerzel'] ?? ''); $kuerzel = trim($_POST['kuerzel'] ?? '');
if ($kuerzel === '' || mb_strlen($kuerzel) > 10) { if ($kuerzel === '' || mb_strlen($kuerzel) > 10) {
View::redirect('/bank/direkt', 'Bitte ein Kürzel angeben (max. 10 Zeichen).', 'error'); View::redirect('/bank/direkt', 'Bitte ein Kürzel angeben (max. 10 Zeichen).', 'error');
@ -439,17 +459,17 @@ class BankController extends BaseController
]); ]);
View::redirect('/settings/bankimport#format-' . $id, 'CSV-Format gespeichert. Spaltenzuordnung bei Bedarf anpassen.'); View::redirect('/settings/bankimport#format-' . $id, 'CSV-Format gespeichert. Spaltenzuordnung bei Bedarf anpassen.');
return;
} }
// Neue Bank anlegen, wenn Dropdown auf "__new__" steht. /** Neue Bank anlegen, wenn Dropdown auf "__new__" steht, sonst vorhandenen Anbieter übernehmen. */
// Verbindungstyp wählt der Nutzer im Formular (API oder FinTS). private function resolveProviderId(string $providerRaw): ?int
{
if ($providerRaw === '__new__') { if ($providerRaw === '__new__') {
$newName = trim((string)($_POST['new_bank_name'] ?? '')); $newName = trim((string)($_POST['new_bank_name'] ?? ''));
$newTyp = ($_POST['new_bank_typ'] ?? 'FinTS') === 'API' ? 'API' : 'FinTS'; $newTyp = ($_POST['new_bank_typ'] ?? 'FinTS') === 'API' ? 'API' : 'FinTS';
if ($newName === '') { if ($newName === '') {
View::redirect('/bank/direkt', 'Bitte den Banknamen angeben.', 'error'); View::redirect('/bank/direkt', 'Bitte den Banknamen angeben.', 'error');
return; return null;
} }
$existing = BankProvider::findByName($newName); $existing = BankProvider::findByName($newName);
$providerId = $existing ? (int)$existing['id'] : BankProvider::create($newName, $newTyp); $providerId = $existing ? (int)$existing['id'] : BankProvider::create($newName, $newTyp);
@ -459,15 +479,14 @@ class BankController extends BaseController
if (!$providerId) { if (!$providerId) {
View::redirect('/bank/direkt', 'Bitte alle Pflichtfelder ausfüllen.', 'error'); View::redirect('/bank/direkt', 'Bitte alle Pflichtfelder ausfüllen.', 'error');
return; return null;
} }
$provider = BankProvider::find($providerId); return $providerId;
if (!$provider) {
View::redirect('/bank/direkt', 'Unbekannter Anbieter.', 'error');
return;
} }
private function storeBankSource(array $provider, int $providerId, string $bezeichnung): void
{
$isApi = $provider['verbindungstyp'] === 'API'; $isApi = $provider['verbindungstyp'] === 'API';
$isFints = $provider['verbindungstyp'] === 'FinTS'; $isFints = $provider['verbindungstyp'] === 'FinTS';
@ -572,6 +591,17 @@ class BankController extends BaseController
return; return;
} }
$data = $this->buildUpdateData($source, $bezeichnung);
if ($data === null) {
return;
}
BankSource::update($id, $data);
View::redirect('/bank/direkt', 'Verbindung gespeichert.');
}
private function buildUpdateData(array $source, string $bezeichnung): ?array
{
$isApi = $source['type'] === 'api'; $isApi = $source['type'] === 'api';
$isFints = $source['type'] === 'fints'; $isFints = $source['type'] === 'fints';
@ -587,15 +617,32 @@ class BankController extends BaseController
} }
if ($isApi) { if ($isApi) {
$data += $this->buildApiUpdateData($source);
} elseif ($isFints) {
$fintsData = $this->buildFintsUpdateData($source);
if ($fintsData === null) {
return null;
}
$data += $fintsData;
}
return $data;
}
private function buildApiUpdateData(array $source): array
{
$newPwd = trim($_POST['client_id'] ?? ''); $newPwd = trim($_POST['client_id'] ?? '');
$newSecret = trim($_POST['secret'] ?? ''); $newSecret = trim($_POST['secret'] ?? '');
$data += [ return [
'sandbox' => isset($_POST['sandbox']) ? 1 : 0, 'sandbox' => isset($_POST['sandbox']) ? 1 : 0,
'app_name' => trim($_POST['app_name'] ?? '') ?: null, 'app_name' => trim($_POST['app_name'] ?? '') ?: null,
'client_id' => $newPwd !== '' ? $newPwd : $source['client_id'], 'client_id' => $newPwd !== '' ? $newPwd : $source['client_id'],
'secret' => $newSecret !== '' ? $newSecret : $source['secret'], 'secret' => $newSecret !== '' ? $newSecret : $source['secret'],
]; ];
} elseif ($isFints) { }
private function buildFintsUpdateData(array $source): ?array
{
$newPin = trim($_POST['pin'] ?? ''); $newPin = trim($_POST['pin'] ?? '');
$fintsUrl = trim($_POST['fints_url'] ?? '') ?: null; $fintsUrl = trim($_POST['fints_url'] ?? '') ?: null;
if ($fintsUrl !== null) { if ($fintsUrl !== null) {
@ -603,10 +650,10 @@ class BankController extends BaseController
SsrfGuard::assertPublicHttpsUrl($fintsUrl); SsrfGuard::assertPublicHttpsUrl($fintsUrl);
} catch (\RuntimeException $e) { } catch (\RuntimeException $e) {
View::redirect('/bank/direkt', 'Ungültige FinTS-URL: ' . $e->getMessage(), 'error'); View::redirect('/bank/direkt', 'Ungültige FinTS-URL: ' . $e->getMessage(), 'error');
return; return null;
} }
} }
$data += [ $data = [
'blz' => trim($_POST['blz'] ?? '') ?: null, 'blz' => trim($_POST['blz'] ?? '') ?: null,
'fints_url' => $fintsUrl, 'fints_url' => $fintsUrl,
'benutzerkennung' => trim($_POST['benutzerkennung'] ?? '') ?: null, 'benutzerkennung' => trim($_POST['benutzerkennung'] ?? '') ?: null,
@ -620,10 +667,7 @@ class BankController extends BaseController
if ($filterUpdate['update']) { if ($filterUpdate['update']) {
$data['account_filter'] = $filterUpdate['value']; $data['account_filter'] = $filterUpdate['value'];
} }
} return $data;
BankSource::update($id, $data);
View::redirect('/bank/direkt', 'Verbindung gespeichert.');
} }
public function direktDelete(array $params): void public function direktDelete(array $params): void

View file

@ -412,6 +412,22 @@ class InvoiceController extends BaseController
} }
$data = $_POST; $data = $_POST;
$errors = $this->validateBookingInput($data);
if ($errors) {
if (!empty($data['_from_modal'])) {
View::redirect("/invoices/{$id}", implode(' ', $errors), 'error');
return;
}
$this->renderBookForm($invoice, $belegId, $errors, $data);
return;
}
$this->persistBooking($id, $invoice, $belegId, $data);
}
private function validateBookingInput(array $data): array
{
$errors = []; $errors = [];
if (empty($data['kategorie_konto_id'])) $errors['kategorie_konto_id'] = 'Pflichtfeld'; if (empty($data['kategorie_konto_id'])) $errors['kategorie_konto_id'] = 'Pflichtfeld';
@ -425,11 +441,11 @@ class InvoiceController extends BaseController
$errors['zahlungskonto_id'] = 'Kategorie- und Zahlungskonto dürfen nicht identisch sein.'; $errors['zahlungskonto_id'] = 'Kategorie- und Zahlungskonto dürfen nicht identisch sein.';
} }
if ($errors) { return $errors;
if (!empty($data['_from_modal'])) {
View::redirect("/invoices/{$id}", implode(' ', $errors), 'error');
return;
} }
private function renderBookForm(array $invoice, int $belegId, array $errors, array $data): void
{
$bookings = Booking::forBeleg($belegId); $bookings = Booking::forBeleg($belegId);
$accounts = Account::all(['aktiv' => 1]); $accounts = Account::all(['aktiv' => 1]);
$view = new View(); $view = new View();
@ -443,9 +459,10 @@ class InvoiceController extends BaseController
'formData' => $data, 'formData' => $data,
]), ]),
]); ]);
return;
} }
private function persistBooking(int $id, array $invoice, int $belegId, array $data): void
{
try { try {
$data['beleg_id'] = $belegId; $data['beleg_id'] = $belegId;
$data['buchungstyp'] = 'Einnahme'; $data['buchungstyp'] = 'Einnahme';
@ -454,33 +471,9 @@ class InvoiceController extends BaseController
View::redirect($returnUrl, 'Buchung erfasst.'); View::redirect($returnUrl, 'Buchung erfasst.');
} catch (\PDOException $e) { } catch (\PDOException $e) {
error_log('[Steuerkram] PDO-Fehler in InvoiceController::storeBooking(): ' . $e->getMessage()); error_log('[Steuerkram] PDO-Fehler in InvoiceController::storeBooking(): ' . $e->getMessage());
$bookings = Booking::forBeleg($belegId); $this->renderBookForm($invoice, $belegId, ['_exception' => 'Datenbankfehler beim Speichern der Buchung.'], $data);
$accounts = Account::all(['aktiv' => 1]);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Zahlungseingang – ' . ($invoice['rechnungs_nr'] ?? 'Rechnung'),
'content' => $this->renderView('invoices/book', [
'invoice' => $invoice,
'bookings' => $bookings,
'accounts' => $accounts,
'formErrors' => ['_exception' => 'Datenbankfehler beim Speichern der Buchung.'],
'formData' => $data,
]),
]);
} catch (\Exception $e) { } catch (\Exception $e) {
$bookings = Booking::forBeleg($belegId); $this->renderBookForm($invoice, $belegId, ['_exception' => $e->getMessage()], $data);
$accounts = Account::all(['aktiv' => 1]);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Zahlungseingang – ' . ($invoice['rechnungs_nr'] ?? 'Rechnung'),
'content' => $this->renderView('invoices/book', [
'invoice' => $invoice,
'bookings' => $bookings,
'accounts' => $accounts,
'formErrors' => ['_exception' => $e->getMessage()],
'formData' => $data,
]),
]);
} }
} }

View file

@ -115,10 +115,34 @@ class Booking
$steuerart = 'normal'; $steuerart = 'normal';
} }
$assetId = isset($data['asset_id']) && $data['asset_id'] !== '' ? (int)$data['asset_id'] : null;
Database::beginTransaction(); Database::beginTransaction();
try { try {
$mainId = self::insertHauptbuchung($data, $fy, $nr, $steuerart, $netto, $ust, $brutto);
self::insertVorsteuerUmsatzsteuerZeile($data, $fy, $nr, $steuerart, $ust, $mainId);
self::insertIgErwerbReverseCharge($data, $fy, $nr, $steuerart, $netto, $mainId);
Database::commit();
} catch (\Throwable $e) {
Database::rollBack();
throw $e;
}
Invoice::recalcPaymentStatusForBeleg((int)$data['beleg_id']);
return $mainId;
}
private static function insertHauptbuchung(
array $data,
array $fy,
string $nr,
string $steuerart,
float $netto,
float $ust,
float $brutto
): int {
$assetId = isset($data['asset_id']) && $data['asset_id'] !== '' ? (int)$data['asset_id'] : null;
Database::execute( Database::execute(
'INSERT INTO bookings (buchungs_nr, beleg_id, asset_id, buchungs_datum, buchungstyp, 'INSERT INTO bookings (buchungs_nr, beleg_id, asset_id, buchungs_datum, buchungstyp,
kategorie_konto_id, zahlungskonto_id, kategorie_konto_id, zahlungskonto_id,
@ -143,15 +167,27 @@ class Booking
] ]
); );
$mainId = (int)Database::lastInsertId(); return (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. */
private static function insertVorsteuerUmsatzsteuerZeile(
array $data,
array $fy,
string $nr,
string $steuerart,
float $ust,
int $mainId
): void {
$ustSatzInt = (int)round((float)($data['ust_satz'] ?? 0)); $ustSatzInt = (int)round((float)($data['ust_satz'] ?? 0));
if (in_array($data['buchungstyp'], [Constants::BUCHUNGSTYP_AUSGABE, Constants::BUCHUNGSTYP_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
) { ) {
return;
}
$isAusgabe = $data['buchungstyp'] === Constants::BUCHUNGSTYP_AUSGABE; $isAusgabe = $data['buchungstyp'] === Constants::BUCHUNGSTYP_AUSGABE;
if ($isAusgabe) { if ($isAusgabe) {
$steuerKontonummer = $ustSatzInt === 19 ? Constants::KONTO_VST_19 : Constants::KONTO_VST_7; $steuerKontonummer = $ustSatzInt === 19 ? Constants::KONTO_VST_19 : Constants::KONTO_VST_7;
@ -161,8 +197,10 @@ class Booking
$steuerText = 'USt zu ' . $nr; $steuerText = 'USt zu ' . $nr;
} }
$steuerAcc = Account::findByKontonummer($steuerKontonummer); $steuerAcc = Account::findByKontonummer($steuerKontonummer);
if ($steuerAcc) { if (!$steuerAcc) {
$steuerNr = self::generateBuchungsNr(); return;
}
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,
@ -170,7 +208,7 @@ class Booking
buchungstext, fiscal_year_id) buchungstext, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[ [
$steuerNr, self::generateBuchungsNr(),
(int)$data['beleg_id'], (int)$data['beleg_id'],
$mainId, $mainId,
$data['buchungs_datum'], $data['buchungs_datum'],
@ -187,20 +225,32 @@ class Booking
] ]
); );
} }
/** ig. Erwerb 19 % ReverseCharge: Kindzeilen auf VST_IGE_19 (Kz 67) und UST_IGE_19 (Kz 91). */
private static function insertIgErwerbReverseCharge(
array $data,
array $fy,
string $nr,
string $steuerart,
float $netto,
int $mainId
): void {
if ($data['buchungstyp'] !== Constants::BUCHUNGSTYP_AUSGABE
|| $steuerart !== 'ig_erwerb_19'
|| abs($netto) <= 0.0
) {
return;
} }
// 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); $igeUst = round($netto * 0.19, 2);
foreach ([ foreach ([
[Constants::KONTO_VST_IGE_19, 'VSt ig.E. zu '], [Constants::KONTO_VST_IGE_19, 'VSt ig.E. zu '],
[Constants::KONTO_UST_IGE_19, 'USt ig.E. zu '], [Constants::KONTO_UST_IGE_19, 'USt ig.E. zu '],
] as [$kontonr, $prefix]) { ] as [$kontonr, $prefix]) {
$igeAcc = Account::findByKontonummer($kontonr); $igeAcc = Account::findByKontonummer($kontonr);
if ($igeAcc) { if (!$igeAcc) {
continue;
}
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,
@ -226,18 +276,6 @@ class Booking
); );
} }
} }
}
Database::commit();
} catch (\Throwable $e) {
Database::rollBack();
throw $e;
}
Invoice::recalcPaymentStatusForBeleg((int)$data['beleg_id']);
return $mainId;
}
public static function reverse(int $id, string $reason): int public static function reverse(int $id, string $reason): int
{ {
@ -263,6 +301,26 @@ class Booking
Database::beginTransaction(); Database::beginTransaction();
try { try {
$reversalId = self::insertHauptstorno($orig, $reversalData, $fy, $nr);
Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$reversalId, $id]);
self::insertKindstornos($id, $reversalId, $reason, $fy);
Database::commit();
} catch (\Throwable $e) {
Database::rollBack();
throw $e;
}
if (!empty($reversalData['beleg_id'])) {
Invoice::recalcPaymentStatusForBeleg((int)$reversalData['beleg_id']);
}
return $reversalId;
}
private static function insertHauptstorno(array $orig, array $reversalData, array $fy, string $nr): int
{
Database::execute( Database::execute(
'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, buchungstyp, 'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, buchungstyp,
kategorie_konto_id, zahlungskonto_id, kategorie_konto_id, zahlungskonto_id,
@ -285,10 +343,12 @@ class Booking
] ]
); );
$reversalId = (int)Database::lastInsertId(); return (int)Database::lastInsertId();
}
Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$reversalId, $id]);
/** Storniert alle noch nicht stornierten Kindzeilen (VSt/USt, IGE) der Ursprungsbuchung. */
private static function insertKindstornos(int $id, int $reversalId, string $reason, array $fy): void
{
$vstBookings = Database::fetchAll( $vstBookings = Database::fetchAll(
'SELECT * FROM bookings WHERE parent_booking_id = ? AND reversed_by IS NULL', 'SELECT * FROM bookings WHERE parent_booking_id = ? AND reversed_by IS NULL',
[$id] [$id]
@ -321,18 +381,6 @@ class Booking
$vstReversalId = (int)Database::lastInsertId(); $vstReversalId = (int)Database::lastInsertId();
Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$vstReversalId, $vst['id']]); Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$vstReversalId, $vst['id']]);
} }
Database::commit();
} catch (\Throwable $e) {
Database::rollBack();
throw $e;
}
if (!empty($reversalData['beleg_id'])) {
Invoice::recalcPaymentStatusForBeleg((int)$reversalData['beleg_id']);
}
return $reversalId;
} }
/** /**

View file

@ -13,6 +13,7 @@ use Fhp\Action\GetStatementOfAccount;
use Fhp\BaseAction; use Fhp\BaseAction;
use Fhp\FinTs; use Fhp\FinTs;
use Fhp\Model\StatementOfAccount\Transaction; use Fhp\Model\StatementOfAccount\Transaction;
use Fhp\Model\TanMode;
use Fhp\Model\TanRequestChallengeImage; use Fhp\Model\TanRequestChallengeImage;
use Fhp\Options\Credentials; use Fhp\Options\Credentials;
use Fhp\Options\FinTsOptions; use Fhp\Options\FinTsOptions;
@ -53,6 +54,122 @@ class FinTsFetchService
{ {
$log = $this->log; $log = $this->log;
$cfg = $this->validateConfig();
if ($cfg === null) {
return;
}
if (!$this->assertUrlSafe($cfg['url'])) {
return;
}
$fints = $this->connect($cfg);
if ($fints === null) {
return;
}
// Erneute Prüfung unmittelbar vor dem ersten tatsächlichen Request: php-fints
// löst den Hostnamen beim Verbindungsaufbau selbst über den System-Resolver auf
// (kein IP-Pinning wie bei den selbst gesteuerten cURL-Aufrufen in
// NextcloudImportService möglich) – die zweite Prüfung minimiert das Zeitfenster
// für DNS-Rebinding zwischen Validierung und Verbindungsaufbau (siehe Issue #130).
if (!$this->assertUrlSafe($cfg['url'])) {
return;
}
if (!$this->selectTan($fints)) {
return;
}
if (!$this->login($fints)) {
return;
}
$accounts = $this->resolveAccounts($fints);
if ($accounts === null) {
return;
}
[$start, $end, $rangeLabel] = $this->computeFetchRange();
$log('Zeitraum: ' . $rangeLabel . ' – Start ' . $start->format('d.m.Y') . ' 00:00 Uhr.');
$batchId = $this->createBatch();
$log('Batch #' . $batchId . ' angelegt.');
[$totalNew, $totalSkip, $balance] = $this->fetchAllStatements($fints, $accounts, $start, $end, $batchId);
$this->updateBatchCount($batchId, $totalNew);
$this->updateLastFetched($balance);
$log(sprintf('Fertig. %d neu importiert, %d bereits vorhanden.', $totalNew, $totalSkip));
}
/** Wählt TAN-Verfahren und ggf. TAN-Medium und registriert die Auswahl bei php-fints. */
private function selectTan(FinTs $fints): bool
{
$log = $this->log;
$selectedTanMode = $this->selectTanMode($fints);
if ($selectedTanMode === null) {
return false;
}
$selectedTanMedium = $this->selectTanMedium($fints, $selectedTanMode);
if ($selectedTanMedium === false) {
return false;
}
try {
$fints->selectTanMode($selectedTanMode, $selectedTanMedium);
} catch (\Throwable $e) {
$log('FEHLER bei selectTanMode: ' . $e->getMessage());
return false;
}
return true;
}
/** Kontenliste abrufen, Filter anwenden bzw. interaktive Auswahl einholen. */
private function resolveAccounts(FinTs $fints): ?array
{
$log = $this->log;
$accounts = $this->fetchAccounts($fints);
if ($accounts === null) {
return null;
}
$accounts = $this->applyAccountFilter($accounts);
if ($accounts === null) {
return null;
}
if (empty($accounts)) {
$log('Kein Konto entspricht der Auswahl – Abbruch.');
return null;
}
return $accounts;
}
/** @return array{0:int,1:int,2:?float} [gesamt neu, gesamt übersprungen, Gesamtsaldo] */
private function fetchAllStatements(FinTs $fints, array $accounts, \DateTime $start, \DateTime $end, int $batchId): array
{
$totalNew = 0;
$totalSkip = 0;
$balance = null;
foreach ($accounts as $account) {
[$accNew, $accSkip, $accBalance] = $this->fetchStatementsForAccount($fints, $account, $start, $end, $batchId);
$totalNew += $accNew;
$totalSkip += $accSkip;
if ($accBalance !== null) {
$balance = ($balance ?? 0.0) + $accBalance;
}
}
return [$totalNew, $totalSkip, $balance];
}
/**
* Liest BLZ, Benutzerkennung, PIN und URL aus der Bankverbindung, loggt sie
* und prüft, dass keines der Pflichtfelder leer ist.
*
* @return array{blz:string,user:string,pin:string,url:string}|null null bei fehlenden Pflichtfeldern
*/
private function validateConfig(): ?array
{
$log = $this->log;
$blz = trim((string)($this->source['blz'] ?? '')); $blz = trim((string)($this->source['blz'] ?? ''));
$user = trim((string)($this->source['benutzerkennung'] ?? '')); $user = trim((string)($this->source['benutzerkennung'] ?? ''));
$pin = trim((string)($this->source['pin'] ?? '')); $pin = trim((string)($this->source['pin'] ?? ''));
@ -70,54 +187,61 @@ class FinTsFetchService
if ($blz === '' || $user === '' || $pin === '' || $url === '') { if ($blz === '' || $user === '' || $pin === '' || $url === '') {
$log('FEHLER: Pflichtfelder unvollständig (BLZ, Benutzerkennung, PIN, URL).'); $log('FEHLER: Pflichtfelder unvollständig (BLZ, Benutzerkennung, PIN, URL).');
return; return null;
} }
return ['blz' => $blz, 'user' => $user, 'pin' => $pin, 'url' => $url];
}
/** SSRF-Prüfung der FinTS-URL (siehe Issue #130 – wird bewusst zweimal aufgerufen). */
private function assertUrlSafe(string $url): bool
{
$log = $this->log;
try { try {
SsrfGuard::assertPublicHttpsUrl($url); SsrfGuard::assertPublicHttpsUrl($url);
return true;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$log('FEHLER: Unzulässige FinTS-URL – ' . $e->getMessage()); $log('FEHLER: Unzulässige FinTS-URL – ' . $e->getMessage());
return; return false;
}
} }
/** @param array{blz:string,user:string,pin:string,url:string} $cfg */
private function connect(array $cfg): ?FinTs
{
$log = $this->log;
$options = new FinTsOptions(); $options = new FinTsOptions();
$options->url = $url; $options->url = $cfg['url'];
$options->bankCode = $blz; $options->bankCode = $cfg['blz'];
$options->productName = 'F399A6377D4D60E0A222D677A'; $options->productName = 'F399A6377D4D60E0A222D677A';
$options->productVersion = '1.0'; $options->productVersion = '1.0';
$credentials = Credentials::create($user, $pin); $credentials = Credentials::create($cfg['user'], $cfg['pin']);
try { try {
$this->fints = FinTs::new($options, $credentials); $this->fints = FinTs::new($options, $credentials);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$log('FEHLER bei Initialisierung: ' . $e->getMessage()); $log('FEHLER bei Initialisierung: ' . $e->getMessage());
return; return null;
}
return $this->fints;
} }
$fints = $this->fints;
// Erneute Prüfung unmittelbar vor dem ersten tatsächlichen Request: php-fints private function selectTanMode(FinTs $fints): ?TanMode
// löst den Hostnamen beim Verbindungsaufbau selbst über den System-Resolver auf {
// (kein IP-Pinning wie bei den selbst gesteuerten cURL-Aufrufen in $log = $this->log;
// NextcloudImportService möglich) – die zweite Prüfung minimiert das Zeitfenster
// für DNS-Rebinding zwischen Validierung und Verbindungsaufbau (siehe Issue #130).
try {
SsrfGuard::assertPublicHttpsUrl($url);
} catch (\Throwable $e) {
$log('FEHLER: Unzulässige FinTS-URL – ' . $e->getMessage());
return;
}
$log('TAN-Verfahren werden ermittelt…'); $log('TAN-Verfahren werden ermittelt…');
try { try {
$tanModes = $fints->getTanModes(); $tanModes = $fints->getTanModes();
} catch (\Throwable $e) { } catch (\Throwable $e) {
$log('FEHLER bei TAN-Modi-Abruf: ' . $e->getMessage()); $log('FEHLER bei TAN-Modi-Abruf: ' . $e->getMessage());
return; return null;
} }
if (empty($tanModes)) { if (empty($tanModes)) {
$log('FEHLER: Bank meldet keine TAN-Verfahren.'); $log('FEHLER: Bank meldet keine TAN-Verfahren.');
return; return null;
} }
foreach ($tanModes as $idx => $mode) { foreach ($tanModes as $idx => $mode) {
@ -144,83 +268,96 @@ class FinTsFetchService
} }
$log('Auswahl: ' . $selectedTanMode->getName() . ' (ID ' . $selectedTanMode->getId() . ')'); $log('Auswahl: ' . $selectedTanMode->getName() . ' (ID ' . $selectedTanMode->getId() . ')');
$selectedTanMedium = null; return $selectedTanMode;
if ($selectedTanMode->needsTanMedium()) { }
/**
* @return mixed Das TAN-Medium, null wenn keines benötigt wird, oder false bei einem Fehler
* (dann wurde bereits geloggt und der Aufrufer soll abbrechen).
*/
private function selectTanMedium(FinTs $fints, TanMode $selectedTanMode): mixed
{
if (!$selectedTanMode->needsTanMedium()) {
return null;
}
$log = $this->log;
try { try {
$tanMedia = $fints->getTanMedia($selectedTanMode); $tanMedia = $fints->getTanMedia($selectedTanMode);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$log('FEHLER beim TAN-Medien-Abruf: ' . $e->getMessage()); $log('FEHLER beim TAN-Medien-Abruf: ' . $e->getMessage());
return; return false;
} }
if (empty($tanMedia)) { if (empty($tanMedia)) {
$log('FEHLER: Verfahren benötigt TAN-Medium, Bank liefert aber keines.'); $log('FEHLER: Verfahren benötigt TAN-Medium, Bank liefert aber keines.');
return; return false;
} }
$selectedTanMedium = reset($tanMedia); $selectedTanMedium = reset($tanMedia);
$log('TAN-Medium: ' . $selectedTanMedium->getName()); $log('TAN-Medium: ' . $selectedTanMedium->getName());
return $selectedTanMedium;
} }
try { private function login(FinTs $fints): bool
$fints->selectTanMode($selectedTanMode, $selectedTanMedium); {
} catch (\Throwable $e) { $log = $this->log;
$log('FEHLER bei selectTanMode: ' . $e->getMessage());
return;
}
$log('Login wird versucht…'); $log('Login wird versucht…');
try { try {
$login = $fints->login(); $login = $fints->login();
if ($login->needsTan()) { if ($login->needsTan()) {
if (!$this->handleStrongAuthentication($login, 'Login')) { if (!$this->handleStrongAuthentication($login, 'Login')) {
return; return false;
} }
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
$log('FEHLER beim Login: ' . $e->getMessage()); $log('FEHLER beim Login: ' . $e->getMessage());
return; return false;
} }
$log('Login OK.'); $log('Login OK.');
return true;
}
private function fetchAccounts(FinTs $fints): ?array
{
$log = $this->log;
try { try {
$getAccounts = GetSEPAAccounts::create(); $getAccounts = GetSEPAAccounts::create();
$fints->execute($getAccounts); $fints->execute($getAccounts);
if ($getAccounts->needsTan()) { if ($getAccounts->needsTan()) {
$log('FEHLER: TAN für Kontenabfrage erforderlich. Nicht unterstützt.'); $log('FEHLER: TAN für Kontenabfrage erforderlich. Nicht unterstützt.');
return; return null;
} }
$accounts = $getAccounts->getAccounts(); $accounts = $getAccounts->getAccounts();
} catch (\Throwable $e) { } catch (\Throwable $e) {
$log('FEHLER bei Kontenabfrage: ' . $e->getMessage()); $log('FEHLER bei Kontenabfrage: ' . $e->getMessage());
return; return null;
} }
$log(count($accounts) . ' SEPA-Konten verfügbar.'); $log(count($accounts) . ' SEPA-Konten verfügbar.');
return $accounts;
$accounts = $this->applyAccountFilter($accounts);
if ($accounts === null) {
return;
}
if (empty($accounts)) {
$log('Kein Konto entspricht der Auswahl – Abbruch.');
return;
} }
/** @return array{0:\DateTime,1:\DateTime,2:string} [Start, Ende, Anzeigetext] */
private function computeFetchRange(): array
{
$end = new \DateTime('today'); $end = new \DateTime('today');
[$startImm, $rangeLabel] = BankSource::computeStartDate( [$startImm, $rangeLabel] = BankSource::computeStartDate(
$this->source['fetch_range'] ?? null, $this->source['fetch_range'] ?? null,
$this->source['last_fetched_at'] ?? null $this->source['last_fetched_at'] ?? null
); );
$start = \DateTime::createFromImmutable($startImm); $start = \DateTime::createFromImmutable($startImm);
$log('Zeitraum: ' . $rangeLabel . ' – Start ' . $start->format('d.m.Y') . ' 00:00 Uhr.'); return [$start, $end, $rangeLabel];
}
$batchId = $this->createBatch(); /**
$log('Batch #' . $batchId . ' angelegt.'); * Ruft Kontoauszug und Saldo für ein einzelnes Konto ab und importiert neue Umsätze.
*
$totalNew = 0; * @return array{0:int,1:int,2:?float} [neue Buchungen, übersprungene Duplikate, Saldo]
$totalSkip = 0; */
$balance = null; private function fetchStatementsForAccount(FinTs $fints, $account, \DateTime $start, \DateTime $end, int $batchId): array
{
foreach ($accounts as $account) { $log = $this->log;
$iban = $account->getIban(); $iban = $account->getIban();
$log('Konto ' . $iban . ':'); $log('Konto ' . $iban . ':');
@ -229,15 +366,25 @@ class FinTsFetchService
$fints->execute($action); $fints->execute($action);
if ($action->needsTan()) { if ($action->needsTan()) {
if (!$this->handleStrongAuthentication($action, $iban)) { if (!$this->handleStrongAuthentication($action, $iban)) {
continue; return [0, 0, null];
} }
} }
$soa = $action->getStatement(); $soa = $action->getStatement();
} catch (\Throwable $e) { } catch (\Throwable $e) {
$log(' → Fehler beim Abruf: ' . $e->getMessage()); $log(' → Fehler beim Abruf: ' . $e->getMessage());
continue; return [0, 0, null];
} }
$accountBalance = $this->fetchAccountBalance($fints, $account, $iban, $soa);
[$new, $skip] = $this->importTransactions($soa, $iban, $batchId);
return [$new, $skip, $accountBalance];
}
private function fetchAccountBalance(FinTs $fints, $account, string $iban, $soa): ?float
{
$log = $this->log;
$accountBalance = null; $accountBalance = null;
try { try {
$balAction = GetBalance::create($account); $balAction = GetBalance::create($account);
@ -263,11 +410,19 @@ class FinTsFetchService
} }
if ($accountBalance !== null) { if ($accountBalance !== null) {
$balance = ($balance ?? 0.0) + $accountBalance;
$log(' → Kontostand: ' . number_format($accountBalance, 2, ',', '.') . ' €'); $log(' → Kontostand: ' . number_format($accountBalance, 2, ',', '.') . ' €');
} }
$accountNew = 0; return $accountBalance;
}
/** @return array{0:int,1:int} [neue Buchungen, übersprungene Duplikate] */
private function importTransactions($soa, string $iban, int $batchId): array
{
$log = $this->log;
$new = 0;
$skip = 0;
foreach ($soa->getStatements() as $statement) { foreach ($soa->getStatements() as $statement) {
foreach ($statement->getTransactions() as $tx) { foreach ($statement->getTransactions() as $tx) {
$date = $tx->getValutaDate() ?: $tx->getBookingDate() ?: new \DateTime(); $date = $tx->getValutaDate() ?: $tx->getBookingDate() ?: new \DateTime();
@ -281,7 +436,7 @@ class FinTsFetchService
$extId = sha1($iban . '|' . $date->format('Y-m-d') . '|' . number_format($amt, 2, '.', '') . '|' . $desc); $extId = sha1($iban . '|' . $date->format('Y-m-d') . '|' . number_format($amt, 2, '.', '') . '|' . $desc);
if ($this->externalIdExists($extId)) { if ($this->externalIdExists($extId)) {
$totalSkip++; $skip++;
continue; continue;
} }
@ -311,17 +466,11 @@ class FinTsFetchService
'eref' => method_exists($tx, 'getEndToEndID') ? $tx->getEndToEndID() : null, 'eref' => method_exists($tx, 'getEndToEndID') ? $tx->getEndToEndID() : null,
], JSON_UNESCAPED_UNICODE), ], JSON_UNESCAPED_UNICODE),
]); ]);
$totalNew++; $new++;
$accountNew++;
} }
} }
$log(' → ' . $accountNew . ' neue Buchungen.'); $log(' → ' . $new . ' neue Buchungen.');
} return [$new, $skip];
$this->updateBatchCount($batchId, $totalNew);
$this->updateLastFetched($balance);
$log(sprintf('Fertig. %d neu importiert, %d bereits vorhanden.', $totalNew, $totalSkip));
} }
/** /**

View file

@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
use Steuerkram\Services\FinTsFetchService;
/**
* Test 88: FinTsFetchService – Hilfsmethoden ohne Bankverbindung.
*
* Deckt die aus der 282-Zeilen-Methode run() (Fix #159) extrahierten
* Teilschritte ab, die ohne echte FinTS-Verbindung testbar sind:
* validateConfig(), applyAccountFilter() und accountId().
*/
class FinTsHilfsmethodenTest extends TestCase
{
protected string $testName = 'Test 88: FinTsFetchService – Hilfsmethoden';
public function run(): void
{
$this->testValidateConfigLehntLeereFelderAb();
$this->testValidateConfigLiefertPflichtfelder();
$this->testAccountId();
$this->testApplyAccountFilter();
}
private function makeService(array $source): FinTsFetchService
{
return new FinTsFetchService($source, function (string $msg): void {
// Logs werden in diesem Test nicht ausgewertet.
});
}
private function callPrivate(object $obj, string $method, array $args = []): mixed
{
$ref = new \ReflectionMethod($obj, $method);
$ref->setAccessible(true);
return $ref->invokeArgs($obj, $args);
}
private function testValidateConfigLehntLeereFelderAb(): void
{
$base = [
'id' => 1,
'provider_name' => 'Testbank',
'blz' => '12345678',
'benutzerkennung' => 'user',
'pin' => 'geheim',
'fints_url' => 'https://example.test/fints',
];
foreach (['blz', 'benutzerkennung', 'pin', 'fints_url'] as $missingField) {
$source = $base;
$source[$missingField] = '';
$svc = $this->makeService($source);
$result = $this->callPrivate($svc, 'validateConfig');
$this->assert($result === null, "validateConfig() lehnt leeres Feld '{$missingField}' ab");
}
}
private function testValidateConfigLiefertPflichtfelder(): void
{
$source = [
'id' => 1,
'provider_name' => 'Testbank',
'blz' => '12345678',
'benutzerkennung' => 'user',
'pin' => 'geheim',
'fints_url' => 'https://example.test/fints',
];
$svc = $this->makeService($source);
$result = $this->callPrivate($svc, 'validateConfig');
$this->assertEquals(
['blz' => '12345678', 'user' => 'user', 'pin' => 'geheim', 'url' => 'https://example.test/fints'],
$result,
'validateConfig() liefert die getrimmten Pflichtfelder bei vollständiger Konfiguration'
);
}
private function testAccountId(): void
{
$ref = new \ReflectionMethod(FinTsFetchService::class, 'accountId');
$ref->setAccessible(true);
$withIban = new class {
public function getIban(): string { return 'DE02120300000000202051'; }
public function getAccountNumber(): string { return '000000202051'; }
};
$this->assertEquals(
'DE02120300000000202051',
$ref->invoke(null, $withIban),
'accountId() nutzt die IBAN, wenn vorhanden'
);
$withoutIbanNoSub = new class {
public function getIban(): string { return ''; }
public function getAccountNumber(): string { return '4711'; }
};
$this->assertEquals(
'AN:4711:',
$ref->invoke(null, $withoutIbanNoSub),
'accountId() baut ohne IBAN einen AN:-Identifier aus der Kontonummer'
);
$withoutIbanWithSub = new class {
public function getIban(): string { return ''; }
public function getAccountNumber(): string { return '4711'; }
public function getSubAccount(): string { return '01'; }
};
$this->assertEquals(
'AN:4711:01',
$ref->invoke(null, $withoutIbanWithSub),
'accountId() hängt die Unterkontonummer an, wenn vorhanden'
);
}
private function testApplyAccountFilter(): void
{
$accA = new class {
public function getIban(): string { return 'DE02120300000000202051'; }
public function getAccountNumber(): string { return '000000202051'; }
};
$accB = new class {
public function getIban(): string { return 'DE11500105170648489890'; }
public function getAccountNumber(): string { return '0648489890'; }
};
// Leere Filterliste → kein Konto entspricht der Auswahl
$svc = $this->makeService(['id' => 1, 'account_filter' => '[]']);
$result = $this->callPrivate($svc, 'applyAccountFilter', [[$accA, $accB]]);
$this->assertEquals([], $result, 'applyAccountFilter() mit leerer Filterliste liefert keine Konten');
// Passender Filter → nur das gefilterte Konto
$svc = $this->makeService(['id' => 1, 'account_filter' => json_encode(['DE02120300000000202051'])]);
$result = $this->callPrivate($svc, 'applyAccountFilter', [[$accA, $accB]]);
$this->assertEquals(1, count($result), 'applyAccountFilter() mit passendem Filter liefert genau ein Konto');
$this->assert(($result[0] ?? null) === $accA, 'applyAccountFilter() liefert das gefilterte Konto A');
// Nicht passender Filter → kein Konto entspricht der Auswahl
$svc = $this->makeService(['id' => 1, 'account_filter' => json_encode(['DE00000000000000000000'])]);
$result = $this->callPrivate($svc, 'applyAccountFilter', [[$accA, $accB]]);
$this->assertEquals([], $result, 'applyAccountFilter() mit nicht passendem Filter liefert keine Konten');
}
}

View file

@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
use Steuerkram\Models\Beleg;
use Steuerkram\Models\Booking;
use Steuerkram\Database;
/**
* Test 89: Booking::create() – die drei Schreibpfade (Fix #159).
*
* Deckt die aus create() extrahierten Teilschritte ab:
* - insertHauptbuchung(): einfache Buchung ohne 7/19%-USt und ohne ig. Erwerb
* - insertVorsteuerUmsatzsteuerZeile(): normale Buchung mit 19% USt/VSt
* - insertIgErwerbReverseCharge(): ig. Erwerb 19% mit zwei Kindzeilen
*/
class BookingCreatePfadeTest extends TestCase
{
protected string $testName = 'Test 89: Booking::create() – Schreibpfade';
public function run(): void
{
$zahlungsId = $this->findAccountId('1200');
$ausgabeId = $this->findAccountId('4120');
if (!$zahlungsId || !$ausgabeId) {
$this->assert(false, 'Benötigte Konten nicht gefunden (1200, 4120)');
return;
}
$this->assert(true, 'Konten 1200, 4120 vorhanden');
$belegId = Beleg::create([
'beleg_nr' => 'BK-TEST-' . uniqid(),
'filename' => 'bk-test.pdf',
'storage_path' => '',
'mime_type' => 'application/pdf',
'file_hash' => hash('sha256', 'bk-test-' . uniqid()),
'beleg_datum' => date('Y-m-d'),
'belegtyp' => 'Eingangsrechnung',
]);
$this->testNurHauptbuchung($belegId, $ausgabeId, $zahlungsId);
$this->testMitVorsteuerZeile($belegId, $ausgabeId, $zahlungsId);
$this->testMitIgErwerbKindzeilen($belegId, $ausgabeId, $zahlungsId);
}
/** Buchung ohne 7/19%-USt und ohne ig. Erwerb → genau eine Zeile. */
private function testNurHauptbuchung(int $belegId, int $ausgabeId, int $zahlungsId): void
{
$bookingId = Booking::create([
'beleg_id' => $belegId,
'buchungs_datum' => date('Y-m-d'),
'buchungstyp' => 'Ausgabe',
'kategorie_konto_id' => $ausgabeId,
'zahlungskonto_id' => $zahlungsId,
'betrag_netto' => 42.00,
'ust_satz' => 0.0,
'buchungstext' => 'Test ohne USt-Zeile',
]);
$rows = Database::fetchAll('SELECT * FROM bookings WHERE beleg_id = ? AND id = ?', [$belegId, $bookingId]);
$this->assertEquals(1, count($rows), 'Buchung ohne 7/19%-USt erzeugt genau eine Zeile');
$kinder = Database::fetchAll('SELECT * FROM bookings WHERE parent_booking_id = ?', [$bookingId]);
$this->assertEquals(0, count($kinder), 'Buchung ohne 7/19%-USt erzeugt keine Kindzeilen');
}
/** Normale Buchung mit 19% USt → Hauptbuchung plus VSt-Korrekturzeile. */
private function testMitVorsteuerZeile(int $belegId, int $ausgabeId, int $zahlungsId): void
{
$bookingId = Booking::create([
'beleg_id' => $belegId,
'buchungs_datum' => date('Y-m-d'),
'buchungstyp' => 'Ausgabe',
'kategorie_konto_id' => $ausgabeId,
'zahlungskonto_id' => $zahlungsId,
'betrag_netto' => 100.00,
'ust_satz' => 19.0,
'buchungstext' => 'Test mit VSt-Zeile',
]);
$kinder = Database::fetchAll(
'SELECT bk.*, a.kontonummer FROM bookings bk
JOIN accounts a ON bk.kategorie_konto_id = a.id
WHERE bk.parent_booking_id = ?',
[$bookingId]
);
$this->assertEquals(1, count($kinder), 'Buchung mit 19% USt erzeugt genau eine VSt-Korrekturzeile');
$vstZeile = $kinder[0] ?? null;
$this->assert($vstZeile !== null, 'VSt-Korrekturzeile vorhanden');
if ($vstZeile !== null) {
$this->assertEquals('1576', $vstZeile['kontonummer'], 'VSt-Korrekturzeile bucht auf Konto 1576 (VSt 19%)');
$this->assertEquals(19.00, round((float)$vstZeile['betrag_netto'], 2), 'VSt-Korrekturzeile: Betrag = 19,00 €');
$this->assertEquals($bookingId, (int)$vstZeile['parent_booking_id'], 'VSt-Korrekturzeile referenziert die Hauptbuchung über parent_booking_id');
}
}
/** ig. Erwerb 19% → zwei Kindzeilen (VST_IGE_19 und UST_IGE_19). */
private function testMitIgErwerbKindzeilen(int $belegId, int $ausgabeId, int $zahlungsId): void
{
$bookingId = Booking::create([
'beleg_id' => $belegId,
'buchungs_datum' => date('Y-m-d'),
'buchungstyp' => 'Ausgabe',
'kategorie_konto_id' => $ausgabeId,
'zahlungskonto_id' => $zahlungsId,
'betrag_netto' => 1000.00,
'ust_satz' => 0.0,
'steuerart' => 'ig_erwerb_19',
'buchungstext' => 'Test ig. Erwerb 19%',
]);
$kinder = Database::fetchAll(
'SELECT bk.*, a.kontonummer FROM bookings bk
JOIN accounts a ON bk.kategorie_konto_id = a.id
WHERE bk.parent_booking_id = ?
ORDER BY a.kontonummer',
[$bookingId]
);
$this->assertEquals(2, count($kinder), 'ig. Erwerb 19% erzeugt genau zwei Kindzeilen');
$summeKinder = 0.0;
foreach ($kinder as $kind) {
$this->assertEquals($bookingId, (int)$kind['parent_booking_id'], 'Kindzeile referenziert die Hauptbuchung');
$summeKinder += (float)$kind['betrag_netto'];
}
$this->assertEquals(380.00, round($summeKinder, 2), 'Summe der Kindzeilen = 2 × 19% von 1000 € = 380 €');
}
}