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
parent
cdea9e923f
commit
ca575e6df1
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue