render($invoice); return response($pdf, Response::HTTP_OK, [ 'Content-Type' => 'application/pdf', 'Content-Disposition' => 'inline; filename="'.$this->filename($invoice).'"', 'Cache-Control' => 'private, max-age=0, must-revalidate', ]); } public function downloadResponse(LegacyInvoice $invoice): Response { $pdf = $this->render($invoice); return response()->streamDownload( static function () use ($pdf): void { echo $pdf; }, $this->filename($invoice), [ 'Content-Type' => 'application/pdf', 'Cache-Control' => 'private, max-age=0, must-revalidate', ], ); } public function render(LegacyInvoice $invoice): string { $data = $this->data($invoice); $content = ''; $isMedia = $data['is_media']; $this->drawHeader($content, $isMedia); $this->drawInvoiceBody($content, $data); $this->drawFooter($content, $isMedia); return $this->buildPdf($content); } public function filename(LegacyInvoice $invoice): string { $number = filled($invoice->number) ? (string) $invoice->number : (string) $invoice->legacy_id; $number = preg_replace('/[^A-Za-z0-9._-]/', '-', Str::ascii($number)) ?: (string) $invoice->id; $portal = preg_replace('/[^A-Za-z0-9._-]/', '-', Str::ascii($invoice->legacy_portal->label())); return "{$portal}-RNr-{$number}.pdf"; } /** * @return array{ * invoice: LegacyInvoice, * billing_address: array, * invoice_data: array, * is_media: bool, * is_netto: bool, * tax_percent: int, * amount: float, * net_amount: float, * tax_amount: float, * service_name: string, * service_period_begin: string, * service_period_end: string * } */ private function data(LegacyInvoice $invoice): array { $payload = $invoice->pdf_payload ?? []; $billingAddress = data_get($payload, 'billing_address') ?: []; $invoiceData = data_get($payload, 'invoice', $invoice->raw_snapshot ?? []); $invoice->loadMissing('user.profile'); $isNetto = (bool) data_get($invoiceData, 'is_netto', false); $isMedia = (bool) data_get($invoiceData, 'is_media', true); $taxPercent = $this->taxPercent($invoice); $amount = $invoice->total_cents / 100; $netAmount = $isNetto ? $amount : $amount / (1 + ($taxPercent / 100)); $taxAmount = $isNetto ? 0 : $amount - $netAmount; $servicePeriodBegin = $this->formatLegacyDate(data_get($invoiceData, 'service_period_begin_date')); $servicePeriodEnd = $this->formatLegacyDate(data_get($invoiceData, 'service_period_end_date')); $serviceName = data_get($payload, 'payment_option_translation.name') ?? data_get($payload, 'payment_option.article_number') ?? 'Legacy-Leistung'; return [ 'invoice' => $invoice, 'billing_address' => $billingAddress, 'invoice_data' => $invoiceData, 'is_media' => $isMedia, 'is_netto' => $isNetto, 'tax_percent' => $taxPercent, 'amount' => $amount, 'net_amount' => $netAmount, 'tax_amount' => $taxAmount, 'service_name' => (string) $serviceName, 'service_period_begin' => $servicePeriodBegin, 'service_period_end' => $servicePeriodEnd, ]; } private function drawHeader(string &$content, bool $isMedia): void { if ($isMedia) { $content .= $this->text(0, 790, 'adametz.media', 21, 'F2', align: 'center'); $content .= $this->line(180, 778, 415, 778, 0.7); $content .= $this->text(0, 762, 'adametz.media, Kevin Adametz, In der Lake 4, 33739 Bielefeld', 9, align: 'center'); $content .= $this->text(0, 748, 'www.businessportal24.com', 10, 'F2', align: 'center'); return; } $content .= $this->text(self::LEFT, 790, 'Stern Consulting GmbH', 19, 'F2'); $content .= $this->line(self::LEFT, 778, self::RIGHT, 778, 0.7); $content .= $this->text(self::LEFT, 760, 'Stern Consulting GmbH * Emser Straße 3 * 10719 Berlin', 10, 'F2'); } /** * @param array $data */ private function drawInvoiceBody(string &$content, array $data): void { /** @var LegacyInvoice $invoice */ $invoice = $data['invoice']; $billingAddress = $data['billing_address']; $invoiceDate = $invoice->invoice_date?->format('d.m.Y') ?? 'n/a'; $invoiceNumber = $invoice->number ?? '#'.$invoice->legacy_id; $y = 700.0; $content .= $this->text(self::LEFT, $y, 'Rechnungsdatum: '.$invoiceDate, 9); $y -= 34; foreach ($this->addressLines($invoice, $billingAddress) as $line) { $content .= $this->text(self::LEFT, $y, $line, 9); $y -= 13; } $y -= 26; $content .= $this->text(self::LEFT, $y, 'Leistung:', 9); $y -= 15; $content .= $this->text(self::LEFT, $y, $data['service_name'].' auf '.$invoice->legacy_portal->label(), 10, 'F2'); $y = $this->wrappedText($content, self::LEFT, $y - 16, $this->pressReleaseTitle($data), 9, 360); $y -= 6; $periodLabel = $data['service_period_begin'] === $data['service_period_end'] ? 'Leistungsdatum: '.$data['service_period_begin'] : 'Leistungszeitraum: '.$data['service_period_begin'].' - '.$data['service_period_end']; $content .= $this->text(self::LEFT, $y, $periodLabel, 9); $y -= 17; $content .= $this->text(self::LEFT, $y, 'Rechnungsnummer: '.$invoiceNumber, 10, 'F2'); $y -= 34; $y = $this->invoiceTable($content, self::LEFT, $y, [ ['Rechnungsstellung:', $invoiceDate, false], ['Netto:', $this->formatEuro($data['net_amount']), false], $data['is_netto'] ? null : ['MwSt. '.$data['tax_percent'].'%:', $this->formatEuro($data['tax_amount']), false], ['Rechnungsbetrag:', $this->formatEuro($data['amount']), true], ]); $y -= 22; foreach ($this->paymentLines($data) as $line) { $content .= $this->text(self::LEFT, $y, $line, 9); $y -= $line === '' ? 9 : 12; } } private function drawFooter(string &$content, bool $isMedia): void { $content .= $this->line(self::LEFT, 76, self::RIGHT, 76, 0.5); if ($isMedia) { $columns = [ ["adametz.media\nKevin Adametz\nIn der Lake 4\n33739 Bielefeld", 91, 62], ["Tel: +49 5206 7076721\nMail: info@businessportal24.com\nSite: www.businessportal24.com", 165, 62], ["Bankverbindung\nSparkasse Bielefeld\nIBAN: DE96 4805 0161 0065 0356 02\nBIC: SPBIDE3BXXX", 292, 62], ["Steuernummer:\n349 / 5001 / 4350\nUSt-ID: DE298729654", 445, 62], ]; } else { $columns = [ ["Stern Consulting GmbH\nEmser Straße 3\n10719 Berlin\nGF: Thomas Stern", 91, 62], ["Tel: +49 (0)30 / 700 9410 0\nFax: +49 (0)30 / 700 9410 44\nMail: info@stern-consulting.de\nSite: www.stern-consulting.de", 185, 62], ["Bankverbindung\nHypo Vereinsbank\nBLZ: 10020890\nK-Nr: 22865552", 325, 62], ["Registergericht:\nAmtsgericht Charlottenburg\nHRB 134586 B\nUSt-Id-Nr.: DE277693156", 430, 62], ]; } foreach ($columns as [$text, $x, $y]) { $this->wrappedText($content, $x, $y, $text, 6, 105, 8); } } /** * @param array $billingAddress * @return list */ private function addressLines(LegacyInvoice $invoice, array $billingAddress): array { $salutation = trim((string) data_get($billingAddress, 'salutation')); $name = trim($salutation.' '.(string) data_get($billingAddress, 'name')); $city = trim((string) data_get($billingAddress, 'postal_code').' '.(string) data_get($billingAddress, 'city')); $lines = [ $name, (string) data_get($billingAddress, 'title'), ...$this->splitLines((string) data_get($billingAddress, 'address')), $city, (string) data_get($billingAddress, 'country_name'), $invoice->user?->profile?->tax_id_number ? 'UID-Nr.: '.$invoice->user->profile->tax_id_number : null, ]; return array_values(array_filter($lines, fn (?string $line): bool => filled($line))); } /** * @param array $data */ private function pressReleaseTitle(array $data): string { return (string) ( data_get($data, 'invoice_data.press_release_title') ?? data_get($data, 'invoice_data.press_release.name') ?? '' ); } /** * @param array $rows */ private function invoiceTable(string &$content, float $x, float $y, array $rows): float { $width = 275.0; $rowHeight = 22.0; $valueX = $x + 160; $content .= $this->line($x, $y, $x + $width, $y, 0.5); foreach (array_values(array_filter($rows)) as [$label, $value, $bold]) { $y -= $rowHeight; $content .= $this->text($x, $y + 7, $label, 9, $bold ? 'F2' : 'F1'); $content .= $this->text($valueX, $y + 7, $value, 9, $bold ? 'F2' : 'F1'); $content .= $this->line($x, $y, $x + $width, $y, 0.5); } return $y; } /** * @param array $data * @return list */ private function paymentLines(array $data): array { /** @var LegacyInvoice $invoice */ $invoice = $data['invoice']; $invoiceNumber = $invoice->number ?? '#'.$invoice->legacy_id; if (! $data['is_media']) { $lines = [ 'Bitte überweisen Sie den Rechnungsbetrag innerhalb von 7 Werktagen auf unser Geschäftskonto:', '', 'Kontoinhaber: Stern Consulting GmbH', 'IBAN: DE23100208900022865552', 'BIC: HYVEDEMM488', 'Bank: Hypo Vereinsbank', '', 'Als Verwendungszweck geben Sie bitte "Rechungsnummer '.$invoiceNumber.'" an!', '', 'Bitte ignorieren Sie diese Zahlungsaufforderung, falls der Betrag bereits entrichtet wurde.', ]; } else { $lines = [ 'Bitte überweisen Sie den Rechnungsbetrag innerhalb von 7 Werktagen auf unser Geschäftskonto:', '', 'Wichtig! Ab Januar 2024 gilt die neue Bankverbindung:', '', 'Kontoinhaber: adametz.media, Kevin Adametz', 'IBAN: DE96 4805 0161 0065 0356 02', 'BIC: SPBIDE3BXXX', 'Bank: Sparkasse Bielefeld', '', 'Als Verwendungszweck geben Sie bitte "Rechungsnummer '.$invoiceNumber.'" an!', '', 'Bitte ignorieren Sie diese Zahlungsaufforderung, falls der Betrag bereits entrichtet wurde.', ]; } if ($data['is_netto']) { $lines[] = ''; $lines[] = 'Leistungen im Reverse-Charge Verfahren - Steuerschuldnerschaft des Leistungsempfängers'; } return $lines; } private function taxPercent(LegacyInvoice $invoice): int { $invoiceDate = $invoice->invoice_date; if ($invoiceDate && $invoiceDate->betweenIncluded(Carbon::parse('2020-07-01'), Carbon::parse('2020-12-31'))) { return 16; } return 19; } private function formatLegacyDate(mixed $value): string { if (blank($value) || $value === '0000-00-00' || $value === '0000-00-00 00:00:00') { return 'n/a'; } return Carbon::parse((string) $value)->format('d.m.Y'); } private function formatEuro(float $amount): string { return number_format($amount, 2, ',', '.').' €'; } private function buildPdf(string $content): string { $objects = [ "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n", "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n", "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ".self::PAGE_WIDTH.' '.self::PAGE_HEIGHT."] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>\nendobj\n", "4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>\nendobj\n", "5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>\nendobj\n", "6 0 obj\n<< /Length ".strlen($content)." >>\nstream\n{$content}endstream\nendobj\n", ]; $pdf = "%PDF-1.4\n"; $offsets = [0]; foreach ($objects as $object) { $offsets[] = strlen($pdf); $pdf .= $object; } $xrefOffset = strlen($pdf); $pdf .= "xref\n0 ".(count($objects) + 1)."\n"; $pdf .= "0000000000 65535 f \n"; foreach (array_slice($offsets, 1) as $offset) { $pdf .= sprintf("%010d 00000 n \n", $offset); } $pdf .= "trailer\n<< /Size ".(count($objects) + 1)." /Root 1 0 R >>\n"; $pdf .= "startxref\n{$xrefOffset}\n%%EOF\n"; return $pdf; } private function escapePdfText(string $text): string { $encoded = iconv('UTF-8', 'Windows-1252//TRANSLIT//IGNORE', $text); return str_replace(['\\', '(', ')'], ['\\\\', '\(', '\)'], $encoded ?: Str::ascii($text)); } private function text(float $x, float $y, string $text, int $size = 9, string $font = 'F1', string $align = 'left'): string { if ($align === 'center') { $x = (self::PAGE_WIDTH - $this->textWidth($text, $size)) / 2; } return sprintf( "BT /%s %d Tf %.2F %.2F Td (%s) Tj ET\n", $font, $size, $x, $y, $this->escapePdfText($text), ); } private function line(float $x1, float $y1, float $x2, float $y2, float $width = 0.5): string { return sprintf("%.2F w %.2F %.2F m %.2F %.2F l S\n", $width, $x1, $y1, $x2, $y2); } private function wrappedText( string &$content, float $x, float $y, string $text, int $size, float $width, float $lineHeight = 12, string $font = 'F1', ): float { foreach ($this->wrap($text, $width, $size) as $line) { if ($line !== '') { $content .= $this->text($x, $y, $line, $size, $font); } $y -= $lineHeight; } return $y; } /** * @return list */ private function wrap(string $text, float $width, int $size): array { $lines = []; $maxCharacters = max(8, (int) floor($width / ($size * 0.48))); foreach ($this->splitLines($text) as $paragraph) { if ($paragraph === '') { $lines[] = ''; continue; } $line = ''; foreach (preg_split('/\s+/', $paragraph) ?: [] as $word) { $candidate = trim($line.' '.$word); if ($line !== '' && Str::length($candidate) > $maxCharacters) { $lines[] = $line; $line = $word; continue; } $line = $candidate; } if ($line !== '') { $lines[] = $line; } } return $lines; } /** * @return list */ private function splitLines(string $text): array { return explode("\n", str_replace(["\r\n", "\r"], "\n", $text)); } private function textWidth(string $text, int $size): float { return Str::length($text) * $size * 0.48; } }