WS-4: Bestandsschutz-Abrechnung – gekoppelte Kündigung + Admin-Sichtbarkeit

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 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-16 15:20:18 +00:00
parent 6668f492ad
commit be7d1799a5
4 changed files with 372 additions and 0 deletions

View file

@ -0,0 +1,56 @@
<?php
namespace App\Services\Billing;
use App\Enums\UserPaymentOptionStatus;
use App\Models\User;
use App\Models\UserPaymentOption;
use Illuminate\Support\Collection;
/**
* Bestandsschutz-Vereinbarungen (WS-4, M2).
*
* Legacy-Posten eines Kunden gelten als Bündel: Sie werden je Portal als eigene
* Kondition geführt (kein Tarif-Merge), sind aber NUR GEMEINSAM kündbar es gibt
* bewusst keine Einzelkündigung. Die Abrechnung selbst bleibt pro Posten (eigene
* MAN-Rechnung, siehe ManualInvoiceService).
*/
class LegacySubscriptionService
{
/**
* Noch laufende Bestandsschutz-Posten eines Kunden (das „Bündel").
*
* @return Collection<int, UserPaymentOption>
*/
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();
}
}

View file

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

View file

@ -1,9 +1,13 @@
<?php
use App\Enums\InvoiceStatus;
use App\Enums\UserPaymentOptionStatus;
use App\Models\Invoice;
use App\Models\Plan;
use App\Models\SinglePurchase;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\Billing\LegacySubscriptionService;
use Illuminate\Database\Eloquent\Builder;
use Laravel\Cashier\Subscription;
use Livewire\Attributes\Layout;
@ -17,11 +21,42 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
public string $search = '';
public string $notification = '';
public string $notificationType = 'success';
public function updatedSearch(): void
{
$this->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
</div>
</header>
@if ($notification)
<div x-data="{ show: true }" x-init="setTimeout(() => show = false, 3500)" x-show="show" x-transition
@class([
'px-4 py-3 rounded-[5px] border text-[12.5px] flex items-center gap-2',
'bg-[color:var(--color-err-soft)] border-[color:var(--color-err)]/30 text-[color:var(--color-ink-2)]' => $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')
<flux:icon.exclamation-triangle class="size-[16px] flex-shrink-0" />
@else
<flux:icon.check-circle class="size-[16px] flex-shrink-0" />
@endif
{{ $notification }}
</div>
@endif
{{-- ============== KPI-Reihe ============== --}}
<section class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<x-portal.stat-card variant="primary" :label="__('Aktive Abos')" :value="number_format($stats['active_subscriptions'], 0, ',', '.')">
@ -156,6 +226,127 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
</div>
</article>
{{-- ============== BESTANDSSCHUTZ (Legacy-Posten, M3/M2) ============== --}}
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Bestandsschutz-Posten') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __(':posts Posten · :customers Kunden', [
'posts' => number_format($stats['grandfathered_posts'], 0, ',', '.'),
'customers' => number_format($stats['grandfathered_customers'], 0, ',', '.'),
]) }}
</span>
</div>
<div class="px-5 pt-4 text-[12px] text-[color:var(--color-ink-3)]">
{{ __('Migrierte Legacy-Vereinbarungen (kein Tarif-Merge). Posten je Portal getrennt, aber als Bündel nur gemeinsam kündbar keine Einzelkündigung.') }}
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('User') }}</flux:table.column>
<flux:table.column>{{ __('Kondition') }}</flux:table.column>
<flux:table.column>{{ __('Portal') }}</flux:table.column>
<flux:table.column>{{ __('Netto') }}</flux:table.column>
<flux:table.column>{{ __('Nächste Fälligkeit') }}</flux:table.column>
<flux:table.column>{{ __('Bündel') }}</flux:table.column>
<flux:table.column>{{ __('Aktion') }}</flux:table.column>
</flux:table.columns>
@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
<flux:table.row wire:key="bestandsschutz-{{ $post->id }}">
<flux:table.cell>
@if ($post->user)
<div class="space-y-0.5">
<flux:button size="xs" variant="filled" :href="route('admin.users.show', $post->user)" wire:navigate>
{{ $post->user->name }}
</flux:button>
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ $post->user->email }}</div>
</div>
@else
<span class="text-[12px] text-[color:var(--color-ink-3)]"></span>
@endif
</flux:table.cell>
<flux:table.cell>
<span class="badge ok dot">{{ __('Bestandsschutz') }}</span>
<div class="text-[12px] text-[color:var(--color-ink)] mt-1">
{{ $conditions['name'] ?? ($post->paymentOption?->article_number ?? '') }}
</div>
</flux:table.cell>
<flux:table.cell>
<span class="badge hub">{{ $portalValue ? \Illuminate\Support\Str::headline($portalValue) : __('Unbekannt') }}</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12.5px] text-[color:var(--color-ink)]">
{{ number_format($netCents / 100, 2, ',', '.') }}
</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12px] text-[color:var(--color-ink-3)]">
{{ $post->current_period_end?->format('d.m.Y') ?? '' }}
</span>
</flux:table.cell>
<flux:table.cell>
@if ($bundleSize > 1)
<span class="badge warn">{{ __('Bündel (:n)', ['n' => $bundleSize]) }}</span>
@else
<span class="badge">{{ __('Einzeln') }}</span>
@endif
</flux:table.cell>
<flux:table.cell>
<flux:modal.trigger name="cancel-bundle-{{ $post->user_id }}">
<flux:button size="sm" variant="filled" icon="x-circle" type="button"
x-on:click.prevent="$dispatch('open-modal', 'cancel-bundle-{{ $post->user_id }}')">
{{ __('Bündel kündigen') }}
</flux:button>
</flux:modal.trigger>
<flux:modal name="cancel-bundle-{{ $post->user_id }}" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Bestandsschutz-Bündel kündigen?') }}</flux:heading>
<flux:subheading>
{{ 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 ?? ''],
) }}
</flux:subheading>
</div>
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button variant="danger" wire:click="cancelBundle({{ $post->user_id }})">
{{ __('Bündel kündigen') }}
</flux:button>
</div>
</div>
</flux:modal>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="7">
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
{{ __('Keine laufenden Bestandsschutz-Posten') }}
</div>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table>
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
{{ $bestandsschutz->links('components.portal.pagination') }}
</div>
</article>
{{-- ============== ABOS ============== --}}
<article class="panel overflow-hidden">
<div class="panel-head">

View file

@ -0,0 +1,118 @@
<?php
use App\Enums\Portal;
use App\Enums\UserPaymentOptionStatus;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\Billing\LegacySubscriptionService;
use App\Services\CurrentPortalContext;
use Database\Seeders\RolesAndPermissionsSeeder;
use Livewire\Volt\Volt;
use Tests\TestCase;
afterEach(function () {
// Statischer Portal-Kontext darf nicht in andere Tests lecken.
CurrentPortalContext::clear();
});
function grandfatheredPost(User $user, string $portal, int $netCents = 100000): UserPaymentOption
{
return UserPaymentOption::factory()->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);
});