Bestandskunden aus dem Altsystem zahlen per Ueberweisung; nach dem 04:30-Lauf fehlte bisher der gesamte Bearbeitungsweg (kein PDF, keine Mail, kein Zahlungseingang, kein Bestandsschutz-Ende). Plan und Befund: dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md. Bereinigung (legacy:settle-unpaid, wiederholbar): - Legacy-Status hold = nie bezahlt, ersatzlos erledigt. 9 daraus abgeleitete Bestandsschutz-Buendel werden gekuendigt, MAN-00001/00002 storniert. Archivzeilen bleiben erhalten, sind aber in Admin-Archiv und Kundenkonto standardmaessig ausgeblendet. - pending/reminder (recurring) = offene Vorgaenge: Periode auf den Leistungsbeginn der unbezahlten Rechnung zurueckgedreht und als MAN-Rechnung neu ausgestellt (Archiv-Status superseded, replaces_legacy_invoice_number). - pending/reminder (single) = abgeschrieben (uncollectible). - legacy:grandfather-subscriptions wertet den Zahlungsstatus jetzt selbst aus. Workflow in /admin/payments (ManualInvoiceDunningService, je Aktion mit Eintrag im Aktivitaets-Verlauf): - Rechnung senden: Mail mit PDF-Anhang, Bankverbindung aus billing.issuer, Hinweis auf Bestandsschutz-Ende; Zahlungsziel zaehlt ab Versand. - Erinnerung: gleiche Mail im Mahn-Ton, Nachfrist bei verstrichener Frist. - Bezahlt: Zahlungseingang laut Kontoauszug verbuchen. - Bestandsschutz beenden: Rechnung storniert (nie geloescht), Buendel gekuendigt, weitere offene MAN-Rechnungen des Kunden storniert, optionale Kundenmail. - Panel "Blockierte Vereinbarungen": faellige Posten ohne Rechnung (meist fehlende Rechnungsadresse) mit "Jetzt abrechnen" oder "Entfernen". Schema: invoices bekommt user_payment_option_id, sent_at, reminded_at, reminder_count, voided_at, void_reason, replaces_legacy_invoice_number (inkl. Backfill der bestehenden MAN-Rechnungen). PDF-Primitiven des Archiv-Renderers in den Trait DrawsPdf ausgelagert; MAN-PDF on-demand ueber admin.invoices.pdf / me.invoices.manual-pdf (InvoicePolicy). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
306 lines
12 KiB
PHP
306 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Billing;
|
|
|
|
use App\Models\LegacyInvoice;
|
|
use App\Services\Billing\Concerns\DrawsPdf;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Str;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
class LegacyInvoicePdfRenderer
|
|
{
|
|
use DrawsPdf;
|
|
|
|
public function inlineResponse(LegacyInvoice $invoice): Response
|
|
{
|
|
$pdf = $this->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<string, mixed>,
|
|
* invoice_data: array<string, mixed>,
|
|
* 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<string, mixed> $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<string, mixed> $billingAddress
|
|
* @return list<string>
|
|
*/
|
|
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<string, mixed> $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<string, mixed> $data
|
|
* @return list<string>
|
|
*/
|
|
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');
|
|
}
|
|
}
|