CluPilotCloud/app/Livewire/Admin/NewInvoice.php

217 lines
8.0 KiB
PHP

<?php
namespace App\Livewire\Admin;
use App\Models\Customer;
use App\Models\InvoiceSeries;
use App\Models\Subscription;
use App\Services\Billing\InvoiceMath;
use App\Services\Billing\IssueInvoice;
use App\Services\Billing\TaxTreatment;
use App\Support\CompanyProfile;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
use Throwable;
/**
* An invoice for work that was done, typed by the person who did it.
*
* Not everything we are paid for comes off a price list. Somebody asks whether
* their data can be moved into Nextcloud; we look at it, we say what it will
* cost, we do it. That is a real invoice with no order and no contract behind
* it, and until now the only way to produce one was to write it somewhere else
* — which puts it outside the series, and a numbered series with a document
* missing from it is worth nothing at an audit.
*
* So it goes through the same door as every other invoice: the same series and
* therefore the same consecutive number, the same tax treatment (a business in
* another member state gets its reverse charge here too), the same immutable
* snapshot, the same archive jobs.
*
* This page only writes what the operator typed. It invents no price and
* applies no rule of its own — what is on the lines is what was agreed on the
* phone. Everything it shows before the button is a preview of what the
* document will say, computed by InvoiceMath, so nobody has to issue one to
* find out what it adds up to.
*/
#[Layout('layouts.admin')]
class NewInvoice extends Component
{
/**
* Who it is for. The uuid, because that is what a URL and a select carry.
*
* In the query string so a link can arrive with the recipient already
* chosen — from a customer's own page, or from the support request that
* asked for the work — and so a reload does not lose them.
*/
#[Url(as: 'customer')]
public string $customerUuid = '';
/** Which series draws the number. Empty means the active invoice series. */
public string $seriesId = '';
/**
* The lines, as a person types them.
*
* Amounts are held as strings in euro rather than integers in cents: this
* is a form, and asking somebody to type 12000 for a hundred and twenty
* euro is how an invoice ends up a hundred times too large. Converted once,
* on the way out.
*
* @var array<int, array{description: string, detail: string, quantity: string, unit: string, price: string}>
*/
public array $lines = [];
public function mount(): void
{
$this->authorize('site.manage');
$this->addLine();
}
public function addLine(): void
{
$this->lines[] = [
'description' => '',
'detail' => '',
'quantity' => '1',
'unit' => '',
'price' => '',
];
}
/**
* Remove a line, but never the last one.
*
* An invoice with no lines is not a draft state worth having — the page
* would show a total of nothing and a button that refuses.
*/
public function removeLine(int $index): void
{
if (count($this->lines) <= 1) {
return;
}
unset($this->lines[$index]);
$this->lines = array_values($this->lines);
}
/**
* Issue it.
*
* There is no draft and no edit afterwards, deliberately: an issued invoice
* cannot lawfully be changed, and a "save as draft" that draws a number
* would be the same thing with a friendlier name. The number is taken at
* this click and not before.
*/
public function issue(IssueInvoice $issuer): void
{
$this->authorize('site.manage');
$data = $this->validate([
'customerUuid' => 'required|string',
'seriesId' => 'nullable|string',
'lines' => 'required|array|min:1',
'lines.*.description' => 'required|string|max:255',
'lines.*.detail' => 'nullable|string|max:255',
// Down to a thousandth, because hours are billed in quarters and
// 0.25 has to survive the trip.
'lines.*.quantity' => 'required|numeric|min:0.001|max:100000',
'lines.*.unit' => 'nullable|string|max:32',
// Negative allowed: a credit line on an ordinary invoice is how a
// goodwill deduction is shown without a second document.
'lines.*.price' => 'required|numeric|min:-1000000|max:1000000',
]);
$customer = Customer::query()->where('uuid', $data['customerUuid'])->first();
if ($customer === null) {
$this->addError('customerUuid', __('new_invoice.no_customer'));
return;
}
$series = $data['seriesId'] === ''
? null
: InvoiceSeries::query()->whereKey($data['seriesId'])->first();
try {
$invoice = $issuer->forService(
customer: $customer,
lines: $this->linesForIssue(),
// The currency the catalogue is priced in, not a second
// setting: a service invoice in another currency than the
// packages would be a second answer to a question that already
// has one.
currency: Subscription::catalogueCurrency(),
series: $series,
);
} catch (Throwable $e) {
// The company profile being incomplete is the usual one, and it is
// a refusal by design: an invoice without a registered name, an
// address or a VAT number is not a valid invoice, and issuing it
// would burn a number that can never be reused.
$this->addError('lines', $e->getMessage());
return;
}
$this->redirectRoute('admin.invoices', navigate: true);
session()->flash('status', __('new_invoice.issued', ['number' => $invoice->number]));
}
/**
* The lines in the shape IssueInvoice wants them.
*
* round(), not a cast: (int) (12.95 * 100) is 1294 on a binary float, and
* an invoice a cent short of what was typed is the kind of error nobody
* finds until a customer does.
*
* @return array<int, array<string, mixed>>
*/
private function linesForIssue(): array
{
return array_map(fn (array $line) => [
'description' => trim($line['description']),
'details' => array_filter([trim((string) $line['detail'])]),
'quantity_milli' => (int) round(((float) $line['quantity']) * 1000),
'unit' => trim((string) $line['unit']),
'unit_net_cents' => (int) round(((float) $line['price']) * 100),
], $this->lines);
}
public function render()
{
$this->authorize('site.manage');
$customer = $this->customerUuid === ''
? null
: Customer::query()->where('uuid', $this->customerUuid)->first();
// The same treatment the document will carry, shown before it is
// issued: an operator writing an invoice for a business in another
// member state should see "reverse charge, 0 %" on the page, not
// discover it on the PDF.
$treatment = TaxTreatment::for($customer);
$rate = (int) round($treatment->rate * 10000);
return view('livewire.admin.new-invoice', [
'customers' => Customer::query()
->whereNull('closed_at')
->orderBy('name')
->get(['id', 'uuid', 'name', 'email']),
'series' => InvoiceSeries::query()->where('kind', 'invoice')->orderBy('name')->get(),
'customer' => $customer,
'treatment' => $treatment,
'totals' => InvoiceMath::totals($this->linesForIssue(), null, $rate),
'currency' => Subscription::catalogueCurrency(),
// Said on the page rather than only thrown at the click: the
// company profile is somebody else's tab, and finding out at the
// last step costs the whole form.
'missing' => CompanyProfile::missingForInvoicing(),
]);
}
}