buckets/app/Services/Projection/ProjectionGeneratorService.php

145 lines
5.3 KiB
PHP

<?php
namespace App\Services\Projection;
use App\Enums\StreamFrequencyEnum;
use App\Enums\StreamTypeEnum;
use App\Models\Inflow;
use App\Models\Outflow;
use App\Models\Scenario;
use App\Models\Stream;
use Carbon\Carbon;
class ProjectionGeneratorService
{
public function __construct(
private readonly PipelineAllocationService $pipelineAllocationService
) {}
public function generateProjections(Scenario $scenario, Carbon $startDate, Carbon $endDate): array
{
$inflows = collect();
$outflows = collect();
$draws = collect();
// Get active streams
$activeStreams = $scenario->streams()
->where('is_active', true)
->get();
// Process each day in the range
$currentDate = $startDate->copy();
while ($currentDate <= $endDate) {
// Process all streams that fire on this date
foreach ($activeStreams as $stream) {
if ($this->streamFiresOnDate($stream, $currentDate)) {
if ($stream->type === StreamTypeEnum::INCOME) {
// Create and save inflow
$inflow = Inflow::create([
'stream_id' => $stream->id,
'amount' => $stream->amount,
'date' => $currentDate->copy(),
'description' => "Projected income from {$stream->name}",
'is_projected' => true,
]);
$inflows->push($inflow);
// Immediately allocate this income to buckets
$dailyDraws = $this->pipelineAllocationService->allocateInflow(
$scenario,
$inflow->amount
);
// Set date and description for each draw and save
foreach ($dailyDraws as $draw) {
$draw->date = $currentDate->copy();
$draw->description = "Allocation from {$stream->name}";
$draw->is_projected = true;
$draw->save();
}
$draws = $draws->merge($dailyDraws);
} else {
// Create and save outflow
$outflow = Outflow::create([
'stream_id' => $stream->id,
'bucket_id' => $stream->bucket_id,
'amount' => $stream->amount,
'date' => $currentDate->copy(),
'description' => "Projected expense from {$stream->name}",
'is_projected' => true,
]);
$outflows->push($outflow);
}
}
}
// Move to next day
$currentDate->addDay();
}
// Calculate summary statistics
$summary = [
'total_inflow' => $inflows->sum('amount'),
'total_outflow' => $outflows->sum('amount'),
'total_allocated' => $draws->sum('amount'),
'net_cashflow' => $inflows->sum('amount') - $outflows->sum('amount'),
];
return [
'inflows' => $inflows->sortBy('date')->values(),
'outflows' => $outflows->sortBy('date')->values(),
'draws' => $draws->sortBy('date')->values(),
'summary' => $summary,
];
}
private function streamFiresOnDate(Stream $stream, Carbon $date): bool
{
// Check if date is within stream's active period
if ($date->lt($stream->start_date)) {
return false;
}
if ($stream->end_date && $date->gt($stream->end_date)) {
return false;
}
// Check frequency-specific rules
return match ($stream->frequency) {
StreamFrequencyEnum::DAILY => true,
StreamFrequencyEnum::WEEKLY => $this->isWeeklyOccurrence($stream, $date),
StreamFrequencyEnum::MONTHLY => $this->isMonthlyOccurrence($stream, $date),
StreamFrequencyEnum::YEARLY => $this->isYearlyOccurrence($stream, $date),
default => false,
};
}
private function isWeeklyOccurrence(Stream $stream, Carbon $date): bool
{
// Check if it's the same day of week as the start date
return $date->dayOfWeek === $stream->start_date->dayOfWeek;
}
private function isMonthlyOccurrence(Stream $stream, Carbon $date): bool
{
// Check if it's the same day of month as the start date
// Handle end-of-month cases (e.g., 31st in months with fewer days)
$targetDay = $stream->start_date->day;
$lastDayOfMonth = $date->copy()->endOfMonth()->day;
if ($targetDay > $lastDayOfMonth) {
// If the target day doesn't exist in this month, use the last day
return $date->day === $lastDayOfMonth;
}
return $date->day === $targetDay;
}
private function isYearlyOccurrence(Stream $stream, Carbon $date): bool
{
// Check if it's the same month and day as the start date
return $date->month === $stream->start_date->month
&& $date->day === $stream->start_date->day;
}
}