*/ 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> */ 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(), ]); } }