CluPilotCloud/app/Services/Billing/StripeInvoiceLines.php

210 lines
8.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?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, which is not the same figure for everybody: our
* catalogue is pushed to Stripe twice, at the domestic gross and at the bare net,
* and the Price a contract is billed on decides which of the two its lines carry.
* IssueInvoice divides the sum according to the customer's own treatment rather
* than adding a rate to it — so a domestic line is split into net and VAT, and a
* reverse-charge line, which contained no VAT to begin with, becomes net at 0 %.
* Either way the document totals to the money. See TaxTreatment.
*
* Naming a line is unaffected by there being two Prices per sellable thing: a
* Price id is unique in both registers, so it resolves to one catalogue row
* whichever of the pair it is.
*/
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;
}
}