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(); +});