*/ public array $products = []; /** @var array */ public array $prices = []; /** @var array */ public array $archived = []; /** * Every Price that was put back on sale, in order — a rate that moved and * moved back reaches for the Price it already has rather than minting a * second one for the same amount. * * @var array */ public array $activated = []; /** * Every metadata write, in order, so a test can assert that an adopted * orphan was brought up to today's metadata instead of being replaced. * * @var array}> */ public array $metadataUpdates = []; /** * Idempotency key → what was first answered for it, and a fingerprint of * what it was first sent with. * * The fingerprint is the half that was missing. This ledger replayed the * first id for a repeated key without ever looking at what the second call * was asking for — so it was unrealistic in precisely the way that mattered, * and no test could see the 2026-07-29 blockade. * * @var array */ public array $keys = []; /** * What Stripe would answer when asked which item a subscription bills * through: subscription id → item id. * * @var array */ public array $items = []; /** * Every price swap that was asked for, in order, so a test can assert the * proration behaviour as well as the price. * * @var array */ public array $priceChanges = []; /** * The items on each subscription as this Stripe sees them: item id → what * it bills. Modules are added and removed here, so a test can assert how * many items a contract carries and at which quantity. * * @var array */ public array $subscriptionItems = []; /** * Every item call that was made, in order, so a test can assert the * proration behaviour as well as the outcome. * * @var array> */ public array $itemCalls = []; /** * The lines this Stripe would report for an invoice: invoice id → lines. * Only for the invoice whose lines an event did not carry in full. * * @var array>> */ public array $invoiceLines = []; /** * Every order to stop billing, in order, so a test can assert WHEN a * contract was cancelled as well as that it was — the difference between a * customer keeping the term they paid for and losing it. * * @var array */ public array $cancellations = []; /** * Every refund that was sent, in order, so a test can assert the amount as * well as the fact of it. * * @var array */ public array $refunds = []; /** * What this Stripe would answer when asked which payment an invoice was * settled by: invoice id → payment reference. * * @var array */ public array $invoicePayments = []; /** * Set to make every call fail, the way an outage, a revoked key or a * network without a route out does. */ public ?string $failWith = null; /** * Every checkout that was opened, in order. * * `one_off` is the setup-fee line, or null where none was sent — a test has to * be able to assert both that it is charged and that it is absent when the * operator has set no fee. * * @var array}> */ public array $checkouts = []; public function isConfigured(): bool { return $this->configured; } public function createCheckoutSession( string $priceId, string $successUrl, string $cancelUrl, array $metadata = [], ?string $customerEmail = null, ?string $idempotencyKey = null, ?array $oneOff = null, ): string { $this->failIfAsked(); $this->checkouts[] = [ 'price' => $priceId, 'success' => $successUrl, 'cancel' => $cancelUrl, 'metadata' => $metadata, 'email' => $customerEmail, 'one_off' => $oneOff, ]; return 'https://checkout.stripe.test/session/'.count($this->checkouts); } public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string { $key = IdempotencyKey::forProduct($idempotencyKey, $name, $metadata); // The same parameters forProduct() just fingerprinted, asked for // through the one method that knows what they are — a local literal // here could drift from what the key actually covers without a test // ever seeing it. $parameters = IdempotencyKey::productParameters($name, $metadata); // Replays the first answer for a repeated key, as Stripe does. $replayed = $this->replay($key, $parameters); if ($replayed !== null) { return $replayed; } $id = 'prod_'.substr(sha1($name.count($this->products)), 0, 12); $this->products[$id] = ['name' => $name, 'metadata' => $metadata]; $this->rememberKey($key, $id, $parameters); return $id; } public function createPrice( string $productId, int $amountCents, string $currency, string $interval, array $metadata = [], ?string $idempotencyKey = null, ): string { $key = IdempotencyKey::forPrice($idempotencyKey, $productId, $amountCents, $currency, $interval, $metadata); // Same reasoning as createProduct(): the parameters forPrice() just // fingerprinted, not a second literal that has to be kept in step by // hand. $parameters = IdempotencyKey::priceParameters($productId, $amountCents, $currency, $interval, $metadata); $replayed = $this->replay($key, $parameters); if ($replayed !== null) { return $replayed; } $id = 'price_'.substr(sha1($productId.$amountCents.$interval.count($this->prices)), 0, 12); $this->prices[$id] = [ 'product' => $productId, 'amount' => $amountCents, 'currency' => $currency, 'interval' => $interval, 'metadata' => $metadata, // Stripe stamps every object with its creation time and // AdoptStripePrice takes the OLDEST of several orphans. Counted // rather than clocked, because two Prices minted in the same second // would share a timestamp — but the two counters are independent: // plantPrice() stamps `created: 1` of its own accord, which ties with // a Price minted here while this list was still empty. Any test whose // outcome depends on which of two prices is older passes `created:` // itself, as the adoption tests do. 'created' => count($this->prices) + 1, ]; $this->rememberKey($key, $id, $parameters); return $id; } public function archivePrice(string $priceId): void { $this->archived[] = $priceId; } public function activatePrice(string $priceId): void { // Taken off the archived list rather than merely appended to a second // one, so a test asking "is this Price still being sold?" gets the // answer Stripe would give instead of a history it has to interpret. $this->archived = array_values(array_filter( $this->archived, fn (string $id) => $id !== $priceId, )); $this->activated[] = $priceId; } public function activePricesFor(string $productId): array { // No failIfAsked(): createPrice(), the call this one stands in front of, // does not fail either, and a test that scripts an outage for a refund // must not have its catalogue sync change behaviour underneath it. // // None of HttpStripeClient's charging-property filter either — the four // fields it names (interval_count, usage_type, transform_quantity, // billing_scheme). This fake only ever holds prices its own // createPrice()/plantPrice() put here, and neither takes an argument for // any of them: there is nothing non-standard a test could plant, so the // filter would have nothing to do. Not an oversight. What that filter // does is proven where it lives, over Http::fake — see // tests/Feature/Billing/StripeIdempotencyKeyTest.php. $found = []; foreach ($this->prices as $id => $price) { if ($price['product'] !== $productId || in_array($id, $this->archived, true)) { continue; } $found[] = [ 'id' => $id, 'unit_amount' => $price['amount'], 'currency' => strtoupper($price['currency']), 'interval' => $price['interval'], 'created' => $price['created'], 'metadata' => $price['metadata'], ]; } return $found; } public function updatePriceMetadata(string $priceId, array $metadata): void { if (isset($this->prices[$priceId])) { $this->prices[$priceId]['metadata'] = $metadata; } $this->metadataUpdates[] = ['price' => $priceId, 'metadata' => $metadata]; } /** * A Price that exists at Stripe and in no table of ours — the orphan a run * leaves behind when it dies between Stripe's create and our insert. * * Here rather than in a test file because it is the state the whole adoption * step exists for, and every test that needs it would otherwise reach into * $prices and write the shape by hand. */ public function plantPrice( string $id, string $productId, int $amountCents, string $currency, string $interval, array $metadata = [], int $created = 1, ): void { $this->prices[$id] = [ 'product' => $productId, 'amount' => $amountCents, 'currency' => $currency, 'interval' => $interval, 'metadata' => $metadata, 'created' => $created, ]; } public function updateSubscriptionPrice( string $subscriptionId, string $itemId, string $priceId, string $prorationBehaviour, ): void { $this->failIfAsked(); $this->priceChanges[] = [ 'subscription' => $subscriptionId, 'item' => $itemId, 'price' => $priceId, 'proration' => $prorationBehaviour, ]; } public function subscriptionItemId(string $subscriptionId): ?string { $this->failIfAsked(); return $this->items[$subscriptionId] ?? null; } public function addSubscriptionItem( string $subscriptionId, string $priceId, int $quantity, string $prorationBehaviour, ?string $idempotencyKey = null, ): string { $this->failIfAsked(); $id = 'si_'.substr(sha1($subscriptionId.$priceId.count($this->subscriptionItems)), 0, 12); $this->subscriptionItems[$id] = [ 'subscription' => $subscriptionId, 'price' => $priceId, 'quantity' => $quantity, ]; $this->itemCalls[] = [ 'call' => 'add', 'subscription' => $subscriptionId, 'item' => $id, 'price' => $priceId, 'quantity' => $quantity, 'proration' => $prorationBehaviour, ]; return $id; } public function updateSubscriptionItemQuantity( string $itemId, int $quantity, string $prorationBehaviour, ): void { $this->failIfAsked(); if (isset($this->subscriptionItems[$itemId])) { $this->subscriptionItems[$itemId]['quantity'] = $quantity; } $this->itemCalls[] = [ 'call' => 'quantity', 'item' => $itemId, 'quantity' => $quantity, 'proration' => $prorationBehaviour, ]; } public function removeSubscriptionItem(string $itemId, string $prorationBehaviour): void { $this->failIfAsked(); unset($this->subscriptionItems[$itemId]); $this->itemCalls[] = [ 'call' => 'remove', 'item' => $itemId, 'proration' => $prorationBehaviour, ]; } public function cancelSubscription( string $subscriptionId, string $when, ?string $idempotencyKey = null, ): void { $this->failIfAsked(); // Refused here as well as in HttpStripeClient, so a caller that invents a // third timing fails in the tests rather than in production. if (! in_array($when, [self::CANCEL_AT_PERIOD_END, self::CANCEL_IMMEDIATELY], true)) { throw new RuntimeException("Unknown cancellation timing: {$when}"); } // No fingerprint: for a subscription cancellation, a changed parameter // under a used key is exactly what must fail loudly. See IdempotencyKey. $parameters = ['subscription' => $subscriptionId, 'when' => $when]; $replayed = $this->replay($idempotencyKey, $parameters); if ($replayed !== null) { return; } $this->cancellations[] = [ 'subscription' => $subscriptionId, 'when' => $when, 'key' => $idempotencyKey, ]; $this->rememberKey($idempotencyKey, $subscriptionId, $parameters); } public function refund( string $paymentReference, ?int $amountCents = null, ?string $idempotencyKey = null, ): string { $this->failIfAsked(); // No fingerprint: for a refund, a changed parameter under a used key is // exactly what must fail loudly. See IdempotencyKey. $parameters = ['payment' => $paymentReference, 'amount' => (string) $amountCents]; $replayed = $this->replay($idempotencyKey, $parameters); if ($replayed !== null) { return $replayed; } $this->refunds[] = [ 'payment' => $paymentReference, 'amount' => $amountCents, 'key' => $idempotencyKey, ]; $id = 're_'.substr(sha1($paymentReference.count($this->refunds)), 0, 12); $this->rememberKey($idempotencyKey, $id, $parameters); return $id; } public function invoicePaymentReference(string $invoiceId): ?string { $this->failIfAsked(); return $this->invoicePayments[$invoiceId] ?? null; } public function invoiceLines(string $invoiceId): array { $this->failIfAsked(); return $this->invoiceLines[$invoiceId] ?? []; } /** The module items this Stripe holds for one subscription. */ public function itemsOn(string $subscriptionId): array { return array_filter( $this->subscriptionItems, fn (array $item) => $item['subscription'] === $subscriptionId, ); } private function failIfAsked(): void { if ($this->failWith !== null) { throw new RuntimeException($this->failWith); } } /** * The id Stripe already answered for this key, or null when it is new. * * Throws on a key that comes back with different parameters, which is what * Stripe does — with this wording — and what makes a test able to see the * failure at all. */ private function replay(?string $key, array $parameters): ?string { if ($key === null || ! isset($this->keys[$key])) { return null; } if ($this->keys[$key]['fingerprint'] !== IdempotencyKey::fingerprint($parameters)) { throw new RuntimeException( 'Keys for idempotent requests can only be used with the same parameters they were first used with.', ); } return $this->keys[$key]['id']; } private function rememberKey(?string $key, string $id, array $parameters): void { if ($key !== null) { $this->keys[$key] = ['id' => $id, 'fingerprint' => IdempotencyKey::fingerprint($parameters)]; } } }