List issued invoices in the console, with no way to change one
tests / pest (push) Failing after 7m34s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details

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 <noreply@anthropic.com>
feat/granted-plans
nexxo 2026-07-29 02:21:15 +02:00
parent 92bcdcd186
commit 86bf4f26e0
10 changed files with 279 additions and 0 deletions

View File

@ -0,0 +1,77 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Invoice;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
/**
* Every document that has been issued, newest first.
*
* Read-only by design, and that is the feature. An issued invoice cannot be
* edited or deleted here or anywhere else a wrong one is corrected by issuing
* a cancellation and a new document, which is the only lawful way to correct
* one. There is no edit button to explain the absence of.
*/
#[Layout('layouts.admin')]
class Invoices extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $year = '';
public function mount(): void
{
$this->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(),
]);
}
}

View File

@ -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'],

View File

@ -21,6 +21,7 @@ return [
'maintenance' => 'Wartungen',
'vpn' => 'VPN',
'finance' => 'Finanzen',
'invoices' => 'Rechnungen',
'revenue' => 'Umsatz',
'mail' => 'E-Mail',
'integrations' => 'Integrationen',

View File

@ -0,0 +1,18 @@
<?php
return [
'title' => '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',
];

View File

@ -21,6 +21,7 @@ return [
'maintenance' => 'Maintenance',
'vpn' => 'VPN',
'finance' => 'Finance',
'invoices' => 'Invoices',
'revenue' => 'Revenue',
'mail' => 'Email',
'integrations' => 'Integrations',

View File

@ -0,0 +1,18 @@
<?php
return [
'title' => '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',
];

View File

@ -6,6 +6,7 @@
'cloud' => '<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/>',
'users' => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
'database' => '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/>',
'file-text' => '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M8 13h8"/><path d="M8 17h8"/><path d="M8 9h2"/>',
'receipt' => '<path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 17.5v-11"/>',
'life-buoy' => '<circle cx="12" cy="12" r="10"/><path d="m4.93 4.93 4.24 4.24"/><path d="m14.83 9.17 4.24-4.24"/><path d="m14.83 14.83 4.24 4.24"/><path d="m9.17 14.83-4.24 4.24"/><circle cx="12" cy="12" r="4"/>',
'menu' => '<path d="M4 12h16"/><path d="M4 6h16"/><path d="M4 18h16"/>',

View File

@ -0,0 +1,62 @@
<div class="space-y-5">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('invoices_admin.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('invoices_admin.subtitle') }}</p>
</div>
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
<div class="flex flex-wrap items-end gap-3 border-b border-line p-4">
<div class="min-w-56 flex-1">
<x-ui.input name="search" wire:model.live.debounce.300ms="search"
:label="__('invoices_admin.search')" :placeholder="__('invoices_admin.search_hint')" />
</div>
<div>
<label class="text-sm font-medium text-body" for="year">{{ __('invoices_admin.year') }}</label>
<select id="year" wire:model.live="year"
class="mt-1.5 rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
<option value="">{{ __('invoices_admin.all_years') }}</option>
@foreach ($years as $y)<option value="{{ $y }}">{{ $y }}</option>@endforeach
</select>
</div>
</div>
@if ($invoices->isEmpty())
<p class="p-8 text-center text-sm text-muted">{{ __('invoices_admin.empty') }}</p>
@else
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
<th class="px-4 py-3 font-semibold">{{ __('invoices_admin.col_number') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('invoices_admin.col_customer') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('invoices_admin.col_date') }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ __('invoices_admin.col_net') }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ __('invoices_admin.col_gross') }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ __('invoices_admin.col_actions') }}</th>
</tr>
</thead>
<tbody>
@foreach ($invoices as $invoice)
<tr wire:key="inv-{{ $invoice->uuid }}" class="border-b border-line last:border-0">
<td class="px-4 py-3 font-mono font-semibold text-ink">{{ $invoice->number }}</td>
<td class="px-4 py-3 text-body">{{ $invoice->customer?->name ?? '—' }}</td>
<td class="px-4 py-3 text-muted">{{ $invoice->issued_on?->local()->format('d.m.Y') }}</td>
<td class="px-4 py-3 text-right font-mono text-muted">{{ \App\Services\Billing\InvoiceMath::money($invoice->net_cents) }}</td>
<td class="px-4 py-3 text-right font-mono font-semibold text-ink">{{ \App\Services\Billing\InvoiceMath::money($invoice->gross_cents) }} {{ $invoice->currency }}</td>
<td class="px-4 py-3 text-right">
{{-- 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. --}}
<a href="{{ route('admin.invoices.pdf', $invoice->uuid) }}"
class="inline-flex items-center gap-1.5 rounded-md border border-line px-2.5 py-1.5 text-xs font-medium text-body hover:border-accent-border hover:text-accent-text">
<x-ui.icon name="download" class="size-4" />{{ __('invoices_admin.download') }}
</a>
</td>
</tr>
@endforeach
</tbody>
</table>
<div class="border-t border-line p-4">{{ $invoices->links() }}</div>
@endif
</div>
</div>

View File

@ -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

View File

@ -0,0 +1,84 @@
<?php
use App\Livewire\Admin\Invoices;
use App\Models\Customer;
use App\Models\Order;
use App\Models\User;
use App\Services\Billing\IssueInvoice;
use App\Support\CompanyProfile;
use Livewire\Livewire;
beforeEach(function () {
CompanyProfile::put([
'name' => '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();
});