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