presseportale/resources/views/livewire/customer/invoices.blade.php
Kevin Adametz a00f9dc79a billing: MAN-Rechnungen fuer Ueberweisungskunden bearbeiten, hold-Altlast bereinigen
Bestandskunden aus dem Altsystem zahlen per Ueberweisung; nach dem 04:30-Lauf
fehlte bisher der gesamte Bearbeitungsweg (kein PDF, keine Mail, kein
Zahlungseingang, kein Bestandsschutz-Ende). Plan und Befund:
dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md.

Bereinigung (legacy:settle-unpaid, wiederholbar):
- Legacy-Status hold = nie bezahlt, ersatzlos erledigt. 9 daraus abgeleitete
  Bestandsschutz-Buendel werden gekuendigt, MAN-00001/00002 storniert.
  Archivzeilen bleiben erhalten, sind aber in Admin-Archiv und Kundenkonto
  standardmaessig ausgeblendet.
- pending/reminder (recurring) = offene Vorgaenge: Periode auf den
  Leistungsbeginn der unbezahlten Rechnung zurueckgedreht und als MAN-Rechnung
  neu ausgestellt (Archiv-Status superseded, replaces_legacy_invoice_number).
- pending/reminder (single) = abgeschrieben (uncollectible).
- legacy:grandfather-subscriptions wertet den Zahlungsstatus jetzt selbst aus.

Workflow in /admin/payments (ManualInvoiceDunningService, je Aktion mit
Eintrag im Aktivitaets-Verlauf):
- Rechnung senden: Mail mit PDF-Anhang, Bankverbindung aus billing.issuer,
  Hinweis auf Bestandsschutz-Ende; Zahlungsziel zaehlt ab Versand.
- Erinnerung: gleiche Mail im Mahn-Ton, Nachfrist bei verstrichener Frist.
- Bezahlt: Zahlungseingang laut Kontoauszug verbuchen.
- Bestandsschutz beenden: Rechnung storniert (nie geloescht), Buendel
  gekuendigt, weitere offene MAN-Rechnungen des Kunden storniert, optionale
  Kundenmail.
- Panel "Blockierte Vereinbarungen": faellige Posten ohne Rechnung (meist
  fehlende Rechnungsadresse) mit "Jetzt abrechnen" oder "Entfernen".

Schema: invoices bekommt user_payment_option_id, sent_at, reminded_at,
reminder_count, voided_at, void_reason, replaces_legacy_invoice_number
(inkl. Backfill der bestehenden MAN-Rechnungen). PDF-Primitiven des
Archiv-Renderers in den Trait DrawsPdf ausgelagert; MAN-PDF on-demand ueber
admin.invoices.pdf / me.invoices.manual-pdf (InvoicePolicy).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:56:29 +00:00

280 lines
14 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\LegacyInvoice;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Component
{
use WithPagination;
public ?string $notification = null;
public function with(): array
{
// Nie bezahlte Alt-Rechnungen (hold) sind erledigt und erscheinen
// dem Kunden nicht mehr als „offen".
$baseQuery = LegacyInvoice::query()
->where('user_id', auth()->id())
->withoutHold();
// Neue Rechnungen (STR = Stripe-Spiegelung, MAN = Bestandskreis).
$currentQuery = Invoice::query()->where('user_id', auth()->id());
// Das Altsystem-Archiv erscheint nur, wenn der User dort überhaupt
// Rechnungen hat (QA-Befund 07.07.) — Neukunden sehen es nie.
$invoices = (clone $baseQuery)
->latest('invoice_date')
->paginate(100);
return [
'currentInvoices' => (clone $currentQuery)
->latest('invoice_date')
->latest('id')
->limit(100)
->get(),
'invoices' => $invoices,
'stats' => [
'count' => (clone $baseQuery)->count() + (clone $currentQuery)->count(),
'total_cents' => (int) (clone $baseQuery)->sum('total_cents') + (int) (clone $currentQuery)->sum('total_cents'),
'paid_count' => (clone $baseQuery)->whereNotNull('paid_at')->count()
+ (clone $currentQuery)->where('status', InvoiceStatus::Paid->value)->count(),
'downloadable_count' => (clone $baseQuery)->count()
+ (clone $currentQuery)->count(),
],
];
}
}; ?>
<div class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<header class="page-header">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-wrap">
<span class="badge hub dot">{{ __('User Backend') }}</span>
<span class="eyebrow muted">{{ __('Mein Bereich · Finanzen') }}</span>
</div>
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ __('Rechnungen') }}
</h1>
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
{{ $invoices->total() > 0
? __('Aktuelle Rechnungen Ihrer Buchungen sowie das Archiv aus dem Altsystem.')
: __('Aktuelle Rechnungen Ihrer Buchungen.') }}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<flux:button size="sm" variant="filled" icon="user" href="{{ route('me.profile') }}#rechnungsadresse" wire:navigate>
{{ __('Rechnungsadresse im Profil pflegen') }}
</flux:button>
</div>
</header>
@if ($notification)
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
<flux:icon.exclamation-triangle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-accent-deep)]" />
<div class="flex-1">{{ $notification }}</div>
</div>
@endif
@if (session('invoice-error'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-center gap-2
bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
<flux:icon.exclamation-triangle class="size-[16px] flex-shrink-0" />
{{ session('invoice-error') }}
</div>
@endif
{{-- ============== KPI-Reihe ============== --}}
<section class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<x-portal.stat-card variant="primary" :label="__('Rechnungen')" :value="number_format($stats['count'])">
<x-slot:meta>{{ __('neu + Archiv') }}</x-slot:meta>
<x-slot:trend>{{ __('User-spezifisch') }}</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Gesamtsumme')" :value="number_format($stats['total_cents'] / 100, 2, ',', '.').' €'">
<x-slot:meta>{{ __('alle Rechnungen') }}</x-slot:meta>
<x-slot:trend>{{ __('alle Portale') }}</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="ok" :label="__('Bezahlt')" :value="number_format($stats['paid_count'])">
<x-slot:meta>{{ __('vollständig') }}</x-slot:meta>
<x-slot:trend>{{ __('mit Zahlungsdatum') }}</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="warn" :label="__('Beleg verfügbar')" :value="number_format($stats['downloadable_count'])">
<x-slot:meta>{{ __('PDF / Stripe') }}</x-slot:meta>
<x-slot:trend>{{ __('direkt abrufbar') }}</x-slot:trend>
</x-portal.stat-card>
</section>
{{-- ============== AKTUELLE RECHNUNGEN (STR/MAN) ============== --}}
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Aktuelle Rechnungen') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __(':count Einträge', ['count' => $currentInvoices->count()]) }}
</span>
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Rechnungsnr.') }}</flux:table.column>
<flux:table.column>{{ __('Netto') }}</flux:table.column>
<flux:table.column>{{ __('USt.') }}</flux:table.column>
<flux:table.column>{{ __('Gesamt') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Rechnungsdatum') }}</flux:table.column>
<flux:table.column>{{ __('Beleg') }}</flux:table.column>
</flux:table.columns>
@forelse ($currentInvoices as $invoice)
<flux:table.row wire:key="invoice-{{ $invoice->id }}">
<flux:table.cell>
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">{{ $invoice->number }}</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12.5px] text-[color:var(--color-ink-2)] tabular-nums">
{{ number_format($invoice->amount_cents / 100, 2, ',', '.') }} €
</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12.5px] text-[color:var(--color-ink-2)] tabular-nums">
{{ number_format($invoice->tax_cents / 100, 2, ',', '.') }} €
</span>
</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>
@if ($invoice->due_date)
<div class="text-[11px] text-[color:var(--color-ink-3)] mt-0.5">{{ __('zahlbar bis :date', ['date' => $invoice->due_date->format('d.m.Y')]) }}</div>
@endif
@else
<span class="badge">{{ $invoice->status->label() }}</span>
@endif
</flux:table.cell>
<flux:table.cell>
<span class="text-[12px] text-[color:var(--color-ink-3)]">
{{ $invoice->invoice_date?->format('d.m.Y') ?? '–' }}
</span>
</flux:table.cell>
<flux:table.cell>
@if ($invoice->stripe_invoice_id)
<flux:button
size="sm"
variant="filled"
icon="arrow-top-right-on-square"
:href="route('me.invoices.stripe', $invoice)"
target="_blank"
>
{{ __('Bei Stripe öffnen') }}
</flux:button>
@else
<flux:button
size="sm"
variant="filled"
icon="document-text"
:href="route('me.invoices.manual-pdf', $invoice)"
target="_blank"
>
{{ __('PDF öffnen') }}
</flux:button>
@endif
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="7">
<div class="px-4 py-6 text-center text-[12.5px] text-[color:var(--color-ink-3)]">
{{ __('Noch keine Rechnungen aus neuen Buchungen — sie erscheinen hier automatisch nach der ersten Abrechnung.') }}
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table>
</article>
{{-- ============== RECHNUNGSARCHIV (ALTSYSTEM) ==============
Nur für Bestandskunden mit importierten Alt-Rechnungen. --}}
@if ($invoices->total() > 0)
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Rechnungsarchiv (Altsystem)') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __(':count Einträge', ['count' => $invoices->count()]) }}
</span>
</div>
<flux:table>
<flux:table.columns>
<flux:table.column>{{ __('Rechnungsnr.') }}</flux:table.column>
<flux:table.column>{{ __('Portal') }}</flux:table.column>
<flux:table.column>{{ __('Betrag') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Rechnungsdatum') }}</flux:table.column>
<flux:table.column>{{ __('Bezahlt am') }}</flux:table.column>
<flux:table.column>{{ __('PDF') }}</flux:table.column>
</flux:table.columns>
@foreach ($invoices as $invoice)
<flux:table.row wire:key="legacy-invoice-{{ $invoice->id }}">
<flux:table.cell>
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ $invoice->number ?? ('#'.$invoice->legacy_id) }}
</span>
</flux:table.cell>
<flux:table.cell>
<span class="badge hub">{{ $invoice->legacy_portal?->label() }}</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ number_format($invoice->total_cents / 100, 2, ',', '.') }} €
</span>
</flux:table.cell>
<flux:table.cell>
@if ($invoice->paid_at)
<span class="badge ok dot">{{ __('Bezahlt') }}</span>
@elseif ($invoice->status === \App\Models\LegacyInvoice::STATUS_SUPERSEDED)
<span class="badge hub">{{ __('Ersetzt durch :number', ['number' => $invoice->raw_snapshot['superseded_by_invoice_number'] ?? '–']) }}</span>
@elseif ($invoice->status === \App\Models\LegacyInvoice::STATUS_UNCOLLECTIBLE)
<span class="badge">{{ __('Erledigt') }}</span>
@else
<span class="badge warn dot">{{ __('Offen') }}</span>
@endif
</flux:table.cell>
<flux:table.cell>
<span class="text-[12px] text-[color:var(--color-ink-3)]">
{{ $invoice->invoice_date?->format('d.m.Y') ?? '–' }}
</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12px] text-[color:var(--color-ink-3)]">
{{ $invoice->paid_at?->format('d.m.Y') ?? '–' }}
</span>
</flux:table.cell>
<flux:table.cell>
<flux:button
size="sm"
variant="filled"
icon="arrow-top-right-on-square"
:href="route('me.invoices.pdf', $invoice)"
target="_blank"
>
{{ __('Öffnen') }}
</flux:button>
</flux:table.cell>
</flux:table.row>
@endforeach
</flux:table>
<x-portal.table-pagination :paginator="$invoices" />
</article>
@endif
</div>