P6.6: legacy:grandfather-subscriptions — aktive Legacy-Abos aus dem Rechnungsarchiv migrieren

Kriterien vom Auftraggeber (12.06.2026): Quelle der Aktiv-Erkennung ist
ausschliesslich das read-only Rechnungsarchiv legacy_invoices (D-12).
Legacy-Rechnungen bleiben Archiv; neue manuelle Rechnungen entstehen im
MAN-Rechnungskreis.

- Aktiv-Regel: juengste Rechnung pro (Portal, Legacy-Vereinbarung) mit
  payment_option.type=recurring und user_payment_option.status=active;
  next_due_date max. --grace-months (Default 12) ueberfaellig, sonst
  stale -> bleibt reines Archiv. Einmal-Kaeufe werden nie uebernommen.
- Uebernahme als grandfathered in user_payment_options:
  current_period_end = next_due_date, Betraege/Intervall der letzten
  Legacy-Rechnung in legacy_conditions -> der taegliche MAN-Lauf
  (billing:generate-manual-invoices) fakturiert zum gewohnten
  jaehrlichen Rhythmus weiter. Versteckte Katalog-Platzhalter
  LEGACY-{PE|BP}-{Artikel} in payment_options.
- Replay-faehig (D-18): Re-Runs aktualisieren anhand der Legacy-IDs in
  legacy_conditions statt zu duplizieren — die Kern-Migration laeuft
  kurz vor dem Relaunch erneut.
- Optionen: --dry-run, --as-of, --grace-months, --no-report; JSON-Report
  nach storage/app/migration/. Dry-Run gegen Test-Snapshot: 22 aktive
  jaehrliche Vereinbarungen, davon 4 sofort faellig, 0 stale.
- Doku: MIGRATION-STEPS.md (Runbook-Reihenfolge nach archive-invoices),
  05-DATABASE-MERGE §5.6, 12-NAECHSTE-SCHRITTE 6.6, 08-PROGRESS,
  PHASE-9-Plan + Checkliste.

Tests: GrandfatherLegacySubscriptionsTest (7, inkl. End-to-End
Migration -> MAN-Rechnung mit Legacy-Betraegen). Suite: 475 passed,
4 skipped. Pint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-12 10:35:48 +00:00
parent d548f4b235
commit 1cd4d8e33a
8 changed files with 526 additions and 12 deletions

View file

@ -0,0 +1,272 @@
<?php
namespace App\Console\Commands;
use App\Enums\UserPaymentOptionStatus;
use App\Models\LegacyInvoice;
use App\Models\PaymentOption;
use App\Models\UserPaymentOption;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
/**
* Leitet die noch aktiven, jährlich wiederkehrenden Legacy-Zahlungs-
* vereinbarungen aus dem Rechnungsarchiv ab und migriert sie als
* `grandfathered` in `user_payment_options` (D-13, Kriterien 12.06.2026).
*
* Quelle ist ausschließlich das read-only Archiv `legacy_invoices` (D-12):
* Pro (Portal, Legacy-Vereinbarung) zählt die jüngste Rechnung. Aktiv ist,
* was dort `payment_option.type = recurring`, `user_payment_option.status
* = active` und eine nicht zu weit zurückliegende `next_due_date` trägt.
* Alle anderen Rechnungen bleiben unangetastet im Archiv.
*
* Die migrierte Vereinbarung trägt `current_period_end = next_due_date`
* damit stellt `billing:generate-manual-invoices` (MAN-Kreis) die nächste
* Rechnung zum gewohnten Rhythmus mit den Beträgen der letzten
* Legacy-Rechnung aus.
*
* Wiederholbar (D-18): Re-Runs aktualisieren bestehende Einträge anhand
* der Legacy-IDs in `legacy_conditions`, statt Duplikate anzulegen
* die Kern-Migration läuft kurz vor dem Relaunch erneut.
*
* Verwendung:
* php artisan legacy:grandfather-subscriptions --dry-run
* php artisan legacy:grandfather-subscriptions --as-of=2026-06-12
* php artisan legacy:grandfather-subscriptions
*/
class GrandfatherLegacySubscriptions extends Command
{
protected $signature = 'legacy:grandfather-subscriptions
{--dry-run : Nur anzeigen, nichts schreiben}
{--as-of= : Stichtag für die Aktiv-Prüfung (Default: heute)}
{--grace-months=12 : Wie lange eine überfällige next_due_date noch als aktiv gilt}
{--no-report : Keinen JSON-Report schreiben}';
protected $description = 'Migriert aktive wiederkehrende Legacy-Zahlungen aus dem Rechnungsarchiv nach user_payment_options (grandfathered).';
private const CHUNK_SIZE = 200;
public function handle(): int
{
$isDryRun = (bool) $this->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<int, array<string, mixed>>
*/
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<string, mixed>
*/
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, ',', '.'),
);
}
}

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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)

View file

@ -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 €.

View file

@ -0,0 +1,189 @@
<?php
use App\Console\Commands\GrandfatherLegacySubscriptions;
use App\Enums\UserPaymentOptionStatus;
use App\Models\BillingAddress;
use App\Models\Invoice;
use App\Models\LegacyInvoice;
use App\Models\PaymentOption;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\Billing\ManualInvoiceService;
use Illuminate\Support\Carbon;
function legacyArchiveInvoice(User $user, array $overrides = []): LegacyInvoice
{
static $legacyId = 1000;
$defaults = [
'legacy_portal' => '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);
});