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', ]); // Stripe's own side of that morning, and load-bearing for the same reason as // the cleared ledger in 'takes over an orphaned package price' below — only // the other way round. Without an entry here createPrice() would simply mint, // and the test could not tell "adoption prevented the 400" from "no 400 was // possible". So the ledger is seeded with what the pre-9da1358 call left // behind: the id it was answered with, and a fingerprint over the metadata // that call sent — `addon` alone, before tax_treatment existed. The entry sits // under the header today's code sends, because that is the only way a ledger // keyed by header can put this createPrice() in front of a poisoned key: the // fingerprint is folded into the header now, so a changed call also changes // the header, which is the branch's OTHER half of the same fix. Seeded // directly, therefore, and honestly so — what it stands in for is Stripe // holding this call's key against different parameters. $this->stripe->keys[IdempotencyKey::forPrice( 'clupilot-addon-price-priority_support-month-3480-EUR', 'prod_support', 3480, 'EUR', 'month', moduleMetadata(), )] = [ 'id' => 'price_1TygdEC7u8NpJ8pOt3nsoyYw', 'fingerprint' => IdempotencyKey::fingerprint(IdempotencyKey::priceParameters( 'prod_support', 3480, 'EUR', 'month', ['addon' => 'priority_support'], )), ]; $id = app(AddonPrices::class)->ensure( 'priority_support', 2900, 'EUR', Subscription::TERM_MONTHLY, TaxTreatment::domestic(), ); // Adoption is what keeps createPrice() from ever being reached. Take the // adopt() call out of AddonPrices::ensure() and this test does not merely // return the wrong id — it dies inside the fake with Stripe's own sentence, // 'Keys for idempotent requests can only be used with the same parameters // they were first used with.' That is the 2026-07-29 blockade, reproduced. 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 `archived` assertion right below — the second of this chain — is // this test's only adoption tooth. AdoptStripePrice archives a Price at // Stripe when it treats one as a duplicate of another it adopted, and // that is the one way it could reach past the figure it was asked for // (4680, the new one) and take the frozen booking's own Price (3480) down // with it as a false "duplicate". // // The three assertions after it read the SWEEP's own row-writing code, // not adoption — AdoptStripePrice performs no database writes at all, so // nothing it does, singly or in combination, can fail them. They earn // their place anyway, because they are exactly what a defect in the // sweep's OTHER two writers would fail: archiveSuperseded() losing its // `->where('net_cents', $netCents)` scoping (its own docblock is about // precisely why that scoping has to hold) would sweep the 2900-net row up // alongside the 3900-net one it is meant to supersede, and remember() // rewritten as an updateOrCreate() keyed without `amount_cents` — the // plausible-looking fix for the UniqueConstraintViolationException catch // a few lines below it — would silently overwrite the very row these // assertions read. // // Neither of adoption's two guards is isolated by this test — the amount // match and the `claimed` callback cover for each other in this scenario, // which is why sabotaging either alone during this fix round left the // booking untouched. Each is isolated on its own elsewhere in this file: // it('adopts nothing when the amount, currency or interval differ') and // it('never hands out a price a row already claims'). 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) // The row itself, not only whether it is archived — `archived_at` // reads null for a row that is still live AND for one that is gone // or rewritten onto a different net_cents, so `exists()` has to stand // beside it or the row's disappearance would pass silently. ->and(StripeAddonPrice::query() ->where('addon_key', 'priority_support') ->where('reverse_charge', false) ->where('net_cents', 2900) ->where('interval', 'month') ->exists())->toBeTrue() ->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); }); it('takes over an orphaned package price', function () { // A catalogue mirrored once, so families have Products and rows have Prices. app(Kernel::class)->call('stripe:sync-catalogue'); $row = PlanPrice::query()->firstOrFail(); $charged = PlanPrices::chargedCents($row, TaxTreatment::domestic()); // The register loses its row and Stripe keeps the Price: a run that died // between the two, seen from the next run's point of view. $orphan = (string) StripePlanPrice::query() ->where('plan_price_id', $row->id) ->where('reverse_charge', false) ->value('stripe_price_id'); StripePlanPrice::query()->where('stripe_price_id', $orphan)->delete(); // Load-bearing, not incidental cleanup: Stripe forgets an idempotency key // after twenty-four hours, and that expiry is the only condition under // which the 2026-07-29 duplicate was ever minted. With the sync's own key // still in the fake's ledger, createPrice() would just replay $orphan's // id on its own — proving nothing about adoption. Clearing it is what // makes createPrice() able to mint a genuine second Price, which is the // only way this test can fail. $this->stripe->keys = []; $before = count($this->stripe->prices); $id = app(PlanPrices::class)->ensure($row->refresh(), TaxTreatment::domestic()); expect($id)->toBe($orphan) ->and(count($this->stripe->prices))->toBe($before) ->and(StripePlanPrice::query() ->where('plan_price_id', $row->id) ->where('reverse_charge', false) ->where('charged_cents', $charged) ->value('stripe_price_id'))->toBe($orphan) // The pointer for the ordinary domestic sale is written as before. ->and((string) $row->refresh()->stripe_price_id)->toBe($orphan); }); it('does not take a package price belonging to another catalogue row', function () { app(Kernel::class)->call('stripe:sync-catalogue'); $row = PlanPrice::query()->firstOrFail(); $product = (string) $row->version->family->stripe_product_id; $charged = PlanPrices::chargedCents($row, TaxTreatment::domestic()); $interval = $row->term === Subscription::TERM_YEARLY ? 'year' : 'month'; // This row's OWN domestic Price from the sync above becomes a legitimate // orphan the moment its register row is gone — exactly like the test // above — and adopt() would be right to reclaim it. Archived here so the // only Price left at this exact figure is the wrong-owner one below: // otherwise adoption would correctly adopt the row's own orphan before // ever weighing 'price_other_row', and the rejection this test exists to // prove would never be reached. $this->stripe->archivePrice((string) StripePlanPrice::query() ->where('plan_price_id', $row->id) ->where('reverse_charge', false) ->value('stripe_price_id')); StripePlanPrice::query()->where('plan_price_id', $row->id)->delete(); // Same product, same money, same interval — and plan_price_id says it is a // DIFFERENT row's Price. One Product carries every version and term of a // family, so this is the ordinary case, not an exotic one. $this->stripe->plantPrice('price_other_row', $product, $charged, (string) $row->currency, $interval, ['plan_price_id' => (string) ($row->id + 1000), 'tax_treatment' => 'domestic']); // Same reason as the test above: with the sync's own idempotency key // still in force, createPrice() would replay THIS row's own previous // Price — which would also not be 'price_other_row', whether or not // adoption ever looked at the wrong-owner Price at all. Clearing the // ledger forces a real mint when nothing adoptable is found, which is // what gives the assertions below something to catch. $this->stripe->keys = []; $before = count($this->stripe->prices); $id = app(PlanPrices::class)->ensure($row->refresh(), TaxTreatment::domestic()); expect($id)->not->toBe('price_other_row') // A fresh Price was minted rather than 'price_other_row' being handed // out. Proves AdoptStripePrice's contradicts()/confirms() pair // correctly refused the wrong-owner Price WHEN adoption runs — the // two guard each other here exactly as they do on the module side, so // either alone still catches this; only disabling both together lets // 'price_other_row' through. It does NOT prove PlanPrices wires // adopt() into ensure() at all: skipping that wiring entirely mints // fresh here too, indistinguishable from a correct refusal. The test // above ("takes over an orphaned package price") is what proves the // wiring is present. ->and(count($this->stripe->prices))->toBe($before + 1) // The Price belonging to the other row must survive untouched. // AdoptStripePrice archives every orphan but the one it adopts, so a // defect that let it treat 'price_other_row' as a duplicate of // whatever it did adopt would take this down as collateral damage — // exactly the failure a wrong plan_price_id must never cause. ->and($this->stripe->archived)->not->toContain('price_other_row'); }); it('does not adopt a package price that only proves the family, not this row', function () { // The whole reason the plan side needs `identifying: ['plan_price_id']` // rather than the module side's simpler key: one Product carries a Price // for every version, term AND treatment of a family, so a Price that // proves only the FAMILY could be any of them. If `identifying` were ever // loosened to ['plan_family'], a Price like the one planted below would // wrongly confirm — nothing else here would catch it. app(Kernel::class)->call('stripe:sync-catalogue'); $row = PlanPrice::query()->firstOrFail(); $product = (string) $row->version->family->stripe_product_id; $charged = PlanPrices::chargedCents($row, TaxTreatment::domestic()); $interval = $row->term === Subscription::TERM_YEARLY ? 'year' : 'month'; // This row's OWN domestic Price is a legitimate orphan the moment its // register row is gone — same reason as the two tests above — and would // otherwise be correctly re-adopted before the planted Price below is // ever weighed, masking the very rejection this test means to prove. $this->stripe->archivePrice((string) StripePlanPrice::query() ->where('plan_price_id', $row->id) ->where('reverse_charge', false) ->value('stripe_price_id')); StripePlanPrice::query()->where('plan_price_id', $row->id)->delete(); // Right product, right money, right currency, right interval, and the // right FAMILY — the one thing missing is plan_price_id, which is the // only key that says which of the family's many rows a Price belongs to. $this->stripe->plantPrice('price_family_only', $product, $charged, (string) $row->currency, $interval, ['plan_family' => $row->version->family->key]); // Same reason as the two tests above: with the sync's own idempotency // key still in force, createPrice() would replay this row's own previous // Price rather than genuinely minting, leaving nothing for the // assertions below to catch. $this->stripe->keys = []; $before = count($this->stripe->prices); $id = app(PlanPrices::class)->ensure($row->refresh(), TaxTreatment::domestic()); expect($id)->not->toBe('price_family_only') // A fresh Price was minted rather than the family-only Price being // handed out — this is the assertion `identifying: ['plan_price_id']` // exists to keep true; swap it for ['plan_family'] and this fails. ->and(count($this->stripe->prices))->toBe($before + 1) ->and($this->stripe->archived)->not->toContain('price_family_only'); }); it('lets no two module rows claim one stripe price', function () { $shared = [ 'addon_key' => 'priority_support', 'net_cents' => 2900, 'currency' => 'EUR', 'stripe_product_id' => 'prod_support', 'stripe_price_id' => 'price_shared', ]; StripeAddonPrice::query()->create([...$shared, 'reverse_charge' => false, 'amount_cents' => 3480, 'interval' => 'month']); // Two rows on one Price is what Block D warned about: archiving the one // would withdraw the Price the other is still selling. expect(fn () => StripeAddonPrice::query()->create([...$shared, 'reverse_charge' => true, 'amount_cents' => 2900, 'interval' => 'month'])) ->toThrow(UniqueConstraintViolationException::class); }); it('keeps only the lowest-id row in every group of duplicates, and leaves the rest of the table alone', function () { // This is the one branch of 2026_07_31_210000_one_row_per_stripe_price that // deletes rows from a live table, and it cannot be reached through the // ordinary migrated schema: RefreshDatabase already ran this migration, so // its OWN unique index would refuse the very duplicate this test needs to // insert. Dropping the index first puts the table back into the shape the // migration was written to find — the shape a real, never-yet-migrated // production table was in — so up() can be run again and actually take its // delete branch instead of finding nothing to do. Schema::table('stripe_addon_prices', fn (Blueprint $table) => $table->dropUnique('stripe_addon_prices_price_unique')); // Group one: two rows sharing a Price, differing in `reverse_charge` so the // pre-existing COMPOSITE unique index (addon_key, reverse_charge, // amount_cents, currency, interval) does not itself refuse the insert — // the VAT-rate-zero case Block D warned about, where only the shared // stripe_price_id gives the duplicate away. $kept = DB::table('stripe_addon_prices')->insertGetId([ 'addon_key' => 'priority_support', 'reverse_charge' => false, 'amount_cents' => 2900, 'net_cents' => 2900, 'currency' => 'EUR', 'interval' => 'month', 'stripe_product_id' => 'prod_support', 'stripe_price_id' => 'price_shared', 'created_at' => now(), 'updated_at' => now(), ]); $duplicate = DB::table('stripe_addon_prices')->insertGetId([ 'addon_key' => 'priority_support', 'reverse_charge' => true, 'amount_cents' => 2900, 'net_cents' => 2900, 'currency' => 'EUR', 'interval' => 'month', 'stripe_product_id' => 'prod_support', 'stripe_price_id' => 'price_shared', 'created_at' => now(), 'updated_at' => now(), ]); // Group two: THREE rows sharing a second, different Price — its own tuple // dimension varied per row (interval, then currency) so the composite index // never fires here either. One group of two proves the loop runs; this one // proves it runs more than once AND that a single iteration can delete more // than one row. $kept2 = DB::table('stripe_addon_prices')->insertGetId([ 'addon_key' => 'priority_support', 'reverse_charge' => false, 'amount_cents' => 3900, 'net_cents' => 3900, 'currency' => 'EUR', 'interval' => 'month', 'stripe_product_id' => 'prod_support', 'stripe_price_id' => 'price_shared_two', 'created_at' => now(), 'updated_at' => now(), ]); $duplicate2a = DB::table('stripe_addon_prices')->insertGetId([ 'addon_key' => 'priority_support', 'reverse_charge' => false, 'amount_cents' => 3900, 'net_cents' => 3900, 'currency' => 'EUR', 'interval' => 'year', 'stripe_product_id' => 'prod_support', 'stripe_price_id' => 'price_shared_two', 'created_at' => now(), 'updated_at' => now(), ]); $duplicate2b = DB::table('stripe_addon_prices')->insertGetId([ 'addon_key' => 'priority_support', 'reverse_charge' => false, 'amount_cents' => 3900, 'net_cents' => 3900, 'currency' => 'USD', 'interval' => 'month', 'stripe_product_id' => 'prod_support', 'stripe_price_id' => 'price_shared_two', 'created_at' => now(), 'updated_at' => now(), ]); // On its own Price entirely — not part of any duplicate group — and must // survive completely untouched. $unrelated = DB::table('stripe_addon_prices')->insertGetId([ 'addon_key' => 'extra_storage', 'reverse_charge' => false, 'amount_cents' => 1000, 'net_cents' => 1000, 'currency' => 'EUR', 'interval' => 'month', 'stripe_product_id' => 'prod_storage', 'stripe_price_id' => 'price_untouched', 'created_at' => now(), 'updated_at' => now(), ]); // require, not require_once: the file `return`s a fresh anonymous-class // instance every time it is evaluated, which is what lets this run the // migration's up() a second time in this same process. (require database_path('migrations/2026_07_31_210000_one_row_per_stripe_price.php'))->up(); $keptRow = DB::table('stripe_addon_prices')->where('id', $kept)->first(); $kept2Row = DB::table('stripe_addon_prices')->where('id', $kept2)->first(); $unrelatedRow = DB::table('stripe_addon_prices')->where('id', $unrelated)->first(); // The LOWER id survives in each group — the row that was written first, // which is the one anything already billing is likeliest to have been // reading. Were that ordering silently inverted, the surviving row could be // one nothing ever pointed at. Read back, not merely checked for existence: // a survivor that exists but was mangled in the process — the wrong // stripe_price_id, or an amount_cents that no longer matches what the // shared Price actually charges — would be exactly the "kept the right row // but damaged it" failure existence alone cannot catch. expect($keptRow)->not->toBeNull() ->and($keptRow->stripe_price_id)->toBe('price_shared') ->and((int) $keptRow->amount_cents)->toBe(2900) ->and($kept2Row)->not->toBeNull() ->and($kept2Row->stripe_price_id)->toBe('price_shared_two') ->and((int) $kept2Row->amount_cents)->toBe(3900) ->and($unrelatedRow)->not->toBeNull() ->and($unrelatedRow->stripe_price_id)->toBe('price_untouched') ->and((int) $unrelatedRow->amount_cents)->toBe(1000) // Every duplicate in both groups is gone — the higher id from group // one, and BOTH higher ids from group two's three-way tie. ->and(DB::table('stripe_addon_prices')->where('id', $duplicate)->exists())->toBeFalse() ->and(DB::table('stripe_addon_prices')->where('id', $duplicate2a)->exists())->toBeFalse() ->and(DB::table('stripe_addon_prices')->where('id', $duplicate2b)->exists())->toBeFalse() // Six rows went in, three survive: nothing beyond the two kept // duplicates and the untouched row is left standing. ->and(DB::table('stripe_addon_prices')->count())->toBe(3); // And the index is back in force: a further row on a third, otherwise // distinct tuple, but the SAME Price id, is refused by stripe_price_id // alone rather than being silently accepted. expect(fn () => DB::table('stripe_addon_prices')->insert([ 'addon_key' => 'priority_support', 'reverse_charge' => false, 'amount_cents' => 4900, 'net_cents' => 4900, 'currency' => 'EUR', 'interval' => 'year', 'stripe_product_id' => 'prod_support', 'stripe_price_id' => 'price_shared', 'created_at' => now(), 'updated_at' => now(), ]))->toThrow(UniqueConstraintViolationException::class); });