158 lines
6.4 KiB
PHP
158 lines
6.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Billing;
|
|
|
|
use App\Models\Customer;
|
|
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),
|
|
];
|
|
|
|
return 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')),
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
];
|
|
}
|
|
}
|