CluPilotCloud/app/Services/Billing/IssueInvoice.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,
];
}
}