From be7d1799a5c8bd9e0a1f466def478e609bec426c Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 16 Jun 2026 15:20:18 +0000 Subject: [PATCH] =?UTF-8?q?WS-4:=20Bestandsschutz-Abrechnung=20=E2=80=93?= =?UTF-8?q?=20gekoppelte=20K=C3=BCndigung=20+=20Admin-Sichtbarkeit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1 (portalübergreifend) und M5 (04:30-Worker) verifiziert; M2 + M3 umgesetzt. - M2 (Entscheidung: getrennte Rechnungen, gekoppelte Kündigung): neuer LegacySubscriptionService::cancelBundleFor() kündigt das gesamte Bestandsschutz-Bündel eines Kunden gemeinsam – keine Einzelkündigung. Kein Rechnungs-Schema-Umbau (jede Kondition behält ihre MAN-Rechnung). - M3: Admin-Payments-Ansicht zeigt eine "Bestandsschutz-Posten"-Tabelle (Badge, Portal, Netto, nächste Fälligkeit, "Bündel (N)") + Bündel-Kündigung mit Bestätigungsmodal. - Tests: tests/Feature/Billing/LegacyBundleTest.php (M1 cross-portal, M2 Bündel- Kündigung + Gate, M3 Admin-Liste + Kündigung). Detailplan WS-4 dokumentiert. Co-Authored-By: Claude Opus 4.8 --- .../Billing/LegacySubscriptionService.php | 56 +++++ ...Link, Compliance, Bestandsschutz, Auth).md | 7 + .../livewire/admin/payments/index.blade.php | 191 ++++++++++++++++++ tests/Feature/Billing/LegacyBundleTest.php | 118 +++++++++++ 4 files changed, 372 insertions(+) create mode 100644 app/Services/Billing/LegacySubscriptionService.php create mode 100644 tests/Feature/Billing/LegacyBundleTest.php 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) +
$notificationType === 'error', + 'bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]' => $notificationType !== 'error', + ])> + @if ($notificationType === 'error') + + @else + + @endif + {{ $notification }} +
+ @endif + {{-- ============== KPI-Reihe ============== --}}
@@ -156,6 +226,127 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon + {{-- ============== BESTANDSSCHUTZ (Legacy-Posten, M3/M2) ============== --}} +
+
+ {{ __('Bestandsschutz-Posten') }} + + {{ __(':posts Posten · :customers Kunden', [ + 'posts' => number_format($stats['grandfathered_posts'], 0, ',', '.'), + 'customers' => number_format($stats['grandfathered_customers'], 0, ',', '.'), + ]) }} + +
+
+ {{ __('Migrierte Legacy-Vereinbarungen (kein Tarif-Merge). Posten je Portal getrennt, aber als Bündel nur gemeinsam kündbar – keine Einzelkündigung.') }} +
+ + + {{ __('User') }} + {{ __('Kondition') }} + {{ __('Portal') }} + {{ __('Netto') }} + {{ __('Nächste Fälligkeit') }} + {{ __('Bündel') }} + {{ __('Aktion') }} + + + @forelse ($bestandsschutz as $post) + @php + $conditions = $post->legacy_conditions ?? []; + $netCents = (int) ($conditions['net_cents'] ?? 0); + $portalValue = $conditions['legacy_portal'] ?? null; + $bundleSize = (int) ($bundleCounts[$post->user_id] ?? 1); + @endphp + + + @if ($post->user) +
+ + {{ $post->user->name }} + +
{{ $post->user->email }}
+
+ @else + + @endif +
+ + {{ __('Bestandsschutz') }} +
+ {{ $conditions['name'] ?? ($post->paymentOption?->article_number ?? '–') }} +
+
+ + {{ $portalValue ? \Illuminate\Support\Str::headline($portalValue) : __('Unbekannt') }} + + + + {{ number_format($netCents / 100, 2, ',', '.') }} € + + + + + {{ $post->current_period_end?->format('d.m.Y') ?? '–' }} + + + + @if ($bundleSize > 1) + {{ __('Bündel (:n)', ['n' => $bundleSize]) }} + @else + {{ __('Einzeln') }} + @endif + + + + + {{ __('Bündel kündigen') }} + + + + +
+
+ {{ __('Bestandsschutz-Bündel kündigen?') }} + + {{ trans_choice( + 'Es wird :n Bestandsschutz-Posten von :user gekündigt. Eine Einzelkündigung ist nicht vorgesehen.|Es werden alle :n Bestandsschutz-Posten von :user gemeinsam gekündigt. Eine Einzelkündigung ist nicht vorgesehen.', + $bundleSize, + ['n' => $bundleSize, 'user' => $post->user?->name ?? '–'], + ) }} + +
+
+ + {{ __('Abbrechen') }} + + + {{ __('Bündel kündigen') }} + +
+
+
+
+
+ @empty + + +
+
+ {{ __('Keine laufenden Bestandsschutz-Posten') }} +
+
+
+
+ @endforelse +
+ +
+ {{ $bestandsschutz->links('components.portal.pagination') }} +
+
+ {{-- ============== ABOS ============== --}}
diff --git a/tests/Feature/Billing/LegacyBundleTest.php b/tests/Feature/Billing/LegacyBundleTest.php new file mode 100644 index 0000000..c26280d --- /dev/null +++ b/tests/Feature/Billing/LegacyBundleTest.php @@ -0,0 +1,118 @@ +create([ + 'user_id' => $user->id, + 'status' => UserPaymentOptionStatus::Grandfathered->value, + 'stripe_subscription_id' => null, + 'cancelled_at' => null, + 'legacy_conditions' => [ + 'legacy_portal' => $portal, + 'net_cents' => $netCents, + 'name' => 'Legacy '.$portal, + 'interval' => 'yearly', + ], + ]); +} + +// ===== M1: portalübergreifend ===== + +test('grandfathered bestandsschutz counts as booking regardless of active portal', function () { + /** @var TestCase $this */ + config()->set('billing.enforce_booking', true); + + $user = User::factory()->create(); + grandfatheredPost($user, Portal::Presseecho->value); + + // Aktiver Portal-Kontext businessportal24 darf den presseecho-Posten nicht ausschließen. + CurrentPortalContext::set(Portal::Businessportal24); + expect($user->hasActiveBooking())->toBeTrue(); + + CurrentPortalContext::clear(); + expect($user->fresh()->hasActiveBooking())->toBeTrue(); +}); + +// ===== M2: gekoppelte Kündigung ===== + +test('cancelling a bundle cancels all grandfathered posts of the customer together', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + $a = grandfatheredPost($user, Portal::Presseecho->value); + $b = grandfatheredPost($user, Portal::Businessportal24->value); + + $count = app(LegacySubscriptionService::class)->cancelBundleFor($user); + + expect($count)->toBe(2); + expect($a->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled); + expect($b->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled); + expect($a->fresh()->cancelled_at)->not->toBeNull(); +}); + +test('cancelling a bundle leaves other customers untouched', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + $other = User::factory()->create(); + grandfatheredPost($user, Portal::Presseecho->value); + $otherPost = grandfatheredPost($other, Portal::Presseecho->value); + + app(LegacySubscriptionService::class)->cancelBundleFor($user); + + expect($otherPost->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered); +}); + +test('a cancelled bundle no longer counts as an active booking', function () { + /** @var TestCase $this */ + config()->set('billing.enforce_booking', true); + + $user = User::factory()->create(); + grandfatheredPost($user, Portal::Presseecho->value); + + app(LegacySubscriptionService::class)->cancelBundleFor($user); + + expect($user->fresh()->hasActiveBooking())->toBeFalse(); +}); + +// ===== M3: Admin-Sichtbarkeit + Bündel-Kündigung über die UI ===== + +test('the payments page lists bestandsschutz posts and cancels a whole bundle', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); + + $admin = User::factory()->create(['is_active' => true]); + $admin->assignRole('admin'); + + $customer = User::factory()->create(['name' => 'Bestandskunde Test', 'is_active' => true]); + grandfatheredPost($customer, Portal::Presseecho->value); + grandfatheredPost($customer, Portal::Businessportal24->value); + + Volt::actingAs($admin) + ->test('admin.payments.index') + ->assertOk() + ->assertSee('Bestandskunde Test') + ->assertSee('Bestandsschutz') + ->call('cancelBundle', $customer->id) + ->assertHasNoErrors(); + + $stillOpen = UserPaymentOption::query() + ->where('user_id', $customer->id) + ->where('status', UserPaymentOptionStatus::Grandfathered->value) + ->count(); + + expect($stillOpen)->toBe(0); +});