CluPilotCloud/app/Services/Billing/IssueInvoice.php

177 lines
7.3 KiB
PHP

<?php
namespace App\Services\Billing;
use App\Jobs\ArchiveInvoice;
use App\Models\Customer;
use App\Models\ExportTarget;
use App\Models\Invoice;
use App\Models\InvoiceSeries;
use App\Models\Order;
use App\Support\CompanyProfile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Turning what somebody bought into a document that cannot change afterwards.
*
* Everything the finished invoice says is copied into `snapshot` here, at the
* moment the number is assigned: the issuer's registered details, the
* customer's address, every line, the rate and the reason for it. The PDF is
* rendered from that and never stored, so the same invoice re-rendered in seven
* years is the page it was on the day it was issued — whatever has changed in
* the settings in between.
*
* The number and the invoice commit together or not at all. A number taken and
* then lost to a failure is a gap in a series that must not have one.
*/
final class IssueInvoice
{
public function __construct(private readonly InvoiceNumbers $numbers) {}
/**
* One invoice covering several orders.
*
* Several rather than one-per-order on purpose: a customer who buys a plan
* and two add-ons in one go has bought once, and sending them three
* invoices for one purchase is three times the paperwork for the same
* money. Each order becomes a line, which is also what was asked for —
* every add-on listed on its own.
*
* @param Collection<int, Order> $orders
*/
public function forOrders(Customer $customer, Collection $orders, ?InvoiceSeries $series = null): Invoice
{
if ($orders->isEmpty()) {
throw new RuntimeException('Refusing to issue an invoice with no lines on it.');
}
$missing = CompanyProfile::missingForInvoicing();
if ($missing !== []) {
// Refusing is the correct outcome. An invoice without a registered
// name, an address or a VAT number is not a valid invoice here, and
// issuing one consumes a number that can never be reused.
throw new RuntimeException(
'Refusing to issue an invoice before the company details are complete: '.implode(', ', $missing)
);
}
$series ??= InvoiceSeries::query()->where('kind', 'invoice')->where('active', true)->firstOrFail();
$treatment = TaxTreatment::for($customer);
$rate = (int) round($treatment->rate * 10000);
$lines = $orders->map(fn (Order $order) => [
'description' => $order->label(),
'details' => array_values(array_filter([
$order->isRecurring() ? __('invoice.line_recurring') : __('invoice.line_once'),
])),
'quantity_milli' => 1000,
'unit' => '',
'unit_net_cents' => (int) $order->amount_cents,
])->all();
$totals = InvoiceMath::totals($lines, null, $rate) + ['rate_basis_points' => $rate];
$snapshot = [
'issuer' => CompanyProfile::all(),
'customer' => $this->customerBlock($customer),
'lines' => $lines,
'totals' => $totals,
'meta' => $this->meta($customer, $treatment),
];
$invoice = DB::transaction(function () use ($series, $customer, $orders, $snapshot, $totals) {
[$number, $sequence, $year] = $this->numbers->next($series);
// The number is only true once it is on the document, so both are
// written inside the same transaction the counter was advanced in.
$snapshot['meta']['number'] = $number;
return Invoice::create([
'invoice_series_id' => $series->id,
'customer_id' => $customer->id,
'order_id' => $orders->first()->id,
'number' => $number,
'number_year' => $year,
'number_sequence' => $sequence,
'issued_on' => now()->toDateString(),
'due_on' => now()->addDays((int) CompanyProfile::get('payment_days', 14))->toDateString(),
'snapshot' => $snapshot,
'net_cents' => $totals['net'],
'tax_cents' => $totals['tax'],
'gross_cents' => $totals['gross'],
'currency' => strtoupper((string) ($orders->first()->currency ?: 'EUR')),
]);
});
// After the commit, never inside it: a worker can pick a job up before
// the transaction lands, and it would then look for an invoice that
// does not exist yet.
//
// One job per destination. The reason for having a second is that the
// first can fail, and a single job writing to both would retry the one
// that worked every time the other did not.
//
// Dispatched rather than done here because a destination can be a
// network mount that blocks for its whole timeout, and an invoice must
// not fail to be issued because a NAS in an office is rebooting.
foreach (ExportTarget::query()->where('active', true)->get() as $target) {
ArchiveInvoice::dispatch($invoice, $target);
}
return $invoice;
}
/**
* The recipient, as the document will show them.
*
* The billing address is one free-text field today, so it is carried as
* lines rather than pulled apart into street and city here — guessing which
* line is which would put a postcode where a street belongs on a document
* nobody can correct afterwards.
*
* @return array<string, mixed>
*/
private function customerBlock(Customer $customer): array
{
$address = trim((string) $customer->billing_address);
return [
'name' => (string) $customer->name,
'address_lines' => $address === ''
? []
: array_values(array_filter(array_map('trim', preg_split('/\R/', $address) ?: []))),
'vat_id' => (string) $customer->vat_id,
'email' => (string) $customer->email,
];
}
/** @return array<string, mixed> */
private function meta(Customer $customer, TaxTreatment $treatment): array
{
$days = (int) CompanyProfile::get('payment_days', 14);
return [
'title' => __('invoice.title'),
'number' => '', // filled inside the transaction, once it is real
'customer_number' => 'KN-'.str_pad((string) $customer->id, 4, '0', STR_PAD_LEFT),
'issued_on' => now()->local()->format('d.m.Y'),
'due_on' => now()->local()->addDays($days)->format('d.m.Y'),
'currency' => 'EUR',
'salutation' => __('invoice.salutation'),
'intro' => __('invoice.intro'),
'adjustment_label' => __('invoice.adjustment'),
'payment_terms' => trim((string) CompanyProfile::get('payment_terms')) !== ''
? (string) CompanyProfile::get('payment_terms')
: __('invoice.payment_default', ['days' => $days]),
// The note that makes a zero-rated invoice lawful. Without it the
// document says nothing about why no VAT was charged, which is the
// one thing an auditor looks for.
'closing' => $treatment->reverseCharge ? __('invoice.reverse_charge') : null,
];
}
}