144 lines
5.4 KiB
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(),
|
|
]);
|
|
}
|
|
}
|