$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::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'], ] ); return (int)Database::lastInsertId(); } 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::execute( 'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, buchungstyp, kategorie_konto_id, zahlungskonto_id, betrag_netto, ust_satz, 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'], -1 * (float)$orig['betrag_ust'], -1 * (float)$orig['betrag_brutto'], $reversalData['buchungstext'], $fy['id'], ] ); $reversalId = (int)Database::lastInsertId(); // Link original to reversal Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$reversalId, $id]); 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); } 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] ); } }