Rechnungsseite: neue STR/MAN-Rechnungen gelistet, STR bei Stripe verlinkt

Die Kundenseite /admin/me/invoices zeigte bisher nur das Legacy-Archiv.
Neue Sektion "Aktuelle Rechnungen" mit Netto/USt/Gesamt und Status;
STR-Rechnungen oeffnen die gehostete Stripe-Rechnungsansicht ueber
einen Redirect-Endpunkt (URL wird je Aufruf frisch geholt, Zugriff nur
fuer den Eigentuemer). KPIs zaehlen neu + Archiv zusammen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-07 12:01:12 +00:00
parent 2a8fd0179e
commit 5166fc7b40
4 changed files with 226 additions and 25 deletions

View file

@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Models\Invoice;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Cashier;
/**
* Öffnet die bei Stripe gehostete Rechnungsansicht (inkl. PDF-Download) zu
* einer gespiegelten STR-Rechnung. Die URL wird bei jedem Aufruf frisch von
* Stripe geholt statt gespeichert — gehostete Links können sich ändern.
*/
class StripeInvoiceRedirectController extends Controller
{
public function __invoke(Request $request, Invoice $invoice): RedirectResponse
{
abort_unless($invoice->user_id === $request->user()->id, 404);
abort_unless(filled($invoice->stripe_invoice_id), 404);
try {
$stripeInvoice = Cashier::stripe()->invoices->retrieve($invoice->stripe_invoice_id);
$hostedUrl = $stripeInvoice->hosted_invoice_url;
} catch (\Throwable $exception) {
Log::warning('Stripe-Rechnungsansicht nicht erreichbar.', [
'invoice_id' => $invoice->id,
'stripe_invoice_id' => $invoice->stripe_invoice_id,
'error' => $exception->getMessage(),
]);
$hostedUrl = null;
}
if (! $hostedUrl) {
return redirect()
->route('me.invoices.index')
->with('invoice-error', __('Die Stripe-Rechnungsansicht ist gerade nicht erreichbar. Bitte versuchen Sie es später erneut.'));
}
return redirect()->away($hostedUrl);
}
}

View file

@ -1,5 +1,7 @@
<?php
use App\Enums\InvoiceStatus;
use App\Models\Invoice;
use App\Models\LegacyInvoice;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
@ -31,6 +33,9 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
$baseQuery = LegacyInvoice::query()
->where('user_id', auth()->id());
// Neue Rechnungen (STR = Stripe-Spiegelung, MAN = Bestandskreis).
$currentQuery = Invoice::query()->where('user_id', auth()->id());
$invoices = (clone $baseQuery)
->when(filled($this->search), function ($query): void {
$query->where('number', 'like', '%'.$this->search.'%');
@ -40,6 +45,11 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
->paginate(100);
return [
'currentInvoices' => (clone $currentQuery)
->latest('invoice_date')
->latest('id')
->limit(100)
->get(),
'invoices' => $invoices,
'statusOptions' => (clone $baseQuery)
->whereNotNull('status')
@ -49,10 +59,12 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
->filter()
->values(),
'stats' => [
'count' => (clone $baseQuery)->count(),
'total_cents' => (int) (clone $baseQuery)->sum('total_cents'),
'paid_count' => (clone $baseQuery)->whereNotNull('paid_at')->count(),
'downloadable_count' => (clone $baseQuery)->count(),
'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)->whereNotNull('stripe_invoice_id')->count(),
],
];
}
@ -65,13 +77,12 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
<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>
<span class="badge hub">{{ __('Archivdaten') }}</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)]">
{{ __('Ihr Rechnungsarchiv im User Backend. PDFs werden bei Bedarf aus den Archivdaten erzeugt.') }}
{{ __('Aktuelle Rechnungen Ihrer Buchungen sowie das Archiv aus dem Altsystem.') }}
</p>
</div>
@ -90,42 +101,117 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
</div>
@endif
{{-- ============== HINWEIS-PANEL ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Hinweis zu Rechnungen') }}</span>
@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>
<div class="p-5">
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0">
{{ __('Aktuell sehen Sie hier die aus dem Legacy-System übernommenen Rechnungen. Neue Abrechnungen werden später in dieselbe Finanznavigation integriert.') }}
</p>
</div>
</article>
@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>{{ __('Archivdatensätze') }}</x-slot:meta>
<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="__('Archivsumme')" :value="number_format($stats['total_cents'] / 100, 2, ',', '.').' €'">
<x-slot:meta>{{ __('historisch') }}</x-slot:meta>
<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="__('PDF-Download')" :value="number_format($stats['downloadable_count'])">
<x-slot:meta>{{ __('verfügbar') }}</x-slot:meta>
<x-slot:trend>{{ __('aus Archivdaten') }}</x-slot:trend>
<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>
@else
<span class="badge warn dot">{{ $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
<span class="text-[12px] text-[color:var(--color-ink-3)]">–</span>
@endif
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="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>
{{-- ============== FILTER-PANEL ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Filter & Suche') }}</span>
<span class="section-eyebrow">{{ __('Filter & Suche (Archiv)') }}</span>
</div>
<div class="p-5 flex flex-col gap-3 sm:flex-row">
<flux:input wire:model.live.debounce.300ms="search" placeholder="{{ __('Rechnungsnummer suchen…') }}" icon="magnifying-glass" class="flex-1" />
@ -141,7 +227,7 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
{{-- ============== TABELLE ============== --}}
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Alle Rechnungen') }}</span>
<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>

View file

@ -3,6 +3,7 @@
use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\LegacyInvoicePdfController;
use App\Http\Controllers\ProofPdfController;
use App\Http\Controllers\StripeInvoiceRedirectController;
use App\Http\Middleware\EnsureUserIsCustomer;
use App\Http\Middleware\LogSlowAdminRequests;
use Illuminate\Support\Facades\Route;
@ -51,6 +52,7 @@ Route::middleware(['auth', 'verified', EnsureUserIsCustomer::class, LogSlowAdmin
->name('checkout.billing-portal');
Volt::route('invoices', 'customer.invoices')->name('invoices.index');
Route::get('legacy-invoices/{legacyInvoice}/pdf', LegacyInvoicePdfController::class)->name('invoices.pdf');
Route::get('invoices/{invoice}/stripe', StripeInvoiceRedirectController::class)->name('invoices.stripe');
Volt::route('tokens', 'customer.tokens')->name('tokens.index');
Volt::route('profile', 'customer.profile')->name('profile');

View file

@ -1,6 +1,8 @@
<?php
use App\Enums\InvoiceStatus;
use App\Enums\UserPaymentOptionStatus;
use App\Models\Invoice;
use App\Models\LegacyInvoice;
use App\Models\User;
use App\Models\UserPaymentOption;
@ -96,6 +98,73 @@ test('customer sees only own legacy invoices', function () {
->assertSee('99,00 €');
});
test('current STR and MAN invoices are listed with a stripe link where available', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$strInvoice = Invoice::factory()->create([
'user_id' => $customer->id,
'user_payment_id' => null,
'number' => 'STR-00042',
'status' => InvoiceStatus::Paid->value,
'amount_cents' => 2900,
'tax_cents' => 551,
'total_cents' => 3451,
'stripe_invoice_id' => 'in_test_42',
]);
Invoice::factory()->create([
'user_id' => $customer->id,
'user_payment_id' => null,
'number' => 'MAN-00007',
'status' => InvoiceStatus::Paid->value,
'stripe_invoice_id' => null,
]);
Invoice::factory()->create([
'user_payment_id' => null,
'number' => 'STR-99999',
'stripe_invoice_id' => 'in_test_other',
]);
$this->actingAs($customer);
LivewireVolt::test('customer.invoices')
->assertSee('Aktuelle Rechnungen')
->assertSee('STR-00042')
->assertSee('MAN-00007')
->assertDontSee('STR-99999')
->assertSee('Bei Stripe öffnen')
->assertSee(route('me.invoices.stripe', $strInvoice));
});
test('the stripe invoice redirect rejects foreign invoices and ones without stripe id', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$intruder = User::factory()->create(['is_active' => true]);
$intruder->assignRole('customer');
$strInvoice = Invoice::factory()->create([
'user_id' => $customer->id,
'user_payment_id' => null,
'stripe_invoice_id' => 'in_test_43',
]);
$manInvoice = Invoice::factory()->create([
'user_id' => $customer->id,
'user_payment_id' => null,
'stripe_invoice_id' => null,
]);
$this->actingAs($intruder)
->get(route('me.invoices.stripe', $strInvoice))
->assertNotFound();
$this->actingAs($customer)
->get(route('me.invoices.stripe', $manInvoice))
->assertNotFound();
});
test('customer can open generated legacy invoice pdf inline from archive data', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
@ -136,7 +205,7 @@ test('customer can open generated legacy invoice pdf inline from archive data',
$this->actingAs($customer);
LivewireVolt::test('customer.invoices')
->assertSee('Hinweis zu Rechnungen')
->assertSee('Rechnungsarchiv (Altsystem)')
->assertSee('Rechnungsadresse im Profil pflegen')
->assertSee('Öffnen');