List issued invoices in the console, with no way to change one
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
parent
92bcdcd186
commit
86bf4f26e0
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ return [
|
|||
'maintenance' => 'Wartungen',
|
||||
'vpn' => 'VPN',
|
||||
'finance' => 'Finanzen',
|
||||
'invoices' => 'Rechnungen',
|
||||
'revenue' => 'Umsatz',
|
||||
'mail' => 'E-Mail',
|
||||
'integrations' => 'Integrationen',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -21,6 +21,7 @@ return [
|
|||
'maintenance' => 'Maintenance',
|
||||
'vpn' => 'VPN',
|
||||
'finance' => 'Finance',
|
||||
'invoices' => 'Invoices',
|
||||
'revenue' => 'Revenue',
|
||||
'mail' => 'Email',
|
||||
'integrations' => 'Integrations',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -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"/>',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue