485 lines
18 KiB
PHP
485 lines
18 KiB
PHP
<?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);
|
||
}
|
||
}
|