diff --git a/app/Services/Stripe/FakeStripeClient.php b/app/Services/Stripe/FakeStripeClient.php index 34b9f81..47efe93 100644 --- a/app/Services/Stripe/FakeStripeClient.php +++ b/app/Services/Stripe/FakeStripeClient.php @@ -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}> + */ + 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, diff --git a/app/Services/Stripe/HttpStripeClient.php b/app/Services/Stripe/HttpStripeClient.php index c24e581..8926f39 100644 --- a/app/Services/Stripe/HttpStripeClient.php +++ b/app/Services/Stripe/HttpStripeClient.php @@ -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, diff --git a/app/Services/Stripe/StripeClient.php b/app/Services/Stripe/StripeClient.php index 841db2d..1e06947 100644 --- a/app/Services/Stripe/StripeClient.php +++ b/app/Services/Stripe/StripeClient.php @@ -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}> + */ + 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; diff --git a/tests/Feature/Billing/StripeIdempotencyKeyTest.php b/tests/Feature/Billing/StripeIdempotencyKeyTest.php index 36b3c26..ef93691 100644 --- a/tests/Feature/Billing/StripeIdempotencyKeyTest.php +++ b/tests/Feature/Billing/StripeIdempotencyKeyTest.php @@ -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); +});