171 lines
6.3 KiB
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;
|
|
}
|
|
}
|