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:
parent
6668f492ad
commit
be7d1799a5
4 changed files with 372 additions and 0 deletions
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue