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
|
|
@ -21,6 +21,13 @@ use Illuminate\Support\Facades\Storage;
|
|||
* = active` und eine nicht zu weit zurückliegende `next_due_date` trägt.
|
||||
* Alle anderen Rechnungen bleiben unangetastet im Archiv.
|
||||
*
|
||||
* Zahlungsstatus der jüngsten Rechnung (Einordnung 16.09.2026):
|
||||
* - `paid` → Bestandsschutz mit der laufenden Periode.
|
||||
* - `pending`/`reminder` → Bestandsschutz, aber die Rechnung ist offen: die
|
||||
* Periode wird auf den Leistungsbeginn dieser Rechnung gesetzt, sodass der
|
||||
* MAN-Kreis sie neu ausstellt (`legacy_conditions.open_legacy_invoice`).
|
||||
* - `hold` → nie bezahlt, kein Bestandsschutz (übersprungen).
|
||||
*
|
||||
* Die migrierte Vereinbarung trägt `current_period_end = next_due_date` —
|
||||
* damit stellt `billing:generate-manual-invoices` (MAN-Kreis) die nächste
|
||||
* Rechnung zum gewohnten Rhythmus mit den Beträgen der letzten
|
||||
|
|
@ -68,12 +75,24 @@ class GrandfatherLegacySubscriptions extends Command
|
|||
'created' => [],
|
||||
'updated' => [],
|
||||
'stale_skipped' => [],
|
||||
'unpaid_skipped' => [],
|
||||
'reopened_unpaid' => [],
|
||||
'immediately_due' => [],
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$nextDue = $candidate['next_due_date'];
|
||||
|
||||
if ($candidate['source_status'] === LegacyInvoice::STATUS_HOLD) {
|
||||
$report['unpaid_skipped'][] = $this->describe($candidate);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($candidate['open_legacy_invoice'] !== null) {
|
||||
$report['reopened_unpaid'][] = $this->describe($candidate);
|
||||
}
|
||||
|
||||
if ($nextDue->lessThan($staleBefore)) {
|
||||
$report['stale_skipped'][] = $this->describe($candidate);
|
||||
|
||||
|
|
@ -120,6 +139,8 @@ class GrandfatherLegacySubscriptions extends Command
|
|||
['Neu angelegt', count($report['created'])],
|
||||
['Aktualisiert (Re-Run)', count($report['updated'])],
|
||||
['Übersprungen (stale)', count($report['stale_skipped'])],
|
||||
['Übersprungen (hold, nie bezahlt)', count($report['unpaid_skipped'])],
|
||||
['Offene Alt-Rechnung → MAN neu', count($report['reopened_unpaid'])],
|
||||
['Davon sofort fällig', count($report['immediately_due'])],
|
||||
],
|
||||
);
|
||||
|
|
@ -197,6 +218,22 @@ class GrandfatherLegacySubscriptions extends Command
|
|||
? Carbon::parse($snapshot['service_period_begin_date'])->startOfDay()
|
||||
: $nextDue->copy()->subYear();
|
||||
|
||||
$openLegacyInvoice = null;
|
||||
|
||||
// Offene Rechnung: Die zuletzt fakturierte Periode ist noch nicht
|
||||
// bezahlt → Fälligkeit auf deren Leistungsbeginn setzen, damit der
|
||||
// MAN-Kreis genau diese Periode neu ausstellt.
|
||||
if (in_array($invoice->status, [LegacyInvoice::STATUS_PENDING, LegacyInvoice::STATUS_REMINDER], true)) {
|
||||
$openLegacyInvoice = [
|
||||
'id' => $invoice->id,
|
||||
'number' => $invoice->number,
|
||||
'invoice_date' => $invoice->invoice_date->toDateString(),
|
||||
'total_cents' => $invoice->total_cents,
|
||||
];
|
||||
$nextDue = $periodStart->copy();
|
||||
$periodStart = $nextDue->copy()->subYear();
|
||||
}
|
||||
|
||||
return [
|
||||
'user_id' => $invoice->user_id,
|
||||
'legacy_portal' => $invoice->legacy_portal->value,
|
||||
|
|
@ -207,6 +244,8 @@ class GrandfatherLegacySubscriptions extends Command
|
|||
'valid_until_date' => isset($upo['valid_until_date']) && $upo['valid_until_date']
|
||||
? Carbon::parse($upo['valid_until_date'])->startOfDay()
|
||||
: null,
|
||||
'source_status' => $invoice->status,
|
||||
'open_legacy_invoice' => $openLegacyInvoice,
|
||||
'legacy_conditions' => [
|
||||
'legacy_portal' => $invoice->legacy_portal->value,
|
||||
'legacy_user_payment_option_id' => (int) $upo['id'],
|
||||
|
|
@ -219,6 +258,7 @@ class GrandfatherLegacySubscriptions extends Command
|
|||
'last_is_netto' => (bool) ($snapshot['is_netto'] ?? false),
|
||||
'source_invoice_number' => $invoice->number,
|
||||
'source_invoice_date' => $invoice->invoice_date->toDateString(),
|
||||
...($openLegacyInvoice ? ['open_legacy_invoice' => $openLegacyInvoice] : []),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
348
app/Console/Commands/SettleUnpaidLegacyInvoices.php
Normal file
348
app/Console/Commands/SettleUnpaidLegacyInvoices.php
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\InvoiceStatus;
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\LegacyInvoice;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPaymentOption;
|
||||
use App\Services\ActivityLog;
|
||||
use App\Services\Billing\LegacySubscriptionService;
|
||||
use App\Services\Billing\ManualInvoiceService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Bereinigt die Altlast aus unbezahlten Legacy-Rechnungen (Einordnung
|
||||
* 16.09.2026, dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md, Phase B).
|
||||
*
|
||||
* `legacy:grandfather-subscriptions` hat Bestandsschutz-Posten auch aus
|
||||
* Rechnungen abgeleitet, die nie bezahlt wurden. Dieser Befehl löst das
|
||||
* anhand des Zahlungsstatus der jeweiligen Quellrechnung auf:
|
||||
*
|
||||
* - `hold` → Kunde zahlt seit Jahren nicht: Bestandsschutz-Bündel kündigen,
|
||||
* offene MAN-Rechnungen des Kunden stornieren. Die Archiv-
|
||||
* Rechnungen bleiben unverändert erhalten (werden nur ausgeblendet).
|
||||
* - `pending` / `reminder` (wiederkehrend) → echte offene Forderung 2026:
|
||||
* Periode auf die unbezahlte Rechnung zurückdrehen und sofort eine
|
||||
* MAN-Rechnung ausstellen, die die Alt-Rechnung ersetzt
|
||||
* (Archiv-Status `superseded`). Ab da gilt der Knopfdruck-Workflow.
|
||||
* - `pending` / `reminder` (Einzelkauf) → abschreiben (`uncollectible`).
|
||||
* - `paid` → unangetastet.
|
||||
*
|
||||
* Wiederholbar: Bereits gekündigte, stornierte oder ersetzte Vorgänge werden
|
||||
* beim nächsten Lauf übersprungen.
|
||||
*
|
||||
* Verwendung:
|
||||
* php artisan legacy:settle-unpaid --dry-run
|
||||
* php artisan legacy:settle-unpaid
|
||||
*/
|
||||
class SettleUnpaidLegacyInvoices extends Command
|
||||
{
|
||||
protected $signature = 'legacy:settle-unpaid
|
||||
{--dry-run : Nur anzeigen, nichts schreiben}
|
||||
{--no-invoice : Offene Vorgänge nur zurückdrehen, MAN-Rechnung dem 04:30-Lauf überlassen}
|
||||
{--no-report : Keinen JSON-Report schreiben}';
|
||||
|
||||
protected $description = 'Löst Bestandsschutz-Posten aus unbezahlten Legacy-Rechnungen auf (hold kündigen, offene Vorgänge neu ausstellen, Einzelkäufe abschreiben).';
|
||||
|
||||
private const VOID_REASON_HOLD = 'Vorjahresrechnung im Altsystem nie bezahlt (hold) — Bestandsschutz beendet.';
|
||||
|
||||
/** @var array<string, list<string>> */
|
||||
private array $report = [];
|
||||
|
||||
private bool $dryRun = false;
|
||||
|
||||
public function handle(
|
||||
LegacySubscriptionService $legacy,
|
||||
ManualInvoiceService $manualInvoices,
|
||||
ActivityLog $activityLog,
|
||||
): int {
|
||||
$this->dryRun = (bool) $this->option('dry-run');
|
||||
$this->report = [
|
||||
'bundles_cancelled' => [],
|
||||
'man_invoices_voided' => [],
|
||||
'reopened' => [],
|
||||
'man_invoices_issued' => [],
|
||||
'singles_written_off' => [],
|
||||
'conflicts' => [],
|
||||
'untouched_paid' => [],
|
||||
];
|
||||
|
||||
if ($this->dryRun) {
|
||||
$this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.');
|
||||
}
|
||||
|
||||
$postsByUser = UserPaymentOption::query()
|
||||
->where('status', UserPaymentOptionStatus::Grandfathered->value)
|
||||
->whereNull('cancelled_at')
|
||||
->with('user')
|
||||
->get()
|
||||
->groupBy('user_id');
|
||||
|
||||
foreach ($postsByUser as $userId => $posts) {
|
||||
$this->settleBundle((int) $userId, $posts, $legacy, $manualInvoices, $activityLog);
|
||||
}
|
||||
|
||||
$this->writeOffSingles();
|
||||
|
||||
$this->table(
|
||||
['Ergebnis', 'Anzahl'],
|
||||
collect($this->report)->map(fn (array $lines, string $key): array => [$key, count($lines)])->values()->all(),
|
||||
);
|
||||
|
||||
foreach ($this->report['conflicts'] as $line) {
|
||||
$this->warn('Konflikt: '.$line);
|
||||
}
|
||||
|
||||
if (! $this->option('no-report')) {
|
||||
$path = sprintf('migration/settle-unpaid-%s.json', now()->format('Ymd-His'));
|
||||
Storage::put($path, json_encode([
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'dry_run' => $this->dryRun,
|
||||
...$this->report,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
$this->info("Report: storage/app/{$path}");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Kunde = ein Bündel. Die Kündigung ist nur gemeinsam möglich, daher
|
||||
* wird nur gekündigt, wenn ALLE Posten des Kunden aus `hold`-Rechnungen
|
||||
* stammen; gemischte Bündel landen als Konflikt im Report.
|
||||
*
|
||||
* @param Collection<int, UserPaymentOption> $posts
|
||||
*/
|
||||
private function settleBundle(
|
||||
int $userId,
|
||||
Collection $posts,
|
||||
LegacySubscriptionService $legacy,
|
||||
ManualInvoiceService $manualInvoices,
|
||||
ActivityLog $activityLog,
|
||||
): void {
|
||||
$classified = $posts->map(fn (UserPaymentOption $post): array => [
|
||||
'post' => $post,
|
||||
'source' => $this->sourceInvoice($post),
|
||||
]);
|
||||
|
||||
$statuses = $classified
|
||||
->map(fn (array $entry): string => $entry['source']?->status ?? 'unknown')
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$user = $posts->first()?->user;
|
||||
|
||||
if ($statuses->count() === 1 && $statuses->first() === LegacyInvoice::STATUS_HOLD) {
|
||||
$this->cancelHoldBundle($userId, $user, $classified, $legacy, $activityLog);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($classified as ['post' => $post, 'source' => $source]) {
|
||||
$status = $source?->status;
|
||||
|
||||
if ($status === 'paid') {
|
||||
$this->report['untouched_paid'][] = $this->describe($post, $source);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($status, [LegacyInvoice::STATUS_PENDING, LegacyInvoice::STATUS_REMINDER], true)) {
|
||||
$this->reopen($post, $source, $manualInvoices, $activityLog);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// hold im gemischten Bündel oder Quelle nicht auffindbar → manuell klären.
|
||||
$this->report['conflicts'][] = $this->describe($post, $source).' · Status '.($status ?? 'unbekannt').' im gemischten Bündel';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, array{post: UserPaymentOption, source: ?LegacyInvoice}> $classified
|
||||
*/
|
||||
private function cancelHoldBundle(
|
||||
int $userId,
|
||||
?User $user,
|
||||
Collection $classified,
|
||||
LegacySubscriptionService $legacy,
|
||||
ActivityLog $activityLog,
|
||||
): void {
|
||||
foreach ($classified as ['post' => $post, 'source' => $source]) {
|
||||
$this->report['bundles_cancelled'][] = $this->describe($post, $source);
|
||||
}
|
||||
|
||||
$openInvoices = Invoice::query()
|
||||
->manual()
|
||||
->open()
|
||||
->where('user_id', $userId)
|
||||
->get();
|
||||
|
||||
foreach ($openInvoices as $invoice) {
|
||||
$this->report['man_invoices_voided'][] = sprintf('%s · User #%d · %s €', $invoice->number, $userId, $this->euro($invoice->total_cents));
|
||||
}
|
||||
|
||||
if ($this->dryRun || ! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user, $openInvoices, $legacy, $activityLog): void {
|
||||
$count = $legacy->cancelBundleFor($user);
|
||||
|
||||
foreach ($openInvoices as $invoice) {
|
||||
$invoice->update([
|
||||
'status' => InvoiceStatus::Void->value,
|
||||
'voided_at' => now(),
|
||||
'void_reason' => self::VOID_REASON_HOLD,
|
||||
]);
|
||||
|
||||
$activityLog->record(
|
||||
ActivityType::ManualInvoice,
|
||||
sprintf('Rechnung %s storniert: %s', $invoice->number, self::VOID_REASON_HOLD),
|
||||
$user,
|
||||
$invoice,
|
||||
['command' => $this->getName()],
|
||||
actorLabel: 'legacy:settle-unpaid',
|
||||
);
|
||||
}
|
||||
|
||||
$activityLog->record(
|
||||
ActivityType::SubscriptionChange,
|
||||
sprintf('Bestandsschutz beendet (%d Posten): Quellrechnung im Altsystem nie bezahlt (hold).', $count),
|
||||
$user,
|
||||
null,
|
||||
['command' => $this->getName(), 'posts' => $count],
|
||||
actorLabel: 'legacy:settle-unpaid',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Periode auf die unbezahlte Alt-Rechnung zurückdrehen und die offene
|
||||
* Forderung als MAN-Rechnung neu ausstellen.
|
||||
*/
|
||||
private function reopen(
|
||||
UserPaymentOption $post,
|
||||
LegacyInvoice $source,
|
||||
ManualInvoiceService $manualInvoices,
|
||||
ActivityLog $activityLog,
|
||||
): void {
|
||||
// Die unbezahlte Periode beginnt am Leistungsbeginn der Alt-Rechnung;
|
||||
// dorthin wird `current_period_end` zurückgedreht, damit der MAN-Kreis
|
||||
// genau diese Periode abrechnet und den gewohnten Rhythmus fortführt.
|
||||
$periodEnd = $this->legacyPeriodBegin($source);
|
||||
$periodStart = $periodEnd->copy()->subYear();
|
||||
|
||||
$this->report['reopened'][] = $this->describe($post, $source).sprintf(' · Periode → %s..%s', $periodStart->toDateString(), $periodEnd->toDateString());
|
||||
|
||||
if ($this->dryRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
$conditions = $post->legacy_conditions ?? [];
|
||||
$conditions['open_legacy_invoice'] = [
|
||||
'id' => $source->id,
|
||||
'number' => $source->number,
|
||||
'invoice_date' => $source->invoice_date->toDateString(),
|
||||
'total_cents' => $source->total_cents,
|
||||
];
|
||||
|
||||
$post->update([
|
||||
'legacy_conditions' => $conditions,
|
||||
'current_period_start' => $periodStart->toDateString(),
|
||||
'current_period_end' => $periodEnd->toDateString(),
|
||||
]);
|
||||
|
||||
$activityLog->record(
|
||||
ActivityType::ManualInvoice,
|
||||
sprintf('Offene Alt-Rechnung %s (%s €) wird im MAN-Kreis neu ausgestellt.', $source->number, $this->euro($source->total_cents)),
|
||||
$post->user,
|
||||
$post,
|
||||
['command' => $this->getName(), 'legacy_invoice_id' => $source->id],
|
||||
actorLabel: 'legacy:settle-unpaid',
|
||||
);
|
||||
|
||||
if ($this->option('no-invoice')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$invoice = $manualInvoices->invoiceFor($post->fresh());
|
||||
|
||||
if ($invoice) {
|
||||
$this->report['man_invoices_issued'][] = sprintf('%s ersetzt %s · User #%d · %s €', $invoice->number, $source->number, $post->user_id, $this->euro($invoice->total_cents));
|
||||
} else {
|
||||
$this->report['conflicts'][] = $this->describe($post, $source).' · MAN-Rechnung nicht ausstellbar (Rechnungsadresse fehlt?) — 04:30-Lauf versucht es erneut';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbezahlte Einzelkäufe (19,95-€-PMs) sind keine Bestandsschutz-Fälle
|
||||
* und werden abgeschrieben (Entscheidung E8).
|
||||
*/
|
||||
private function writeOffSingles(): void
|
||||
{
|
||||
$singles = LegacyInvoice::query()
|
||||
->openLegacy()
|
||||
->get()
|
||||
->filter(fn (LegacyInvoice $invoice): bool => ($invoice->pdf_payload['payment_option']['type'] ?? null) === 'single');
|
||||
|
||||
foreach ($singles as $invoice) {
|
||||
$this->report['singles_written_off'][] = sprintf('%s · User #%s · %s €', $invoice->number, $invoice->user_id ?? '-', $this->euro($invoice->total_cents));
|
||||
|
||||
if (! $this->dryRun) {
|
||||
$invoice->forceFill(['status' => LegacyInvoice::STATUS_UNCOLLECTIBLE])->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function sourceInvoice(UserPaymentOption $post): ?LegacyInvoice
|
||||
{
|
||||
$conditions = $post->legacy_conditions ?? [];
|
||||
$number = $conditions['source_invoice_number'] ?? null;
|
||||
|
||||
if (! $number) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return LegacyInvoice::query()
|
||||
->where('number', $number)
|
||||
->when($conditions['legacy_portal'] ?? null, fn ($query, string $portal) => $query->where('legacy_portal', $portal))
|
||||
->first();
|
||||
}
|
||||
|
||||
private function legacyPeriodBegin(LegacyInvoice $source): Carbon
|
||||
{
|
||||
$begin = $source->raw_snapshot['service_period_begin_date'] ?? null;
|
||||
|
||||
if ($begin && ! str_starts_with((string) $begin, '0000-00-00')) {
|
||||
return Carbon::parse(substr((string) $begin, 0, 10))->startOfDay();
|
||||
}
|
||||
|
||||
return Carbon::instance($source->invoice_date)->startOfDay();
|
||||
}
|
||||
|
||||
private function describe(UserPaymentOption $post, ?LegacyInvoice $source): string
|
||||
{
|
||||
return sprintf(
|
||||
'UPO #%d · User #%d · Quelle %s (%s, %s)',
|
||||
$post->id,
|
||||
$post->user_id,
|
||||
$source?->number ?? '?',
|
||||
$source?->status ?? 'unbekannt',
|
||||
$source?->invoice_date?->toDateString() ?? '-',
|
||||
);
|
||||
}
|
||||
|
||||
private function euro(int $cents): string
|
||||
{
|
||||
return number_format($cents / 100, 2, ',', '.');
|
||||
}
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ enum ActivityType: string
|
|||
case Correction = 'correction';
|
||||
case ContentRemoval = 'content_removal';
|
||||
case MailSuppressed = 'mail_suppressed';
|
||||
case ManualInvoice = 'manual_invoice';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
|
|
@ -53,6 +54,7 @@ enum ActivityType: string
|
|||
self::Correction => 'Korrektur',
|
||||
self::ContentRemoval => 'Entfernung (410)',
|
||||
self::MailSuppressed => 'Mail-Sperre',
|
||||
self::ManualInvoice => 'Rechnung (MAN)',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -77,6 +79,7 @@ enum ActivityType: string
|
|||
self::Correction => 'blue',
|
||||
self::ContentRemoval => 'red',
|
||||
self::MailSuppressed => 'orange',
|
||||
self::ManualInvoice => 'amber',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
app/Exceptions/ManualInvoiceActionException.php
Normal file
12
app/Exceptions/ManualInvoiceActionException.php
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Eine Aktion auf einer MAN-Rechnung (senden, erinnern, bezahlt, beenden)
|
||||
* ist im aktuellen Zustand nicht zulässig. Die Meldung ist für die
|
||||
* Admin-Oberfläche formuliert.
|
||||
*/
|
||||
class ManualInvoiceActionException extends RuntimeException {}
|
||||
24
app/Http/Controllers/InvoicePdfController.php
Normal file
24
app/Http/Controllers/InvoicePdfController.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Invoice;
|
||||
use App\Services\Billing\ManualInvoicePdfRenderer;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* PDF einer MAN-Rechnung (on-demand gerendert). STR-Rechnungen haben ihren
|
||||
* Beleg bei Stripe (`StripeInvoiceRedirectController`) — hier 404.
|
||||
*/
|
||||
class InvoicePdfController extends Controller
|
||||
{
|
||||
public function __invoke(Invoice $invoice, ManualInvoicePdfRenderer $renderer): Response
|
||||
{
|
||||
abort_unless($invoice->isManual(), 404);
|
||||
|
||||
Gate::authorize('downloadPdf', $invoice);
|
||||
|
||||
return $renderer->inlineResponse($invoice);
|
||||
}
|
||||
}
|
||||
48
app/Mail/GrandfatheringTerminated.php
Normal file
48
app/Mail/GrandfatheringTerminated.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Invoice;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Bestätigung an den Kunden, dass der Bestandsschutz wegen ausbleibender
|
||||
* Zahlung beendet und die offene Rechnung storniert wurde — inkl. Hinweis,
|
||||
* wie es weitergeht (bestehende PMs bleiben online, neue nur über Tarife).
|
||||
*
|
||||
* Transaktionsmail: bewusst ohne Opt-out.
|
||||
*/
|
||||
class GrandfatheringTerminated extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public string $bookingUrl;
|
||||
|
||||
public function __construct(
|
||||
public readonly User $user,
|
||||
public readonly Invoice $invoice,
|
||||
) {
|
||||
$this->bookingUrl = route('me.bookings.index');
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$bcc = (string) config('legal.contact_email');
|
||||
|
||||
return new Envelope(
|
||||
subject: 'Ihr Bestandsschutz auf pressekonto ist beendet — Rechnung '.$this->invoice->number.' storniert',
|
||||
bcc: $bcc !== '' ? [$bcc] : [],
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.billing.grandfathering-terminated');
|
||||
}
|
||||
}
|
||||
86
app/Mail/ManualInvoiceIssued.php
Normal file
86
app/Mail/ManualInvoiceIssued.php
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Invoice;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\ManualInvoicePdfRenderer;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Attachment;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Rechnung an einen Legacy-Überweisungskunden (MAN-Kreis) mit PDF im
|
||||
* Anhang, Bankverbindung, Zahlungsziel und dem Hinweis, dass der
|
||||
* Bestandsschutz ohne Zahlung zum Zahlungsziel endet. Als Zahlungs-
|
||||
* erinnerung (`$isReminder`) mit gleichem Inhalt im Mahn-Ton.
|
||||
*
|
||||
* Transaktionsmail: bewusst ohne Opt-out. Die Bounce-Sperrliste greift
|
||||
* über `GateOutgoingMail`; der Service prüft sie zusätzlich vorab.
|
||||
*/
|
||||
class ManualInvoiceIssued extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public string $invoicesUrl;
|
||||
|
||||
public string $bookingUrl;
|
||||
|
||||
public string $serviceName;
|
||||
|
||||
/** @var array{0: string, 1: string}|null */
|
||||
public ?array $servicePeriod;
|
||||
|
||||
/** @var array<string, string> */
|
||||
public array $issuer;
|
||||
|
||||
public function __construct(
|
||||
public readonly User $user,
|
||||
public readonly Invoice $invoice,
|
||||
public readonly bool $isReminder = false,
|
||||
) {
|
||||
$renderer = app(ManualInvoicePdfRenderer::class);
|
||||
|
||||
$this->invoicesUrl = route('me.invoices.index');
|
||||
$this->bookingUrl = route('me.bookings.index');
|
||||
$this->serviceName = $renderer->serviceName($invoice);
|
||||
$this->servicePeriod = $renderer->servicePeriod($invoice);
|
||||
$this->issuer = array_map(fn (mixed $value): string => (string) $value, (array) config('billing.issuer', []));
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$bcc = (string) config('legal.contact_email');
|
||||
|
||||
$subject = $this->isReminder
|
||||
? 'Zahlungserinnerung — Rechnung '.$this->invoice->number
|
||||
: 'Ihre Rechnung '.$this->invoice->number.' — Bestandsschutz auf pressekonto';
|
||||
|
||||
return new Envelope(
|
||||
subject: $subject,
|
||||
bcc: $bcc !== '' ? [$bcc] : [],
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.billing.manual-invoice-issued');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
$renderer = app(ManualInvoicePdfRenderer::class);
|
||||
|
||||
return [
|
||||
Attachment::fromData(fn (): string => $renderer->render($this->invoice), $renderer->filename($this->invoice))
|
||||
->withMime('application/pdf'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Enums\InvoiceStatus;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
|
@ -15,6 +16,7 @@ class Invoice extends Model
|
|||
protected $fillable = [
|
||||
'user_id',
|
||||
'user_payment_id',
|
||||
'user_payment_option_id',
|
||||
'invoice_billing_address_id',
|
||||
'number',
|
||||
'status',
|
||||
|
|
@ -27,6 +29,12 @@ class Invoice extends Model
|
|||
'invoice_date',
|
||||
'due_date',
|
||||
'paid_at',
|
||||
'sent_at',
|
||||
'reminded_at',
|
||||
'reminder_count',
|
||||
'voided_at',
|
||||
'void_reason',
|
||||
'replaces_legacy_invoice_number',
|
||||
'stripe_invoice_id',
|
||||
'pdf_path',
|
||||
];
|
||||
|
|
@ -42,6 +50,10 @@ class Invoice extends Model
|
|||
'invoice_date' => 'date',
|
||||
'due_date' => 'date',
|
||||
'paid_at' => 'datetime',
|
||||
'sent_at' => 'datetime',
|
||||
'reminded_at' => 'datetime',
|
||||
'reminder_count' => 'integer',
|
||||
'voided_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
|
@ -62,6 +74,16 @@ class Invoice extends Model
|
|||
return $this->belongsTo(UserPayment::class, 'user_payment_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Abgerechnete Zahlungsvereinbarung (MAN-Kreis). Für STR-Rechnungen NULL.
|
||||
*
|
||||
* @return BelongsTo<UserPaymentOption, $this>
|
||||
*/
|
||||
public function userPaymentOption(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(UserPaymentOption::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<InvoiceBillingAddress, $this>
|
||||
*/
|
||||
|
|
@ -69,4 +91,37 @@ class Invoice extends Model
|
|||
{
|
||||
return $this->belongsTo(InvoiceBillingAddress::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* MAN-Kreis: lokal ausgestellte Rechnung, per Überweisung zu zahlen.
|
||||
*/
|
||||
public function isManual(): bool
|
||||
{
|
||||
return $this->stripe_invoice_id === null;
|
||||
}
|
||||
|
||||
public function isOverdue(): bool
|
||||
{
|
||||
return $this->status === InvoiceStatus::Open
|
||||
&& $this->due_date !== null
|
||||
&& $this->due_date->isBefore(today());
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechnungen des manuellen Kreises (ohne Stripe-Beleg).
|
||||
*/
|
||||
public function scopeManual(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('stripe_invoice_id');
|
||||
}
|
||||
|
||||
public function scopeOpen(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', InvoiceStatus::Open->value);
|
||||
}
|
||||
|
||||
public function scopeOverdue(Builder $query): Builder
|
||||
{
|
||||
return $this->scopeOpen($query)->whereDate('due_date', '<', today());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,25 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
|
||||
class LegacyInvoice extends Model
|
||||
{
|
||||
/**
|
||||
* Legacy-Status: nie bezahlt, überfällig, Kunde meist nicht mehr
|
||||
* erreichbar. Gilt als ersatzlos erledigt (Einordnung 16.09.2026) und
|
||||
* wird in Kunden- und Admin-Ansicht standardmäßig ausgeblendet.
|
||||
*/
|
||||
public const STATUS_HOLD = 'hold';
|
||||
|
||||
/** Offene Alt-Rechnung, im Altsystem noch nicht gemahnt. */
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
/** Offene Alt-Rechnung, im Altsystem bereits gemahnt. */
|
||||
public const STATUS_REMINDER = 'reminder';
|
||||
|
||||
/** Durch eine MAN-Rechnung des neuen Systems ersetzt (Phase B). */
|
||||
public const STATUS_SUPERSEDED = 'superseded';
|
||||
|
||||
/** Abgeschrieben — Forderung wird nicht weiter verfolgt. */
|
||||
public const STATUS_UNCOLLECTIBLE = 'uncollectible';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
|
|
@ -65,4 +84,27 @@ class LegacyInvoice extends Model
|
|||
{
|
||||
return $query->orderByDesc('invoice_date')->orderByDesc('id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ohne die erledigten `hold`-Rechnungen (Default in allen Ansichten).
|
||||
*/
|
||||
public function scopeWithoutHold(Builder $query): Builder
|
||||
{
|
||||
return $query->where(function (Builder $query): void {
|
||||
$query->whereNull('status')->orWhere('status', '!=', self::STATUS_HOLD);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Noch offene Alt-Vorgänge, die im neuen System abgearbeitet werden.
|
||||
*/
|
||||
public function scopeOpenLegacy(Builder $query): Builder
|
||||
{
|
||||
return $query->whereIn('status', [self::STATUS_PENDING, self::STATUS_REMINDER]);
|
||||
}
|
||||
|
||||
public function isHold(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_HOLD;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
app/Policies/InvoicePolicy.php
Normal file
39
app/Policies/InvoicePolicy.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Authorization\Permission;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Rechnungen des neuen Rechnungsausgangs (STR/MAN). Kunden sehen ihre
|
||||
* eigenen Belege, die Bearbeitung (senden, bezahlt, stornieren) ist an
|
||||
* `billing:manage` gebunden.
|
||||
*/
|
||||
class InvoicePolicy
|
||||
{
|
||||
public function before(User $user): ?bool
|
||||
{
|
||||
return $user->is_super_admin ? true : null;
|
||||
}
|
||||
|
||||
public function view(User $user, Invoice $invoice): bool
|
||||
{
|
||||
if ($user->can(Permission::BillingManage)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $invoice->user_id === $user->id;
|
||||
}
|
||||
|
||||
public function downloadPdf(User $user, Invoice $invoice): bool
|
||||
{
|
||||
return $invoice->isManual() && $this->view($user, $invoice);
|
||||
}
|
||||
|
||||
public function manage(User $user, Invoice $invoice): bool
|
||||
{
|
||||
return $user->can(Permission::BillingManage);
|
||||
}
|
||||
}
|
||||
177
app/Services/Billing/Concerns/DrawsPdf.php
Normal file
177
app/Services/Billing/Concerns/DrawsPdf.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
306
app/Services/Billing/ManualInvoiceDunningService.php
Normal file
306
app/Services/Billing/ManualInvoiceDunningService.php
Normal 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,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
267
app/Services/Billing/ManualInvoicePdfRenderer.php
Normal file
267
app/Services/Billing/ManualInvoicePdfRenderer.php
Normal 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', []));
|
||||
}
|
||||
}
|
||||
|
|
@ -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']
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue