CluPilotCloud/app/Services/Billing/AdoptStripeProduct.php

150 lines
5.5 KiB
PHP

<?php
namespace App\Services\Billing;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Facades\Log;
/**
* The Stripe Product we were about to create and Stripe already has.
*
* The sibling of AdoptStripePrice, one level up, and the gap that made that one
* fragile. A run that creates a Product and dies before storing its id leaves an
* orphan; the next run past the idempotency key's twenty-four hours makes a
* second Product — and from then on activePricesFor() is asked about the new
* one, so the Price recognition goes blind for the whole family and every
* orphaned Price on the first Product is unreachable.
*
* **No money gate.** A Product carries no amount, no interval and no recurrence,
* so there is nothing here that could move money and nothing to compare but the
* metadata. That is the whole of the proof: a Product must CONFIRM an
* identifying key of ours and contradict none it carries.
*
* **A duplicate is reported, never deactivated.** This is the one place the
* Price side's reasoning does not carry over. Archiving a Price is provably
* harmless — Stripe goes on billing every subscription already on it, which is
* what the grandfathering rule has always rested on. Deactivating a Product
* makes its PRICES unsellable, and contracts can be running on those. What was
* proven for one is not proven for the other, so it is not done.
*
* **Silent about strangers.** Unlike the Price side, which asks only about our
* own Product and warns when something unexplained sits there, this asks for
* every active Product on the account. An operator selling something else
* through the same account is an ordinary state, not a finding, and warning
* about it would fire on every run for ever.
*/
final class AdoptStripeProduct
{
/**
* Products that matched but were not taken, oldest-first order having
* decided against them. Read by stripe:sync-catalogue for its report — a
* log line alone is not seen by the operator running the sweep.
*
* @var array<int, string>
*/
public array $duplicates = [];
/**
* How many Products this run took over rather than minted.
*
* Mirrors AdoptStripePrice::$adoptions, and for the same reason:
* stripe:sync-catalogue counts an intent to create a Product BEFORE it
* knows whether this class will hand back an existing one, so a Product
* that was adopted must not silently count as one that was created.
*/
public int $adoptions = 0;
public function __construct(private readonly StripeClient $stripe) {}
/**
* The id of an existing Stripe Product to use instead of creating one.
*
* @param array<string, string> $metadata what the create call would send
* @param array<int, string> $identifying metadata keys that mark a Product as ours
*/
public function __invoke(array $metadata, array $identifying): ?string
{
$metadata = array_map(fn ($value) => (string) $value, $metadata);
$candidates = [];
foreach ($this->stripe->activeProducts() as $product) {
if ($this->contradicts($product['metadata'], $metadata)) {
continue;
}
if (! $this->confirms($product['metadata'], $metadata, $identifying)) {
continue;
}
$candidates[] = $product;
}
if ($candidates === []) {
return null;
}
// Oldest first, and on a tie the lower id, so two runs agree. The tie is
// not merely theoretical: createProduct()'s counter and plantProduct()'s
// default both start at 1, so a minted Product and a planted orphan can
// share a `created` — the same collision AdoptStripePrice's usort breaks
// the same way, documented at FakeStripeClient::createPrice().
usort($candidates, fn (array $a, array $b) => [$a['created'], $a['id']] <=> [$b['created'], $b['id']]);
$adopted = array_shift($candidates);
foreach ($candidates as $duplicate) {
$this->duplicates[] = $duplicate['id'];
Log::warning('stripe: a second product claims to be the same thing — left alone, not deactivated', [
'product' => $duplicate['id'],
'adopted' => $adopted['id'],
'metadata' => $metadata,
]);
}
Log::info('stripe: adopted an existing product instead of creating a second one', [
'product' => $adopted['id'],
'metadata' => $metadata,
]);
$this->adoptions++;
return $adopted['id'];
}
/**
* @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;
}
/**
* Failing to contradict is not enough — an empty metadata bag contradicts
* nothing, and this list holds every Product on the account.
*
* @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;
}
}