Let the catalogue ask Stripe what it already has

Nothing here could. The client could create, archive and unarchive a Price and
had no way to list one, so a run that died between Stripe's create and our
insert left an orphan our table never learned about — and once the key expired,
the next run made a second live Price for the same money.

Paged to the end like invoiceLines(), because a family Product collects a Price
per version, term, treatment and rate change, and stopping at the first page
would leave the orphan unfound and mint the duplicate anyway.

updatePriceMetadata() alongside it: metadata is one of the few fields a Price
lets you change, which is why the metadata format is not part of a Price's
identity and why an adopted orphan is brought up to date rather than replaced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus^2
nexxo 2026-07-30 10:54:23 +02:00
parent cdea9e923f
commit ca575e6df1
4 changed files with 224 additions and 0 deletions

View File

@ -34,6 +34,14 @@ class FakeStripeClient implements StripeClient
*/
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.
@ -240,6 +248,67 @@ class FakeStripeClient implements StripeClient
$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,

View File

@ -144,6 +144,57 @@ class HttpStripeClient implements StripeClient
->throw();
}
public function activePricesFor(string $productId): array
{
$prices = [];
$after = null;
// Paged through to the end, for the same reason invoiceLines() is: Stripe
// caps a page at a hundred, and a family Product collects a Price per
// version, term, treatment and rate change. Stopping at the first page
// would leave an orphan unfound and mint the duplicate anyway.
do {
$page = $this->request()
->get($this->url('prices'), array_filter([
'product' => $productId,
'active' => 'true',
'type' => 'recurring',
'limit' => 100,
'starting_after' => $after,
], fn ($value) => $value !== null))
->throw()
->json();
$data = (array) ($page['data'] ?? []);
foreach ($data as $price) {
$prices[] = [
'id' => (string) ($price['id'] ?? ''),
'unit_amount' => (int) ($price['unit_amount'] ?? 0),
'currency' => strtoupper((string) ($price['currency'] ?? '')),
'interval' => (string) ($price['recurring']['interval'] ?? ''),
'created' => (int) ($price['created'] ?? 0),
'metadata' => array_map(
fn ($value) => (string) $value,
(array) ($price['metadata'] ?? []),
),
];
}
$after = $data === [] ? null : ($data[array_key_last($data)]['id'] ?? null);
} while (($page['has_more'] ?? false) === true && $after !== null);
return $prices;
}
public function updatePriceMetadata(string $priceId, array $metadata): void
{
$this->request()
->asForm()
->post($this->url('prices/'.$priceId), $this->flatten('metadata', $metadata))
->throw();
}
public function updateSubscriptionPrice(
string $subscriptionId,
string $itemId,

View File

@ -124,6 +124,35 @@ interface StripeClient
?string $idempotencyKey = null,
): string;
/**
* Every active recurring Price of a Product, as Stripe holds them.
*
* The half of the catalogue mirror that was missing: nothing here ever asked
* Stripe what it already had. A run that created a Price and died before the
* row was written left an orphan our table never learned about, and once the
* idempotency key expired the next run made a SECOND live Price for the same
* money the exact duplicate the key exists to prevent. See
* App\Services\Billing\AdoptStripePrice, which is the only caller.
*
* Active ones only, because the question being asked is "is there something
* here I can sell on?". `currency` comes back UPPER CASE, the way our own
* tables hold it.
*
* @return array<int, array{id: string, unit_amount: int, currency: string, interval: string, created: int, metadata: array<string, string>}>
*/
public function activePricesFor(string $productId): array;
/**
* Write metadata onto a Price that already exists.
*
* Metadata is one of the few fields a Stripe Price lets you change the
* same property activatePrice() rests on, and the reason the metadata format
* is NOT part of a Price's identity. An adopted orphan is brought up to
* today's metadata rather than replaced, which is exactly what was done by
* hand after 2026-07-29 and keeps Stripe's own dashboard readable.
*/
public function updatePriceMetadata(string $priceId, array $metadata): void;
/** Stop a Price being offered. The Price itself stays, as Stripe requires. */
public function archivePrice(string $priceId): void;

View File

@ -120,3 +120,78 @@ it('mints a second price rather than blocking when the metadata moved', function
// AdoptStripePrice, not of the key — see StripePriceAdoptionTest.
expect($second)->not->toBe($first);
});
it('pages through every active price of a product', function () {
Http::fake([
'api.stripe.com/*' => Http::sequence()
->push([
'data' => [
['id' => 'price_a', 'unit_amount' => 3480, 'currency' => 'eur',
'created' => 100, 'recurring' => ['interval' => 'month'],
'metadata' => ['addon' => 'priority_support']],
['id' => 'price_b', 'unit_amount' => 41760, 'currency' => 'eur',
'created' => 101, 'recurring' => ['interval' => 'year'], 'metadata' => []],
],
'has_more' => true,
])
->push([
'data' => [
['id' => 'price_c', 'unit_amount' => 2900, 'currency' => 'eur',
'created' => 102, 'recurring' => ['interval' => 'month'],
'metadata' => ['addon' => 'priority_support', 'tax_treatment' => 'reverse_charge']],
],
'has_more' => false,
]),
]);
$prices = (new HttpStripeClient)->activePricesFor('prod_1');
expect($prices)->toHaveCount(3)
->and($prices[0])->toBe([
'id' => 'price_a',
'unit_amount' => 3480,
// Upper case, because that is how our own tables hold it and the
// comparison in AdoptStripePrice must not have to remember which
// side is which.
'currency' => 'EUR',
'interval' => 'month',
'created' => 100,
'metadata' => ['addon' => 'priority_support'],
])
->and($prices[2]['id'])->toBe('price_c');
// The second page has to be asked for, or this reintroduces the very gap it
// exists to close — a family product accumulates prices across versions,
// terms, treatments and every rate change.
Http::assertSent(fn ($request) => str_contains($request->url(), 'starting_after=price_b'));
// Archived prices are none of our business here: we are looking for
// something to SELL on.
Http::assertSent(fn ($request) => str_contains($request->url(), 'active=true'));
});
it('writes metadata onto a price that already exists', function () {
Http::fake(['api.stripe.com/*' => Http::response(['id' => 'price_a'])]);
(new HttpStripeClient)->updatePriceMetadata('price_a', ['addon' => 'priority_support']);
Http::assertSent(fn ($request) => $request->url() === 'https://api.stripe.com/v1/prices/price_a'
&& $request['metadata[addon]'] === 'priority_support');
});
it('lets the fake answer with the prices it holds, minus the archived ones', function () {
$fake = new FakeStripeClient;
$kept = $fake->createPrice('prod_1', 3480, 'EUR', 'month', ['addon' => 'priority_support']);
$gone = $fake->createPrice('prod_1', 2900, 'EUR', 'month', ['addon' => 'priority_support']);
$other = $fake->createPrice('prod_2', 3480, 'EUR', 'month', []);
$fake->archivePrice($gone);
$fake->plantPrice('price_orphan', 'prod_1', 3480, 'EUR', 'month',
['addon' => 'priority_support'], created: 0);
$found = collect($fake->activePricesFor('prod_1'))->pluck('id')->all();
expect($found)->toContain($kept, 'price_orphan')
->and($found)->not->toContain($gone, $other);
});