| Server IP : 202.61.199.114 / Your IP : 216.73.217.139 Web Server : nginx/1.22.1 System : Linux de.arni-solutions.de 6.1.0-49-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.174-1 (2026-05-26) x86_64 User : web20 ( 1018) PHP Version : 8.4.23 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : ON Directory : /var/www/arni-solutions.de/web/crm/app/ |
Upload File : |
<?php
/**
* Rechnungs-PDF mit TCPDF (lokal vorhanden, keine CDN).
*/
class Pdf
{
/**
* Erzeugt das Rechnungs-PDF.
* @param int $invoiceId
* @param string $dest 'F' = in storage speichern, 'I' = inline anzeigen, 'D' = Download
* @return string|null Dateipfad (bei 'F') sonst null
*/
public static function invoice($invoiceId, $dest = 'F')
{
$tcpdf = cfg('paths.tcpdf');
if (!is_file($tcpdf)) {
throw new RuntimeException('TCPDF nicht gefunden unter ' . $tcpdf);
}
// Die ältere TCPDF-Version auf dem Server gibt PHP-Warnungen aus, die den
// PDF-Stream zerstören würden. Daher Anzeige während der PDF-Erzeugung aus
// und sämtliche Streu-Ausgaben über einen Output-Buffer verwerfen.
@ini_set('display_errors', '0');
error_reporting(error_reporting() & ~E_DEPRECATED & ~E_WARNING & ~E_NOTICE & ~E_STRICT);
ob_start();
require_once $tcpdf; // lädt die TCPDF-Basisklasse (CrmTcpdf erweitert sie via Autoloader)
$inv = Invoice::find($invoiceId);
if (!$inv) throw new RuntimeException('Rechnung nicht gefunden.');
$items = InvoiceItem::forInvoice($invoiceId);
$s = Setting::allAssoc();
$pdf = new CrmTcpdf('P', 'mm', 'A4', true, 'UTF-8');
$pdf->footerHtml = self::footerLine($s);
$pdf->SetCreator('ArNi CRM');
$pdf->SetAuthor($s['company_name'] ?? 'ArNi Solutions');
$pdf->SetTitle('Rechnung ' . ($inv['invoice_number'] ?: ''));
$pdf->setPrintHeader(false);
$pdf->setPrintFooter(true);
$pdf->setFooterMargin(18);
$pdf->SetMargins(18, 18, 18);
$pdf->SetAutoPageBreak(true, 26);
$pdf->SetFont('helvetica', '', 10);
$pdf->AddPage();
$html = self::buildHtml($inv, $items, $s);
$pdf->writeHTML($html, true, false, true, false, '');
$filename = 'Rechnung_' . preg_replace('/[^A-Za-z0-9_\-]/', '_', ($inv['invoice_number'] ?: ('Entwurf_' . $inv['id']))) . '.pdf';
// Aufgefangene TCPDF-Warnungen verwerfen, damit der Stream sauber ist.
if (ob_get_level() > 0) { ob_end_clean(); }
if ($dest === 'F') {
$path = cfg('paths.invoices') . '/' . $filename;
$pdf->Output($path, 'F');
Invoice::update($invoiceId, ['pdf_path' => $filename]);
return $path;
}
$pdf->Output($filename, $dest === 'D' ? 'D' : 'I');
return null;
}
private static function m($n) { return number_format((float)$n, 2, ',', '.') . ' €'; }
private static function q($n) { return rtrim(rtrim(number_format((float)$n, 3, ',', '.'), '0'), ','); }
/** Erkennt Deutschland (inkl. leer/Default) – dann wird das Land nicht ausgegeben. */
public static function isGermany($c)
{
$c = strtolower(trim((string)$c));
return $c === '' || in_array($c, ['deutschland', 'germany', 'de', 'd', 'brd', 'allemagne'], true);
}
private static function footerLine($s)
{
$bits = [];
$owner = !empty($s['company_owner']) ? ' · Inh. ' . $s['company_owner'] : '';
$bits[] = ($s['company_name'] ?? '') . $owner . ' · ' . ($s['company_address'] ?? '') . ', ' . ($s['company_zip'] ?? '') . ' ' . ($s['company_city'] ?? '');
$line2 = [];
if (!empty($s['company_phone'])) $line2[] = 'Tel: ' . $s['company_phone'];
if (!empty($s['company_email'])) $line2[] = $s['company_email'];
if (!empty($s['tax_number'])) $line2[] = 'St.-Nr.: ' . $s['tax_number'];
if (!empty($s['vat_id'])) $line2[] = 'USt-IdNr.: ' . $s['vat_id'];
if ($line2) $bits[] = implode(' · ', $line2);
$line3 = [];
if (!empty($s['bank_name'])) $line3[] = $s['bank_name'];
if (!empty($s['bank_iban'])) $line3[] = 'IBAN: ' . $s['bank_iban'];
if (!empty($s['bank_bic'])) $line3[] = 'BIC: ' . $s['bank_bic'];
if ($line3) $bits[] = implode(' · ', $line3);
return implode('<br>', array_map('htmlspecialchars', $bits));
}
private static function buildHtml($inv, $items, $s)
{
$logoHtml = '';
if (!empty($s['logo_path'])) {
$logoFile = cfg('paths.public') . '/uploads/' . $s['logo_path'];
if (is_file($logoFile)) {
$logoHtml = '<img src="' . $logoFile . '" height="60">';
}
}
$company = htmlspecialchars($s['company_name'] ?? 'ArNi Solutions');
$compAddr = nl2br(htmlspecialchars(
($s['company_address'] ?? '') . "\n" . ($s['company_zip'] ?? '') . ' ' . ($s['company_city'] ?? '') . "\n" . ($s['company_country'] ?? '')
));
// Rechnungs-Logo (dunkle, auf weißem Grund sichtbare Variante) oben links.
// Fällt auf Firmenname + Adresse zurück, falls die Datei fehlt.
$tplLogoFile = cfg('paths.public') . '/assets/img/invoice-logo.png';
$brandLogo = is_file($tplLogoFile)
? '<img src="' . $tplLogoFile . '" height="40">'
: $company . '<br>' . $compAddr;
// Empfänger – Ansprechpartner (bill_name) NICHT ausgeben, wenn eine Firma
// vorhanden ist. Ohne Firma ist die Person selbst der Empfänger.
// Land nur ausgeben, wenn der Empfänger außerhalb Deutschlands sitzt.
$rcptCountry = self::isGermany($inv['bill_country'] ?? '') ? '' : trim((string)$inv['bill_country']);
$rcpt = array_filter([
$inv['bill_company'],
empty($inv['bill_company']) ? ($inv['bill_name'] ?? '') : '',
$inv['bill_address1'],
trim(($inv['bill_zip'] ?? '') . ' ' . ($inv['bill_city'] ?? '')),
$rcptCountry,
]);
$rcptHtml = nl2br(htmlspecialchars(implode("\n", $rcpt)));
$senderSmall = htmlspecialchars(($s['company_name'] ?? '') . (!empty($s['company_owner']) ? ' · Inh. ' . $s['company_owner'] : '') . ' · ' . ($s['company_address'] ?? '') . ' · ' . ($s['company_zip'] ?? '') . ' ' . ($s['company_city'] ?? ''));
$number = htmlspecialchars($inv['invoice_number'] ?: '(Entwurf)');
$date = $inv['invoice_date'] ? date('d.m.Y', strtotime($inv['invoice_date'])) : '';
$due = $inv['due_date'] ? date('d.m.Y', strtotime($inv['due_date'])) : '';
$svc = $inv['service_date'] ? date('d.m.Y', strtotime($inv['service_date'])) : '';
// Kleinunternehmerregelung § 19 UStG: kein MwSt-Ausweis, stattdessen Pflichthinweis.
$klein = ((int)($s['inv_kleinunternehmer'] ?? 0)) === 1;
// Rabatt-Spalte nur, wenn mindestens eine Position einen Rabatt hat.
$hasDiscount = false;
foreach ($items as $it) { if ((float)($it['discount_percent'] ?? 0) > 0) { $hasDiscount = true; break; } }
// Frei werdende Spaltenbreiten (MwSt 8%, Rabatt 8%) fließen in die Beschreibung.
$descW = 38 + ($klein ? 8 : 0) + ($hasDiscount ? 0 : 8);
// Positionen
$rows = '';
$i = 1;
foreach ($items as $it) {
$desc = htmlspecialchars($it['title']);
if (!empty($it['description']) && $it['description'] !== $it['title']) {
$desc .= '<br><span style="color:#666;font-size:8px;">' . nl2br(htmlspecialchars($it['description'])) . '</span>';
}
$disc = (float)$it['discount_percent'] > 0 ? number_format((float)$it['discount_percent'], 0) . '%' : '–';
$rows .= '<tr>'
. '<td width="5%" align="center">' . $i++ . '</td>'
. '<td width="' . $descW . '%">' . $desc . '</td>'
. '<td width="13%" align="right">' . self::q($it['qty']) . ' ' . htmlspecialchars($it['unit']) . '</td>'
. '<td width="14%" align="right">' . self::m($it['unit_price']) . '</td>'
. ($hasDiscount ? '<td width="8%" align="right">' . $disc . '</td>' : '')
. ($klein ? '' : '<td width="8%" align="right">' . number_format((float)$it['tax_rate'], 0) . '%</td>')
. '<td width="14%" align="right">' . self::m($it['line_net']) . '</td>'
. '</tr>';
}
// Kopfzeile der Positionstabelle (Rabatt/MwSt nur wenn relevant)
$thDisc = $hasDiscount ? '<td width="8%" align="right">Rabatt</td>' : '';
$thMwst = $klein ? '' : '<td width="8%" align="right">MwSt</td>';
$thTotal = $klein ? 'Betrag' : 'Netto';
// Summenblock. Bei § 19 kein MwSt-Ausweis – nur Gesamtbetrag (= Nettobetrag).
$note = '';
if ($klein) {
$note = trim((string)($s['inv_kleinunternehmer_note'] ?? ''));
if ($note === '') $note = 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet (Kleinunternehmerregelung).';
$sumRows = '<tr style="font-weight:bold;font-size:11px;border-top:1px solid #333;">'
. '<td align="right" width="68%">Gesamtbetrag</td><td align="right" width="32%">' . self::m($inv['total_gross']) . '</td></tr>';
} else {
$tax = Invoice::taxBreakdown($items);
$taxRows = '';
foreach ($tax as $t) {
$taxRows .= '<tr><td align="right" width="68%">zzgl. ' . number_format($t['rate'], 0) . '% MwSt. (auf ' . self::m($t['net']) . ')</td>'
. '<td align="right" width="32%">' . self::m($t['tax']) . '</td></tr>';
}
$sumRows = '<tr><td align="right" width="68%">Zwischensumme netto</td><td align="right" width="32%">' . self::m($inv['subtotal_net']) . '</td></tr>'
. $taxRows
. '<tr style="font-weight:bold;font-size:11px;border-top:1px solid #333;">'
. '<td align="right" width="68%">Gesamtbetrag</td><td align="right" width="32%">' . self::m($inv['total_gross']) . '</td></tr>';
}
$textTop = nl2br(htmlspecialchars($inv['text_top'] ?: ($s['invoice_text_top'] ?? '')));
$textBottom = nl2br(htmlspecialchars($inv['text_bottom'] ?: ($s['invoice_text_bottom'] ?? '')));
// Info-Block (Zahlung + Umsatzsteuer-Hinweis) – schlicht, mit kleinen
// Akzent-Überschriften passend zum RECHNUNG-Blau, ohne farbige Kästen.
$H = function ($t) { return '<span style="font-size:8px;color:#3f51b5;font-weight:bold;">' . strtoupper($t) . '</span><br>'; };
$cols = [];
if (!empty($s['bank_iban'])) {
// Zahlungsdaten als ruhige Label-Wert-Tabelle: graue Labels, dunkle Werte.
$pv = function ($label, $val) {
return '<tr>'
. '<td width="34%"><span style="font-size:8px;color:#8a94a6;">' . $label . '</span></td>'
. '<td width="66%"><span style="font-size:10px;color:#222;">' . $val . '</span></td>'
. '</tr>';
};
$cols[] = $H('Zahlung')
. '<table cellpadding="2">'
. $pv('Fällig', '<strong>' . $due . '</strong>')
. $pv('Empfänger', htmlspecialchars($s['bank_name'] ?? ''))
. $pv('IBAN', htmlspecialchars($s['bank_iban']))
. $pv('BIC', htmlspecialchars($s['bank_bic'] ?? ''))
. $pv('Verwendungszweck', $number)
. '</table>';
}
if ($klein) {
$cols[] = $H('Umsatzsteuer') . '<br><span style="font-size:9px;color:#555;">' . nl2br(htmlspecialchars($note)) . '</span>';
}
$infoHtml = '';
if ($cols) {
$inner = count($cols) === 2
? '<table cellpadding="0"><tr><td width="52%" style="vertical-align:top;">' . $cols[0] . '</td>'
. '<td width="6%"></td><td width="42%" style="vertical-align:top;">' . $cols[1] . '</td></tr></table>'
: '<div>' . $cols[0] . '</div>';
$infoHtml = '<div style="border-top:1px solid #dcdfe6;font-size:1px;line-height:1px;"> </div><br>' . $inner . '<br>';
}
return '
<table cellpadding="2"><tr>
<td width="50%" style="vertical-align:top;">' . $brandLogo . '</td>
<td width="50%" align="right" style="font-size:9px;color:#333;vertical-align:top;">
<strong style="font-size:17px;color:#3f51b5;">RECHNUNG</strong><br><br>
Rechnungsnr.: <strong>' . $number . '</strong><br>
Datum: ' . $date . '<br>'
. ($svc ? 'Leistungsdatum: ' . $svc . '<br>' : '')
. 'Zahlungsziel: ' . $due . '
</td>
</tr></table>
<br>
<table cellpadding="2"><tr>
<td width="60%">
<span style="font-size:7px;color:#888;">' . $senderSmall . '</span>
<div style="border-bottom:1px solid #ccc;font-size:1px;line-height:1px;"> </div>
<div style="font-size:11px;">' . $rcptHtml . '</div>
</td>
<td width="40%"></td>
</tr></table>
<br><br><br>
<div style="font-size:10px;">' . $textTop . '</div>
<br>
<table border="0" cellpadding="5" style="font-size:11px;">
<thead>
<tr style="font-weight:bold;border-bottom:1px solid #333;">
<td width="5%" align="center">#</td>
<td width="' . $descW . '%">Beschreibung</td>
<td width="13%" align="right">Menge</td>
<td width="14%" align="right">Einzel</td>
' . $thDisc . '
' . $thMwst . '
<td width="14%" align="right">' . $thTotal . '</td>
</tr>
</thead>
<tbody>' . $rows . '</tbody>
</table>
<br>
<table cellpadding="3" style="font-size:11px;">
<tr><td width="50%"></td><td width="50%">
<table cellpadding="3">
' . $sumRows . '
</table>
</td></tr>
</table>
<br>
' . $infoHtml . '
<div style="font-size:9px;color:#444;">' . $textBottom . '</div>
';
}
}