service = new ProjectionGeneratorService(new PipelineAllocationService()); $this->scenario = Scenario::factory()->create(); // Set a fixed "now" for consistent testing Carbon::setTestNow('2026-01-01'); } protected function tearDown(): void { Carbon::setTestNow(); parent::tearDown(); } public function test_generates_daily_income_stream_projections() { // Arrange: Daily income stream of $100 $stream = Stream::factory()->create([ 'scenario_id' => $this->scenario->id, 'type' => StreamTypeEnum::INCOME, 'frequency' => StreamFrequencyEnum::DAILY, 'amount' => 10000, // $100 in cents 'start_date' => Carbon::parse('2026-01-01'), 'end_date' => null, ]); // Act: Generate projections for 5 days $projections = $this->service->generateProjections( $this->scenario, Carbon::parse('2026-01-01'), Carbon::parse('2026-01-05') ); // Assert: Should have 5 inflows $this->assertCount(5, $projections['inflows']); $this->assertEquals(10000, $projections['inflows'][0]->amount); $this->assertEquals('2026-01-01', $projections['inflows'][0]->date->format('Y-m-d')); $this->assertEquals('2026-01-05', $projections['inflows'][4]->date->format('Y-m-d')); } public function test_generates_weekly_expense_stream_projections() { // Arrange: Weekly expense stream of $50 on Mondays $bucket = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::UNLIMITED, ]); $stream = Stream::factory()->create([ 'scenario_id' => $this->scenario->id, 'bucket_id' => $bucket->id, 'type' => StreamTypeEnum::EXPENSE, 'frequency' => StreamFrequencyEnum::WEEKLY, 'amount' => 5000, // $50 in cents 'start_date' => Carbon::parse('2026-01-01'), // Monday 'end_date' => null, ]); // Act: Generate projections for 2 weeks $projections = $this->service->generateProjections( $this->scenario, Carbon::parse('2026-01-01'), Carbon::parse('2026-01-14') ); // Assert: Should have 2 outflows (Jan 1 and Jan 8) $this->assertCount(2, $projections['outflows']); $this->assertEquals(5000, $projections['outflows'][0]->amount); $this->assertEquals('2026-01-01', $projections['outflows'][0]->date->format('Y-m-d')); $this->assertEquals('2026-01-08', $projections['outflows'][1]->date->format('Y-m-d')); } public function test_allocates_income_immediately_to_buckets() { // Arrange: Income stream and buckets with priority $bucket1 = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, 'allocation_value' => 30000, 'starting_amount' => 0, 'priority' => 1, ]); $bucket2 = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::UNLIMITED, 'allocation_value' => null, 'starting_amount' => 0, 'priority' => 2, ]); $stream = Stream::factory()->create([ 'scenario_id' => $this->scenario->id, 'type' => StreamTypeEnum::INCOME, 'frequency' => StreamFrequencyEnum::DAILY, 'amount' => 10000, // $100 daily 'start_date' => Carbon::parse('2026-01-01'), ]); // Act: Generate projections for 5 days ($500 total income) $projections = $this->service->generateProjections( $this->scenario, Carbon::parse('2026-01-01'), Carbon::parse('2026-01-05') ); // Assert: Draws should be created for each day's income $this->assertCount(5, $projections['inflows']); $this->assertGreaterThan(0, $projections['draws']->count()); // First 3 days should fill bucket1 ($300) $bucket1Draws = $projections['draws']->where('bucket_id', $bucket1->id); $this->assertEquals(30000, $bucket1Draws->sum('amount')); // $300 total // Remaining $200 should go to bucket2 $bucket2Draws = $projections['draws']->where('bucket_id', $bucket2->id); $this->assertEquals(20000, $bucket2Draws->sum('amount')); // $200 total } public function test_respects_stream_start_and_end_dates() { // Arrange: Stream that starts mid-period and ends before period ends $stream = Stream::factory()->create([ 'scenario_id' => $this->scenario->id, 'type' => StreamTypeEnum::INCOME, 'frequency' => StreamFrequencyEnum::DAILY, 'amount' => 10000, 'start_date' => Carbon::parse('2026-01-03'), 'end_date' => Carbon::parse('2026-01-07'), ]); // Act: Generate projections for longer period $projections = $this->service->generateProjections( $this->scenario, Carbon::parse('2026-01-01'), Carbon::parse('2026-01-10') ); // Assert: Should only have 5 inflows (Jan 3-7) $this->assertCount(5, $projections['inflows']); $this->assertEquals('2026-01-03', $projections['inflows'][0]->date->format('Y-m-d')); $this->assertEquals('2026-01-07', $projections['inflows'][4]->date->format('Y-m-d')); } public function test_handles_monthly_streams_correctly() { // Arrange: Monthly income on the 15th $stream = Stream::factory()->create([ 'scenario_id' => $this->scenario->id, 'type' => StreamTypeEnum::INCOME, 'frequency' => StreamFrequencyEnum::MONTHLY, 'amount' => 200000, // $2000 'start_date' => Carbon::parse('2026-01-15'), ]); // Act: Generate projections for 3 months $projections = $this->service->generateProjections( $this->scenario, Carbon::parse('2026-01-01'), Carbon::parse('2026-03-31') ); // Assert: Should have 3 inflows (Jan 15, Feb 15, Mar 15) $this->assertCount(3, $projections['inflows']); $this->assertEquals('2026-01-15', $projections['inflows'][0]->date->format('Y-m-d')); $this->assertEquals('2026-02-15', $projections['inflows'][1]->date->format('Y-m-d')); $this->assertEquals('2026-03-15', $projections['inflows'][2]->date->format('Y-m-d')); } public function test_processes_multiple_streams_on_same_day() { // Arrange: Two income streams that fire on the same day $bucket = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::UNLIMITED, 'starting_amount' => 0, 'priority' => 1, ]); $stream1 = Stream::factory()->create([ 'scenario_id' => $this->scenario->id, 'type' => StreamTypeEnum::INCOME, 'frequency' => StreamFrequencyEnum::DAILY, 'amount' => 10000, // $100 'start_date' => Carbon::parse('2026-01-01'), ]); $stream2 = Stream::factory()->create([ 'scenario_id' => $this->scenario->id, 'type' => StreamTypeEnum::INCOME, 'frequency' => StreamFrequencyEnum::DAILY, 'amount' => 5000, // $50 'start_date' => Carbon::parse('2026-01-01'), ]); // Act: Generate projections for 1 day $projections = $this->service->generateProjections( $this->scenario, Carbon::parse('2026-01-01'), Carbon::parse('2026-01-01') ); // Assert: Should have 2 inflows and draws totaling $150 $this->assertCount(2, $projections['inflows']); $this->assertEquals(15000, $projections['inflows']->sum('amount')); // $150 total $this->assertEquals(15000, $projections['draws']->sum('amount')); // All allocated } public function test_handles_mixed_income_and_expense_streams() { // Arrange: Income and expense streams $bucket = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::UNLIMITED, 'starting_amount' => 0, 'priority' => 1, ]); $incomeStream = Stream::factory()->create([ 'scenario_id' => $this->scenario->id, 'type' => StreamTypeEnum::INCOME, 'frequency' => StreamFrequencyEnum::DAILY, 'amount' => 10000, // $100 'start_date' => Carbon::parse('2026-01-01'), ]); $expenseStream = Stream::factory()->create([ 'scenario_id' => $this->scenario->id, 'bucket_id' => $bucket->id, 'type' => StreamTypeEnum::EXPENSE, 'frequency' => StreamFrequencyEnum::DAILY, 'amount' => 3000, // $30 'start_date' => Carbon::parse('2026-01-01'), ]); // Act: Generate projections for 5 days $projections = $this->service->generateProjections( $this->scenario, Carbon::parse('2026-01-01'), Carbon::parse('2026-01-05') ); // Assert: Should have both inflows and outflows $this->assertCount(5, $projections['inflows']); $this->assertCount(5, $projections['outflows']); $this->assertEquals(50000, $projections['inflows']->sum('amount')); // $500 income $this->assertEquals(15000, $projections['outflows']->sum('amount')); // $150 expenses $this->assertEquals(50000, $projections['draws']->sum('amount')); // All income allocated } public function test_summary_calculations_are_accurate() { // Arrange: Income and expense streams $bucket = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'allocation_type' => BucketAllocationTypeEnum::UNLIMITED, 'starting_amount' => 0, 'priority' => 1, ]); Stream::factory()->create([ 'scenario_id' => $this->scenario->id, 'type' => StreamTypeEnum::INCOME, 'frequency' => StreamFrequencyEnum::DAILY, 'amount' => 10000, // $100 'start_date' => Carbon::parse('2026-01-01'), ]); Stream::factory()->create([ 'scenario_id' => $this->scenario->id, 'bucket_id' => $bucket->id, 'type' => StreamTypeEnum::EXPENSE, 'frequency' => StreamFrequencyEnum::DAILY, 'amount' => 3000, // $30 'start_date' => Carbon::parse('2026-01-01'), ]); // Act: Generate projections for 10 days $projections = $this->service->generateProjections( $this->scenario, Carbon::parse('2026-01-01'), Carbon::parse('2026-01-10') ); // Assert: Summary should be accurate $this->assertEquals(100000, $projections['summary']['total_inflow']); // $1000 $this->assertEquals(30000, $projections['summary']['total_outflow']); // $300 $this->assertEquals(100000, $projections['summary']['total_allocated']); // $1000 $this->assertEquals(70000, $projections['summary']['net_cashflow']); // $700 } }