render('layout/base.php', [ 'pageTitle' => 'Ausgangsrechnungen', 'content' => $this->renderView('invoices/list', [ 'rows' => $result['rows'], 'total' => $result['total'], 'pages' => $result['pages'], 'page' => $page, 'filter' => $filter, 'settings' => $settings, ]), 'topbarActions' => '' . '' . '' . '' . '', ]); } public function create(array $params): void { $customers = Customer::all(); $settings = Setting::get(); $invoiceDefaults = [ 'rechnungs_datum' => date('Y-m-d'), ]; $preCustomerId = (int)($_GET['customer_id'] ?? 0); if ($preCustomerId > 0) { $customer = Customer::find($preCustomerId); if ($customer) { $invoiceDefaults['customer_id'] = $preCustomerId; $invoiceDefaults['payment_days'] = $customer['payment_days']; } } $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Neue Rechnung', 'content' => $this->renderView('invoices/form', [ 'invoice' => $invoiceDefaults, '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(); // Querverweis: Gibt es eine Stornorechnung zu dieser Rechnung? $stornorechnung = \Steuerkram\Database::fetchOne( 'SELECT id, rechnungs_nr FROM invoices WHERE storno_of_id = ? LIMIT 1', [(int)$params['id']] ); // Querverweis: Ist dies eine Stornorechnung → Originalrechnung laden $originalrechnung = null; if (!empty($invoice['storno_of_id'])) { $originalrechnung = \Steuerkram\Database::fetchOne( 'SELECT id, rechnungs_nr, rechnungs_datum FROM invoices WHERE id = ?', [(int)$invoice['storno_of_id']] ); } // Buchungen + Konten für das Zahlungs-Modal laden (nur wenn Beleg vorhanden) $bookings = []; $accounts = []; if (!empty($invoice['beleg_id'])) { $bookings = Booking::forBeleg((int)$invoice['beleg_id']); $accounts = Account::all(['aktiv' => 1]); } // Kunde für CC-Vorbelegung im E-Mail-Modal $customer = !empty($invoice['customer_id']) ? \Steuerkram\Models\Customer::find((int)$invoice['customer_id']) : null; // Fehlende Pflichtangaben für die EN16931-XML-Rechnung ermitteln $xmlHinweise = (new ZugferdService())->collectMissingData($invoice, $lines, $settings); $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, 'xmlHinweise' => $xmlHinweise, 'stornorechnung' => $stornorechnung ?: null, 'originalrechnung' => $originalrechnung, 'bookings' => $bookings, 'accounts' => $accounts, 'customer' => $customer ?: null, ]), ]); } 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 (\PDOException $e) { error_log('[Steuerkram] PDO-Fehler in InvoiceController::update(): ' . $e->getMessage()); View::redirect("/invoices/{$id}", 'Datenbankfehler beim Speichern der Rechnung.', 'error'); } catch (\Exception $e) { View::redirect("/invoices/{$id}", $e->getMessage(), 'error'); } } public function finalize(array $params): void { $id = (int)$params['id']; try { \Steuerkram\Database::beginTransaction(); $nr = Invoice::finalize($id); $invoice = Invoice::find($id); $belegNr = self::createBeleg($invoice); \Steuerkram\Database::commit(); View::redirect("/invoices/{$id}", "Rechnung {$nr} wurde finalisiert (Beleg {$belegNr}). Bitte Zahlungseingang erfassen."); } catch (\PDOException $e) { \Steuerkram\Database::rollBack(); error_log('[Steuerkram] PDO-Fehler in InvoiceController::finalize(): ' . $e->getMessage()); View::redirect("/invoices/{$id}", 'Datenbankfehler beim Finalisieren der Rechnung.', 'error'); } catch (\Exception $e) { \Steuerkram\Database::rollBack(); View::redirect("/invoices/{$id}", $e->getMessage(), 'error'); } } public function send(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}", 'Entwürfe können nicht gesendet werden.', 'error'); return; } // Empfänger aus POST (bis zu 3), Fallback: Kunden-E-Mail $recipients = array_values(array_filter(array_map( 'trim', array_merge( [$_POST['email_to'] ?? $invoice['email'] ?? ''], [$_POST['email_cc1'] ?? ''], [$_POST['email_cc2'] ?? ''], ) ))); if (empty($recipients)) { View::redirect("/invoices/{$id}", 'Keine Empfänger-E-Mail-Adresse angegeben.', 'error'); return; } try { $lines = Invoice::lines($id); $settings = Setting::get(); $customer = !empty($invoice['customer_id']) ? \Steuerkram\Models\Customer::find((int)$invoice['customer_id']) : null; (new EmailService())->sendInvoice($invoice, $lines, $settings, $recipients, $customer ?: null); if ($invoice['status'] === 'Finalisiert') { \Steuerkram\Database::execute( "UPDATE invoices SET status='Gesendet', updated_at=NOW() WHERE id=?", [$id] ); } View::redirect("/invoices/{$id}", 'Rechnung erfolgreich gesendet.'); } catch (\RuntimeException $e) { View::redirect("/invoices/{$id}", 'Fehler beim Senden: ' . $e->getMessage(), 'error'); } } public function pdf(array $params): void { $invoice = Invoice::find((int)$params['id']); if (!$invoice) { http_response_code(404); return; } // Gespeicherte PDF aus dem Beleg ausliefern (GoBD: unveränderlicher Stand) $beleg = null; if (!empty($invoice['beleg_id'])) { $beleg = Beleg::find((int)$invoice['beleg_id']); $absPath = ($beleg && !empty($beleg['storage_path'])) ? Beleg::absolutePath($beleg) : ''; if ($absPath && is_file($absPath)) { header('Content-Type: application/pdf'); header('Content-Disposition: attachment; filename="' . $beleg['filename'] . '"'); header('Content-Length: ' . filesize($absPath)); readfile($absPath); return; } } // PDF generieren (Entwurf oder Altrechnung ohne gespeicherte Datei) if (!empty($invoice['storno_of_id'])) { $original = Invoice::find((int)$invoice['storno_of_id']); if ($original) { $invoice['original_rechnungs_nr'] = $original['rechnungs_nr']; $invoice['original_rechnungs_datum'] = $original['rechnungs_datum']; } } $lines = Invoice::lines((int)$params['id']); $settings = Setting::get(); $svc = new PdfInvoiceService(); $pdf = $svc->renderToString($invoice, $lines, $settings); // Finalisierte Altrechnung: PDF nachträglich im Beleg speichern if ($invoice['status'] !== 'Entwurf' && $beleg !== null) { if (!empty($beleg['storage_path'])) { $relPath = $beleg['storage_path']; $absDir = dirname(Beleg::absolutePath($beleg)); } else { $year = date('Y', strtotime($invoice['rechnungs_datum'])); $relDir = $year . '/' . $beleg['beleg_nr']; $relPath = $relDir . '/original.pdf'; $absDir = Config::get('upload_dir') . '/' . $relDir; } if (!is_dir($absDir)) { mkdir($absDir, 0755, true); } $absFilePath = Beleg::absolutePath(['storage_path' => $relPath]); file_put_contents($absFilePath, $pdf); \Steuerkram\Database::execute( 'UPDATE belege SET storage_path=?, filename=?, file_hash=?, updated_at=NOW() WHERE id=?', [$relPath, ($invoice['rechnungs_nr'] ?? 'Rechnung') . '.pdf', hash('sha256', $pdf), (int)$invoice['beleg_id']] ); } $prefix = !empty($invoice['storno_of_id']) ? 'Stornorechnung-' : 'Rechnung-'; $filename = $prefix . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf'; header('Content-Type: application/pdf'); header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Content-Length: ' . strlen($pdf)); echo $pdf; } public function xml(array $params): void { $id = (int)$params['id']; $invoice = Invoice::find($id); if (!$invoice) { http_response_code(404); return; } $lines = Invoice::lines($id); $settings = Setting::get(); try { $xml = (new ZugferdService())->createXml($invoice, $lines, $settings); } catch (\Throwable $e) { error_log('[Steuerkram] Fehler in InvoiceController::xml(): ' . $e->getMessage()); View::redirect("/invoices/{$id}", 'XML-Rechnung konnte nicht erzeugt werden.', 'error'); return; } $prefix = !empty($invoice['storno_of_id']) ? 'Stornorechnung-' : 'Rechnung-'; $filename = $prefix . ($invoice['rechnungs_nr'] ?: 'Entwurf') . '.xml'; header('Content-Type: application/xml; charset=UTF-8'); header('Content-Disposition: attachment; filename="' . $filename . '"'); header('Content-Length: ' . strlen($xml)); echo $xml; } 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}", 'Kein Beleg zugeordnet.', 'error'); return; } $bookings = Booking::forBeleg((int)$invoice['beleg_id']); $accounts = Account::all(['aktiv' => 1]); $formErrors = []; $formData = []; $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Zahlungseingang – ' . ($invoice['rechnungs_nr'] ?? 'Rechnung'), 'content' => $this->renderView('invoices/book', [ 'invoice' => $invoice, 'bookings' => $bookings, 'accounts' => $accounts, 'formErrors' => $formErrors, 'formData' => $formData, ]), ]); } public function storeBooking(array $params): void { $id = (int)$params['id']; $invoice = Invoice::find($id); if (!$invoice) { http_response_code(404); return; } $belegId = (int)($invoice['beleg_id'] ?? 0); if (!$belegId) { View::redirect("/invoices/{$id}", 'Kein Beleg zugeordnet.', 'error'); return; } $data = $_POST; $errors = []; if (empty($data['kategorie_konto_id'])) $errors['kategorie_konto_id'] = 'Pflichtfeld'; if (empty($data['zahlungskonto_id'])) $errors['zahlungskonto_id'] = 'Pflichtfeld'; if (empty($data['buchungs_datum'])) $errors['buchungs_datum'] = 'Pflichtfeld'; if (!isset($data['betrag_netto']) || $data['betrag_netto'] === '') $errors['betrag_netto'] = 'Pflichtfeld'; if (empty($data['buchungstext'])) $errors['buchungstext'] = 'Pflichtfeld'; if (empty($errors) && $data['kategorie_konto_id'] === $data['zahlungskonto_id']) { $errors['zahlungskonto_id'] = 'Kategorie- und Zahlungskonto dürfen nicht identisch sein.'; } if ($errors) { if (!empty($data['_from_modal'])) { View::redirect("/invoices/{$id}", implode(' ', $errors), 'error'); return; } $bookings = Booking::forBeleg($belegId); $accounts = Account::all(['aktiv' => 1]); $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Zahlungseingang – ' . ($invoice['rechnungs_nr'] ?? 'Rechnung'), 'content' => $this->renderView('invoices/book', [ 'invoice' => $invoice, 'bookings' => $bookings, 'accounts' => $accounts, 'formErrors' => $errors, 'formData' => $data, ]), ]); return; } try { $data['beleg_id'] = $belegId; $data['buchungstyp'] = 'Einnahme'; Booking::create($data); $returnUrl = !empty($data['_from_modal']) ? "/invoices/{$id}" : "/invoices/{$id}/book"; View::redirect($returnUrl, 'Buchung erfasst.'); } catch (\PDOException $e) { error_log('[Steuerkram] PDO-Fehler in InvoiceController::storeBooking(): ' . $e->getMessage()); $bookings = Booking::forBeleg($belegId); $accounts = Account::all(['aktiv' => 1]); $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Zahlungseingang – ' . ($invoice['rechnungs_nr'] ?? 'Rechnung'), 'content' => $this->renderView('invoices/book', [ 'invoice' => $invoice, 'bookings' => $bookings, 'accounts' => $accounts, 'formErrors' => ['_exception' => 'Datenbankfehler beim Speichern der Buchung.'], 'formData' => $data, ]), ]); } catch (\Exception $e) { $bookings = Booking::forBeleg($belegId); $accounts = Account::all(['aktiv' => 1]); $view = new View(); $view->render('layout/base.php', [ 'pageTitle' => 'Zahlungseingang – ' . ($invoice['rechnungs_nr'] ?? 'Rechnung'), 'content' => $this->renderView('invoices/book', [ 'invoice' => $invoice, 'bookings' => $bookings, 'accounts' => $accounts, 'formErrors' => ['_exception' => $e->getMessage()], 'formData' => $data, ]), ]); } } public function quickBook(array $params): void { $id = (int)$params['id']; $invoice = Invoice::find($id); if (!$invoice) { http_response_code(404); return; } $data = $_POST; if (empty($data['kategorie_konto_id']) || empty($data['zahlungskonto_id']) || empty($data['buchungs_datum'])) { View::redirect("/invoices/{$id}", 'Bitte Konto und Datum angeben.', 'error'); return; } try { $cnt = count(Invoice::createBookingsFromLines( $id, (int)$data['kategorie_konto_id'], (int)$data['zahlungskonto_id'], $data['buchungs_datum'] )); View::redirect("/invoices/{$id}", "{$cnt} Buchung(en) automatisch erfasst."); } catch (\RuntimeException $e) { View::redirect("/invoices/{$id}", $e->getMessage(), 'error'); } } public static function createBeleg(array $invoice): string { $id = (int)$invoice['id']; $lines = Invoice::lines($id); $settings = Setting::get(); // Bei Stornorechnung Originaldaten für Referenzzeile ergänzen if (!empty($invoice['storno_of_id'])) { $original = Invoice::find((int)$invoice['storno_of_id']); if ($original) { $invoice['original_rechnungs_nr'] = $original['rechnungs_nr']; $invoice['original_rechnungs_datum'] = $original['rechnungs_datum']; } } // PDF zum Zeitpunkt der Finalisierung unveränderlich generieren und speichern (GoBD) $svc = new PdfInvoiceService(); $pdf = $svc->renderToString($invoice, $lines, $settings); $belegNr = Beleg::generateBelegNr(); $year = date('Y', strtotime($invoice['rechnungs_datum'])); $relDir = "{$year}/{$belegNr}"; $absDir = Config::get('upload_dir') . "/{$relDir}"; if (!is_dir($absDir)) mkdir($absDir, 0755, true); $filename = $invoice['rechnungs_nr'] . '.pdf'; $storagePath = "{$relDir}/original.pdf"; file_put_contents(Config::get('upload_dir') . "/{$storagePath}", $pdf); $belegId = Beleg::create([ 'invoice_id' => $id, 'beleg_nr' => $belegNr, 'filename' => $filename, 'storage_path' => $storagePath, 'mime_type' => 'application/pdf', 'file_hash' => hash('sha256', $pdf), 'beleg_datum' => $invoice['rechnungs_datum'], 'rechnungs_nr' => $invoice['rechnungs_nr'], 'betrag_brutto' => $invoice['betrag_brutto'], 'belegtyp' => 'Ausgangsrechnung', 'notes' => (!empty($invoice['storno_of_id']) ? 'Stornorechnung zu Rechnung ' : 'Automatisch erstellt aus Rechnung ') . $invoice['rechnungs_nr'], ]); \Steuerkram\Database::execute( 'UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $id] ); return $belegNr; } public function destroy(array $params): void { if (!Setting::isTestMode()) { View::redirect('/invoices', 'Löschen ist nur im Testmodus erlaubt.', 'error'); return; } $id = (int)$params['id']; Invoice::delete($id); View::redirect('/invoices', 'Rechnung gelöscht.'); } 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 { $stornoId = Invoice::createCancellation((int)$params['id']); $storno = Invoice::find($stornoId); if ($storno) { self::createBeleg($storno); } View::redirect('/invoices/' . $stornoId, 'Stornorechnung erstellt.'); } 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; } }