$rows, 'total' => $total, 'pages' => (int)ceil($total / $perPage)]; } public static function find(int $id): array|false { return Database::fetchOne( 'SELECT i.*, i.storno_of_id, c.firma, c.nachname, c.vorname, c.anrede, c.kundennummer, c.strasse, c.plz, c.ort, c.land, c.email, c.payment_days, c.ust_id FROM invoices i JOIN customers c ON i.customer_id = c.id WHERE i.id = ?', [$id] ); } public static function lines(int $invoiceId): array { return Database::fetchAll( 'SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY id', [$invoiceId] ); } public static function create(array $data): int { $customer = Customer::find((int)$data['customer_id']); $payDays = (int)($data['payment_days'] ?? $customer['payment_days'] ?? 14); $datum = $data['rechnungs_datum'] ?? date('Y-m-d'); $faellig = date('Y-m-d', strtotime($datum . " +{$payDays} days")); Database::execute( 'INSERT INTO invoices (customer_id, betreff, leistungszeitraum, rechnungs_datum, faellig_datum, status, notes) VALUES (?, ?, ?, ?, ?, \'Entwurf\', ?)', [ (int)$data['customer_id'], $data['betreff'], ($data['leistungszeitraum'] ?? '') !== '' ? $data['leistungszeitraum'] : null, $datum, $faellig, $data['notes'] ?? null, ] ); $id = (int)Database::lastInsertId(); // Create lines if (!empty($data['lines'])) { self::saveLines($id, $data['lines']); self::recalcTotals($id); } return $id; } public static function update(int $id, array $data): void { $inv = self::find($id); if ($inv && $inv['status'] !== 'Entwurf') { throw new \RuntimeException('Nur Entwürfe können bearbeitet werden.'); } $customer = Customer::find((int)$data['customer_id']); $payDays = (int)($data['payment_days'] ?? $customer['payment_days'] ?? 14); $datum = $data['rechnungs_datum']; $faellig = date('Y-m-d', strtotime($datum . " +{$payDays} days")); Database::execute( 'UPDATE invoices SET customer_id=?, betreff=?, leistungszeitraum=?, rechnungs_datum=?, faellig_datum=?, notes=?, updated_at=NOW() WHERE id=? AND status=\'Entwurf\'', [ (int)$data['customer_id'], $data['betreff'], ($data['leistungszeitraum'] ?? '') !== '' ? $data['leistungszeitraum'] : null, $datum, $faellig, $data['notes'] ?? null, $id, ] ); // Replace lines Database::execute('DELETE FROM invoice_lines WHERE invoice_id=?', [$id]); if (!empty($data['lines'])) { self::saveLines($id, $data['lines']); } self::recalcTotals($id); } public static function finalize(int $id): string { $inv = self::find($id); if (!$inv) throw new \RuntimeException('Rechnung nicht gefunden.'); if ($inv['status'] !== 'Entwurf') throw new \RuntimeException('Rechnung ist kein Entwurf mehr.'); $nr = self::generateRechnungsNr(); // Set fiscal year $year = (int)date('Y', strtotime($inv['rechnungs_datum'])); $fy = FiscalYear::findOrCreateByYear($year); Database::execute( 'UPDATE invoices SET rechnungs_nr=?, status=\'Finalisiert\', fiscal_year_id=?, updated_at=NOW() WHERE id=?', [$nr, $fy['id'], $id] ); return $nr; } public static function markPaid(int $id): void { Database::execute('UPDATE invoices SET status=\'Bezahlt\', updated_at=NOW() WHERE id=?', [$id]); } /** Status, die aus den zugeordneten Buchungen automatisch abgeleitet werden. */ public const ZAHL_STATUS = ['Finalisiert', 'Gesendet', 'Teilzahlung', 'Bezahlt', 'Überzahlt']; /** * Leitet den Zahlungsstatus einer Rechnung aus der Summe der zugeordneten * Buchungen ab: keine Zahlung → Finalisiert/Gesendet, zu wenig → Teilzahlung, * exakt → Bezahlt, zu viel → Überzahlt. * Entwürfe und stornierte Rechnungen bleiben unangetastet. */ public static function recalcPaymentStatus(int $invoiceId): ?string { $inv = Database::fetchOne( 'SELECT id, beleg_id, betrag_brutto, status, sent_at FROM invoices WHERE id = ?', [$invoiceId] ); if (!$inv) return null; if (!in_array($inv['status'], self::ZAHL_STATUS, true)) return $inv['status']; if (empty($inv['beleg_id'])) return $inv['status']; $soll = round((float)$inv['betrag_brutto'], 2); $ist = Booking::sumBruttoForBeleg((int)$inv['beleg_id'], 'Einnahme'); // Stornorechnungen haben negative Beträge – Vorzeichen normalisieren if ($soll < 0) { $soll = -$soll; $ist = -$ist; } $offen = round($soll - $ist, 2); if ($ist <= 0.0) { $status = !empty($inv['sent_at']) ? 'Gesendet' : 'Finalisiert'; } elseif ($offen > 0.0) { $status = 'Teilzahlung'; } elseif ($offen < 0.0) { $status = 'Überzahlt'; } else { $status = 'Bezahlt'; } if ($status !== $inv['status']) { Database::execute( 'UPDATE invoices SET status=?, updated_at=NOW() WHERE id=?', [$status, $invoiceId] ); } return $status; } /** Wie recalcPaymentStatus(), ausgehend vom Beleg (Aufruf nach jeder Buchungsänderung). */ public static function recalcPaymentStatusForBeleg(int $belegId): void { $inv = Database::fetchOne('SELECT id FROM invoices WHERE beleg_id = ? LIMIT 1', [$belegId]); if ($inv) { self::recalcPaymentStatus((int)$inv['id']); } } public static function createBookingsFromLines( int $invoiceId, int $kategorieKontoId, int $zahlungskontoId, string $buchungsDatum ): array { $inv = self::find($invoiceId); if (!$inv) throw new \RuntimeException('Rechnung nicht gefunden.'); if (empty($inv['beleg_id'])) throw new \RuntimeException('Rechnung hat keinen Beleg (noch nicht finalisiert?).'); $gruppen = []; foreach (self::lines($invoiceId) as $line) { if (($line['type'] ?? 'position') !== 'position') continue; $satz = (float)$line['ust_satz']; $gruppen[$satz] = ($gruppen[$satz] ?? 0.0) + round((float)$line['betrag_netto'], 2); } $ids = []; foreach ($gruppen as $satz => $netto) { $ids[] = Booking::create([ 'beleg_id' => (int)$inv['beleg_id'], 'buchungs_datum' => $buchungsDatum, 'buchungstyp' => 'Einnahme', 'kategorie_konto_id' => $kategorieKontoId, 'zahlungskonto_id' => $zahlungskontoId, 'betrag_netto' => round($netto, 2), 'ust_satz' => $satz, 'buchungstext' => 'Ausgangsrechnung ' . $inv['rechnungs_nr'], ]); } return $ids; } public static function cancel(int $id): void { $invoice = self::find($id); if (!$invoice) return; // Stornobuchungen für alle gebuchten Buchungen des Belegs erzeugen if (!empty($invoice['beleg_id'])) { $bookings = Database::fetchAll( 'SELECT id FROM bookings WHERE beleg_id = ? AND is_reversal = 0 AND reversed_by IS NULL AND parent_booking_id IS NULL', [(int)$invoice['beleg_id']] ); foreach ($bookings as $bk) { Booking::reverse((int)$bk['id'], 'Storno Rechnung ' . ($invoice['rechnungs_nr'] ?? $id)); } } Database::execute('UPDATE invoices SET status=\'Storniert\', updated_at=NOW() WHERE id=?', [$id]); } public static function createCancellation(int $originalId): int { $original = self::find($originalId); if (!$original) { throw new \RuntimeException('Originalrechnung nicht gefunden.'); } if (!in_array($original['status'], self::ZAHL_STATUS, true)) { throw new \RuntimeException('Nur finalisierte, gesendete oder bezahlte Rechnungen können storniert werden.'); } if (!empty($original['storno_of_id'])) { throw new \RuntimeException('Eine Stornorechnung kann nicht erneut storniert werden.'); } $originalLines = self::lines($originalId); $today = date('Y-m-d'); $fy = FiscalYear::findOrCreateByYear((int)date('Y')); $nr = self::generateRechnungsNr(); Database::execute( 'INSERT INTO invoices (customer_id, betreff, leistungszeitraum, rechnungs_datum, faellig_datum, status, rechnungs_nr, fiscal_year_id, storno_of_id) VALUES (?, ?, ?, ?, ?, \'Finalisiert\', ?, ?, ?)', [ (int)$original['customer_id'], $original['betreff'], $original['leistungszeitraum'], $today, $today, $nr, $fy['id'], $originalId, ] ); $stornoId = (int)Database::lastInsertId(); $lines = []; foreach ($originalLines as $line) { $lines[] = [ 'type' => $line['type'] ?? 'position', 'bezeichnung' => $line['bezeichnung'], 'menge' => $line['menge'], 'einheit' => $line['einheit'], 'einzelpreis' => -round((float)$line['einzelpreis'], 2), 'ust_satz' => $line['ust_satz'], ]; } self::saveLines($stornoId, $lines); self::recalcTotals($stornoId); self::cancel($originalId); return $stornoId; } private static function saveLines(int $invoiceId, array $lines): void { $posCounter = 0; foreach ($lines as $line) { if (empty($line['bezeichnung'])) continue; $type = ($line['type'] ?? 'position') === 'heading' ? 'heading' : 'position'; if ($type === 'heading') { Database::execute( 'INSERT INTO invoice_lines (invoice_id, position, bezeichnung, menge, einheit, einzelpreis, ust_satz, betrag_netto, betrag_ust, betrag_brutto, type) VALUES (?, ?, ?, 0, \'\', 0, 0, 0, 0, 0, ?)', [$invoiceId, 0, $line['bezeichnung'], 'heading'] ); } else { $posCounter++; $menge = round((float)$line['menge'], 3); $preis = round((float)$line['einzelpreis'], 2); $ustSatz = round((float)$line['ust_satz'], 2); $netto = round($menge * $preis, 2); $ust = round($netto * $ustSatz / 100, 2); $brutto = round($netto + $ust, 2); Database::execute( 'INSERT INTO invoice_lines (invoice_id, position, bezeichnung, menge, einheit, einzelpreis, ust_satz, betrag_netto, betrag_ust, betrag_brutto, type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ $invoiceId, $posCounter, $line['bezeichnung'], $menge, $line['einheit'] ?? 'Stk.', $preis, $ustSatz, $netto, $ust, $brutto, 'position', ] ); } } } private static function recalcTotals(int $id): void { $result = Database::fetchOne( 'SELECT SUM(betrag_netto) as netto, SUM(betrag_ust) as ust, SUM(betrag_brutto) as brutto FROM invoice_lines WHERE invoice_id = ?', [$id] ); Database::execute( 'UPDATE invoices SET betrag_netto=?, betrag_ust=?, betrag_brutto=?, updated_at=NOW() WHERE id=?', [ round((float)$result['netto'], 2), round((float)$result['ust'], 2), round((float)$result['brutto'], 2), $id, ] ); } public static function delete(int $id): void { $invoice = self::find($id); if (!$invoice) return; $belegId = !empty($invoice['beleg_id']) ? (int)$invoice['beleg_id'] : null; $beleg = $belegId !== null ? Beleg::find($belegId) : false; Database::execute('DELETE FROM invoice_lines WHERE invoice_id = ?', [$id]); Database::execute('DELETE FROM invoices WHERE id = ?', [$id]); if ($belegId !== null) { Database::execute('DELETE FROM bookings WHERE beleg_id = ?', [$belegId]); Database::execute('DELETE FROM belege WHERE id = ?', [$belegId]); } // Beide Nummernkreise zurückrollen, solange die gelöschten Datensätze // die zuletzt vergebenen Nummern trugen – sonst bliebe eine Lücke. self::releaseRechnungsNr((string)($invoice['rechnungs_nr'] ?? '')); if ($beleg) { Beleg::releaseBelegNr((string)($beleg['beleg_nr'] ?? '')); } } /** * Gibt die Rechnungsnummer einer gelöschten Rechnung wieder frei, sofern sie * die zuletzt vergebene war. * * Die Nummer wird aus dem aktuellen Zählerstand rekonstruiert; nur bei exakter * Übereinstimmung wird zurückgezählt. Gibt es bereits eine jüngere Rechnung, * passt die Rekonstruktion nicht und der Zähler bleibt stehen – eine Nummer * kann so nie doppelt vergeben werden. */ private static function releaseRechnungsNr(string $nr): void { if ($nr === '') return; $settings = Database::fetchOne( 'SELECT invoice_nr_prefix, invoice_nr_next, invoice_nr_year, invoice_nr_month FROM company_settings WHERE id=1' ); if (!$settings) return; $next = (int)($settings['invoice_nr_next'] ?? 1); if ($next <= 1) return; $letzte = self::formatRechnungsNr( (string)($settings['invoice_nr_prefix'] ?? 'AR'), (int)($settings['invoice_nr_year'] ?? 0), (int)($settings['invoice_nr_month'] ?? 0), $next - 1 ); if ($nr !== $letzte) return; Database::execute( 'UPDATE company_settings SET invoice_nr_next=? WHERE id=1 AND invoice_nr_next=?', [$next - 1, $next] ); } public static function generateRechnungsNr(): string { $settings = Database::fetchOne( 'SELECT invoice_nr_prefix, invoice_nr_next, invoice_nr_year, invoice_nr_month FROM company_settings WHERE id=1' ); $prefix = $settings['invoice_nr_prefix'] ?? 'AR'; $next = (int)($settings['invoice_nr_next'] ?? 1); $savedYear = (int)($settings['invoice_nr_year'] ?? 0); $savedMonth = (int)($settings['invoice_nr_month'] ?? 0); $year = (int)date('Y'); $month = (int)date('n'); if ($savedYear !== $year || $savedMonth !== $month) { $next = 1; } $nr = self::formatRechnungsNr($prefix, $year, $month, $next); Database::execute( 'UPDATE company_settings SET invoice_nr_next=?, invoice_nr_year=?, invoice_nr_month=? WHERE id=1', [$next + 1, $year, $month] ); return $nr; } /** Einheitliches Format des Nummernkreises: Präfix + Jahr + Monat + laufende Nummer. */ private static function formatRechnungsNr(string $prefix, int $year, int $month, int $lfd): string { $digits = max(2, strlen((string)$lfd)); return sprintf('%s%04d%02d-%0' . $digits . 'd', $prefix, $year, $month, $lfd); } }