diff --git a/Changes.md b/Changes.md index 4cd7137..bf16a38 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,14 @@ --- +## 2026-08-03 – Fix: AfA-Buchungen umgehen das Booking-Modell (#155) + +- `AssetController::bookAfa()` und `reverseAfaBooking()` setzten ihre `INSERT INTO bookings`-Statements bislang direkt ab statt über `Booking::create()`/`reverse()`. Vier Folgen: Die Sperre eines abgeschlossenen Geschäftsjahres griff nicht, ein zweiter, unabhängiger Nummernkreis (`AFA-NNNNN`) lief neben `BU-YYYY-NNNNNN` auf derselben Spalte, die Nummernvergabe stand außerhalb der Transaktion, und das Storno einer AfA-Buchung wurde immer dem *aktuellen* Kalenderjahr statt dem Jahr der Originalbuchung zugeordnet. +- **`src/Models/Booking.php`:** Neue Methoden `createAfa()` und `reverseAfa()` bündeln die komplette Schreiblogik für AfA-Buchungen – inklusive Sperrprüfung des Geschäftsjahres, Nummernvergabe (`generateAfaBuchungsNr()`, eigener `AFA-NNNNN`-Kreis, bewusst beibehalten statt vereinheitlicht) innerhalb der Transaktion, und – beim Storno – Übernahme der `fiscal_year_id` der Originalbuchung. Das Buchungsdatum des Storno bleibt das tatsächliche Stornodatum (Ist-Versteuerung). +- **`src/Controllers/AssetController.php`:** `bookAfa()` und `reverseAfaBooking()` rufen nur noch die Modellmethoden auf, die bisherigen Vorprüfungen bleiben erhalten. `RuntimeException` aus dem Modell wird als HTTP 409 mit `{"error": "..."}` beantwortet. Kein direkter `Database`-Zugriff auf `bookings` mehr im Controller. +- **`migrations/092_afa_storno_fiscal_year_korrektur.sql`** (neu): Korrigiert die `fiscal_year_id` bereits vorhandener AfA-Stornobuchungen rückwirkend auf das Jahr ihrer Originalbuchung. +- **`tests/82_AfaBuchungModellTest.php`** (neu): Sperre verhindert AfA-Buchung und -Storno, Buchung im offenen Jahr landet korrekt (Jahr, 31.12.-Datum, `betrag_ust = 0`), Storno übernimmt die `fiscal_year_id` der Originalbuchung, `reversed_by`/`is_reversal` werden gesetzt, Buchungsnummer folgt dem Schema `AFA-NNNNN`. + ## 2026-08-02 – Testsuite als echtes Gate in update.sh (#169) - Schritt 9/9 von `scripts/update.sh` gab bei rotem Testergebnis nur eine Warnung aus – Exit-Code 0, kein Abbruch. Ein Update mit fehlgeschlagenen Tests galt damit als erfolgreich, obwohl die neue Version zu diesem Zeitpunkt bereits live war. diff --git a/docs/anlagevermögen.md b/docs/anlagevermögen.md index 00f7750..da51cff 100644 --- a/docs/anlagevermögen.md +++ b/docs/anlagevermögen.md @@ -100,3 +100,11 @@ Die Tabelle besitzt ein Flag `automatisch_berechnet`: 3. Im Dialog: Toggle „Manuell bearbeiten" aktiviert editierbare Zellen. 4. **„Neu berechnen"** überschreibt manuelle Änderungen (nach Bestätigung). 5. **„Speichern"** sichert die aktuelle Tabelle (inklusive manueller Änderungen) in der Datenbank. + +### AfA-Buchung + +Für eine Zeile der Abschreibungstabelle lässt sich per Klick eine AfA-Buchung anlegen (`Booking::createAfa()`). Sie bucht den Abschreibungsbetrag zum 31.12. des jeweiligen Jahres vom hinterlegten Anlagekonto auf ein gewähltes Ausgabenkonto, ohne Umsatzsteuer. + +- **Sperre des Geschäftsjahres:** Ist das Geschäftsjahr des AfA-Jahres bereits gesperrt, wird die Buchung mit einer Fehlermeldung abgelehnt (HTTP 409) – wie bei jeder anderen Buchung auch. +- **Nummernkreis:** AfA-Buchungen erhalten eine eigene, global fortlaufende Nummer im Format `AFA-NNNNN`, getrennt vom regulären Nummernkreis `BU-YYYY-NNNNNN`. Erkennbar sind sie zusätzlich am `buchungstyp = 'AfA'`. +- **Storno:** Beim Stornieren (`Booking::reverseAfa()`) erhält die Stornobuchung die `fiscal_year_id` der Originalbuchung, nicht die des aktuellen Kalenderjahres – eine 2026 stornierte AfA-Buchung für 2024 verändert die Jahressumme 2024, nicht 2026. Das Buchungsdatum des Storno bleibt bewusst das tatsächliche Stornodatum (Ist-Versteuerung). Ist das Geschäftsjahr der Originalbuchung gesperrt, wird auch das Storno abgelehnt. diff --git a/migrations/092_afa_storno_fiscal_year_korrektur.sql b/migrations/092_afa_storno_fiscal_year_korrektur.sql new file mode 100644 index 0000000..68c474a --- /dev/null +++ b/migrations/092_afa_storno_fiscal_year_korrektur.sql @@ -0,0 +1,11 @@ +-- Migration 092: Bestandskorrektur – AfA-Stornobuchungen erhalten die +-- fiscal_year_id ihrer Originalbuchung statt des Kalenderjahres der Stornierung. +-- (Fix #155: reverseAfaBooking() verwendete bislang date('Y') statt afa_jahr/ +-- fiscal_year_id der stornierten Buchung.) + +UPDATE bookings storno + JOIN bookings orig ON orig.reversed_by = storno.id + SET storno.fiscal_year_id = orig.fiscal_year_id + WHERE storno.buchungstyp = 'AfA' + AND storno.is_reversal = 1 + AND storno.fiscal_year_id <> orig.fiscal_year_id; diff --git a/src/Controllers/AssetController.php b/src/Controllers/AssetController.php index 7b64997..023af4d 100644 --- a/src/Controllers/AssetController.php +++ b/src/Controllers/AssetController.php @@ -9,7 +9,6 @@ use Steuerkram\Models\Account; use Steuerkram\Models\Asset; use Steuerkram\Models\AssetDepreciation; use Steuerkram\Models\Booking; -use Steuerkram\Models\FiscalYear; use Steuerkram\Models\Setting; use Steuerkram\View; @@ -327,45 +326,28 @@ class AssetController extends BaseController return; } - $jahr = (int)$scheduleRow['jahr']; - $betrag = round((float)$scheduleRow['abschreibungsbetrag'], 2); - $datum = $jahr . '-12-31'; - $text = 'AfA ' . $asset['bezeichnung'] . ' ' . $jahr; + $jahr = (int)$scheduleRow['jahr']; + $betrag = round((float)$scheduleRow['abschreibungsbetrag'], 2); + $text = 'AfA ' . $asset['bezeichnung'] . ' ' . $jahr; - $fy = FiscalYear::findOrCreateByYear($jahr); + try { + $bookingId = Booking::createAfa([ + 'asset_id' => $assetId, + 'afa_jahr' => $jahr, + 'kategorie_konto_id' => $ausgabekontoId, + 'zahlungskonto_id' => (int)$asset['account_id'], + 'betrag' => $betrag, + 'buchungstext' => $text, + 'schedule_row_id' => $rowId, + ]); + } catch (\RuntimeException $e) { + http_response_code(409); + echo json_encode(['error' => $e->getMessage()]); + return; + } - $buchungsNr = \Steuerkram\Database::fetchOne( - "SELECT CONCAT('AFA-', LPAD(COALESCE(MAX(CAST(SUBSTRING(buchungs_nr,5) AS UNSIGNED)),0)+1,5,'0')) AS nr - FROM bookings WHERE buchungs_nr LIKE 'AFA-%'" - )['nr'] ?? ('AFA-' . str_pad('1', 5, '0', STR_PAD_LEFT)); - - \Steuerkram\Database::execute( - 'INSERT INTO bookings - (buchungs_nr, beleg_id, asset_id, afa_jahr, buchungs_datum, buchungstyp, - kategorie_konto_id, zahlungskonto_id, - betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, - buchungstext, fiscal_year_id) - VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, 0, ?, 0, ?, ?, ?)', - [ - $buchungsNr, - $assetId, - $jahr, - $datum, - Constants::BUCHUNGSTYP_AFA, - $ausgabekontoId, - (int)$asset['account_id'], - $betrag, - 'normal', - $betrag, - $text, - (int)$fy['id'], - ] - ); - - $bookingId = (int)\Steuerkram\Database::lastInsertId(); - AssetDepreciation::setBookingId($rowId, $bookingId); - - echo json_encode(['ok' => true, 'buchungs_nr' => $buchungsNr]); + $buchung = Booking::find($bookingId); + echo json_encode(['ok' => true, 'buchungs_nr' => $buchung['buchungs_nr']]); } /** @@ -411,51 +393,16 @@ class AssetController extends BaseController return; } - $fy = FiscalYear::findOrCreateByYear((int)date('Y')); - $nr = \Steuerkram\Database::fetchOne( - "SELECT CONCAT('AFA-', LPAD(COALESCE(MAX(CAST(SUBSTRING(buchungs_nr,5) AS UNSIGNED)),0)+1,5,'0')) AS nr - FROM bookings WHERE buchungs_nr LIKE 'AFA-%'" - )['nr'] ?? 'AFA-00001'; - - \Steuerkram\Database::beginTransaction(); try { - \Steuerkram\Database::execute( - 'INSERT INTO bookings - (buchungs_nr, beleg_id, asset_id, afa_jahr, buchungs_datum, buchungstyp, - kategorie_konto_id, zahlungskonto_id, - betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, - buchungstext, is_reversal, fiscal_year_id) - VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, 0, ?, 0, ?, ?, 1, ?)', - [ - $nr, - $orig['asset_id'], - $orig['afa_jahr'], - date('Y-m-d'), - Constants::BUCHUNGSTYP_AFA, - $orig['kategorie_konto_id'], - $orig['zahlungskonto_id'], - -1 * (float)$orig['betrag_netto'], - 'normal', - -1 * (float)$orig['betrag_brutto'], - 'STORNO zu ' . $orig['buchungs_nr'], - $fy['id'], - ] - ); - $reversalId = (int)\Steuerkram\Database::lastInsertId(); - \Steuerkram\Database::execute( - 'UPDATE bookings SET reversed_by = ? WHERE id = ?', - [$reversalId, $bookingId] - ); - AssetDepreciation::setBookingId($rowId, 0); - \Steuerkram\Database::commit(); - } catch (\Throwable $e) { - \Steuerkram\Database::rollBack(); - http_response_code(500); + $reversalId = Booking::reverseAfa($bookingId, $rowId); + } catch (\RuntimeException $e) { + http_response_code(409); echo json_encode(['error' => $e->getMessage()]); return; } - echo json_encode(['ok' => true, 'storno_nr' => $nr]); + $storno = Booking::find($reversalId); + echo json_encode(['ok' => true, 'storno_nr' => $storno['buchungs_nr']]); } /** diff --git a/src/Models/Booking.php b/src/Models/Booking.php index 591ee32..687aa99 100644 --- a/src/Models/Booking.php +++ b/src/Models/Booking.php @@ -335,6 +335,124 @@ class Booking return $reversalId; } + /** + * Legt eine AfA-Buchung im eigenen AFA-Nummernkreis an. Referenz für Sperrprüfung, + * Nummernvergabe innerhalb der Transaktion und Schreibumfang: self::create(). + */ + public static function createAfa(array $data): int + { + $jahr = (int)$data['afa_jahr']; + $fy = FiscalYear::findOrCreateByYear($jahr); + + if ($fy['locked']) { + throw new \RuntimeException("Geschäftsjahr {$jahr} ist gesperrt. Neue Buchungen nicht möglich."); + } + + $betrag = round((float)$data['betrag'], 2); + $datum = $jahr . '-12-31'; + + Database::beginTransaction(); + try { + $nr = self::generateAfaBuchungsNr(); + Database::execute( + 'INSERT INTO bookings + (buchungs_nr, beleg_id, asset_id, afa_jahr, buchungs_datum, buchungstyp, + kategorie_konto_id, zahlungskonto_id, + betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, + buchungstext, fiscal_year_id) + VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, 0, ?, 0, ?, ?, ?)', + [ + $nr, + (int)$data['asset_id'], + $jahr, + $datum, + Constants::BUCHUNGSTYP_AFA, + (int)$data['kategorie_konto_id'], + (int)$data['zahlungskonto_id'], + $betrag, + 'normal', + $betrag, + $data['buchungstext'], + $fy['id'], + ] + ); + + $id = (int)Database::lastInsertId(); + + if (!empty($data['schedule_row_id'])) { + AssetDepreciation::setBookingId((int)$data['schedule_row_id'], $id); + } + + Database::commit(); + } catch (\Throwable $e) { + Database::rollBack(); + throw $e; + } + + return $id; + } + + /** + * Storniert eine AfA-Buchung im eigenen AFA-Nummernkreis. Das Storno erhält die + * fiscal_year_id der Originalbuchung (nicht das laufende Kalenderjahr) – wer 2026 + * eine AfA-Buchung für 2024 storniert, darf die Jahressumme 2026 nicht verfälschen. + * Das Buchungsdatum bleibt bewusst das tatsächliche Stornodatum (Ist-Versteuerung). + */ + public static function reverseAfa(int $bookingId, ?int $scheduleRowId = null): int + { + $orig = self::find($bookingId); + if (!$orig) throw new \RuntimeException('Buchung nicht gefunden.'); + if ($orig['is_reversal']) throw new \RuntimeException('Stornobuchungen können nicht storniert werden.'); + + $fy = FiscalYear::find((int)$orig['fiscal_year_id']); + if (!$fy) throw new \RuntimeException('Geschäftsjahr der Originalbuchung nicht gefunden.'); + if ($fy['locked']) { + throw new \RuntimeException("Geschäftsjahr {$fy['year']} ist gesperrt. Neue Buchungen nicht möglich."); + } + + Database::beginTransaction(); + try { + $nr = self::generateAfaBuchungsNr(); + Database::execute( + 'INSERT INTO bookings + (buchungs_nr, beleg_id, asset_id, afa_jahr, buchungs_datum, buchungstyp, + kategorie_konto_id, zahlungskonto_id, + betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, + buchungstext, is_reversal, fiscal_year_id) + VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, 0, ?, 0, ?, ?, 1, ?)', + [ + $nr, + $orig['asset_id'], + $orig['afa_jahr'], + date('Y-m-d'), + Constants::BUCHUNGSTYP_AFA, + $orig['kategorie_konto_id'], + $orig['zahlungskonto_id'], + -1 * (float)$orig['betrag_netto'], + 'normal', + -1 * (float)$orig['betrag_brutto'], + 'STORNO zu ' . $orig['buchungs_nr'], + $fy['id'], + ] + ); + + $reversalId = (int)Database::lastInsertId(); + + Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$reversalId, $bookingId]); + + if ($scheduleRowId !== null) { + AssetDepreciation::setBookingId($scheduleRowId, 0); + } + + Database::commit(); + } catch (\Throwable $e) { + Database::rollBack(); + throw $e; + } + + return $reversalId; + } + public static function upsertEigenanteil30( int $belegId, float $betragNetto, @@ -437,6 +555,17 @@ class Booking return sprintf('BU-%s-%06d', $year, $next); } + /** Eigener, global fortlaufender Nummernkreis für AfA-Buchungen (AFA-NNNNN). */ + private static function generateAfaBuchungsNr(): string + { + $result = Database::fetchOne( + "SELECT MAX(CAST(SUBSTRING(buchungs_nr,5) AS UNSIGNED)) as maxnr + FROM bookings WHERE buchungs_nr LIKE 'AFA-%'" + ); + $next = (int)($result['maxnr'] ?? 0) + 1; + return sprintf('AFA-%05d', $next); + } + /** Summiert betrag_brutto der aktiven Einnahme-Hauptbuchungen einer bereits geladenen Buchungsliste. */ public static function sumZahlungen(array $bookings): float { diff --git a/tests/82_AfaBuchungModellTest.php b/tests/82_AfaBuchungModellTest.php new file mode 100644 index 0000000..3a52a99 --- /dev/null +++ b/tests/82_AfaBuchungModellTest.php @@ -0,0 +1,163 @@ +kategorieId = (int)$this->findAccountId('4855'); + $this->zahlungsId = (int)$this->findAccountId('0420'); + + $this->assetId = Asset::create([ + 'bezeichnung' => 'Testgerät AfA-Modell', + 'anschaffungsdatum' => '2024-01-15', + 'anschaffungskosten' => 1200.00, + 'zugaenge' => 0, + 'abgaenge' => 0, + 'abschreibung_jahr' => 400.00, + 'nutzungsdauer' => 3, + 'account_id' => $this->zahlungsId, + ]); + + $this->gesperrtesJahrLehntAfaBuchungAb(); + $this->afaBuchungInOffenemJahr(); + $this->stornoUebernimmtJahrDerOriginalbuchung(); + $this->gesperrtesJahrLehntStornoAb(); + } + + private function gesperrtesJahrLehntAfaBuchungAb(): void + { + $jahr = 2019; + $fy = FiscalYear::findOrCreateByYear($jahr); + \Steuerkram\Database::execute('UPDATE fiscal_years SET locked = 1 WHERE id = ?', [$fy['id']]); + + $vorher = $this->countBookings(); + $exception = null; + try { + Booking::createAfa($this->afaData($jahr)); + } catch (\RuntimeException $e) { + $exception = $e; + } + + $this->assert($exception !== null, 'AfA-Buchung in gesperrtem Geschäftsjahr wirft eine RuntimeException'); + $this->assertEquals( + $vorher, + $this->countBookings(), + 'Bei Sperre wird kein Datensatz in bookings angelegt' + ); + } + + private function afaBuchungInOffenemJahr(): void + { + $jahr = 2024; + $bookingId = Booking::createAfa($this->afaData($jahr)); + $this->assertGreaterThan(0, $bookingId, 'AfA-Buchung in offenem Jahr wird angelegt'); + + $booking = Booking::find($bookingId); + $this->assertEquals($jahr, (int)$booking['afa_jahr'], 'afa_jahr ist gesetzt'); + $this->assertEquals($jahr . '-12-31', $booking['buchungs_datum'], 'buchungs_datum liegt auf dem 31.12. des AfA-Jahres'); + $this->assertEquals(0.00, (float)$booking['betrag_ust'], 'betrag_ust ist 0'); + $this->assertEquals(400.00, (float)$booking['betrag_netto'], 'betrag_netto entspricht dem AfA-Betrag'); + $this->assert( + (bool)preg_match('/^AFA-\d{5}$/', $booking['buchungs_nr']), + 'Buchungsnummer folgt dem Schema AFA-NNNNN (erhalten: ' . $booking['buchungs_nr'] . ')' + ); + } + + private function stornoUebernimmtJahrDerOriginalbuchung(): void + { + // Vorjahr: 2022 – das laufende Kalenderjahr ist ein anderes. + $jahr = 2022; + $bookingId = Booking::createAfa($this->afaData($jahr)); + $original = Booking::find($bookingId); + + $reversalId = Booking::reverseAfa($bookingId); + $reversal = Booking::find($reversalId); + + $this->assertEquals( + $original['fiscal_year_id'], + $reversal['fiscal_year_id'], + 'Storno erhält die fiscal_year_id der Originalbuchung, nicht die des aktuellen Jahres' + ); + $aktuellesJahr = FiscalYear::findOrCreateByYear((int)date('Y')); + $this->assert( + (int)$reversal['fiscal_year_id'] !== (int)$aktuellesJahr['id'], + 'fiscal_year_id des Storno ist nicht die des laufenden Kalenderjahres' + ); + $this->assertEquals(1, (int)$reversal['is_reversal'], 'Stornobuchung ist als is_reversal=1 markiert'); + + $updatedOriginal = Booking::find($bookingId); + $this->assertEquals( + $reversalId, + (int)$updatedOriginal['reversed_by'], + 'reversed_by der Originalbuchung zeigt auf die Stornobuchung' + ); + $this->assert( + (bool)preg_match('/^AFA-\d{5}$/', $reversal['buchungs_nr']), + 'Storno-Buchungsnummer folgt ebenfalls dem Schema AFA-NNNNN' + ); + } + + private function gesperrtesJahrLehntStornoAb(): void + { + $jahr = 2020; + $bookingId = Booking::createAfa($this->afaData($jahr)); + + $fy = FiscalYear::findByYear($jahr); + \Steuerkram\Database::execute('UPDATE fiscal_years SET locked = 1 WHERE id = ?', [$fy['id']]); + + $vorher = $this->countBookings(); + $exception = null; + try { + Booking::reverseAfa($bookingId); + } catch (\RuntimeException $e) { + $exception = $e; + } + + $this->assert($exception !== null, 'Storno einer AfA-Buchung im gesperrten Jahr wirft eine RuntimeException'); + $this->assertEquals( + $vorher, + $this->countBookings(), + 'Bei Sperre wird kein zusätzlicher Storno-Datensatz angelegt' + ); + + $unverändert = Booking::find($bookingId); + $this->assert(empty($unverändert['reversed_by']), 'Originalbuchung bleibt ohne reversed_by, wenn das Storno abgelehnt wird'); + } + + private function afaData(int $jahr): array + { + return [ + 'asset_id' => $this->assetId, + 'afa_jahr' => $jahr, + 'kategorie_konto_id' => $this->kategorieId, + 'zahlungskonto_id' => $this->zahlungsId, + 'betrag' => 400.00, + 'buchungstext' => 'AfA Test ' . $jahr, + ]; + } + + private function countBookings(): int + { + $row = \Steuerkram\Database::fetchOne('SELECT COUNT(*) as cnt FROM bookings'); + return (int)($row['cnt'] ?? 0); + } +}