billing: MAN-Rechnungen fuer Ueberweisungskunden bearbeiten, hold-Altlast bereinigen
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>
This commit is contained in:
parent
ada41cb067
commit
36691a5b13
33 changed files with 3606 additions and 195 deletions
|
|
@ -3,19 +3,14 @@
|
|||
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
|
||||
{
|
||||
private const PAGE_WIDTH = 595;
|
||||
|
||||
private const PAGE_HEIGHT = 842;
|
||||
|
||||
private const LEFT = 91;
|
||||
|
||||
private const RIGHT = 524;
|
||||
use DrawsPdf;
|
||||
|
||||
public function inlineResponse(LegacyInvoice $invoice): Response
|
||||
{
|
||||
|
|
@ -241,26 +236,6 @@ class LegacyInvoicePdfRenderer
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{0: string, 1: string, 2: bool}|null> $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<string, mixed> $data
|
||||
* @return list<string>
|
||||
|
|
@ -328,140 +303,4 @@ class LegacyInvoicePdfRenderer
|
|||
|
||||
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<string>
|
||||
*/
|
||||
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<string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue