*/ public array $company = []; public float $taxRate = 20.0; /** * The one-off setup fee, in euro, as a person types it. * * Held in euro rather than cents because this is a form field, and asking * an operator to enter 9900 for ninety-nine euro is how a fee ends up a * hundred times too large. Converted on the way in and out. */ public string $setupFee = '0'; /** * A freshly minted collection key, shown once and never stored. * * Held in the component for the length of one page view. The private half * exists to be copied into a NAS and nowhere else — writing it into the * database "for convenience" would put a working credential in every backup * of that database. * * @var array|null */ public ?array $archiveKey = null; /** True between asking the host for a key and the host answering. */ public bool $waitingForKey = false; /** 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(); $this->setupFee = number_format(CompanyProfile::setupFeeCents() / 100, 2, '.', ''); } 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', 'setupFee' => 'required|numeric|min:0|max:100000', ]); 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']); // round(), not a cast: (int) (99.95 * 100) is 9994 on a binary float, // and a fee one cent short of what was typed is the kind of thing // nobody finds until a customer does. Settings::set('company.setup_fee_cents', (int) round(((float) $data['setupFee']) * 100)); $this->dispatch('notify', message: __('finance.company_saved')); } /** * Ask the host to mint an SSH key that may only read this archive. * * Everything an operator otherwise does by hand across three machines — * generate a keypair, install the public half restricted to one directory, * carry the private half to the NAS. None of it can happen here: the panel * is www-data in a container and the keys, the home directory and rrsync * are all on the host. So it asks, and the agent does it. */ public function createPullAccess(string $uuid): void { $this->authorize('site.manage'); $target = ExportTarget::query()->where('uuid', $uuid)->firstOrFail(); if ($target->driver !== ExportTarget::LOCAL) { // Only a directory on this host has a home directory and an // authorized_keys to put anything in. A destination somewhere else // is somebody else's machine. $this->dispatch('notify', message: __('finance.pull_local_only')); return; } $accepted = app(UpdateChannel::class)->requestArchiveKey( (string) (auth('operator')->user()?->email ?? ''), $target->path, 'clupilot-archiv-'.$target->uuid, ); $this->archiveKey = null; $this->waitingForKey = $accepted; $this->dispatch('notify', message: __($accepted ? 'finance.pull_requested' : 'admin_settings.update_already_requested')); } /** Polled while waiting. Takes the key the moment the host has left one. */ public function collectArchiveKey(): void { $this->authorize('site.manage'); if (! $this->waitingForKey) { return; } $key = app(UpdateChannel::class)->takeArchiveKey(); if ($key !== null) { $this->archiveKey = $key; $this->waitingForKey = false; } } 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(), // Counted from the join table, never by asking the destinations. // A stat() on a network mount that has gone away blocks for the // mount's whole timeout, and this is a page an operator opens when // something is already wrong. 'targets' => ExportTarget::query()->orderBy('name')->get()->map(fn (ExportTarget $t) => [ 'model' => $t, 'pending' => Invoice::query()->whereDoesntHave('exports', fn ($q) => $q ->where('export_target_id', $t->id)->whereNotNull('exported_at'))->count(), 'failed' => InvoiceExport::query()->where('export_target_id', $t->id) ->whereNotNull('error')->whereNull('exported_at')->count(), ]), ]); } }