Steuerkram/src/Controllers/InvoiceController.php
2026-03-25 00:19:33 +01:00

325 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Models\Invoice;
use Steuerkram\Models\Customer;
use Steuerkram\Models\Beleg;
use Steuerkram\Models\Booking;
use Steuerkram\Models\Setting;
use Steuerkram\Services\PdfInvoiceService;
use Steuerkram\View;
class InvoiceController
{
public function index(array $params): void
{
$filter = $_GET;
$page = max(1, (int)($_GET['page'] ?? 1));
$result = Invoice::all($filter, $page);
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Ausgangsrechnungen',
'content' => $this->renderView('invoices/list', [
'rows' => $result['rows'],
'total' => $result['total'],
'pages' => $result['pages'],
'page' => $page,
'filter' => $filter,
]),
'topbarActions' => '<a href="/invoices/new" class="btn btn-primary">+ Rechnung erstellen</a>',
]);
}
public function create(array $params): void
{
$customers = Customer::all();
$settings = Setting::get();
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Neue Rechnung',
'content' => $this->renderView('invoices/form', [
'invoice' => [
'rechnungs_datum' => date('Y-m-d'),
'leistungszeitraum_von' => date('Y-m-01'),
'leistungszeitraum_bis' => date('Y-m-t'),
],
'lines' => [],
'customers' => $customers,
'errors' => [],
'action' => '/invoices',
'settings' => $settings,
]),
]);
}
public function store(array $params): void
{
$data = $_POST;
$errors = $this->validate($data);
if ($errors) {
$customers = Customer::all();
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Neue Rechnung',
'content' => $this->renderView('invoices/form', [
'invoice' => $data,
'lines' => $data['lines'] ?? [],
'customers' => $customers,
'errors' => $errors,
'action' => '/invoices',
'settings' => Setting::get(),
]),
]);
return;
}
$id = Invoice::create($data);
View::redirect("/invoices/{$id}", 'Rechnung als Entwurf gespeichert.');
}
public function show(array $params): void
{
$invoice = Invoice::find((int)$params['id']);
if (!$invoice) { http_response_code(404); return; }
$lines = Invoice::lines((int)$params['id']);
$settings = Setting::get();
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Rechnung ' . ($invoice['rechnungs_nr'] ?? 'Entwurf'),
'content' => $this->renderView('invoices/show', [
'invoice' => $invoice,
'lines' => $lines,
'settings' => $settings,
]),
]);
}
public function edit(array $params): void
{
$invoice = Invoice::find((int)$params['id']);
if (!$invoice) { http_response_code(404); return; }
if ($invoice['status'] !== 'Entwurf') {
View::redirect("/invoices/{$invoice['id']}", 'Nur Entwürfe können bearbeitet werden.', 'error');
return;
}
$lines = Invoice::lines((int)$params['id']);
$customers = Customer::all();
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Rechnung bearbeiten',
'content' => $this->renderView('invoices/form', [
'invoice' => $invoice,
'lines' => $lines,
'customers' => $customers,
'errors' => [],
'action' => '/invoices/' . $invoice['id'],
'settings' => Setting::get(),
]),
]);
}
public function update(array $params): void
{
$id = (int)$params['id'];
$invoice = Invoice::find($id);
if (!$invoice) { http_response_code(404); return; }
$data = $_POST;
$errors = $this->validate($data);
if ($errors) {
$customers = Customer::all();
$view = new View();
$view->render('layout/base.php', [
'pageTitle' => 'Rechnung bearbeiten',
'content' => $this->renderView('invoices/form', [
'invoice' => array_merge($invoice, $data),
'lines' => $data['lines'] ?? [],
'customers' => $customers,
'errors' => $errors,
'action' => '/invoices/' . $id,
'settings' => Setting::get(),
]),
]);
return;
}
try {
Invoice::update($id, $data);
View::redirect("/invoices/{$id}", 'Rechnung gespeichert.');
} catch (\Exception $e) {
View::redirect("/invoices/{$id}", $e->getMessage(), 'error');
}
}
public function finalize(array $params): void
{
$id = (int)$params['id'];
try {
$nr = Invoice::finalize($id);
View::redirect("/invoices/{$id}", "Rechnung {$nr} wurde finalisiert.");
} catch (\Exception $e) {
View::redirect("/invoices/{$id}", $e->getMessage(), 'error');
}
}
public function pdf(array $params): void
{
$invoice = Invoice::find((int)$params['id']);
if (!$invoice) { http_response_code(404); return; }
$lines = Invoice::lines((int)$params['id']);
$settings = Setting::get();
$svc = new PdfInvoiceService();
$svc->render($invoice, $lines, $settings);
}
public function book(array $params): void
{
$id = (int)$params['id'];
$invoice = Invoice::find($id);
if (!$invoice) { http_response_code(404); return; }
if ($invoice['status'] === 'Entwurf') {
View::redirect("/invoices/{$id}", 'Rechnung muss zuerst finalisiert werden.', 'error');
return;
}
if (!empty($invoice['beleg_id'])) {
View::redirect("/invoices/{$id}", 'Rechnung wurde bereits gebucht.', 'warning');
return;
}
try {
\Steuerkram\Database::beginTransaction();
// Create Beleg for this invoice
$belegNr = Beleg::generateBelegNr();
$belegId = Beleg::create([
'beleg_nr' => $belegNr,
'filename' => $invoice['rechnungs_nr'] . '.pdf',
'storage_path' => '', // no physical file for outgoing invoices
'mime_type' => 'application/pdf',
'file_hash' => hash('sha256', $invoice['rechnungs_nr']),
'beleg_datum' => $invoice['rechnungs_datum'],
'eingangs_datum' => $invoice['rechnungs_datum'],
'rechnungs_nr' => $invoice['rechnungs_nr'],
'betrag_brutto' => $invoice['betrag_brutto'],
'belegtyp' => 'Ausgangsrechnung',
'notes' => 'Automatisch erstellt aus Rechnung ' . $invoice['rechnungs_nr'],
]);
$debitorKonto = \Steuerkram\Database::fetchOne(
'SELECT id FROM accounts WHERE kontonummer = ?', ['1400']
);
// Erlöse-Konten nach USt-Satz: 8000 (19%), 8200 (7%), 8400 (0%/steuerfrei)
$erloesKonten = [];
foreach (\Steuerkram\Database::fetchAll(
"SELECT id, kontonummer FROM accounts WHERE kontonummer IN ('8000','8200','8400')"
) as $row) {
$erloesKonten[$row['kontonummer']] = $row['id'];
}
// Buchungen pro USt-Satz aus den Rechnungszeilen
$lines = Invoice::lines($id);
$gruppenNetto = [];
foreach ($lines as $line) {
$satz = (float)$line['ust_satz'];
$netto = round((float)$line['menge'] * (float)$line['einzelpreis'], 2);
$gruppenNetto[$satz] = ($gruppenNetto[$satz] ?? 0.0) + $netto;
}
foreach ($gruppenNetto as $satz => $netto) {
if (abs($satz - 19) < 0.01) {
$habenId = $erloesKonten['8000'] ?? null;
} elseif (abs($satz - 7) < 0.01) {
$habenId = $erloesKonten['8200'] ?? null;
} else {
$habenId = $erloesKonten['8400'] ?? null;
}
if (!$habenId) {
throw new \RuntimeException("Erlöskonto für USt-Satz {$satz}% nicht gefunden.");
}
Booking::create([
'beleg_id' => $belegId,
'buchungs_datum' => $invoice['rechnungs_datum'],
'soll_konto_id' => $debitorKonto['id'],
'haben_konto_id' => $habenId,
'betrag_netto' => round($netto, 2),
'ust_satz' => $satz,
'buchungstext' => 'Ausgangsrechnung ' . $invoice['rechnungs_nr'],
]);
}
// Link beleg to invoice
\Steuerkram\Database::execute(
'UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $id]
);
\Steuerkram\Database::commit();
View::redirect("/invoices/{$id}", 'Rechnung erfolgreich gebucht (Beleg ' . $belegNr . ').');
} catch (\Exception $e) {
\Steuerkram\Database::rollBack();
View::redirect("/invoices/{$id}", $e->getMessage(), 'error');
}
}
public function markPaid(array $params): void
{
Invoice::markPaid((int)$params['id']);
View::redirect('/invoices/' . $params['id'], 'Rechnung als bezahlt markiert.');
}
public function cancel(array $params): void
{
Invoice::cancel((int)$params['id']);
View::redirect('/invoices/' . $params['id'], 'Rechnung storniert.');
}
public function addLine(array $params): void
{
// This is handled client-side via JS; redirect back
View::redirect('/invoices/' . $params['id'] . '/edit');
}
public function deleteLine(array $params): void
{
\Steuerkram\Database::execute(
'DELETE FROM invoice_lines WHERE id=? AND invoice_id=?',
[(int)$params['line_id'], (int)$params['id']]
);
View::redirect('/invoices/' . $params['id'] . '/edit', 'Position gelöscht.');
}
private function validate(array $data): array
{
$errors = [];
if (empty($data['customer_id'])) $errors['customer_id'] = 'Pflichtfeld';
if (empty($data['betreff'])) $errors['betreff'] = 'Pflichtfeld';
if (empty($data['rechnungs_datum'])) $errors['rechnungs_datum'] = 'Pflichtfeld';
if (empty($data['lines'])) {
$errors['lines'] = 'Mindestens eine Position erforderlich.';
}
return $errors;
}
private function renderView(string $template, array $data): string
{
extract($data, EXTR_SKIP);
ob_start();
include dirname(__DIR__) . '/Views/' . $template . '.php';
return ob_get_clean();
}
}