214 lines
9.1 KiB
PHP
214 lines
9.1 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
|
|
{
|
|
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,
|
|
]);
|
|
|
|
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;
|
|
}
|
|
}
|