diff --git a/tests/Feature/Billing/StripePriceAdoptionTest.php b/tests/Feature/Billing/StripePriceAdoptionTest.php index 54336c1..aaf86ef 100644 --- a/tests/Feature/Billing/StripePriceAdoptionTest.php +++ b/tests/Feature/Billing/StripePriceAdoptionTest.php @@ -12,8 +12,11 @@ use App\Services\Billing\TaxTreatment; use App\Services\Stripe\FakeStripeClient; use App\Services\Stripe\StripeClient; use Illuminate\Contracts\Console\Kernel; +use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\UniqueConstraintViolationException; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Schema; /** * A run that dies between Stripe's create and our insert leaves an orphan — and @@ -467,3 +470,65 @@ it('lets no two module rows claim one stripe price', function () { 'reverse_charge' => true, 'amount_cents' => 2900, 'interval' => 'month'])) ->toThrow(UniqueConstraintViolationException::class); }); + +it('deletes the newer of two rows already sharing one stripe price when the migration runs', 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')); + + // Two rows sharing one 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(), + ]); + + // On its own Price entirely, and must survive 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(); + + // The LOWER id survives — 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. + expect(DB::table('stripe_addon_prices')->where('id', $kept)->exists())->toBeTrue() + ->and(DB::table('stripe_addon_prices')->where('id', $duplicate)->exists())->toBeFalse() + ->and(DB::table('stripe_addon_prices')->where('id', $unrelated)->exists())->toBeTrue(); + + // 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); +});