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', + ]); +}