diff --git a/app/Http/Controllers/StripeInvoiceRedirectController.php b/app/Http/Controllers/StripeInvoiceRedirectController.php new file mode 100644 index 00000000..597389f4 --- /dev/null +++ b/app/Http/Controllers/StripeInvoiceRedirectController.php @@ -0,0 +1,44 @@ +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); + } +} diff --git a/resources/views/livewire/customer/invoices.blade.php b/resources/views/livewire/customer/invoices.blade.php index d2c62605..5f839111 100644 --- a/resources/views/livewire/customer/invoices.blade.php +++ b/resources/views/livewire/customer/invoices.blade.php @@ -1,5 +1,7 @@ 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
{{ __('User Backend') }} {{ __('Mein Bereich · Finanzen') }} - {{ __('Archivdaten') }}

{{ __('Rechnungen') }}

- {{ __('Ihr Rechnungsarchiv im User Backend. PDFs werden bei Bedarf aus den Archivdaten erzeugt.') }} + {{ __('Aktuelle Rechnungen Ihrer Buchungen sowie das Archiv aus dem Altsystem.') }}

@@ -90,42 +101,117 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo @endif - {{-- ============== HINWEIS-PANEL ============== --}} -
-
- {{ __('Hinweis zu Rechnungen') }} + @if (session('invoice-error')) +
+ + {{ session('invoice-error') }}
-
-

- {{ __('Aktuell sehen Sie hier die aus dem Legacy-System übernommenen Rechnungen. Neue Abrechnungen werden später in dieselbe Finanznavigation integriert.') }} -

-
-
+ @endif {{-- ============== KPI-Reihe ============== --}}
- {{ __('Archivdatensätze') }} + {{ __('neu + Archiv') }} {{ __('User-spezifisch') }} - - {{ __('historisch') }} + + {{ __('alle Rechnungen') }} {{ __('alle Portale') }} {{ __('vollständig') }} {{ __('mit Zahlungsdatum') }} - - {{ __('verfügbar') }} - {{ __('aus Archivdaten') }} + + {{ __('PDF / Stripe') }} + {{ __('direkt abrufbar') }}
+ {{-- ============== AKTUELLE RECHNUNGEN (STR/MAN) ============== --}} +
+
+ {{ __('Aktuelle Rechnungen') }} + + {{ __(':count Einträge', ['count' => $currentInvoices->count()]) }} + +
+ + + {{ __('Rechnungsnr.') }} + {{ __('Netto') }} + {{ __('USt.') }} + {{ __('Gesamt') }} + {{ __('Status') }} + {{ __('Rechnungsdatum') }} + {{ __('Beleg') }} + + + @forelse ($currentInvoices as $invoice) + + + {{ $invoice->number }} + + + + {{ number_format($invoice->amount_cents / 100, 2, ',', '.') }} € + + + + + {{ number_format($invoice->tax_cents / 100, 2, ',', '.') }} € + + + + + {{ number_format($invoice->total_cents / 100, 2, ',', '.') }} € + + + + @if ($invoice->status === \App\Enums\InvoiceStatus::Paid) + {{ $invoice->status->label() }} + @else + {{ $invoice->status->label() }} + @endif + + + + {{ $invoice->invoice_date?->format('d.m.Y') ?? '–' }} + + + + @if ($invoice->stripe_invoice_id) + + {{ __('Bei Stripe öffnen') }} + + @else + – + @endif + + + @empty + + +
+ {{ __('Noch keine Rechnungen aus neuen Buchungen — sie erscheinen hier automatisch nach der ersten Abrechnung.') }} +
+
+
+ @endforelse +
+
+ {{-- ============== FILTER-PANEL ============== --}}
- {{ __('Filter & Suche') }} + {{ __('Filter & Suche (Archiv)') }}
@@ -141,7 +227,7 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo {{-- ============== TABELLE ============== --}}
- {{ __('Alle Rechnungen') }} + {{ __('Rechnungsarchiv (Altsystem)') }} {{ __(':count Einträge', ['count' => $invoices->count()]) }} diff --git a/routes/customer.php b/routes/customer.php index f49bf5ca..6ed342c1 100644 --- a/routes/customer.php +++ b/routes/customer.php @@ -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'); diff --git a/tests/Feature/CustomerPortalTest.php b/tests/Feature/CustomerPortalTest.php index 0691fff0..11387fe1 100644 --- a/tests/Feature/CustomerPortalTest.php +++ b/tests/Feature/CustomerPortalTest.php @@ -1,6 +1,8 @@ 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');