204 lines
8.3 KiB
PHP
204 lines
8.3 KiB
PHP
<?php
|
||
|
||
namespace App\Services\Billing;
|
||
|
||
use App\Models\PlanFamily;
|
||
use App\Models\PlanPrice;
|
||
use App\Models\StripeAddonPrice;
|
||
use App\Models\StripePlanPrice;
|
||
use Illuminate\Support\Carbon;
|
||
|
||
/**
|
||
* Stripe's own invoice lines, turned into lines a customer can read.
|
||
*
|
||
* The document is built from what Stripe CHARGED, not from our snapshot of what
|
||
* the contract says. Those two agree almost always and the exception is the
|
||
* whole point: a proration, a module booked on the eleventh, a quantity changed
|
||
* mid-term — Stripe worked each of those out against its own period boundaries,
|
||
* and a document assembled from our figures would state a sum that is not the
|
||
* one taken. A document that does not match the money is the one thing that must
|
||
* never happen.
|
||
*
|
||
* What is ours is the WORDING. Stripe's line descriptions are its own ("1 ×
|
||
* price_1QxYz (at €10.00 / month)"), and the customer register is where a module
|
||
* has a name — so each line is matched back to the catalogue by its Price id and
|
||
* named from the customer register in the language files.
|
||
*
|
||
* **A line we cannot name is kept.** It is carried with whatever description
|
||
* Stripe sent and reported to the caller, which logs it. A document missing a
|
||
* line that was charged is worse than an ugly one: the total would still be
|
||
* right and the reader would have no way to see what they had paid for.
|
||
*
|
||
* Amounts are what was CHARGED — our catalogue is pushed to Stripe gross, so
|
||
* every figure here already contains the VAT. IssueInvoice divides the sum
|
||
* according to the customer's own treatment rather than adding a rate to it; a
|
||
* reverse-charge customer paid the same total as everybody else and their
|
||
* document states the whole of it as net at 0 %. See TaxTreatment.
|
||
*/
|
||
final class StripeInvoiceLines
|
||
{
|
||
/**
|
||
* @param array<int, array<string, mixed>> $lines Stripe's `lines.data`
|
||
* @return array{lines: array<int, array<string, mixed>>, unnamed: array<int, string>}
|
||
*/
|
||
public function build(array $lines): array
|
||
{
|
||
$documentLines = [];
|
||
$unnamed = [];
|
||
|
||
foreach ($lines as $line) {
|
||
$amount = (int) ($line['amount'] ?? 0);
|
||
$quantity = max(1, (int) ($line['quantity'] ?? 1));
|
||
$priceId = $this->priceId($line);
|
||
$description = $this->name($priceId, $line);
|
||
|
||
if ($description === null) {
|
||
$unnamed[] = $priceId ?? (string) ($line['id'] ?? 'unknown');
|
||
$description = $this->fallbackName($line);
|
||
}
|
||
|
||
// The unit price only when it divides cleanly. Stripe's line amount
|
||
// is what was charged, and it is the figure the document has to
|
||
// total to — so a quantity that does not divide it exactly is shown
|
||
// as one line at the full amount rather than as a unit price the
|
||
// reader could multiply and get a different answer. InvoiceMath
|
||
// applies the same rule again once the amount has been split into
|
||
// net, which is the figure actually printed.
|
||
$unit = intdiv($amount, $quantity);
|
||
$divides = $unit * $quantity === $amount;
|
||
|
||
$documentLines[] = [
|
||
'description' => $description,
|
||
'details' => $this->details($line),
|
||
'quantity_milli' => $divides ? $quantity * 1000 : 1000,
|
||
'unit' => '',
|
||
'unit_net_cents' => $divides ? $unit : $amount,
|
||
];
|
||
}
|
||
|
||
return ['lines' => $documentLines, 'unnamed' => $unnamed];
|
||
}
|
||
|
||
/**
|
||
* The Stripe Price a line billed against.
|
||
*
|
||
* Two shapes, because Stripe moved it: older invoice lines carry `price`
|
||
* inline, newer ones put it under `pricing.price_details`. Both are read
|
||
* rather than one, so an account on either API version is named correctly.
|
||
*/
|
||
private function priceId(array $line): ?string
|
||
{
|
||
$id = $line['price']['id']
|
||
?? $line['pricing']['price_details']['price']
|
||
?? null;
|
||
|
||
return is_string($id) && $id !== '' ? $id : null;
|
||
}
|
||
|
||
/**
|
||
* What this line is, in the words the customer knows it by. Null when the
|
||
* catalogue has never heard of it.
|
||
*/
|
||
private function name(?string $priceId, array $line): ?string
|
||
{
|
||
$metadata = (array) ($line['price']['metadata'] ?? []);
|
||
|
||
// A module, by the Price we minted for it. Archived Prices are in that
|
||
// table too and are looked up exactly like live ones — a contract keeps
|
||
// billing on the Price it was put on until it is deliberately moved, and
|
||
// a line nobody can name is the one failure this class exists to avoid.
|
||
// The metadata is the same answer from the other direction and covers a
|
||
// Price created against this account before the table existed.
|
||
$addonKey = $priceId === null
|
||
? null
|
||
: StripeAddonPrice::query()->where('stripe_price_id', $priceId)->value('addon_key');
|
||
|
||
$addonKey ??= is_string($metadata['addon'] ?? null) ? $metadata['addon'] : null;
|
||
|
||
if (is_string($addonKey) && app(AddonCatalogue::class)->knows($addonKey)) {
|
||
return app(AddonCatalogue::class)->name($addonKey);
|
||
}
|
||
|
||
// The package, by the priced row stripe:sync-catalogue pushed. Named by
|
||
// FAMILY rather than by the version number: "Paket Team" is what the
|
||
// customer bought, and the version is a fact about our catalogue.
|
||
//
|
||
// `plan_prices.stripe_price_id` only ever holds the Price on sale right
|
||
// now, so a contract still billing on a superseded one is found through
|
||
// `stripe_plan_prices`, which keeps every Price a row has ever had.
|
||
$planPrice = $priceId === null ? null : PlanPrice::query()
|
||
->where('stripe_price_id', $priceId)
|
||
->with('version.family')
|
||
->first();
|
||
|
||
if ($planPrice === null && $priceId !== null) {
|
||
$planPrice = StripePlanPrice::query()
|
||
->where('stripe_price_id', $priceId)
|
||
->with('planPrice.version.family')
|
||
->first()?->planPrice;
|
||
}
|
||
|
||
$family = $planPrice?->version?->family;
|
||
|
||
$family ??= is_string($metadata['plan_family'] ?? null)
|
||
? PlanFamily::query()->where('key', $metadata['plan_family'])->first()
|
||
: null;
|
||
|
||
return $family === null ? null : __('billing.cart.plan', ['plan' => $family->name]);
|
||
}
|
||
|
||
/**
|
||
* The best we can say about a line the catalogue does not know.
|
||
*
|
||
* Stripe's own description if it sent one — it is at least true — and
|
||
* otherwise a sentence saying plainly that this is a charge from the payment
|
||
* provider. Never nothing: the line stays on the document either way.
|
||
*/
|
||
private function fallbackName(array $line): string
|
||
{
|
||
$description = $line['description'] ?? null;
|
||
|
||
return is_string($description) && trim($description) !== ''
|
||
? trim($description)
|
||
: __('invoice.line_unnamed');
|
||
}
|
||
|
||
/**
|
||
* The small print under a line: which term it covers, and whether it was
|
||
* worked out pro rata.
|
||
*
|
||
* @return array<int, string>
|
||
*/
|
||
private function details(array $line): array
|
||
{
|
||
$details = [];
|
||
|
||
$from = $line['period']['start'] ?? null;
|
||
$to = $line['period']['end'] ?? null;
|
||
|
||
if (is_numeric($from) && is_numeric($to)) {
|
||
$start = Carbon::createFromTimestamp((int) $from);
|
||
$end = Carbon::createFromTimestamp((int) $to);
|
||
|
||
$details[] = __('invoice.line_period', [
|
||
'from' => $start->local()->format('d.m.Y'),
|
||
// Stripe's period end is the EXCLUSIVE boundary — the first
|
||
// moment of the next term, which the next invoice claims in
|
||
// turn. Printed as it comes, the same day would appear on two
|
||
// consecutive documents.
|
||
'to' => $end->greaterThan($start)
|
||
? $end->copy()->subDay()->local()->format('d.m.Y')
|
||
: $end->local()->format('d.m.Y'),
|
||
]);
|
||
}
|
||
|
||
// Said out loud, because a customer looking at a figure that is not the
|
||
// monthly price they know has to be able to see why.
|
||
if (($line['proration'] ?? false) === true) {
|
||
$details[] = __('invoice.line_prorated');
|
||
}
|
||
|
||
return $details;
|
||
}
|
||
}
|