Steuerkram/src/Models/Invoice.php

287 lines
10 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.*, c.firma, c.nachname, c.vorname, c.anrede, c.kundennummer,
c.strasse, c.plz, c.ort, c.land, c.email, c.payment_days
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=\'Gesendet\', 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]);
}
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',
[(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]);
}
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;
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]);
}
}
public static function generateRechnungsNr(): string
{
$settings = Database::fetchOne(
'SELECT invoice_nr_prefix, invoice_nr_next, invoice_nr_year 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);
$year = (int)date('Y');
// Reset counter on new year
if ($savedYear !== $year) {
$next = 1;
}
$nr = sprintf('%s-%s-%04d', $prefix, $year, $next);
Database::execute(
'UPDATE company_settings SET invoice_nr_next=?, invoice_nr_year=? WHERE id=1',
[$next + 1, $year]
);
return $nr;
}
}