Give the console a Finance tab: company details, VAT rate, invoice series
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 <noreply@anthropic.com>feat/granted-plans
parent
21762284a7
commit
e6d2e6dc33
|
|
@ -0,0 +1,123 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\InvoiceSeries;
|
||||
use Illuminate\Validation\Rule;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Editing a Rechnungskreis, with the parts that cannot move held still.
|
||||
*
|
||||
* The same shape as EditDatacenter and for the same reason: some fields stop
|
||||
* being editable once something depends on them. Here the something is a
|
||||
* document in somebody's accounts.
|
||||
*
|
||||
* The prefix is part of every number already issued. Changing it from RE to AR
|
||||
* does not rename anything — it leaves a series whose documents say RE-2026-…
|
||||
* and whose next document says AR-2026-…, with no record that they belong
|
||||
* together. Free while the series is empty, which is the case that matters: a
|
||||
* prefix chosen wrongly on the day the shop opens.
|
||||
*
|
||||
* The counter may be raised but never lowered. Lowering it re-issues a number
|
||||
* that is already printed on a document somebody holds, and gapless-and-
|
||||
* ascending is a legal requirement rather than a preference.
|
||||
*/
|
||||
class EditInvoiceSeries extends ModalComponent
|
||||
{
|
||||
public string $uuid = '';
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $prefix = '';
|
||||
|
||||
public int $digits = 4;
|
||||
|
||||
public bool $yearlyReset = true;
|
||||
|
||||
public int $nextNumber = 1;
|
||||
|
||||
public bool $active = true;
|
||||
|
||||
/** How many documents this series has already issued. */
|
||||
public int $issued = 0;
|
||||
|
||||
/** The lowest number this counter may still be set to. */
|
||||
public int $floor = 1;
|
||||
|
||||
public function mount(string $uuid): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Models\InvoiceSeries;
|
||||
use App\Support\CompanyProfile;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
/**
|
||||
* Everything an invoice needs before one can be issued.
|
||||
*
|
||||
* Its own tab rather than a section of Settings: the company's registered
|
||||
* details, the VAT rate and the Rechnungskreise are not preferences, they are
|
||||
* what appears on a legal document, and burying them under "Einstellungen"
|
||||
* beside the site-visibility switch invites somebody to change one in passing.
|
||||
*
|
||||
* None of it reaches an invoice that already exists. Every value is copied into
|
||||
* the invoice when its number is assigned, so an address corrected here is
|
||||
* correct from the next document onwards and cannot rewrite an old one.
|
||||
*/
|
||||
#[Layout('layouts.admin')]
|
||||
class Finance extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Who is issuing the invoice.
|
||||
*
|
||||
* Every field a printed invoice needs, kept in settings rather than in config,
|
||||
* because an operator has to be able to change an address without a deployment
|
||||
* — and because the values are copied into each invoice at issue anyway, so
|
||||
* changing one here never rewrites a document that already exists.
|
||||
*
|
||||
* A typed front door onto Settings rather than string keys scattered through
|
||||
* views and PDF code: there are fifteen of them, an invoice is a legal
|
||||
* document, and a typo in a key silently prints an empty line where the VAT
|
||||
* number belongs.
|
||||
*/
|
||||
final class CompanyProfile
|
||||
{
|
||||
private const PREFIX = 'company.';
|
||||
|
||||
/** Every key, with its default. The list IS the schema. */
|
||||
public const FIELDS = [
|
||||
'name' => '',
|
||||
'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<string, mixed> */
|
||||
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<string, mixed> $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<int, string> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ return [
|
|||
'provisioning' => 'Provisioning',
|
||||
'maintenance' => 'Wartungen',
|
||||
'vpn' => 'VPN',
|
||||
'finance' => 'Finanzen',
|
||||
'revenue' => 'Umsatz',
|
||||
'mail' => 'E-Mail',
|
||||
'secrets' => 'Zugangsdaten',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => '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',
|
||||
],
|
||||
];
|
||||
|
|
@ -20,6 +20,7 @@ return [
|
|||
'provisioning' => 'Provisioning',
|
||||
'maintenance' => 'Maintenance',
|
||||
'vpn' => 'VPN',
|
||||
'finance' => 'Finance',
|
||||
'revenue' => 'Revenue',
|
||||
'mail' => 'Email',
|
||||
'secrets' => 'Credentials',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'title' => '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',
|
||||
],
|
||||
];
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
<div class="rounded-lg bg-surface p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-base font-semibold text-ink">{{ __('finance.series_edit_title') }}</h3>
|
||||
<span class="rounded bg-surface-2 px-2 py-0.5 font-mono text-xs text-muted">{{ $prefix }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<x-ui.input name="name" wire:model="name" :label="__('finance.series.name')" />
|
||||
|
||||
{{-- 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. --}}
|
||||
<div>
|
||||
<label class="text-sm font-medium text-body" for="series-prefix">{{ __('finance.series.prefix') }}</label>
|
||||
@if ($issued === 0)
|
||||
<input id="series-prefix" type="text" wire:model="prefix"
|
||||
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 font-mono text-sm uppercase text-ink" />
|
||||
@else
|
||||
<input id="series-prefix" type="text" value="{{ $prefix }}" disabled
|
||||
class="mt-1.5 w-full cursor-not-allowed rounded-md border border-line bg-surface-2 px-3 py-2 font-mono text-sm text-muted" />
|
||||
<p class="mt-1 text-xs text-muted">{{ __('finance.series_locked', ['n' => $issued]) }}</p>
|
||||
@endif
|
||||
@error('prefix')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<x-ui.input name="digits" wire:model="digits" type="number" :label="__('finance.series.digits')" />
|
||||
<div>
|
||||
<label class="text-sm font-medium text-body" for="series-next">{{ __('finance.series.next') }}</label>
|
||||
<input id="series-next" type="number" min="{{ $floor }}" wire:model="nextNumber"
|
||||
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 font-mono text-sm text-ink" />
|
||||
<p class="mt-1 text-xs text-muted">{{ __('finance.series_floor', ['n' => $floor]) }}</p>
|
||||
@error('nextNumber')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($issued === 0)
|
||||
<div class="rounded-lg border border-line bg-surface-2 p-4">
|
||||
<x-ui.checkbox name="yearlyReset" wire:model="yearlyReset" :label="__('finance.series.reset_label')" />
|
||||
<p class="mt-1.5 pl-7 text-xs text-muted">{{ __('finance.series.reset_hint') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="rounded-lg border border-line bg-surface-2 p-4">
|
||||
<x-ui.checkbox name="active" wire:model="active" :label="__('finance.series.active_label')" />
|
||||
<p class="mt-1.5 pl-7 text-xs text-muted">{{ __('finance.series.active_hint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('finance.cancel') }}</x-ui.button>
|
||||
<x-ui.button variant="primary" wire:click="save" wire:loading.attr="disabled">{{ __('finance.save') }}</x-ui.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
<div class="space-y-5">
|
||||
<div class="animate-rise">
|
||||
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('finance.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-muted">{{ __('finance.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
{{-- 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 !== [])
|
||||
<x-ui.alert variant="warning" class="animate-rise">
|
||||
{{ __('finance.incomplete', ['fields' => collect($missing)->map(fn ($f) => __('finance.field.'.$f))->join(', ')]) }}
|
||||
</x-ui.alert>
|
||||
@endif
|
||||
|
||||
<form wire:submit="saveCompany" class="space-y-5 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
|
||||
<div>
|
||||
<h2 class="font-semibold text-ink">{{ __('finance.company_title') }}</h2>
|
||||
<p class="mt-1 max-w-2xl text-sm text-muted">{{ __('finance.company_sub') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<x-ui.input name="company.name" wire:model="company.name" :label="__('finance.field.name')" class="sm:col-span-2" />
|
||||
<x-ui.input name="company.address" wire:model="company.address" :label="__('finance.field.address')" class="sm:col-span-2" />
|
||||
<x-ui.input name="company.postcode" wire:model="company.postcode" :label="__('finance.field.postcode')" />
|
||||
<x-ui.input name="company.city" wire:model="company.city" :label="__('finance.field.city')" />
|
||||
<x-ui.input name="company.country" wire:model="company.country" :label="__('finance.field.country')" />
|
||||
<x-ui.input name="company.phone" wire:model="company.phone" :label="__('finance.field.phone')" />
|
||||
<x-ui.input name="company.email" wire:model="company.email" :label="__('finance.field.email')" />
|
||||
<x-ui.input name="company.website" wire:model="company.website" :label="__('finance.field.website')" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 border-t border-line pt-5 sm:grid-cols-2">
|
||||
<x-ui.input name="company.vat_id" wire:model="company.vat_id" :label="__('finance.field.vat_id')" :hint="__('finance.hint.vat_id')" placeholder="ATU12345678" />
|
||||
<x-ui.input name="company.register_number" wire:model="company.register_number" :label="__('finance.field.register_number')" placeholder="FN 123456a" />
|
||||
<x-ui.input name="company.register_court" wire:model="company.register_court" :label="__('finance.field.register_court')" placeholder="Handelsgericht Wien" />
|
||||
<div>
|
||||
<label class="text-sm font-medium text-body" for="tax-rate">{{ __('finance.field.tax_rate') }}</label>
|
||||
<input id="tax-rate" type="number" step="0.1" min="0" max="100" wire:model="taxRate"
|
||||
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 font-mono text-sm text-ink" />
|
||||
<p class="mt-1 text-xs text-muted">{{ __('finance.hint.tax_rate') }}</p>
|
||||
@error('taxRate')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 border-t border-line pt-5 sm:grid-cols-3">
|
||||
<x-ui.input name="company.bank_name" wire:model="company.bank_name" :label="__('finance.field.bank_name')" />
|
||||
<x-ui.input name="company.iban" wire:model="company.iban" :label="__('finance.field.iban')" />
|
||||
<x-ui.input name="company.bic" wire:model="company.bic" :label="__('finance.field.bic')" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 border-t border-line pt-5 sm:grid-cols-3">
|
||||
<x-ui.input name="company.payment_days" wire:model="company.payment_days" type="number" :label="__('finance.field.payment_days')" />
|
||||
<x-ui.input name="company.payment_terms" wire:model="company.payment_terms" :label="__('finance.field.payment_terms')" class="sm:col-span-2" />
|
||||
</div>
|
||||
|
||||
{{-- 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. --}}
|
||||
<div class="border-t border-line pt-5">
|
||||
<label class="text-sm font-medium text-body">{{ __('finance.field.logo') }}</label>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-4">
|
||||
<div class="grid size-20 place-items-center overflow-hidden rounded-lg border border-line bg-surface-2">
|
||||
@if ($logo)
|
||||
<img src="{{ $logo->temporaryUrl() }}" alt="" class="max-h-16 max-w-16 object-contain" />
|
||||
@elseif ($logoPath)
|
||||
<img src="{{ Storage::disk('public')->url($logoPath) }}" alt="" class="max-h-16 max-w-16 object-contain" />
|
||||
@else
|
||||
<x-ui.icon name="receipt" class="size-6 text-muted" />
|
||||
@endif
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<input type="file" wire:model="logo" accept="image/png,image/webp"
|
||||
class="block w-full text-sm text-body file:mr-3 file:rounded-md file:border file:border-line-strong file:bg-surface-2 file:px-3 file:py-1.5 file:text-sm file:text-body" />
|
||||
<p class="mt-1 text-xs text-muted">{{ __('finance.hint.logo') }}</p>
|
||||
@error('logo')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
|
||||
@if ($logoPath)
|
||||
<button type="button" wire:click="removeLogo" class="mt-2 text-xs font-medium text-danger underline">{{ __('finance.logo_remove') }}</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end border-t border-line pt-5">
|
||||
<x-ui.button variant="primary" type="submit" wire:loading.attr="disabled">{{ __('finance.save') }}</x-ui.button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{-- The Rechnungskreise. Each has its own prefix and its own gapless
|
||||
counter — a credit note is not an invoice with a minus sign. --}}
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:90ms]">
|
||||
<div class="border-b border-line p-6">
|
||||
<h2 class="font-semibold text-ink">{{ __('finance.series_title') }}</h2>
|
||||
<p class="mt-1 max-w-2xl text-sm text-muted">{{ __('finance.series_sub') }}</p>
|
||||
</div>
|
||||
<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">{{ __('finance.series.name') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('finance.series.prefix') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('finance.series.next') }}</th>
|
||||
<th class="px-4 py-3 font-semibold">{{ __('finance.series.reset') }}</th>
|
||||
<th class="px-4 py-3 text-right font-semibold">{{ __('finance.series.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($series as $row)
|
||||
<tr wire:key="series-{{ $row->uuid }}" class="border-b border-line last:border-0">
|
||||
<td class="px-4 py-3 text-body">{{ $row->name }}</td>
|
||||
<td class="px-4 py-3 font-mono font-semibold text-ink">{{ $row->prefix }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-muted">{{ str_pad((string) $row->next_number, $row->digits, '0', STR_PAD_LEFT) }}</td>
|
||||
<td class="px-4 py-3 text-xs text-muted">{{ $row->yearly_reset ? __('finance.series.reset_yearly') : __('finance.series.reset_never') }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<x-ui.button variant="secondary"
|
||||
x-on:click="$dispatch('openModal', { component: 'admin.edit-invoice-series', arguments: { uuid: '{{ $row->uuid }}' } })">
|
||||
{{ __('finance.series.edit') }}
|
||||
</x-ui.button>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\EditInvoiceSeries;
|
||||
use App\Livewire\Admin\Finance;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\InvoiceSeries;
|
||||
use App\Support\CompanyProfile;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* The details that end up on a legal document, and the parts of an invoice
|
||||
* series that stop being editable once one has been issued.
|
||||
*/
|
||||
it('refuses to consider itself ready without the fields an invoice legally needs', function () {
|
||||
// An invoice without a name, an address or a VAT number is not a valid
|
||||
// invoice in Austria. Issuing one is worse than refusing to.
|
||||
expect(CompanyProfile::missingForInvoicing())
|
||||
->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',
|
||||
]);
|
||||
}
|
||||
Loading…
Reference in New Issue