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>
This commit is contained in:
Kevin Adametz 2026-09-16 10:56:29 +00:00
parent ada41cb067
commit 36691a5b13
33 changed files with 3606 additions and 195 deletions

View file

@ -0,0 +1,30 @@
<x-emails.layout title="Ihr Bestandsschutz ist beendet" preheader="Die Rechnung {{ $invoice->number }} wurde storniert; Ihr Bestandsschutz auf pressekonto ist beendet.">
<p>Guten Tag {{ $user->name }},</p>
<p>
für die Rechnung <strong>{{ $invoice->number }}</strong> vom
{{ $invoice->invoice_date?->format('d.m.Y') }} ist bis zum Zahlungsziel
({{ $invoice->due_date?->format('d.m.Y') }}) kein Zahlungseingang bei uns
verzeichnet worden. Wie in der Rechnung angekündigt, haben wir daher heute
</p>
<dl class="meta">
<dt>Rechnung</dt>
<dd>{{ $invoice->number }} — storniert, es besteht keine Forderung mehr</dd>
<dt>Bestandsschutz</dt>
<dd>beendet zum {{ now()->format('d.m.Y') }}</dd>
</dl>
<p>
Ihre bereits veröffentlichten Pressemitteilungen bleiben unverändert online.
Für neue Veröffentlichungen stehen Ihnen die aktuellen Tarife und der
Einzelversand auf pressekonto zur Verfügung — jederzeit direkt in Ihrem Konto buchbar.
</p>
<x-emails.button :url="$bookingUrl">Tarife ansehen</x-emails.button>
<div class="note">
<p>Sollte Ihre Zahlung sich mit dieser E-Mail überschnitten haben, melden Sie sich bitte kurz — wir klären das unkompliziert.</p>
<p>Bei Fragen erreichen Sie uns über den <a href="{{ config('domains.domain_portal_url') }}/de/hilfe">Hilfe-Bereich auf pressekonto</a>.</p>
</div>
</x-emails.layout>

View file

@ -0,0 +1,93 @@
@php
$formatCents = fn (int $cents): string => number_format($cents / 100, 2, ',', '.')
.' '.($invoice->currency === 'EUR' ? '€' : $invoice->currency);
$dueDate = $invoice->due_date?->format('d.m.Y');
$title = $isReminder ? 'Zahlungserinnerung zu Ihrer Rechnung' : 'Ihre Rechnung für den Bestandsschutz';
$preheader = $isReminder
? 'Für die Rechnung '.$invoice->number.' liegt uns noch kein Zahlungseingang vor.'
: 'Rechnung '.$invoice->number.' im Anhang — bitte bis zum '.$dueDate.' überweisen.';
@endphp
<x-emails.layout :title="$title" :preheader="$preheader">
<p>Guten Tag {{ $user->name }},</p>
@if ($isReminder)
<p>
zu der unten aufgeführten Rechnung konnten wir bisher keinen Zahlungseingang
feststellen. Vielleicht ist sie in der Ablage untergegangen — die Rechnung liegt
dieser E-Mail noch einmal als PDF bei.
</p>
@else
<p>
als Bestandskunde nutzen Sie Ihre bisherige Vereinbarung auf pressekonto weiter
zu den gewohnten Konditionen (Bestandsschutz). Für den neuen Leistungszeitraum
erhalten Sie anbei Ihre Rechnung als PDF.
</p>
@if ($invoice->replaces_legacy_invoice_number)
<p>
Diese Rechnung ersetzt die Rechnung Nr. {{ $invoice->replaces_legacy_invoice_number }}
aus unserem bisherigen System. Bitte begleichen Sie nur noch die hier
aufgeführte Rechnung.
</p>
@endif
@endif
<dl class="meta">
<dt>Rechnungsnummer</dt>
<dd>{{ $invoice->number }}</dd>
<dt>Rechnungsdatum</dt>
<dd>{{ $invoice->invoice_date?->format('d.m.Y') }}</dd>
<dt>Leistung</dt>
<dd>{{ $serviceName }}</dd>
@if ($servicePeriod)
<dt>Leistungszeitraum</dt>
<dd>{{ $servicePeriod[0] }} – {{ $servicePeriod[1] }}</dd>
@endif
<dt>Betrag (netto)</dt>
<dd>{{ $formatCents($invoice->amount_cents) }}</dd>
@if ($invoice->tax_cents > 0)
<dt>Umsatzsteuer</dt>
<dd>{{ $formatCents($invoice->tax_cents) }}</dd>
@endif
<dt>Rechnungsbetrag</dt>
<dd><strong>{{ $formatCents($invoice->total_cents) }}</strong></dd>
<dt>Zahlbar bis</dt>
<dd><strong>{{ $dueDate }}</strong></dd>
</dl>
<p>Bitte überweisen Sie den Rechnungsbetrag auf folgendes Konto:</p>
<dl class="meta">
<dt>Kontoinhaber</dt>
<dd>{{ $issuer['account_holder'] }}</dd>
<dt>IBAN</dt>
<dd>{{ $issuer['iban'] }}</dd>
<dt>BIC</dt>
<dd>{{ $issuer['bic'] }}</dd>
<dt>Bank</dt>
<dd>{{ $issuer['bank_name'] }}</dd>
<dt>Verwendungszweck</dt>
<dd>Rechnungsnummer {{ $invoice->number }}</dd>
</dl>
<div class="meta-danger">
<p style="margin: 0;">
<strong>Wichtiger Hinweis zum Bestandsschutz:</strong>
Geht die Zahlung nicht bis zum <strong>{{ $dueDate }}</strong> bei uns ein, endet
Ihr Bestandsschutz zu diesem Datum und die Rechnung wird storniert. Ihre bereits
veröffentlichten Pressemitteilungen bleiben online; neue Veröffentlichungen sind
danach nur noch über die aktuellen Tarife auf pressekonto möglich.
</p>
</div>
<x-emails.button :url="$invoicesUrl">Rechnung im Konto ansehen</x-emails.button>
<div class="note">
<p>
Sie möchten die Vereinbarung nicht fortführen? Dann müssen Sie nichts weiter tun —
der Bestandsschutz endet mit Ablauf des Zahlungsziels automatisch. Die aktuellen
Tarife finden Sie unter <a href="{{ $bookingUrl }}">Buchungen &amp; Add-ons</a>.
</p>
<p>Falls Sie den Betrag bereits überwiesen haben, betrachten Sie diese E-Mail bitte als gegenstandslos.</p>
<p>Bei Fragen erreichen Sie uns über den <a href="{{ config('domains.domain_portal_url') }}/de/hilfe">Hilfe-Bereich auf pressekonto</a> oder per E-Mail an {{ $issuer['email'] }}.</p>
</div>
</x-emails.layout>

View file

@ -23,6 +23,17 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
public string $pdfFilter = 'all';
/**
* `hold` = im Altsystem nie bezahlt, ersatzlos erledigt (16.09.2026).
* Bleibt im Archiv, wird aber standardmäßig ausgeblendet.
*/
public bool $showHold = false;
public function updatedShowHold(): void
{
$this->resetPage();
}
public function updatedSearch(): void
{
$this->resetPage();
@ -50,7 +61,7 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
public function resetFilters(): void
{
$this->reset(['search', 'portalFilter', 'statusFilter', 'mappingFilter', 'pdfFilter']);
$this->reset(['search', 'portalFilter', 'statusFilter', 'mappingFilter', 'pdfFilter', 'showHold']);
$this->resetPage();
}
@ -82,6 +93,8 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
'generated_pdf_count' => $supportsPdfGeneratedAt
? (clone $baseQuery)->whereNotNull('pdf_generated_at')->count()
: 0,
'hold_count' => (clone $baseQuery)->where('status', LegacyInvoice::STATUS_HOLD)->count(),
'open_legacy_count' => (clone $baseQuery)->openLegacy()->count(),
'filtered_count' => (clone $filteredQuery)->count(),
],
'supportsPdfGeneratedAt' => $supportsPdfGeneratedAt,
@ -91,6 +104,8 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
private function filteredQuery(): Builder
{
return LegacyInvoice::query()
// hold nur zeigen, wenn ausdrücklich gewünscht oder direkt gefiltert.
->when(! $this->showHold && $this->statusFilter !== LegacyInvoice::STATUS_HOLD, fn (Builder $query) => $query->withoutHold())
->when(filled($this->search), function (Builder $query): void {
$search = trim($this->search);
@ -126,7 +141,7 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
:badge="__('Admin Backend')"
:eyebrow="__('Administration · Finanzen')"
:title="__('Legacy Rechnungen')"
:description="__('Legacy-Rechnungsarchiv mit read-only Übersicht, Filtern und PDF-Download. Der neue Stripe-Rechnungslauf folgt separat in Phase 8.')"
:description="__('Read-only-Archiv der Alt-Rechnungen mit Filtern und PDF-Download. Neue Rechnungen (Stripe und manueller Kreis) laufen unter Zahlungen. Nie bezahlte Alt-Rechnungen (hold) sind erledigt und standardmäßig ausgeblendet.')"
description-width="720px"
>
<x-slot:meta>
@ -215,9 +230,15 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
</flux:select>
</div>
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __(':count Treffer für die aktuelle Filterung. PDF-Dateien werden bei Bedarf aus den archivierten Legacy-Daten erzeugt.', ['count' => number_format($stats['filtered_count'], 0, ',', '.')]) }}
</p>
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __(':count Treffer für die aktuelle Filterung. PDF-Dateien werden bei Bedarf aus den archivierten Legacy-Daten erzeugt.', ['count' => number_format($stats['filtered_count'], 0, ',', '.')]) }}
</p>
<flux:checkbox
wire:model.live="showHold"
:label="__(':count erledigte hold-Rechnungen (nie bezahlt) einblenden', ['count' => number_format($stats['hold_count'], 0, ',', '.')])"
/>
</div>
</div>
</article>
@ -277,6 +298,12 @@ new #[Layout('components.layouts.app'), Title('Legacy Rechnungen')] class extend
<flux:table.cell>
@if ($invoice->paid_at)
<span class="badge ok dot">{{ $invoice->status ?? __('Bezahlt') }}</span>
@elseif ($invoice->status === \App\Models\LegacyInvoice::STATUS_HOLD)
<span class="badge" title="{{ __('Nie bezahlt — ersatzlos erledigt') }}">{{ $invoice->status }}</span>
@elseif ($invoice->status === \App\Models\LegacyInvoice::STATUS_SUPERSEDED)
<span class="badge hub" title="{{ __('Ersetzt durch :number', ['number' => $invoice->raw_snapshot['superseded_by_invoice_number'] ?? '–']) }}">{{ __('ersetzt') }}</span>
@elseif ($invoice->status === \App\Models\LegacyInvoice::STATUS_UNCOLLECTIBLE)
<span class="badge">{{ __('abgeschrieben') }}</span>
@else
<span class="badge warn dot">{{ $invoice->status ?? __('Offen') }}</span>
@endif

View file

@ -2,13 +2,18 @@
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;
@ -25,6 +30,23 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
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');
@ -33,6 +55,118 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
$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).
@ -61,6 +195,51 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
$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();
@ -103,6 +282,23 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
->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' => [
@ -115,7 +311,13 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
'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')
@ -128,7 +330,8 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
->latest('created_at')
->paginate(25, pageName: 'purchasesPage'),
'invoices' => $this->searchByUser(Invoice::query()->with('user'))
->latest('invoice_date')
->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'),
];
@ -192,7 +395,7 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
@endif
{{-- ============== KPI-Reihe ============== --}}
<section class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<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>
@ -205,6 +408,15 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
<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 ============== --}}
@ -222,6 +434,88 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
</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->user?->billingAddress)
<span class="badge warn dot">{{ __('Keine Rechnungsadresse') }}</span>
@else
<span class="badge">{{ __('Siehe Log') }}</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">
@ -285,6 +579,18 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
<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)
@ -491,10 +797,21 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
<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 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>
@ -503,6 +820,7 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
<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)
@ -533,13 +851,38 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
<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
@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">
@ -549,12 +892,125 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
@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="6">
<flux:table.cell colspan="7">
<div class="px-4 py-8 text-center text-[13px] text-[color:var(--color-ink-3)]">
{{ __('Noch keine Rechnungen im neuen Rechnungsausgang.') }}
{{ $invoiceFilter === 'man_open' ? __('Keine offenen MAN-Rechnungen — alles abgearbeitet.') : __('Noch keine Rechnungen im neuen Rechnungsausgang.') }}
</div>
</flux:table.cell>
</flux:table.row>

View file

@ -16,8 +16,11 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
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());
->where('user_id', auth()->id())
->withoutHold();
// Neue Rechnungen (STR = Stripe-Spiegelung, MAN = Bestandskreis).
$currentQuery = Invoice::query()->where('user_id', auth()->id());
@ -41,7 +44,7 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
'paid_count' => (clone $baseQuery)->whereNotNull('paid_at')->count()
+ (clone $currentQuery)->where('status', InvoiceStatus::Paid->value)->count(),
'downloadable_count' => (clone $baseQuery)->count()
+ (clone $currentQuery)->whereNotNull('stripe_invoice_id')->count(),
+ (clone $currentQuery)->count(),
],
];
}
@ -150,8 +153,13 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
<flux:table.cell>
@if ($invoice->status === \App\Enums\InvoiceStatus::Paid)
<span class="badge ok dot">{{ $invoice->status->label() }}</span>
@else
@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>
@ -171,7 +179,15 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
{{ __('Bei Stripe öffnen') }}
</flux:button>
@else
<span class="text-[12px] text-[color:var(--color-ink-3)]">–</span>
<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>
@ -225,9 +241,13 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
</flux:table.cell>
<flux:table.cell>
@if ($invoice->paid_at)
<span class="badge ok dot">{{ $invoice->status ?? __('Bezahlt') }}</span>
<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">{{ $invoice->status ?? __('Offen') }}</span>
<span class="badge warn dot">{{ __('Offen') }}</span>
@endif
</flux:table.cell>
<flux:table.cell>