From 86bf4f26e0da5f200be1f38bb79654e341abc352 Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 02:21:15 +0200 Subject: [PATCH] List issued invoices in the console, with no way to change one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only by design, and the absence is the feature: an issued invoice is corrected by cancelling it and issuing another, which is the only lawful way to correct one. There is no edit button here whose absence needs explaining, and a test asserts there is none. The PDF is a plain route rather than a Livewire action. A download is a download, and routing one through a component round-trip only adds a way for it to fail. Two findings on the way, and both were mine. The year filter used YEAR() in raw SQL, which is MySQL's. SQLite has no such function, so the page worked against the real database and threw against the test one — a query that only runs on one engine makes the test database a different product from the one being shipped. The years are derived in PHP now. And a test put a User on the operator guard, where EnsureAdmin calls isActive() on it and a 403 becomes a 500. Not a code defect: actingAs() calls Auth::shouldUse(), so a second actingAs() in the same test with no guard named lands on whichever guard the FIRST one named. That trap is written up in CLAUDE.md, I quoted it earlier today, and I walked into it anyway. The tests are split now and both name their guard. Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/Invoices.php | 77 +++++++++++++++++ app/Support/Navigation.php | 1 + lang/de/admin.php | 1 + lang/de/invoices_admin.php | 18 ++++ lang/en/admin.php | 1 + lang/en/invoices_admin.php | 18 ++++ resources/views/components/ui/icon.blade.php | 1 + .../views/livewire/admin/invoices.blade.php | 62 ++++++++++++++ routes/admin.php | 16 ++++ tests/Feature/Billing/InvoiceListTest.php | 84 +++++++++++++++++++ 10 files changed, 279 insertions(+) create mode 100644 app/Livewire/Admin/Invoices.php create mode 100644 lang/de/invoices_admin.php create mode 100644 lang/en/invoices_admin.php create mode 100644 resources/views/livewire/admin/invoices.blade.php create mode 100644 tests/Feature/Billing/InvoiceListTest.php diff --git a/app/Livewire/Admin/Invoices.php b/app/Livewire/Admin/Invoices.php new file mode 100644 index 0000000..128f1d4 --- /dev/null +++ b/app/Livewire/Admin/Invoices.php @@ -0,0 +1,77 @@ +authorize('site.manage'); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedYear(): void + { + $this->resetPage(); + } + + public function render() + { + $invoices = Invoice::query() + ->with(['customer', 'series']) + ->when($this->search !== '', function ($q) { + $term = '%'.$this->search.'%'; + $q->where(fn ($w) => $w->where('number', 'like', $term) + ->orWhereHas('customer', fn ($c) => $c->where('name', 'like', $term))); + }) + ->when($this->year !== '', fn ($q) => $q->whereYear('issued_on', (int) $this->year)) + ->orderByDesc('issued_on') + ->orderByDesc('id') + ->paginate(25); + + return view('livewire.admin.invoices', [ + 'invoices' => $invoices, + // Straight from the rows rather than a fixed range: a list of years + // with nothing in them is a filter that mostly returns nothing. + // + // Derived in PHP rather than with YEAR() in raw SQL. That function + // is MySQL's; SQLite has no such thing, and a query that only runs + // on one engine turns the test database into a different product + // from the real one. + 'years' => Invoice::query() + ->orderByDesc('issued_on') + ->pluck('issued_on') + ->map(fn ($date) => $date?->format('Y')) + ->filter() + ->unique() + ->values(), + ]); + } +} diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index 396bc37..6c850e0 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -54,6 +54,7 @@ final class Navigation // appears on a legal document, and burying it beside the // site-visibility switch invites somebody to change one in passing. ['admin.finance', 'receipt', 'finance', 'site.manage'], + ['admin.invoices', 'file-text', 'invoices', 'site.manage'], ]], ['label' => __('admin.nav_group.system'), 'items' => [ ['admin.mail', 'mail', 'mail', 'mail.manage'], diff --git a/lang/de/admin.php b/lang/de/admin.php index d65bce7..7f2cab7 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -21,6 +21,7 @@ return [ 'maintenance' => 'Wartungen', 'vpn' => 'VPN', 'finance' => 'Finanzen', + 'invoices' => 'Rechnungen', 'revenue' => 'Umsatz', 'mail' => 'E-Mail', 'integrations' => 'Integrationen', diff --git a/lang/de/invoices_admin.php b/lang/de/invoices_admin.php new file mode 100644 index 0000000..d997d0f --- /dev/null +++ b/lang/de/invoices_admin.php @@ -0,0 +1,18 @@ + 'Rechnungen', + 'subtitle' => 'Jedes ausgestellte Dokument. Ausgestellte Rechnungen lassen sich nicht ändern — eine falsche wird durch Storno und Neuausstellung berichtigt.', + 'search' => 'Suche', + 'search_hint' => 'Rechnungsnummer oder Kunde', + 'year' => 'Jahr', + 'all_years' => 'Alle', + 'empty' => 'Noch keine Rechnung ausgestellt.', + 'col_number' => 'Nummer', + 'col_customer' => 'Kunde', + 'col_date' => 'Datum', + 'col_net' => 'Netto', + 'col_gross' => 'Brutto', + 'col_actions' => 'Aktionen', + 'download' => 'PDF', +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index 9a0dfd2..5f9fb24 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -21,6 +21,7 @@ return [ 'maintenance' => 'Maintenance', 'vpn' => 'VPN', 'finance' => 'Finance', + 'invoices' => 'Invoices', 'revenue' => 'Revenue', 'mail' => 'Email', 'integrations' => 'Integrations', diff --git a/lang/en/invoices_admin.php b/lang/en/invoices_admin.php new file mode 100644 index 0000000..9098cac --- /dev/null +++ b/lang/en/invoices_admin.php @@ -0,0 +1,18 @@ + 'Invoices', + 'subtitle' => 'Every document issued. An issued invoice cannot be edited — a wrong one is corrected by cancelling it and issuing another.', + 'search' => 'Search', + 'search_hint' => 'Invoice number or customer', + 'year' => 'Year', + 'all_years' => 'All', + 'empty' => 'No invoice issued yet.', + 'col_number' => 'Number', + 'col_customer' => 'Customer', + 'col_date' => 'Date', + 'col_net' => 'Net', + 'col_gross' => 'Gross', + 'col_actions' => 'Actions', + 'download' => 'PDF', +]; diff --git a/resources/views/components/ui/icon.blade.php b/resources/views/components/ui/icon.blade.php index 3113248..e69ec28 100644 --- a/resources/views/components/ui/icon.blade.php +++ b/resources/views/components/ui/icon.blade.php @@ -6,6 +6,7 @@ 'cloud' => '', 'users' => '', 'database' => '', + 'file-text' => '', 'receipt' => '', 'life-buoy' => '', 'menu' => '', diff --git a/resources/views/livewire/admin/invoices.blade.php b/resources/views/livewire/admin/invoices.blade.php new file mode 100644 index 0000000..e77256b --- /dev/null +++ b/resources/views/livewire/admin/invoices.blade.php @@ -0,0 +1,62 @@ +
+
+

{{ __('invoices_admin.title') }}

+

{{ __('invoices_admin.subtitle') }}

+
+ +
+
+
+ +
+
+ + +
+
+ + @if ($invoices->isEmpty()) +

{{ __('invoices_admin.empty') }}

+ @else + + + + + + + + + + + + + @foreach ($invoices as $invoice) + + + + + + + + + @endforeach + +
{{ __('invoices_admin.col_number') }}{{ __('invoices_admin.col_customer') }}{{ __('invoices_admin.col_date') }}{{ __('invoices_admin.col_net') }}{{ __('invoices_admin.col_gross') }}{{ __('invoices_admin.col_actions') }}
{{ $invoice->number }}{{ $invoice->customer?->name ?? '—' }}{{ $invoice->issued_on?->local()->format('d.m.Y') }}{{ \App\Services\Billing\InvoiceMath::money($invoice->net_cents) }}{{ \App\Services\Billing\InvoiceMath::money($invoice->gross_cents) }} {{ $invoice->currency }} + {{-- A link, not a Livewire action: a PDF is a file + download, and routing one through a component + round-trip only adds a way for it to fail. --}} + + {{ __('invoices_admin.download') }} + +
+ +
{{ $invoices->links() }}
+ @endif +
+
diff --git a/routes/admin.php b/routes/admin.php index 28c1b81..04dfd61 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -39,6 +39,22 @@ Route::get('/maintenance', Admin\Maintenance::class)->name('maintenance'); Route::get('/vpn', Admin\Vpn::class)->name('vpn'); Route::get('/revenue', Admin\Revenue::class)->name('revenue'); Route::get('/finance', Admin\Finance::class)->name('finance'); +Route::get('/invoices', Admin\Invoices::class)->name('invoices'); + +// The PDF, rendered on demand from the frozen document. A plain route rather +// than a Livewire action: a download is a download, and routing one through a +// component round-trip only adds a way for it to fail. +Route::get('/invoices/{uuid}/pdf', function (string $uuid) { + Illuminate\Support\Facades\Gate::authorize('site.manage'); + + $invoice = App\Models\Invoice::query()->where('uuid', $uuid)->firstOrFail(); + + return response()->streamDownload( + fn () => print (app(App\Services\Billing\InvoiceRenderer::class)->forInvoice($invoice)), + $invoice->number.'.pdf', + ['Content-Type' => 'application/pdf'], + ); +})->name('invoices.pdf'); Route::get('/mail', Admin\Mail::class)->name('mail'); Route::get('/integrations', Admin\Integrations::class)->name('integrations'); // The former admin.secrets and admin.infrastructure pages, merged into the diff --git a/tests/Feature/Billing/InvoiceListTest.php b/tests/Feature/Billing/InvoiceListTest.php new file mode 100644 index 0000000..477c036 --- /dev/null +++ b/tests/Feature/Billing/InvoiceListTest.php @@ -0,0 +1,84 @@ + 'CluPilot Cloud e.U.', 'address' => 'Dreherstraße 66/1/8', + 'postcode' => '1110', 'city' => 'Wien', 'vat_id' => 'ATU00000000', + ]); +}); + +function invoiceFor(string $customerName): App\Models\Invoice +{ + $customer = Customer::factory()->create(['name' => $customerName]); + + return app(IssueInvoice::class)->forOrders($customer, collect([ + Order::factory()->create(['customer_id' => $customer->id, 'amount_cents' => 1900, 'currency' => 'EUR', 'status' => 'paid']), + ])); +} + +it('lists issued invoices with no way to edit or delete one', function () { + // The absence is the feature. A wrong invoice is corrected by cancelling it + // and issuing another, which is the only lawful way to correct one — so + // there is no button here whose absence needs explaining. + $invoice = invoiceFor('Muster GmbH'); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Invoices::class) + ->assertSee($invoice->number) + ->assertSee('Muster GmbH') + ->assertDontSee(__('datacenters.edit')) + ->assertDontSee(__('datacenters.delete')); +}); + +it('finds an invoice by its number and by the customer on it', function () { + invoiceFor('Muster GmbH'); + $other = invoiceFor('Andere AG'); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Invoices::class) + ->set('search', 'Andere') + ->assertSee($other->number) + ->assertDontSee('Muster GmbH'); +}); + +it('serves the PDF from the frozen document', function () { + $invoice = invoiceFor('Muster GmbH'); + + $response = $this->actingAs(operator('Owner'), 'operator') + ->get(route('admin.invoices.pdf', $invoice->uuid)); + + $response->assertOk() + ->assertHeader('content-type', 'application/pdf'); + + expect(substr($response->streamedContent(), 0, 5))->toBe('%PDF-'); +}); + +it('does not hand the PDF to an operator without the capability', function () { + $invoice = invoiceFor('Muster GmbH'); + + $this->actingAs(operator('Support'), 'operator') + ->get(route('admin.invoices.pdf', $invoice->uuid)) + ->assertForbidden(); +}); + +it('does not hand the PDF to a customer', function () { + // Its own test, and the guard named explicitly. Both matter: actingAs() + // calls Auth::shouldUse(), so a second actingAs() in the same test with no + // guard lands on whichever guard the FIRST one named — putting a User on + // the operator guard, where EnsureAdmin calls isActive() on it and the + // 403 becomes a 500. The trap is written up in CLAUDE.md and I walked into + // it anyway. + $invoice = invoiceFor('Muster GmbH'); + + $this->actingAs(User::factory()->create(), 'web') + ->get(route('admin.invoices.pdf', $invoice->uuid)) + ->assertForbidden(); +});