Steuerkram/src/Controllers/InvoiceController.php
fds2610-bot 3e5810990c Fix #217: Content-Disposition-Header gegen Parameter-Injection maskieren
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 15:20:21 +02:00

603 lines
23 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace Steuerkram\Controllers;
use Steuerkram\Config;
use Steuerkram\Models\Invoice;
use Steuerkram\Models\Customer;
use Steuerkram\Models\Beleg;
use Steuerkram\Models\Booking;
use Steuerkram\Models\Account;
use Steuerkram\Models\Setting;
use Steuerkram\Services\EmailService;
use Steuerkram\Services\PdfInvoiceService;
use Steuerkram\Services\ZugferdService;
use Steuerkram\View;
class InvoiceController extends BaseController
{
public function index(array $params): void
{
$filter = $_GET;
$page = max(1, (int)($_GET['page'] ?? 1));
$result = Invoice::all($filter, $page);
$settings = Setting::get();
$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,
'settings' => $settings,
]),
'topbarActions' => '<a href="/invoices/new" class="btn btn-primary" title="Rechnung erstellen" style="display:inline-flex;align-items:center;justify-content:center;padding:.36rem;border-radius:50%;width:1.8rem;height:1.8rem">'
. '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">'
. '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>'
. '</svg>'
. '</a>',
]);
}
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 = Invoice::findStorno((int)$params['id']);
// Querverweis: Ist dies eine Stornorechnung → Originalrechnung laden
$originalrechnung = null;
if (!empty($invoice['storno_of_id'])) {
$originalrechnung = Invoice::findReference((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 {
[$nr, $belegNr] = Invoice::finalizeWithBeleg($id, fn(array $invoice): string => self::createBeleg($invoice));
View::redirect("/invoices/{$id}", "Rechnung {$nr} wurde finalisiert (Beleg {$belegNr}). Bitte Zahlungseingang erfassen.");
} catch (\PDOException $e) {
error_log('[Steuerkram] PDO-Fehler in InvoiceController::finalize(): ' . $e->getMessage());
View::redirect("/invoices/{$id}", 'Datenbankfehler beim Finalisieren der Rechnung.', 'error');
} catch (\Exception $e) {
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);
Invoice::markSent($id, $invoice['status']);
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: ' . View::contentDispositionHeader('attachment', $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);
Beleg::updateStorage(
(int)$invoice['beleg_id'],
$relPath,
($invoice['rechnungs_nr'] ?? 'Rechnung') . '.pdf',
hash('sha256', $pdf)
);
}
$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 = $this->validateBookingInput($data);
if ($errors) {
if (!empty($data['_from_modal'])) {
View::redirect("/invoices/{$id}", implode(' ', $errors), 'error');
return;
}
$this->renderBookForm($invoice, $belegId, $errors, $data);
return;
}
$this->persistBooking($id, $invoice, $belegId, $data);
}
private function validateBookingInput(array $data): array
{
$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.';
}
return $errors;
}
private function renderBookForm(array $invoice, int $belegId, array $errors, array $data): void
{
$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,
]),
]);
}
private function persistBooking(int $id, array $invoice, int $belegId, array $data): void
{
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());
$this->renderBookForm($invoice, $belegId, ['_exception' => 'Datenbankfehler beim Speichern der Buchung.'], $data);
} catch (\Exception $e) {
$this->renderBookForm($invoice, $belegId, ['_exception' => $e->getMessage()], $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'],
]);
Invoice::setBelegId($id, $belegId);
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
{
Invoice::deleteLine((int)$params['id'], (int)$params['line_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;
}
}