520 lines
23 KiB
PHP
520 lines
23 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.
|
|
*
|
|
* **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,
|
|
): 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,
|
|
],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* **Net in, tax on top.** Our catalogue is pushed to Stripe as NET, so the
|
|
* line amounts that come back are net and the document adds VAT from the
|
|
* customer's own treatment. Nothing here reads `amount_paid`, which is a
|
|
* gross total and belongs to the proof register.
|
|
*
|
|
* @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,
|
|
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. 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: [],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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'] ?? []));
|
|
|
|
$totals = InvoiceMath::totals($lines, null, $rate) + ['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,
|
|
],
|
|
);
|
|
}
|
|
|
|
private function issue(
|
|
Customer $customer,
|
|
array $lines,
|
|
string $currency,
|
|
?InvoiceSeries $series,
|
|
array $attributes,
|
|
): Invoice {
|
|
$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),
|
|
];
|
|
|
|
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,
|
|
];
|
|
}
|
|
}
|