From b637b7b5ab8eb11781b7c806ec4a60cf70787dc0 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Sun, 24 May 2026 12:56:21 +0200 Subject: [PATCH] =?UTF-8?q?Anlageverm=C3=B6gen=20=C3=BCberarbeitet,=20AFA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 + Changes.md | 39 ++++ docker-compose.yml | 1 + migrations/074_afa_booking_support.sql | 15 ++ public/index.php | 7 +- src/Config.php | 3 +- src/Constants.php | 4 + src/Controllers/AssetController.php | 240 ++++++++++++++++++++++- src/Controllers/BookingController.php | 19 +- src/Controllers/EuerController.php | 84 +++++++- src/Models/Account.php | 9 + src/Models/Asset.php | 52 +++++ src/Models/AssetDepreciation.php | 78 ++++++-- src/Models/Booking.php | 18 +- src/Services/PdfAnlagelisteService.php | 135 +++++++++++++ src/Views/assets/form.php | 261 ++++++++++++++++++++++++- src/Views/assets/list.php | 10 +- src/Views/bookings/journal.php | 25 ++- src/Views/layout/base.php | 1 + src/Views/tax/euer_show.php | 76 ++++++- src/version.txt | 2 +- tests/26_AbschreibungstabelleTest.php | 3 +- tests/41_AfaAnteiligTest.php | 116 +++++++++++ 23 files changed, 1138 insertions(+), 63 deletions(-) create mode 100644 migrations/074_afa_booking_support.sql create mode 100644 src/Services/PdfAnlagelisteService.php create mode 100644 tests/41_AfaAnteiligTest.php diff --git a/.env.example b/.env.example index d172c53..f370923 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,9 @@ DB_PASS=steuerkram # Erzeugen mit: php -r "echo bin2hex(random_bytes(32));" APP_KEY= +# Hintergrundfarbe der linken Sidebar (hex, optional) +# SIDEBAR_BG_COLOR=#1e293b + # KI-Kontierungsvorschlag (optional) # Wenn nichts konfiguriert wird, sucht die App automatisch nach einem lokalen Ollama auf localhost:11434. # diff --git a/Changes.md b/Changes.md index 6447f1b..e9799be 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,45 @@ --- +## 2026-05-24 – Feature: EÜR Anlageliste CSV/PDF-Export + +- EÜR-Ansicht: Kopfzeile der Anlageliste-Karte enthält jetzt rechtsbündig „Herunterladen: [CSV] [PDF]" +- CSV-Export: `GET /tax/euer/{year}/anlageliste/csv` – alle 9 Spalten mit UTF-8 BOM (Excel-kompatibel), Semikolon-getrennt, inkl. Summenzeile +- PDF-Export: `GET /tax/euer/{year}/anlageliste/pdf` – A4 Querformat, enge Spaltenbreiten sodass alle 9 Spalten auf eine Seite passen; neuer `PdfAnlagelisteService` +- EÜR-Karte: heller „PDF herunterladen"-Button entfernt; Topbar-Button umbenannt zu „PDF" + +--- + +## 2026-05-24 – Feature: AFA-Buchung stornieren/löschen in AfA-Tabelle + +- AfA-Tabelle im Asset-Edit-Modal: Gebuchte Zeilen zeigen neben dem grünen Haken einen Storno-Button (Undo-Pfeil, alle Modi) und im Testmodus zusätzlich einen Papierkorb-Button +- Storno legt eine Gegenbuchung (`buchungstyp=AfA`, `is_reversal=1`, negativer Betrag) an und setzt `afa_booking_id` auf NULL → der Pfeil-aus-Box-Button erscheint wieder +- Löschen (Testmodus) löscht die AFA-Buchung direkt und setzt `afa_booking_id` auf NULL +- Neuer Controller: `POST /assets/{id}/depreciation/{rowId}/reverse` und `…/delete` +- `AssetDepreciation::setBookingId()` akzeptiert jetzt `?int` (null erlaubt) + +--- + +## 2026-05-24 – Feature: Buchungsjournal-Filter Buchungstyp + EÜR-Anlageliste + +- Buchungsjournal: neues Filterfeld „Buchungstyp" (Einnahme / Ausgabe / AfA); vorbelegt mit „Alle Typen" +- Buchungsjournal: AfA-Buchungen (ohne Beleg) werden jetzt korrekt angezeigt (`LEFT JOIN belege`); Beleg-Zelle zeigt „–" wenn kein Beleg +- Buchungsjournal: Badge für Buchungstyp `AfA` in grau (badge-gray) +- EÜR-Ansicht: neue Tabelle „Anlagegüter {Jahr}" unterhalb der EÜR mit Spalten Anlage-ID, Bezeichnung, Anschaffungsdatum, Anschaffungskosten, Nutzungsdauer, AfA Vorjahre (kumulierte gebuchte AfA-Buchungen), Restbuchwert Vorjahr, AfA {Jahr} (gebuchte AfA des Jahres), Restbuchwert 31.12.{Jahr}; Summenzeile am Ende +- `Asset::anlageliste(int $year)` als neue Modell-Methode (single query mit Subqueries) + +--- + +## 2026-05-24 – Feature: AfA-Tabelle anteilig nach Anschaffungsmonat + +- Das erste Abschreibungsjahr wird anteilig nach Restmonaten berechnet: der Anschaffungsmonat zählt voll (Januar = 12 Monate, April = 9 Monate usw.) +- Das letzte Jahr erhält automatisch die verbleibenden Monate (12 − Monate des ersten Jahres); bei Januar-Kauf entfällt das extra letzte Jahr +- Die Cent-Option (`cent_im_ersten_jahr`) bleibt davon unberührt: der Cent-Rest geht weiterhin vollständig ins erste Jahr, ohne Aufteilung auf das letzte Jahr +- Sofortabschreibung (`nutzungsdauer = 0`) ist unverändert +- Neuer Test `41_AfaAnteiligTest` prüft April-, Oktober-, Juli-, Dezember- und Januar-Käufe sowie die Cent-Option mit anteiligem Jahr + +--- + ## 2026-05-22 – Feature: „Abrufen ab"-Auswahl für Direktverbindungen - Im Bearbeiten-Dialog jeder FinTS- bzw. API-Direktverbindung gibt es ein neues Dropdown **Abrufen ab** mit den Optionen *Ab letztem Abruf (00:00 Uhr)* (Default), *Letzte 7 Tage*, *Aktueller Monat (ab 01.)*, *Letzte 30 Tage*, *Ohne Datum (letzte 365 Tage)* diff --git a/docker-compose.yml b/docker-compose.yml index f3c2b43..848f2bf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: DB_PASS: steuerkram APP_SECRET: change-this-in-production APP_KEY: ${APP_KEY:-} + SIDEBAR_BG_COLOR: ${SIDEBAR_BG_COLOR:-#1e293b} OLLAMA_URL: ${OLLAMA_URL:-http://host-gateway:11434} OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen3-vl} extra_hosts: diff --git a/migrations/074_afa_booking_support.sql b/migrations/074_afa_booking_support.sql new file mode 100644 index 0000000..a7a8e26 --- /dev/null +++ b/migrations/074_afa_booking_support.sql @@ -0,0 +1,15 @@ +-- Migration 074: AFA-Buchungen direkt aus dem Abschreibungsplan anlegen +-- bookings.beleg_id wird nullable (keine Pflichtdokument für interne AFA-Buchungen) +-- bookings.afa_jahr zur Jahres-Zuordnung +-- buchungstyp um 'AfA' erweitert +-- asset_depreciation_schedule bekommt afa_booking_id + +ALTER TABLE bookings + MODIFY COLUMN beleg_id INT UNSIGNED NULL, + MODIFY COLUMN buchungstyp ENUM('Einnahme','Ausgabe','AfA') NOT NULL, + ADD COLUMN afa_jahr SMALLINT UNSIGNED NULL AFTER asset_id; + +ALTER TABLE asset_depreciation_schedule + ADD COLUMN IF NOT EXISTS afa_booking_id INT UNSIGNED NULL AFTER position, + ADD CONSTRAINT fk_afa_schedule_booking + FOREIGN KEY IF NOT EXISTS (afa_booking_id) REFERENCES bookings(id) ON DELETE SET NULL; diff --git a/public/index.php b/public/index.php index 70407de..3e54ec0 100644 --- a/public/index.php +++ b/public/index.php @@ -137,7 +137,10 @@ $router->get('/assets/depreciation-preview', fn($p) => (new AssetController())- $router->get('/assets/{id}/edit', fn($p) => (new AssetController())->edit($p)); $router->post('/assets/{id}', fn($p) => (new AssetController())->update($p)); $router->post('/assets/{id}/delete', fn($p) => (new AssetController())->destroy($p)); -$router->post('/assets/{id}/depreciation', fn($p) => (new AssetController())->saveDepreciation($p)); +$router->post('/assets/{id}/depreciation', fn($p) => (new AssetController())->saveDepreciation($p)); +$router->post('/assets/{id}/depreciation/{rowId}/book', fn($p) => (new AssetController())->bookAfa($p)); +$router->post('/assets/{id}/depreciation/{rowId}/reverse', fn($p) => (new AssetController())->reverseAfaBooking($p)); +$router->post('/assets/{id}/depreciation/{rowId}/delete', fn($p) => (new AssetController())->deleteAfaBooking($p)); // ── UStVA ──────────────────────────────────────────────────── $router->get('/tax/ustva', fn($p) => (new UstvaController())->index($p)); @@ -151,6 +154,8 @@ $router->get('/tax/euer', fn($p) => (new EuerController())->index($p)); $router->get('/tax/euer/{year}', fn($p) => (new EuerController())->show($p)); $router->get('/tax/euer/{year}/pdf', fn($p) => (new EuerController())->pdf($p)); $router->get('/tax/euer/{year}/konto/{konto_id}', fn($p) => (new EuerController())->kontoBookings($p)); +$router->get('/tax/euer/{year}/anlageliste/csv', fn($p) => (new EuerController())->anlageCsv($p)); +$router->get('/tax/euer/{year}/anlageliste/pdf', fn($p) => (new EuerController())->anlagePdf($p)); // ── Einstellungen ──────────────────────────────────────────── $router->get('/settings', fn($p) => (new SettingsController())->show($p)); diff --git a/src/Config.php b/src/Config.php index 56fa1dc..5b36f7e 100644 --- a/src/Config.php +++ b/src/Config.php @@ -15,7 +15,8 @@ class Config 'db_name' => $_ENV['DB_NAME'] ?? getenv('DB_NAME') ?: 'steuerkram', 'db_user' => $_ENV['DB_USER'] ?? getenv('DB_USER') ?: 'steuerkram', 'db_pass' => $_ENV['DB_PASS'] ?? getenv('DB_PASS') ?: 'steuerkram', - 'app_key' => $_ENV['APP_KEY'] ?? getenv('APP_KEY') ?: '', + 'app_key' => $_ENV['APP_KEY'] ?? getenv('APP_KEY') ?: '', + 'sidebar_bg_color' => $_ENV['SIDEBAR_BG_COLOR'] ?? getenv('SIDEBAR_BG_COLOR') ?: '#1e293b', 'upload_dir' => dirname(__DIR__) . '/public/static/uploads', 'base_url' => '', ]; diff --git a/src/Constants.php b/src/Constants.php index 1f5d4e8..8903e13 100644 --- a/src/Constants.php +++ b/src/Constants.php @@ -9,6 +9,10 @@ class Constants // Buchungstypen const BUCHUNGSTYP_EINNAHME = 'Einnahme'; const BUCHUNGSTYP_AUSGABE = 'Ausgabe'; + const BUCHUNGSTYP_AFA = 'AfA'; + + // AFA-Abschreibungskonten + const KONTO_AFA_GWG = '4855'; // Sofortabschreibung GWG (vorgewählt für Konto 0480) // Vorsteuer-Konten (SKR03) const KONTO_VST_19 = '1576'; diff --git a/src/Controllers/AssetController.php b/src/Controllers/AssetController.php index e2d40e8..8108981 100644 --- a/src/Controllers/AssetController.php +++ b/src/Controllers/AssetController.php @@ -4,8 +4,13 @@ declare(strict_types=1); namespace Steuerkram\Controllers; +use Steuerkram\Constants; +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; class AssetController extends BaseController @@ -77,17 +82,29 @@ class AssetController extends BaseController $asset = Asset::find((int)$params['id']); if (!$asset) { http_response_code(404); return; } - $scheduleRows = AssetDepreciation::findByAsset((int)$asset['id']); + $scheduleRows = AssetDepreciation::findByAsset((int)$asset['id']); + $abschreibungskonten = Account::abschreibungskonten(); + + $prevAfaKonto = \Steuerkram\Database::fetchOne( + 'SELECT kategorie_konto_id FROM bookings + WHERE asset_id = ? AND buchungstyp = ? AND is_reversal = 0 AND reversed_by IS NULL + ORDER BY id DESC LIMIT 1', + [(int)$asset['id'], Constants::BUCHUNGSTYP_AFA] + ); + $previousAfaKontoId = $prevAfaKonto ? (int)$prevAfaKonto['kategorie_konto_id'] : null; $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Wirtschaftsgut bearbeiten', 'content' => $this->renderView('assets/form', [ - 'asset' => $asset, - 'errors' => [], - 'action' => '/assets/' . $asset['id'], - 'anlagearten' => Asset::anlagearten(), - 'scheduleRows' => $scheduleRows, + 'asset' => $asset, + 'errors' => [], + 'action' => '/assets/' . $asset['id'], + 'anlagearten' => Asset::anlagearten(), + 'scheduleRows' => $scheduleRows, + 'abschreibungskonten' => $abschreibungskonten, + 'previousAfaKontoId' => $previousAfaKontoId, + 'testMode' => Setting::isTestMode(), ]), ]); } @@ -208,6 +225,217 @@ class AssetController extends BaseController echo json_encode(['ok' => true]); } + /** + * AJAX: AFA-Buchung für eine Zeile der Abschreibungstabelle anlegen + * POST /assets/{id}/depreciation/{rowId}/book + */ + public function bookAfa(array $params): void + { + header('Content-Type: application/json; charset=utf-8'); + + $assetId = (int)$params['id']; + $rowId = (int)$params['rowId']; + + $asset = Asset::find($assetId); + if (!$asset) { + http_response_code(404); + echo json_encode(['error' => 'Asset nicht gefunden']); + return; + } + + $scheduleRow = AssetDepreciation::findRow($rowId); + if (!$scheduleRow || (int)$scheduleRow['asset_id'] !== $assetId) { + http_response_code(404); + echo json_encode(['error' => 'Abschreibungszeile nicht gefunden']); + return; + } + + if (!empty($scheduleRow['afa_booking_id'])) { + http_response_code(409); + echo json_encode(['error' => 'Buchung bereits vorhanden']); + return; + } + + $body = json_decode(file_get_contents('php://input'), true); + $ausgabekontoId = isset($body['ausgabe_konto_id']) ? (int)$body['ausgabe_konto_id'] : 0; + if ($ausgabekontoId <= 0) { + http_response_code(400); + echo json_encode(['error' => 'Ausgabenkonto fehlt']); + return; + } + + if (!$asset['account_id']) { + http_response_code(400); + echo json_encode(['error' => 'Kein Anlagekonto am Wirtschaftsgut hinterlegt']); + return; + } + + $jahr = (int)$scheduleRow['jahr']; + $betrag = round((float)$scheduleRow['abschreibungsbetrag'], 2); + $datum = $jahr . '-12-31'; + $text = 'AfA ' . $asset['bezeichnung'] . ' ' . $jahr; + + $fy = FiscalYear::findOrCreateByYear($jahr); + + $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]); + } + + /** + * AJAX: AFA-Buchung stornieren (alle Modi) + * POST /assets/{id}/depreciation/{rowId}/reverse + */ + public function reverseAfaBooking(array $params): void + { + header('Content-Type: application/json; charset=utf-8'); + + $rowId = (int)$params['rowId']; + $row = AssetDepreciation::findRow($rowId); + + if (!$row || (int)$row['asset_id'] !== (int)$params['id']) { + http_response_code(404); + echo json_encode(['error' => 'Zeile nicht gefunden']); + return; + } + if (empty($row['afa_booking_id'])) { + http_response_code(409); + echo json_encode(['error' => 'Keine Buchung vorhanden']); + return; + } + + $bookingId = (int)$row['afa_booking_id']; + $orig = Booking::find($bookingId); + + if (!$orig) { + // Buchung bereits weg – Schedule-Zeile trotzdem bereinigen + AssetDepreciation::setBookingId($rowId, 0); + echo json_encode(['ok' => true]); + return; + } + if ($orig['is_reversal']) { + http_response_code(409); + echo json_encode(['error' => 'Stornobuchungen können nicht storniert werden']); + return; + } + if (!empty($orig['reversed_by'])) { + // Bereits storniert – nur Schedule bereinigen + AssetDepreciation::setBookingId($rowId, 0); + echo json_encode(['ok' => true]); + 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); + echo json_encode(['error' => $e->getMessage()]); + return; + } + + echo json_encode(['ok' => true, 'storno_nr' => $nr]); + } + + /** + * AJAX: AFA-Buchung löschen (nur Testmodus) + * POST /assets/{id}/depreciation/{rowId}/delete + */ + public function deleteAfaBooking(array $params): void + { + header('Content-Type: application/json; charset=utf-8'); + + if (!Setting::isTestMode()) { + http_response_code(403); + echo json_encode(['error' => 'Nur im Testmodus erlaubt']); + return; + } + + $rowId = (int)$params['rowId']; + $row = AssetDepreciation::findRow($rowId); + + if (!$row || (int)$row['asset_id'] !== (int)$params['id']) { + http_response_code(404); + echo json_encode(['error' => 'Zeile nicht gefunden']); + return; + } + if (empty($row['afa_booking_id'])) { + http_response_code(409); + echo json_encode(['error' => 'Keine Buchung vorhanden']); + return; + } + + $bookingId = (int)$row['afa_booking_id']; + \Steuerkram\Database::execute('DELETE FROM bookings WHERE id = ?', [$bookingId]); + AssetDepreciation::setBookingId($rowId, 0); + + echo json_encode(['ok' => true]); + } + // ── Private helpers ────────────────────────────────────────── private function extractPost(): array diff --git a/src/Controllers/BookingController.php b/src/Controllers/BookingController.php index a300927..e016081 100644 --- a/src/Controllers/BookingController.php +++ b/src/Controllers/BookingController.php @@ -29,15 +29,16 @@ class BookingController extends BaseController $view->render('layout/base.php', [ 'pageTitle' => 'Buchungsjournal', 'content' => $this->renderView('bookings/journal', [ - 'rows' => $result['rows'], - 'total' => $result['total'], - 'totalAll' => $totalAll, - 'pages' => $result['pages'], - 'page' => $page, - 'filter' => $filter, - 'years' => $years, - 'accounts' => $accounts, - 'testMode' => Setting::isTestMode(), + 'rows' => $result['rows'], + 'total' => $result['total'], + 'totalAll' => $totalAll, + 'pages' => $result['pages'], + 'page' => $page, + 'filter' => $filter, + 'years' => $years, + 'accounts' => $accounts, + 'buchungstypen' => [\Steuerkram\Constants::BUCHUNGSTYP_EINNAHME, \Steuerkram\Constants::BUCHUNGSTYP_AUSGABE, \Steuerkram\Constants::BUCHUNGSTYP_AFA], + 'testMode' => Setting::isTestMode(), ]), 'topbarActions' => 'CSV Export', ]); diff --git a/src/Controllers/EuerController.php b/src/Controllers/EuerController.php index 11d90c8..a4a6827 100644 --- a/src/Controllers/EuerController.php +++ b/src/Controllers/EuerController.php @@ -4,10 +4,12 @@ declare(strict_types=1); namespace Steuerkram\Controllers; +use Steuerkram\Models\Asset; use Steuerkram\Models\Setting; use Steuerkram\Models\FiscalYear; use Steuerkram\Services\EuerCalculator; use Steuerkram\Services\PdfEuerService; +use Steuerkram\Services\PdfAnlagelisteService; use Steuerkram\View; class EuerController extends BaseController @@ -30,14 +32,17 @@ class EuerController extends BaseController $calc = new EuerCalculator(); $euer = $calc->calculate($year); + $anlageliste = Asset::anlageliste($year); + $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => "EÜR {$year}", 'content' => $this->renderView('tax/euer_show', [ - 'euer' => $euer, - 'year' => $year, + 'euer' => $euer, + 'year' => $year, + 'anlageliste' => $anlageliste, ]), - 'topbarActions' => 'PDF herunterladen', + 'topbarActions' => '', ]); } @@ -70,5 +75,78 @@ class EuerController extends BaseController $svc = new PdfEuerService(); $svc->render($euer, $settings); } + + public function anlageCsv(array $params): void + { + $year = (int)$params['year']; + $anlageliste = Asset::anlageliste($year); + $settings = Setting::get(); + $firma = $settings['firma'] ?? ''; + + $fmtDate = function ($v) { + if (empty($v)) return ''; + $ts = strtotime($v); + return $ts ? date('d.m.Y', $ts) : $v; + }; + $fmtNum = fn($v) => number_format((float)$v, 2, ',', '.'); + + header('Content-Type: text/csv; charset=UTF-8'); + header('Content-Disposition: attachment; filename="Anlageliste-' . $year . '.csv"'); + header('Cache-Control: no-cache'); + + $out = fopen('php://output', 'w'); + fprintf($out, chr(0xEF) . chr(0xBB) . chr(0xBF)); // UTF-8 BOM für Excel + fputcsv($out, [ + 'Anlage-ID', 'Bezeichnung', 'Anschaffungsdatum', 'Anschaffungskosten', + 'Nutzungsdauer', 'AfA Vorjahre', 'Restbuchwert Vorjahr', + 'AfA ' . $year, 'Restbuchwert 31.12.' . $year, + ], ';'); + + $sumBasis = $sumAfaVj = $sumRbwVj = $sumAfaJ = $sumRbw = 0.0; + foreach ($anlageliste as $al) { + $sumBasis += $al['basis']; + $sumAfaVj += $al['afa_vorjahre']; + $sumRbwVj += $al['restbuchwert_vorjahr']; + $sumAfaJ += $al['afa_jahr']; + $sumRbw += $al['restbuchwert']; + + $nd = $al['nutzungsdauer'] !== null + ? ($al['nutzungsdauer'] == 0 ? 'sofort' : $al['nutzungsdauer']) + : ''; + + fputcsv($out, [ + $al['anlage_nr'] ?: $al['lfd_nr'], + $al['bezeichnung'], + $fmtDate($al['anschaffungsdatum']), + $fmtNum($al['basis']), + $nd, + $fmtNum($al['afa_vorjahre']), + $fmtNum($al['restbuchwert_vorjahr']), + $fmtNum($al['afa_jahr']), + $fmtNum($al['restbuchwert']), + ], ';'); + } + + fputcsv($out, [ + 'Summe', '', '', + $fmtNum($sumBasis), '', + $fmtNum($sumAfaVj), + $fmtNum($sumRbwVj), + $fmtNum($sumAfaJ), + $fmtNum($sumRbw), + ], ';'); + + fclose($out); + } + + public function anlagePdf(array $params): void + { + $year = (int)$params['year']; + $anlageliste = Asset::anlageliste($year); + $settings = Setting::get(); + + $svc = new PdfAnlagelisteService(); + $svc->render($year, $anlageliste, $settings); + } } diff --git a/src/Models/Account.php b/src/Models/Account.php index 7ca1796..2665778 100644 --- a/src/Models/Account.php +++ b/src/Models/Account.php @@ -41,6 +41,15 @@ class Account return Database::fetchOne('SELECT * FROM accounts WHERE kontonummer = ?', [$nr]); } + public static function abschreibungskonten(): array + { + return Database::fetchAll( + "SELECT id, kontonummer, bezeichnung FROM accounts + WHERE kontonummer BETWEEN '4815' AND '4899' AND aktiv = 1 + ORDER BY kontonummer ASC" + ); + } + public static function search(string $q, int $limit = 20): array { return Database::fetchAll( diff --git a/src/Models/Asset.php b/src/Models/Asset.php index 7d65e8f..69bae38 100644 --- a/src/Models/Asset.php +++ b/src/Models/Asset.php @@ -8,6 +8,58 @@ use Steuerkram\Database; class Asset { + /** + * Anlageliste für die EÜR: alle Assets, die im gegebenen Jahr aktiv sind, + * mit kumulierten AfA-Buchungen aus Vorjahren und dem laufenden Jahr. + */ + public static function anlageliste(int $year): array + { + $rows = Database::fetchAll( + "SELECT + a.*, + ac.kontonummer AS anlageart_kontonummer, + ac.bezeichnung AS anlageart_bezeichnung, + COALESCE(bk_prev.afa_vorjahre, 0) AS afa_vorjahre, + COALESCE(bk_curr.afa_jahr, 0) AS afa_jahr + FROM assets a + LEFT JOIN accounts ac ON a.account_id = ac.id + LEFT JOIN ( + SELECT asset_id, SUM(betrag_netto) AS afa_vorjahre + FROM bookings + WHERE buchungstyp = 'AfA' AND afa_jahr < ? + GROUP BY asset_id + ) bk_prev ON bk_prev.asset_id = a.id + LEFT JOIN ( + SELECT asset_id, SUM(betrag_netto) AS afa_jahr + FROM bookings + WHERE buchungstyp = 'AfA' AND afa_jahr = ? + GROUP BY asset_id + ) bk_curr ON bk_curr.asset_id = a.id + WHERE YEAR(a.anschaffungsdatum) <= ? + ORDER BY a.lfd_nr ASC, a.id ASC", + [$year, $year, $year] + ); + + $result = []; + foreach ($rows as $row) { + $basis = (float)$row['anschaffungskosten'] + (float)$row['zugaenge'] - (float)$row['abgaenge']; + $afaVorjahre = (float)$row['afa_vorjahre']; + $afaJahr = (float)$row['afa_jahr']; + $restbuchwertVorjahr = round($basis - $afaVorjahre, 2); + $restbuchwert = round($restbuchwertVorjahr - $afaJahr, 2); + + $result[] = array_merge($row, [ + 'basis' => $basis, + 'afa_vorjahre' => $afaVorjahre, + 'afa_jahr' => $afaJahr, + 'restbuchwert_vorjahr' => $restbuchwertVorjahr, + 'restbuchwert' => $restbuchwert, + ]); + } + + return $result; + } + public static function all(?int $fiscalYearId = null): array { $where = '1=1'; diff --git a/src/Models/AssetDepreciation.php b/src/Models/AssetDepreciation.php index a96da53..65c8dc2 100644 --- a/src/Models/AssetDepreciation.php +++ b/src/Models/AssetDepreciation.php @@ -11,7 +11,7 @@ class AssetDepreciation public static function findByAsset(int $assetId): array { return Database::fetchAll( - 'SELECT id, asset_id, jahr, abschreibungsbetrag, restwert, position + 'SELECT id, asset_id, jahr, abschreibungsbetrag, restwert, position, afa_booking_id FROM asset_depreciation_schedule WHERE asset_id = ? ORDER BY position ASC', @@ -19,6 +19,23 @@ class AssetDepreciation ); } + public static function findRow(int $id): array|false + { + return Database::fetchOne( + 'SELECT id, asset_id, jahr, abschreibungsbetrag, restwert, position, afa_booking_id + FROM asset_depreciation_schedule WHERE id = ?', + [$id] + ); + } + + public static function setBookingId(int $rowId, ?int $bookingId): void + { + Database::execute( + 'UPDATE asset_depreciation_schedule SET afa_booking_id = ? WHERE id = ?', + [$bookingId ?: null, $rowId] + ); + } + public static function calculate(array $asset): array { $anschaffungskosten = (float)($asset['anschaffungskosten'] ?? 0); @@ -48,24 +65,43 @@ class AssetDepreciation return $rows; } + // Anteilige Monate: Anschaffungsmonat zählt voll + $anschaffungsmonat = !empty($asset['anschaffungsdatum']) + ? (int)date('n', strtotime($asset['anschaffungsdatum'])) + : 1; + $monateErstesJahr = 13 - $anschaffungsmonat; // Jan=12, Apr=9, Dez=1 + $monateLetztesJahr = 12 - $monateErstesJahr; // Jan=0, Apr=3, Dez=11 + $hatLetztesJahr = $monateLetztesJahr > 0; + $totalJahre = $nutzungsdauer + ($hatLetztesJahr ? 1 : 0); + if ($centImErstenJahr) { - // Folgejahre: auf volle Euro gerundet (abgerundet) - $regelJahr = floor($basis / $nutzungsdauer); - $erstesJahr = round($basis - $regelJahr * ($nutzungsdauer - 1), 2); + // Mitteljahre: volle Euro (abgerundet); Cent-Rest geht komplett ins erste Jahr + $regelJahr = floor($basis / $nutzungsdauer); + $centRest = round($basis - $regelJahr * $nutzungsdauer, 2); + $erstesJahrAfa = round($regelJahr * $monateErstesJahr / 12, 2) + $centRest; } else { - $regelJahr = round($basis / $nutzungsdauer, 2); - $erstesJahr = round($basis - $regelJahr * ($nutzungsdauer - 1), 2); + $regelJahr = round($basis / $nutzungsdauer, 2); + $erstesJahrAfa = round((float)$regelJahr * $monateErstesJahr / 12, 2); } $restwert = $basis; - for ($i = 0; $i < $nutzungsdauer; $i++) { - $afa = ($i === 0) ? $erstesJahr : (float)$regelJahr; + for ($i = 0; $i < $totalJahre; $i++) { + $istLetztesJahr = ($i === $totalJahre - 1); + + if ($i === 0) { + $afa = $erstesJahrAfa; + } elseif ($istLetztesJahr) { + // Letztes Jahr absorbiert Rundungsdifferenz, Restwert wird exakt 0 + $afa = round($restwert, 2); + } else { + $afa = (float)$regelJahr; + } + $restwert = round($restwert - $afa, 2); - // Letztes Jahr: Restwert auf 0 normalisieren (Rundungsfehler) - if ($i === $nutzungsdauer - 1) { - $afa = round($afa + $restwert, 2); + if ($istLetztesJahr) { $restwert = 0.00; } + $rows[] = [ 'jahr' => $startjahr + $i, 'abschreibungsbetrag' => $afa, @@ -79,18 +115,32 @@ class AssetDepreciation public static function saveForAsset(int $assetId, array $rows): void { + // Bestehende afa_booking_id-Zuordnungen per Jahr sichern + $existing = Database::fetchAll( + 'SELECT jahr, afa_booking_id FROM asset_depreciation_schedule + WHERE asset_id = ? AND afa_booking_id IS NOT NULL', + [$assetId] + ); + $bookingByJahr = []; + foreach ($existing as $e) { + $bookingByJahr[(int)$e['jahr']] = (int)$e['afa_booking_id']; + } + self::deleteByAsset($assetId); foreach ($rows as $row) { + $jahr = (int)$row['jahr']; + $bookingId = $bookingByJahr[$jahr] ?? null; Database::execute( 'INSERT INTO asset_depreciation_schedule - (asset_id, jahr, abschreibungsbetrag, restwert, position) - VALUES (?, ?, ?, ?, ?)', + (asset_id, jahr, abschreibungsbetrag, restwert, position, afa_booking_id) + VALUES (?, ?, ?, ?, ?, ?)', [ $assetId, - (int)$row['jahr'], + $jahr, round((float)$row['abschreibungsbetrag'], 2), round((float)$row['restwert'], 2), (int)$row['position'], + $bookingId, ] ); } diff --git a/src/Models/Booking.php b/src/Models/Booking.php index 59683d3..b9f9987 100644 --- a/src/Models/Booking.php +++ b/src/Models/Booking.php @@ -49,6 +49,10 @@ class Booking $params[] = '%' . $filter['search'] . '%'; $params[] = '%' . $filter['search'] . '%'; } + if (!empty($filter['buchungstyp'])) { + $where[] = 'bk.buchungstyp = ?'; + $params[] = $filter['buchungstyp']; + } $offset = ($page - 1) * $perPage; $sql = 'SELECT bk.*, @@ -58,11 +62,11 @@ class Booking a.anlage_nr, a.bezeichnung as anlage_bezeichnung, s.name as supplier_name FROM bookings bk - JOIN accounts k ON bk.kategorie_konto_id = k.id - JOIN accounts z ON bk.zahlungskonto_id = z.id - JOIN belege bl ON bk.beleg_id = bl.id - JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id - LEFT JOIN assets a ON bk.asset_id = a.id + JOIN accounts k ON bk.kategorie_konto_id = k.id + JOIN accounts z ON bk.zahlungskonto_id = z.id + LEFT JOIN belege bl ON bk.beleg_id = bl.id + JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id + LEFT JOIN assets a ON bk.asset_id = a.id LEFT JOIN suppliers s ON bl.supplier_id = s.id WHERE ' . implode(' AND ', $where) . ' ORDER BY bk.buchungs_datum DESC, bk.id DESC @@ -74,8 +78,8 @@ class Booking $rows = Database::fetchAll($sql, $params); $countSql = 'SELECT COUNT(*) as cnt FROM bookings bk - JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id - JOIN belege bl ON bk.beleg_id = bl.id + JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id + LEFT JOIN belege bl ON bk.beleg_id = bl.id LEFT JOIN suppliers s ON bl.supplier_id = s.id WHERE ' . implode(' AND ', $where); $countParams = array_slice($params, 0, -2); diff --git a/src/Services/PdfAnlagelisteService.php b/src/Services/PdfAnlagelisteService.php new file mode 100644 index 0000000..31b1ed9 --- /dev/null +++ b/src/Services/PdfAnlagelisteService.php @@ -0,0 +1,135 @@ + 'utf-8', + 'format' => 'A4-L', + 'margin_top' => 15, + 'margin_bottom' => 15, + 'margin_left' => 10, + 'margin_right' => 10, + ]); + + $mpdf->SetTitle('Anlageliste ' . $year); + $mpdf->SetAuthor($settings['firma'] ?? ''); + $mpdf->WriteHTML($this->buildCss(), \Mpdf\HTMLParserMode::HEADER_CSS); + $mpdf->WriteHTML($this->buildHtml($year, $anlageliste, $settings), \Mpdf\HTMLParserMode::HTML_BODY); + $mpdf->Output('Anlageliste-' . $year . '.pdf', 'D'); + } + + private function buildHtml(int $year, array $anlageliste, array $settings): string + { + $h = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8'); + $fmt = fn($v) => number_format((float)$v, 2, ',', '.') . ' €'; + $fmtDate = function ($v) { + if (empty($v)) return '–'; + $ts = strtotime($v); + return $ts ? date('d.m.Y', $ts) : $v; + }; + + $firma = $h($settings['firma'] ?? ''); + $rows = ''; + $sumBasis = $sumAfaVj = $sumRbwVj = $sumAfaJ = $sumRbw = 0.0; + + foreach ($anlageliste as $al) { + $sumBasis += $al['basis']; + $sumAfaVj += $al['afa_vorjahre']; + $sumRbwVj += $al['restbuchwert_vorjahr']; + $sumAfaJ += $al['afa_jahr']; + $sumRbw += $al['restbuchwert']; + + $nd = $al['nutzungsdauer'] !== null + ? ($al['nutzungsdauer'] == 0 ? 'sofort' : $al['nutzungsdauer'] . ' J.') + : '–'; + + $rows .= '' + . '' . $h($al['anlage_nr'] ?: $al['lfd_nr']) . '' + . '' . $h($al['bezeichnung']) . '' + . '' . $h($fmtDate($al['anschaffungsdatum'])) . '' + . '' . $fmt($al['basis']) . '' + . '' . $h($nd) . '' + . '' . $fmt($al['afa_vorjahre']) . '' + . '' . $fmt($al['restbuchwert_vorjahr']) . '' + . '' . $fmt($al['afa_jahr']) . '' + . '' . $fmt($al['restbuchwert']) . '' + . ''; + } + + $sumRow = '' + . 'Summe' + . '' . $fmt($sumBasis) . '' + . '' + . '' . $fmt($sumAfaVj) . '' + . '' . $fmt($sumRbwVj) . '' + . '' . $fmt($sumAfaJ) . '' + . '' . $fmt($sumRbw) . '' + . ''; + + return <<Anlageliste {$year} +

{$firma} · Geschäftsjahr {$year}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + {$rows} + + + {$sumRow} + +
Anlage-IDBezeichnungAnschaffungs-
datum
Anschaffungs-
kosten
NDAfA
Vorjahre
Restbuchwert
Vorjahr
AfA
{$year}
Restbuchwert
31.12.{$year}
+

Erstellt am {$h(date('d.m.Y H:i'))} · Steuerkram

+HTML; + } + + private function buildCss(): string + { + return << + + diff --git a/src/Views/bookings/journal.php b/src/Views/bookings/journal.php index 34d3c91..9147857 100644 --- a/src/Views/bookings/journal.php +++ b/src/Views/bookings/journal.php @@ -25,6 +25,12 @@ + ✕ @@ -65,12 +71,23 @@ - - -
+ + + +
+ + + – - + 'badge-green', + 'Ausgabe' => 'badge-red', + default => 'badge-gray', + }; + ?> +
diff --git a/src/Views/layout/base.php b/src/Views/layout/base.php index 584b7ce..a717116 100644 --- a/src/Views/layout/base.php +++ b/src/Views/layout/base.php @@ -5,6 +5,7 @@ <?= \Steuerkram\View::h($pageTitle ?? 'Steuerkram') ?> – Steuerkram + diff --git a/src/Views/tax/euer_show.php b/src/Views/tax/euer_show.php index e5ca3be..d97d9e1 100644 --- a/src/Views/tax/euer_show.php +++ b/src/Views/tax/euer_show.php @@ -7,9 +7,7 @@ $fmt = fn($v) => View::formatMoney($v);
EÜR Geschäftsjahr
-
- PDF herunterladen -
+ PDF
@@ -129,3 +127,75 @@ $fmt = fn($v) => View::formatMoney($v);
Für wurden noch keine Buchungen erfasst.
+ + +
+
+
Anlagegüter
+
+ Herunterladen: + CSV + PDF +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Anlage-IDBezeichnungAnschaffungsdatumAnschaffungskostenNutzungsdauerAfA VorjahreRestbuchwert VorjahrAfA Restbuchwert 31.12.
+ +
Summe
+
+
+ diff --git a/src/version.txt b/src/version.txt index 6743bd8..6163983 100644 --- a/src/version.txt +++ b/src/version.txt @@ -1 +1 @@ -1-260523 +1-260524 diff --git a/tests/26_AbschreibungstabelleTest.php b/tests/26_AbschreibungstabelleTest.php index b6453ff..b1c517a 100644 --- a/tests/26_AbschreibungstabelleTest.php +++ b/tests/26_AbschreibungstabelleTest.php @@ -77,7 +77,8 @@ class AbschreibungstabelleTest extends TestCase 'anschaffungsdatum' => '2022-06-01', ]); - $this->assertEquals(5, count($rows), 'AfA mit Zu-/Abgängen: 5 Zeilen'); + // Juni = 7 Monate erstes Jahr, 5 Monate letztes Jahr → 5+1=6 Zeilen + $this->assertEquals(6, count($rows), 'AfA mit Zu-/Abgängen: 6 Zeilen (anteiliges Jahr)'); $basis = 5000.00 + 500.00 - 200.00; $sumAfa = array_sum(array_column($rows, 'abschreibungsbetrag')); $this->assertEquals($basis, round($sumAfa, 2), 'Summe AfA = Basis mit Zu-/Abgängen'); diff --git a/tests/41_AfaAnteiligTest.php b/tests/41_AfaAnteiligTest.php new file mode 100644 index 0000000..c7f4c33 --- /dev/null +++ b/tests/41_AfaAnteiligTest.php @@ -0,0 +1,116 @@ + 1200.00, + 'zugaenge' => 0, + 'abgaenge' => 0, + 'nutzungsdauer' => 3, + 'cent_im_ersten_jahr'=> false, + 'anschaffungsdatum' => '2024-04-01', + ]); + + $this->assertEquals(4, count($rows), 'April-Kauf 3 Jahre: 4 Zeilen (inkl. anteiliges letztes Jahr)'); + $this->assertEquals(300.00, $rows[0]['abschreibungsbetrag'], 'April: erstes Jahr 9/12 = 300,00'); + $this->assertEquals(400.00, $rows[1]['abschreibungsbetrag'], 'April: Jahr 2 voll = 400,00'); + $this->assertEquals(400.00, $rows[2]['abschreibungsbetrag'], 'April: Jahr 3 voll = 400,00'); + $this->assertEquals(100.00, $rows[3]['abschreibungsbetrag'], 'April: letztes Jahr 3/12 = 100,00'); + $this->assertEquals(0.00, $rows[3]['restwert'], 'April: Restwert am Ende = 0'); + $sumAfa = array_sum(array_column($rows, 'abschreibungsbetrag')); + $this->assertEquals(1200.00, round($sumAfa, 2), 'April: Summe = Basis'); + + // ── Oktober-Kauf (Monat 10): 3 Monate erstes Jahr, 9 Monate letztes Jahr ── + $rows = AssetDepreciation::calculate([ + 'anschaffungskosten' => 1200.00, + 'zugaenge' => 0, + 'abgaenge' => 0, + 'nutzungsdauer' => 2, + 'cent_im_ersten_jahr'=> false, + 'anschaffungsdatum' => '2023-10-15', + ]); + + $this->assertEquals(3, count($rows), 'Oktober-Kauf 2 Jahre: 3 Zeilen'); + $this->assertEquals(150.00, $rows[0]['abschreibungsbetrag'], 'Oktober: erstes Jahr 3/12 = 150,00'); + $this->assertEquals(600.00, $rows[1]['abschreibungsbetrag'], 'Oktober: Jahr 2 voll = 600,00'); + $this->assertEquals(450.00, $rows[2]['abschreibungsbetrag'], 'Oktober: letztes Jahr 9/12 = 450,00'); + $this->assertEquals(0.00, $rows[2]['restwert'], 'Oktober: Restwert = 0'); + + // ── Januar-Kauf (Monat 1): 12 Monate = kein extra letztes Jahr ── + $rows = AssetDepreciation::calculate([ + 'anschaffungskosten' => 1200.00, + 'zugaenge' => 0, + 'abgaenge' => 0, + 'nutzungsdauer' => 3, + 'cent_im_ersten_jahr'=> false, + 'anschaffungsdatum' => '2024-01-10', + ]); + + $this->assertEquals(3, count($rows), 'Januar-Kauf: weiterhin exakt nutzungsdauer Zeilen'); + $sumAfa = array_sum(array_column($rows, 'abschreibungsbetrag')); + $this->assertEquals(1200.00, round($sumAfa, 2), 'Januar: Summe = Basis'); + + // ── April-Kauf mit Cent-Option ─────────────────────────────────────────── + // basis=1001.40, nutzungsdauer=3, April + // regelJahr=floor(333.8)=333, centRest=1001.40-999=2.40 + // erstesJahr = round(333*9/12,2) + 2.40 = 249.75 + 2.40 = 252.15 + // Jahr 2+3: 333 (volle Euro) + // letztes Jahr: 1001.40 - 252.15 - 333 - 333 = 83.25 + $rows = AssetDepreciation::calculate([ + 'anschaffungskosten' => 1001.40, + 'zugaenge' => 0, + 'abgaenge' => 0, + 'nutzungsdauer' => 3, + 'cent_im_ersten_jahr'=> true, + 'anschaffungsdatum' => '2024-04-01', + ]); + + $this->assertEquals(4, count($rows), 'April Cent-Option 3 Jahre: 4 Zeilen'); + $this->assertEquals(252.15, $rows[0]['abschreibungsbetrag'], 'April Cent: erstes Jahr 249,75 + 2,40 Cent = 252,15'); + $this->assertEquals(333.00, $rows[1]['abschreibungsbetrag'], 'April Cent: Jahr 2 voll = 333,00 (volle Euro)'); + $this->assertEquals(333.00, $rows[2]['abschreibungsbetrag'], 'April Cent: Jahr 3 voll = 333,00 (volle Euro)'); + $this->assertEquals(83.25, $rows[3]['abschreibungsbetrag'], 'April Cent: letztes Jahr 3/12 von 333 = 83,25'); + $sumAfa = array_sum(array_column($rows, 'abschreibungsbetrag')); + $this->assertEquals(1001.40, round($sumAfa, 2), 'April Cent: Summe = Basis'); + + // ── Dezember-Kauf (Monat 12): 1 Monat erstes Jahr, 11 Monate letztes Jahr ── + $rows = AssetDepreciation::calculate([ + 'anschaffungskosten' => 1200.00, + 'zugaenge' => 0, + 'abgaenge' => 0, + 'nutzungsdauer' => 3, + 'cent_im_ersten_jahr'=> false, + 'anschaffungsdatum' => '2024-12-01', + ]); + + $this->assertEquals(4, count($rows), 'Dezember-Kauf 3 Jahre: 4 Zeilen'); + $this->assertEquals(round(400 * 1 / 12, 2), $rows[0]['abschreibungsbetrag'], 'Dezember: erstes Jahr 1/12'); + $this->assertEquals(0.00, $rows[3]['restwert'], 'Dezember: Restwert am Ende = 0'); + $sumAfa = array_sum(array_column($rows, 'abschreibungsbetrag')); + $this->assertEquals(1200.00, round($sumAfa, 2), 'Dezember: Summe = Basis'); + + // ── Startjahr des letzten Jahres korrekt ──────────────────────────────── + $rows = AssetDepreciation::calculate([ + 'anschaffungskosten' => 600.00, + 'zugaenge' => 0, + 'abgaenge' => 0, + 'nutzungsdauer' => 2, + 'cent_im_ersten_jahr'=> false, + 'anschaffungsdatum' => '2023-07-01', + ]); + // Juli = 6 Monate erstes Jahr, 6 Monate letztes Jahr → 3 Zeilen + $this->assertEquals(3, count($rows), 'Juli-Kauf 2 Jahre: 3 Zeilen'); + $this->assertEquals(2023, $rows[0]['jahr'], 'Startjahr erste Zeile = 2023'); + $this->assertEquals(2024, $rows[1]['jahr'], 'Startjahr zweite Zeile = 2024'); + $this->assertEquals(2025, $rows[2]['jahr'], 'Startjahr letzte Zeile = 2025'); + } +}