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:
Kevin Adametz 2026-09-16 10:56:29 +00:00
parent f581e1eb3a
commit a00f9dc79a
33 changed files with 3606 additions and 195 deletions

View file

@ -0,0 +1,177 @@
<?php
namespace App\Services\Billing\Concerns;
use Illuminate\Support\Str;
/**
* Minimaler PDF-Zeichensatz (PDF 1.4, Helvetica, WinAnsi) ohne externe
* Bibliothek — gemeinsam genutzt vom Archiv-Renderer (Legacy) und dem
* MAN-Rechnungs-Renderer. Koordinaten in PDF-Punkten, Ursprung unten links.
*/
trait DrawsPdf
{
protected const PAGE_WIDTH = 595;
protected const PAGE_HEIGHT = 842;
protected const LEFT = 91;
protected const RIGHT = 524;
/**
* @param array<int, array{0: string, 1: string, 2: bool}|null> $rows
*/
protected 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;
}
protected function formatEuro(float $amount): string
{
return number_format($amount, 2, ',', '.').' €';
}
protected 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;
}
protected function escapePdfText(string $text): string
{
$encoded = iconv('UTF-8', 'Windows-1252//TRANSLIT//IGNORE', $text);
return str_replace(['\\', '(', ')'], ['\\\\', '\(', '\)'], $encoded ?: Str::ascii($text));
}
protected 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),
);
}
protected 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);
}
protected 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>
*/
protected 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>
*/
protected function splitLines(string $text): array
{
return explode("\n", str_replace(["\r\n", "\r"], "\n", $text));
}
protected function textWidth(string $text, int $size): float
{
return Str::length($text) * $size * 0.48;
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,306 @@
<?php
namespace App\Services\Billing;
use App\Enums\ActivityType;
use App\Enums\InvoiceStatus;
use App\Exceptions\ManualInvoiceActionException;
use App\Mail\GrandfatheringTerminated;
use App\Mail\ManualInvoiceIssued;
use App\Models\Invoice;
use App\Models\LegacyInvoice;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\ActivityLog;
use App\Services\Mail\EmailSuppressionService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
* Bearbeitungsweg einer MAN-Rechnung nach der Ausstellung (Legacy-
* Überweisungskunden, dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md):
*
* senden → (erinnern) → als bezahlt markieren
* → Bestandsschutz beenden (Rechnung storniert, Bündel gekündigt)
*
* Jede Aktion ist ein Admin-Klick, läuft transaktional und hinterlässt
* einen Eintrag im Aktivitäts-Verlauf. Die Zahlungsfrist zählt ab Versand
* (Entscheidung E1), nicht ab dem 04:30-Lauf.
*/
class ManualInvoiceDunningService
{
public function __construct(
private readonly EmailSuppressionService $suppressions,
private readonly LegacySubscriptionService $legacy,
private readonly ActivityLog $activityLog,
) {}
/**
* Erstversand: Rechnung per Mail mit PDF, Zahlungsziel ab heute.
*/
public function send(Invoice $invoice, User $actor): Invoice
{
$this->assertOpenManual($invoice);
$this->assertDeliverable($invoice);
if ($invoice->sent_at !== null) {
throw new ManualInvoiceActionException(sprintf(
'Rechnung %s wurde bereits am %s versendet — bitte „Erinnerung senden" verwenden.',
$invoice->number,
$invoice->sent_at->format('d.m.Y'),
));
}
$dueDate = today()->addDays((int) config('billing.manual_due_days', 14));
DB::transaction(function () use ($invoice, $actor, $dueDate): void {
$invoice->update([
'sent_at' => now(),
'due_date' => $dueDate,
]);
Mail::to($invoice->user)->queue(new ManualInvoiceIssued($invoice->user, $invoice));
$this->activityLog->record(
ActivityType::ManualInvoice,
sprintf('Rechnung %s an %s gesendet, zahlbar bis %s.', $invoice->number, $invoice->user->email, $dueDate->format('d.m.Y')),
$invoice->user,
$invoice,
['action' => 'send', 'due_date' => $dueDate->toDateString()],
actorLabel: $actor->name,
);
});
return $invoice->refresh();
}
/**
* Zahlungserinnerung mit kurzer Nachfrist. Setzt die Frist nur neu, wenn
* die ursprüngliche bereits verstrichen ist.
*/
public function remind(Invoice $invoice, User $actor): Invoice
{
$this->assertOpenManual($invoice);
$this->assertDeliverable($invoice);
if ($invoice->sent_at === null) {
throw new ManualInvoiceActionException(sprintf(
'Rechnung %s wurde noch nicht versendet — bitte zuerst „Rechnung senden".',
$invoice->number,
));
}
$graceDays = (int) config('billing.reminder_due_days', 7);
$dueDate = $invoice->due_date !== null && $invoice->due_date->isAfter(today())
? $invoice->due_date
: today()->addDays($graceDays);
DB::transaction(function () use ($invoice, $actor, $dueDate): void {
$invoice->update([
'reminded_at' => now(),
'reminder_count' => $invoice->reminder_count + 1,
'due_date' => $dueDate,
]);
Mail::to($invoice->user)->queue(new ManualInvoiceIssued($invoice->user, $invoice, isReminder: true));
$this->activityLog->record(
ActivityType::ManualInvoice,
sprintf('Zahlungserinnerung (%d.) zu Rechnung %s gesendet, zahlbar bis %s.', $invoice->reminder_count, $invoice->number, $dueDate->format('d.m.Y')),
$invoice->user,
$invoice,
['action' => 'remind', 'due_date' => $dueDate->toDateString(), 'reminder_count' => $invoice->reminder_count],
actorLabel: $actor->name,
);
});
return $invoice->refresh();
}
/**
* Zahlungseingang per Kontoauszug verbucht.
*/
public function markPaid(Invoice $invoice, Carbon $paidAt, User $actor): Invoice
{
$this->assertOpenManual($invoice);
if ($paidAt->isAfter(now())) {
throw new ManualInvoiceActionException('Das Zahlungsdatum darf nicht in der Zukunft liegen.');
}
DB::transaction(function () use ($invoice, $actor, $paidAt): void {
$invoice->update([
'status' => InvoiceStatus::Paid->value,
'paid_at' => $paidAt,
]);
$this->activityLog->record(
ActivityType::ManualInvoice,
sprintf('Rechnung %s als bezahlt markiert (Zahlungseingang %s, %s €).', $invoice->number, $paidAt->format('d.m.Y'), number_format($invoice->total_cents / 100, 2, ',', '.')),
$invoice->user,
$invoice,
['action' => 'mark_paid', 'paid_at' => $paidAt->toDateString()],
actorLabel: $actor->name,
);
});
return $invoice->refresh();
}
/**
* Keine Zahlung: Rechnung stornieren, Bestandsschutz-Bündel des Kunden
* kündigen, weitere offene MAN-Rechnungen desselben Kunden ebenfalls
* stornieren (ein Kunde, ein Bündel, ein Vorgang).
*
* @return int Anzahl der gekündigten Bestandsschutz-Posten
*/
public function terminateForNonPayment(Invoice $invoice, User $actor, bool $notifyCustomer = true, ?string $note = null): int
{
$this->assertOpenManual($invoice);
$user = $invoice->user;
$reason = trim('Bestandsschutz beendet: keine Zahlung bis zum Zahlungsziel'
.($invoice->due_date ? ' ('.$invoice->due_date->format('d.m.Y').')' : '')
.'.'.($note ? ' '.$note : ''));
return DB::transaction(function () use ($invoice, $user, $actor, $reason, $notifyCustomer): int {
$siblings = Invoice::query()
->manual()
->open()
->where('user_id', $user->id)
->whereKeyNot($invoice->getKey())
->get();
foreach ([$invoice, ...$siblings] as $open) {
$open->update([
'status' => InvoiceStatus::Void->value,
'voided_at' => now(),
'void_reason' => $reason,
]);
}
$cancelled = $this->legacy->cancelBundleFor($user);
$this->activityLog->record(
ActivityType::ManualInvoice,
sprintf(
'Rechnung %s storniert%s. %s',
$invoice->number,
$siblings->isNotEmpty() ? ' (sowie '.$siblings->pluck('number')->implode(', ').')' : '',
$reason,
),
$user,
$invoice,
['action' => 'terminate', 'voided' => [$invoice->number, ...$siblings->pluck('number')->all()]],
actorLabel: $actor->name,
);
$this->activityLog->record(
ActivityType::SubscriptionChange,
sprintf('Bestandsschutz beendet (%d Posten): keine Zahlung zu Rechnung %s.', $cancelled, $invoice->number),
$user,
null,
['action' => 'terminate_grandfathering', 'invoice' => $invoice->number, 'posts' => $cancelled],
actorLabel: $actor->name,
);
if ($notifyCustomer && ! $this->suppressions->isSuppressed($user->email)) {
Mail::to($user)->queue(new GrandfatheringTerminated($user, $invoice));
}
return $cancelled;
});
}
/**
* Blockierte Vereinbarung ohne Abrechnung entfernen (Kunde nicht mehr
* erreichbar, keine Rechnungsadresse o. ä.): Bündel kündigen, offene
* MAN-Rechnungen des Kunden stornieren und eine noch offene Alt-Rechnung
* im Archiv abschreiben. Keine Kundenmail.
*
* @return int Anzahl der gekündigten Bestandsschutz-Posten
*/
public function abandonAgreement(UserPaymentOption $option, User $actor, ?string $note = null): int
{
$user = $option->user;
if (! $user) {
throw new ManualInvoiceActionException('Die Vereinbarung hat keinen zugeordneten Kunden.');
}
if ($option->stripe_subscription_id !== null) {
throw new ManualInvoiceActionException('Stripe-Abos werden über das Stripe-Dashboard beendet.');
}
$reason = trim('Vereinbarung ohne Abrechnung entfernt (nicht abrechenbar).'.($note ? ' '.$note : ''));
return DB::transaction(function () use ($option, $user, $actor, $reason): int {
$openInvoices = Invoice::query()->manual()->open()->where('user_id', $user->id)->get();
foreach ($openInvoices as $open) {
$open->update([
'status' => InvoiceStatus::Void->value,
'voided_at' => now(),
'void_reason' => $reason,
]);
}
$legacyNumbers = [];
foreach (UserPaymentOption::query()->where('user_id', $user->id)->get() as $post) {
$openLegacy = $post->legacy_conditions['open_legacy_invoice'] ?? null;
$legacyInvoice = isset($openLegacy['id']) ? LegacyInvoice::query()->find($openLegacy['id']) : null;
if ($legacyInvoice && in_array($legacyInvoice->status, [LegacyInvoice::STATUS_PENDING, LegacyInvoice::STATUS_REMINDER], true)) {
$legacyInvoice->forceFill(['status' => LegacyInvoice::STATUS_UNCOLLECTIBLE])->save();
$legacyNumbers[] = $legacyInvoice->number;
}
}
$cancelled = $this->legacy->cancelBundleFor($user);
$this->activityLog->record(
ActivityType::SubscriptionChange,
sprintf(
'Bestandsschutz entfernt (%d Posten). %s%s%s',
$cancelled,
$reason,
$legacyNumbers ? ' Alt-Rechnung abgeschrieben: '.implode(', ', $legacyNumbers).'.' : '',
$openInvoices->isNotEmpty() ? ' Storniert: '.$openInvoices->pluck('number')->implode(', ').'.' : '',
),
$user,
$option,
['action' => 'abandon', 'posts' => $cancelled, 'legacy_invoices' => $legacyNumbers, 'voided' => $openInvoices->pluck('number')->all()],
actorLabel: $actor->name,
);
return $cancelled;
});
}
private function assertOpenManual(Invoice $invoice): void
{
if (! $invoice->isManual()) {
throw new ManualInvoiceActionException(sprintf('Rechnung %s ist eine Stripe-Rechnung — Stripe übernimmt Versand und Zahlungseinzug.', $invoice->number));
}
if ($invoice->status !== InvoiceStatus::Open) {
throw new ManualInvoiceActionException(sprintf('Rechnung %s ist nicht mehr offen (%s).', $invoice->number, $invoice->status->label()));
}
if (! $invoice->user) {
throw new ManualInvoiceActionException(sprintf('Rechnung %s hat keinen zugeordneten Kunden.', $invoice->number));
}
}
private function assertDeliverable(Invoice $invoice): void
{
if ($this->suppressions->isSuppressed($invoice->user->email)) {
throw new ManualInvoiceActionException(sprintf(
'Die Adresse %s steht auf der Mail-Sperrliste (Rückläufer). Bitte zuerst die Adresse klären oder den Bestandsschutz beenden.',
$invoice->user->email,
));
}
}
}

View file

@ -0,0 +1,267 @@
<?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;
if (! $option?->current_period_start || ! $option->current_period_end) {
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') ?? 'n/a';
$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', []));
}
}

View file

@ -6,6 +6,7 @@ use App\Enums\InvoiceStatus;
use App\Enums\UserPaymentOptionStatus;
use App\Models\Invoice;
use App\Models\InvoiceBillingAddress;
use App\Models\LegacyInvoice;
use App\Models\UserPaymentOption;
use App\Services\Billing\Concerns\LocksModelForUpdate;
use Illuminate\Support\Carbon;
@ -125,8 +126,12 @@ class ManualInvoiceService
'vat_id' => $billingAddress->vat_id,
]);
$conditions = $lockedOption->legacy_conditions ?? [];
$openLegacyInvoice = $conditions['open_legacy_invoice'] ?? null;
$invoice = Invoice::query()->create([
'user_id' => $user->id,
'user_payment_option_id' => $lockedOption->id,
'invoice_billing_address_id' => $addressSnapshot->id,
'number' => $this->numbers->nextManualNumber(),
'status' => InvoiceStatus::Open->value,
@ -138,9 +143,18 @@ class ManualInvoiceService
'tax_note' => $treatment->taxNote(),
'invoice_date' => $asOf,
'due_date' => $asOf->copy()->addDays((int) config('billing.manual_due_days', 14)),
'replaces_legacy_invoice_number' => $openLegacyInvoice['number'] ?? null,
]);
// Eine offene Alt-Rechnung (Phase B, `legacy:settle-unpaid`) ist
// mit dieser MAN-Rechnung ersetzt — Vermerk einmalig verbrauchen.
if ($openLegacyInvoice) {
unset($conditions['open_legacy_invoice']);
$this->markLegacyInvoiceSuperseded($openLegacyInvoice, $invoice);
}
$lockedOption->update([
'legacy_conditions' => $conditions ?: null,
'current_period_start' => $lockedOption->current_period_end,
'current_period_end' => $interval === 'yearly'
? $lockedOption->current_period_end->copy()->addYear()
@ -151,6 +165,28 @@ class ManualInvoiceService
});
}
/**
* @param array{number?: string, id?: int} $openLegacyInvoice
*/
private function markLegacyInvoiceSuperseded(array $openLegacyInvoice, Invoice $invoice): void
{
$legacyInvoice = isset($openLegacyInvoice['id'])
? LegacyInvoice::query()->find($openLegacyInvoice['id'])
: null;
if (! $legacyInvoice) {
return;
}
$legacyInvoice->forceFill([
'status' => LegacyInvoice::STATUS_SUPERSEDED,
'raw_snapshot' => array_merge($legacyInvoice->raw_snapshot ?? [], [
'superseded_by_invoice_number' => $invoice->number,
'superseded_at' => now()->toDateString(),
]),
])->save();
}
private function billingInterval(UserPaymentOption $option): ?string
{
$interval = $option->legacy_conditions['interval']