presseportale/app/Console/Commands/SettleUnpaidLegacyInvoices.php
Kevin Adametz 8e3554bf91 dev: Doku aufgeräumt, neue Roadmap als einzige Liste offener Punkte
- dev/roadmap.md neu: alle offenen Punkte aus Plänen, Audits und Protokollen,
  gegen Code und Git abgeglichen (Stand v1.7.0)
- Abgeschlossenes ins Archiv: 2026-09-01 → archive/2026-09-nutzergewinnung,
  2026-09-11-audit → archive/2026-09-audit, lagacy-rechnungen →
  archive/legacy-rechnungen, Deploy-Protokoll v1.1.0, Umami-Snippets,
  Go-Live-Roadmap vom 20.07.
- Aktiv bleiben eigene Ordner bildrechte/ (Plan Fassung 2, Decision-Update)
  und changelog/ (Decision-Update Changelog)
- dev/README.md als Ordnerkarte neu, Verweise und zwei Code-Kommentare auf
  die neuen Pfade

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:25:09 +00:00

358 lines
13 KiB
PHP

<?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/archive/legacy-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' => [],
'already_reissued' => [],
];
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;
}
// Ein früherer Lauf hat die Forderung schon als MAN-Rechnung neu
// ausgestellt — der Posten bleibt Bestandsschutz, die Quelle trägt
// `superseded`. Kein Konflikt, nichts zu tun.
if ($status === LegacyInvoice::STATUS_SUPERSEDED) {
$this->report['already_reissued'][] = $this->describe($post, $source).' · ersetzt durch '.($source->raw_snapshot['superseded_by_invoice_number'] ?? '?');
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, ',', '.');
}
}