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:
commit
e786cd02e1
6 changed files with 344 additions and 78 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
11
migrations/092_afa_storno_fiscal_year_korrektur.sql
Normal file
11
migrations/092_afa_storno_fiscal_year_korrektur.sql
Normal 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;
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
@ -327,45 +326,28 @@ class AssetController extends BaseController
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$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']]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
163
tests/82_AfaBuchungModellTest.php
Normal file
163
tests/82_AfaBuchungModellTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue