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>
269 lines
9.4 KiB
PHP
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', []));
|
|
}
|
|
}
|