presseportale/resources/views/livewire/admin/payments/index.blade.php
Kevin Adametz 31da9ea8b6
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
livewire: Volt entfernt — Komponenten als Livewire-4-Single-File-Components, Route::livewire(), Livewire::test(); livewire/volt deinstalliert
- 83 Komponenten: Livewire\Volt\Component → Livewire\Component
- 73 Routen (admin, auth, customer): Volt::route() → Route::livewire()
- 88 Testdateien: Volt::test/actingAs → Livewire::test/actingAs
- VoltServiceProvider gelöscht; composer remove livewire/volt
- config/livewire.php auf v4-Schlüssel (component_layout, component_placeholder,
  component_locations, make_command: SFC ohne ⚡). Der alte Schlüssel layout war
  wirkungslos; alle gerouteten Komponenten setzen ihr Layout selbst.
- CLAUDE.md, AGENTS.md, boost.json, .cursorrules, Skill volt-development
- Roadmap: erledigt; Larastan erfasst SFCs weiterhin nicht (Blade-Dateien)

Suite 2.325 grün, einer übersprungen; Larastan und Pint ohne Meldung;
route:cache und view:cache laufen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:59:36 +00:00

1024 lines
57 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Enums\InvoiceStatus;
use App\Enums\UserPaymentOptionStatus;
use App\Exceptions\ManualInvoiceActionException;
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 App\Services\Billing\ManualInvoiceDunningService;
use App\Services\Billing\ManualInvoiceService;
use Flux\Flux;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Laravel\Cashier\Subscription;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Component
{
use WithPagination;
public string $search = '';
public string $notification = '';
public string $notificationType = 'success';
/** Rechnungsausgang: `all` oder `man_open` (offene MAN-Rechnungen zuerst bearbeiten). */
public string $invoiceFilter = 'man_open';
/** Eingaben der Aktions-Modals (eine Rechnung zur Zeit). */
public string $paidAtInput = '';
public string $terminateNote = '';
public bool $terminateNotify = true;
public string $abandonNote = '';
public function mount(): void
{
$this->paidAtInput = today()->toDateString();
}
public function updatedSearch(): void
{
$this->resetPage('subscriptionsPage');
$this->resetPage('purchasesPage');
$this->resetPage('invoicesPage');
$this->resetPage('bestandsschutzPage');
}
public function updatedInvoiceFilter(): void
{
$this->resetPage('invoicesPage');
}
/**
* MAN-Rechnung per Mail (mit PDF) an den Kunden senden; Zahlungsziel
* läuft ab heute.
*/
public function sendInvoice(int $invoiceId, ManualInvoiceDunningService $dunning): void
{
$this->runInvoiceAction($invoiceId, fn (Invoice $invoice): string => __(
'Rechnung :number an :email gesendet — zahlbar bis :date.',
$this->messageParts($dunning->send($invoice, auth()->user())),
), 'send-invoice-'.$invoiceId);
}
public function remindInvoice(int $invoiceId, ManualInvoiceDunningService $dunning): void
{
$this->runInvoiceAction($invoiceId, fn (Invoice $invoice): string => __(
'Zahlungserinnerung zu :number an :email gesendet — zahlbar bis :date.',
$this->messageParts($dunning->remind($invoice, auth()->user())),
), 'remind-invoice-'.$invoiceId);
}
public function markInvoicePaid(int $invoiceId, ManualInvoiceDunningService $dunning): void
{
$this->validate(['paidAtInput' => ['required', 'date']]);
$this->runInvoiceAction($invoiceId, function (Invoice $invoice) use ($dunning): string {
$paid = $dunning->markPaid($invoice, Carbon::parse($this->paidAtInput)->startOfDay(), auth()->user());
return __('Rechnung :number als bezahlt markiert (Zahlungseingang :date).', [
'number' => $paid->number,
'date' => $paid->paid_at->format('d.m.Y'),
]);
}, 'paid-invoice-'.$invoiceId);
}
public function terminateInvoice(int $invoiceId, ManualInvoiceDunningService $dunning): void
{
$this->runInvoiceAction($invoiceId, function (Invoice $invoice) use ($dunning): string {
$cancelled = $dunning->terminateForNonPayment(
$invoice,
auth()->user(),
$this->terminateNotify,
filled($this->terminateNote) ? trim($this->terminateNote) : null,
);
$this->reset(['terminateNote', 'terminateNotify']);
$this->resetPage('bestandsschutzPage');
return trans_choice(
'Rechnung :number storniert, :count Bestandsschutz-Posten gekündigt.|Rechnung :number storniert, :count Bestandsschutz-Posten gekündigt.',
$cancelled,
['number' => $invoice->number, 'count' => $cancelled],
);
}, 'terminate-invoice-'.$invoiceId);
}
/**
* Fällige, aber blockierte Vereinbarung sofort abrechnen (nachdem z. B.
* die Rechnungsadresse nachgetragen wurde) statt auf den 04:30-Lauf zu warten.
*/
public function invoiceNow(int $optionId, ManualInvoiceService $manualInvoices): void
{
$this->authorize('billing:manage');
$option = UserPaymentOption::query()->find($optionId);
$invoice = $option ? $manualInvoices->invoiceFor($option) : null;
if (! $invoice) {
$this->notify(__('Die Vereinbarung konnte nicht abgerechnet werden — fehlt die Rechnungsadresse des Kunden?'), 'error');
return;
}
$this->notify(__('Rechnung :number ausgestellt — jetzt an den Kunden senden.', ['number' => $invoice->number]));
}
/**
* Blockierte Vereinbarung ohne Abrechnung entfernen (Kunde nicht
* erreichbar / keine Adresse): Bündel gekündigt, Alt-Rechnung abgeschrieben.
*/
public function abandonAgreement(int $optionId, ManualInvoiceDunningService $dunning): void
{
$this->authorize('billing:manage');
$option = UserPaymentOption::query()->with('user')->find($optionId);
if (! $option) {
$this->notify(__('Die Vereinbarung wurde nicht gefunden.'), 'error');
return;
}
try {
$cancelled = $dunning->abandonAgreement($option, auth()->user(), filled($this->abandonNote) ? trim($this->abandonNote) : null);
$this->reset('abandonNote');
$this->resetPage('bestandsschutzPage');
$this->notify(trans_choice(
'Vereinbarung von :user entfernt — :count Bestandsschutz-Posten gekündigt, keine Rechnung gestellt.|Vereinbarung von :user entfernt — :count Bestandsschutz-Posten gekündigt, keine Rechnung gestellt.',
$cancelled,
['user' => $option->user?->name ?? '–', 'count' => $cancelled],
));
} catch (ManualInvoiceActionException $exception) {
$this->notify($exception->getMessage(), 'error');
}
Flux::modal('abandon-agreement-'.$optionId)->close();
}
/**
* Kündigt das gesamte Bestandsschutz-Bündel eines Kunden gemeinsam
* (M2: keine Einzelkündigung).
*/
public function cancelBundle(int $userId, LegacySubscriptionService $legacy): void
{
$this->authorize('billing:manage');
$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');
}
/**
* Gemeinsamer Rahmen der Rechnungsaktionen: Berechtigung, Laden,
* fachliche Fehlermeldung statt Exception, Modal schließen.
*
* @param \Closure(Invoice): string $action
*/
private function runInvoiceAction(int $invoiceId, \Closure $action, string $modal): void
{
$this->authorize('billing:manage');
$invoice = Invoice::query()->with('user')->find($invoiceId);
if (! $invoice) {
$this->notify(__('Die Rechnung wurde nicht gefunden.'), 'error');
return;
}
try {
$this->notify($action($invoice));
} catch (ManualInvoiceActionException $exception) {
$this->notify($exception->getMessage(), 'error');
}
Flux::modal($modal)->close();
}
/**
* @return array{number: string, email: string, date: string}
*/
private function messageParts(Invoice $invoice): array
{
return [
'number' => (string) $invoice->number,
'email' => (string) $invoice->user?->email,
'date' => (string) $invoice->due_date?->format('d.m.Y'),
];
}
private function notify(string $message, string $type = 'success'): void
{
$this->notification = $message;
$this->notificationType = $type;
}
public function with(): array
{
$plans = Plan::query()->get();
/** @var array<string, array{plan: Plan, interval: string}> $plansByPriceId */
$plansByPriceId = [];
foreach ($plans as $plan) {
if ($plan->stripe_price_id_monthly) {
$plansByPriceId[$plan->stripe_price_id_monthly] = ['plan' => $plan, 'interval' => __('monatlich')];
}
if ($plan->stripe_price_id_yearly) {
$plansByPriceId[$plan->stripe_price_id_yearly] = ['plan' => $plan, 'interval' => __('jährlich')];
}
}
$activeSubscriptions = Subscription::query()->active()->get();
$monthlyRecurringCents = $activeSubscriptions->sum(function (Subscription $subscription) use ($plansByPriceId): int {
$entry = $plansByPriceId[$subscription->stripe_price] ?? null;
if (! $entry) {
return 0;
}
return $entry['interval'] === __('jährlich')
? (int) round($entry['plan']->yearly_price_cents / 12)
: $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');
// Offene MAN-Rechnungen (Überweisungskunden): der Bearbeitungsstapel.
$openManual = Invoice::query()->manual()->open();
// Fällige Vereinbarungen ohne Rechnung = vom 04:30-Lauf übersprungen
// (fehlende Rechnungsadresse o. ä.) — sonst wäre die Periode weitergeschaltet.
$blockedAgreements = app(ManualInvoiceService::class)->duePaymentOptions(limit: 100);
// Letzte MAN-Rechnung je Bestandsschutz-Posten (für die Inline-Anzeige).
$latestInvoiceByOption = Invoice::query()
->manual()
->whereNotNull('user_payment_option_id')
->orderByDesc('invoice_date')
->orderByDesc('id')
->get()
->unique('user_payment_option_id')
->keyBy('user_payment_option_id');
return [
'plansByPriceId' => $plansByPriceId,
'stats' => [
'active_subscriptions' => $activeSubscriptions->count(),
'mrr_cents' => $monthlyRecurringCents,
'revenue_30d_cents' => (int) Invoice::query()
->where('status', InvoiceStatus::Paid->value)
->where('paid_at', '>=', now()->subDays(30))
->sum('total_cents'),
'open_purchases' => SinglePurchase::query()->grantingSubmission()->count(),
'grandfathered_posts' => $bundleCounts->sum(),
'grandfathered_customers' => $bundleCounts->count(),
'open_manual_count' => (clone $openManual)->count(),
'open_manual_cents' => (int) (clone $openManual)->sum('total_cents'),
'overdue_manual_count' => (clone $openManual)->whereDate('due_date', '<', today())->count(),
'unsent_manual_count' => (clone $openManual)->whereNull('sent_at')->count(),
],
'blockedAgreements' => $blockedAgreements,
'latestInvoiceByOption' => $latestInvoiceByOption,
'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'),
'purchases' => $this->searchByUser(SinglePurchase::query()->with(['user', 'pressRelease']))
->latest('created_at')
->paginate(25, pageName: 'purchasesPage'),
'invoices' => $this->searchByUser(Invoice::query()->with('user'))
->when($this->invoiceFilter === 'man_open', fn (Builder $query) => $query->manual()->open())
->when($this->invoiceFilter === 'man_open', fn (Builder $query) => $query->orderBy('due_date'), fn (Builder $query) => $query->latest('invoice_date'))
->latest('id')
->paginate(25, pageName: 'invoicesPage'),
];
}
/**
* Wendet die User-Suche (Name oder E-Mail) auf eine der drei
* Zahlungs-Tabellen an. Abos hängen über `owner` am User, Käufe und
* Rechnungen über `user`.
*/
private function searchByUser(Builder $query): Builder
{
if (! filled($this->search)) {
return $query;
}
$search = trim($this->search);
$relation = $query->getModel() instanceof Subscription ? 'owner' : 'user';
return $query->whereHas($relation, function (Builder $query) use ($search): void {
$query
->where('name', 'like', '%'.$search.'%')
->orWhere('email', 'like', '%'.$search.'%');
});
}
}; ?>
<div class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<x-portal.page-header
:badge="__('Admin Backend')"
:eyebrow="__('Administration · Finanzen')"
:title="__('Zahlungen')"
:description="__('Stripe-Abos, Einmalkäufe und der lokale Rechnungsausgang (STR-/MAN-Kreis) auf einen Blick. Stripe bleibt Zahlungs- und Belegquelle — diese Übersicht spiegelt die per Webhook synchronisierten Daten.')"
description-width="720px"
>
<x-slot:actions>
<flux:button size="sm" variant="filled" icon="archive-box" :href="route('admin.invoices.index')" wire:navigate>
{{ __('Legacy-Rechnungen') }}
</flux:button>
<flux:button size="sm" variant="primary" icon="rectangle-stack" :href="route('admin.payments.plans')" wire:navigate>
{{ __('Tarife & Pakete') }}
</flux:button>
</x-slot:actions>
</x-portal.page-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-5">
<x-portal.stat-card variant="primary" :label="__('Aktive Abos')" :value="number_format($stats['active_subscriptions'], 0, ',', '.')">
<x-slot:meta>{{ __('Stripe-Subscriptions') }}</x-slot:meta>
</x-portal.stat-card>
<x-portal.stat-card variant="ok" :label="__('MRR (netto)')" :value="number_format($stats['mrr_cents'] / 100, 2, ',', '.').' €'">
<x-slot:meta>{{ __('monatlich wiederkehrend') }}</x-slot:meta>
</x-portal.stat-card>
<x-portal.stat-card variant="ok" :label="__('Umsatz 30 Tage')" :value="number_format($stats['revenue_30d_cents'] / 100, 2, ',', '.').' €'">
<x-slot:meta>{{ __('bezahlte Rechnungen, brutto') }}</x-slot:meta>
</x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Offene Einzel-PMs')" :value="number_format($stats['open_purchases'], 0, ',', '.')">
<x-slot:meta>{{ __('bezahlt, noch nicht eingelöst') }}</x-slot:meta>
</x-portal.stat-card>
<x-portal.stat-card :variant="$stats['overdue_manual_count'] > 0 ? 'warn' : 'muted'" :label="__('Offene MAN-Rechnungen')" :value="number_format($stats['open_manual_count'], 0, ',', '.')">
<x-slot:meta>
{{ __(':sum € · :overdue überfällig · :unsent ungesendet', [
'sum' => number_format($stats['open_manual_cents'] / 100, 2, ',', '.'),
'overdue' => $stats['overdue_manual_count'],
'unsent' => $stats['unsent_manual_count'],
]) }}
</x-slot:meta>
</x-portal.stat-card>
</section>
{{-- ============== SUCHE ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Suche') }}</span>
</div>
<div class="p-5">
<flux:input
wire:model.live.debounce.300ms="search"
placeholder="{{ __('Nach User-Name oder E-Mail suchen — filtert Abos, Käufe und Rechnungen...') }}"
icon="magnifying-glass"
class="max-w-xl"
/>
</div>
</article>
{{-- ============== BLOCKIERTE VEREINBARUNGEN (fällig, aber ohne Rechnung) ============== --}}
@if ($blockedAgreements->isNotEmpty())
<article class="panel overflow-hidden border-[color:var(--color-warn)]/40">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Blockierte Vereinbarungen') }}</span>
<span class="badge warn dot">{{ trans_choice(':count fällig ohne Rechnung|:count fällig ohne Rechnung', $blockedAgreements->count(), ['count' => $blockedAgreements->count()]) }}</span>
</div>
<div class="px-5 pt-4 text-[12px] text-[color:var(--color-ink-3)]">
{{ __('Die Fälligkeit ist erreicht, aber der tägliche Rechnungslauf konnte keine Rechnung ausstellen — in der Regel fehlt die Rechnungsadresse. Entweder Adresse im User-Profil nachtragen und „Jetzt abrechnen", oder die Vereinbarung ohne Abrechnung „Entfernen" (Kunde nicht erreichbar).') }}
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('User') }}</flux:table.column>
<flux:table.column>{{ __('Kondition') }}</flux:table.column>
<flux:table.column>{{ __('Fällig seit') }}</flux:table.column>
<flux:table.column>{{ __('Grund') }}</flux:table.column>
<flux:table.column>{{ __('Aktion') }}</flux:table.column>
</flux:table.columns>
@foreach ($blockedAgreements as $blocked)
<flux:table.row wire:key="blocked-{{ $blocked->id }}">
<flux:table.cell>
@if ($blocked->user)
<div class="space-y-0.5">
<flux:button size="xs" variant="filled" :href="route('admin.users.edit', $blocked->user)" wire:navigate>
{{ $blocked->user->name }}
</flux:button>
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ $blocked->user->email }}</div>
</div>
@else
<span class="text-[12px] text-[color:var(--color-ink-3)]">–</span>
@endif
</flux:table.cell>
<flux:table.cell>
<div class="text-[12px] text-[color:var(--color-ink)]">{{ $blocked->legacy_conditions['name'] ?? ($blocked->paymentOption?->article_number ?? '–') }}</div>
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ number_format((int) ($blocked->legacy_conditions['net_cents'] ?? $blocked->paymentOption?->price_cents ?? 0) / 100, 2, ',', '.') }} € netto</div>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12px] text-[color:var(--color-ink-2)]">{{ $blocked->current_period_end?->format('d.m.Y') }}</span>
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ $blocked->current_period_end?->diffForHumans() }}</div>
</flux:table.cell>
<flux:table.cell>
@if ($blocked->billing_hold_reason)
<span class="badge warn dot">{{ $blocked->billing_hold_reason }}</span>
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ __('seit :date', ['date' => $blocked->billing_hold_at?->format('d.m.Y')]) }}</div>
@elseif (! $blocked->user?->billingAddress)
<span class="badge warn dot">{{ __('Keine Rechnungsadresse') }}</span>
@else
<span class="badge">{{ __('Noch nicht geprüft') }}</span>
@endif
</flux:table.cell>
<flux:table.cell>
<div class="flex flex-wrap items-center gap-1.5">
<flux:button size="sm" variant="primary" icon="document-plus" wire:click="invoiceNow({{ $blocked->id }})">
{{ __('Jetzt abrechnen') }}
</flux:button>
<flux:modal.trigger name="abandon-agreement-{{ $blocked->id }}">
<flux:button size="sm" variant="danger" icon="trash">{{ __('Entfernen') }}</flux:button>
</flux:modal.trigger>
</div>
<flux:modal name="abandon-agreement-{{ $blocked->id }}" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Vereinbarung von :user ohne Abrechnung entfernen?', ['user' => $blocked->user?->name ?? '–']) }}</flux:heading>
<flux:subheading>
{{ __('Es wird keine Rechnung gestellt. Alle Bestandsschutz-Posten des Kunden werden gekündigt, eine noch offene Alt-Rechnung im Archiv wird abgeschrieben. Der Kunde erhält keine E-Mail; für neue Veröffentlichungen gelten die aktuellen Tarife.') }}
</flux:subheading>
</div>
<flux:field>
<flux:label>{{ __('Interner Vermerk (optional)') }}</flux:label>
<flux:input wire:model="abandonNote" placeholder="{{ __('z. B. E-Mail nicht erreichbar, keine Adresse') }}" />
</flux:field>
<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" icon="trash" wire:click="abandonAgreement({{ $blocked->id }})">{{ __('Entfernen') }}</flux:button>
</div>
</div>
</flux:modal>
</flux:table.cell>
</flux:table.row>
@endforeach
</flux:table>
</article>
@endif
{{-- ============== 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>
@if ($lastInvoice = $latestInvoiceByOption[$post->id] ?? null)
<div class="text-[11px] mt-0.5 flex items-center gap-1.5">
<span class="font-mono text-[color:var(--color-ink-2)]">{{ $lastInvoice->number }}</span>
@if ($lastInvoice->status === \App\Enums\InvoiceStatus::Paid)
<span class="badge ok">{{ __('bezahlt') }}</span>
@elseif ($lastInvoice->status === \App\Enums\InvoiceStatus::Open)
<span class="badge warn">{{ $lastInvoice->isOverdue() ? __('überfällig') : ($lastInvoice->sent_at ? __('offen') : __('ungesendet')) }}</span>
@else
<span class="badge">{{ $lastInvoice->status->label() }}</span>
@endif
</div>
@endif
</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>
<x-portal.table-pagination :paginator="$bestandsschutz" />
</article>
{{-- ============== ABOS ============== --}}
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Abos') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __(':count Einträge', ['count' => number_format($subscriptions->total(), 0, ',', '.')]) }}
</span>
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('User') }}</flux:table.column>
<flux:table.column>{{ __('Tarif') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Seit') }}</flux:table.column>
<flux:table.column>{{ __('Endet') }}</flux:table.column>
</flux:table.columns>
@forelse ($subscriptions as $subscription)
<flux:table.row wire:key="admin-subscription-{{ $subscription->id }}">
<flux:table.cell>
@if ($subscription->owner)
<div class="space-y-0.5">
<flux:button size="xs" variant="filled" :href="route('admin.users.show', $subscription->owner)" wire:navigate>
{{ $subscription->owner->name }}
</flux:button>
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ $subscription->owner->email }}</div>
</div>
@else
<span class="text-[12px] text-[color:var(--color-ink-3)]">–</span>
@endif
</flux:table.cell>
<flux:table.cell>
@php($planEntry = $plansByPriceId[$subscription->stripe_price] ?? null)
@if ($planEntry)
<div class="space-y-0.5">
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $planEntry['plan']->name }}</div>
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ $planEntry['interval'] }}</div>
</div>
@else
<div class="text-[11px] text-[color:var(--color-ink-3)] font-mono">{{ $subscription->stripe_price ?? '–' }}</div>
@endif
</flux:table.cell>
<flux:table.cell>
@if (in_array($subscription->stripe_status, ['active', 'trialing'], true))
<span class="badge ok dot">{{ $subscription->stripe_status === 'trialing' ? __('Testphase') : __('Aktiv') }}</span>
@elseif (in_array($subscription->stripe_status, ['past_due', 'unpaid', 'incomplete'], true))
<span class="badge warn dot">{{ $subscription->stripe_status }}</span>
@else
<span class="badge">{{ $subscription->stripe_status }}</span>
@endif
</flux:table.cell>
<flux:table.cell>
<span class="text-[12px] text-[color:var(--color-ink-2)]">{{ $subscription->created_at?->format('d.m.Y') ?? '–' }}</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12px] text-[color:var(--color-ink-2)]">{{ $subscription->ends_at?->format('d.m.Y') ?? '–' }}</span>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="5">
<div class="px-4 py-8 text-center text-[13px] text-[color:var(--color-ink-3)]">
{{ __('Noch keine Stripe-Abos vorhanden.') }}
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table>
<x-portal.table-pagination :paginator="$subscriptions" />
</article>
{{-- ============== EINMALKÄUFE ============== --}}
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Einmalkäufe') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __(':count Einträge', ['count' => number_format($purchases->total(), 0, ',', '.')]) }}
</span>
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('User') }}</flux:table.column>
<flux:table.column>{{ __('Typ') }}</flux:table.column>
<flux:table.column>{{ __('Betrag (netto)') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Bezahlt am') }}</flux:table.column>
<flux:table.column>{{ __('Eingelöst für') }}</flux:table.column>
</flux:table.columns>
@forelse ($purchases as $purchase)
<flux:table.row wire:key="admin-purchase-{{ $purchase->id }}">
<flux:table.cell>
@if ($purchase->user)
<div class="space-y-0.5">
<flux:button size="xs" variant="filled" :href="route('admin.users.show', $purchase->user)" wire:navigate>
{{ $purchase->user->name }}
</flux:button>
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ $purchase->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="text-[12.5px] text-[color:var(--color-ink-2)]">{{ $purchase->type->label() }}</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[13px] font-semibold text-[color:var(--color-ink)] tabular-nums">{{ number_format($purchase->price_cents / 100, 2, ',', '.') }} €</span>
</flux:table.cell>
<flux:table.cell>
@if ($purchase->status === \App\Enums\SinglePurchaseStatus::Paid)
<span class="badge ok dot">{{ $purchase->status->label() }}</span>
@elseif ($purchase->status === \App\Enums\SinglePurchaseStatus::Consumed)
<span class="badge hub dot">{{ $purchase->status->label() }}</span>
@elseif ($purchase->status === \App\Enums\SinglePurchaseStatus::Pending)
<span class="badge warn dot">{{ $purchase->status->label() }}</span>
@else
<span class="badge">{{ $purchase->status->label() }}</span>
@endif
</flux:table.cell>
<flux:table.cell>
<span class="text-[12px] text-[color:var(--color-ink-2)]">{{ $purchase->paid_at?->format('d.m.Y H:i') ?? '–' }}</span>
</flux:table.cell>
<flux:table.cell>
@if ($purchase->pressRelease)
<flux:button size="xs" variant="filled" :href="route('admin.press-releases.show', $purchase->pressRelease->id)" wire:navigate>
{{ \Illuminate\Support\Str::limit($purchase->pressRelease->title, 40) }}
</flux:button>
@else
<span class="text-[12px] text-[color:var(--color-ink-3)]">–</span>
@endif
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="6">
<div class="px-4 py-8 text-center text-[13px] text-[color:var(--color-ink-3)]">
{{ __('Noch keine Einmalkäufe vorhanden.') }}
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table>
<x-portal.table-pagination :paginator="$purchases" />
</article>
{{-- ============== RECHNUNGEN (STR/MAN) ============== --}}
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Rechnungsausgang (STR/MAN)') }}</span>
<div class="flex items-center gap-3">
<flux:select wire:model.live="invoiceFilter" size="sm" class="min-w-[220px]">
<option value="man_open">{{ __('Offene MAN-Rechnungen (zu bearbeiten)') }}</option>
<option value="all">{{ __('Alle Rechnungen (STR + MAN)') }}</option>
</flux:select>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __(':count Einträge', ['count' => number_format($invoices->total(), 0, ',', '.')]) }}
</span>
</div>
</div>
@if ($invoiceFilter === 'man_open')
<div class="px-5 pt-4 text-[12px] text-[color:var(--color-ink-3)]">
{{ __('Überweisungskunden (Bestandsschutz): Rechnung senden → Zahlung abwarten → als bezahlt markieren. Ohne Zahlung bis zum Zahlungsziel: Bestandsschutz beenden (Rechnung wird storniert, Bündel gekündigt).') }}
</div>
@endif
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Nummer') }}</flux:table.column>
<flux:table.column>{{ __('Kreis') }}</flux:table.column>
<flux:table.column>{{ __('User') }}</flux:table.column>
<flux:table.column>{{ __('Betrag (brutto)') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Rechnungsdatum') }}</flux:table.column>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
</flux:table.columns>
@forelse ($invoices as $invoice)
<flux:table.row wire:key="admin-invoice-{{ $invoice->id }}">
<flux:table.cell>
<span class="text-[13px] font-semibold text-[color:var(--color-ink)] font-mono">{{ $invoice->number }}</span>
</flux:table.cell>
<flux:table.cell>
@if ($invoice->stripe_invoice_id)
<span class="badge hub">{{ __('Stripe (STR)') }}</span>
@else
<span class="badge">{{ __('Manuell (MAN)') }}</span>
@endif
</flux:table.cell>
<flux:table.cell>
@if ($invoice->user)
<div class="space-y-0.5">
<flux:button size="xs" variant="filled" :href="route('admin.users.show', $invoice->user)" wire:navigate>
{{ $invoice->user->name }}
</flux:button>
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ $invoice->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="text-[13px] font-semibold text-[color:var(--color-ink)] tabular-nums">{{ number_format($invoice->total_cents / 100, 2, ',', '.') }} €</span>
</flux:table.cell>
<flux:table.cell>
@php($isManualOpen = $invoice->isManual() && $invoice->status === \App\Enums\InvoiceStatus::Open)
<div class="space-y-1">
@if ($invoice->status === \App\Enums\InvoiceStatus::Paid)
<span class="badge ok dot">{{ $invoice->status->label() }}</span>
@elseif ($isManualOpen && $invoice->isOverdue())
<span class="badge err dot">{{ __('Überfällig') }}</span>
@elseif ($invoice->status === \App\Enums\InvoiceStatus::Open)
<span class="badge warn dot">{{ $invoice->status->label() }}</span>
@elseif ($invoice->status === \App\Enums\InvoiceStatus::Void)
<span class="badge" title="{{ $invoice->void_reason }}">{{ $invoice->status->label() }}</span>
@else
<span class="badge">{{ $invoice->status->label() }}</span>
@endif
@if ($invoice->isManual())
<div class="text-[11px] text-[color:var(--color-ink-3)] leading-[1.5]">
@if ($invoice->sent_at)
{{ __('gesendet :date', ['date' => $invoice->sent_at->format('d.m.Y')]) }}
@elseif ($invoice->status === \App\Enums\InvoiceStatus::Open)
{{ __('noch nicht gesendet') }}
@endif
@if ($invoice->reminded_at)
· {{ __('erinnert :date (:n×)', ['date' => $invoice->reminded_at->format('d.m.Y'), 'n' => $invoice->reminder_count]) }}
@endif
@if ($isManualOpen && $invoice->due_date)
· {{ __('zahlbar bis :date', ['date' => $invoice->due_date->format('d.m.Y')]) }}
@endif
@if ($invoice->replaces_legacy_invoice_number)
· {{ __('ersetzt :number', ['number' => $invoice->replaces_legacy_invoice_number]) }}
@endif
</div>
@endif
</div>
</flux:table.cell>
<flux:table.cell>
<div class="space-y-0.5">
<div class="text-[12px] text-[color:var(--color-ink-2)]">{{ $invoice->invoice_date?->format('d.m.Y') ?? '–' }}</div>
@if ($invoice->paid_at)
<div class="text-[11px] text-[color:var(--color-ink-3)]">{{ __('bezahlt: :date', ['date' => $invoice->paid_at->format('d.m.Y')]) }}</div>
@endif
</div>
</flux:table.cell>
<flux:table.cell>
@if ($invoice->isManual())
<div class="flex flex-wrap items-center gap-1.5">
<flux:button size="xs" variant="filled" icon="document-text" :href="route('admin.invoices.pdf', $invoice)" target="_blank">
{{ __('PDF') }}
</flux:button>
@if ($isManualOpen)
@if (! $invoice->sent_at)
<flux:modal.trigger name="send-invoice-{{ $invoice->id }}">
<flux:button size="xs" variant="primary" icon="paper-airplane">{{ __('Rechnung senden') }}</flux:button>
</flux:modal.trigger>
@else
<flux:modal.trigger name="remind-invoice-{{ $invoice->id }}">
<flux:button size="xs" variant="filled" icon="bell-alert">{{ __('Erinnerung') }}</flux:button>
</flux:modal.trigger>
@endif
<flux:modal.trigger name="paid-invoice-{{ $invoice->id }}">
<flux:button size="xs" variant="filled" icon="check-circle">{{ __('Bezahlt') }}</flux:button>
</flux:modal.trigger>
<flux:modal.trigger name="terminate-invoice-{{ $invoice->id }}">
<flux:button size="xs" variant="danger" icon="x-circle">{{ __('Bestandsschutz beenden') }}</flux:button>
</flux:modal.trigger>
@endif
</div>
@if ($isManualOpen)
{{-- Senden --}}
<flux:modal name="send-invoice-{{ $invoice->id }}" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Rechnung :number senden?', ['number' => $invoice->number]) }}</flux:heading>
<flux:subheading>
{{ __('Die Rechnung geht als PDF an :email — mit Bankverbindung, Zahlungsziel :date (:days Tage ab heute) und dem Hinweis, dass der Bestandsschutz ohne Zahlung zu diesem Datum endet.', [
'email' => $invoice->user?->email ?? '–',
'date' => today()->addDays((int) config('billing.manual_due_days', 14))->format('d.m.Y'),
'days' => (int) config('billing.manual_due_days', 14),
]) }}
</flux:subheading>
</div>
<dl class="grid grid-cols-2 gap-y-1 text-[12.5px]">
<dt class="text-[color:var(--color-ink-3)]">{{ __('Betrag (brutto)') }}</dt>
<dd class="font-semibold tabular-nums">{{ number_format($invoice->total_cents / 100, 2, ',', '.') }} €</dd>
<dt class="text-[color:var(--color-ink-3)]">{{ __('Rechnungsdatum') }}</dt>
<dd>{{ $invoice->invoice_date?->format('d.m.Y') }}</dd>
</dl>
<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="primary" icon="paper-airplane" wire:click="sendInvoice({{ $invoice->id }})">{{ __('Jetzt senden') }}</flux:button>
</div>
</div>
</flux:modal>
{{-- Erinnern --}}
<flux:modal name="remind-invoice-{{ $invoice->id }}" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Zahlungserinnerung zu :number senden?', ['number' => $invoice->number]) }}</flux:heading>
<flux:subheading>
{{ __('Gleiche Rechnung als PDF, Betreff „Zahlungserinnerung". Ist das Zahlungsziel verstrichen, gilt eine Nachfrist von :days Tagen ab heute; sonst bleibt es bestehen.', ['days' => (int) config('billing.reminder_due_days', 7)]) }}
</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="primary" icon="bell-alert" wire:click="remindInvoice({{ $invoice->id }})">{{ __('Erinnerung senden') }}</flux:button>
</div>
</div>
</flux:modal>
{{-- Bezahlt --}}
<flux:modal name="paid-invoice-{{ $invoice->id }}" class="max-w-md">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Zahlungseingang zu :number verbuchen', ['number' => $invoice->number]) }}</flux:heading>
<flux:subheading>{{ __('Betrag laut Kontoauszug: :amount €. Die Rechnung wird auf „Bezahlt" gesetzt.', ['amount' => number_format($invoice->total_cents / 100, 2, ',', '.')]) }}</flux:subheading>
</div>
<flux:field>
<flux:label>{{ __('Zahlungseingang am') }}</flux:label>
<flux:input type="date" wire:model="paidAtInput" max="{{ today()->toDateString() }}" />
<flux:error name="paidAtInput" />
</flux:field>
<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="primary" icon="check-circle" wire:click="markInvoicePaid({{ $invoice->id }})">{{ __('Als bezahlt markieren') }}</flux:button>
</div>
</div>
</flux:modal>
{{-- Bestandsschutz beenden --}}
<flux:modal name="terminate-invoice-{{ $invoice->id }}" class="max-w-lg">
<div class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Bestandsschutz von :user beenden?', ['user' => $invoice->user?->name ?? '–']) }}</flux:heading>
<flux:subheading>
{{ __('Rechnung :number wird storniert (keine Forderung mehr), alle Bestandsschutz-Posten des Kunden werden gekündigt und weitere offene MAN-Rechnungen des Kunden ebenfalls storniert. Veröffentlichte PMs bleiben online; neue Veröffentlichungen nur noch über die aktuellen Tarife.', ['number' => $invoice->number]) }}
</flux:subheading>
</div>
<flux:field>
<flux:label>{{ __('Interner Vermerk (optional)') }}</flux:label>
<flux:input wire:model="terminateNote" placeholder="{{ __('z. B. telefonisch nicht erreichbar') }}" />
</flux:field>
<flux:checkbox wire:model="terminateNotify" :label="__('Kunden per E-Mail informieren (Rechnung storniert, Bestandsschutz beendet, Hinweis auf Tarife)')" />
<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" icon="x-circle" wire:click="terminateInvoice({{ $invoice->id }})">{{ __('Bestandsschutz beenden') }}</flux:button>
</div>
</div>
</flux:modal>
@endif
@else
<span class="text-[11px] text-[color:var(--color-ink-3)]">{{ __('Stripe') }}</span>
@endif
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="7">
<div class="px-4 py-8 text-center text-[13px] text-[color:var(--color-ink-3)]">
{{ $invoiceFilter === 'man_open' ? __('Keine offenen MAN-Rechnungen — alles abgearbeitet.') : __('Noch keine Rechnungen im neuen Rechnungsausgang.') }}
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table>
<x-portal.table-pagination :paginator="$invoices" />
</article>
</div>