option('dry-run'); $asOf = $this->option('as-of') ? Carbon::parse($this->option('as-of'))->startOfDay() : today(); $graceMonths = max(0, (int) $this->option('grace-months')); $staleBefore = $asOf->copy()->subMonths($graceMonths); if ($isDryRun) { $this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.'); } $candidates = $this->collectLatestRecurringAgreements(); $report = [ 'generated_at' => now()->toIso8601String(), 'as_of' => $asOf->toDateString(), 'grace_months' => $graceMonths, 'dry_run' => $isDryRun, 'created' => [], 'updated' => [], 'stale_skipped' => [], 'immediately_due' => [], ]; foreach ($candidates as $candidate) { $nextDue = $candidate['next_due_date']; if ($nextDue->lessThan($staleBefore)) { $report['stale_skipped'][] = $this->describe($candidate); continue; } if ($nextDue->lessThanOrEqualTo($asOf)) { $report['immediately_due'][] = $this->describe($candidate); } if ($isDryRun) { $this->line(sprintf('[dry-run] %s', $this->describe($candidate))); continue; } $paymentOption = $this->resolveCatalogOption($candidate); $existing = $this->findExisting($candidate); $attributes = [ 'user_id' => $candidate['user_id'], 'payment_option_id' => $paymentOption->id, 'status' => UserPaymentOptionStatus::Grandfathered->value, 'grandfathered_until' => $candidate['valid_until_date']?->toDateString(), 'current_period_start' => $candidate['period_start']->toDateString(), 'current_period_end' => $nextDue->toDateString(), 'stripe_subscription_id' => null, 'legacy_conditions' => $candidate['legacy_conditions'], ]; if ($existing) { $existing->update($attributes); $report['updated'][] = $this->describe($candidate); } else { UserPaymentOption::query()->create($attributes); $report['created'][] = $this->describe($candidate); } } $this->table( ['Ergebnis', 'Anzahl'], [ ['Kandidaten (aktiv, recurring)', count($candidates)], ['Neu angelegt', count($report['created'])], ['Aktualisiert (Re-Run)', count($report['updated'])], ['Übersprungen (stale)', count($report['stale_skipped'])], ['Davon sofort fällig', count($report['immediately_due'])], ], ); foreach ($report['immediately_due'] as $line) { $this->warn('Sofort fällig (MAN-Lauf rechnet beim nächsten Lauf ab): '.$line); } if (! $this->option('no-report')) { $path = sprintf('migration/grandfather-subscriptions-%s.json', now()->format('Ymd-His')); Storage::put($path, json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); $this->info("Report: storage/app/{$path}"); } return self::SUCCESS; } /** * Jüngste Archiv-Rechnung pro (Portal, Legacy-Vereinbarung) mit * aktiver wiederkehrender Zahlungsoption. * * @return array> */ private function collectLatestRecurringAgreements(): array { $latest = []; LegacyInvoice::query() ->whereNotNull('user_id') ->whereNotNull('pdf_payload') ->orderBy('id') ->chunk(self::CHUNK_SIZE, function ($invoices) use (&$latest): void { foreach ($invoices as $invoice) { $payload = $invoice->pdf_payload; $upo = $payload['user_payment_option'] ?? null; $option = $payload['payment_option'] ?? null; if (! $upo || ! $option) { continue; } if (($option['type'] ?? null) !== 'recurring' || ($upo['status'] ?? null) !== 'active') { continue; } $key = $invoice->legacy_portal->value.'#'.$upo['id']; $current = $latest[$key] ?? null; if ($current && $current->invoice_date->greaterThanOrEqualTo($invoice->invoice_date)) { continue; } $latest[$key] = $invoice; } }); return array_values(array_map(fn (LegacyInvoice $invoice): array => $this->toCandidate($invoice), $latest)); } /** * @return array */ private function toCandidate(LegacyInvoice $invoice): array { $payload = $invoice->pdf_payload; $snapshot = $invoice->raw_snapshot ?? []; $upo = $payload['user_payment_option']; $option = $payload['payment_option']; $nextDue = isset($upo['next_due_date']) ? Carbon::parse($upo['next_due_date'])->startOfDay() : Carbon::parse($snapshot['service_period_end_date'] ?? $invoice->invoice_date)->startOfDay(); $periodStart = isset($snapshot['service_period_begin_date']) ? Carbon::parse($snapshot['service_period_begin_date'])->startOfDay() : $nextDue->copy()->subYear(); return [ 'user_id' => $invoice->user_id, 'legacy_portal' => $invoice->legacy_portal->value, 'legacy_upo_id' => (int) $upo['id'], 'article_number' => (string) ($option['article_number'] ?? 'UNBEKANNT'), 'next_due_date' => $nextDue, 'period_start' => $periodStart, 'valid_until_date' => isset($upo['valid_until_date']) && $upo['valid_until_date'] ? Carbon::parse($upo['valid_until_date'])->startOfDay() : null, 'legacy_conditions' => [ 'legacy_portal' => $invoice->legacy_portal->value, 'legacy_user_payment_option_id' => (int) $upo['id'], 'legacy_payment_option_id' => (int) ($option['id'] ?? 0), 'article_number' => $option['article_number'] ?? null, 'name' => $payload['payment_option_translation']['name'] ?? null, 'interval' => 'yearly', 'net_cents' => $this->deriveNetCents($invoice), 'last_total_cents' => $invoice->total_cents, 'last_is_netto' => (bool) ($snapshot['is_netto'] ?? false), 'source_invoice_number' => $invoice->number, 'source_invoice_date' => $invoice->invoice_date->toDateString(), ], ]; } /** * Netto-Vertragsbasis aus der letzten Legacy-Rechnung. Legacy fakturierte * brutto (Steuer inkludiert, z. B. 199,00 €); steuerbefreite Kunden * erhielten den Netto-Ausweis (`is_netto`, z. B. 167,23 €). Die neue * Rechnungsstellung arbeitet immer auf Netto-Basis — die Steuer wird * pro Rechnung über den VatResolver bestimmt. */ private function deriveNetCents(LegacyInvoice $invoice): int { $isNetto = (bool) (($invoice->raw_snapshot ?? [])['is_netto'] ?? false); if ($isNetto) { return $invoice->total_cents; } $vatRate = (float) config('billing.vat_rate', 0.19); return (int) round($invoice->total_cents / (1 + $vatRate)); } /** * Versteckter Katalog-Eintrag pro (Portal, Legacy-Artikel) — die * verbindlichen Beträge pro Vereinbarung liegen in legacy_conditions. */ private function resolveCatalogOption(array $candidate): PaymentOption { $portalShort = $candidate['legacy_portal'] === 'presseecho' ? 'PE' : 'BP'; $articleNumber = sprintf('LEGACY-%s-%s', $portalShort, $candidate['article_number']); return PaymentOption::query()->firstOrCreate( ['article_number' => $articleNumber], [ 'type' => 'recurring', // Katalogpreise sind netto (Entscheidung 12.06.2026). 'price_cents' => $candidate['legacy_conditions']['net_cents'], 'currency' => 'EUR', 'interval' => 'yearly', 'is_hidden' => true, ], ); } private function findExisting(array $candidate): ?UserPaymentOption { return UserPaymentOption::query() ->where('user_id', $candidate['user_id']) ->get() ->first(function (UserPaymentOption $option) use ($candidate): bool { $conditions = $option->legacy_conditions ?? []; return ($conditions['legacy_portal'] ?? null) === $candidate['legacy_portal'] && (int) ($conditions['legacy_user_payment_option_id'] ?? 0) === $candidate['legacy_upo_id']; }); } private function describe(array $candidate): string { return sprintf( 'User #%d · %s · Legacy-UPO #%d · fällig %s · netto %s €', $candidate['user_id'], $candidate['legacy_portal'], $candidate['legacy_upo_id'], $candidate['next_due_date']->toDateString(), number_format($candidate['legacy_conditions']['net_cents'] / 100, 2, ',', '.'), ); } }