view-distribution rechnete das Alter der Kohorten ab dem Enddatum des Fensters, die Zähler stehen aber immer auf heute — mit einem --to in der Vergangenheit waren alle Kohorten um die Verzögerung des Laufs zu jung. Das Alter zählt jetzt ab dem Stichtag der Erhebung (Feld stichtag), junge Kohorten fallen bei altem Enddatum leer aus. settle-unpaid meldete beim zweiten Lauf einen Posten, dessen Quelle der erste Lauf schon per MAN-Rechnung ersetzt hatte (superseded), als Konflikt. Solche Quellen zählen jetzt als already_reissued. Basiswerte Block 0: Live-Erhebung vom 21.09.2026 (gleiches Fenster, Median 126/303 statt 98/200) eingetragen, Kohortenzeile mit Alters- Versatz erklärt, letzter Schritt für 0.1 benannt (Lauf ohne --to). Protokoll: Nach-Deploy-Schritte, Lesart von Audit und Rechnungslauf. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
358 lines
13 KiB
PHP
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/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' => [],
|
|
'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, ',', '.');
|
|
}
|
|
}
|