515 lines
16 KiB
PHP
515 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Stripe;
|
|
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* A Stripe that records what it was asked to do.
|
|
*
|
|
* The catalogue sync has to be exercised for real — it is the thing that must
|
|
* be idempotent and must never mint a second Price for the same row — and that
|
|
* is testable without a network. Ids are deterministic so a test can assert
|
|
* which product a price was attached to.
|
|
*/
|
|
class FakeStripeClient implements StripeClient
|
|
{
|
|
public bool $configured = true;
|
|
|
|
/** @var array<int, array{name: string, metadata: array}> */
|
|
public array $products = [];
|
|
|
|
/** @var array<int, array{product: string, amount: int, currency: string, interval: string, metadata: array, created: int}> */
|
|
public array $prices = [];
|
|
|
|
/** @var array<int, string> */
|
|
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<int, string>
|
|
*/
|
|
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<int, array{price: string, metadata: array<string, string>}>
|
|
*/
|
|
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<string, array{id: string, fingerprint: string}>
|
|
*/
|
|
public array $keys = [];
|
|
|
|
/**
|
|
* What Stripe would answer when asked which item a subscription bills
|
|
* through: subscription id → item id.
|
|
*
|
|
* @var array<string, string>
|
|
*/
|
|
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<int, array{subscription: string, item: string, price: string, proration: string}>
|
|
*/
|
|
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<string, array{subscription: string, price: string, quantity: int}>
|
|
*/
|
|
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<int, array<string, mixed>>
|
|
*/
|
|
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<string, array<int, array<string, mixed>>>
|
|
*/
|
|
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<int, array{subscription: string, when: string, key: ?string}>
|
|
*/
|
|
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<int, array{payment: string, amount: ?int, key: ?string}>
|
|
*/
|
|
public array $refunds = [];
|
|
|
|
/**
|
|
* What this Stripe would answer when asked which payment an invoice was
|
|
* settled by: invoice id → payment reference.
|
|
*
|
|
* @var array<string, ?string>
|
|
*/
|
|
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<int, array{price: string, success: string, cancel: string, metadata: array, email: ?string, one_off: ?array<string, mixed>}>
|
|
*/
|
|
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. A counter
|
|
// is enough and beats a clock: it cannot tie.
|
|
'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.
|
|
$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)];
|
|
}
|
|
}
|
|
}
|