2025-12-31 02:34:30 +01:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace Tests\Unit;
|
|
|
|
|
|
|
|
|
|
use App\Enums\BucketAllocationTypeEnum;
|
|
|
|
|
use App\Enums\StreamFrequencyEnum;
|
|
|
|
|
use App\Enums\StreamTypeEnum;
|
|
|
|
|
use App\Models\Bucket;
|
|
|
|
|
use App\Models\Scenario;
|
|
|
|
|
use App\Models\Stream;
|
|
|
|
|
use App\Services\Projection\PipelineAllocationService;
|
|
|
|
|
use App\Services\Projection\ProjectionGeneratorService;
|
|
|
|
|
use Carbon\Carbon;
|
|
|
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
|
|
|
use Tests\TestCase;
|
|
|
|
|
|
|
|
|
|
class ProjectionGeneratorServiceTest extends TestCase
|
|
|
|
|
{
|
|
|
|
|
use RefreshDatabase;
|
|
|
|
|
|
|
|
|
|
private ProjectionGeneratorService $service;
|
2026-03-19 01:09:47 +01:00
|
|
|
|
2025-12-31 02:34:30 +01:00
|
|
|
private Scenario $scenario;
|
|
|
|
|
|
|
|
|
|
protected function setUp(): void
|
|
|
|
|
{
|
|
|
|
|
parent::setUp();
|
2026-03-19 01:09:47 +01:00
|
|
|
$this->service = new ProjectionGeneratorService(new PipelineAllocationService);
|
2025-12-31 02:34:30 +01:00
|
|
|
$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
|
2026-03-21 23:55:21 +01:00
|
|
|
$bucket1 = Bucket::factory()->need()->fixedLimit(30000)->create([
|
2025-12-31 02:34:30 +01:00
|
|
|
'scenario_id' => $this->scenario->id,
|
|
|
|
|
'starting_amount' => 0,
|
|
|
|
|
'priority' => 1,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-03-21 23:55:21 +01:00
|
|
|
$bucket2 = Bucket::factory()->overflow()->create([
|
2025-12-31 02:34:30 +01:00
|
|
|
'scenario_id' => $this->scenario->id,
|
|
|
|
|
'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
|
2026-03-21 23:55:21 +01:00
|
|
|
$bucket = Bucket::factory()->overflow()->create([
|
2025-12-31 02:34:30 +01:00
|
|
|
'scenario_id' => $this->scenario->id,
|
|
|
|
|
'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
|
2026-03-21 23:55:21 +01:00
|
|
|
$bucket = Bucket::factory()->overflow()->create([
|
2025-12-31 02:34:30 +01:00
|
|
|
'scenario_id' => $this->scenario->id,
|
|
|
|
|
'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
|
2026-03-21 23:55:21 +01:00
|
|
|
$bucket = Bucket::factory()->overflow()->create([
|
2025-12-31 02:34:30 +01:00
|
|
|
'scenario_id' => $this->scenario->id,
|
|
|
|
|
'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
|
|
|
|
|
}
|
|
|
|
|
}
|