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:
parent
ada41cb067
commit
36691a5b13
33 changed files with 3606 additions and 195 deletions
|
|
@ -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>
|
||||
|
|
@ -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 & 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue