225 lines
8.5 KiB
PHP
225 lines
8.5 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Admin;
|
|
|
|
use App\Models\ExportTarget;
|
|
use App\Models\Invoice;
|
|
use App\Models\InvoiceExport;
|
|
use App\Models\InvoiceSeries;
|
|
use App\Services\Deployment\UpdateChannel;
|
|
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;
|
|
|
|
/**
|
|
* 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<string, string>|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(),
|
|
]),
|
|
]);
|
|
}
|
|
}
|