create(); return ProductPrice::factory()->create(array_merge([ 'purchasable_id' => $product->id, 'purchasable_type' => Product::class, ], $overrides)); } #[Test] public function tiers_relation_orders_by_sort_order(): void { $price = $this->makePrice(); $b = ProductPriceTier::factory()->create(['price_id' => $price->id, 'up_to' => 30, 'sort_order' => 1]); $a = ProductPriceTier::factory()->create(['price_id' => $price->id, 'up_to' => 10, 'sort_order' => 0]); $c = ProductPriceTier::factory()->create(['price_id' => $price->id, 'up_to' => 60, 'sort_order' => 2]); $ids = $price->tiers->pluck('id')->all(); $this->assertSame([$a->id, $b->id, $c->id], $ids); } #[Test] public function unbounded_tier_sorts_after_bounded_tiers_regardless_of_insertion_order(): void { $price = $this->makePrice(); // Insert the unbounded tier first (sort_order=0, the same as the // first bounded tier) — the orderByRaw guard should still push it // to the end. $unbounded = ProductPriceTier::factory()->create([ 'price_id' => $price->id, 'up_to' => null, 'unit_amount' => 999, 'sort_order' => 99, ]); $first = ProductPriceTier::factory()->create([ 'price_id' => $price->id, 'up_to' => 14, 'unit_amount' => 0, 'sort_order' => 0, ]); $second = ProductPriceTier::factory()->create([ 'price_id' => $price->id, 'up_to' => 60, 'unit_amount' => 100, 'sort_order' => 1, ]); $ids = $price->tiers()->pluck('id')->all(); $this->assertSame([$first->id, $second->id, $unbounded->id], $ids); } #[Test] public function calculate_for_usage_walks_tiers_in_relation_order(): void { $price = $this->makePrice(['billing_scheme' => 'tiered']); // Out-of-order inserts to prove that the relation ordering — not // insertion order — drives the math. ProductPriceTier::factory()->create(['price_id' => $price->id, 'up_to' => null, 'unit_amount' => 200, 'sort_order' => 2]); ProductPriceTier::factory()->create(['price_id' => $price->id, 'up_to' => 14, 'unit_amount' => 0, 'sort_order' => 0]); ProductPriceTier::factory()->create(['price_id' => $price->id, 'up_to' => 60, 'unit_amount' => 100, 'sort_order' => 1]); // 75 days: 14 free + 46×100 + 15×200 = 7600 $this->assertSame(7600, $price->fresh()->calculateForUsage(75)); } #[Test] public function tiers_table_declares_cascade_on_delete_for_price_id(): void { // FK enforcement on SQLite under RefreshDatabase is config-sensitive // (transactions + PRAGMA scoping make a runtime cascade hard to // observe reliably). The package's contract here is structural: // the price_id FK should be declared with ON DELETE CASCADE so a // production MySQL / Postgres deployment behaves correctly. $migration = file_get_contents(__DIR__.'/../../../database/migrations/2025_01_01_000002_create_product_price_tiers_table.php'); $this->assertMatchesRegularExpression( '/foreignUuid\(\'price_id\'\)[^;]*cascadeOnDelete\(\)/s', $migration, 'price_id should be declared with cascadeOnDelete()' ); } }