CluPilotCloud/app/Services/Billing/AdoptStripePrice.php

171 lines
6.3 KiB
PHP

<?php
namespace App\Services\Billing;
use App\Services\Stripe\StripeClient;
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 {
$candidates = [];
foreach ($this->stripe->activePricesFor($productId) as $price) {
if ($price['unit_amount'] !== $amountCents
|| $price['currency'] !== strtoupper($currency)
|| $price['interval'] !== $interval) {
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)) {
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;
}
usort($candidates, fn (array $a, array $b) => $a['created'] <=> $b['created']);
$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.
if ($adopted['metadata'] !== $metadata) {
$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;
}
}