CluPilotCloud/app/Livewire/Admin/Finance.php

144 lines
5.4 KiB
PHP

<?php
namespace App\Livewire\Admin;
use App\Models\Invoice;
use App\Models\InvoiceSeries;
use App\Services\Billing\InvoiceArchive;
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;
/**
* Where a copy of every invoice is written, or empty for none.
*
* A plain directory. Whether it is a local disk, a bind mount or a NAS over
* NFS 4.1 is the mount's business — nothing here knows or needs to.
*/
public string $archivePath = '';
/** 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->archivePath = InvoiceArchive::path();
}
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',
'archivePath' => 'nullable|string|max:255',
]);
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']);
Settings::set(InvoiceArchive::PATH_KEY, trim((string) ($data['archivePath'] ?? '')));
$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(),
// Written from here rather than checked on every render: a stat()
// on a network mount that has gone away blocks for the mount's
// timeout, and this is a page an operator opens when something is
// already wrong.
'unexported' => Invoice::query()->whereNull('exported_at')->count(),
'exportFailures' => Invoice::query()->whereNotNull('export_error')->count(),
]);
}
}