diff --git a/tests/Feature/Billing/StripePriceAdoptionTest.php b/tests/Feature/Billing/StripePriceAdoptionTest.php index e552943..6066ed5 100644 --- a/tests/Feature/Billing/StripePriceAdoptionTest.php +++ b/tests/Feature/Billing/StripePriceAdoptionTest.php @@ -318,6 +318,15 @@ it('takes over an orphaned package price', function () { ->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()); @@ -341,6 +350,18 @@ it('does not take a package price belonging to another catalogue row', function $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 @@ -349,7 +370,34 @@ it('does not take a package price belonging to another catalogue row', function $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'); + 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'); });