Steuerkram/src/Models/Booking.php

456 lines
18 KiB
PHP

<?php
declare(strict_types=1);
namespace Steuerkram\Models;
use Steuerkram\Constants;
use Steuerkram\Database;
class Booking
{
public static function forBeleg(int $belegId): array
{
return Database::fetchAll(
'SELECT bk.*,
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 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 bk.beleg_id = ?
ORDER BY bk.buchungs_datum, bk.id',
[$belegId]
);
}
public static function journal(array $filter = [], int $page = 1, int $perPage = 50): array
{
$where = ['1=1'];
$params = [];
if (!empty($filter['year'])) {
$where[] = 'fy.year = ?';
$params[] = (int)$filter['year'];
}
if (!empty($filter['month'])) {
$where[] = 'MONTH(bk.buchungs_datum) = ?';
$params[] = (int)$filter['month'];
}
if (!empty($filter['konto'])) {
$where[] = '(bk.kategorie_konto_id = ? OR bk.zahlungskonto_id = ?)';
$params[] = (int)$filter['konto'];
$params[] = (int)$filter['konto'];
}
if (!empty($filter['search'])) {
$where[] = '(bk.buchungstext LIKE ? OR s.name LIKE ?)';
$params[] = '%' . $filter['search'] . '%';
$params[] = '%' . $filter['search'] . '%';
}
$offset = ($page - 1) * $perPage;
$sql = 'SELECT bk.*,
k.kontonummer as kategorie_nr, k.bezeichnung as kategorie_name,
z.kontonummer as zahlungs_nr, z.bezeichnung as zahlungs_name,
bl.beleg_nr, fy.year as fiscal_year_nr,
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
LEFT JOIN suppliers s ON bl.supplier_id = s.id
WHERE ' . implode(' AND ', $where) . '
ORDER BY bk.buchungs_datum DESC, bk.id DESC
LIMIT ? OFFSET ?';
$params[] = $perPage;
$params[] = $offset;
$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
LEFT JOIN suppliers s ON bl.supplier_id = s.id
WHERE ' . implode(' AND ', $where);
$countParams = array_slice($params, 0, -2);
$total = (int)(Database::fetchOne($countSql, $countParams)['cnt'] ?? 0);
return ['rows' => $rows, 'total' => $total, 'pages' => (int)ceil($total / $perPage)];
}
public static function find(int $id): array|false
{
return Database::fetchOne('SELECT * FROM bookings WHERE id = ?', [$id]);
}
public static function create(array $data): int
{
// Determine fiscal year
$year = (int)date('Y', strtotime($data['buchungs_datum']));
$fy = FiscalYear::findOrCreateByYear($year);
if ($fy['locked']) {
throw new \RuntimeException("Geschäftsjahr {$year} ist gesperrt. Neue Buchungen nicht möglich.");
}
// Calculate amounts
$netto = round((float)$data['betrag_netto'], 2);
$ust = round((float)($data['ust_satz'] ?? 0) / 100 * $netto, 2);
$brutto = round($netto + $ust, 2);
$nr = self::generateBuchungsNr();
$steuerart = $data['steuerart'] ?? 'normal';
if (!in_array($steuerart, ['normal','ig_lieferung','export','ig_erwerb_19','ig_erwerb_7'], true)) {
$steuerart = 'normal';
}
$assetId = isset($data['asset_id']) && $data['asset_id'] !== '' ? (int)$data['asset_id'] : null;
Database::beginTransaction();
try {
Database::execute(
'INSERT INTO bookings (buchungs_nr, beleg_id, asset_id, buchungs_datum, buchungstyp,
kategorie_konto_id, zahlungskonto_id,
betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto,
buchungstext, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
$nr,
(int)$data['beleg_id'],
$assetId,
$data['buchungs_datum'],
$data['buchungstyp'],
(int)$data['kategorie_konto_id'],
(int)$data['zahlungskonto_id'],
$netto,
(float)($data['ust_satz'] ?? 0),
$steuerart,
$ust,
$brutto,
$data['buchungstext'],
$fy['id'],
]
);
$mainId = (int)Database::lastInsertId();
// VSt-/USt-Buchungszeile für normale Buchungen mit 7 % oder 19 % anlegen
$ustSatzInt = (int)round((float)($data['ust_satz'] ?? 0));
if (in_array($data['buchungstyp'], [Constants::BUCHUNGSTYP_AUSGABE, Constants::BUCHUNGSTYP_EINNAHME], true)
&& in_array($ustSatzInt, [7, 19], true)
&& $steuerart === 'normal'
&& abs($ust) > 0.0
) {
$isAusgabe = $data['buchungstyp'] === Constants::BUCHUNGSTYP_AUSGABE;
if ($isAusgabe) {
$steuerKontonummer = $ustSatzInt === 19 ? Constants::KONTO_VST_19 : Constants::KONTO_VST_7;
$steuerText = 'Vorsteuer zu ' . $nr;
} else {
$steuerKontonummer = $ustSatzInt === 19 ? Constants::KONTO_UST_19 : Constants::KONTO_UST_7;
$steuerText = 'USt zu ' . $nr;
}
$steuerAcc = Account::findByKontonummer($steuerKontonummer);
if ($steuerAcc) {
$steuerNr = self::generateBuchungsNr();
Database::execute(
'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp,
kategorie_konto_id, zahlungskonto_id,
betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto,
buchungstext, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
$steuerNr,
(int)$data['beleg_id'],
$mainId,
$data['buchungs_datum'],
$data['buchungstyp'],
(int)$steuerAcc['id'],
(int)$data['zahlungskonto_id'],
$ust,
0.0,
'normal',
0.0,
$ust,
$steuerText,
$fy['id'],
]
);
}
}
// ig. Erwerb 19 % ReverseCharge: Kindzeilen auf VST_IGE_19 (Kz 67) und UST_IGE_19 (Kz 91)
if ($data['buchungstyp'] === Constants::BUCHUNGSTYP_AUSGABE
&& $steuerart === 'ig_erwerb_19'
&& abs($netto) > 0.0
) {
$igeUst = round($netto * 0.19, 2);
foreach ([
[Constants::KONTO_VST_IGE_19, 'VSt ig.E. zu '],
[Constants::KONTO_UST_IGE_19, 'USt ig.E. zu '],
] as [$kontonr, $prefix]) {
$igeAcc = Account::findByKontonummer($kontonr);
if ($igeAcc) {
Database::execute(
'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp,
kategorie_konto_id, zahlungskonto_id,
betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto,
buchungstext, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
self::generateBuchungsNr(),
(int)$data['beleg_id'],
$mainId,
$data['buchungs_datum'],
Constants::BUCHUNGSTYP_AUSGABE,
(int)$igeAcc['id'],
(int)$data['zahlungskonto_id'],
$igeUst,
0.0,
'normal',
0.0,
$igeUst,
$prefix . $nr,
$fy['id'],
]
);
}
}
}
Database::commit();
} catch (\Throwable $e) {
Database::rollBack();
throw $e;
}
return $mainId;
}
public static function reverse(int $id, string $reason): int
{
$orig = self::find($id);
if (!$orig) throw new \RuntimeException('Buchung nicht gefunden.');
if ($orig['is_reversal']) throw new \RuntimeException('Stornobuchungen können nicht storniert werden.');
$reversalData = [
'beleg_id' => $orig['beleg_id'],
'buchungs_datum' => date('Y-m-d'),
'buchungstyp' => $orig['buchungstyp'],
'kategorie_konto_id' => $orig['kategorie_konto_id'],
'zahlungskonto_id' => $orig['zahlungskonto_id'],
'betrag_netto' => $orig['betrag_netto'],
'ust_satz' => $orig['ust_satz'],
'buchungstext' => 'STORNO zu ' . $orig['buchungs_nr'] . ': ' . $reason,
];
$year = (int)date('Y', strtotime($reversalData['buchungs_datum']));
$fy = FiscalYear::findOrCreateByYear($year);
$nr = self::generateBuchungsNr();
Database::beginTransaction();
try {
Database::execute(
'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, buchungstyp,
kategorie_konto_id, zahlungskonto_id,
betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto, buchungstext, is_reversal, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)',
[
$nr,
$reversalData['beleg_id'],
$reversalData['buchungs_datum'],
$reversalData['buchungstyp'],
$reversalData['kategorie_konto_id'],
$reversalData['zahlungskonto_id'],
-1 * (float)$orig['betrag_netto'],
$orig['ust_satz'],
$orig['steuerart'],
-1 * (float)$orig['betrag_ust'],
-1 * (float)$orig['betrag_brutto'],
$reversalData['buchungstext'],
$fy['id'],
]
);
$reversalId = (int)Database::lastInsertId();
Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$reversalId, $id]);
$vstBookings = Database::fetchAll(
'SELECT * FROM bookings WHERE parent_booking_id = ? AND reversed_by IS NULL',
[$id]
);
foreach ($vstBookings as $vst) {
$vstNr = self::generateBuchungsNr();
Database::execute(
'INSERT INTO bookings (buchungs_nr, beleg_id, parent_booking_id, buchungs_datum, buchungstyp,
kategorie_konto_id, zahlungskonto_id,
betrag_netto, ust_satz, steuerart, betrag_ust, betrag_brutto,
buchungstext, is_reversal, fiscal_year_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)',
[
$vstNr,
$vst['beleg_id'],
$reversalId,
date('Y-m-d'),
$vst['buchungstyp'],
$vst['kategorie_konto_id'],
$vst['zahlungskonto_id'],
-1 * (float)$vst['betrag_netto'],
$vst['ust_satz'],
$vst['steuerart'],
-1 * (float)$vst['betrag_ust'],
-1 * (float)$vst['betrag_brutto'],
'STORNO zu ' . $vst['buchungs_nr'] . ': ' . $reason,
$fy['id'],
]
);
$vstReversalId = (int)Database::lastInsertId();
Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$vstReversalId, $vst['id']]);
}
Database::commit();
} catch (\Throwable $e) {
Database::rollBack();
throw $e;
}
return $reversalId;
}
public static function upsertEigenanteil30(
int $belegId,
float $betragNetto,
float $ustSatz,
string $buchungsDatum
): void {
$acc8922 = Account::findByKontonummer('8922');
$acc1800 = Account::findByKontonummer('1800');
if (!$acc8922 || !$acc1800) {
throw new \RuntimeException('Konten 1800 und/oder 8922 nicht gefunden.');
}
$netto30 = round($betragNetto * 0.3, 2);
$ust30 = round($betragNetto * $ustSatz / 100 * 0.3, 2);
$isTest = Setting::isTestMode();
// Einzige Buchung: 30% Brutto an 8922 inkl. USt-Anteil → fließt korrekt in UStVA KZ 81/83
self::upsertKorrekturbuchung(
belegId: $belegId,
kategorieId: (int)$acc8922['id'],
zahlungsId: (int)$acc1800['id'],
betragNetto: $netto30,
ustSatz: $ustSatz,
betragUst: $ust30,
buchungsDatum: $buchungsDatum,
buchungstext: '30% Eigenanteil (nicht abzugsfähig)',
isTest: $isTest,
);
}
private static function upsertKorrekturbuchung(
int $belegId,
int $kategorieId,
int $zahlungsId,
float $betragNetto,
float $ustSatz,
float $betragUst,
string $buchungsDatum,
string $buchungstext,
bool $isTest,
): void {
$betragBrutto = round($betragNetto + $betragUst, 2);
$existing = Database::fetchOne(
'SELECT * FROM bookings
WHERE beleg_id = ? AND kategorie_konto_id = ? AND zahlungskonto_id = ?
AND is_reversal = 0 AND reversed_by IS NULL
LIMIT 1',
[$belegId, $kategorieId, $zahlungsId]
);
if ($existing) {
if ($isTest) {
Database::execute(
'UPDATE bookings
SET betrag_netto = ?, betrag_ust = ?, betrag_brutto = ?,
ust_satz = ?, buchungs_datum = ?, buchungstext = ?
WHERE id = ?',
[$betragNetto, $betragUst, $betragBrutto, $ustSatz, $buchungsDatum, $buchungstext, $existing['id']]
);
} else {
self::reverse((int)$existing['id'], 'Anpassung 30% Eigenanteil');
self::create([
'beleg_id' => $belegId,
'buchungs_datum' => $buchungsDatum,
'buchungstyp' => 'Einnahme',
'kategorie_konto_id' => $kategorieId,
'zahlungskonto_id' => $zahlungsId,
'betrag_netto' => $betragNetto,
'ust_satz' => $ustSatz,
'steuerart' => 'normal',
'buchungstext' => $buchungstext,
]);
}
} else {
self::create([
'beleg_id' => $belegId,
'buchungs_datum' => $buchungsDatum,
'buchungstyp' => 'Einnahme',
'kategorie_konto_id' => $kategorieId,
'zahlungskonto_id' => $zahlungsId,
'betrag_netto' => $betragNetto,
'ust_satz' => $ustSatz,
'steuerart' => 'normal',
'buchungstext' => $buchungstext,
]);
}
}
public static function generateBuchungsNr(): string
{
$year = date('Y');
$result = Database::fetchOne(
"SELECT MAX(CAST(SUBSTRING_INDEX(buchungs_nr, '-', -1) AS UNSIGNED)) as maxnr
FROM bookings WHERE buchungs_nr LIKE ?",
["BU-{$year}-%"]
);
$next = (int)($result['maxnr'] ?? 0) + 1;
return sprintf('BU-%s-%06d', $year, $next);
}
/** Summiert betrag_brutto aller aktiven Hauptbuchungen eines Belegs. */
public static function sumBruttoForBeleg(int $belegId): float
{
$row = Database::fetchOne(
'SELECT COALESCE(SUM(betrag_brutto), 0) AS total
FROM bookings
WHERE beleg_id = ? AND is_reversal = 0 AND parent_booking_id IS NULL AND reversed_by IS NULL',
[$belegId]
);
return round((float)($row['total'] ?? 0), 2);
}
public static function sumByYear(int $year): array
{
return Database::fetchAll(
'SELECT bk.buchungstyp, bk.kategorie_konto_id, bk.zahlungskonto_id,
SUM(bk.betrag_netto) as sum_netto,
SUM(bk.betrag_ust) as sum_ust,
SUM(bk.betrag_brutto) as sum_brutto
FROM bookings bk
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
WHERE fy.year = ?
GROUP BY bk.buchungstyp, bk.kategorie_konto_id, bk.zahlungskonto_id',
[$year]
);
}
}