Steuerkram/src/Controllers/InvoiceController.php
fds2610-bot 117c9a5c88 Fix #xml-rechnung: XML-Download (EN 16931 / XRechnung 3.0) in der Rechnungs-Detailansicht
XML-Symbol neben dem PDF-Symbol lädt die Rechnung als reine XML-Datei
(Profil XRechnung 3.0, CIUS von EN 16931). Fehlende Stamm- oder
Rechnungsdaten verhindern den Download nicht, werden aber mit BT-Nummer
in der Detailansicht ausgewiesen. Die PDF-Einbettung bleibt bei
Factur-X EN16931.
2026-07-31 18:20:16 +02:00

630 lines
24 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 = \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;
}
}