> $lines Stripe's `lines.data` * @return array{lines: array>, unnamed: array} */ 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 */ 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; } }