CluPilotCloud/app/Services/Stripe/FakeStripeClient.php

386 lines
11 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}> */
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 = [];
/** Idempotency key → the id first returned for it. @var array<string, 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
{
// Replays the first answer for a repeated key, as Stripe does.
if ($idempotencyKey !== null && isset($this->keys[$idempotencyKey])) {
return $this->keys[$idempotencyKey];
}
$id = 'prod_'.substr(sha1($name.count($this->products)), 0, 12);
$this->products[$id] = ['name' => $name, 'metadata' => $metadata];
if ($idempotencyKey !== null) {
$this->keys[$idempotencyKey] = $id;
}
return $id;
}
public function createPrice(
string $productId,
int $amountCents,
string $currency,
string $interval,
array $metadata = [],
?string $idempotencyKey = null,
): string {
if ($idempotencyKey !== null && isset($this->keys[$idempotencyKey])) {
return $this->keys[$idempotencyKey];
}
$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,
];
if ($idempotencyKey !== null) {
$this->keys[$idempotencyKey] = $id;
}
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 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}");
}
// Replays the first answer for a repeated key, as Stripe does, so a retry
// after a timeout records one order to stop rather than two.
if ($idempotencyKey !== null && isset($this->keys[$idempotencyKey])) {
return;
}
$this->cancellations[] = [
'subscription' => $subscriptionId,
'when' => $when,
'key' => $idempotencyKey,
];
if ($idempotencyKey !== null) {
$this->keys[$idempotencyKey] = $subscriptionId;
}
}
public function refund(
string $paymentReference,
?int $amountCents = null,
?string $idempotencyKey = null,
): string {
$this->failIfAsked();
// Replays the first answer for a repeated key, as Stripe does — which
// is the whole point of sending one on a refund.
if ($idempotencyKey !== null && isset($this->keys[$idempotencyKey])) {
return $this->keys[$idempotencyKey];
}
$this->refunds[] = [
'payment' => $paymentReference,
'amount' => $amountCents,
'key' => $idempotencyKey,
];
$id = 're_'.substr(sha1($paymentReference.count($this->refunds)), 0, 12);
if ($idempotencyKey !== null) {
$this->keys[$idempotencyKey] = $id;
}
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);
}
}
}