From 0d820c150c2cc992f6d6abfac48b8acc36e6941a Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 31 Dec 2025 02:34:30 +0100 Subject: [PATCH] Projection generation system --- app/Actions/CreateBucketAction.php | 20 +- ...nType.php => BucketAllocationTypeEnum.php} | 2 +- app/Enums/StreamFrequencyEnum.php | 54 +++ app/Enums/StreamTypeEnum.php | 33 ++ app/Http/Controllers/ProjectionController.php | 30 ++ .../Requests/CalculateProjectionRequest.php | 29 ++ app/Http/Resources/DrawResource.php | 23 ++ app/Http/Resources/InflowResource.php | 22 ++ app/Http/Resources/OutflowResource.php | 23 ++ app/Http/Resources/ProjectionResource.php | 24 ++ app/Models/Bucket.php | 24 +- app/Models/Draw.php | 1 + app/Models/Outflow.php | 1 + app/Models/Scenario.php | 1 + app/Models/Stream.php | 58 ++-- .../Projection/PipelineAllocationService.php | 37 +- .../Projection/ProjectionGeneratorService.php | 145 ++++++++ database/factories/BucketFactory.php | 28 +- database/factories/StreamFactory.php | 97 ++++++ ...2025_12_30_202750_create_streams_table.php | 6 +- tests/Unit/Actions/CreateBucketActionTest.php | 54 +-- tests/Unit/BucketTest.php | 14 +- tests/Unit/PipelineAllocationServiceTest.php | 138 ++++---- tests/Unit/ProjectionGeneratorServiceTest.php | 316 ++++++++++++++++++ 24 files changed, 978 insertions(+), 202 deletions(-) rename app/Enums/{BucketAllocationType.php => BucketAllocationTypeEnum.php} (96%) create mode 100644 app/Enums/StreamFrequencyEnum.php create mode 100644 app/Enums/StreamTypeEnum.php create mode 100644 app/Http/Controllers/ProjectionController.php create mode 100644 app/Http/Requests/CalculateProjectionRequest.php create mode 100644 app/Http/Resources/DrawResource.php create mode 100644 app/Http/Resources/InflowResource.php create mode 100644 app/Http/Resources/OutflowResource.php create mode 100644 app/Http/Resources/ProjectionResource.php create mode 100644 app/Services/Projection/ProjectionGeneratorService.php create mode 100644 database/factories/StreamFactory.php create mode 100644 tests/Unit/ProjectionGeneratorServiceTest.php diff --git a/app/Actions/CreateBucketAction.php b/app/Actions/CreateBucketAction.php index 2175ba7..922454a 100644 --- a/app/Actions/CreateBucketAction.php +++ b/app/Actions/CreateBucketAction.php @@ -2,7 +2,7 @@ namespace App\Actions; -use App\Enums\BucketAllocationType; +use App\Enums\BucketAllocationTypeEnum; use App\Models\Bucket; use App\Models\Scenario; use Illuminate\Support\Facades\DB; @@ -13,7 +13,7 @@ class CreateBucketAction public function execute( Scenario $scenario, string $name, - BucketAllocationType $allocationType, + BucketAllocationTypeEnum $allocationType, ?float $allocationValue = null, ?int $priority = null ): Bucket { @@ -21,7 +21,7 @@ public function execute( $this->validateAllocationValue($allocationType, $allocationValue); // Set allocation_value to null for unlimited buckets - if ($allocationType === BucketAllocationType::UNLIMITED) { + if ($allocationType === BucketAllocationTypeEnum::UNLIMITED) { $allocationValue = null; } @@ -61,10 +61,10 @@ public function execute( /** * Validate allocation value based on allocation type. */ - private function validateAllocationValue(BucketAllocationType $allocationType, ?float $allocationValue): void + private function validateAllocationValue(BucketAllocationTypeEnum $allocationType, ?float $allocationValue): void { switch ($allocationType) { - case BucketAllocationType::FIXED_LIMIT: + case BucketAllocationTypeEnum::FIXED_LIMIT: if ($allocationValue === null) { throw new InvalidArgumentException('Fixed limit buckets require an allocation value'); } @@ -73,7 +73,7 @@ private function validateAllocationValue(BucketAllocationType $allocationType, ? } break; - case BucketAllocationType::PERCENTAGE: + case BucketAllocationTypeEnum::PERCENTAGE: if ($allocationValue === null) { throw new InvalidArgumentException('Percentage buckets require an allocation value'); } @@ -82,7 +82,7 @@ private function validateAllocationValue(BucketAllocationType $allocationType, ? } break; - case BucketAllocationType::UNLIMITED: + case BucketAllocationTypeEnum::UNLIMITED: // Unlimited buckets should not have an allocation value // We'll set it to null in the main method regardless break; @@ -100,7 +100,7 @@ public function createDefaultBuckets(Scenario $scenario): array $buckets[] = $this->execute( $scenario, 'Monthly Expenses', - BucketAllocationType::FIXED_LIMIT, + BucketAllocationTypeEnum::FIXED_LIMIT, 0, 1 ); @@ -109,7 +109,7 @@ public function createDefaultBuckets(Scenario $scenario): array $buckets[] = $this->execute( $scenario, 'Emergency Fund', - BucketAllocationType::FIXED_LIMIT, + BucketAllocationTypeEnum::FIXED_LIMIT, 0, 2 ); @@ -118,7 +118,7 @@ public function createDefaultBuckets(Scenario $scenario): array $buckets[] = $this->execute( $scenario, 'Investments', - BucketAllocationType::UNLIMITED, + BucketAllocationTypeEnum::UNLIMITED, null, 3 ); diff --git a/app/Enums/BucketAllocationType.php b/app/Enums/BucketAllocationTypeEnum.php similarity index 96% rename from app/Enums/BucketAllocationType.php rename to app/Enums/BucketAllocationTypeEnum.php index a8d5c69..a068e96 100644 --- a/app/Enums/BucketAllocationType.php +++ b/app/Enums/BucketAllocationTypeEnum.php @@ -2,7 +2,7 @@ namespace App\Enums; -enum BucketAllocationType: string +enum BucketAllocationTypeEnum: string { case FIXED_LIMIT = 'fixed_limit'; case PERCENTAGE = 'percentage'; diff --git a/app/Enums/StreamFrequencyEnum.php b/app/Enums/StreamFrequencyEnum.php new file mode 100644 index 0000000..1cef1ed --- /dev/null +++ b/app/Enums/StreamFrequencyEnum.php @@ -0,0 +1,54 @@ + 'One-time', + self::DAILY => 'Daily', + self::WEEKLY => 'Weekly', + self::BIWEEKLY => 'Bi-weekly', + self::MONTHLY => 'Monthly', + self::QUARTERLY => 'Quarterly', + self::YEARLY => 'Yearly', + }; + } + + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + public static function labels(): array + { + $labels = []; + foreach (self::cases() as $case) { + $labels[$case->value] = $case->label(); + } + return $labels; + } + + public function getMonthlyEquivalentMultiplier(): float + { + return match ($this) { + self::DAILY => 30.44, // Average days per month + self::WEEKLY => 4.33, // Average weeks per month + self::BIWEEKLY => 2.17, + self::MONTHLY => 1.0, + self::QUARTERLY => 1.0 / 3.0, + self::YEARLY => 1.0 / 12.0, + self::ONCE => 0.0, + }; + } +} \ No newline at end of file diff --git a/app/Enums/StreamTypeEnum.php b/app/Enums/StreamTypeEnum.php new file mode 100644 index 0000000..9218c85 --- /dev/null +++ b/app/Enums/StreamTypeEnum.php @@ -0,0 +1,33 @@ + 'Income', + self::EXPENSE => 'Expense', + }; + } + + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + public static function labels(): array + { + $labels = []; + + foreach (self::cases() as $case) { + $labels[$case->value] = $case->label(); + } + + return $labels; + } +} diff --git a/app/Http/Controllers/ProjectionController.php b/app/Http/Controllers/ProjectionController.php new file mode 100644 index 0000000..050ab18 --- /dev/null +++ b/app/Http/Controllers/ProjectionController.php @@ -0,0 +1,30 @@ +input('start_date')); + $endDate = Carbon::parse($request->input('end_date')); + + $projections = $this->projectionGeneratorService->generateProjections( + $scenario, + $startDate, + $endDate + ); + + return new ProjectionResource($projections); + } +} diff --git a/app/Http/Requests/CalculateProjectionRequest.php b/app/Http/Requests/CalculateProjectionRequest.php new file mode 100644 index 0000000..a3d6a80 --- /dev/null +++ b/app/Http/Requests/CalculateProjectionRequest.php @@ -0,0 +1,29 @@ + ['required', 'date', 'before_or_equal:end_date'], + 'end_date' => ['required', 'date', 'after_or_equal:start_date'], + ]; + } + + public function messages(): array + { + return [ + 'start_date.before_or_equal' => 'Start date must be before or equal to end date.', + 'end_date.after_or_equal' => 'End date must be after or equal to start date.', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Resources/DrawResource.php b/app/Http/Resources/DrawResource.php new file mode 100644 index 0000000..1b9ead3 --- /dev/null +++ b/app/Http/Resources/DrawResource.php @@ -0,0 +1,23 @@ + $this->id, + 'bucket_id' => $this->bucket_id, + 'amount' => $this->amount_currency, + 'formatted_amount' => $this->formatted_amount, + 'date' => $this->date->format('Y-m-d'), + 'description' => $this->description, + 'is_projected' => $this->is_projected, + 'priority_order' => $this->priority_order ?? null, + ]; + } +} diff --git a/app/Http/Resources/InflowResource.php b/app/Http/Resources/InflowResource.php new file mode 100644 index 0000000..c0e3097 --- /dev/null +++ b/app/Http/Resources/InflowResource.php @@ -0,0 +1,22 @@ + $this->id, + 'stream_id' => $this->stream_id, + 'amount' => $this->amount_currency, + 'formatted_amount' => $this->formatted_amount, + 'date' => $this->date->format('Y-m-d'), + 'description' => $this->description, + 'is_projected' => $this->is_projected, + ]; + } +} diff --git a/app/Http/Resources/OutflowResource.php b/app/Http/Resources/OutflowResource.php new file mode 100644 index 0000000..142d6d8 --- /dev/null +++ b/app/Http/Resources/OutflowResource.php @@ -0,0 +1,23 @@ + $this->id, + 'stream_id' => $this->stream_id, + 'bucket_id' => $this->bucket_id, + 'amount' => $this->amount_currency, + 'formatted_amount' => $this->formatted_amount, + 'date' => $this->date->format('Y-m-d'), + 'description' => $this->description, + 'is_projected' => $this->is_projected, + ]; + } +} diff --git a/app/Http/Resources/ProjectionResource.php b/app/Http/Resources/ProjectionResource.php new file mode 100644 index 0000000..aeba1b6 --- /dev/null +++ b/app/Http/Resources/ProjectionResource.php @@ -0,0 +1,24 @@ + InflowResource::collection($this->resource['inflows']), + 'outflows' => OutflowResource::collection($this->resource['outflows']), + 'draws' => DrawResource::collection($this->resource['draws']), + 'summary' => [ + 'total_inflow' => $this->resource['inflows']->sum('amount_currency'), + 'total_outflow' => $this->resource['outflows']->sum('amount_currency'), + 'total_allocated' => $this->resource['draws']->sum('amount_currency'), + 'net_cashflow' => $this->resource['inflows']->sum('amount_currency') - $this->resource['outflows']->sum('amount_currency'), + ], + ]; + } +} \ No newline at end of file diff --git a/app/Models/Bucket.php b/app/Models/Bucket.php index 61ff889..5725b9c 100644 --- a/app/Models/Bucket.php +++ b/app/Models/Bucket.php @@ -2,7 +2,7 @@ namespace App\Models; -use App\Enums\BucketAllocationType; +use App\Enums\BucketAllocationTypeEnum; use Database\Factories\BucketFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -15,9 +15,11 @@ * @property Scenario $scenario * @property string $name * @property int $priority - * @property BucketAllocationType $allocation_type + * @property BucketAllocationTypeEnum $allocation_type * @property float $starting_amount * @property float $allocation_value + * + * @method static BucketFactory factory() */ class Bucket extends Model { @@ -39,7 +41,7 @@ class Bucket extends Model 'sort_order' => 'integer', 'allocation_value' => 'decimal:2', 'starting_amount' => 'integer', - 'allocation_type' => BucketAllocationType::class, + 'allocation_type' => BucketAllocationTypeEnum::class, ]; public function scenario(): BelongsTo @@ -83,12 +85,12 @@ public function scopeOrderedBySortOrder($query) * Get the current balance of the bucket. * Calculates starting amount plus total draws (money allocated to bucket) minus total outflows (money spent from bucket). */ - public function getCurrentBalance(): float + public function getCurrentBalance(): int { - $totalDrawsCents = $this->draws()->sum('amount'); - $totalOutflowsCents = $this->outflows()->sum('amount'); + $totalDraws = $this->draws()->sum('amount'); + $totalOutflows = $this->outflows()->sum('amount'); - return ($this->starting_amount + $totalDrawsCents - $totalOutflowsCents) / 100; + return $this->starting_amount + $totalDraws - $totalOutflows; } /** @@ -96,7 +98,7 @@ public function getCurrentBalance(): float */ public function hasAvailableSpace(): bool { - if ($this->allocation_type !== BucketAllocationType::FIXED_LIMIT) { + if ($this->allocation_type !== BucketAllocationTypeEnum::FIXED_LIMIT) { return true; } @@ -108,7 +110,7 @@ public function hasAvailableSpace(): bool */ public function getAvailableSpace(): float { - if ($this->allocation_type !== BucketAllocationType::FIXED_LIMIT) { + if ($this->allocation_type !== BucketAllocationTypeEnum::FIXED_LIMIT) { return PHP_FLOAT_MAX; } @@ -138,7 +140,7 @@ public static function validationRules($scenarioId = null): array { $rules = [ 'name' => 'required|string|max:255', - 'allocation_type' => 'required|in:' . implode(',', BucketAllocationType::values()), + 'allocation_type' => 'required|in:' . implode(',', BucketAllocationTypeEnum::values()), 'priority' => 'required|integer|min:1', ]; @@ -153,7 +155,7 @@ public static function validationRules($scenarioId = null): array /** * Get allocation value validation rules based on type. */ - public static function allocationValueRules(BucketAllocationType $allocationType): array + public static function allocationValueRules(BucketAllocationTypeEnum $allocationType): array { return $allocationType->getAllocationValueRules(); } diff --git a/app/Models/Draw.php b/app/Models/Draw.php index 8040ae0..1bcae64 100644 --- a/app/Models/Draw.php +++ b/app/Models/Draw.php @@ -17,6 +17,7 @@ * @property Carbon $date * @property string $description * @property bool $is_projected + * @method static create(array $array) */ class Draw extends Model { diff --git a/app/Models/Outflow.php b/app/Models/Outflow.php index f569416..95033ca 100644 --- a/app/Models/Outflow.php +++ b/app/Models/Outflow.php @@ -18,6 +18,7 @@ * @property Carbon $date * @property string $description * @property bool $is_projected + * @method static create(array $array) */ class Outflow extends Model { diff --git a/app/Models/Scenario.php b/app/Models/Scenario.php index 9a8ca20..9744bf9 100644 --- a/app/Models/Scenario.php +++ b/app/Models/Scenario.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; /** + * @property int $id * @property Collection $buckets * @method static create(array $data) */ diff --git a/app/Models/Stream.php b/app/Models/Stream.php index 0e68039..6a2666b 100644 --- a/app/Models/Stream.php +++ b/app/Models/Stream.php @@ -2,22 +2,22 @@ namespace App\Models; +use App\Enums\StreamFrequencyEnum; +use App\Enums\StreamTypeEnum; use App\Models\Traits\HasAmount; +use Carbon\Carbon; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +/** + * @property int $amount + * @property StreamFrequencyEnum $frequency + * @property Carbon $start_date + */ class Stream extends Model { - use HasAmount; - const TYPE_INCOME = 'income'; - const TYPE_EXPENSE = 'expense'; - - const FREQUENCY_ONCE = 'once'; - const FREQUENCY_WEEKLY = 'weekly'; - const FREQUENCY_BIWEEKLY = 'biweekly'; - const FREQUENCY_MONTHLY = 'monthly'; - const FREQUENCY_QUARTERLY = 'quarterly'; - const FREQUENCY_YEARLY = 'yearly'; + use HasFactory, HasAmount; protected $fillable = [ 'scenario_id', @@ -33,6 +33,8 @@ class Stream extends Model ]; protected $casts = [ + 'type' => StreamTypeEnum::class, + 'frequency' => StreamFrequencyEnum::class, 'amount' => 'integer', 'start_date' => 'date', 'end_date' => 'date', @@ -44,7 +46,6 @@ public function scenario(): BelongsTo return $this->belongsTo(Scenario::class); } - public function bucket(): BelongsTo { return $this->belongsTo(Bucket::class); @@ -52,51 +53,30 @@ public function bucket(): BelongsTo public static function getTypes(): array { - return [ - self::TYPE_INCOME => 'Income', - self::TYPE_EXPENSE => 'Expense', - ]; + return StreamTypeEnum::labels(); } public static function getFrequencies(): array { - return [ - self::FREQUENCY_ONCE => 'One-time', - self::FREQUENCY_WEEKLY => 'Weekly', - self::FREQUENCY_BIWEEKLY => 'Bi-weekly', - self::FREQUENCY_MONTHLY => 'Monthly', - self::FREQUENCY_QUARTERLY => 'Quarterly', - self::FREQUENCY_YEARLY => 'Yearly', - ]; + return StreamFrequencyEnum::labels(); } public function getTypeLabel(): string { - return self::getTypes()[$this->type] ?? $this->type; + return $this->type?->label() ?? ''; } public function getFrequencyLabel(): string { - return self::getFrequencies()[$this->frequency] ?? $this->frequency; + return $this->frequency?->label() ?? ''; } public function getMonthlyEquivalent(): float { - switch ($this->frequency) { - case self::FREQUENCY_WEEKLY: - return $this->amount * 4.33; // Average weeks per month - case self::FREQUENCY_BIWEEKLY: - return $this->amount * 2.17; - case self::FREQUENCY_MONTHLY: - return $this->amount; - case self::FREQUENCY_QUARTERLY: - return $this->amount / 3; - case self::FREQUENCY_YEARLY: - return $this->amount / 12; - case self::FREQUENCY_ONCE: - default: - return 0; + if (!$this->frequency) { + return 0; } + return $this->amount * $this->frequency->getMonthlyEquivalentMultiplier(); } /* SCOPES */ diff --git a/app/Services/Projection/PipelineAllocationService.php b/app/Services/Projection/PipelineAllocationService.php index 5835e37..4c078b7 100644 --- a/app/Services/Projection/PipelineAllocationService.php +++ b/app/Services/Projection/PipelineAllocationService.php @@ -2,7 +2,7 @@ namespace App\Services\Projection; -use App\Enums\BucketAllocationType; +use App\Enums\BucketAllocationTypeEnum; use App\Models\Bucket; use App\Models\Draw; use App\Models\Scenario; @@ -15,12 +15,12 @@ * Allocate an inflow amount across scenario buckets according to priority rules. * * @param Scenario $scenario - * @param float $amount + * @param int $amount * @param Carbon|null $date * @param string|null $description * @return Collection Collection of Draw models */ - public function allocateInflow(Scenario $scenario, float $amount, ?Carbon $date = null, ?string $description = null): Collection + public function allocateInflow(Scenario $scenario, int $amount, ?Carbon $date = null, ?string $description = null): Collection { $draws = collect(); @@ -58,9 +58,6 @@ public function allocateInflow(Scenario $scenario, float $amount, ?Carbon $date 'is_projected' => true, ]); - // Add priority_order as a custom attribute for pipeline visualization - $draw->priority_order = $priorityOrder; - $draws->push($draw); $remainingAmount -= $allocation; $priorityOrder++; @@ -74,15 +71,15 @@ public function allocateInflow(Scenario $scenario, float $amount, ?Carbon $date * Calculate how much should be allocated to a specific bucket. * * @param Bucket $bucket - * @param float $remainingAmount - * @return float + * @param int $remainingAmount + * @return int */ - private function calculateBucketAllocation(Bucket $bucket, float $remainingAmount): float + private function calculateBucketAllocation(Bucket $bucket, int $remainingAmount): int { return match ($bucket->allocation_type) { - BucketAllocationType::FIXED_LIMIT => $this->calculateFixedAllocation($bucket, $remainingAmount), - BucketAllocationType::PERCENTAGE => $this->calculatePercentageAllocation($bucket, $remainingAmount), - BucketAllocationType::UNLIMITED => $remainingAmount, // Takes all remaining + BucketAllocationTypeEnum::FIXED_LIMIT => $this->calculateFixedAllocation($bucket, $remainingAmount), + BucketAllocationTypeEnum::PERCENTAGE => $this->calculatePercentageAllocation($bucket, $remainingAmount), + BucketAllocationTypeEnum::UNLIMITED => $remainingAmount, // Takes all remaining default => 0, }; } @@ -91,12 +88,12 @@ private function calculateBucketAllocation(Bucket $bucket, float $remainingAmoun * Calculate allocation for fixed limit buckets. * * @param Bucket $bucket - * @param float $remainingAmount - * @return float + * @param int $remainingAmount + * @return int */ - private function calculateFixedAllocation(Bucket $bucket, float $remainingAmount): float + private function calculateFixedAllocation(Bucket $bucket, int $remainingAmount): int { - $bucketCapacity = $bucket->allocation_value ?? 0; + $bucketCapacity = (int)($bucket->allocation_value ?? 0); $currentBalance = $bucket->getCurrentBalance(); $availableSpace = max(0, $bucketCapacity - $currentBalance); @@ -107,12 +104,12 @@ private function calculateFixedAllocation(Bucket $bucket, float $remainingAmount * Calculate allocation for percentage buckets. * * @param Bucket $bucket - * @param float $remainingAmount - * @return float + * @param int $remainingAmount + * @return int */ - private function calculatePercentageAllocation(Bucket $bucket, float $remainingAmount): float + private function calculatePercentageAllocation(Bucket $bucket, int $remainingAmount): int { $percentage = $bucket->allocation_value ?? 0; - return $remainingAmount * ($percentage / 100); + return (int)round($remainingAmount * ($percentage / 100)); } } diff --git a/app/Services/Projection/ProjectionGeneratorService.php b/app/Services/Projection/ProjectionGeneratorService.php new file mode 100644 index 0000000..5dfcad2 --- /dev/null +++ b/app/Services/Projection/ProjectionGeneratorService.php @@ -0,0 +1,145 @@ +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; + } +} diff --git a/database/factories/BucketFactory.php b/database/factories/BucketFactory.php index 3235250..ea822be 100644 --- a/database/factories/BucketFactory.php +++ b/database/factories/BucketFactory.php @@ -2,7 +2,7 @@ namespace Database\Factories; -use App\Enums\BucketAllocationType; +use App\Enums\BucketAllocationTypeEnum; use App\Models\Bucket; use App\Models\Scenario; use Illuminate\Database\Eloquent\Factories\Factory; @@ -15,9 +15,9 @@ class BucketFactory extends Factory public function definition(): array { $allocationType = $this->faker->randomElement([ - BucketAllocationType::FIXED_LIMIT, - BucketAllocationType::PERCENTAGE, - BucketAllocationType::UNLIMITED, + BucketAllocationTypeEnum::FIXED_LIMIT, + BucketAllocationTypeEnum::PERCENTAGE, + BucketAllocationTypeEnum::UNLIMITED, ]); return [ @@ -52,7 +52,7 @@ public function fixedLimit($amount = null): Factory $amount = $amount ?? $this->faker->numberBetween(500, 5000); return $this->state([ - 'allocation_type' => BucketAllocationType::FIXED_LIMIT, + 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, 'allocation_value' => $amount, ]); } @@ -65,7 +65,7 @@ public function percentage($percentage = null): Factory $percentage = $percentage ?? $this->faker->numberBetween(10, 50); return $this->state([ - 'allocation_type' => BucketAllocationType::PERCENTAGE, + 'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE, 'allocation_value' => $percentage, ]); } @@ -76,7 +76,7 @@ public function percentage($percentage = null): Factory public function unlimited(): Factory { return $this->state([ - 'allocation_type' => BucketAllocationType::UNLIMITED, + 'allocation_type' => BucketAllocationTypeEnum::UNLIMITED, 'allocation_value' => null, ]); } @@ -91,7 +91,7 @@ public function defaultSet(): array 'name' => 'Monthly Expenses', 'priority' => 1, 'sort_order' => 1, - 'allocation_type' => BucketAllocationType::FIXED_LIMIT, + 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, 'allocation_value' => 0, 'starting_amount' => 0, ]), @@ -99,7 +99,7 @@ public function defaultSet(): array 'name' => 'Emergency Fund', 'priority' => 2, 'sort_order' => 2, - 'allocation_type' => BucketAllocationType::FIXED_LIMIT, + 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, 'allocation_value' => 0, 'starting_amount' => 0, ]), @@ -107,7 +107,7 @@ public function defaultSet(): array 'name' => 'Investments', 'priority' => 3, 'sort_order' => 3, - 'allocation_type' => BucketAllocationType::UNLIMITED, + 'allocation_type' => BucketAllocationTypeEnum::UNLIMITED, 'allocation_value' => null, 'starting_amount' => 0, ]), @@ -117,12 +117,12 @@ public function defaultSet(): array /** * Get allocation value based on type. */ - private function getAllocationValueForType(BucketAllocationType $type): ?float + private function getAllocationValueForType(BucketAllocationTypeEnum $type): ?float { return match($type) { - BucketAllocationType::FIXED_LIMIT => $this->faker->numberBetween(100, 10000), - BucketAllocationType::PERCENTAGE => $this->faker->numberBetween(5, 50), - BucketAllocationType::UNLIMITED => null, + BucketAllocationTypeEnum::FIXED_LIMIT => $this->faker->numberBetween(100, 10000), + BucketAllocationTypeEnum::PERCENTAGE => $this->faker->numberBetween(5, 50), + BucketAllocationTypeEnum::UNLIMITED => null, }; } } \ No newline at end of file diff --git a/database/factories/StreamFactory.php b/database/factories/StreamFactory.php new file mode 100644 index 0000000..5cfb2a3 --- /dev/null +++ b/database/factories/StreamFactory.php @@ -0,0 +1,97 @@ + + */ +class StreamFactory extends Factory +{ + protected $model = Stream::class; + + public function definition(): array + { + $type = $this->faker->randomElement(StreamTypeEnum::cases()); + + return [ + 'scenario_id' => null, // Set in test + 'name' => $this->faker->words(3, true), + 'type' => $type, + 'amount' => $this->faker->numberBetween(5000, 200000), // $50 to $2000 + 'frequency' => $this->faker->randomElement([ + StreamFrequencyEnum::DAILY, + StreamFrequencyEnum::WEEKLY, + StreamFrequencyEnum::MONTHLY, + StreamFrequencyEnum::YEARLY, + ]), + 'start_date' => $this->faker->dateTimeBetween('-1 year', 'now'), + 'end_date' => $this->faker->optional(0.2)->dateTimeBetween('now', '+1 year'), + 'bucket_id' => null, // Only for expenses + 'description' => $this->faker->optional()->sentence(), + 'is_active' => true, + ]; + } + + public function scenario(Scenario $scenario): self + { + return $this->state(fn () => [ + 'scenario_id' => $scenario->id, + ]); + } + + public function income(): self + { + return $this->state(fn (array $attributes) => [ + 'type' => StreamTypeEnum::INCOME, + 'bucket_id' => null, + ]); + } + + public function expense(): self + { + return $this->state(fn (array $attributes) => [ + 'type' => StreamTypeEnum::EXPENSE, + ]); + } + + public function inactive(): self + { + return $this->state(fn (array $attributes) => [ + 'is_active' => false, + ]); + } + + public function daily(): self + { + return $this->state(fn (array $attributes) => [ + 'frequency' => StreamFrequencyEnum::DAILY, + ]); + } + + public function weekly(): self + { + return $this->state(fn (array $attributes) => [ + 'frequency' => StreamFrequencyEnum::WEEKLY, + ]); + } + + public function monthly(): self + { + return $this->state(fn (array $attributes) => [ + 'frequency' => StreamFrequencyEnum::MONTHLY, + ]); + } + + public function yearly(): self + { + return $this->state(fn (array $attributes) => [ + 'frequency' => StreamFrequencyEnum::YEARLY, + ]); + } +} diff --git a/database/migrations/2025_12_30_202750_create_streams_table.php b/database/migrations/2025_12_30_202750_create_streams_table.php index 8f5463f..95e8afc 100644 --- a/database/migrations/2025_12_30_202750_create_streams_table.php +++ b/database/migrations/2025_12_30_202750_create_streams_table.php @@ -1,5 +1,7 @@ string('name'); $table->boolean('is_active')->default(true); $table->unsignedBigInteger('amount'); - $table->enum('type', ['income', 'expense']); - $table->enum('frequency', ['once', 'weekly', 'biweekly', 'monthly', 'quarterly', 'yearly']); + $table->enum('type', StreamTypeEnum::values()); + $table->enum('frequency', StreamFrequencyEnum::values()); $table->date('start_date'); $table->date('end_date')->nullable(); $table->text('description')->nullable(); diff --git a/tests/Unit/Actions/CreateBucketActionTest.php b/tests/Unit/Actions/CreateBucketActionTest.php index 42782b8..c2a09d1 100644 --- a/tests/Unit/Actions/CreateBucketActionTest.php +++ b/tests/Unit/Actions/CreateBucketActionTest.php @@ -3,7 +3,7 @@ namespace Tests\Unit\Actions; use App\Actions\CreateBucketAction; -use App\Enums\BucketAllocationType; +use App\Enums\BucketAllocationTypeEnum; use App\Models\Bucket; use App\Models\Scenario; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -29,13 +29,13 @@ public function test_can_create_fixed_limit_bucket(): void $bucket = $this->action->execute( $this->scenario, 'Test Bucket', - BucketAllocationType::FIXED_LIMIT, + BucketAllocationTypeEnum::FIXED_LIMIT, 1000.00 ); $this->assertInstanceOf(Bucket::class, $bucket); $this->assertEquals('Test Bucket', $bucket->name); - $this->assertEquals(BucketAllocationType::FIXED_LIMIT, $bucket->allocation_type); + $this->assertEquals(BucketAllocationTypeEnum::FIXED_LIMIT, $bucket->allocation_type); $this->assertEquals(1000.00, $bucket->allocation_value); $this->assertEquals(1, $bucket->priority); $this->assertEquals(1, $bucket->sort_order); @@ -47,11 +47,11 @@ public function test_can_create_percentage_bucket(): void $bucket = $this->action->execute( $this->scenario, 'Percentage Bucket', - BucketAllocationType::PERCENTAGE, + BucketAllocationTypeEnum::PERCENTAGE, 25.5 ); - $this->assertEquals(BucketAllocationType::PERCENTAGE, $bucket->allocation_type); + $this->assertEquals(BucketAllocationTypeEnum::PERCENTAGE, $bucket->allocation_type); $this->assertEquals(25.5, $bucket->allocation_value); } @@ -60,10 +60,10 @@ public function test_can_create_unlimited_bucket(): void $bucket = $this->action->execute( $this->scenario, 'Unlimited Bucket', - BucketAllocationType::UNLIMITED + BucketAllocationTypeEnum::UNLIMITED ); - $this->assertEquals(BucketAllocationType::UNLIMITED, $bucket->allocation_type); + $this->assertEquals(BucketAllocationTypeEnum::UNLIMITED, $bucket->allocation_type); $this->assertNull($bucket->allocation_value); } @@ -72,11 +72,11 @@ public function test_unlimited_bucket_ignores_allocation_value(): void $bucket = $this->action->execute( $this->scenario, 'Unlimited Bucket', - BucketAllocationType::UNLIMITED, + BucketAllocationTypeEnum::UNLIMITED, 999.99 // This should be ignored and set to null ); - $this->assertEquals(BucketAllocationType::UNLIMITED, $bucket->allocation_type); + $this->assertEquals(BucketAllocationTypeEnum::UNLIMITED, $bucket->allocation_type); $this->assertNull($bucket->allocation_value); } @@ -85,14 +85,14 @@ public function test_priority_auto_increments_when_not_specified(): void $bucket1 = $this->action->execute( $this->scenario, 'First Bucket', - BucketAllocationType::FIXED_LIMIT, + BucketAllocationTypeEnum::FIXED_LIMIT, 100 ); $bucket2 = $this->action->execute( $this->scenario, 'Second Bucket', - BucketAllocationType::FIXED_LIMIT, + BucketAllocationTypeEnum::FIXED_LIMIT, 200 ); @@ -105,7 +105,7 @@ public function test_can_specify_custom_priority(): void $bucket = $this->action->execute( $this->scenario, 'Priority Bucket', - BucketAllocationType::FIXED_LIMIT, + BucketAllocationTypeEnum::FIXED_LIMIT, 100, 5 ); @@ -116,12 +116,12 @@ public function test_can_specify_custom_priority(): void public function test_existing_priorities_are_shifted_when_inserting(): void { // Create initial buckets - $bucket1 = $this->action->execute($this->scenario, 'Bucket 1', BucketAllocationType::FIXED_LIMIT, 100, 1); - $bucket2 = $this->action->execute($this->scenario, 'Bucket 2', BucketAllocationType::FIXED_LIMIT, 200, 2); - $bucket3 = $this->action->execute($this->scenario, 'Bucket 3', BucketAllocationType::FIXED_LIMIT, 300, 3); + $bucket1 = $this->action->execute($this->scenario, 'Bucket 1', BucketAllocationTypeEnum::FIXED_LIMIT, 100, 1); + $bucket2 = $this->action->execute($this->scenario, 'Bucket 2', BucketAllocationTypeEnum::FIXED_LIMIT, 200, 2); + $bucket3 = $this->action->execute($this->scenario, 'Bucket 3', BucketAllocationTypeEnum::FIXED_LIMIT, 300, 3); // Insert a bucket at priority 2 - $newBucket = $this->action->execute($this->scenario, 'New Bucket', BucketAllocationType::FIXED_LIMIT, 150, 2); + $newBucket = $this->action->execute($this->scenario, 'New Bucket', BucketAllocationTypeEnum::FIXED_LIMIT, 150, 2); // Refresh models from database $bucket1->refresh(); @@ -144,7 +144,7 @@ public function test_throws_exception_for_fixed_limit_without_allocation_value() $this->action->execute( $this->scenario, 'Test Bucket', - BucketAllocationType::FIXED_LIMIT, + BucketAllocationTypeEnum::FIXED_LIMIT, null ); } @@ -157,7 +157,7 @@ public function test_throws_exception_for_negative_fixed_limit_value(): void $this->action->execute( $this->scenario, 'Test Bucket', - BucketAllocationType::FIXED_LIMIT, + BucketAllocationTypeEnum::FIXED_LIMIT, -100 ); } @@ -170,7 +170,7 @@ public function test_throws_exception_for_percentage_without_allocation_value(): $this->action->execute( $this->scenario, 'Test Bucket', - BucketAllocationType::PERCENTAGE, + BucketAllocationTypeEnum::PERCENTAGE, null ); } @@ -183,7 +183,7 @@ public function test_throws_exception_for_percentage_below_minimum(): void $this->action->execute( $this->scenario, 'Test Bucket', - BucketAllocationType::PERCENTAGE, + BucketAllocationTypeEnum::PERCENTAGE, 0.005 ); } @@ -196,7 +196,7 @@ public function test_throws_exception_for_percentage_above_maximum(): void $this->action->execute( $this->scenario, 'Test Bucket', - BucketAllocationType::PERCENTAGE, + BucketAllocationTypeEnum::PERCENTAGE, 101 ); } @@ -209,7 +209,7 @@ public function test_throws_exception_for_negative_priority(): void $this->action->execute( $this->scenario, 'Test Bucket', - BucketAllocationType::FIXED_LIMIT, + BucketAllocationTypeEnum::FIXED_LIMIT, 100, 0 ); @@ -226,19 +226,19 @@ public function test_create_default_buckets(): void // Monthly Expenses $this->assertEquals('Monthly Expenses', $buckets[0]->name); $this->assertEquals(1, $buckets[0]->priority); - $this->assertEquals(BucketAllocationType::FIXED_LIMIT, $buckets[0]->allocation_type); + $this->assertEquals(BucketAllocationTypeEnum::FIXED_LIMIT, $buckets[0]->allocation_type); $this->assertEquals(0, $buckets[0]->allocation_value); // Emergency Fund $this->assertEquals('Emergency Fund', $buckets[1]->name); $this->assertEquals(2, $buckets[1]->priority); - $this->assertEquals(BucketAllocationType::FIXED_LIMIT, $buckets[1]->allocation_type); + $this->assertEquals(BucketAllocationTypeEnum::FIXED_LIMIT, $buckets[1]->allocation_type); $this->assertEquals(0, $buckets[1]->allocation_value); // Investments $this->assertEquals('Investments', $buckets[2]->name); $this->assertEquals(3, $buckets[2]->priority); - $this->assertEquals(BucketAllocationType::UNLIMITED, $buckets[2]->allocation_type); + $this->assertEquals(BucketAllocationTypeEnum::UNLIMITED, $buckets[2]->allocation_type); $this->assertNull($buckets[2]->allocation_value); } @@ -246,8 +246,8 @@ public function test_creates_buckets_in_database_transaction(): void { // This test ensures database consistency by creating multiple buckets // and verifying they all exist with correct priorities - $this->action->execute($this->scenario, 'Bucket 1', BucketAllocationType::FIXED_LIMIT, 100, 1); - $this->action->execute($this->scenario, 'Bucket 2', BucketAllocationType::FIXED_LIMIT, 200, 1); // Insert at priority 1 + $this->action->execute($this->scenario, 'Bucket 1', BucketAllocationTypeEnum::FIXED_LIMIT, 100, 1); + $this->action->execute($this->scenario, 'Bucket 2', BucketAllocationTypeEnum::FIXED_LIMIT, 200, 1); // Insert at priority 1 // Both buckets should exist with correct priorities $buckets = $this->scenario->buckets()->orderBy('priority')->get(); diff --git a/tests/Unit/BucketTest.php b/tests/Unit/BucketTest.php index c860038..da29f3b 100644 --- a/tests/Unit/BucketTest.php +++ b/tests/Unit/BucketTest.php @@ -2,7 +2,7 @@ namespace Tests\Unit; -use App\Enums\BucketAllocationType; +use App\Enums\BucketAllocationTypeEnum; use App\Models\Bucket; use App\Models\Draw; use App\Models\Outflow; @@ -21,7 +21,7 @@ public function test_current_balance_includes_starting_amount() $bucket = Bucket::factory()->create([ 'scenario_id' => $scenario->id, 'starting_amount' => 100000, // $1000 in cents - 'allocation_type' => BucketAllocationType::UNLIMITED, + 'allocation_type' => BucketAllocationTypeEnum::UNLIMITED, ]); // Create draws and outflows directly @@ -43,8 +43,8 @@ public function test_current_balance_includes_starting_amount() ]); // Act & Assert - // starting_amount (1000) + draws (500) - outflows (200) = 1300 - $this->assertEquals(1300.00, $bucket->getCurrentBalance()); + // starting_amount (100000) + draws (50000) - outflows (20000) = 130000 + $this->assertEquals(130000, $bucket->getCurrentBalance()); } public function test_current_balance_without_starting_amount_defaults_to_zero() @@ -54,7 +54,7 @@ public function test_current_balance_without_starting_amount_defaults_to_zero() $bucket = Bucket::factory()->create([ 'scenario_id' => $scenario->id, 'starting_amount' => 0, // $0 in cents - 'allocation_type' => BucketAllocationType::UNLIMITED, + 'allocation_type' => BucketAllocationTypeEnum::UNLIMITED, ]); // Create draws and outflows directly @@ -76,7 +76,7 @@ public function test_current_balance_without_starting_amount_defaults_to_zero() ]); // Act & Assert - // starting_amount (0) + draws (300) - outflows (100) = 200 - $this->assertEquals(200.00, $bucket->getCurrentBalance()); + // starting_amount (0) + draws (30000) - outflows (10000) = 20000 + $this->assertEquals(20000, $bucket->getCurrentBalance()); } } diff --git a/tests/Unit/PipelineAllocationServiceTest.php b/tests/Unit/PipelineAllocationServiceTest.php index e3688f7..9e49685 100644 --- a/tests/Unit/PipelineAllocationServiceTest.php +++ b/tests/Unit/PipelineAllocationServiceTest.php @@ -2,7 +2,7 @@ namespace Tests\Unit; -use App\Enums\BucketAllocationType; +use App\Enums\BucketAllocationTypeEnum; use App\Models\Bucket; use App\Models\Scenario; use App\Services\Projection\PipelineAllocationService; @@ -29,20 +29,19 @@ public function test_allocates_to_single_fixed_bucket() $bucket = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, 'name' => 'Emergency Fund', - 'allocation_type' => BucketAllocationType::FIXED_LIMIT, - 'allocation_value' => 500.00, + 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, + 'allocation_value' => 50000, 'starting_amount' => 0, 'priority' => 1 ]); // Act: Allocate $300 - $draws = $this->service->allocateInflow($this->scenario, 300.00); + $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(300.00, $draws[0]->amount); - $this->assertEquals(1, $draws[0]->priority_order); + $this->assertEquals(30000, $draws[0]->amount); } public function test_allocates_across_multiple_fixed_buckets_by_priority() @@ -50,46 +49,43 @@ 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' => BucketAllocationType::FIXED_LIMIT, - 'allocation_value' => 200.00, + 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, + 'allocation_value' => 20000, 'starting_amount' => 0, 'priority' => 1 ]); $bucket2 = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, - 'allocation_type' => BucketAllocationType::FIXED_LIMIT, - 'allocation_value' => 300.00, + 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, + 'allocation_value' => 30000, 'starting_amount' => 0, 'priority' => 2 ]); $bucket3 = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, - 'allocation_type' => BucketAllocationType::FIXED_LIMIT, - 'allocation_value' => 150.00, + '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, 550.00); + $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(200.00, $draws[0]->amount); - $this->assertEquals(1, $draws[0]->priority_order); + $this->assertEquals(20000, $draws[0]->amount); // Bucket 2: fully filled $this->assertEquals($bucket2->id, $draws[1]->bucket_id); - $this->assertEquals(300.00, $draws[1]->amount); - $this->assertEquals(2, $draws[1]->priority_order); + $this->assertEquals(30000, $draws[1]->amount); // Bucket 3: partially filled $this->assertEquals($bucket3->id, $draws[2]->bucket_id); - $this->assertEquals(50.00, $draws[2]->amount); - $this->assertEquals(3, $draws[2]->priority_order); + $this->assertEquals(5000, $draws[2]->amount); } public function test_percentage_bucket_gets_percentage_of_remaining_amount() @@ -97,26 +93,26 @@ 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' => BucketAllocationType::FIXED_LIMIT, - 'allocation_value' => 300.00, + 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, + 'allocation_value' => 30000, 'starting_amount' => 0, 'priority' => 1 ]); $percentageBucket = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, - 'allocation_type' => BucketAllocationType::PERCENTAGE, + 'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE, 'allocation_value' => 20.00, // 20% 'starting_amount' => 0, 'priority' => 2 ]); // Act: Allocate $1000 - $draws = $this->service->allocateInflow($this->scenario, 1000.00); + $draws = $this->service->allocateInflow($this->scenario, 100000); // Assert: Fixed gets $300, percentage gets 20% of remaining $700 = $140 $this->assertCount(2, $draws); - $this->assertEquals(300.00, $draws[0]->amount); // Fixed bucket - $this->assertEquals(140.00, $draws[1]->amount); // 20% of $700 + $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() @@ -124,26 +120,26 @@ public function test_unlimited_bucket_gets_all_remaining_amount() // Arrange: Fixed + unlimited buckets $fixedBucket = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, - 'allocation_type' => BucketAllocationType::FIXED_LIMIT, - 'allocation_value' => 500.00, + 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, + 'allocation_value' => 50000, 'starting_amount' => 0, 'priority' => 1 ]); $unlimitedBucket = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, - 'allocation_type' => BucketAllocationType::UNLIMITED, + 'allocation_type' => BucketAllocationTypeEnum::UNLIMITED, 'allocation_value' => null, 'starting_amount' => 0, 'priority' => 2 ]); // Act: Allocate $1500 - $draws = $this->service->allocateInflow($this->scenario, 1500.00); + $draws = $this->service->allocateInflow($this->scenario, 150000); // Assert: Fixed gets $500, unlimited gets remaining $1000 $this->assertCount(2, $draws); - $this->assertEquals(500.00, $draws[0]->amount); - $this->assertEquals(1000.00, $draws[1]->amount); + $this->assertEquals(50000, $draws[0]->amount); + $this->assertEquals(100000, $draws[1]->amount); } public function test_skips_buckets_with_zero_allocation() @@ -151,26 +147,26 @@ 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' => BucketAllocationType::FIXED_LIMIT, + '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' => BucketAllocationType::FIXED_LIMIT, - 'allocation_value' => 300.00, + 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, + 'allocation_value' => 30000, 'starting_amount' => 0, 'priority' => 2 ]); // Act: Allocate $200 - $draws = $this->service->allocateInflow($this->scenario, 200.00); + $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(200.00, $draws[0]->amount); + $this->assertEquals(20000, $draws[0]->amount); } public function test_handles_complex_mixed_bucket_scenario() @@ -178,67 +174,67 @@ 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' => BucketAllocationType::FIXED_LIMIT, - 'allocation_value' => 1000.00, + 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, + 'allocation_value' => 100000, 'starting_amount' => 0, 'priority' => 1 ]); $percentage1 = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, - 'allocation_type' => BucketAllocationType::PERCENTAGE, + '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' => BucketAllocationType::FIXED_LIMIT, - 'allocation_value' => 500.00, + 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, + 'allocation_value' => 50000, 'starting_amount' => 0, 'priority' => 3 ]); $percentage2 = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, - 'allocation_type' => BucketAllocationType::PERCENTAGE, + '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' => BucketAllocationType::UNLIMITED, + 'allocation_type' => BucketAllocationTypeEnum::UNLIMITED, 'allocation_value' => null, 'starting_amount' => 0, 'priority' => 5 ]); // Act: Allocate $5000 - $draws = $this->service->allocateInflow($this->scenario, 5000.00); + $draws = $this->service->allocateInflow($this->scenario, 500000); // Assert: Complex allocation logic $this->assertCount(5, $draws); // Fixed1: gets $1000 (full capacity) - $this->assertEquals(1000.00, $draws[0]->amount); + $this->assertEquals(100000, $draws[0]->amount); // Percentage1: gets 15% of remaining $4000 = $600 - $this->assertEquals(600.00, $draws[1]->amount); + $this->assertEquals(60000, $draws[1]->amount); // Fixed2: gets $500 (full capacity) - $this->assertEquals(500.00, $draws[2]->amount); + $this->assertEquals(50000, $draws[2]->amount); // Remaining after fixed allocations: $5000 - $1000 - $600 - $500 = $2900 // Percentage2: gets 25% of remaining $2900 = $725 - $this->assertEquals(725.00, $draws[3]->amount); + $this->assertEquals(72500, $draws[3]->amount); // Unlimited: gets remaining $2900 - $725 = $2175 - $this->assertEquals(2175.00, $draws[4]->amount); + $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, 1000.00); + $draws = $this->service->allocateInflow($this->scenario, 100000); // Assert: No allocations made $this->assertEmpty($draws); @@ -249,14 +245,14 @@ public function test_returns_empty_array_when_amount_is_zero() // Arrange: Create bucket Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, - 'allocation_type' => BucketAllocationType::FIXED_LIMIT, - 'allocation_value' => 500.00, + 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, + 'allocation_value' => 50000, 'starting_amount' => 0, 'priority' => 1 ]); // Act: Allocate $0 - $draws = $this->service->allocateInflow($this->scenario, 0.00); + $draws = $this->service->allocateInflow($this->scenario, 0); // Assert: No allocations made $this->assertEmpty($draws); @@ -267,14 +263,14 @@ public function test_handles_negative_amount_gracefully() // Arrange: Create bucket Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, - 'allocation_type' => BucketAllocationType::FIXED_LIMIT, - 'allocation_value' => 500.00, + 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, + 'allocation_value' => 50000, 'starting_amount' => 0, 'priority' => 1 ]); // Act: Allocate negative amount - $draws = $this->service->allocateInflow($this->scenario, -100.00); + $draws = $this->service->allocateInflow($this->scenario, -10000); // Assert: No allocations made $this->assertEmpty($draws); @@ -285,35 +281,35 @@ 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' => BucketAllocationType::FIXED_LIMIT, - 'allocation_value' => 100.00, + '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' => BucketAllocationType::FIXED_LIMIT, - 'allocation_value' => 200.00, + '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' => BucketAllocationType::FIXED_LIMIT, - 'allocation_value' => 150.00, + 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, + 'allocation_value' => 15000, 'starting_amount' => 0, 'priority' => 5 // Middle ]); // Act: Allocate $250 - $draws = $this->service->allocateInflow($this->scenario, 250.00); + $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(200.00, $draws[0]->amount); + $this->assertEquals(20000, $draws[0]->amount); $this->assertEquals($bucket2->id, $draws[1]->bucket_id); // Priority 5 second - $this->assertEquals(50.00, $draws[1]->amount); // Partial fill + $this->assertEquals(5000, $draws[1]->amount); // Partial fill } public function test_percentage_allocation_with_insufficient_remaining_amount() @@ -321,25 +317,25 @@ 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' => BucketAllocationType::FIXED_LIMIT, - 'allocation_value' => 950.00, + 'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT, + 'allocation_value' => 95000, 'starting_amount' => 0, 'priority' => 1 ]); $percentageBucket = Bucket::factory()->create([ 'scenario_id' => $this->scenario->id, - 'allocation_type' => BucketAllocationType::PERCENTAGE, + '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, 1000.00); + $draws = $this->service->allocateInflow($this->scenario, 100000); // Assert: Percentage gets 20% of remaining $50 = $10 $this->assertCount(2, $draws); - $this->assertEquals(950.00, $draws[0]->amount); - $this->assertEquals(10.00, $draws[1]->amount); // 20% of $50 + $this->assertEquals(95000, $draws[0]->amount); + $this->assertEquals(1000, $draws[1]->amount); // 20% of $50 } } diff --git a/tests/Unit/ProjectionGeneratorServiceTest.php b/tests/Unit/ProjectionGeneratorServiceTest.php new file mode 100644 index 0000000..2a3edc3 --- /dev/null +++ b/tests/Unit/ProjectionGeneratorServiceTest.php @@ -0,0 +1,316 @@ +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 + } +}