Steuerkram/src/Services/UstvaCalculator.php
fds2610-bot 49d145e251 Feature: Anlage-Links auf Bearbeitungsmaske, Backfill anlage_nr, README
- Anlage-Spalte in allen Buchungstabellen verlinkt jetzt auf /assets/{id}/edit
- bk.asset_id in UstvaCalculator und EuerCalculator-Queries ergänzt
- Fallback-Anzeige: Bezeichnung wenn anlage_nr leer ist
- Migration 032: anlage_nr für bestehende Anlagegüter nachgenerieren
- README: Feature Anlagevermögen dokumentiert
2026-04-12 18:51:54 +02:00

253 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace Steuerkram\Services;
use Steuerkram\Database;
/**
* Berechnet alle ELSTER-Kennzahlen (Kz) für die Umsatzsteuervoranmeldung.
*
* Zwei Quellen fließen in die Berechnung ein:
*
* 1. Kontobasiert (accounts.ustva_kz des Kategorie-Kontos):
* ustva_kz = '81' → Kz 81 (betrag_netto), Kz 83 (betrag_ust)
* ustva_kz = '86' → Kz 86 (betrag_netto), Kz 84 (betrag_ust)
* ustva_kz = '35' → Kz 35 (betrag_netto)
* ustva_kz = '41' → Kz 41 (betrag_netto) [steuerfreie ig. Lieferungen via Konto]
* ustva_kz = '44' → Kz 44 (betrag_netto) [steuerfreie Ausfuhren via Konto]
* (Kz 61/62 werden NICHT mehr über ustva_kz geroutet)
*
* 2. Buchungsbasiert (bookings.ust_satz, für Vorsteuer):
* buchungstyp = 'Ausgabe', ust_satz = 19 → Kz 61 (betrag_ust)
* buchungstyp = 'Ausgabe', ust_satz = 7 → Kz 62 (betrag_ust)
* Damit entfällt die Notwendigkeit separater VSt19/VSt7-Konten.
*
* 3. Steuerart-basiert (bookings.steuerart, unabhängig vom Konto):
* steuerart = 'ig_lieferung' → Kz 41 (betrag_netto)
* steuerart = 'export' → Kz 44 (betrag_netto)
* steuerart = 'ig_erwerb_19' → Kz 89 (betrag_netto), Kz 91 (netto × 0,19)
* steuerart = 'ig_erwerb_7' → Kz 93 (betrag_netto), Kz 94 (netto × 0,07)
* Buchungen mit diesen steuerart-Werten bleiben aus der kontobasierten Berechnung
* ausgeschlossen, um Doppelzählung zu vermeiden.
* Buchungen mit EU-Steuerarten fließen ebenfalls NICHT in Kz 61/62.
*
* Abgeleitete Kennzahlen:
* Kz 67 = Kz 91 + Kz 94 (Vorsteuer aus ig. Erwerb)
* Kz 66 = Kz 61 + Kz 62 + Kz 67 (Vorsteuer gesamt)
* Kz 69 = (Kz 83 + Kz 84 + Kz 91 + Kz 94) − Kz 66 (Zahllast)
*/
class UstvaCalculator
{
/** Steuerart-Werte die AUSSCHLIESSLICH steuerart-basiert berechnet werden. */
private const EU_STEUERARTEN = ['ig_lieferung', 'export', 'ig_erwerb_19', 'ig_erwerb_7'];
/** Vorsteuer-Kennzahl je USt-Satz (buchungsbasiert). */
private const VST_SATZ_KZ = [19 => '61', 7 => '62'];
/**
* Welche accounts.ustva_kz-Werte liefern Buchungen für die Drill-down-Ansicht.
* Nur für den kontobasierten Teil; EU-Steuerarten kommen aus KZ_STEUERART_MAP.
* Kz 61/62 sind hier nicht mehr enthalten (buchungsbasiert via VST_SATZ_KZ).
*/
private const KZ_ACCOUNT_MAP = [
'81' => ['81'],
'83' => ['81'],
'86' => ['86'],
'84' => ['86'],
'35' => ['35'],
'41' => ['41'],
'44' => ['44'],
];
/**
* Welche steuerart-Werte liefern Buchungen für eine Kennzahl.
*/
private const KZ_STEUERART_MAP = [
'41' => ['ig_lieferung'],
'44' => ['export'],
'89' => ['ig_erwerb_19'],
'91' => ['ig_erwerb_19'],
'93' => ['ig_erwerb_7'],
'94' => ['ig_erwerb_7'],
'67' => ['ig_erwerb_19', 'ig_erwerb_7'],
'66' => ['ig_erwerb_19', 'ig_erwerb_7'],
'69' => ['ig_lieferung', 'export', 'ig_erwerb_19', 'ig_erwerb_7'],
];
public function calculate(int $year, int $month): array
{
// ── 1. Kontobasierte Kennzahlen (EU-Steuerarten und Kz 61/62 ausgeschlossen) ──
$euPlaceholders = implode(',', array_fill(0, count(self::EU_STEUERARTEN), '?'));
$accountRows = Database::fetchAll(
'SELECT a.ustva_kz,
SUM(bk.betrag_netto) AS summe_netto,
SUM(bk.betrag_ust) AS summe_ust
FROM bookings bk
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
JOIN accounts a ON a.id = bk.kategorie_konto_id
WHERE fy.year = ? AND MONTH(bk.buchungs_datum) = ?
AND a.ustva_kz IS NOT NULL AND a.ustva_kz != \'\'
AND a.ustva_kz NOT IN (\'61\', \'62\')
AND (bk.steuerart IS NULL OR bk.steuerart NOT IN (' . $euPlaceholders . '))
GROUP BY a.ustva_kz',
array_merge([$year, $month], self::EU_STEUERARTEN)
);
$byKz = [];
foreach ($accountRows as $row) {
$byKz[$row['ustva_kz']] = [
'netto' => (float)$row['summe_netto'],
'ust' => (float)$row['summe_ust'],
];
}
// ── 2. Buchungsbasierte Vorsteuer Kz 61/62 (via ust_satz) ────────────
$vstRows = Database::fetchAll(
'SELECT bk.ust_satz, SUM(bk.betrag_ust) AS summe_ust
FROM bookings bk
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
WHERE fy.year = ? AND MONTH(bk.buchungs_datum) = ?
AND bk.buchungstyp = \'Ausgabe\'
AND bk.betrag_ust != 0
AND (bk.steuerart IS NULL OR bk.steuerart NOT IN (' . $euPlaceholders . '))
GROUP BY bk.ust_satz',
array_merge([$year, $month], self::EU_STEUERARTEN)
);
foreach ($vstRows as $row) {
$satz = (int)round((float)$row['ust_satz']);
$kzNr = self::VST_SATZ_KZ[$satz] ?? null;
if ($kzNr !== null) {
$byKz[$kzNr] = ['netto' => 0.0, 'ust' => (float)$row['summe_ust']];
}
}
// ── 3. Steuerart-basierte Kennzahlen ─────────────────────────────────
$stRows = Database::fetchAll(
'SELECT bk.steuerart, SUM(bk.betrag_netto) AS summe_netto
FROM bookings bk
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
WHERE fy.year = ? AND MONTH(bk.buchungs_datum) = ?
AND bk.steuerart IN (' . $euPlaceholders . ')
GROUP BY bk.steuerart',
array_merge([$year, $month], self::EU_STEUERARTEN)
);
$bySteuerart = [];
foreach ($stRows as $row) {
$bySteuerart[$row['steuerart']] = (float)$row['summe_netto'];
}
// ── 4. Kennzahlen zusammensetzen ─────────────────────────────────────
$n = fn(string $kz): float => (float)($byKz[$kz]['netto'] ?? 0.0);
$u = fn(string $kz): float => (float)($byKz[$kz]['ust'] ?? 0.0);
$st = fn(string $art): float => (float)($bySteuerart[$art] ?? 0.0);
$kz81 = $n('81'); $kz83 = $u('81');
$kz86 = $n('86'); $kz84 = $u('86');
$kz35 = $n('35');
$kz41 = $n('41') + $st('ig_lieferung');
$kz44 = $n('44') + $st('export');
$kz89 = $st('ig_erwerb_19');
$kz91 = round($kz89 * 0.19, 2);
$kz93 = $st('ig_erwerb_7');
$kz94 = round($kz93 * 0.07, 2);
$kz61 = $u('61');
$kz62 = $u('62');
$kz67 = $kz91 + $kz94;
$kz66 = $kz61 + $kz62 + $kz67;
$kz69 = ($kz83 + $kz84 + $kz91 + $kz94) - $kz66;
return [
'81' => ['name' => 'Steuerpfl. Umsätze 19 %', 'betrag' => round($kz81, 2), 'steuer' => null],
'83' => ['name' => 'USt auf steuerpfl. Umsätze 19 %', 'betrag' => null, 'steuer' => round($kz83, 2)],
'86' => ['name' => 'Steuerpfl. Umsätze 7 %', 'betrag' => round($kz86, 2), 'steuer' => null],
'84' => ['name' => 'USt auf steuerpfl. Umsätze 7 %', 'betrag' => null, 'steuer' => round($kz84, 2)],
'35' => ['name' => 'Steuerfreie Umsätze (ohne Vorsteuerabzug)', 'betrag' => round($kz35, 2), 'steuer' => null],
'41' => ['name' => 'Steuerfreie ig. Lieferungen (§ 4 Nr. 1b)', 'betrag' => round($kz41, 2), 'steuer' => null],
'44' => ['name' => 'Steuerfreie Ausfuhren (§ 4 Nr. 1a)', 'betrag' => round($kz44, 2), 'steuer' => null],
'89' => ['name' => 'Ig. Erwerb 19 % – Bemessungsgrundlage', 'betrag' => round($kz89, 2), 'steuer' => null],
'91' => ['name' => 'USt auf ig. Erwerb 19 %', 'betrag' => null, 'steuer' => round($kz91, 2)],
'93' => ['name' => 'Ig. Erwerb 7 % – Bemessungsgrundlage', 'betrag' => round($kz93, 2), 'steuer' => null],
'94' => ['name' => 'USt auf ig. Erwerb 7 %', 'betrag' => null, 'steuer' => round($kz94, 2)],
'61' => ['name' => 'Vorsteuer aus Eingangsrechnungen 19 %', 'betrag' => null, 'steuer' => round($kz61, 2)],
'62' => ['name' => 'Vorsteuer aus Eingangsrechnungen 7 %', 'betrag' => null, 'steuer' => round($kz62, 2)],
'67' => ['name' => 'Vorsteuer aus ig. Erwerb', 'betrag' => null, 'steuer' => round($kz67, 2)],
'66' => ['name' => 'Vorsteuerbeträge gesamt', 'betrag' => null, 'steuer' => round($kz66, 2)],
'69' => ['name' => 'Verbleibende USt-Vorauszahlung/Erstattung', 'betrag' => null, 'steuer' => round($kz69, 2)],
];
}
/**
* Liefert alle Buchungszeilen, die zu einer Kennzahl beitragen (Drill-down).
* Kombiniert kontobasierte, buchungsbasierte und steuerart-basierte Quellen per UNION.
*/
public function fetchBookingsForKz(int $year, int $month, string $kz): array
{
$accountKzList = self::KZ_ACCOUNT_MAP[$kz] ?? [];
$steuerartList = self::KZ_STEUERART_MAP[$kz] ?? [];
// Kz 61/62: buchungsbasiert via ust_satz
$vstSatz = array_search($kz, self::VST_SATZ_KZ, true);
if (empty($accountKzList) && empty($steuerartList) && $vstSatz === false) {
return [];
}
$select = 'SELECT bk.id, bk.buchungs_datum, bk.buchungstyp, bk.steuerart, bk.ust_satz,
bk.betrag_netto, bk.betrag_ust, bk.betrag_brutto, bk.buchungstext,
bk.is_reversal, bk.beleg_id, bk.asset_id, bl.beleg_nr,
k.kontonummer AS kategorie_nr, k.bezeichnung AS kategorie_name,
z.kontonummer AS zahlungs_nr, z.bezeichnung AS zahlungs_name,
a.anlage_nr, a.bezeichnung AS anlage_bezeichnung
FROM bookings bk
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
LEFT JOIN belege bl ON bk.beleg_id = bl.id
JOIN accounts k ON bk.kategorie_konto_id = k.id
JOIN accounts z ON bk.zahlungskonto_id = z.id
LEFT JOIN assets a ON bk.asset_id = a.id
WHERE fy.year = ? AND MONTH(bk.buchungs_datum) = ?';
$parts = [];
$params = [];
if (!empty($accountKzList)) {
$ph = implode(',', array_fill(0, count($accountKzList), '?'));
$euPh = implode(',', array_fill(0, count(self::EU_STEUERARTEN), '?'));
$parts[] = $select
. ' AND k.ustva_kz IN (' . $ph . ')'
. ' AND (bk.steuerart IS NULL OR bk.steuerart NOT IN (' . $euPh . '))';
$params = array_merge($params,
[$year, $month],
$accountKzList,
self::EU_STEUERARTEN
);
}
if ($vstSatz !== false) {
$euPh = implode(',', array_fill(0, count(self::EU_STEUERARTEN), '?'));
$parts[] = $select
. ' AND bk.buchungstyp = \'Ausgabe\''
. ' AND bk.betrag_ust != 0'
. ' AND ROUND(bk.ust_satz) = ?'
. ' AND (bk.steuerart IS NULL OR bk.steuerart NOT IN (' . $euPh . '))';
$params = array_merge($params,
[$year, $month],
[$vstSatz],
self::EU_STEUERARTEN
);
}
if (!empty($steuerartList)) {
$ph = implode(',', array_fill(0, count($steuerartList), '?'));
$parts[] = $select . ' AND bk.steuerart IN (' . $ph . ')';
$params = array_merge($params, [$year, $month], $steuerartList);
}
$sql = implode(' UNION ALL ', $parts) . ' ORDER BY buchungs_datum, id';
return Database::fetchAll($sql, $params);
}
}