service = new PipelineAllocationService; $this->scenario = Scenario::factory()->create(); } public function test_allocates_to_single_fixed_bucket() { // Arrange: Single bucket with $500 limit $bucket = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'name' => 'Emergency Fund', 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, 'allocation_value' => 50000, 'starting_amount' => 0, 'priority' => 1, ]); // Act: Allocate $300 $draws = $this->service->allocateInflow($this->scenario, 30000); // Assert: All money goes to emergency fund $this->assertCount(1, $draws); $this->assertEquals($bucket->id, $draws[0]->bucket_id); $this->assertEquals(30000, $draws[0]->amount); } public function test_allocates_across_multiple_fixed_buckets_by_priority() { // Arrange: Three buckets with different priorities $bucket1 = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, 'allocation_value' => 20000, 'starting_amount' => 0, 'priority' => 1, ]); $bucket2 = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, 'allocation_value' => 30000, 'starting_amount' => 0, 'priority' => 2, ]); $bucket3 = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, 'allocation_value' => 15000, 'starting_amount' => 0, 'priority' => 3, ]); // Act: Allocate $550 (should fill bucket1 + bucket2 + partial bucket3) $draws = $this->service->allocateInflow($this->scenario, 55000); // Assert: Allocation follows priority order $this->assertCount(3, $draws); // Bucket 1: fully filled $this->assertEquals($bucket1->id, $draws[0]->bucket_id); $this->assertEquals(20000, $draws[0]->amount); // Bucket 2: fully filled $this->assertEquals($bucket2->id, $draws[1]->bucket_id); $this->assertEquals(30000, $draws[1]->amount); // Bucket 3: partially filled $this->assertEquals($bucket3->id, $draws[2]->bucket_id); $this->assertEquals(5000, $draws[2]->amount); } public function test_percentage_bucket_gets_percentage_of_remaining_amount() { // Arrange: Fixed bucket + percentage bucket $fixedBucket = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, 'allocation_value' => 30000, 'starting_amount' => 0, 'priority' => 1, ]); $percentageBucket = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE, 'allocation_value' => 20.00, // 20% 'starting_amount' => 0, 'priority' => 2, ]); // Act: Allocate $1000 $draws = $this->service->allocateInflow($this->scenario, 100000); // Assert: Fixed gets $300, percentage gets 20% of remaining $700 = $140 $this->assertCount(2, $draws); $this->assertEquals(30000, $draws[0]->amount); // Fixed bucket $this->assertEquals(14000, $draws[1]->amount); // 20% of $700 } public function test_unlimited_bucket_gets_all_remaining_amount() { // Arrange: Fixed + unlimited buckets $fixedBucket = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, 'allocation_value' => 50000, 'starting_amount' => 0, 'priority' => 1, ]); $unlimitedBucket = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::UNLIMITED, 'allocation_value' => null, 'starting_amount' => 0, 'priority' => 2, ]); // Act: Allocate $1500 $draws = $this->service->allocateInflow($this->scenario, 150000); // Assert: Fixed gets $500, unlimited gets remaining $1000 $this->assertCount(2, $draws); $this->assertEquals(50000, $draws[0]->amount); $this->assertEquals(100000, $draws[1]->amount); } public function test_skips_buckets_with_zero_allocation() { // Arrange: Bucket that can't accept any money $fullBucket = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, 'allocation_value' => 0, // No capacity 'starting_amount' => 0, 'priority' => 1, ]); $normalBucket = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, 'allocation_value' => 30000, 'starting_amount' => 0, 'priority' => 2, ]); // Act: Allocate $200 $draws = $this->service->allocateInflow($this->scenario, 20000); // Assert: Only normal bucket gets allocation $this->assertCount(1, $draws); $this->assertEquals($normalBucket->id, $draws[0]->bucket_id); $this->assertEquals(20000, $draws[0]->amount); } public function test_handles_complex_mixed_bucket_scenario() { // Arrange: All bucket types in priority order $fixed1 = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, 'allocation_value' => 100000, 'starting_amount' => 0, 'priority' => 1, ]); $percentage1 = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE, 'allocation_value' => 15.00, // 15% 'starting_amount' => 0, 'priority' => 2, ]); $fixed2 = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, 'allocation_value' => 50000, 'starting_amount' => 0, 'priority' => 3, ]); $percentage2 = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE, 'allocation_value' => 25.00, // 25% 'starting_amount' => 0, 'priority' => 4, ]); $unlimited = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::UNLIMITED, 'allocation_value' => null, 'starting_amount' => 0, 'priority' => 5, ]); // Act: Allocate $5000 $draws = $this->service->allocateInflow($this->scenario, 500000); // Assert: Complex allocation logic $this->assertCount(5, $draws); // Fixed1: gets $1000 (full capacity) $this->assertEquals(100000, $draws[0]->amount); // Percentage1: gets 15% of remaining $4000 = $600 $this->assertEquals(60000, $draws[1]->amount); // Fixed2: gets $500 (full capacity) $this->assertEquals(50000, $draws[2]->amount); // Remaining after fixed allocations: $5000 - $1000 - $600 - $500 = $2900 // Percentage2: gets 25% of remaining $2900 = $725 $this->assertEquals(72500, $draws[3]->amount); // Unlimited: gets remaining $2900 - $725 = $2175 $this->assertEquals(217500, $draws[4]->amount); } public function test_returns_empty_array_when_no_buckets() { // Act: Allocate to scenario with no buckets $draws = $this->service->allocateInflow($this->scenario, 100000); // Assert: No allocations made $this->assertEmpty($draws); } public function test_returns_empty_array_when_amount_is_zero() { // Arrange: Create bucket Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, 'allocation_value' => 50000, 'starting_amount' => 0, 'priority' => 1, ]); // Act: Allocate $0 $draws = $this->service->allocateInflow($this->scenario, 0); // Assert: No allocations made $this->assertEmpty($draws); } public function test_handles_negative_amount_gracefully() { // Arrange: Create bucket Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, 'allocation_value' => 50000, 'starting_amount' => 0, 'priority' => 1, ]); // Act: Allocate negative amount $draws = $this->service->allocateInflow($this->scenario, -10000); // Assert: No allocations made $this->assertEmpty($draws); } public function test_respects_bucket_priority_order() { // Arrange: Buckets in non-sequential priority order $bucket3 = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, 'allocation_value' => 10000, 'starting_amount' => 0, 'priority' => 10, // Higher number ]); $bucket1 = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, 'allocation_value' => 20000, 'starting_amount' => 0, 'priority' => 1, // Lower number (higher priority) ]); $bucket2 = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, 'allocation_value' => 15000, 'starting_amount' => 0, 'priority' => 5, // Middle ]); // Act: Allocate $250 $draws = $this->service->allocateInflow($this->scenario, 25000); // Assert: Priority order respected (1, 5, 10) $this->assertCount(2, $draws); $this->assertEquals($bucket1->id, $draws[0]->bucket_id); // Priority 1 first $this->assertEquals(20000, $draws[0]->amount); $this->assertEquals($bucket2->id, $draws[1]->bucket_id); // Priority 5 second $this->assertEquals(5000, $draws[1]->amount); // Partial fill } public function test_percentage_allocation_with_insufficient_remaining_amount() { // Arrange: Large fixed bucket + percentage bucket $fixedBucket = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, 'allocation_value' => 95000, 'starting_amount' => 0, 'priority' => 1, ]); $percentageBucket = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE, 'allocation_value' => 20.00, // 20% 'starting_amount' => 0, 'priority' => 2, ]); // Act: Allocate $1000 (only $50 left after fixed) $draws = $this->service->allocateInflow($this->scenario, 100000); // Assert: Percentage gets 20% of remaining $50 = $10 $this->assertCount(2, $draws); $this->assertEquals(95000, $draws[0]->amount); $this->assertEquals(1000, $draws[1]->amount); // 20% of $50 } }