CluPilotCloud/app/Services/Stripe/IdempotencyKey.php

112 lines
4.4 KiB
PHP

<?php
namespace App\Services\Stripe;
/**
* The Idempotency-Key header, formed so that it cannot lie.
*
* Stripe replays the first response for a repeated key — but only if the call
* is repeated exactly. A key that comes back with so much as one added metadata
* field is answered with HTTP 400 and stays poisoned for twenty-four hours.
*
* That happened on 2026-07-29: a run created a Price and died before the row was
* written, 9da1358 then added the `tax_treatment` metadata field, and the next
* sweep sent yesterday's key with today's parameters. The sweep was blocked —
* and so was every module booking, because AddonPrices::ensure() runs inside one.
*
* The mistake was conceptual. A key does not state what an object IS; identity
* is the Price's product, amount, currency and interval, and it is
* AdoptStripePrice that recognises those. A key states "I have already sent
* this call", so everything the call sends belongs in it — which is what
* fingerprint() folds in.
*
* **Only for the catalogue calls.** createPrice() and createProduct(): a
* duplicate there is a Stripe object nothing bills on, recoverable, while a
* blockade reaches a paying customer. For refund(), cancelSubscription(),
* addSubscriptionItem() and createCheckoutSession() it is the other way round —
* a second object is the customer's money taken twice or a package billed
* twice — so those keep their bare key and Stripe's refusal with it.
*/
final class IdempotencyKey
{
/** The key for a Price, carrying everything createPrice() puts on the wire. */
public static function forPrice(
?string $key,
string $productId,
int $amountCents,
string $currency,
string $interval,
array $metadata,
): ?string {
return self::with($key, self::priceParameters($productId, $amountCents, $currency, $interval, $metadata));
}
/** The key for a Product, for the same reason and against the same trap. */
public static function forProduct(?string $key, string $name, array $metadata): ?string
{
return self::with($key, self::productParameters($name, $metadata));
}
/**
* What counts as "the same call" for a Price — public because
* FakeStripeClient has to ask this exact question too, to decide whether a
* repeated key is a retry or a changed call. Built inline a second time in
* the fake, it would only ever match this by coincidence, and a later
* change to what a Price call fingerprints could drift from what the fake
* detects without a single test noticing — which is exactly the gap this
* task exists to close.
*/
public static function priceParameters(
string $productId,
int $amountCents,
string $currency,
string $interval,
array $metadata,
): array {
return [
'product' => $productId,
'unit_amount' => $amountCents,
'currency' => strtolower($currency),
'interval' => $interval,
'metadata' => $metadata,
];
}
/** What counts as "the same call" for a Product — same reason as priceParameters(). */
public static function productParameters(string $name, array $metadata): array
{
return ['name' => $name, 'metadata' => $metadata];
}
/**
* Eight hex characters over the parameters, canonically ordered.
*
* Ordered, because PHP keeps insertion order and two callers writing the
* same metadata in a different order would otherwise send two keys for one
* call — which would mint two Prices and be a worse bug than the one this
* closes.
*/
public static function fingerprint(array $parameters): string
{
return substr(sha1(self::canonical($parameters)), 0, 8);
}
private static function with(?string $key, array $parameters): ?string
{
// Null stays null: a caller who sends no key wants no replay, and
// inventing one here would change what the call means.
return $key === null ? null : $key.'-'.self::fingerprint($parameters);
}
private static function canonical(array $parameters): string
{
ksort($parameters);
foreach ($parameters as $name => $value) {
$parameters[$name] = is_array($value) ? self::canonical($value) : (string) $value;
}
return json_encode($parameters, JSON_THROW_ON_ERROR);
}
}