- Migration 046: ustva_kz='83'/'84' für Konten 1776/1771, Bestandsmigration für Einnahme-Buchungen - Booking::create(): USt-Kindzeile auf 1771/1776 für Einnahmen mit 7/19 % (analog VSt) - UstvaCalculator: Kz 83/84 kontobasiert via 1771/1776, KZ_ACCOUNT_MAP aktualisiert - Invoice::cancel(): nur Hauptbuchungen direkt stornieren (parent_booking_id IS NULL) - Tests 10, 27 angepasst; Test 29 neu
377 lines
14 KiB
PHP
377 lines
14 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
|
|
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]);
|
|
}
|
|
|
|
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'], ['Finalisiert', 'Gesendet', 'Bezahlt'])) {
|
|
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;
|
|
|
|
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, 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;
|
|
}
|
|
|
|
$digits = max(2, strlen((string)$next));
|
|
$nr = sprintf('%s%04d%02d-%0' . $digits . 'd', $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;
|
|
}
|
|
}
|