Merge pull request 'Fix #159: Überlange Methoden zerlegt (FinTsFetchService, Booking, Bank-/InvoiceController)' (#182) from fds2610-bot/Steuerkram:main into main
Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/182
This commit is contained in:
commit
7f4d5eb47e
7 changed files with 967 additions and 450 deletions
10
Changes.md
10
Changes.md
|
|
@ -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)
|
||||
|
||||
- **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`).
|
||||
|
|
|
|||
|
|
@ -405,6 +405,26 @@ class BankController extends BaseController
|
|||
// Provider entfällt hier (provider_id = NULL); Spaltenzuordnung erfolgt
|
||||
// anschließend unter /settings/bankimport.
|
||||
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'] ?? '');
|
||||
if ($kuerzel === '' || mb_strlen($kuerzel) > 10) {
|
||||
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.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Neue Bank anlegen, wenn Dropdown auf "__new__" steht.
|
||||
// Verbindungstyp wählt der Nutzer im Formular (API oder FinTS).
|
||||
/** Neue Bank anlegen, wenn Dropdown auf "__new__" steht, sonst vorhandenen Anbieter übernehmen. */
|
||||
private function resolveProviderId(string $providerRaw): ?int
|
||||
{
|
||||
if ($providerRaw === '__new__') {
|
||||
$newName = trim((string)($_POST['new_bank_name'] ?? ''));
|
||||
$newTyp = ($_POST['new_bank_typ'] ?? 'FinTS') === 'API' ? 'API' : 'FinTS';
|
||||
if ($newName === '') {
|
||||
View::redirect('/bank/direkt', 'Bitte den Banknamen angeben.', 'error');
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
$existing = BankProvider::findByName($newName);
|
||||
$providerId = $existing ? (int)$existing['id'] : BankProvider::create($newName, $newTyp);
|
||||
|
|
@ -459,15 +479,14 @@ class BankController extends BaseController
|
|||
|
||||
if (!$providerId) {
|
||||
View::redirect('/bank/direkt', 'Bitte alle Pflichtfelder ausfüllen.', 'error');
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
$provider = BankProvider::find($providerId);
|
||||
if (!$provider) {
|
||||
View::redirect('/bank/direkt', 'Unbekannter Anbieter.', 'error');
|
||||
return;
|
||||
return $providerId;
|
||||
}
|
||||
|
||||
private function storeBankSource(array $provider, int $providerId, string $bezeichnung): void
|
||||
{
|
||||
$isApi = $provider['verbindungstyp'] === 'API';
|
||||
$isFints = $provider['verbindungstyp'] === 'FinTS';
|
||||
|
||||
|
|
@ -572,6 +591,17 @@ class BankController extends BaseController
|
|||
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';
|
||||
$isFints = $source['type'] === 'fints';
|
||||
|
||||
|
|
@ -587,15 +617,32 @@ class BankController extends BaseController
|
|||
}
|
||||
|
||||
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'] ?? '');
|
||||
$newSecret = trim($_POST['secret'] ?? '');
|
||||
$data += [
|
||||
return [
|
||||
'sandbox' => isset($_POST['sandbox']) ? 1 : 0,
|
||||
'app_name' => trim($_POST['app_name'] ?? '') ?: null,
|
||||
'client_id' => $newPwd !== '' ? $newPwd : $source['client_id'],
|
||||
'secret' => $newSecret !== '' ? $newSecret : $source['secret'],
|
||||
];
|
||||
} elseif ($isFints) {
|
||||
}
|
||||
|
||||
private function buildFintsUpdateData(array $source): ?array
|
||||
{
|
||||
$newPin = trim($_POST['pin'] ?? '');
|
||||
$fintsUrl = trim($_POST['fints_url'] ?? '') ?: null;
|
||||
if ($fintsUrl !== null) {
|
||||
|
|
@ -603,10 +650,10 @@ class BankController extends BaseController
|
|||
SsrfGuard::assertPublicHttpsUrl($fintsUrl);
|
||||
} catch (\RuntimeException $e) {
|
||||
View::redirect('/bank/direkt', 'Ungültige FinTS-URL: ' . $e->getMessage(), 'error');
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
$data += [
|
||||
$data = [
|
||||
'blz' => trim($_POST['blz'] ?? '') ?: null,
|
||||
'fints_url' => $fintsUrl,
|
||||
'benutzerkennung' => trim($_POST['benutzerkennung'] ?? '') ?: null,
|
||||
|
|
@ -620,10 +667,7 @@ class BankController extends BaseController
|
|||
if ($filterUpdate['update']) {
|
||||
$data['account_filter'] = $filterUpdate['value'];
|
||||
}
|
||||
}
|
||||
|
||||
BankSource::update($id, $data);
|
||||
View::redirect('/bank/direkt', 'Verbindung gespeichert.');
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function direktDelete(array $params): void
|
||||
|
|
|
|||
|
|
@ -412,6 +412,22 @@ class InvoiceController extends BaseController
|
|||
}
|
||||
|
||||
$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 = [];
|
||||
|
||||
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.';
|
||||
}
|
||||
|
||||
if ($errors) {
|
||||
if (!empty($data['_from_modal'])) {
|
||||
View::redirect("/invoices/{$id}", implode(' ', $errors), 'error');
|
||||
return;
|
||||
return $errors;
|
||||
}
|
||||
|
||||
private function renderBookForm(array $invoice, int $belegId, array $errors, array $data): void
|
||||
{
|
||||
$bookings = Booking::forBeleg($belegId);
|
||||
$accounts = Account::all(['aktiv' => 1]);
|
||||
$view = new View();
|
||||
|
|
@ -443,9 +459,10 @@ class InvoiceController extends BaseController
|
|||
'formData' => $data,
|
||||
]),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
private function persistBooking(int $id, array $invoice, int $belegId, array $data): void
|
||||
{
|
||||
try {
|
||||
$data['beleg_id'] = $belegId;
|
||||
$data['buchungstyp'] = 'Einnahme';
|
||||
|
|
@ -454,33 +471,9 @@ class InvoiceController extends BaseController
|
|||
View::redirect($returnUrl, 'Buchung erfasst.');
|
||||
} catch (\PDOException $e) {
|
||||
error_log('[Steuerkram] PDO-Fehler in InvoiceController::storeBooking(): ' . $e->getMessage());
|
||||
$bookings = Booking::forBeleg($belegId);
|
||||
$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,
|
||||
]),
|
||||
]);
|
||||
$this->renderBookForm($invoice, $belegId, ['_exception' => 'Datenbankfehler beim Speichern der Buchung.'], $data);
|
||||
} catch (\Exception $e) {
|
||||
$bookings = Booking::forBeleg($belegId);
|
||||
$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,
|
||||
]),
|
||||
]);
|
||||
$this->renderBookForm($invoice, $belegId, ['_exception' => $e->getMessage()], $data);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -115,10 +115,34 @@ class Booking
|
|||
$steuerart = 'normal';
|
||||
}
|
||||
|
||||
$assetId = isset($data['asset_id']) && $data['asset_id'] !== '' ? (int)$data['asset_id'] : null;
|
||||
|
||||
Database::beginTransaction();
|
||||
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(
|
||||
'INSERT INTO bookings (buchungs_nr, beleg_id, asset_id, buchungs_datum, buchungstyp,
|
||||
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));
|
||||
if (in_array($data['buchungstyp'], [Constants::BUCHUNGSTYP_AUSGABE, Constants::BUCHUNGSTYP_EINNAHME], true)
|
||||
&& in_array($ustSatzInt, [7, 19], true)
|
||||
&& $steuerart === 'normal'
|
||||
&& abs($ust) > 0.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
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$isAusgabe = $data['buchungstyp'] === Constants::BUCHUNGSTYP_AUSGABE;
|
||||
if ($isAusgabe) {
|
||||
$steuerKontonummer = $ustSatzInt === 19 ? Constants::KONTO_VST_19 : Constants::KONTO_VST_7;
|
||||
|
|
@ -161,8 +197,10 @@ class Booking
|
|||
$steuerText = 'USt zu ' . $nr;
|
||||
}
|
||||
$steuerAcc = Account::findByKontonummer($steuerKontonummer);
|
||||
if ($steuerAcc) {
|
||||
$steuerNr = self::generateBuchungsNr();
|
||||
if (!$steuerAcc) {
|
||||
return;
|
||||
}
|
||||
|
||||
Database::execute(
|
||||
'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp,
|
||||
kategorie_konto_id, zahlungskonto_id,
|
||||
|
|
@ -170,7 +208,7 @@ class Booking
|
|||
buchungstext, fiscal_year_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
$steuerNr,
|
||||
self::generateBuchungsNr(),
|
||||
(int)$data['beleg_id'],
|
||||
$mainId,
|
||||
$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);
|
||||
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) {
|
||||
if (!$igeAcc) {
|
||||
continue;
|
||||
}
|
||||
Database::execute(
|
||||
'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp,
|
||||
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
|
||||
{
|
||||
|
|
@ -263,6 +301,26 @@ class Booking
|
|||
|
||||
Database::beginTransaction();
|
||||
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(
|
||||
'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, buchungstyp,
|
||||
kategorie_konto_id, zahlungskonto_id,
|
||||
|
|
@ -285,10 +343,12 @@ class Booking
|
|||
]
|
||||
);
|
||||
|
||||
$reversalId = (int)Database::lastInsertId();
|
||||
|
||||
Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$reversalId, $id]);
|
||||
return (int)Database::lastInsertId();
|
||||
}
|
||||
|
||||
/** 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(
|
||||
'SELECT * FROM bookings WHERE parent_booking_id = ? AND reversed_by IS NULL',
|
||||
[$id]
|
||||
|
|
@ -321,18 +381,6 @@ class Booking
|
|||
$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;
|
||||
}
|
||||
|
||||
if (!empty($reversalData['beleg_id'])) {
|
||||
Invoice::recalcPaymentStatusForBeleg((int)$reversalData['beleg_id']);
|
||||
}
|
||||
|
||||
return $reversalId;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ use Fhp\Action\GetStatementOfAccount;
|
|||
use Fhp\BaseAction;
|
||||
use Fhp\FinTs;
|
||||
use Fhp\Model\StatementOfAccount\Transaction;
|
||||
use Fhp\Model\TanMode;
|
||||
use Fhp\Model\TanRequestChallengeImage;
|
||||
use Fhp\Options\Credentials;
|
||||
use Fhp\Options\FinTsOptions;
|
||||
|
|
@ -53,6 +54,122 @@ class FinTsFetchService
|
|||
{
|
||||
$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'] ?? ''));
|
||||
$user = trim((string)($this->source['benutzerkennung'] ?? ''));
|
||||
$pin = trim((string)($this->source['pin'] ?? ''));
|
||||
|
|
@ -70,54 +187,61 @@ class FinTsFetchService
|
|||
|
||||
if ($blz === '' || $user === '' || $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 {
|
||||
SsrfGuard::assertPublicHttpsUrl($url);
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$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->url = $url;
|
||||
$options->bankCode = $blz;
|
||||
$options->url = $cfg['url'];
|
||||
$options->bankCode = $cfg['blz'];
|
||||
$options->productName = 'F399A6377D4D60E0A222D677A';
|
||||
$options->productVersion = '1.0';
|
||||
$credentials = Credentials::create($user, $pin);
|
||||
$credentials = Credentials::create($cfg['user'], $cfg['pin']);
|
||||
|
||||
try {
|
||||
$this->fints = FinTs::new($options, $credentials);
|
||||
} catch (\Throwable $e) {
|
||||
$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
|
||||
// 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).
|
||||
try {
|
||||
SsrfGuard::assertPublicHttpsUrl($url);
|
||||
} catch (\Throwable $e) {
|
||||
$log('FEHLER: Unzulässige FinTS-URL – ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
private function selectTanMode(FinTs $fints): ?TanMode
|
||||
{
|
||||
$log = $this->log;
|
||||
|
||||
$log('TAN-Verfahren werden ermittelt…');
|
||||
try {
|
||||
$tanModes = $fints->getTanModes();
|
||||
} catch (\Throwable $e) {
|
||||
$log('FEHLER bei TAN-Modi-Abruf: ' . $e->getMessage());
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (empty($tanModes)) {
|
||||
$log('FEHLER: Bank meldet keine TAN-Verfahren.');
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($tanModes as $idx => $mode) {
|
||||
|
|
@ -144,83 +268,96 @@ class FinTsFetchService
|
|||
}
|
||||
$log('Auswahl: ' . $selectedTanMode->getName() . ' (ID ' . $selectedTanMode->getId() . ')');
|
||||
|
||||
$selectedTanMedium = null;
|
||||
if ($selectedTanMode->needsTanMedium()) {
|
||||
return $selectedTanMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 {
|
||||
$tanMedia = $fints->getTanMedia($selectedTanMode);
|
||||
} catch (\Throwable $e) {
|
||||
$log('FEHLER beim TAN-Medien-Abruf: ' . $e->getMessage());
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (empty($tanMedia)) {
|
||||
$log('FEHLER: Verfahren benötigt TAN-Medium, Bank liefert aber keines.');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
$selectedTanMedium = reset($tanMedia);
|
||||
$log('TAN-Medium: ' . $selectedTanMedium->getName());
|
||||
return $selectedTanMedium;
|
||||
}
|
||||
|
||||
try {
|
||||
$fints->selectTanMode($selectedTanMode, $selectedTanMedium);
|
||||
} catch (\Throwable $e) {
|
||||
$log('FEHLER bei selectTanMode: ' . $e->getMessage());
|
||||
return;
|
||||
}
|
||||
private function login(FinTs $fints): bool
|
||||
{
|
||||
$log = $this->log;
|
||||
|
||||
$log('Login wird versucht…');
|
||||
try {
|
||||
$login = $fints->login();
|
||||
if ($login->needsTan()) {
|
||||
if (!$this->handleStrongAuthentication($login, 'Login')) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$log('FEHLER beim Login: ' . $e->getMessage());
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
$log('Login OK.');
|
||||
return true;
|
||||
}
|
||||
|
||||
private function fetchAccounts(FinTs $fints): ?array
|
||||
{
|
||||
$log = $this->log;
|
||||
|
||||
try {
|
||||
$getAccounts = GetSEPAAccounts::create();
|
||||
$fints->execute($getAccounts);
|
||||
if ($getAccounts->needsTan()) {
|
||||
$log('FEHLER: TAN für Kontenabfrage erforderlich. Nicht unterstützt.');
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
$accounts = $getAccounts->getAccounts();
|
||||
} catch (\Throwable $e) {
|
||||
$log('FEHLER bei Kontenabfrage: ' . $e->getMessage());
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
$log(count($accounts) . ' SEPA-Konten verfügbar.');
|
||||
|
||||
$accounts = $this->applyAccountFilter($accounts);
|
||||
if ($accounts === null) {
|
||||
return;
|
||||
}
|
||||
if (empty($accounts)) {
|
||||
$log('Kein Konto entspricht der Auswahl – Abbruch.');
|
||||
return;
|
||||
return $accounts;
|
||||
}
|
||||
|
||||
/** @return array{0:\DateTime,1:\DateTime,2:string} [Start, Ende, Anzeigetext] */
|
||||
private function computeFetchRange(): array
|
||||
{
|
||||
$end = new \DateTime('today');
|
||||
[$startImm, $rangeLabel] = BankSource::computeStartDate(
|
||||
$this->source['fetch_range'] ?? null,
|
||||
$this->source['last_fetched_at'] ?? null
|
||||
);
|
||||
$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.');
|
||||
|
||||
$totalNew = 0;
|
||||
$totalSkip = 0;
|
||||
$balance = null;
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
/**
|
||||
* Ruft Kontoauszug und Saldo für ein einzelnes Konto ab und importiert neue Umsätze.
|
||||
*
|
||||
* @return array{0:int,1:int,2:?float} [neue Buchungen, übersprungene Duplikate, Saldo]
|
||||
*/
|
||||
private function fetchStatementsForAccount(FinTs $fints, $account, \DateTime $start, \DateTime $end, int $batchId): array
|
||||
{
|
||||
$log = $this->log;
|
||||
$iban = $account->getIban();
|
||||
$log('Konto ' . $iban . ':');
|
||||
|
||||
|
|
@ -229,15 +366,25 @@ class FinTsFetchService
|
|||
$fints->execute($action);
|
||||
if ($action->needsTan()) {
|
||||
if (!$this->handleStrongAuthentication($action, $iban)) {
|
||||
continue;
|
||||
return [0, 0, null];
|
||||
}
|
||||
}
|
||||
$soa = $action->getStatement();
|
||||
} catch (\Throwable $e) {
|
||||
$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;
|
||||
try {
|
||||
$balAction = GetBalance::create($account);
|
||||
|
|
@ -263,11 +410,19 @@ class FinTsFetchService
|
|||
}
|
||||
|
||||
if ($accountBalance !== null) {
|
||||
$balance = ($balance ?? 0.0) + $accountBalance;
|
||||
$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 ($statement->getTransactions() as $tx) {
|
||||
$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);
|
||||
|
||||
if ($this->externalIdExists($extId)) {
|
||||
$totalSkip++;
|
||||
$skip++;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -311,17 +466,11 @@ class FinTsFetchService
|
|||
'eref' => method_exists($tx, 'getEndToEndID') ? $tx->getEndToEndID() : null,
|
||||
], JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
$totalNew++;
|
||||
$accountNew++;
|
||||
$new++;
|
||||
}
|
||||
}
|
||||
$log(' → ' . $accountNew . ' neue Buchungen.');
|
||||
}
|
||||
|
||||
$this->updateBatchCount($batchId, $totalNew);
|
||||
$this->updateLastFetched($balance);
|
||||
|
||||
$log(sprintf('Fertig. %d neu importiert, %d bereits vorhanden.', $totalNew, $totalSkip));
|
||||
$log(' → ' . $new . ' neue Buchungen.');
|
||||
return [$new, $skip];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
143
tests/88_FinTsHilfsmethodenTest.php
Normal file
143
tests/88_FinTsHilfsmethodenTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
130
tests/89_BookingCreatePfadeTest.php
Normal file
130
tests/89_BookingCreatePfadeTest.php
Normal 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 €');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue