CluPilotCloud/app/Services/Billing/AdoptStripePrice.php

226 lines
9.5 KiB
PHP

<?php
namespace App\Services\Billing;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* The Stripe Price we were about to create and Stripe already has.
*
* An abandoned run leaves an orphan: Stripe made the Price, our insert never
* happened, and the table that decides everything afterwards does not know it
* exists. The idempotency key covers the next twenty-four hours; after that it
* covers nothing, and the run that follows makes a SECOND live Price for the
* same money — precisely the duplicate the key was there to prevent. It happened
* on 2026-07-29 with priority_support at 3480 EUR.
*
* So before minting, we ask. Nothing else in the catalogue ever did.
*
* **What may be taken over, and why so narrowly.** Same amount, same currency,
* same interval — a Price at another figure would move money, and moving money
* is what this must never do: a running contract keeps the Price it was sold on
* and every booking stays frozen at what it cost that day. Then proof: the
* candidate must not contradict our metadata, and it must confirm at least one
* identifying key of it. An active Price at the right money on our own Product
* with no metadata at all is what a person clicking through Stripe's dashboard
* leaves behind, and adopting that would be worse than minting a second one.
* Finally it must be unclaimed — a Price id belongs to one row, which is what
* the unique index on both registers now enforces.
*
* **Several orphans.** The oldest is adopted, because it is the one a lost row
* is likeliest to have been billing on, and the others are archived at Stripe.
* Archiving stops a Price being SOLD and leaves every subscription on it exactly
* where it was — the same grandfathering archiveSuperseded() has always relied
* on — so the rule "one live Price per figure" is restored without touching a
* single contract.
*
* **Reported through the log, not the console.** AddonPrices::ensure() runs
* inside a customer's module booking as well as in the sweep, and there is no
* command line there.
*/
final class AdoptStripePrice
{
/**
* How many Prices this run took over rather than minted.
*
* Read by stripe:sync-catalogue, which counts an intent BEFORE calling
* ensure() and so cannot tell the two apart on its own — comparing the Price
* id before and after does not distinguish them either, because there is no
* id before in either case.
*/
public int $adoptions = 0;
public function __construct(private readonly StripeClient $stripe) {}
/**
* The id of an existing Stripe Price to use instead of creating one, or null.
*
* @param array<string, string> $metadata what the create call would send
* @param array<int, string> $identifying metadata keys that mark a Price as ours
* @param callable(string): bool $claimed is this Price id already in our register?
*/
public function __invoke(
string $productId,
int $amountCents,
string $currency,
string $interval,
array $metadata,
array $identifying,
callable $claimed,
): ?string {
// Stripe hands metadata back as strings. An un-cast int here would then
// never satisfy confirms()'s strict ===, silently disabling adoption for
// that Price forever and warning on every sweep and every booking. One
// cast here makes the class independent of what a caller happens to pass.
$metadata = array_map(fn ($value) => (string) $value, $metadata);
$candidates = [];
foreach ($this->stripe->activePricesFor($productId) as $price) {
// The money gate, and the whole of it — this class promises that no
// adoption can move money, and until these four moved here it could
// compare only the first three. A Price at our figure with
// interval_count 3 bills quarterly; one with transform_quantity
// divide_by 10 charges a customer holding three packs for one; a
// metered or tiered one does not charge an amount at all. Stripe's
// dashboard copies metadata when it duplicates a Price, so such a
// Price can carry our own identifying key and be indistinguishable
// from ours by every other test here.
if ($price['unit_amount'] !== $amountCents
|| $price['currency'] !== strtoupper($currency)
|| $price['interval'] !== $interval
|| $price['interval_count'] !== 1
|| $price['usage_type'] !== 'licensed'
|| $price['transform_quantity']
|| $price['billing_scheme'] !== 'per_unit') {
continue;
}
if ($claimed($price['id'])) {
continue;
}
// Contradicts on something it carries: another Price of ours, not a
// mystery. Silent — at a VAT rate of nought the two treatments share
// an amount, and this would otherwise warn on every sweep.
if ($this->contradicts($price['metadata'], $metadata)) {
continue;
}
if (! $this->confirms($price['metadata'], $metadata, $identifying)) {
// Once a day per Price. An orphan nobody can adopt is a STATE,
// not an event, and this runs in a customer's module booking as
// well as in the sweep — without a throttle it writes a line
// every time anybody books anything, for as long as the orphan
// exists, and the clean-up command that would end it is a
// separate one. Cache::add is this repo's throttle; see
// App\Livewire\Billing.
if (Cache::add('stripe:unexplained-price:'.$price['id'], true, now()->addDay())) {
Log::warning('stripe: left an unexplained active price alone rather than adopting it', [
'price' => $price['id'],
'product' => $productId,
'amount_cents' => $amountCents,
'currency' => strtoupper($currency),
'interval' => $interval,
]);
}
continue;
}
$candidates[] = $price;
}
if ($candidates === []) {
return null;
}
// Oldest first, and on a tie the lower id — so the same two orphans give
// the same answer on every run, whatever order Stripe lists them in.
usort($candidates, fn (array $a, array $b) => [$a['created'], $a['id']] <=> [$b['created'], $b['id']]);
$adopted = array_shift($candidates);
foreach ($candidates as $duplicate) {
$this->stripe->archivePrice($duplicate['id']);
Log::warning('stripe: stopped selling a duplicate price for one figure', [
'price' => $duplicate['id'],
'adopted' => $adopted['id'],
'product' => $productId,
'amount_cents' => $amountCents,
]);
}
// Only when it differs, so a sweep over a healthy catalogue makes no
// writes at Stripe at all. Compared through array_intersect_key() rather
// than a bare !==, for two reasons: Stripe MERGES a metadata write rather
// than replacing it, so a Price can carry a key of its own that a write
// would never remove and a bare !== would then never call equal — and
// PHP's array !== is key-order sensitive, so Stripe handing the same
// values back in a different order would trigger a write every single
// sweep. ksort() on both sides settles the order; the intersect settles
// the extra key, by looking only at what WE would send.
$overlap = array_intersect_key($adopted['metadata'], $metadata);
$wanted = $metadata;
ksort($overlap);
ksort($wanted);
if ($overlap !== $wanted) {
$this->stripe->updatePriceMetadata($adopted['id'], $metadata);
}
Log::info('stripe: adopted an existing price instead of creating a second one', [
'price' => $adopted['id'],
'product' => $productId,
'amount_cents' => $amountCents,
'currency' => strtoupper($currency),
'interval' => $interval,
]);
$this->adoptions++;
return $adopted['id'];
}
/**
* Does this Price say something about itself that we do not?
*
* @param array<string, string> $found
* @param array<string, string> $expected
*/
private function contradicts(array $found, array $expected): bool
{
foreach ($expected as $key => $value) {
if (array_key_exists($key, $found) && $found[$key] !== $value) {
return true;
}
}
return false;
}
/**
* Does it prove it is ours?
*
* Failing to contradict is not enough — an empty metadata bag contradicts
* nothing. At least one identifying key has to be there and agree.
*
* @param array<string, string> $found
* @param array<string, string> $expected
* @param array<int, string> $identifying
*/
private function confirms(array $found, array $expected, array $identifying): bool
{
foreach ($identifying as $key) {
if (isset($found[$key]) && $found[$key] === ($expected[$key] ?? null)) {
return true;
}
}
return false;
}
}