456 lines
18 KiB
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]
|
|
);
|
|
}
|
|
}
|