Merge pull request 'Fix #155: AfA-Buchungen laufen jetzt über Booking::createAfa()/reverseAfa()' (#178) from fds2610-bot/Steuerkram:main into main

Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/178
This commit is contained in:
fds2610 2026-08-07 12:06:14 +02:00
commit e786cd02e1
6 changed files with 344 additions and 78 deletions

View file

@ -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) ## 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. - 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.

View file

@ -100,3 +100,11 @@ Die Tabelle besitzt ein Flag `automatisch_berechnet`:
3. Im Dialog: Toggle „Manuell bearbeiten" aktiviert editierbare Zellen. 3. Im Dialog: Toggle „Manuell bearbeiten" aktiviert editierbare Zellen.
4. **„Neu berechnen"** überschreibt manuelle Änderungen (nach Bestätigung). 4. **„Neu berechnen"** überschreibt manuelle Änderungen (nach Bestätigung).
5. **„Speichern"** sichert die aktuelle Tabelle (inklusive manueller Änderungen) in der Datenbank. 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.

View file

@ -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;

View file

@ -9,7 +9,6 @@ use Steuerkram\Models\Account;
use Steuerkram\Models\Asset; use Steuerkram\Models\Asset;
use Steuerkram\Models\AssetDepreciation; use Steuerkram\Models\AssetDepreciation;
use Steuerkram\Models\Booking; use Steuerkram\Models\Booking;
use Steuerkram\Models\FiscalYear;
use Steuerkram\Models\Setting; use Steuerkram\Models\Setting;
use Steuerkram\View; use Steuerkram\View;
@ -329,43 +328,26 @@ class AssetController extends BaseController
$jahr = (int)$scheduleRow['jahr']; $jahr = (int)$scheduleRow['jahr'];
$betrag = round((float)$scheduleRow['abschreibungsbetrag'], 2); $betrag = round((float)$scheduleRow['abschreibungsbetrag'], 2);
$datum = $jahr . '-12-31';
$text = 'AfA ' . $asset['bezeichnung'] . ' ' . $jahr; $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( $buchung = Booking::find($bookingId);
"SELECT CONCAT('AFA-', LPAD(COALESCE(MAX(CAST(SUBSTRING(buchungs_nr,5) AS UNSIGNED)),0)+1,5,'0')) AS nr echo json_encode(['ok' => true, 'buchungs_nr' => $buchung['buchungs_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]);
} }
/** /**
@ -411,51 +393,16 @@ class AssetController extends BaseController
return; 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 { try {
\Steuerkram\Database::execute( $reversalId = Booking::reverseAfa($bookingId, $rowId);
'INSERT INTO bookings } catch (\RuntimeException $e) {
(buchungs_nr, beleg_id, asset_id, afa_jahr, buchungs_datum, buchungstyp, http_response_code(409);
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);
echo json_encode(['error' => $e->getMessage()]); echo json_encode(['error' => $e->getMessage()]);
return; return;
} }
echo json_encode(['ok' => true, 'storno_nr' => $nr]); $storno = Booking::find($reversalId);
echo json_encode(['ok' => true, 'storno_nr' => $storno['buchungs_nr']]);
} }
/** /**

View file

@ -335,6 +335,124 @@ class Booking
return $reversalId; 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( public static function upsertEigenanteil30(
int $belegId, int $belegId,
float $betragNetto, float $betragNetto,
@ -437,6 +555,17 @@ class Booking
return sprintf('BU-%s-%06d', $year, $next); 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. */ /** Summiert betrag_brutto der aktiven Einnahme-Hauptbuchungen einer bereits geladenen Buchungsliste. */
public static function sumZahlungen(array $bookings): float public static function sumZahlungen(array $bookings): float
{ {

View file

@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
use Steuerkram\Models\Asset;
use Steuerkram\Models\Booking;
use Steuerkram\Models\FiscalYear;
/**
* Test 82: AfA-Buchungen laufen über Booking::createAfa()/reverseAfa().
*
* Fix #155: AssetController setzte INSERT INTO bookings bislang direkt ab und
* umging damit die Sperre des Geschäftsjahres, den Transaktionsschutz der
* Nummernvergabe und – beim Storno – die richtige Jahreszuordnung.
*/
class AfaBuchungModellTest extends TestCase
{
protected string $testName = 'Test 82: AfA-Buchungen über Booking-Modell';
private int $assetId;
private int $kategorieId;
private int $zahlungsId;
public function run(): void
{
$this->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);
}
}