Steuerkram/src/Services/UstvaCalculator.php

149 lines
7 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 Kennzahlen (Kz) für die Umsatzsteuervoranmeldung.
*
* Kontobasierte Kennzahlen (accounts.ustva_kz des Kategorie-Kontos):
* ustva_kz = '81' → Kz 81 (betrag_netto, EU-Steuerarten ausgeschlossen)
* ustva_kz = '66' → Kz 66 (betrag_netto, Konten 1571 + 1576)
* ustva_kz = '83' → Summand für Kz 83 (Konten 1771 + 1774 + 1776)
*
* Steuerart-basiert:
* steuerart = 'ig_erwerb_19' → Kz 89 (betrag_netto, Bemessungsgrundlage)
*
* Abgeleitete Kennzahlen:
* ust89 = round(Kz 89 × 0,19, 2) (abzuführende USt aus Kz 89)
* Kz 61 = ust89 (VSt ig. Erwerb = USt ig. Erwerb, ReverseCharge-Symmetrie)
* Kz 83 = ust81 + ust89 − Kz 66 − Kz 61 (Vorauszahlung / Überschuss)
*
* Nur-Anzeige (kein XML-Export):
* ust81 = round(Kz 81 × 0,19, 2) (abzuführende USt aus Kz 81)
* ust89 = s.o.
*/
class UstvaCalculator
{
private const EU_STEUERARTEN = ['ig_lieferung', 'export', 'ig_erwerb_19', 'ig_erwerb_7'];
private const KZ_ACCOUNT_MAP = [
'81' => ['81'],
'66' => ['66'],
'61' => ['61'],
'83' => ['83'],
];
private const KZ_STEUERART_MAP = [
'89' => ['ig_erwerb_19'],
];
public function calculate(int $year, int $month): array
{
// ── 1. Kontobasierte Kennzahlen (EU-Steuerarten ausgeschlossen) ─────────
$euPh = implode(',', array_fill(0, count(self::EU_STEUERARTEN), '?'));
$accountRows = Database::fetchAll(
'SELECT a.ustva_kz, SUM(bk.betrag_netto) AS summe_netto
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 (bk.steuerart IS NULL OR bk.steuerart NOT IN (' . $euPh . '))
GROUP BY a.ustva_kz',
array_merge([$year, $month], self::EU_STEUERARTEN)
);
$byKz = [];
foreach ($accountRows as $row) {
$byKz[$row['ustva_kz']] = (float)$row['summe_netto'];
}
// ── 2. Steuerart-basiert: ig_erwerb_19 → Kz 89 ──────────────────────
$stRow = Database::fetchOne(
'SELECT 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 = ?',
[$year, $month, 'ig_erwerb_19']
);
$kz89 = (float)($stRow['summe_netto'] ?? 0.0);
// ── 3. Kennzahlen zusammensetzen ─────────────────────────────────────
$n = fn(string $kz): float => (float)($byKz[$kz] ?? 0.0);
// Kz 81 und 89 werden laut UStVA auf volle Euro abgerundet
$kz81 = floor($n('81'));
$kz89 = floor($kz89);
$kz66 = $n('66');
$ust81 = round($kz81 * 0.19, 2);
$ust89 = round($kz89 * 0.19, 2);
// Kz 61 (VSt ig. Erwerb) = ust89: ReverseCharge ist immer symmetrisch zur USt aus Kz 89
$kz61 = $ust89;
$kz83 = $ust81 + $ust89 - $kz66 - $kz61;
return [
'81' => ['name' => 'Steuerpflichtige Umsätze zum Steuersatz von 19 %', 'betrag' => $kz81, 'steuer' => null, 'xml' => true, 'ganze_euro' => true],
'ust81' => ['name' => 'Abzuführende USt aus Kz 81 (19 %)', 'betrag' => null, 'steuer' => $ust81, 'xml' => false, 'ganze_euro' => false],
'89' => ['name' => 'Steuerpfl. innergemeinschaftliche Erwerbe 19 %', 'betrag' => $kz89, 'steuer' => null, 'xml' => true, 'ganze_euro' => true],
'ust89' => ['name' => 'Abzuführende USt aus Kz 89 (19 %)', 'betrag' => null, 'steuer' => $ust89, 'xml' => false, 'ganze_euro' => false],
'66' => ['name' => 'Gesamte Vorsteuerbeträge', 'betrag' => null, 'steuer' => round($kz66, 2), 'xml' => true, 'ganze_euro' => false],
'61' => ['name' => 'Vorsteuerbeträge i.g. Erwerb', 'betrag' => null, 'steuer' => round($kz61, 2), 'xml' => true, 'ganze_euro' => false],
'83' => ['name' => 'Vorauszahlung / Überschuss', 'betrag' => null, 'steuer' => round($kz83, 2), 'xml' => true, 'ganze_euro' => false],
];
}
/**
* Liefert alle Buchungszeilen, die zu einer Kennzahl beitragen (Drill-down).
*/
public function fetchBookingsForKz(int $year, int $month, string $kz): array
{
$accountKzList = self::KZ_ACCOUNT_MAP[$kz] ?? [];
$steuerartList = self::KZ_STEUERART_MAP[$kz] ?? [];
if (empty($accountKzList) && empty($steuerartList)) {
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 (!empty($steuerartList)) {
$ph = implode(',', array_fill(0, count($steuerartList), '?'));
$parts[] = $select . ' AND bk.steuerart IN (' . $ph . ')';
$params = array_merge($params, [$year, $month], $steuerartList);
}
return Database::fetchAll(
implode(' UNION ALL ', $parts) . ' ORDER BY buchungs_datum, id',
$params
);
}
}