112 lines
4.4 KiB
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);
|
|
}
|
|
}
|