> */ 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 $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 $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, ',', '.'); } }