336 lines
14 KiB
PHP
336 lines
14 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\Models\Subscription;
|
|
use App\Support\CompanyProfile;
|
|
use Illuminate\Support\Carbon;
|
|
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
|
|
{
|
|
// A full gift produces no invoice — no amount, no number drawn from
|
|
// the series. A zero invoice with a sequential number is a bookkeeping
|
|
// foreign body. A discounted grant is unaffected: it still charges
|
|
// something and is invoiced exactly like an ordinary sale below.
|
|
$orders = $orders->reject(fn (Order $order) => $order->isFreeGrant());
|
|
|
|
if ($orders->isEmpty()) {
|
|
throw new RuntimeException('Refusing to issue an invoice with no lines on it.');
|
|
}
|
|
|
|
$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();
|
|
|
|
return $this->issue(
|
|
customer: $customer,
|
|
lines: $lines,
|
|
currency: strtoupper((string) ($orders->first()->currency ?: 'EUR')),
|
|
series: $series,
|
|
attributes: ['order_id' => $orders->first()->id],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* One invoice for a term that was billed — a renewal.
|
|
*
|
|
* Deliberately NOT forOrders() with an Order made up for the occasion. An
|
|
* Order is something a customer bought: it is created at a checkout, it sits
|
|
* in their cart until it is paid, and provisioning and plan changes consume
|
|
* it. A month rolling over is none of those things, and writing a purchase
|
|
* nobody made would corrupt the one record that says what this customer has
|
|
* actually ordered. The document points at the CONTRACT instead, which is
|
|
* what a renewal renews.
|
|
*
|
|
* The line is the contract's own frozen price — `price_cents`, the
|
|
* catalogue's NET figure — and not Stripe's `amount_paid`, which is a gross
|
|
* total. What the customer is owed is what they agreed to; the register
|
|
* records beside it what was actually taken, and `stripe_invoice_id` on the
|
|
* row is how the two are reconciled afterwards.
|
|
*
|
|
* Booked modules are NOT on it. They are not items on the Stripe
|
|
* subscription, so this renewal did not charge for them — putting them on
|
|
* the document would invoice money nobody was asked for. (That they are
|
|
* never billed after the month they were booked in is a real gap, and it is
|
|
* a gap in the booking, not in this document.)
|
|
*
|
|
* `$stripeInvoiceId` is what makes issuing this idempotent: the column is
|
|
* unique, so a redelivered webhook collides in the database and the
|
|
* transaction takes its invoice number back with it.
|
|
*/
|
|
public function forBilledPeriod(
|
|
Subscription $subscription,
|
|
Carbon $from,
|
|
Carbon $to,
|
|
?string $stripeInvoiceId = null,
|
|
?InvoiceSeries $series = null,
|
|
): Invoice {
|
|
$customer = $subscription->customer;
|
|
|
|
if ($customer === null) {
|
|
throw new RuntimeException('Refusing to issue an invoice for a contract with nobody on it.');
|
|
}
|
|
|
|
$lines = [[
|
|
'description' => __('billing.cart.plan', ['plan' => ucfirst((string) $subscription->plan)]),
|
|
'details' => [__('invoice.line_period', [
|
|
'from' => $from->local()->format('d.m.Y'),
|
|
// Stripe's period_end is the EXCLUSIVE boundary — the first
|
|
// moment of the next term, which the next invoice will claim in
|
|
// turn. Printed as it comes, the same day would appear on two
|
|
// consecutive documents.
|
|
'to' => $to->greaterThan($from)
|
|
? $to->copy()->subDay()->local()->format('d.m.Y')
|
|
: $to->local()->format('d.m.Y'),
|
|
])],
|
|
'quantity_milli' => 1000,
|
|
'unit' => '',
|
|
'unit_net_cents' => (int) $subscription->price_cents,
|
|
]];
|
|
|
|
return $this->issue(
|
|
customer: $customer,
|
|
lines: $lines,
|
|
currency: strtoupper((string) ($subscription->currency ?: 'EUR')),
|
|
series: $series,
|
|
attributes: [
|
|
'subscription_id' => $subscription->id,
|
|
// Left null on purpose. The contract's opening order is the
|
|
// FIRST purchase and already has its own invoice; pointing this
|
|
// one at it would file two documents under one sale.
|
|
'order_id' => null,
|
|
'stripe_invoice_id' => $stripeInvoiceId,
|
|
],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The part every document has in common: the number, the freeze, the copy
|
|
* to the archive.
|
|
*
|
|
* @param array<int, array<string, mixed>> $lines
|
|
* @param array<string, mixed> $attributes what this document is FOR
|
|
*/
|
|
/**
|
|
* One invoice for work that was done, typed by a person.
|
|
*
|
|
* Not everything we are paid for is a package. Somebody asks whether their
|
|
* data can be moved into Nextcloud, we look at it, we say yes, we do it —
|
|
* and that is an invoice with no order and no contract behind it, because
|
|
* nobody bought it from a price list.
|
|
*
|
|
* It goes through the same door as every other invoice on purpose: the same
|
|
* series, so the number stays consecutive; the same tax treatment, so a
|
|
* business in another member state gets its reverse charge here too; the
|
|
* same immutable snapshot and the same archive jobs. A second way of making
|
|
* invoices would be a second numbering — and a series with a document
|
|
* missing from it is worth nothing at an audit.
|
|
*
|
|
* Lines arrive as a person typed them. Nothing here invents a price: the
|
|
* caller is the operator, and what they wrote is what the customer agreed
|
|
* to on the phone.
|
|
*
|
|
* @param array<int, array{description: string, details?: array<int, string>, quantity_milli: int, unit?: string, unit_net_cents: int}> $lines
|
|
*/
|
|
public function forService(
|
|
Customer $customer,
|
|
array $lines,
|
|
string $currency = 'EUR',
|
|
?InvoiceSeries $series = null,
|
|
): Invoice {
|
|
if ($lines === []) {
|
|
throw new RuntimeException('Refusing to issue an invoice with no lines on it.');
|
|
}
|
|
|
|
return $this->issue(
|
|
customer: $customer,
|
|
// Normalised here rather than trusted: the optional keys are
|
|
// optional to the caller, and InvoiceMath and the renderer both
|
|
// read them without asking whether they are there.
|
|
lines: array_map(fn (array $line) => [
|
|
'description' => (string) $line['description'],
|
|
'details' => array_values(array_filter((array) ($line['details'] ?? []))),
|
|
'quantity_milli' => (int) $line['quantity_milli'],
|
|
'unit' => (string) ($line['unit'] ?? ''),
|
|
'unit_net_cents' => (int) $line['unit_net_cents'],
|
|
], array_values($lines)),
|
|
currency: strtoupper($currency),
|
|
series: $series,
|
|
// No order and no contract: this document points at neither, and
|
|
// saying so with a null is more honest than inventing an Order
|
|
// nobody placed just to have something to point at.
|
|
attributes: [],
|
|
);
|
|
}
|
|
|
|
private function issue(
|
|
Customer $customer,
|
|
array $lines,
|
|
string $currency,
|
|
?InvoiceSeries $series,
|
|
array $attributes,
|
|
): Invoice {
|
|
$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);
|
|
|
|
$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, $snapshot, $totals, $currency, $attributes) {
|
|
[$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.
|
|
// That is also what keeps a redelivered Stripe invoice from eating a
|
|
// number: the unique index rejects the row, the transaction rolls
|
|
// back, and the counter comes back with it.
|
|
$snapshot['meta']['number'] = $number;
|
|
|
|
return Invoice::create([
|
|
'invoice_series_id' => $series->id,
|
|
'customer_id' => $customer->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' => $currency,
|
|
...$attributes,
|
|
]);
|
|
});
|
|
|
|
// 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,
|
|
];
|
|
}
|
|
}
|