$metadata what the create call would send * @param array $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 $found * @param array $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 $found * @param array $expected * @param array $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; } }