stripe = new FakeStripeClient; app()->instance(StripeClient::class, $this->stripe); }); /** The module metadata as AddonPrices sends it today. */ function moduleMetadata(string $treatment = 'domestic'): array { return ['addon' => 'priority_support', 'tax_treatment' => $treatment]; } /** Ask the adoption step the question AddonPrices asks it. */ function adoptModulePrice(?array $metadata = null, ?callable $claimed = null): ?string { return app(AdoptStripePrice::class)( productId: 'prod_support', amountCents: 3480, currency: 'EUR', interval: 'month', metadata: $metadata ?? moduleMetadata(), identifying: ['addon'], claimed: $claimed ?? fn (string $id) => false, ); } it('adopts the orphan of 2026-07-29 instead of minting a second price', function () { // The state that morning: the Price exists at Stripe, carries the metadata // of the code that made it — WITHOUT tax_treatment, which 9da1358 added // afterwards — and no row of ours knows it. $this->stripe->plantPrice('price_1TygdEC7u8NpJ8pOt3nsoyYw', 'prod_support', 3480, 'EUR', 'month', ['addon' => 'priority_support']); expect(adoptModulePrice())->toBe('price_1TygdEC7u8NpJ8pOt3nsoyYw'); // Brought up to today's metadata rather than replaced: metadata is mutable // at Stripe, the amount is not, which is the whole reason the format is no // part of a Price's identity. expect($this->stripe->metadataUpdates)->toBe([[ 'price' => 'price_1TygdEC7u8NpJ8pOt3nsoyYw', 'metadata' => moduleMetadata(), ]]); }); it('leaves the metadata alone when it already says the right thing', function () { $this->stripe->plantPrice('price_ok', 'prod_support', 3480, 'EUR', 'month', moduleMetadata()); expect(adoptModulePrice())->toBe('price_ok') ->and($this->stripe->metadataUpdates)->toBe([]); }); it('leaves the metadata alone when Stripe merged it in a different order plus a key of its own', function () { // plantPrice() stores our own array in our own order, so the test above // cannot catch an order- or merge-sensitive comparison: Stripe returns // metadata as the result of a MERGE, never a replace, in whatever key // order it likes — and a write never removes a key it did not send. $this->stripe->plantPrice('price_ok', 'prod_support', 3480, 'EUR', 'month', [ 'tax_treatment' => 'domestic', 'addon' => 'priority_support', 'internal_note' => 'added by hand in the Stripe dashboard', ]); expect(adoptModulePrice())->toBe('price_ok') ->and($this->stripe->metadataUpdates)->toBe([]); }); it('adopts nothing when the amount, currency or interval differ', function () { $this->stripe->plantPrice('price_cheaper', 'prod_support', 2900, 'EUR', 'month', moduleMetadata()); $this->stripe->plantPrice('price_yearly', 'prod_support', 3480, 'EUR', 'year', moduleMetadata()); $this->stripe->plantPrice('price_dollars', 'prod_support', 3480, 'USD', 'month', moduleMetadata()); expect(adoptModulePrice())->toBeNull(); }); it('refuses a price nothing proves is ours, and says so', function () { Log::spy(); // What a person clicking through Stripe's own dashboard leaves behind: the // right money on our product, and not one word about what it is for. $this->stripe->plantPrice('price_by_hand', 'prod_support', 3480, 'EUR', 'month', []); expect(adoptModulePrice())->toBeNull(); Log::shouldHaveReceived('warning')->once(); }); it('passes silently over another of our own prices', function () { Log::spy(); // At a VAT rate of nought both treatments are the same amount, so the // reverse-charge Price sits at the domestic one's money — and contradicts on // tax_treatment. That is not a mystery worth a warning; it is a Price of // ours that is not the one being asked for. $this->stripe->plantPrice('price_rc', 'prod_support', 3480, 'EUR', 'month', moduleMetadata('reverse_charge')); expect(adoptModulePrice())->toBeNull(); Log::shouldNotHaveReceived('warning'); }); it('never hands out a price a row already claims', function () { $this->stripe->plantPrice('price_taken', 'prod_support', 3480, 'EUR', 'month', moduleMetadata()); expect(adoptModulePrice(claimed: fn (string $id) => $id === 'price_taken'))->toBeNull(); }); it('adopts the oldest of several orphans and stops selling the rest', function () { Log::spy(); $this->stripe->plantPrice('price_second', 'prod_support', 3480, 'EUR', 'month', ['addon' => 'priority_support'], created: 200); $this->stripe->plantPrice('price_first', 'prod_support', 3480, 'EUR', 'month', ['addon' => 'priority_support'], created: 100); $this->stripe->plantPrice('price_third', 'prod_support', 3480, 'EUR', 'month', ['addon' => 'priority_support'], created: 300); // The oldest, because it is the one a lost row is likeliest to have been // billing on. expect(adoptModulePrice())->toBe('price_first') ->and($this->stripe->archived)->toBe(['price_second', 'price_third']); Log::shouldHaveReceived('warning'); }); it('takes the orphan over instead of minting a second module price', function () { // The product exists because a previous run got that far; the Price exists // because the run that made it died before the insert. $this->stripe->plantPrice('price_1TygdEC7u8NpJ8pOt3nsoyYw', 'prod_support', 3480, 'EUR', 'month', ['addon' => 'priority_support']); StripeAddonPrice::query()->create([ 'addon_key' => 'priority_support', 'reverse_charge' => false, 'amount_cents' => 41760, 'net_cents' => 34800, 'currency' => 'EUR', 'interval' => 'year', 'stripe_product_id' => 'prod_support', 'stripe_price_id' => 'price_yearly_already_known', ]); $before = count($this->stripe->prices); $id = app(AddonPrices::class)->ensure( 'priority_support', 2900, 'EUR', Subscription::TERM_MONTHLY, TaxTreatment::domestic(), ); expect($id)->toBe('price_1TygdEC7u8NpJ8pOt3nsoyYw') // Nothing new at Stripe: the orphan was taken over, not replaced. ->and(count($this->stripe->prices))->toBe($before) ->and(StripeAddonPrice::query() ->where('addon_key', 'priority_support') ->where('interval', 'month') ->where('reverse_charge', false) ->value('stripe_price_id'))->toBe('price_1TygdEC7u8NpJ8pOt3nsoyYw'); }); it('does not block a booking because the metadata format moved', function () { // 2026-07-29, exactly: orphan with the old metadata, code with the new. This // is the call that answered HTTP 400 for twenty-four hours. $this->stripe->plantPrice('price_1TygdEC7u8NpJ8pOt3nsoyYw', 'prod_support', 3480, 'EUR', 'month', ['addon' => 'priority_support']); StripeAddonPrice::query()->create([ 'addon_key' => 'priority_support', 'reverse_charge' => false, 'amount_cents' => 41760, 'net_cents' => 34800, 'currency' => 'EUR', 'interval' => 'year', 'stripe_product_id' => 'prod_support', 'stripe_price_id' => 'price_yearly_already_known', ]); $id = app(AddonPrices::class)->ensure( 'priority_support', 2900, 'EUR', Subscription::TERM_MONTHLY, TaxTreatment::domestic(), ); expect($id)->toBe('price_1TygdEC7u8NpJ8pOt3nsoyYw') ->and($this->stripe->metadataUpdates)->toHaveCount(1); }); it('leaves a frozen booking on the price it was sold at', function () { // Seeds the product id AddonPrices::product() will find and reuse — same // reason as the first two tests in this file. Without it, the sync below // mints its own Stripe Product with a generated id, and the orphan planted // further down (deliberately at the literal 'prod_support' Stripe uses // throughout this file) would sit on a product nothing ever asks about, // making it inert rather than the competing figure the test needs. StripeAddonPrice::query()->create([ 'addon_key' => 'priority_support', 'reverse_charge' => false, 'amount_cents' => 41760, 'net_cents' => 34800, 'currency' => 'EUR', 'interval' => 'year', 'stripe_product_id' => 'prod_support', 'stripe_price_id' => 'price_yearly_already_known', ]); app(Kernel::class)->call('stripe:sync-catalogue'); $sold = app(AddonPrices::class)->liveFor( 'priority_support', 2900, 'EUR', Subscription::TERM_MONTHLY, TaxTreatment::domestic(), ); $subscription = Subscription::factory()->plan('team')->create(); $booking = SubscriptionAddon::query()->create([ 'subscription_id' => $subscription->id, 'addon_key' => 'priority_support', // Net, per month, per unit — frozen at booking. `currency` and // `booked_at` are both NOT NULL (2026_07_26_060000), and `uuid` fills // itself through the model's uniqueIds(). 'price_cents' => 2900, 'currency' => 'EUR', 'quantity' => 1, 'booked_at' => now(), 'stripe_price_id' => $sold, ]); // The catalogue moves, and somebody has already left an orphan at the new // figure. Neither may reach a booking that is already frozen. config()->set('provisioning.addons.priority_support.price_cents', 3900); $this->stripe->plantPrice('price_orphan_new_figure', 'prod_support', 4680, 'EUR', 'month', ['addon' => 'priority_support']); app(Kernel::class)->call('stripe:sync-catalogue'); // The two assertions below only say the booking ROW was not rewritten and // that ITS OWN Price was not archived — true in this exact path even with // adoption removed altogether, because stripe:sync-catalogue never writes // subscription_addons.stripe_price_id (that is SyncStripeAddonItems, a // separate write path) and the orphan planted above sits at a different // amount from $sold, so archiveSuperseded's net_cents scoping was already // never going to reach it. They stay because the untouched row is still // worth stating, but they are not the guard. The three assertions after // them are: they fail the moment adoption reaches for a figure other than // the one it was asked for — the exact way a defect here would move money // on an already-frozen booking. expect($booking->refresh()->stripe_price_id)->toBe($sold) ->and($this->stripe->archived)->not->toContain($sold) // The OLD figure's Price is still what a checkout for it would use — // adoption of the orphan at the NEW figure must not have reached // backwards and pulled the frozen figure's Price out of circulation. ->and(app(AddonPrices::class)->liveFor( 'priority_support', 2900, 'EUR', Subscription::TERM_MONTHLY, TaxTreatment::domestic(), ))->toBe($sold) ->and(StripeAddonPrice::query() ->where('addon_key', 'priority_support') ->where('reverse_charge', false) ->where('net_cents', 2900) ->where('interval', 'month') ->value('archived_at'))->toBeNull() // And asking for the OLD figure again — the same call a renewal on // this very booking would make — must still be handed $sold, not the // orphan sitting at the new figure's amount. ->and(app(AddonPrices::class)->ensure( 'priority_support', 2900, 'EUR', Subscription::TERM_MONTHLY, TaxTreatment::domestic(), ))->toBe($sold); });