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' => '+ Rechnung erstellen', ]); } 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(); } }