diff --git a/Changes.md b/Changes.md index 5c5092c..3550cd2 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,22 @@ --- +## 2026-04-26 – Feature #57: Lineare Abschreibungstabelle für Anlagegüter + +### Hinzugefügt +- `migrations/035_assets_abschreibung_flags.sql`: Neue Spalten `automatisch_berechnet` und `cent_im_ersten_jahr` in `assets` +- `migrations/036_asset_depreciation_schedule.sql`: Neue Tabelle `asset_depreciation_schedule` (Jahr, Abschreibungsbetrag, Restwert) +- `src/Models/AssetDepreciation.php`: Berechnung (linear, Sofortabschreibung, Cent-Option), Persistenz +- `tests/26_AbschreibungstabelleTest.php`: 22 Tests für alle AfA-Szenarien + +### Geändert +- `src/Models/Asset.php`: `create()` und `update()` speichern `automatisch_berechnet` und `cent_im_ersten_jahr` +- `src/Controllers/AssetController.php`: Abschreibungstabelle wird nach Speichern automatisch generiert; neue Endpunkte `/assets/depreciation-preview` (AJAX-Vorschau) und `/assets/{id}/depreciation` (manuelle Speicherung) +- `src/Views/assets/form.php`: Nutzungsdauer-Dropdown (sofort, 1–10, 12, 13, 15, 20, 25, 50 Jahre + individuell), Cent-Option, „AfA-Tabelle"-Button, Modal mit Auto/Manuell-Toggle und editierbarer Tabelle +- `public/index.php`: Neue Routen für Depreciation-Preview und -Speicherung + +--- + ## 2026-04-26 – Fix UStVA: Kennziffern auf offizielle ELSTER-Definitionen korrigiert ### Geändert diff --git a/docs/anlagevermögen.md b/docs/anlagevermögen.md index e196242..6ab83ba 100644 --- a/docs/anlagevermögen.md +++ b/docs/anlagevermögen.md @@ -65,5 +65,36 @@ Folgende Felder können nachträglich angepasst werden: - Anschaffungsdatum und -kosten - Zugänge und Abgänge - Abschreibung laufendes Jahr -- Nutzungsdauer (in Jahren) +- Nutzungsdauer (Dropdown: sofort, 1–10, 12, 13, 15, 20, 25, 50 Jahre oder individuell) +- Centbeträge im ersten Jahr abschreiben (Option) - Notizen + +--- + +## Abschreibungstabelle (AfA) + +Für jedes gespeicherte Wirtschaftsgut wird automatisch eine lineare AfA-Tabelle erzeugt und persistent gespeichert (`asset_depreciation_schedule`). Die Tabelle enthält pro Jahr: **Jahr**, **Abschreibungsbetrag**, **Restwert**. + +### Berechnungsregeln + +| Modus | Verhalten | +|---|---| +| **Nutzungsdauer = sofort** | Vollständige Abschreibung im Anschaffungsjahr, Restwert = 0 | +| **Nutzungsdauer = N Jahre** | Gleichmäßige lineare Verteilung; letztes Jahr gleicht Rundungsdifferenzen aus | +| **Cent-Option aktiv** | Folgejahre auf volle Euro abgerundet; erstes Jahr übernimmt alle Centbeträge; Restwert nach Jahr 1 ist volle Euro | + +Basis der Berechnung: `Anschaffungskosten + Zugänge − Abgänge`. + +### Auto- und Manuell-Modus + +Die Tabelle besitzt ein Flag `automatisch_berechnet`: +- **Automatisch (Standard):** Bei jeder Parameteränderung (Nutzungsdauer, Cent-Option, Kosten) wird die Tabelle neu berechnet. +- **Manuell:** Durch Klick auf „Manuell bearbeiten" im Modal wird das Flag deaktiviert. Parameteränderungen zeigen eine Bestätigungsabfrage, bevor neu berechnet wird. + +### Bedienung + +1. Wirtschaftsgut speichern → Tabelle wird automatisch berechnet. +2. Button **„AfA-Tabelle"** öffnet den modalen Dialog. +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. diff --git a/migrations/035_assets_abschreibung_flags.sql b/migrations/035_assets_abschreibung_flags.sql new file mode 100644 index 0000000..e8da606 --- /dev/null +++ b/migrations/035_assets_abschreibung_flags.sql @@ -0,0 +1,8 @@ +-- Migration 035: Abschreibungsflags für Wirtschaftsgüter +ALTER TABLE assets + ADD COLUMN IF NOT EXISTS automatisch_berechnet TINYINT(1) NOT NULL DEFAULT 1 + COMMENT 'true = Abschreibungstabelle automatisch berechnet, false = manuell bearbeitet' + AFTER nutzungsdauer, + ADD COLUMN IF NOT EXISTS cent_im_ersten_jahr TINYINT(1) NOT NULL DEFAULT 0 + COMMENT 'Centbeträge vollständig im ersten Jahr abschreiben; Folgejahre auf volle Euro' + AFTER automatisch_berechnet; diff --git a/migrations/036_asset_depreciation_schedule.sql b/migrations/036_asset_depreciation_schedule.sql new file mode 100644 index 0000000..d0d07c5 --- /dev/null +++ b/migrations/036_asset_depreciation_schedule.sql @@ -0,0 +1,13 @@ +-- Migration 036: Abschreibungstabelle je Wirtschaftsgut +CREATE TABLE IF NOT EXISTS asset_depreciation_schedule ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + asset_id INT UNSIGNED NOT NULL, + jahr SMALLINT UNSIGNED NOT NULL COMMENT 'Kalenderjahr der Abschreibung', + abschreibungsbetrag DECIMAL(12,2) NOT NULL DEFAULT 0.00, + restwert DECIMAL(12,2) NOT NULL DEFAULT 0.00, + position SMALLINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Reihenfolge (0-basiert)', + PRIMARY KEY (id), + KEY idx_ads_asset_id (asset_id), + UNIQUE KEY uq_ads_asset_jahr (asset_id, jahr) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Lineare Abschreibungstabelle je Wirtschaftsgut'; diff --git a/public/index.php b/public/index.php index fef6b00..f8f0dfe 100644 --- a/public/index.php +++ b/public/index.php @@ -100,12 +100,14 @@ $router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLi $router->post('/invoices/{id}/lines/{line_id}/delete', fn($p) => (new InvoiceController())->deleteLine($p)); // ── Anlagevermögen ─────────────────────────────────────────── -$router->get('/assets', fn($p) => (new AssetController())->index($p)); -$router->get('/assets/new', fn($p) => (new AssetController())->create($p)); -$router->post('/assets', fn($p) => (new AssetController())->store($p)); -$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->get('/assets', fn($p) => (new AssetController())->index($p)); +$router->get('/assets/new', fn($p) => (new AssetController())->create($p)); +$router->post('/assets', fn($p) => (new AssetController())->store($p)); +$router->get('/assets/depreciation-preview', fn($p) => (new AssetController())->depreciationPreview($p)); +$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)); // ── UStVA ──────────────────────────────────────────────────── $router->get('/tax/ustva', fn($p) => (new UstvaController())->index($p)); diff --git a/src/Controllers/AssetController.php b/src/Controllers/AssetController.php index dded27f..a743974 100644 --- a/src/Controllers/AssetController.php +++ b/src/Controllers/AssetController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Steuerkram\Controllers; use Steuerkram\Models\Asset; +use Steuerkram\Models\AssetDepreciation; use Steuerkram\View; class AssetController @@ -29,10 +30,11 @@ class AssetController $view->render('layout/base.php', [ 'pageTitle' => 'Wirtschaftsgut anlegen', 'content' => $this->renderView('assets/form', [ - 'asset' => [], - 'errors' => [], - 'action' => '/assets', - 'anlagearten' => Asset::anlagearten(), + 'asset' => [], + 'errors' => [], + 'action' => '/assets', + 'anlagearten' => Asset::anlagearten(), + 'scheduleRows' => [], ]), ]); } @@ -47,17 +49,23 @@ class AssetController $view->render('layout/base.php', [ 'pageTitle' => 'Wirtschaftsgut anlegen', 'content' => $this->renderView('assets/form', [ - 'asset' => $data, - 'errors' => $errors, - 'action' => '/assets', - 'anlagearten' => Asset::anlagearten(), + 'asset' => $data, + 'errors' => $errors, + 'action' => '/assets', + 'anlagearten' => Asset::anlagearten(), + 'scheduleRows' => [], ]), ]); return; } - Asset::create($data); - View::redirect('/assets', 'Wirtschaftsgut erfolgreich angelegt.'); + $id = Asset::create($data); + $asset = Asset::find($id); + if ($asset && (int)($asset['automatisch_berechnet'] ?? 1) === 1) { + AssetDepreciation::generateAndSave($asset); + } + + View::redirect('/assets/' . $id . '/edit', 'Wirtschaftsgut erfolgreich angelegt.'); } public function edit(array $params): void @@ -65,14 +73,17 @@ class AssetController $asset = Asset::find((int)$params['id']); if (!$asset) { http_response_code(404); return; } + $scheduleRows = AssetDepreciation::findByAsset((int)$asset['id']); + $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(), + 'asset' => $asset, + 'errors' => [], + 'action' => '/assets/' . $asset['id'], + 'anlagearten' => Asset::anlagearten(), + 'scheduleRows' => $scheduleRows, ]), ]); } @@ -87,46 +98,128 @@ class AssetController $errors = $this->validate($data); if ($errors) { + $scheduleRows = AssetDepreciation::findByAsset($id); $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Wirtschaftsgut bearbeiten', 'content' => $this->renderView('assets/form', [ - 'asset' => array_merge($asset, $data), - 'errors' => $errors, - 'action' => '/assets/' . $id, - 'anlagearten' => Asset::anlagearten(), + 'asset' => array_merge($asset, $data), + 'errors' => $errors, + 'action' => '/assets/' . $id, + 'anlagearten' => Asset::anlagearten(), + 'scheduleRows' => $scheduleRows, ]), ]); return; } + // Wenn Parameter geändert und Tabelle war manuell: Flag beibehalten + $wasAutomatisch = (bool)(int)($asset['automatisch_berechnet'] ?? 1); Asset::update($id, $data); - View::redirect('/assets', 'Wirtschaftsgut aktualisiert.'); + + $updatedAsset = Asset::find($id); + $isAutomatisch = (bool)(int)($updatedAsset['automatisch_berechnet'] ?? 1); + + if ($isAutomatisch) { + AssetDepreciation::generateAndSave($updatedAsset); + } + + View::redirect('/assets/' . $id . '/edit', 'Wirtschaftsgut aktualisiert.'); } public function destroy(array $params): void { - if (!Asset::delete((int)$params['id'])) { + $id = (int)$params['id']; + AssetDepreciation::deleteByAsset($id); + if (!Asset::delete($id)) { View::redirect('/assets', 'Wirtschaftsgut konnte nicht gelöscht werden.', 'error'); return; } View::redirect('/assets', 'Wirtschaftsgut gelöscht.'); } + /** + * AJAX: Abschreibungstabelle vorschau berechnen (ohne DB-Speicherung) + * GET /assets/depreciation-preview?anschaffungskosten=...&nutzungsdauer=...&... + */ + public function depreciationPreview(array $params): void + { + header('Content-Type: application/json; charset=utf-8'); + + $asset = [ + 'anschaffungskosten' => (float)($_GET['anschaffungskosten'] ?? 0), + 'zugaenge' => (float)($_GET['zugaenge'] ?? 0), + 'abgaenge' => (float)($_GET['abgaenge'] ?? 0), + 'nutzungsdauer' => (int)($_GET['nutzungsdauer'] ?? 0), + 'cent_im_ersten_jahr'=> !empty($_GET['cent_im_ersten_jahr']) ? 1 : 0, + 'anschaffungsdatum' => $_GET['anschaffungsdatum'] ?? date('Y-01-01'), + ]; + + $rows = AssetDepreciation::calculate($asset); + echo json_encode(['rows' => $rows]); + } + + /** + * AJAX: Manuell bearbeitete Abschreibungstabelle speichern + * POST /assets/{id}/depreciation + */ + public function saveDepreciation(array $params): void + { + header('Content-Type: application/json; charset=utf-8'); + + $id = (int)$params['id']; + $asset = Asset::find($id); + if (!$asset) { + http_response_code(404); + echo json_encode(['error' => 'Asset nicht gefunden']); + return; + } + + $body = json_decode(file_get_contents('php://input'), true); + if (!is_array($body) || !isset($body['rows'])) { + http_response_code(400); + echo json_encode(['error' => 'Ungültige Daten']); + return; + } + + $rows = []; + foreach ($body['rows'] as $i => $row) { + $rows[] = [ + 'jahr' => (int)($row['jahr'] ?? 0), + 'abschreibungsbetrag' => (float)str_replace(',', '.', $row['abschreibungsbetrag'] ?? 0), + 'restwert' => (float)str_replace(',', '.', $row['restwert'] ?? 0), + 'position' => $i, + ]; + } + + AssetDepreciation::saveForAsset($id, $rows); + + // Flag setzen: wenn Rows manuell geändert + $automatisch = !empty($body['automatisch']) ? 1 : 0; + \Steuerkram\Database::execute( + 'UPDATE assets SET automatisch_berechnet = ? WHERE id = ?', + [$automatisch, $id] + ); + + echo json_encode(['ok' => true]); + } + // ── Private helpers ────────────────────────────────────────── private function extractPost(): array { return [ - 'bezeichnung' => trim($_POST['bezeichnung'] ?? ''), - 'account_id' => trim($_POST['account_id'] ?? ''), - 'anschaffungsdatum' => trim($_POST['anschaffungsdatum'] ?? ''), - 'anschaffungskosten' => $_POST['anschaffungskosten'] ?? '0', - 'zugaenge' => $_POST['zugaenge'] ?? '0', - 'abgaenge' => $_POST['abgaenge'] ?? '0', - 'abschreibung_jahr' => $_POST['abschreibung_jahr'] ?? '0', - 'nutzungsdauer' => trim($_POST['nutzungsdauer'] ?? ''), - 'notizen' => trim($_POST['notizen'] ?? ''), + 'bezeichnung' => trim($_POST['bezeichnung'] ?? ''), + 'account_id' => trim($_POST['account_id'] ?? ''), + 'anschaffungsdatum' => trim($_POST['anschaffungsdatum'] ?? ''), + 'anschaffungskosten' => $_POST['anschaffungskosten'] ?? '0', + 'zugaenge' => $_POST['zugaenge'] ?? '0', + 'abgaenge' => $_POST['abgaenge'] ?? '0', + 'abschreibung_jahr' => $_POST['abschreibung_jahr'] ?? '0', + 'nutzungsdauer' => trim($_POST['nutzungsdauer'] ?? ''), + 'automatisch_berechnet'=> isset($_POST['automatisch_berechnet']) ? (int)(bool)$_POST['automatisch_berechnet'] : 1, + 'cent_im_ersten_jahr' => isset($_POST['cent_im_ersten_jahr']) ? 1 : 0, + 'notizen' => trim($_POST['notizen'] ?? ''), ]; } diff --git a/src/Models/Asset.php b/src/Models/Asset.php index 6307cef..7d65e8f 100644 --- a/src/Models/Asset.php +++ b/src/Models/Asset.php @@ -85,8 +85,9 @@ class Asset Database::execute( 'INSERT INTO assets (lfd_nr, anlage_nr, bezeichnung, account_id, anschaffungsdatum, anschaffungskosten, - zugaenge, abgaenge, abschreibung_jahr, nutzungsdauer, notizen, fiscal_year_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + zugaenge, abgaenge, abschreibung_jahr, nutzungsdauer, automatisch_berechnet, + cent_im_ersten_jahr, notizen, fiscal_year_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ $data['lfd_nr'] ?? self::nextLfdNr(), $anlageNr, @@ -98,6 +99,8 @@ class Asset $data['abgaenge'] ?? 0, $data['abschreibung_jahr'] ?? 0, isset($data['nutzungsdauer']) && $data['nutzungsdauer'] !== '' ? (int)$data['nutzungsdauer'] : null, + isset($data['automatisch_berechnet']) ? (int)(bool)$data['automatisch_berechnet'] : 1, + isset($data['cent_im_ersten_jahr']) ? (int)(bool)$data['cent_im_ersten_jahr'] : 0, $data['notizen'] ?? null, $fyId, ] @@ -116,17 +119,19 @@ class Asset Database::execute( 'UPDATE assets SET - bezeichnung = ?, - account_id = ?, - anschaffungsdatum = ?, - anschaffungskosten = ?, - zugaenge = ?, - abgaenge = ?, - abschreibung_jahr = ?, - nutzungsdauer = ?, - notizen = ?, - fiscal_year_id = COALESCE(?, fiscal_year_id), - updated_at = NOW() + bezeichnung = ?, + account_id = ?, + anschaffungsdatum = ?, + anschaffungskosten = ?, + zugaenge = ?, + abgaenge = ?, + abschreibung_jahr = ?, + nutzungsdauer = ?, + automatisch_berechnet = ?, + cent_im_ersten_jahr = ?, + notizen = ?, + fiscal_year_id = COALESCE(?, fiscal_year_id), + updated_at = NOW() WHERE id = ?', [ $data['bezeichnung'], @@ -137,6 +142,8 @@ class Asset $data['abgaenge'] ?? 0, $data['abschreibung_jahr'] ?? 0, isset($data['nutzungsdauer']) && $data['nutzungsdauer'] !== '' ? (int)$data['nutzungsdauer'] : null, + isset($data['automatisch_berechnet']) ? (int)(bool)$data['automatisch_berechnet'] : 1, + isset($data['cent_im_ersten_jahr']) ? (int)(bool)$data['cent_im_ersten_jahr'] : 0, $data['notizen'] ?? null, $fyId, $id, diff --git a/src/Models/AssetDepreciation.php b/src/Models/AssetDepreciation.php new file mode 100644 index 0000000..a96da53 --- /dev/null +++ b/src/Models/AssetDepreciation.php @@ -0,0 +1,116 @@ + $startjahr, + 'abschreibungsbetrag' => round($basis, 2), + 'restwert' => 0.00, + 'position' => 0, + ]; + return $rows; + } + + if ($centImErstenJahr) { + // Folgejahre: auf volle Euro gerundet (abgerundet) + $regelJahr = floor($basis / $nutzungsdauer); + $erstesJahr = round($basis - $regelJahr * ($nutzungsdauer - 1), 2); + } else { + $regelJahr = round($basis / $nutzungsdauer, 2); + $erstesJahr = round($basis - $regelJahr * ($nutzungsdauer - 1), 2); + } + + $restwert = $basis; + for ($i = 0; $i < $nutzungsdauer; $i++) { + $afa = ($i === 0) ? $erstesJahr : (float)$regelJahr; + $restwert = round($restwert - $afa, 2); + // Letztes Jahr: Restwert auf 0 normalisieren (Rundungsfehler) + if ($i === $nutzungsdauer - 1) { + $afa = round($afa + $restwert, 2); + $restwert = 0.00; + } + $rows[] = [ + 'jahr' => $startjahr + $i, + 'abschreibungsbetrag' => $afa, + 'restwert' => $restwert, + 'position' => $i, + ]; + } + + return $rows; + } + + public static function saveForAsset(int $assetId, array $rows): void + { + self::deleteByAsset($assetId); + foreach ($rows as $row) { + Database::execute( + 'INSERT INTO asset_depreciation_schedule + (asset_id, jahr, abschreibungsbetrag, restwert, position) + VALUES (?, ?, ?, ?, ?)', + [ + $assetId, + (int)$row['jahr'], + round((float)$row['abschreibungsbetrag'], 2), + round((float)$row['restwert'], 2), + (int)$row['position'], + ] + ); + } + } + + public static function deleteByAsset(int $assetId): void + { + Database::execute( + 'DELETE FROM asset_depreciation_schedule WHERE asset_id = ?', + [$assetId] + ); + } + + public static function generateAndSave(array $asset): void + { + $id = (int)($asset['id'] ?? 0); + if ($id <= 0) { + return; + } + $rows = self::calculate($asset); + self::saveForAsset($id, $rows); + } +} diff --git a/src/Views/assets/form.php b/src/Views/assets/form.php index e5b5aea..9a115d0 100644 --- a/src/Views/assets/form.php +++ b/src/Views/assets/form.php @@ -1,6 +1,17 @@ - View::h($v); ?> + View::h($v); -