150 lines
5.5 KiB
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;
|
|
}
|
|
}
|