presseportale/resources/views/livewire/admin/payments/index.blade.php
2026-06-12 13:54:53 +00:00

386 lines
20 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\Models\Invoice;
use App\Models\Plan;
use App\Models\SinglePurchase;
use Illuminate\Database\Eloquent\Builder;
use Laravel\Cashier\Subscription;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Component
{
use WithPagination;
public string $search = '';
public function updatedSearch(): void
{
$this->resetPage('subscriptionsPage');
$this->resetPage('purchasesPage');
$this->resetPage('invoicesPage');
}
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;
});
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(),
],
'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'))
->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 ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-wrap">
<span class="badge hub dot">{{ __('Admin Backend') }}</span>
<span class="eyebrow muted">{{ __('Administration · Finanzen') }}</span>
</div>
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ __('Zahlungen') }}
</h1>
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[720px] text-[color:var(--color-ink-2)]">
{{ __('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.') }}
</p>
</div>
<div class="flex items-center gap-2">
<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>
</div>
</header>
{{-- ============== 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, ',', '.')">
<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>
</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>
{{-- ============== 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>
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
{{ $subscriptions->links('components.portal.pagination') }}
</div>
</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>
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
{{ $purchases->links('components.portal.pagination') }}
</div>
</article>
{{-- ============== RECHNUNGEN (STR/MAN) ============== --}}
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Rechnungsausgang (STR/MAN)') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __(':count Einträge', ['count' => number_format($invoices->total(), 0, ',', '.')]) }}
</span>
</div>
<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.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>
@if ($invoice->status === \App\Enums\InvoiceStatus::Paid)
<span class="badge ok dot">{{ $invoice->status->label() }}</span>
@elseif ($invoice->status === \App\Enums\InvoiceStatus::Open)
<span class="badge warn dot">{{ $invoice->status->label() }}</span>
@else
<span class="badge">{{ $invoice->status->label() }}</span>
@endif
</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.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 Rechnungen im neuen Rechnungsausgang.') }}
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table>
<div class="border-t border-[color:var(--color-bg-rule)] p-4">
{{ $invoices->links('components.portal.pagination') }}
</div>
</article>
</div>