Steuerkram/src/Models/Invoice.php

485 lines
18 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\Models;
use Steuerkram\Database;
class Invoice
{
public static function all(array $filter = [], int $page = 1, int $perPage = 30): array
{
$where = ['1=1'];
$params = [];
if (!empty($filter['status'])) {
$where[] = 'i.status = ?';
$params[] = $filter['status'];
}
if (!empty($filter['year'])) {
$where[] = 'YEAR(i.rechnungs_datum) = ?';
$params[] = (int)$filter['year'];
}
if (!empty($filter['customer_id'])) {
$where[] = 'i.customer_id = ?';
$params[] = (int)$filter['customer_id'];
}
if (!empty($filter['search'])) {
$where[] = '(i.rechnungs_nr LIKE ? OR i.betreff LIKE ? OR c.nachname LIKE ? OR c.firma LIKE ?)';
$s = '%' . $filter['search'] . '%';
$params = array_merge($params, [$s, $s, $s, $s]);
}
$offset = ($page - 1) * $perPage;
$sql = 'SELECT i.*, c.firma, c.nachname, c.vorname, c.anrede, c.kundennummer
FROM invoices i
JOIN customers c ON i.customer_id = c.id
WHERE ' . implode(' AND ', $where) . '
ORDER BY i.rechnungs_datum DESC, i.id DESC
LIMIT ? OFFSET ?';
$params[] = $perPage;
$params[] = $offset;
$rows = Database::fetchAll($sql, $params);
$countSql = 'SELECT COUNT(*) as cnt FROM invoices i JOIN customers c ON i.customer_id = c.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 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);
}
}