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:
parent
2a8fd0179e
commit
5166fc7b40
4 changed files with 226 additions and 25 deletions
44
app/Http/Controllers/StripeInvoiceRedirectController.php
Normal file
44
app/Http/Controllers/StripeInvoiceRedirectController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue