presseportale/app/Services/Billing/ManualInvoicePdfRenderer.php
Kevin Adametz 58bc82a91f billing: tote Null-Pruefungen auf NOT-NULL-Spalten entfernt (phpstan)
current_period_start/-end und invoice_date sind NOT NULL; die nullsafe-
Zugriffe und der Fallback 'n/a' konnten nie greifen. Suite wieder auf
"No errors".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:34:56 +00:00

269 lines
9.4 KiB
PHP

<?php
namespace App\Services\Billing;
use App\Models\Invoice;
use App\Models\InvoiceBillingAddress;
use App\Services\Billing\Concerns\DrawsPdf;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
/**
* Rechnungs-PDF für den manuellen Kreis (MAN-): Legacy-Überweisungskunden.
*
* Wird on-demand aus dem Rechnungsdatensatz gerendert (kein Ablage-Pfad),
* wie das Archiv-PDF. Aussteller, Bankverbindung und Steuerangaben kommen
* aus `billing.issuer`; Empfänger aus dem eingefrorenen Adress-Snapshot der
* Rechnung; Leistung und Zeitraum aus der abgerechneten Vereinbarung.
*/
class ManualInvoicePdfRenderer
{
use DrawsPdf;
public function inlineResponse(Invoice $invoice): Response
{
return response($this->render($invoice), Response::HTTP_OK, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$this->filename($invoice).'"',
'Cache-Control' => 'private, max-age=0, must-revalidate',
]);
}
public function render(Invoice $invoice): string
{
$invoice->loadMissing(['invoiceBillingAddress', 'userPaymentOption.paymentOption', 'user']);
$content = '';
$this->drawHeader($content);
$this->drawBody($content, $invoice);
$this->drawFooter($content);
return $this->buildPdf($content);
}
public function filename(Invoice $invoice): string
{
$number = preg_replace('/[^A-Za-z0-9._-]/', '-', Str::ascii((string) $invoice->number)) ?: (string) $invoice->id;
return "Rechnung-{$number}.pdf";
}
/**
* Leistungsbezeichnung wie sie auf Rechnung und in der Mail erscheint.
*/
public function serviceName(Invoice $invoice): string
{
$option = $invoice->userPaymentOption;
$conditions = $option->legacy_conditions ?? [];
$name = $conditions['name']
?? $option->paymentOption->article_number
?? 'Leistung laut Vereinbarung';
$portal = match ($conditions['legacy_portal'] ?? null) {
'presseecho' => 'Presseecho',
'businessportal24' => 'Businessportal24',
default => null,
};
return $portal ? "{$name} auf {$portal}" : (string) $name;
}
/**
* Abgerechneter Leistungszeitraum. Nach dem Ausstellen zeigt die
* Vereinbarung bereits auf die fakturierte Periode (Start = alter
* Periodenschluss), deshalb reicht ihr aktueller Stand.
*
* @return array{0: string, 1: string}|null
*/
public function servicePeriod(Invoice $invoice): ?array
{
$option = $invoice->userPaymentOption;
// Beide Spalten sind NOT NULL — ohne Vereinbarung gibt es keinen
// Zeitraum, mit Vereinbarung immer einen.
if ($option === null) {
return null;
}
return [
$option->current_period_start->format('d.m.Y'),
$option->current_period_end->copy()->subDay()->format('d.m.Y'),
];
}
private function drawHeader(string &$content): void
{
$issuer = $this->issuer();
$content .= $this->text(0, 790, $issuer['name'], 21, 'F2', align: 'center');
$content .= $this->line(180, 778, 415, 778, 0.7);
$content .= $this->text(0, 762, implode(', ', array_filter([
$issuer['name'],
$issuer['owner'],
$issuer['street'],
trim($issuer['postal_code'].' '.$issuer['city']),
])), 9, align: 'center');
$content .= $this->text(0, 748, $issuer['website'], 10, 'F2', align: 'center');
}
private function drawBody(string &$content, Invoice $invoice): void
{
$invoiceDate = $invoice->invoice_date->format('d.m.Y');
$y = 700.0;
$content .= $this->text(self::LEFT, $y, 'Rechnungsdatum: '.$invoiceDate, 9);
$y -= 34;
foreach ($this->addressLines($invoice->invoiceBillingAddress) as $line) {
$content .= $this->text(self::LEFT, $y, $line, 9);
$y -= 13;
}
$y -= 26;
$content .= $this->text(self::LEFT, $y, 'Leistung:', 9);
$y -= 15;
$y = $this->wrappedText($content, self::LEFT, $y, $this->serviceName($invoice), 10, 400, 13, 'F2');
$y -= 3;
if ($period = $this->servicePeriod($invoice)) {
$content .= $this->text(self::LEFT, $y, 'Leistungszeitraum: '.$period[0].' - '.$period[1], 9);
} else {
$content .= $this->text(self::LEFT, $y, 'Leistungsdatum: '.$invoiceDate, 9);
}
$y -= 17;
$content .= $this->text(self::LEFT, $y, 'Rechnungsnummer: '.$invoice->number, 10, 'F2');
$y -= 13;
if ($invoice->replaces_legacy_invoice_number) {
$content .= $this->text(self::LEFT, $y, 'Ersetzt die Rechnung Nr. '.$invoice->replaces_legacy_invoice_number.' aus dem bisherigen System.', 8);
$y -= 13;
}
$y -= 21;
$taxLabel = $invoice->is_netto
? null
: sprintf('MwSt. %d%%:', (int) round(config('billing.vat_rate', 0.19) * 100));
$y = $this->invoiceTable($content, self::LEFT, $y, [
['Rechnungsstellung:', $invoiceDate, false],
['Netto:', $this->formatEuro($invoice->amount_cents / 100), false],
$taxLabel ? [$taxLabel, $this->formatEuro($invoice->tax_cents / 100), false] : null,
['Rechnungsbetrag:', $this->formatEuro($invoice->total_cents / 100), true],
['Zahlbar bis:', $invoice->due_date?->format('d.m.Y') ?? '-', true],
]);
$y -= 22;
foreach ($this->paymentLines($invoice) as $line) {
$y = $this->wrappedText($content, self::LEFT, $y, $line, 9, 430, 12);
if ($line === '') {
$y += 3;
}
}
}
private function drawFooter(string &$content): void
{
$issuer = $this->issuer();
$content .= $this->line(self::LEFT, 76, self::RIGHT, 76, 0.5);
$columns = [
[implode("\n", array_filter([$issuer['name'], $issuer['owner'], $issuer['street'], trim($issuer['postal_code'].' '.$issuer['city'])])), 91, 62],
[implode("\n", array_filter(['Tel: '.$issuer['phone'], 'Mail: '.$issuer['email'], 'Site: '.$issuer['website']])), 165, 62],
[implode("\n", ['Bankverbindung', $issuer['bank_name'], 'IBAN: '.$issuer['iban'], 'BIC: '.$issuer['bic']]), 292, 62],
[implode("\n", array_filter(['Steuernummer:', $issuer['tax_number'], 'USt-ID: '.config('billing.own_vat_id')])), 445, 62],
];
foreach ($columns as [$text, $x, $y]) {
$this->wrappedText($content, $x, $y, $text, 6, 105, 8);
}
}
/**
* @return list<string>
*/
private function addressLines(?InvoiceBillingAddress $address): array
{
if (! $address) {
return [];
}
$salutation = config('salutations.items.'.$address->salutation_key.'.de');
$salutation = in_array($address->salutation_key, ['mr', 'mrs'], true) ? $salutation : null;
$person = trim(implode(' ', array_filter([$salutation, $address->title, $address->name])));
$lines = [
$address->company,
$address->company === $address->name ? null : $person,
$address->address1,
$address->address2,
trim((string) $address->postal_code.' '.(string) $address->city),
$this->countryName($address->country_code),
$address->vat_id ? 'USt-ID: '.$address->vat_id : null,
];
return array_values(array_filter($lines, fn (?string $line): bool => filled($line)));
}
/**
* @return list<string>
*/
private function paymentLines(Invoice $invoice): array
{
$issuer = $this->issuer();
$dueDate = $invoice->due_date?->format('d.m.Y');
$lines = [
$dueDate
? 'Bitte überweisen Sie den Rechnungsbetrag bis zum '.$dueDate.' auf unser Geschäftskonto:'
: 'Bitte überweisen Sie den Rechnungsbetrag auf unser Geschäftskonto:',
'',
'Kontoinhaber: '.$issuer['account_holder'],
'IBAN: '.$issuer['iban'],
'BIC: '.$issuer['bic'],
'Bank: '.$issuer['bank_name'],
'',
'Als Verwendungszweck geben Sie bitte "Rechnungsnummer '.$invoice->number.'" an.',
'',
'Bitte ignorieren Sie diese Zahlungsaufforderung, falls der Betrag bereits entrichtet wurde.',
];
if ($invoice->tax_note) {
$lines[] = '';
$lines[] = $invoice->tax_note;
}
return $lines;
}
private function countryName(?string $countryCode): ?string
{
return match (strtoupper((string) $countryCode)) {
'DE' => 'Deutschland',
'AT' => 'Österreich',
'CH' => 'Schweiz',
'NL' => 'Niederlande',
'BE' => 'Belgien',
'FR' => 'Frankreich',
'IT' => 'Italien',
'ES' => 'Spanien',
'PL' => 'Polen',
'LU' => 'Luxemburg',
'LI' => 'Liechtenstein',
'GB' => 'Vereinigtes Königreich',
'US' => 'USA',
'' => null,
default => strtoupper((string) $countryCode),
};
}
/**
* @return array<string, string>
*/
private function issuer(): array
{
return array_map(fn (mixed $value): string => (string) $value, (array) config('billing.issuer', []));
}
}