Anlagevermögen überarbeitet, AFA

This commit is contained in:
fds2610-bot 2026-05-24 12:56:21 +02:00
parent 1e2c8cf498
commit b637b7b5ab
23 changed files with 1138 additions and 63 deletions

View file

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

View file

@ -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)*

View file

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

View file

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

View file

@ -138,6 +138,9 @@ $router->get('/assets/{id}/edit', fn($p) => (new AssetController())-
$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/{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));

View file

@ -16,6 +16,7 @@ class Config
'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') ?: '',
'sidebar_bg_color' => $_ENV['SIDEBAR_BG_COLOR'] ?? getenv('SIDEBAR_BG_COLOR') ?: '#1e293b',
'upload_dir' => dirname(__DIR__) . '/public/static/uploads',
'base_url' => '',
];

View file

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

View file

@ -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
@ -78,6 +83,15 @@ class AssetController extends BaseController
if (!$asset) { http_response_code(404); return; }
$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', [
@ -88,6 +102,9 @@ class AssetController extends BaseController
'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

View file

@ -37,6 +37,7 @@ class BookingController extends BaseController
'filter' => $filter,
'years' => $years,
'accounts' => $accounts,
'buchungstypen' => [\Steuerkram\Constants::BUCHUNGSTYP_EINNAHME, \Steuerkram\Constants::BUCHUNGSTYP_AUSGABE, \Steuerkram\Constants::BUCHUNGSTYP_AFA],
'testMode' => Setting::isTestMode(),
]),
'topbarActions' => '<a href="/journal/export?'.http_build_query($filter).'" class="btn btn-secondary">CSV Export</a>',

View file

@ -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,
'anlageliste' => $anlageliste,
]),
'topbarActions' => '<a href="/tax/euer/' . $year . '/pdf" class="btn btn-secondary">PDF herunterladen</a>',
'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);
}
}

View file

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

View file

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

View file

@ -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)
// Mitteljahre: volle Euro (abgerundet); Cent-Rest geht komplett ins erste Jahr
$regelJahr = floor($basis / $nutzungsdauer);
$erstesJahr = round($basis - $regelJahr * ($nutzungsdauer - 1), 2);
$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);
$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,
]
);
}

View file

@ -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.*,
@ -60,7 +64,7 @@ class Booking
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
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
@ -75,7 +79,7 @@ class Booking
$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
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);

View file

@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace Steuerkram\Services;
use Mpdf\Mpdf;
class PdfAnlagelisteService
{
public function render(int $year, array $anlageliste, array $settings): void
{
$mpdf = new Mpdf([
'mode' => '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 .= '<tr>'
. '<td class="nr">' . $h($al['anlage_nr'] ?: $al['lfd_nr']) . '</td>'
. '<td class="bez">' . $h($al['bezeichnung']) . '</td>'
. '<td class="dt">' . $h($fmtDate($al['anschaffungsdatum'])) . '</td>'
. '<td class="val">' . $fmt($al['basis']) . '</td>'
. '<td class="nd">' . $h($nd) . '</td>'
. '<td class="val">' . $fmt($al['afa_vorjahre']) . '</td>'
. '<td class="val">' . $fmt($al['restbuchwert_vorjahr']) . '</td>'
. '<td class="val">' . $fmt($al['afa_jahr']) . '</td>'
. '<td class="val">' . $fmt($al['restbuchwert']) . '</td>'
. '</tr>';
}
$sumRow = '<tr class="total-row">'
. '<td colspan="3"><strong>Summe</strong></td>'
. '<td class="val"><strong>' . $fmt($sumBasis) . '</strong></td>'
. '<td></td>'
. '<td class="val"><strong>' . $fmt($sumAfaVj) . '</strong></td>'
. '<td class="val"><strong>' . $fmt($sumRbwVj) . '</strong></td>'
. '<td class="val"><strong>' . $fmt($sumAfaJ) . '</strong></td>'
. '<td class="val"><strong>' . $fmt($sumRbw) . '</strong></td>'
. '</tr>';
return <<<HTML
<h1>Anlageliste {$year}</h1>
<p class="subtitle">{$firma} · Geschäftsjahr {$year}</p>
<table class="anlage-table">
<colgroup>
<col style="width:18mm">
<col style="width:60mm">
<col style="width:22mm">
<col style="width:30mm">
<col style="width:16mm">
<col style="width:30mm">
<col style="width:33mm">
<col style="width:26mm">
<col style="width:33mm">
</colgroup>
<thead>
<tr>
<th>Anlage-ID</th>
<th>Bezeichnung</th>
<th>Anschaffungs-<br>datum</th>
<th>Anschaffungs-<br>kosten</th>
<th>ND</th>
<th>AfA<br>Vorjahre</th>
<th>Restbuchwert<br>Vorjahr</th>
<th>AfA<br>{$year}</th>
<th>Restbuchwert<br>31.12.{$year}</th>
</tr>
</thead>
<tbody>
{$rows}
</tbody>
<tfoot>
{$sumRow}
</tfoot>
</table>
<p class="generated-note">Erstellt am {$h(date('d.m.Y H:i'))} · Steuerkram</p>
HTML;
}
private function buildCss(): string
{
return <<<CSS
body { font-family: DejaVu Sans, sans-serif; font-size: 8pt; }
h1 { font-size: 13pt; margin-bottom: 1mm; }
.subtitle { font-size: 8pt; color: #555; margin-bottom: 5mm; }
.anlage-table { width: 100%; border-collapse: collapse; font-size: 7.5pt; table-layout: fixed; }
.anlage-table thead th { background: #1e293b; color: #fff; padding: 2mm; text-align: left; vertical-align: bottom; }
.anlage-table tbody tr:nth-child(even) { background: #f8f8f8; }
.anlage-table tbody td { padding: 1.2mm 2mm; border-bottom: 0.2pt solid #e0e0e0; vertical-align: middle; }
.nr { color: #555; font-size: 7pt; }
.dt { color: #555; font-size: 7pt; }
.nd { text-align: right; color: #555; }
.val { text-align: right; font-family: DejaVu Sans Mono, monospace; }
.total-row td { padding: 2mm; background: #e8ecf0; border-top: 1pt solid #555; font-size: 8pt; }
.generated-note { font-size: 6.5pt; color: #888; margin-top: 5mm; }
CSS;
}
}

View file

@ -146,9 +146,23 @@ $autoFlag = isset($asset['automatisch_berechnet']) ? (bool)(int)$asset['automa
</div>
<?php if ($isEdit): ?>
<?php
$gwgKontonummer = '0480';
$isGwg = ($asset['anlageart_kontonummer'] ?? '') === $gwgKontonummer;
$abschreibungskonten = $abschreibungskonten ?? [];
$defaultKontoNr = \Steuerkram\Constants::KONTO_AFA_GWG;
$defaultKontoId = 0;
foreach ($abschreibungskonten as $k) {
if ($k['kontonummer'] === $defaultKontoNr) { $defaultKontoId = $k['id']; break; }
}
if (!$isGwg) { $defaultKontoId = 0; } // nur bei GWG vorauswählen
// Früher genutztes Konto hat Vorrang vor dem GWG-Standard
if (!empty($previousAfaKontoId)) { $defaultKontoId = (int)$previousAfaKontoId; }
$testMode = $testMode ?? false;
?>
<!-- ── AfA-Tabellen-Modal ─────────────────────────────────── -->
<div class="modal-overlay" id="modal-afa">
<div class="modal" style="width:min(680px,95vw);max-height:85vh;overflow-y:auto">
<div class="modal" style="width:min(760px,95vw);max-height:85vh;overflow-y:auto">
<div class="modal-title">Abschreibungstabelle</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.75rem;flex-wrap:wrap;gap:.5rem">
@ -174,19 +188,21 @@ $autoFlag = isset($asset['automatisch_berechnet']) ? (bool)(int)$asset['automa
<thead>
<tr>
<th style="text-align:left;padding:.5rem .75rem;border-bottom:2px solid var(--color-border)">Jahr</th>
<th style="text-align:right;padding:.5rem .75rem;border-bottom:2px solid var(--color-border)">Abschreibung (€)</th>
<th style="text-align:right;padding:.5rem .75rem;border-bottom:2px solid var(--color-border)">Restwert (€)</th>
<th style="text-align:right;padding:.5rem .75rem;border-bottom:2px solid var(--color-border)">Abschreibung&nbsp;(€)</th>
<th style="text-align:right;padding:.5rem .75rem;border-bottom:2px solid var(--color-border)">Restwert&nbsp;(€)</th>
<th style="text-align:center;padding:.5rem .75rem;border-bottom:2px solid var(--color-border);width:2.5rem" title="Buchungsstatus">Buchung</th>
</tr>
</thead>
<tbody id="afa-table-body">
<?php if (empty($scheduleRows)): ?>
<tr id="afa-empty-row">
<td colspan="3" class="text-center text-muted" style="padding:1.5rem">
<td colspan="4" class="text-center text-muted" style="padding:1.5rem">
Noch keine Abschreibungstabelle vorhanden. Klicke „Neu berechnen".
</td>
</tr>
<?php else: ?>
<?php foreach ($scheduleRows as $row): ?>
<?php $gebucht = !empty($row['afa_booking_id']); ?>
<tr>
<td style="padding:.4rem .75rem;border-bottom:1px solid var(--color-border)">
<?= $h($row['jahr']) ?>
@ -197,6 +213,60 @@ $autoFlag = isset($asset['automatisch_berechnet']) ? (bool)(int)$asset['automa
<td class="td-num afa-restwert" style="padding:.4rem .75rem;border-bottom:1px solid var(--color-border)">
<?= number_format((float)$row['restwert'], 2, ',', '.') ?>
</td>
<td style="padding:.4rem .75rem;border-bottom:1px solid var(--color-border);text-align:center">
<?php if ($gebucht): ?>
<div style="display:inline-flex;gap:.2rem;align-items:center;justify-content:center">
<span title="Buchung vorhanden" style="color:#22c55e;display:inline-flex;align-items:center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
</span>
<button type="button"
class="afa-reverse-btn"
data-row-id="<?= (int)$row['id'] ?>"
title="AFA-Buchung stornieren"
style="background:none;border:none;cursor:pointer;color:var(--color-muted);padding:.2rem;line-height:1;display:inline-flex;align-items:center">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7v6h6"/>
<path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/>
</svg>
</button>
<?php if ($testMode): ?>
<button type="button"
class="afa-delete-btn"
data-row-id="<?= (int)$row['id'] ?>"
title="AFA-Buchung löschen (Testmodus)"
style="background:none;border:none;cursor:pointer;color:var(--color-danger);padding:.2rem;line-height:1;display:inline-flex;align-items:center">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3,6 5,6 21,6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6M14 11v6"/>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</button>
<?php endif; ?>
</div>
<?php else: ?>
<button type="button"
class="afa-book-btn"
data-row-id="<?= (int)$row['id'] ?>"
data-jahr="<?= (int)$row['jahr'] ?>"
data-betrag="<?= number_format((float)$row['abschreibungsbetrag'], 2, ',', '.') ?>"
data-default-konto="<?= $defaultKontoId ?>"
title="AFA-Buchung für <?= (int)$row['jahr'] ?> anlegen"
style="background:none;border:none;cursor:pointer;color:var(--color-muted);padding:.2rem;line-height:1;display:inline-flex;align-items:center;justify-content:center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="14" height="14" rx="2"/>
<polyline points="17 8 21 12 17 16"/>
<line x1="10" y1="12" x2="21" y2="12"/>
</svg>
</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
@ -210,6 +280,28 @@ $autoFlag = isset($asset['automatisch_berechnet']) ? (bool)(int)$asset['automa
</div>
</div>
</div>
<!-- ── AFA-Buchungs-Dialog ──────────────────────────────────── -->
<div class="modal-overlay" id="modal-afa-book">
<div class="modal" style="width:min(460px,95vw)">
<div class="modal-title">AFA-Buchung anlegen</div>
<p class="text-sm text-muted" style="margin-bottom:1rem">
Jahr <strong id="afabook-jahr"></strong> &middot; Betrag <strong id="afabook-betrag"></strong>&nbsp;€
</p>
<div class="form-group">
<label for="afabook-konto">Abschreibungskonto (Aufwand)</label>
<select id="afabook-konto">
<?php foreach ($abschreibungskonten as $k): ?>
<option value="<?= (int)$k['id'] ?>"><?= $h($k['kontonummer']) ?> – <?= $h($k['bezeichnung']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button type="button" class="btn btn-primary" id="afabook-confirm-btn">Buchung anlegen</button>
</div>
</div>
</div>
<?php endif; ?>
<script>
@ -368,7 +460,7 @@ $autoFlag = isset($asset['automatisch_berechnet']) ? (bool)(int)$asset['automa
function renderTable(rows) {
if (!tbody) return;
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="3" class="text-center text-muted" style="padding:1.5rem">Keine Daten.</td></tr>';
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted" style="padding:1.5rem">Keine Daten.</td></tr>';
return;
}
const isManual = manualToggle?.checked ?? false;
@ -383,6 +475,9 @@ $autoFlag = isset($asset['automatisch_berechnet']) ? (bool)(int)$asset['automa
style="padding:.4rem .75rem;border-bottom:1px solid var(--color-border)${isManual ? ';background:var(--color-input-bg,#fff);outline:1px solid var(--color-border);border-radius:3px' : ''}">
${fmtNum(r.restwert)}
</td>
<td style="padding:.4rem .75rem;border-bottom:1px solid var(--color-border);text-align:center;width:2.5rem">
<span title="Tabelle neu speichern, um Buchungen anzulegen" style="color:var(--color-muted);font-size:.7rem">–</span>
</td>
</tr>`).join('');
}
@ -425,5 +520,161 @@ $autoFlag = isset($asset['automatisch_berechnet']) ? (bool)(int)$asset['automa
// Init: Neu-Berechnen-Button nur bei manueller Tabelle zeigen
syncToggle();
// ── AFA-Buchungs-Dialog ──────────────────────────────────────
let afabookRowId = 0;
const afabookJahr = document.getElementById('afabook-jahr');
const afabookBetrag = document.getElementById('afabook-betrag');
const afabookKonto = document.getElementById('afabook-konto');
const afabookModal = document.getElementById('modal-afa-book');
const afabookBtn = document.getElementById('afabook-confirm-btn');
document.addEventListener('click', (e) => {
const btn = e.target.closest('.afa-book-btn');
if (!btn) return;
afabookRowId = parseInt(btn.dataset.rowId, 10);
if (afabookJahr) afabookJahr.textContent = btn.dataset.jahr;
if (afabookBetrag) afabookBetrag.textContent = btn.dataset.betrag;
const defaultId = parseInt(btn.dataset.defaultKonto, 10);
if (afabookKonto && defaultId) {
afabookKonto.value = defaultId;
}
if (afabookModal) afabookModal.classList.add('open');
});
const isTestMode = <?= $testMode ? 'true' : 'false' ?>;
function bookedCellHtml(rowId, buchungsNr) {
const title = buchungsNr ? `Buchung vorhanden (${buchungsNr})` : 'Buchung vorhanden';
const deleteBtn = isTestMode
? `<button type="button" class="afa-delete-btn" data-row-id="${rowId}"
title="AFA-Buchung löschen (Testmodus)"
style="background:none;border:none;cursor:pointer;color:var(--color-danger);padding:.2rem;line-height:1;display:inline-flex;align-items:center">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3,6 5,6 21,6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6M14 11v6"/>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</button>`
: '';
return `<div style="display:inline-flex;gap:.2rem;align-items:center;justify-content:center">
<span title="${title}" style="color:#22c55e;display:inline-flex;align-items:center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
</span>
<button type="button" class="afa-reverse-btn" data-row-id="${rowId}"
title="AFA-Buchung stornieren"
style="background:none;border:none;cursor:pointer;color:var(--color-muted);padding:.2rem;line-height:1;display:inline-flex;align-items:center">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7v6h6"/>
<path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/>
</svg>
</button>
${deleteBtn}
</div>`;
}
// SVG für "noch nicht gebucht" (Pfeil aus Box)
function unbookedCellHtml(rowId) {
return `<button type="button"
class="afa-book-btn"
data-row-id="${rowId}"
data-jahr=""
data-betrag=""
data-default-konto="${<?= $defaultKontoId ?>}"
title="AFA-Buchung anlegen"
style="background:none;border:none;cursor:pointer;color:var(--color-muted);padding:.2rem;line-height:1;display:inline-flex;align-items:center;justify-content:center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="14" height="14" rx="2"/>
<polyline points="17 8 21 12 17 16"/>
<line x1="10" y1="12" x2="21" y2="12"/>
</svg>
</button>`;
}
// Storno: Buchung stornieren, danach Haken → Pfeil-Button
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.afa-reverse-btn');
if (!btn) return;
if (!confirm('AFA-Buchung wirklich stornieren? Es wird eine Gegenbuchung angelegt.')) return;
const rowId = parseInt(btn.dataset.rowId, 10);
try {
const res = await fetch(`/assets/${assetId}/depreciation/${rowId}/reverse`, { method: 'POST' });
const data = await res.json();
if (data.ok) {
const cell = btn.closest('td');
cell.innerHTML = unbookedCellHtml(rowId);
// Jahr/Betrag aus Tabellenzeile ermitteln und in data-* setzen
const tr = cell.closest('tr');
const tds = tr.querySelectorAll('td');
const newBtn = cell.querySelector('.afa-book-btn');
if (newBtn && tds.length >= 2) {
newBtn.dataset.jahr = tds[0].textContent.trim();
newBtn.dataset.betrag = tds[1].textContent.trim();
}
} else {
alert('Fehler: ' + (data.error ?? 'Unbekannt'));
}
} catch (_) { alert('Netzwerkfehler beim Stornieren.'); }
});
// Löschen (Testmodus): Buchung löschen, danach Haken → Pfeil-Button
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.afa-delete-btn');
if (!btn) return;
if (!confirm('AFA-Buchung wirklich löschen? (Nur im Testmodus – nicht GoBD-konform!)')) return;
const rowId = parseInt(btn.dataset.rowId, 10);
try {
const res = await fetch(`/assets/${assetId}/depreciation/${rowId}/delete`, { method: 'POST' });
const data = await res.json();
if (data.ok) {
const cell = btn.closest('td');
cell.innerHTML = unbookedCellHtml(rowId);
const tr = cell.closest('tr');
const tds = tr.querySelectorAll('td');
const newBtn = cell.querySelector('.afa-book-btn');
if (newBtn && tds.length >= 2) {
newBtn.dataset.jahr = tds[0].textContent.trim();
newBtn.dataset.betrag = tds[1].textContent.trim();
}
} else {
alert('Fehler: ' + (data.error ?? 'Unbekannt'));
}
} catch (_) { alert('Netzwerkfehler beim Löschen.'); }
});
if (afabookBtn) {
afabookBtn.addEventListener('click', async () => {
const kontoId = afabookKonto ? parseInt(afabookKonto.value, 10) : 0;
if (!kontoId) { alert('Bitte ein Abschreibungskonto wählen.'); return; }
try {
const res = await fetch(`/assets/${assetId}/depreciation/${afabookRowId}/book`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ausgabe_konto_id: kontoId }),
});
const data = await res.json();
if (data.ok) {
if (afabookModal) afabookModal.classList.remove('open');
const bookBtn = tbody?.querySelector(`.afa-book-btn[data-row-id="${afabookRowId}"]`);
if (bookBtn) {
const cell = bookBtn.closest('td');
cell.innerHTML = bookedCellHtml(afabookRowId, data.buchungs_nr);
}
} else {
alert('Fehler: ' + (data.error ?? 'Unbekannt'));
}
} catch (_) {
alert('Netzwerkfehler beim Buchen.');
}
});
}
})();
</script>

View file

@ -10,9 +10,6 @@
<th>Anlageart</th>
<th>Wirtschaftsgut</th>
<th class="td-num">Anschaffungskosten</th>
<th class="td-num">Zugänge</th>
<th class="td-num">Abgänge</th>
<th class="td-num">Abschreibungen</th>
<th style="width:6rem"></th>
</tr>
</thead>
@ -30,9 +27,6 @@
</td>
<td><?= $h($a['bezeichnung']) ?></td>
<td class="td-num"><?= number_format((float)$a['anschaffungskosten'], 2, ',', '.') ?> €</td>
<td class="td-num"><?= number_format((float)$a['zugaenge'], 2, ',', '.') ?> €</td>
<td class="td-num"><?= number_format((float)$a['abgaenge'], 2, ',', '.') ?> €</td>
<td class="td-num"><?= number_format((float)$a['abschreibung_jahr'], 2, ',', '.') ?> €</td>
<td>
<div style="display:flex;gap:.4rem;justify-content:flex-end">
<a href="/assets/<?= $h($a['id']) ?>/edit"
@ -64,12 +58,12 @@
<?php endforeach; ?>
<?php if (empty($assets)): ?>
<tr>
<td colspan="9" class="text-center text-muted" style="padding:2rem">
<td colspan="6" class="text-center text-muted" style="padding:2rem">
Noch keine Wirtschaftsgüter angelegt.
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>

View file

@ -25,6 +25,12 @@
</option>
<?php endforeach; ?>
</select>
<select name="buchungstyp" style="width:100px">
<option value="">Alle Typen</option>
<?php foreach ($buchungstypen as $bt): ?>
<option value="<?= $h($bt) ?>"<?= ($filter['buchungstyp'] ?? '') === $bt ? ' selected' : '' ?>><?= $h($bt) ?></option>
<?php endforeach; ?>
</select>
<button type="submit" class="btn btn-secondary">Filtern</button>
<a href="/journal" class="btn btn-secondary">✕</a>
</form>
@ -65,12 +71,23 @@
</td>
<td class="td-muted"><?= View::formatDate($bk['buchungs_datum']) ?></td>
<td>
<?php if (!empty($bk['beleg_id'])): ?>
<a href="/belege/<?= $h($bk['beleg_id']) ?>" class="td-mono text-sm"><?= $h($bk['beleg_nr']) ?></a>
<?php if (!empty($bk['supplier_name'])): ?>
<br><span class="td-muted"><?= $h(mb_strimwidth($bk['supplier_name'], 0, 25, '…')) ?></span>
<?php endif; ?>
<?php else: ?>
<span class="text-muted">–</span>
<?php endif; ?>
</td>
<td><span class="badge <?= $bk['buchungstyp'] === 'Einnahme' ? 'badge-green' : 'badge-red' ?>"><?= $h($bk['buchungstyp']) ?></span></td>
<?php
$badgeClass = match($bk['buchungstyp']) {
'Einnahme' => 'badge-green',
'Ausgabe' => 'badge-red',
default => 'badge-gray',
};
?>
<td><span class="badge <?= $badgeClass ?>"><?= $h($bk['buchungstyp']) ?></span></td>
<td class="td-mono text-sm">
<?= $h($bk['kategorie_nr']) ?><br>
<span class="td-muted"><?= $h(mb_strimwidth($bk['kategorie_name'], 0, 25, '…')) ?></span>

View file

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= \Steuerkram\View::h($pageTitle ?? 'Steuerkram') ?> – Steuerkram</title>
<link rel="stylesheet" href="/static/css/app.css?v=<?= defined('ASSET_VERSION') ? ASSET_VERSION : 'dev' ?>">
<style>:root { --color-nav-bg: <?= \Steuerkram\View::h(\Steuerkram\Config::get('sidebar_bg_color', '#1e293b')) ?>; }</style>
<link rel="icon" type="image/png" href="/static/favicon/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/static/favicon/favicon.svg" />
<link rel="shortcut icon" href="/static/favicon/favicon.ico" />

View file

@ -7,9 +7,7 @@ $fmt = fn($v) => View::formatMoney($v);
<div class="card">
<div class="card-header">
<div class="card-title">EÜR Geschäftsjahr <?= $h($year) ?></div>
<div style="display:flex;gap:.5rem">
<a href="/tax/euer/<?= $h($year) ?>/pdf" class="btn btn-primary">PDF herunterladen</a>
</div>
<a href="/tax/euer/<?= $h($year) ?>/pdf" class="btn btn-primary btn-sm">PDF</a>
</div>
<div class="table-wrap">
@ -129,3 +127,75 @@ $fmt = fn($v) => View::formatMoney($v);
<?php if (empty($euer['einnahmen']) && empty($euer['ausgaben']) && empty($euer['nicht_abzugsfaehig'])): ?>
<div class="flash flash-info mt-2">Für <?= $h($year) ?> wurden noch keine Buchungen erfasst.</div>
<?php endif; ?>
<?php if (!empty($anlageliste)): ?>
<div class="card mt-2">
<div class="card-header">
<div class="card-title">Anlagegüter <?= $h($year) ?></div>
<div style="display:flex;align-items:center;gap:.5rem;margin-left:auto">
<span style="color:var(--color-muted);font-size:.85rem">Herunterladen:</span>
<a href="/tax/euer/<?= $h($year) ?>/anlageliste/csv" class="btn btn-sm btn-primary">CSV</a>
<a href="/tax/euer/<?= $h($year) ?>/anlageliste/pdf" class="btn btn-sm btn-primary">PDF</a>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Anlage-ID</th>
<th>Bezeichnung</th>
<th>Anschaffungsdatum</th>
<th class="td-right">Anschaffungskosten</th>
<th class="td-right">Nutzungsdauer</th>
<th class="td-right">AfA Vorjahre</th>
<th class="td-right">Restbuchwert Vorjahr</th>
<th class="td-right">AfA <?= $h($year) ?></th>
<th class="td-right">Restbuchwert 31.12.<?= $h($year) ?></th>
</tr>
</thead>
<tbody>
<?php
$sumBasis = 0.0;
$sumAfaVorjahre = 0.0;
$sumRbwVorjahr = 0.0;
$sumAfaJahr = 0.0;
$sumRbw = 0.0;
foreach ($anlageliste as $al):
$sumBasis += $al['basis'];
$sumAfaVorjahre += $al['afa_vorjahre'];
$sumRbwVorjahr += $al['restbuchwert_vorjahr'];
$sumAfaJahr += $al['afa_jahr'];
$sumRbw += $al['restbuchwert'];
$nd = $al['nutzungsdauer'] !== null ? $al['nutzungsdauer'] . ($al['nutzungsdauer'] == 1 ? ' J.' : ' J.') : '–';
if ($al['nutzungsdauer'] === '0' || $al['nutzungsdauer'] === 0) $nd = 'sofort';
?>
<tr>
<td class="td-mono text-sm">
<a href="/assets/<?= (int)$al['id'] ?>/edit"><?= $h($al['anlage_nr'] ?: $al['lfd_nr']) ?></a>
</td>
<td><?= $h($al['bezeichnung']) ?></td>
<td class="td-muted text-sm"><?= View::formatDate($al['anschaffungsdatum']) ?></td>
<td class="td-right td-mono"><?= $fmt($al['basis']) ?></td>
<td class="td-right td-muted"><?= $h($nd) ?></td>
<td class="td-right td-mono"><?= $fmt($al['afa_vorjahre']) ?></td>
<td class="td-right td-mono"><?= $fmt($al['restbuchwert_vorjahr']) ?></td>
<td class="td-right td-mono"><?= $fmt($al['afa_jahr']) ?></td>
<td class="td-right td-mono"><?= $fmt($al['restbuchwert']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr style="background:#e8ecf0;font-weight:700">
<td colspan="3">Summe</td>
<td class="td-right td-mono"><?= $fmt($sumBasis) ?></td>
<td></td>
<td class="td-right td-mono"><?= $fmt($sumAfaVorjahre) ?></td>
<td class="td-right td-mono"><?= $fmt($sumRbwVorjahr) ?></td>
<td class="td-right td-mono"><?= $fmt($sumAfaJahr) ?></td>
<td class="td-right td-mono"><?= $fmt($sumRbw) ?></td>
</tr>
</tfoot>
</table>
</div>
</div>
<?php endif; ?>

View file

@ -1 +1 @@
1-260523
1-260524

View file

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

View file

@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
use Steuerkram\Models\AssetDepreciation;
class AfaAnteiligTest extends TestCase
{
protected string $testName = 'Test 41: AfA anteiliges erstes/letztes Jahr';
public function run(): void
{
// ── April-Kauf (Monat 4): 9 Monate erstes Jahr, 3 Monate letztes Jahr ──
$rows = AssetDepreciation::calculate([
'anschaffungskosten' => 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');
}
}