diff --git a/app/Services/Billing/LegacySubscriptionService.php b/app/Services/Billing/LegacySubscriptionService.php new file mode 100644 index 0000000..cd64b95 --- /dev/null +++ b/app/Services/Billing/LegacySubscriptionService.php @@ -0,0 +1,56 @@ + + */ + public function grandfatheredFor(User $user): Collection + { + return UserPaymentOption::query() + ->where('user_id', $user->getKey()) + ->where('status', UserPaymentOptionStatus::Grandfathered->value) + ->whereNull('cancelled_at') + ->get(); + } + + /** + * Kündigt das GESAMTE Bestandsschutz-Bündel eines Kunden gemeinsam. Es gibt + * keine Einzelkündigung – alle laufenden Grandfathered-Posten werden in einem + * Schritt auf `cancelled` gesetzt, womit der 04:30-Worker keine weiteren + * MAN-Rechnungen mehr erzeugt (duePaymentOptions berücksichtigt nur + * Active/Grandfathered). + * + * @return int Anzahl der gekündigten Posten + */ + public function cancelBundleFor(User $user): int + { + $posts = $this->grandfatheredFor($user); + + foreach ($posts as $post) { + $post->update([ + 'status' => UserPaymentOptionStatus::Cancelled->value, + 'cancelled_at' => now(), + ]); + } + + return $posts->count(); + } +} diff --git a/docs/weiteres/Detailplan Umsetzung Launch-Slice (Magic-Link, Compliance, Bestandsschutz, Auth).md b/docs/weiteres/Detailplan Umsetzung Launch-Slice (Magic-Link, Compliance, Bestandsschutz, Auth).md index 7df2521..63d31c5 100644 --- a/docs/weiteres/Detailplan Umsetzung Launch-Slice (Magic-Link, Compliance, Bestandsschutz, Auth).md +++ b/docs/weiteres/Detailplan Umsetzung Launch-Slice (Magic-Link, Compliance, Bestandsschutz, Auth).md @@ -78,6 +78,13 @@ Alle hinter WS-2 **oder** regulärem Login. ### WS-4 — Bestandsschutz-Abrechnung & -Übersicht (Merkliste 1·2·3·5) **Scope:** Vorhandene Mechanik (`GrandfatherLegacySubscriptions`, `GenerateManualInvoices`) verifizieren + Lücken schließen. **Legacy-Posten bleiben unverändert (Entscheidung 1) – kein Tarif-Mapping.** + +**Status (16.06.):** +- **M1 (portalübergreifend) – verifiziert:** `hasActiveBooking()` / `duePaymentOptions()` filtern nicht nach `legacy_portal`; Beweis-Test ergänzt (grandfathered Posten zählt unabhängig vom aktiven Portal-Kontext). +- **M5 (Import + 04:30-Worker) – verifiziert:** `GenerateManualInvoices` läuft täglich 04:30 (`routes/console.php`), berücksichtigt Active + Grandfathered ohne Stripe. +- **M2 (gemeinsam abrechnen, keine Einzelkündigung) – umgesetzt (Entscheidung 16.06.: getrennte Rechnungen, gekoppelte Kündigung):** kein Rechnungs-Schema-Umbau; jede Kondition behält ihre eigene MAN-Rechnung. Neuer `LegacySubscriptionService::cancelBundleFor()` kündigt das gesamte Bestandsschutz-Bündel eines Kunden **gemeinsam** – es gibt bewusst keine Einzelkündigung. +- **M3 (Admin-Sichtbarkeit) – umgesetzt:** Admin-Payments-Ansicht zeigt eine eigene „Bestandsschutz-Posten"-Tabelle (Badge, Portal, Netto, nächste Fälligkeit, „Bündel (N)"-Indikator) inkl. Bündel-Kündigung mit Bestätigung. +- Tests: `tests/Feature/Billing/LegacyBundleTest.php` (M1/M2/M3). **Offen optional:** kombinierte Sammel-Rechnung (eine Rechnung mit Positionen) wurde bewusst nicht gebaut. - **M1 — Beide Portale ohne Unterscheidung:** Bestandsschutz gilt portalübergreifend, auch wenn nur für ein Portal gebucht → sicherstellen, dass `legacy_portal` im Import **kein** Filter ist. - **M2 — Zwei Posten zusammen abrechnen, nicht einzeln kündbar:** Zwei Legacy-Posten desselben Kunden (je Portal, z. B. connektar 2×1000 € netto) werden gemeinsam abgerechnet und gelten zusammen (Gesamtsumme 2000 € netto bleibt). Die einzelnen Positionen bleiben als eigene Legacy-Konditionen erhalten – **keine** Zusammenführung zu einem Tarif, **keine** Einzelkündigung. - **M5 — Einmal-Import + täglicher Worker:** Verifizieren, dass alle aktiven Abrechnungen erfasst werden und der 04:30-Worker fällige Rechnungen korrekt erzeugt; ggf. Gesamtübersicht ergänzen. diff --git a/resources/views/livewire/admin/payments/index.blade.php b/resources/views/livewire/admin/payments/index.blade.php index 594dc11..8835adf 100644 --- a/resources/views/livewire/admin/payments/index.blade.php +++ b/resources/views/livewire/admin/payments/index.blade.php @@ -1,9 +1,13 @@ resetPage('subscriptionsPage'); $this->resetPage('purchasesPage'); $this->resetPage('invoicesPage'); + $this->resetPage('bestandsschutzPage'); + } + + /** + * Kündigt das gesamte Bestandsschutz-Bündel eines Kunden gemeinsam + * (M2: keine Einzelkündigung). + */ + public function cancelBundle(int $userId, LegacySubscriptionService $legacy): void + { + $user = User::query()->find($userId); + + if (! $user) { + $this->notification = __('Der angeforderte Kunde wurde nicht gefunden.'); + $this->notificationType = 'error'; + + return; + } + + $count = $legacy->cancelBundleFor($user); + + $this->notification = trans_choice( + ':count Bestandsschutz-Posten gemeinsam gekündigt.|:count Bestandsschutz-Posten gemeinsam gekündigt.', + $count, + ['count' => $count], + ); + $this->notificationType = 'success'; + $this->resetPage('bestandsschutzPage'); } public function with(): array @@ -54,6 +89,18 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon : $entry['plan']->monthly_price_cents; }); + // Bestandsschutz-Posten (M3): laufende Grandfathered-Vereinbarungen. + $grandfatheredBase = fn (): Builder => UserPaymentOption::query() + ->where('status', UserPaymentOptionStatus::Grandfathered->value) + ->whereNull('cancelled_at'); + + // Bündel-Größe je Kunde (für die „Bündel (N)"-Anzeige) – eine günstige + // Aggregat-Query statt N+1. + $bundleCounts = $grandfatheredBase() + ->selectRaw('user_id, count(*) as aggregate_count') + ->groupBy('user_id') + ->pluck('aggregate_count', 'user_id'); + return [ 'plansByPriceId' => $plansByPriceId, 'stats' => [ @@ -64,7 +111,14 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon ->where('paid_at', '>=', now()->subDays(30)) ->sum('total_cents'), 'open_purchases' => SinglePurchase::query()->grantingSubmission()->count(), + 'grandfathered_posts' => $bundleCounts->sum(), + 'grandfathered_customers' => $bundleCounts->count(), ], + 'bestandsschutz' => $this->searchByUser($grandfatheredBase()->with('user:id,name,email')) + ->orderBy('user_id') + ->orderBy('id') + ->paginate(25, pageName: 'bestandsschutzPage'), + 'bundleCounts' => $bundleCounts, 'subscriptions' => $this->searchByUser(Subscription::query()->with('owner')) ->latest('created_at') ->paginate(25, pageName: 'subscriptionsPage'), @@ -125,6 +179,22 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon + @if ($notification) +