From e6d2e6dc3329f49f341427f196764d4ec0d1930e Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 01:18:43 +0200 Subject: [PATCH] Give the console a Finance tab: company details, VAT rate, invoice series MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its own tab rather than a section of Settings, as asked. What is set here appears on a legal document, and burying it beside the site-visibility switch invites somebody to change one in passing. Everything the printed invoice needs — registered name, address, Firmenbuch number and court, VAT ID, bank details, payment terms, logo — with the logo replaceable, because a company changes its mark. None of it reaches an invoice that already exists: the values are copied into each document when its number is assigned, so a corrected address is correct from the next one onwards and cannot rewrite an old one. The console refuses to consider itself ready while a name, an address or a VAT number is missing, and says which. An invoice without them is not a valid invoice in Austria, and issuing one is worse than refusing to. The series editor carries the same shape as EditDatacenter and for the same reason. The prefix is free while the series is empty and frozen once a document carries it: changing RE to AR renames nothing and leaves a series whose past says RE and whose future says AR, with no record they belong together. The counter may be raised — an operator migrating from another system needs exactly that — but never lowered, because a lower number is one already printed on a document somebody holds. Both rules are recomputed from the database on save rather than read back from the properties the browser returns. CompanyProfile drops keys that are not part of the profile. It is fed from a form, and a forged key must not be able to write an arbitrary setting into the same table the site-visibility switch lives in. Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/EditInvoiceSeries.php | 123 ++++++++++++++++ app/Livewire/Admin/Finance.php | 124 ++++++++++++++++ app/Support/CompanyProfile.php | 103 ++++++++++++++ app/Support/Navigation.php | 4 + lang/de/admin.php | 1 + lang/de/finance.php | 69 +++++++++ lang/en/admin.php | 1 + lang/en/finance.php | 69 +++++++++ .../admin/edit-invoice-series.blade.php | 54 +++++++ .../views/livewire/admin/finance.blade.php | 125 ++++++++++++++++ routes/admin.php | 1 + tests/Feature/Billing/FinanceSettingsTest.php | 134 ++++++++++++++++++ 12 files changed, 808 insertions(+) create mode 100644 app/Livewire/Admin/EditInvoiceSeries.php create mode 100644 app/Livewire/Admin/Finance.php create mode 100644 app/Support/CompanyProfile.php create mode 100644 lang/de/finance.php create mode 100644 lang/en/finance.php create mode 100644 resources/views/livewire/admin/edit-invoice-series.blade.php create mode 100644 resources/views/livewire/admin/finance.blade.php create mode 100644 tests/Feature/Billing/FinanceSettingsTest.php diff --git a/app/Livewire/Admin/EditInvoiceSeries.php b/app/Livewire/Admin/EditInvoiceSeries.php new file mode 100644 index 0000000..eed4209 --- /dev/null +++ b/app/Livewire/Admin/EditInvoiceSeries.php @@ -0,0 +1,123 @@ +authorize('site.manage'); // modals are reachable without the route middleware + + $series = InvoiceSeries::query()->where('uuid', $uuid)->firstOrFail(); + + $this->uuid = $uuid; + $this->name = $series->name; + $this->prefix = $series->prefix; + $this->digits = (int) $series->digits; + $this->yearlyReset = (bool) $series->yearly_reset; + $this->nextNumber = (int) $series->next_number; + $this->active = (bool) $series->active; + $this->issued = $series->invoices()->count(); + $this->floor = (int) $series->next_number; + } + + public function save() + { + $this->authorize('site.manage'); + + $series = InvoiceSeries::query()->where('uuid', $this->uuid)->firstOrFail(); + + // Recomputed from the database, never read back from the properties the + // browser returns: these two rules are what keep already-issued numbers + // from being re-used, and a forged field must not lift either. + $issued = $series->invoices()->count(); + $floor = (int) $series->next_number; + + $rules = [ + 'name' => 'required|string|max:120', + 'digits' => 'required|integer|min:3|max:10', + 'active' => 'boolean', + 'nextNumber' => ['required', 'integer', 'min:'.$floor], + ]; + + if ($issued === 0) { + $rules['prefix'] = [ + 'required', 'string', 'max:12', 'regex:/^[A-Z0-9-]+$/', + Rule::unique('invoice_series', 'prefix')->ignore($series->id), + ]; + // Only while nothing has been issued: switching an established + // series between yearly and continuous puts a gap in one of them. + $rules['yearlyReset'] = 'boolean'; + } + + if ($issued === 0) { + $this->prefix = strtoupper(trim($this->prefix)); + } else { + $this->prefix = $series->prefix; + $this->yearlyReset = (bool) $series->yearly_reset; + } + + $data = $this->validate($rules); + + $series->update([ + 'name' => $data['name'], + 'digits' => $data['digits'], + 'next_number' => $data['nextNumber'], + 'active' => $data['active'], + ...($issued === 0 ? [ + 'prefix' => $data['prefix'], + 'yearly_reset' => $data['yearlyReset'], + ] : []), + ]); + + $this->dispatch('notify', message: __('finance.series_saved')); + + return $this->redirectRoute('admin.finance', navigate: true); + } + + public function render() + { + return view('livewire.admin.edit-invoice-series'); + } +} diff --git a/app/Livewire/Admin/Finance.php b/app/Livewire/Admin/Finance.php new file mode 100644 index 0000000..3d71dcc --- /dev/null +++ b/app/Livewire/Admin/Finance.php @@ -0,0 +1,124 @@ + */ + public array $company = []; + + public float $taxRate = 20.0; + + /** New logo upload, validated on save. */ + public $logo = null; + + public string $logoPath = ''; + + public function mount(): void + { + $this->authorize('site.manage'); + + $this->company = CompanyProfile::all(); + $this->logoPath = (string) ($this->company['logo_path'] ?? ''); + $this->taxRate = CompanyProfile::taxRate(); + } + + public function saveCompany(): void + { + $this->authorize('site.manage'); + + $data = $this->validate([ + 'company.name' => 'required|string|max:190', + 'company.address' => 'required|string|max:190', + 'company.postcode' => 'required|string|max:20', + 'company.city' => 'required|string|max:120', + 'company.country' => 'required|string|max:120', + 'company.phone' => 'nullable|string|max:60', + 'company.email' => 'nullable|email|max:190', + 'company.website' => 'nullable|string|max:190', + 'company.register_number' => 'nullable|string|max:60', + 'company.register_court' => 'nullable|string|max:120', + 'company.vat_id' => 'required|string|max:40', + 'company.bank_name' => 'nullable|string|max:120', + 'company.iban' => 'nullable|string|max:42', + 'company.bic' => 'nullable|string|max:15', + 'company.payment_days' => 'required|integer|min:0|max:180', + 'company.payment_terms' => 'nullable|string|max:500', + // PNG or WEBP only, and small. A PDF embeds whatever it is given, + // and a four-megabyte photograph would be embedded in every invoice + // ever rendered from then on. + 'logo' => 'nullable|image|mimes:png,webp|max:1024', + 'taxRate' => 'required|numeric|min:0|max:100', + ]); + + if ($this->logo !== null) { + $previous = $this->logoPath; + + $this->logoPath = $this->logo->store('company', 'public'); + $this->company['logo_path'] = $this->logoPath; + + // The old file goes only once the new one is stored. The other + // order loses the logo entirely if the upload fails, and every + // invoice rendered afterwards is missing it. + if ($previous !== '' && $previous !== $this->logoPath) { + Storage::disk('public')->delete($previous); + } + + $this->logo = null; + } + + CompanyProfile::put($data['company']); + Settings::set('company.tax_rate', (float) $data['taxRate']); + + $this->dispatch('notify', message: __('finance.company_saved')); + } + + public function removeLogo(): void + { + $this->authorize('site.manage'); + + if ($this->logoPath !== '') { + Storage::disk('public')->delete($this->logoPath); + } + + $this->logoPath = ''; + $this->company['logo_path'] = ''; + CompanyProfile::put(['logo_path' => '']); + + $this->dispatch('notify', message: __('finance.logo_removed')); + } + + public function render() + { + return view('livewire.admin.finance', [ + 'series' => InvoiceSeries::query()->orderBy('kind')->get(), + // What is still missing before an invoice may be issued at all. An + // invoice without a name, an address or a VAT number is not a valid + // invoice here, and issuing one is worse than refusing to. + 'missing' => CompanyProfile::missingForInvoicing(), + ]); + } +} diff --git a/app/Support/CompanyProfile.php b/app/Support/CompanyProfile.php new file mode 100644 index 0000000..3cf12ee --- /dev/null +++ b/app/Support/CompanyProfile.php @@ -0,0 +1,103 @@ + '', + 'address' => '', + 'postcode' => '', + 'city' => '', + 'country' => 'Österreich', + 'phone' => '', + 'email' => '', + 'website' => '', + // Firmenbuch / commercial register, and the court that keeps it. + 'register_number' => '', + 'register_court' => '', + // UID / VAT identification number — the one field an invoice is + // legally worthless without. + 'vat_id' => '', + 'bank_name' => '', + 'iban' => '', + 'bic' => '', + 'logo_path' => '', + // Payment terms in days, and the sentence printed under the total. + 'payment_days' => 14, + 'payment_terms' => '', + ]; + + /** @return array */ + public static function all(): array + { + $values = []; + + foreach (self::FIELDS as $key => $default) { + $values[$key] = Settings::get(self::PREFIX.$key, $default); + } + + return $values; + } + + public static function get(string $field, mixed $default = null): mixed + { + return Settings::get(self::PREFIX.$field, $default ?? (self::FIELDS[$field] ?? null)); + } + + /** @param array $values */ + public static function put(array $values): void + { + foreach ($values as $key => $value) { + // Unknown keys are dropped rather than stored. This is fed from a + // form, and a forged field must not be able to write an arbitrary + // setting into the same table the site-visibility switch lives in. + if (! array_key_exists($key, self::FIELDS)) { + continue; + } + + Settings::set(self::PREFIX.$key, $value); + } + } + + /** + * Is there enough here to put on an invoice? + * + * Not a nicety: an invoice without a company name, an address or a VAT + * number is not a valid invoice in Austria, and issuing one is worse than + * refusing to. The console asks this before it offers the button. + * + * @return array the fields that are missing, empty when ready + */ + public static function missingForInvoicing(): array + { + $required = ['name', 'address', 'postcode', 'city', 'vat_id']; + + return array_values(array_filter( + $required, + fn (string $field) => trim((string) self::get($field)) === '', + )); + } + + /** The VAT rate as a percentage, e.g. 20.0. */ + public static function taxRate(): float + { + return (float) Settings::get('company.tax_rate', 20.0); + } +} diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index 0f3df14..d3a01c4 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -50,6 +50,10 @@ final class Navigation ['admin.maintenance', 'alert-triangle', 'maintenance', null], ['admin.vpn', 'shield', 'vpn', null], ['admin.revenue', 'trending-up', 'revenue', null], + // Its own entry, not a section of Settings: what is set here + // 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'], ]], ['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 d17f508..0f45efe 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -20,6 +20,7 @@ return [ 'provisioning' => 'Provisioning', 'maintenance' => 'Wartungen', 'vpn' => 'VPN', + 'finance' => 'Finanzen', 'revenue' => 'Umsatz', 'mail' => 'E-Mail', 'secrets' => 'Zugangsdaten', diff --git a/lang/de/finance.php b/lang/de/finance.php new file mode 100644 index 0000000..9525323 --- /dev/null +++ b/lang/de/finance.php @@ -0,0 +1,69 @@ + 'Finanzen', + 'subtitle' => 'Firmendaten für die Rechnung, Steuersatz und Rechnungskreise.', + + // Was fehlt, bevor überhaupt etwas ausgestellt werden darf. Eine Rechnung + // ohne Namen, Anschrift oder UID ist keine gültige Rechnung — sie + // auszustellen ist schlimmer, als es zu verweigern. + 'incomplete' => 'Es fehlen noch Angaben, ohne die keine Rechnung ausgestellt werden kann: :fields', + + 'company_title' => 'Firmendaten', + 'company_sub' => 'Diese Angaben stehen im Kopf und in der Fußzeile jeder Rechnung. Eine Änderung gilt ab der nächsten Rechnung — bereits ausgestellte behalten den Stand, unter dem sie ausgestellt wurden.', + 'company_saved' => 'Firmendaten gespeichert.', + 'save' => 'Speichern', + 'logo_remove' => 'Logo entfernen', + 'logo_removed' => 'Logo entfernt.', + + 'field' => [ + 'name' => 'Firmenwortlaut', + 'address' => 'Straße und Hausnummer', + 'postcode' => 'PLZ', + 'city' => 'Ort', + 'country' => 'Land', + 'phone' => 'Telefon', + 'email' => 'E-Mail', + 'website' => 'Website', + 'register_number' => 'Firmenbuchnummer', + 'register_court' => 'Firmenbuchgericht', + 'vat_id' => 'UID-Nummer', + 'bank_name' => 'Bank', + 'iban' => 'IBAN', + 'bic' => 'BIC', + 'logo' => 'Logo', + 'tax_rate' => 'Umsatzsteuer (%)', + 'payment_days' => 'Zahlungsziel (Tage)', + 'payment_terms' => 'Zahlungsbedingungen', + ], + + 'hint' => [ + 'vat_id' => 'Pflichtangabe auf jeder Rechnung.', + 'tax_rate' => 'Wird in jede Rechnung eingefroren. Eine Änderung wirkt nur auf neue Rechnungen.', + 'logo' => 'PNG oder WEBP, höchstens 1 MB. Wird in jede erzeugte Rechnung eingebettet.', + ], + + 'series_edit_title' => 'Rechnungskreis bearbeiten', + 'series_saved' => 'Rechnungskreis gespeichert.', + 'series_locked' => 'Nicht mehr änderbar: :n Dokument(e) tragen dieses Kürzel bereits in ihrer Nummer.', + 'series_floor' => 'Kann nur erhöht werden. Eine niedrigere Nummer wäre schon vergeben — mindestens :n.', + 'cancel' => 'Abbrechen', + + 'series_title' => 'Rechnungskreise', + 'series_sub' => 'Jeder Kreis hat sein eigenes Kürzel und seinen eigenen lückenlosen Zähler. Eine Gutschrift ist keine Rechnung mit Minus davor.', + 'series' => [ + 'name' => 'Bezeichnung', + 'prefix' => 'Kürzel', + 'next' => 'Nächste Nummer', + 'reset' => 'Zähler', + 'reset_yearly' => 'jährlich neu', + 'reset_never' => 'fortlaufend', + 'actions' => 'Aktionen', + 'digits' => 'Stellen', + 'reset_label' => 'Zähler jedes Jahr neu beginnen', + 'reset_hint' => 'Dann steht das Jahr in der Nummer: RE-2026-0001. Nach dem ersten Dokument nicht mehr änderbar.', + 'active_label' => 'Für neue Dokumente verwendbar', + 'active_hint' => 'Abgeschaltet bleibt alles Bestehende erhalten, es wird nur nichts Neues mehr daraus ausgestellt.', + 'edit' => 'Bearbeiten', + ], +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index b058726..413ae6f 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -20,6 +20,7 @@ return [ 'provisioning' => 'Provisioning', 'maintenance' => 'Maintenance', 'vpn' => 'VPN', + 'finance' => 'Finance', 'revenue' => 'Revenue', 'mail' => 'Email', 'secrets' => 'Credentials', diff --git a/lang/en/finance.php b/lang/en/finance.php new file mode 100644 index 0000000..cf29cf5 --- /dev/null +++ b/lang/en/finance.php @@ -0,0 +1,69 @@ + 'Finance', + 'subtitle' => 'Company details for invoices, the VAT rate, and the invoice series.', + + // What is missing before anything may be issued. An invoice without a name, + // an address or a VAT number is not a valid invoice — issuing one is worse + // than refusing to. + 'incomplete' => 'Some details are still missing, and no invoice can be issued without them: :fields', + + 'company_title' => 'Company details', + 'company_sub' => 'These appear in the header and footer of every invoice. A change applies from the next invoice onwards — anything already issued keeps the details it was issued under.', + 'company_saved' => 'Company details saved.', + 'save' => 'Save', + 'logo_remove' => 'Remove logo', + 'logo_removed' => 'Logo removed.', + + 'field' => [ + 'name' => 'Registered name', + 'address' => 'Street and number', + 'postcode' => 'Postcode', + 'city' => 'City', + 'country' => 'Country', + 'phone' => 'Phone', + 'email' => 'Email', + 'website' => 'Website', + 'register_number' => 'Register number', + 'register_court' => 'Registering court', + 'vat_id' => 'VAT ID', + 'bank_name' => 'Bank', + 'iban' => 'IBAN', + 'bic' => 'BIC', + 'logo' => 'Logo', + 'tax_rate' => 'VAT (%)', + 'payment_days' => 'Payment terms (days)', + 'payment_terms' => 'Payment conditions', + ], + + 'hint' => [ + 'vat_id' => 'Required on every invoice.', + 'tax_rate' => 'Frozen into every invoice. A change affects new invoices only.', + 'logo' => 'PNG or WEBP, 1 MB at most. Embedded into every invoice rendered.', + ], + + 'series_edit_title' => 'Edit invoice series', + 'series_saved' => 'Invoice series saved.', + 'series_locked' => 'No longer editable: :n document(s) already carry this prefix in their number.', + 'series_floor' => 'Can only be raised. A lower number has already been issued — at least :n.', + 'cancel' => 'Cancel', + + 'series_title' => 'Invoice series', + 'series_sub' => 'Each series keeps its own prefix and its own gapless counter. A credit note is not an invoice with a minus sign in front of it.', + 'series' => [ + 'name' => 'Name', + 'prefix' => 'Prefix', + 'next' => 'Next number', + 'reset' => 'Counter', + 'reset_yearly' => 'restarts yearly', + 'reset_never' => 'continuous', + 'actions' => 'Actions', + 'digits' => 'Digits', + 'reset_label' => 'Restart the counter each year', + 'reset_hint' => 'The year then appears in the number: RE-2026-0001. Not editable once a document has been issued.', + 'active_label' => 'Available for new documents', + 'active_hint' => 'Switched off, everything already issued stays; only nothing new is issued from it.', + 'edit' => 'Edit', + ], +]; diff --git a/resources/views/livewire/admin/edit-invoice-series.blade.php b/resources/views/livewire/admin/edit-invoice-series.blade.php new file mode 100644 index 0000000..02ba45c --- /dev/null +++ b/resources/views/livewire/admin/edit-invoice-series.blade.php @@ -0,0 +1,54 @@ +
+
+

{{ __('finance.series_edit_title') }}

+ {{ $prefix }} +
+ +
+ + + {{-- Editable only while nothing has been issued. The prefix is part of + every number already on a document: changing it renames nothing and + leaves a series whose past says RE and whose future says AR. --}} +
+ + @if ($issued === 0) + + @else + +

{{ __('finance.series_locked', ['n' => $issued]) }}

+ @endif + @error('prefix')

{{ $message }}

@enderror +
+ +
+ +
+ + +

{{ __('finance.series_floor', ['n' => $floor]) }}

+ @error('nextNumber')

{{ $message }}

@enderror +
+
+ + @if ($issued === 0) +
+ +

{{ __('finance.series.reset_hint') }}

+
+ @endif + +
+ +

{{ __('finance.series.active_hint') }}

+
+
+ +
+ {{ __('finance.cancel') }} + {{ __('finance.save') }} +
+
diff --git a/resources/views/livewire/admin/finance.blade.php b/resources/views/livewire/admin/finance.blade.php new file mode 100644 index 0000000..9615b83 --- /dev/null +++ b/resources/views/livewire/admin/finance.blade.php @@ -0,0 +1,125 @@ +
+
+

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

+

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

+
+ + {{-- What is still missing before anything may be issued. An invoice without + a name, an address or a VAT number is not a valid invoice, and issuing + one is worse than refusing to. --}} + @if ($missing !== []) + + {{ __('finance.incomplete', ['fields' => collect($missing)->map(fn ($f) => __('finance.field.'.$f))->join(', ')]) }} + + @endif + +
+
+

{{ __('finance.company_title') }}

+

{{ __('finance.company_sub') }}

+
+ +
+ + + + + + + + +
+ +
+ + + +
+ + +

{{ __('finance.hint.tax_rate') }}

+ @error('taxRate')

{{ $message }}

@enderror +
+
+ +
+ + + +
+ +
+ + +
+ + {{-- The logo. Replaceable, because a company changes its mark and every + invoice rendered after that has to carry the new one — while every + invoice already issued keeps the one it was issued with, because + the path is copied into the document at issue. --}} +
+ +
+
+ @if ($logo) + + @elseif ($logoPath) + + @else + + @endif +
+
+ +

{{ __('finance.hint.logo') }}

+ @error('logo')

{{ $message }}

@enderror + @if ($logoPath) + + @endif +
+
+
+ +
+ {{ __('finance.save') }} +
+
+ + {{-- The Rechnungskreise. Each has its own prefix and its own gapless + counter — a credit note is not an invoice with a minus sign. --}} +
+
+

{{ __('finance.series_title') }}

+

{{ __('finance.series_sub') }}

+
+ + + + + + + + + + + + @foreach ($series as $row) + + + + + + + + @endforeach + +
{{ __('finance.series.name') }}{{ __('finance.series.prefix') }}{{ __('finance.series.next') }}{{ __('finance.series.reset') }}{{ __('finance.series.actions') }}
{{ $row->name }}{{ $row->prefix }}{{ str_pad((string) $row->next_number, $row->digits, '0', STR_PAD_LEFT) }}{{ $row->yearly_reset ? __('finance.series.reset_yearly') : __('finance.series.reset_never') }} + + {{ __('finance.series.edit') }} + +
+
+
diff --git a/routes/admin.php b/routes/admin.php index 598a933..0945b67 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -38,6 +38,7 @@ Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning'); 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('/mail', Admin\Mail::class)->name('mail'); Route::get('/secrets', Admin\Secrets::class)->name('secrets'); Route::get('/infrastructure', Admin\Infrastructure::class)->name('infrastructure'); diff --git a/tests/Feature/Billing/FinanceSettingsTest.php b/tests/Feature/Billing/FinanceSettingsTest.php new file mode 100644 index 0000000..badef37 --- /dev/null +++ b/tests/Feature/Billing/FinanceSettingsTest.php @@ -0,0 +1,134 @@ +toContain('name')->toContain('address')->toContain('vat_id'); + + CompanyProfile::put([ + 'name' => 'CluPilot Cloud e.U.', + 'address' => 'Musterstraße 1', + 'postcode' => '1010', + 'city' => 'Wien', + 'vat_id' => 'ATU12345678', + ]); + + expect(CompanyProfile::missingForInvoicing())->toBe([]); +}); + +it('stores the company details from the finance tab', function () { + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Finance::class) + ->set('company.name', 'CluPilot Cloud e.U.') + ->set('company.address', 'Musterstraße 1') + ->set('company.postcode', '1010') + ->set('company.city', 'Wien') + ->set('company.country', 'Österreich') + ->set('company.vat_id', 'ATU12345678') + ->set('company.payment_days', 14) + ->set('taxRate', 20) + ->call('saveCompany') + ->assertHasNoErrors(); + + expect(CompanyProfile::get('vat_id'))->toBe('ATU12345678') + ->and(CompanyProfile::taxRate())->toBe(20.0); +}); + +it('drops a field that is not part of the profile', function () { + // This is fed from a form. A forged key must not be able to write an + // arbitrary setting into the same table the site-visibility switch uses. + CompanyProfile::put(['site.public' => false, 'name' => 'Echt']); + + expect(App\Support\Settings::get('site.public', true))->toBeTrue() + ->and(CompanyProfile::get('name'))->toBe('Echt'); +}); + +it('lets a series be renamed and its prefix corrected while nothing has been issued', function () { + $series = InvoiceSeries::query()->where('kind', 'invoice')->firstOrFail(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(EditInvoiceSeries::class, ['uuid' => $series->uuid]) + ->set('name', 'Ausgangsrechnung') + ->set('prefix', 'ar') + ->call('save') + ->assertHasNoErrors(); + + expect($series->refresh()->prefix)->toBe('AR') + ->and($series->name)->toBe('Ausgangsrechnung'); +}); + +it('freezes the prefix once a document carries it', function () { + // The prefix is part of every number already issued. Changing it renames + // nothing and leaves a series whose past says RE and whose future says AR. + $series = InvoiceSeries::query()->where('kind', 'invoice')->firstOrFail(); + issueOne($series); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(EditInvoiceSeries::class, ['uuid' => $series->uuid]) + ->set('prefix', 'AR') + ->set('name', 'Ausgangsrechnung') + ->call('save') + ->assertHasNoErrors(); + + expect($series->refresh()->prefix)->toBe('RE') + // The rest of the form still saves. + ->and($series->name)->toBe('Ausgangsrechnung'); +}); + +it('will not let the counter be wound back onto a number already issued', function () { + // Gapless AND ascending. A lower number is one that is already printed on + // a document somebody holds. + $series = InvoiceSeries::query()->where('kind', 'invoice')->firstOrFail(); + $series->update(['next_number' => 25]); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(EditInvoiceSeries::class, ['uuid' => $series->uuid]) + ->set('nextNumber', 7) + ->call('save') + ->assertHasErrors(['nextNumber']); + + expect($series->refresh()->next_number)->toBe(25); +}); + +it('does let the counter be moved forward', function () { + // Skipping numbers is lawful — reusing them is not. An operator migrating + // from another system needs exactly this. + $series = InvoiceSeries::query()->where('kind', 'invoice')->firstOrFail(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(EditInvoiceSeries::class, ['uuid' => $series->uuid]) + ->set('nextNumber', 500) + ->call('save') + ->assertHasNoErrors(); + + expect($series->refresh()->next_number)->toBe(500); +}); + +/** A minimal issued document, enough to make a series non-empty. */ +function issueOne(InvoiceSeries $series): Invoice +{ + return Invoice::create([ + 'invoice_series_id' => $series->id, + 'number' => $series->prefix.'-2026-0001', + 'number_year' => 2026, + 'number_sequence' => 1, + 'issued_on' => now()->toDateString(), + 'snapshot' => ['issuer' => [], 'lines' => []], + 'net_cents' => 1900, + 'tax_cents' => 380, + 'gross_cents' => 2280, + 'currency' => 'EUR', + ]); +}