CluPilotCloud/app/Services/Billing/IssueInvoice.php

608 lines
27 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.
*
* ## Two kinds of line, and why
*
* Everything sold from the catalogue is priced at the till: the price sheet, the
* Stripe Price and the card charge are one figure — the domestic gross for
* everybody who is charged VAT, the bare net for a reverse-charge business, whose
* Stripe Price carries exactly that. A document for such a sale therefore starts
* from what was CHARGED and divides it — see InvoiceMath::fromCharged() — because
* a document that rebuilds the total from a net figure and a rate states a sum
* nobody was ever charged. That was the whole defect: 179,00 € taken and
* 214,80 € invoiced.
*
* A service invoice an operator types is the other kind. Nothing has been
* charged yet, the figures are what was agreed on the telephone, and they are
* entered net with the VAT put on top exactly as before.
*
* The two never mix on one document, and which one a method produces is stated
* on the method.
*/
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.
*
* Each line is what the order actually COST the customer —
* Order::packageChargedCents(), which is Stripe's own total for the package
* where the order went through a checkout. It used to be `amount_cents` read
* as a net figure, and once the catalogue started charging gross that put the
* VAT on the document twice.
*
* A one-off setup fee charged on the same session gets a line of its own, so
* the document adds up to exactly what the card was charged AND says what each
* part of it was for.
*
* @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' => '',
// The package alone. A first purchase can carry the one-off setup fee
// on the same Stripe session, and it gets a line of its own below —
// folded into this one it would be a charge the customer can see on
// their statement and cannot find on their invoice.
'unit_net_cents' => $order->packageChargedCents(),
])->all();
// The setup fee, once, after the packages. Its own line because it is its
// own thing: charged one time, not monthly, and named on the document with
// the same words the price sheet and the checkout used.
foreach ($orders as $order) {
if ((int) $order->setup_fee_cents <= 0) {
continue;
}
$lines[] = [
'description' => __('checkout.setup_fee_line'),
'details' => [__('invoice.line_once')],
'quantity_milli' => 1000,
'unit' => '',
'unit_net_cents' => (int) $order->setup_fee_cents,
];
}
return $this->issue(
customer: $customer,
lines: $lines,
currency: strtoupper((string) ($orders->first()->currency ?: 'EUR')),
series: $series,
attributes: ['order_id' => $orders->first()->id],
linesAreCharged: true,
);
}
/**
* 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 what the cycle CHARGED. Where Stripe reported a total it is
* that total, to the cent; where it did not, it is the contract's own frozen
* net price put through this customer's treatment, which is the amount the
* Stripe Price this contract is billed on carries — the domestic gross where
* VAT is charged, the bare net for a reverse-charge business. `price_cents`
* itself stays the catalogue's net figure and is not by itself what a document
* totals to.
*
* **The fallback, not the ordinary path.** Modules are items on the Stripe
* subscription now, so what a cycle actually charged for is on Stripe's own
* invoice — package line, every module line, any proration — and
* forStripeInvoice() below builds the document from those. This remains for
* the cycle invoice that arrives carrying no line detail at all, where the
* contract's own frozen price is the honest description of the term that was
* billed. Better a document from the contract than none.
*
* `$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,
?int $chargedCents = 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' => $chargedCents
?? TaxTreatment::for($customer)->chargeCents((int) $subscription->price_cents),
]];
return $this->issue(
customer: $customer,
lines: $lines,
currency: strtoupper((string) ($subscription->currency ?: 'EUR')),
series: $series,
linesAreCharged: true,
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,
],
);
}
/**
* One document for one paid Stripe invoice, carrying every line Stripe
* charged for.
*
* This is the ordinary path now, and it replaces a rule that had stopped
* being true. A cycle invoice used to be one line at the contract's frozen
* price, because that was all Stripe billed; modules are items on the
* subscription today, so a cycle carries the package AND every module still
* running, a mid-period booking carries that module's prorated line on its
* own, and an upgrade carries its proration. Each of those is money that was
* taken, so each of them owes the customer a document — and the owner's
* rule is one document per charge, with the package and the modules of one
* cycle together on a single one.
*
* **Built from Stripe's lines, not from our snapshot.** Stripe is what
* actually charged the customer: it worked the pro-rata out against its own
* period boundaries and its own view of what is already on the account, and
* a document assembled from the contract would state a sum that is not the
* one taken. Only the WORDING is ours — see StripeInvoiceLines, which names
* each line from the catalogue instead of printing a Price id.
*
* **Charged in, split here.** Our catalogue is pushed to Stripe as GROSS, so
* the line amounts that come back are what the customer actually paid, and
* the document divides that sum into net and VAT rather than adding a rate
* on top of it. That distinction is not cosmetic: Stripe prorates a term by
* days and hands back figures that are not the gross of any whole-cent net,
* and a document rebuilt from a net figure would miss those by a cent.
*
* @param array<int, array<string, mixed>> $lines Stripe's `lines.data`
* @param array<int, string> $unnamed filled with the lines the catalogue could not name
*/
public function forStripeInvoice(
Subscription $subscription,
string $stripeInvoiceId,
array $lines,
?string $currency = null,
?InvoiceSeries $series = null,
array &$unnamed = [],
): Invoice {
$customer = $subscription->customer;
if ($customer === null) {
throw new RuntimeException('Refusing to issue an invoice for a contract with nobody on it.');
}
$built = app(StripeInvoiceLines::class)->build($lines);
$unnamed = $built['unnamed'];
if ($built['lines'] === []) {
throw new RuntimeException('Refusing to issue an invoice with no lines on it.');
}
return $this->issue(
customer: $customer,
lines: $built['lines'],
currency: strtoupper((string) ($currency ?: $subscription->currency ?: 'EUR')),
series: $series,
linesAreCharged: true,
attributes: [
'subscription_id' => $subscription->id,
// Left null for the same reason as on a renewal: the contract's
// opening order is the first purchase and already has its own
// document.
'order_id' => null,
'stripe_invoice_id' => $stripeInvoiceId,
],
);
}
/**
* 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, NET, with the VAT put on top — the
* one door into this class that still works that way, and deliberately. A
* catalogue sale has already been charged a gross figure that the document
* has to divide; a service invoice charges nothing yet, and the console page
* asks the operator for net amounts because that is what was agreed on the
* telephone.
*
* @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: [],
);
}
/**
* Take an issued invoice back, by issuing the document that says so.
*
* The only lawful way to undo an invoice. Nothing is edited and nothing is
* deleted — an issued number has been on a document somebody has, and it can
* never be reused or withdrawn — so the original stays exactly as it is and
* a SECOND document, in the Storno series with its own gapless number,
* states the same figures with the opposite sign. `cancels_invoice_id` is
* what ties the pair together.
*
* Mirrored from the original's own snapshot rather than rebuilt from
* today's settings: a cancellation has to state the same issuer, the same
* recipient, the same lines and above all the same TAX RATE as the document
* it cancels. Recomputing the rate would produce a cancellation that does
* not cancel — a 20 % invoice taken back at 21 % leaves a cent standing for
* ever.
*
* Only the meta changes: its own number, today's date, the Storno wording
* and a reference to the number being taken back. The reverse-charge note
* is carried over with everything else, because a zero-rated cancellation
* needs the same justification on it that the zero-rated invoice did.
*
* Refuses to cancel a cancellation. Two Stornos of one invoice would take
* the same money back twice on paper, and a Storno of a Storno is a
* re-issue, which is a new invoice and not this.
*/
public function cancelling(Invoice $original, ?InvoiceSeries $series = null): Invoice
{
$customer = $original->customer;
if ($customer === null) {
throw new RuntimeException('Refusing to cancel an invoice with nobody on it.');
}
if ($original->cancels_invoice_id !== null) {
throw new RuntimeException('Refusing to cancel a cancellation: re-issue the document instead.');
}
if (Invoice::query()->where('cancels_invoice_id', $original->id)->exists()) {
throw new RuntimeException("Invoice {$original->number} has already been cancelled.");
}
$series ??= InvoiceSeries::query()->where('kind', 'cancellation')->where('active', true)->firstOrFail();
$snapshot = (array) $original->snapshot;
$rate = (int) ($snapshot['totals']['rate_basis_points'] ?? 0);
// Every line at its own price with the sign turned over. Quantities are
// left alone: a negative quantity of a positive price adds up to the
// same figure and reads, on the page, as though we had delivered minus
// one of something.
$lines = array_map(function (array $line) {
$line['unit_net_cents'] = -(int) $line['unit_net_cents'];
return $line;
}, (array) ($snapshot['lines'] ?? []));
// The original's own figures, negated — not recomputed from the lines.
// A document built from a charged amount holds a split that its own
// lines cannot be made to reproduce (the odd cent lives on one of them),
// so recomputing would take back a cent more or less than was invoiced,
// and a cancellation that does not cancel leaves that cent standing for
// ever.
$totals = array_map(
fn ($value) => -(int) $value,
array_diff_key((array) ($snapshot['totals'] ?? []), ['rate_basis_points' => null]),
) + ['subtotal' => 0, 'lines_net' => 0, 'adjustment' => 0, 'net' => 0, 'tax' => 0, 'gross' => 0]
+ ['rate_basis_points' => $rate];
$snapshot['lines'] = $lines;
$snapshot['totals'] = $totals;
$snapshot['meta'] = (array) ($snapshot['meta'] ?? []);
$snapshot['meta']['title'] = __('invoice.cancellation_title');
$snapshot['meta']['intro'] = __('invoice.cancellation_intro', ['number' => (string) $original->number]);
$snapshot['meta']['issued_on'] = now()->local()->format('d.m.Y');
// A Storno asks for no money, so it has no due date and no payment
// terms. Leaving the original's there would tell the customer to pay a
// negative amount within fourteen days.
$snapshot['meta']['due_on'] = null;
$snapshot['meta']['payment_terms'] = null;
return $this->persist(
series: $series,
customer: $customer,
snapshot: $snapshot,
totals: $totals,
currency: (string) $original->currency,
dueOn: null,
attributes: [
'cancels_invoice_id' => $original->id,
// The same sale, so the same order and contract: a cancellation
// that pointed at nothing could not be found from the thing it
// undoes.
'order_id' => $original->order_id,
'subscription_id' => $original->subscription_id,
],
);
}
/**
* @param bool $linesAreCharged true when `unit_net_cents` holds what the
* customer was actually charged, tax included —
* every catalogue sale. False for the service
* invoice an operator types net.
*/
private function issue(
Customer $customer,
array $lines,
string $currency,
?InvoiceSeries $series,
array $attributes,
bool $linesAreCharged = false,
): Invoice {
$series ??= InvoiceSeries::query()->where('kind', 'invoice')->where('active', true)->firstOrFail();
$treatment = TaxTreatment::for($customer);
$rate = $treatment->basisPoints();
if ($linesAreCharged) {
// The charged figure is the fixed point, whatever it was: at a rate of
// zero the whole of it becomes net rather than the document shrinking
// by a VAT the price never contained. That is what keeps the total
// equal to the money for a reverse-charge business, who is charged the
// bare net and whose document therefore states that same net at 0 %.
['lines' => $lines, 'totals' => $totals] = InvoiceMath::fromCharged($lines, $rate);
$totals += ['rate_basis_points' => $rate];
} else {
$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 $this->persist(
series: $series,
customer: $customer,
snapshot: $snapshot,
totals: $totals,
currency: $currency,
dueOn: now()->addDays((int) CompanyProfile::get('payment_days', 14))->toDateString(),
attributes: $attributes,
);
}
/**
* The part that is the same for every document there will ever be: refuse
* while the company details are incomplete, take the number, write the row,
* copy it to the archive.
*
* One method rather than one per kind, because the number and the row have
* to commit together — and a second copy of that rule is how one kind of
* document would end up leaving a gap in a series the other kind keeps
* intact.
*
* @param array<string, mixed> $snapshot
* @param array<string, int> $totals
* @param array<string, mixed> $attributes
*/
private function persist(
InvoiceSeries $series,
Customer $customer,
array $snapshot,
array $totals,
string $currency,
?string $dueOn,
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)
);
}
$invoice = DB::transaction(function () use ($series, $customer, $snapshot, $totals, $currency, $dueOn, $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' => $dueOn,
'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,
];
}
}