diff --git a/app/Console/Commands/GrandfatherLegacySubscriptions.php b/app/Console/Commands/GrandfatherLegacySubscriptions.php new file mode 100644 index 0000000..89e7e6d --- /dev/null +++ b/app/Console/Commands/GrandfatherLegacySubscriptions.php @@ -0,0 +1,272 @@ +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', + 'amount_cents' => $invoice->amount_cents, + 'tax_cents' => $invoice->tax_cents, + 'total_cents' => $invoice->total_cents, + 'is_netto' => (bool) ($snapshot['is_netto'] ?? false), + 'source_invoice_number' => $invoice->number, + 'source_invoice_date' => $invoice->invoice_date->toDateString(), + ], + ]; + } + + /** + * 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', + 'price_cents' => $candidate['legacy_conditions']['amount_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 · %s €', + $candidate['user_id'], + $candidate['legacy_portal'], + $candidate['legacy_upo_id'], + $candidate['next_due_date']->toDateString(), + number_format($candidate['legacy_conditions']['total_cents'] / 100, 2, ',', '.'), + ); + } +} diff --git a/dev/migration 2026/05-DATABASE-MERGE.md b/dev/migration 2026/05-DATABASE-MERGE.md index cd9b1aa..755038a 100644 --- a/dev/migration 2026/05-DATABASE-MERGE.md +++ b/dev/migration 2026/05-DATABASE-MERGE.md @@ -214,12 +214,20 @@ Vor dem Go-Live-Rehearsal muss der Report gegen den aktuellen Produktiv-Snapshot ### 5.6 Payment ⭐ NEU + GRANDFATHERING (D-13) -- **Legacy-PaymentOptions werden nicht übernommen.** Neue Produkte werden vom Auftraggeber definiert und als Stripe-Prices angelegt. -- **Aktive `UserPaymentOption`-Einträge** (Status `active`, `valid_until >= today`) werden als `grandfathered` migriert: - - Neuer Datensatz in `user_payment_options` mit `status = 'grandfathered'`, `grandfathered_until = legacy.valid_until`, `legacy_conditions = {...Snapshot...}`. - - **Kein** Stripe-Subscription-Versuch (kein automatischer Import alter Abos in Stripe). - - Scheduler `ExpireGrandfatheredSubscriptions` erzeugt am `grandfathered_until` eine Customer-Benachrichtigung für Umstellung auf neues Produkt. +> **Umgesetzt 2026-06-12** mit präzisierten Kriterien des Auftraggebers: +> Quelle der Aktiv-Erkennung ist **ausschließlich das Rechnungsarchiv** +> (`legacy_invoices`, D-12) — nicht die Legacy-Payment-Tabellen direkt. +> Command: `legacy:grandfather-subscriptions` (idempotent, Replay-fähig). + +- **Legacy-PaymentOptions werden nicht übernommen.** Neue Produkte werden vom Auftraggeber definiert und als Stripe-Prices angelegt. Für die Grandfathered-Vereinbarungen entstehen versteckte Katalog-Platzhalter (`payment_options.article_number = LEGACY-{PE|BP}-{Artikel}`, `is_hidden = true`); die verbindlichen Beträge liegen pro Vereinbarung in `legacy_conditions`. +- **Aktiv-Regel** (aus dem Archiv abgeleitet): jüngste Rechnung pro (Portal, Legacy-`user_payment_option`) mit `pdf_payload.payment_option.type = 'recurring'` und `pdf_payload.user_payment_option.status = 'active'`; `next_due_date` darf höchstens `--grace-months` (Default 12) überfällig sein, sonst gilt die Vereinbarung als stale und bleibt reines Archiv. Einmal-Käufe (`type = single`) werden nie übernommen. +- **Übernahme** als `grandfathered` in `user_payment_options`: + - `status = 'grandfathered'`, `grandfathered_until = legacy.valid_until_date` (nullable), `stripe_subscription_id = null`. + - `current_period_start = service_period_begin_date` der jüngsten Rechnung, `current_period_end = next_due_date` → der tägliche MAN-Kreis-Lauf (`billing:generate-manual-invoices`) stellt die nächste Rechnung zum gewohnten (jährlichen) Rhythmus aus, mit den Beträgen der letzten Legacy-Rechnung (`legacy_conditions.amount/tax/total_cents`). + - **Kein** Stripe-Subscription-Versuch (kein automatischer Import alter Abos in Stripe). Neue manuelle Rechnungen entstehen im **MAN-Rechnungskreis** (`invoices`), nie im Archiv. + - Scheduler `ExpireGrandfatheredSubscriptions` (Customer-Benachrichtigung am `grandfathered_until`) bleibt offen — folgt mit dem Stripe-Billing-Block. - Alle historischen `user_payments` werden als Information ins Archiv geschrieben (analog `legacy_invoices` – optional). +- **Replay (D-18)**: Re-Runs aktualisieren bestehende Einträge anhand `legacy_conditions.legacy_portal` + `legacy_user_payment_option_id` — der Lauf kurz vor dem Relaunch übernimmt damit den dann aktuellen Stand ohne Duplikate. ### 5.7 Coupons diff --git a/dev/migration 2026/08-PROGRESS.md b/dev/migration 2026/08-PROGRESS.md index 419b712..5dfd8ae 100644 --- a/dev/migration 2026/08-PROGRESS.md +++ b/dev/migration 2026/08-PROGRESS.md @@ -4,6 +4,34 @@ Chronologisches Protokoll aller Migrationsschritte. Jede Session / jeder Commit --- +## 2026-06-12 – P6.6 Grandfathering aktiver Legacy-Abos ✅ + +**Phase:** P6 Daten-Migration (D-13, Kriterien vom Auftraggeber präzisiert) +**Status:** ✅ umgesetzt; Rehearsal gegen Produktiv-Snapshot bleibt P6.10. + +- Neuer Command `legacy:grandfather-subscriptions` (`--dry-run`, `--as-of=`, + `--grace-months=12`, `--no-report`; JSON-Report nach + `storage/app/migration/grandfather-subscriptions-*.json`). +- Quelle ist ausschließlich das Rechnungsarchiv `legacy_invoices` (D-12): + jüngste Rechnung pro (Portal, Legacy-UPO) mit `payment_option.type = + recurring` und `user_payment_option.status = active`; `next_due_date` + max. 12 Monate überfällig, sonst stale → bleibt Archiv. +- Übernahme als `grandfathered` in `user_payment_options` mit + `current_period_end = next_due_date` und Beträgen der letzten + Legacy-Rechnung in `legacy_conditions` — der tägliche MAN-Kreis-Lauf + (`billing:generate-manual-invoices`, Phase 9D im Hauptprojekt) + fakturiert ab dann zum gewohnten jährlichen Rhythmus weiter. +- Versteckte Katalog-Platzhalter `LEGACY-{PE|BP}-{Artikel}` in + `payment_options`; Re-Runs aktualisieren statt duplizieren (D-18, + Replay kurz vor Relaunch). +- Dry-Run gegen aktuellen Test-Snapshot: 22 aktive jährliche + Vereinbarungen (49 € bis 1.190 €), davon 4 sofort fällig; 0 stale. +- Tests: `tests/Feature/Billing/GrandfatherLegacySubscriptionsTest.php` + (7 Tests, inkl. End-to-End: Migration → MAN-Rechnung mit + Legacy-Beträgen). + +--- + ## 2026-05-04 – P6.5d Legacy-Rechnungen Vollimport + on-demand PDF ✅ **Phase:** P6 Daten-Migration + P4 Customer-Portal diff --git a/dev/migration 2026/12-NAECHSTE-SCHRITTE.md b/dev/migration 2026/12-NAECHSTE-SCHRITTE.md index ae4429b..ad5cac0 100644 --- a/dev/migration 2026/12-NAECHSTE-SCHRITTE.md +++ b/dev/migration 2026/12-NAECHSTE-SCHRITTE.md @@ -107,7 +107,7 @@ Der Kern (Erstellen → Submit → Review → Publish/Reject mit Reason + Audit- | # | Aufgabe | Priorität | Status | |---|---|---|---| | 6.5d | **Legacy-Rechnungen Vollimport**: alle bestehenden Rechnungen aus den Legacy-DBs inkl. Status, Beträgen, Datum, Zahlart, vollständigem Raw-Snapshot und User-Zuordnung importieren; `legacy:archive-invoices` schreibt Import-Report + PDF-Payload; PDF-Erzeugung bleibt DB-basiert/on-demand statt Datei-Migration | 🔴 | ✅ umgesetzt 2026-05-04; Rehearsal gegen Produktiv-Snapshot bleibt P6.10 | -| 6.6 | `legacy:grandfather-subscriptions` (aktive Alt-Abos übernehmen) | 🔴 | ⬜ wartet auf Auftraggeber-Kriterien | +| 6.6 | `legacy:grandfather-subscriptions` (aktive Alt-Abos übernehmen) | 🔴 | ✅ umgesetzt 2026-06-12 (Kriterien vom Auftraggeber: Quelle ist das Rechnungsarchiv — jüngste Rechnung pro Vereinbarung mit `recurring` + `active`; Übernahme als `grandfathered` mit `current_period_end = next_due_date`, MAN-Kreis fakturiert weiter; Replay-fähig, Rehearsal bleibt P6.10) | | 6.10 | **Rehearsal-Lauf** gegen produktiven Snapshot auf Staging | 🔴 | ⬜ | **Wichtig für 6.5d:** `legacy:archive-invoices` importiert jetzt Rechnungsdaten, Billing-Adress-Snapshot und User-Payment-Snapshot in `legacy_invoices.raw_snapshot`/`pdf_payload`, zählt unzugeordnete Legacy-User im Report und lässt diese Rechnungen trotzdem im Archiv. Für Legacy-Rechnungen bleibt die bestehende Logik erhalten: Rechnung liegt als Datenbankdatensatz vor und das PDF wird bei Bedarf auf Knopfdruck aus diesen Daten erzeugt. Neue Stripe-Rechnungen werden separat in P8 geplant. Der finale Nachweis der Vollständigkeit erfolgt weiterhin im Staging-Rehearsal mit aktuellem Produktiv-Snapshot. diff --git a/dev/migration 2026/MIGRATION-STEPS.md b/dev/migration 2026/MIGRATION-STEPS.md index ce96c6b..3d52b66 100644 --- a/dev/migration 2026/MIGRATION-STEPS.md +++ b/dev/migration 2026/MIGRATION-STEPS.md @@ -1,12 +1,13 @@ # Migration Steps – aktuelles Runbook -Stand: 2026-05-04. Dieses Kurz-Runbook spiegelt den aktuell implementierten Command-Stand. Details und Go-Live-Kontext stehen in `05-DATABASE-MERGE.md` und `08-PROGRESS.md`. +Stand: 2026-06-12. Dieses Kurz-Runbook spiegelt den aktuell implementierten Command-Stand. Details und Go-Live-Kontext stehen in `05-DATABASE-MERGE.md` und `08-PROGRESS.md`. ## Dry-Run ```bash php artisan legacy:import --source=all --dry-run php artisan legacy:archive-invoices --dry-run +php artisan legacy:grandfather-subscriptions --dry-run php artisan legacy:verify --no-report php artisan legacy:migrate-media --portal=all --type=all --base-path=dev/migration --dry-run @@ -27,10 +28,22 @@ php artisan legacy:import --source=presseecho --step=press-releases --force php artisan legacy:import --source=businessportal24 --step=press-releases --force php artisan legacy:import --step=link-associations --force php artisan legacy:archive-invoices +php artisan legacy:grandfather-subscriptions php artisan legacy:fix-timestamps php artisan legacy:verify ``` +Hinweis: `legacy:grandfather-subscriptions` läuft **nach** `legacy:archive-invoices`, +weil es die aktiven, jährlich wiederkehrenden Zahlungsvereinbarungen aus dem +Rechnungsarchiv ableitet (jüngste Rechnung pro Vereinbarung mit +`payment_option.type = recurring` und `user_payment_option.status = active`) +und als `grandfathered` in `user_payment_options` schreibt. Die nächste +Rechnung stellt danach der tägliche MAN-Kreis-Lauf +(`billing:generate-manual-invoices`) zum gewohnten Rhythmus aus. Re-Runs +aktualisieren bestehende Einträge (Replay-fähig für den Lauf kurz vor dem +Relaunch). Optionen: `--dry-run`, `--as-of=`, `--grace-months=12` (älter +überfällige Vereinbarungen gelten als stale und bleiben reines Archiv). + Hinweis: Der Schritt `--step=users` importiert nicht nur `sf_guard_user`, sondern auch die direkt verknüpften Daten aus `sf_guard_user_profile` in die neue Tabelle `profiles`. ## Alternativer Komplettlauf @@ -38,6 +51,7 @@ Hinweis: Der Schritt `--step=users` importiert nicht nur `sf_guard_user`, sonder ```bash php artisan legacy:import --source=all --force php artisan legacy:archive-invoices +php artisan legacy:grandfather-subscriptions php artisan legacy:fix-timestamps php artisan legacy:verify php artisan legacy:migrate-media --portal=all --type=all --base-path=dev/migration @@ -45,7 +59,6 @@ php artisan legacy:migrate-media --portal=all --type=all --base-path=dev/migrati ## Noch nicht im Runbook finalisiert -- `legacy:grandfather-subscriptions`: noch nicht implementiert bzw. blockiert durch Kriterien vom Auftraggeber. - Medien-/Bilddateien-Transfer: Scope und finaler Command noch offen. - Staging-Rehearsal mit aktuellem Produktiv-Snapshot bleibt Pflicht vor Go-Live. diff --git a/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md b/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md index ed912e0..13f2bb5 100644 --- a/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md +++ b/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md @@ -164,11 +164,15 @@ ist hybrid mit zwei getrennten Rechnungskreisen (plus Altbestand): - **`User::hasActiveBooking()`** prüft jetzt echt (hinter `billing.enforce_booking`): Cashier-Abo ∨ bezahlter Einzel-/Extra-PM-Kauf ∨ aktive/grandfathered Legacy-Vereinbarung (MAN-Kreis). +- **Legacy-Migration (12.06.)**: `legacy:grandfather-subscriptions` leitet + die aktiven, jährlich wiederkehrenden Vereinbarungen aus dem + Rechnungsarchiv ab und schreibt sie als `grandfathered` in + `user_payment_options` (Replay-fähig — die Kern-Migration läuft kurz + vor dem Relaunch erneut). Details: + `dev/migration 2026/05-DATABASE-MERGE.md` §5.6 + `MIGRATION-STEPS.md`. - **Noch offen in 9D** (folgt mit 9E, braucht Checkout/Webhooks): Slot-Logik von `users.press_release_quota`-Stub auf Plan-Kontingent + - Periodenzähler umstellen und Stub-Spalten entfernen. Voraussetzung: - Die aktiven Legacy-Zahlungen müssen noch in `user_payment_options` - migriert werden (Tabelle ist aktuell leer — eigener Migrations-Schritt). + Periodenzähler umstellen und Stub-Spalten entfernen. ### 9E · Stripe (Laravel Cashier) diff --git a/docs/user-admin/checkliste-user-backend.md b/docs/user-admin/checkliste-user-backend.md index 871abc5..ab34496 100644 --- a/docs/user-admin/checkliste-user-backend.md +++ b/docs/user-admin/checkliste-user-backend.md @@ -125,7 +125,7 @@ Verbindliche Entscheidungen: `docs/Decision-Update Preisstruktur & Veröffentlic - [x] Tarif-Datenmodell (Phase 9D, 12.06.): `plans` (4 Tiers + Seeder), `single_purchases` (Einzel-PM/Extra-PM/Boost/PDF), Laravel Cashier installiert (`User` ist Billable), `hasActiveBooking()` prueft hybrid (Stripe-Abo / Einmalkauf / Legacy-Vereinbarung). - [x] Hybride Rechnungskreise (Entscheidung 12.06.): fortlaufende Nummern via `InvoiceNumberGenerator` — **STR-** fuer den neuen Stripe-Shop, **MAN-** fuer laufende Legacy-Zahlungen; Alt-Archiv (`legacy_invoices`) bleibt unveraendert. - [x] MAN-Faelligkeitslauf: `billing:generate-manual-invoices` (taeglich 04:30) prueft `user_payment_options` ohne Stripe-Subscription auf erreichtes Periodenende, stellt Rechnung mit Adress-Snapshot aus und schaltet die Periode weiter (Konditions-Overrides via `legacy_conditions`). -- [ ] Aktive Legacy-Zahlungen in `user_payment_options` migrieren (Tabelle aktuell leer) — Voraussetzung fuer den ersten echten MAN-Lauf. +- [x] Aktive Legacy-Zahlungen migrieren (12.06.): `legacy:grandfather-subscriptions` leitet aus dem Rechnungsarchiv die aktiven jaehrlichen Vereinbarungen ab (22 im Test-Snapshot) und schreibt sie als `grandfathered` nach `user_payment_options` — Replay-faehig fuer den Lauf kurz vor Relaunch. Doku: `dev/migration 2026/05-DATABASE-MERGE.md` §5.6. - [ ] Stripe-Checkout + Webhooks (Phase 9E): Produkte/Preise anlegen, STR-Rechnungsspiegelung, Slot-Logik auf Plan-Kontingent umstellen, Quota-Stub abloesen. Benoetigt `STRIPE_KEY`/`STRIPE_SECRET` in `.env`. - [ ] Tageslimit je Tier (Business 2 / Pro 3 / Agency 5), gilt auch fuer Extra-PMs. - [ ] Launch-Credits: Extra-PM, Boost (nur gruene PMs), Veroeffentlichungsnachweis-PDF; Credit-Anker 1 Credit = 1 €. diff --git a/tests/Feature/Billing/GrandfatherLegacySubscriptionsTest.php b/tests/Feature/Billing/GrandfatherLegacySubscriptionsTest.php new file mode 100644 index 0000000..1b09977 --- /dev/null +++ b/tests/Feature/Billing/GrandfatherLegacySubscriptionsTest.php @@ -0,0 +1,189 @@ + 'presseecho', + 'legacy_id' => ++$legacyId, + 'user_id' => $user->id, + 'legacy_user_id' => 90000 + $legacyId, + 'number' => 'PE-'.$legacyId, + 'amount_cents' => 4900, + 'tax_cents' => 0, + 'total_cents' => 4900, + 'status' => 'paid', + 'invoice_date' => '2025-08-01', + 'raw_snapshot' => [ + 'is_netto' => false, + 'service_period_begin_date' => '2025-08-01', + 'service_period_end_date' => '2026-07-31', + ], + 'pdf_payload' => [ + 'user_payment_option' => [ + 'id' => 42, + 'status' => 'active', + 'next_due_date' => '2026-08-01', + 'valid_until_date' => null, + 'payment_option_id' => 1, + ], + 'payment_option' => [ + 'id' => 1, + 'type' => 'recurring', + 'article_number' => 'PK-01', + ], + 'payment_option_translation' => ['name' => 'Pressemappe klein'], + ], + 'imported_at' => now(), + ]; + + return LegacyInvoice::query()->create(array_replace_recursive($defaults, $overrides)); +} + +beforeEach(function (): void { + Carbon::setTestNow('2026-06-12 09:00:00'); +}); + +afterEach(function (): void { + Carbon::setTestNow(); +}); + +test('an active recurring legacy agreement is migrated as grandfathered with the legacy rhythm', function () { + $user = User::factory()->create(); + legacyArchiveInvoice($user); + + $this->artisan(GrandfatherLegacySubscriptions::class, ['--no-report' => true])->assertSuccessful(); + + $agreement = UserPaymentOption::sole(); + expect($agreement->user_id)->toBe($user->id); + expect($agreement->status)->toBe(UserPaymentOptionStatus::Grandfathered); + expect($agreement->stripe_subscription_id)->toBeNull(); + expect($agreement->current_period_start->toDateString())->toBe('2025-08-01'); + expect($agreement->current_period_end->toDateString())->toBe('2026-08-01'); + expect($agreement->legacy_conditions['interval'])->toBe('yearly'); + expect($agreement->legacy_conditions['total_cents'])->toBe(4900); + expect($agreement->legacy_conditions['legacy_user_payment_option_id'])->toBe(42); + + $catalog = PaymentOption::query()->where('article_number', 'LEGACY-PE-PK-01')->sole(); + expect($catalog->is_hidden)->toBeTrue(); +}); + +test('single-type and inactive legacy agreements stay in the archive', function () { + $user = User::factory()->create(); + + legacyArchiveInvoice($user, [ + 'pdf_payload' => ['payment_option' => ['type' => 'single']], + ]); + legacyArchiveInvoice($user, [ + 'pdf_payload' => ['user_payment_option' => ['id' => 43, 'status' => 'canceled']], + ]); + + $this->artisan(GrandfatherLegacySubscriptions::class, ['--no-report' => true])->assertSuccessful(); + + expect(UserPaymentOption::count())->toBe(0); +}); + +test('the latest invoice per agreement wins', function () { + $user = User::factory()->create(); + + legacyArchiveInvoice($user, [ + 'invoice_date' => '2024-08-01', + 'total_cents' => 3900, + 'amount_cents' => 3900, + 'raw_snapshot' => ['service_period_begin_date' => '2024-08-01', 'service_period_end_date' => '2025-07-31'], + 'pdf_payload' => ['user_payment_option' => ['next_due_date' => '2025-08-01']], + ]); + legacyArchiveInvoice($user); + + $this->artisan(GrandfatherLegacySubscriptions::class, ['--no-report' => true])->assertSuccessful(); + + $agreement = UserPaymentOption::sole(); + expect($agreement->current_period_end->toDateString())->toBe('2026-08-01'); + expect($agreement->legacy_conditions['total_cents'])->toBe(4900); +}); + +test('re-running updates the agreement instead of duplicating it (pre-relaunch replay)', function () { + $user = User::factory()->create(); + legacyArchiveInvoice($user); + + $this->artisan(GrandfatherLegacySubscriptions::class, ['--no-report' => true])->assertSuccessful(); + + // Kurz vor dem Relaunch kommt eine neuere Rechnung in den Snapshot. + legacyArchiveInvoice($user, [ + 'invoice_date' => '2026-06-10', + 'raw_snapshot' => ['service_period_begin_date' => '2026-06-10', 'service_period_end_date' => '2027-06-09'], + 'pdf_payload' => ['user_payment_option' => ['next_due_date' => '2027-06-10']], + ]); + + $this->artisan(GrandfatherLegacySubscriptions::class, ['--no-report' => true])->assertSuccessful(); + + $agreement = UserPaymentOption::sole(); + expect($agreement->current_period_end->toDateString())->toBe('2027-06-10'); +}); + +test('agreements overdue beyond the grace window are skipped as stale', function () { + $user = User::factory()->create(); + legacyArchiveInvoice($user, [ + 'invoice_date' => '2023-08-01', + 'raw_snapshot' => ['service_period_begin_date' => '2023-08-01', 'service_period_end_date' => '2024-07-31'], + 'pdf_payload' => ['user_payment_option' => ['next_due_date' => '2024-08-01']], + ]); + + $this->artisan(GrandfatherLegacySubscriptions::class, ['--no-report' => true])->assertSuccessful(); + + expect(UserPaymentOption::count())->toBe(0); +}); + +test('dry-run writes nothing', function () { + legacyArchiveInvoice(User::factory()->create()); + + $this->artisan(GrandfatherLegacySubscriptions::class, ['--dry-run' => true, '--no-report' => true]) + ->assertSuccessful(); + + expect(UserPaymentOption::count())->toBe(0); + expect(PaymentOption::query()->where('article_number', 'like', 'LEGACY-%')->count())->toBe(0); +}); + +test('after migration the MAN circle invoices a due legacy agreement with the legacy amounts', function () { + $user = User::factory()->create(); + BillingAddress::factory()->create(['user_id' => $user->id]); + + // Fällig: next_due_date liegt vor dem Stichtag (überfälliger Jahreskunde). + legacyArchiveInvoice($user, [ + 'invoice_date' => '2025-05-14', + 'amount_cents' => 19900, + 'tax_cents' => 0, + 'total_cents' => 19900, + 'raw_snapshot' => ['service_period_begin_date' => '2025-05-14', 'service_period_end_date' => '2026-05-13'], + 'pdf_payload' => ['user_payment_option' => ['next_due_date' => '2026-05-14']], + ]); + + $this->artisan(GrandfatherLegacySubscriptions::class, ['--no-report' => true])->assertSuccessful(); + + $service = app(ManualInvoiceService::class); + $due = $service->duePaymentOptions(); + expect($due)->toHaveCount(1); + + $invoice = $service->invoiceFor($due->first()); + + expect($invoice->number)->toBe('MAN-00001'); + expect($invoice->amount_cents)->toBe(19900); + expect($invoice->tax_cents)->toBe(0); + expect($invoice->total_cents)->toBe(19900); + + // Periode jährlich weitergeschaltet. + expect($due->first()->fresh()->current_period_end->toDateString())->toBe('2027-05-14'); + expect(Invoice::count())->toBe(1); +});