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.
630 lines
24 KiB
PHP
630 lines
24 KiB
PHP
<?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;
|
||
}
|
||
}
|
||
|