Projection generation system

This commit is contained in:
myrmidex 2025-12-31 02:34:30 +01:00
parent 8df75b0a2a
commit 0d820c150c
24 changed files with 978 additions and 202 deletions

View file

@ -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
);

View file

@ -2,7 +2,7 @@
namespace App\Enums;
enum BucketAllocationType: string
enum BucketAllocationTypeEnum: string
{
case FIXED_LIMIT = 'fixed_limit';
case PERCENTAGE = 'percentage';

View file

@ -0,0 +1,54 @@
<?php
namespace App\Enums;
enum StreamFrequencyEnum: string
{
case ONCE = 'once';
case DAILY = 'daily';
case WEEKLY = 'weekly';
case BIWEEKLY = 'biweekly';
case MONTHLY = 'monthly';
case QUARTERLY = 'quarterly';
case YEARLY = 'yearly';
public function label(): string
{
return match ($this) {
self::ONCE => '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,
};
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Enums;
enum StreamTypeEnum: string
{
case INCOME = 'income';
case EXPENSE = 'expense';
public function label(): string
{
return match ($this) {
self::INCOME => '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;
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\CalculateProjectionRequest;
use App\Http\Resources\ProjectionResource;
use App\Models\Scenario;
use App\Services\Projection\ProjectionGeneratorService;
use Carbon\Carbon;
class ProjectionController extends Controller
{
public function __construct(
private readonly ProjectionGeneratorService $projectionGeneratorService
) {}
public function calculate(CalculateProjectionRequest $request, Scenario $scenario): ProjectionResource
{
$startDate = Carbon::parse($request->input('start_date'));
$endDate = Carbon::parse($request->input('end_date'));
$projections = $this->projectionGeneratorService->generateProjections(
$scenario,
$startDate,
$endDate
);
return new ProjectionResource($projections);
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CalculateProjectionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'start_date' => ['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.',
];
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class DrawResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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,
];
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class InflowResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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,
];
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class OutflowResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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,
];
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ProjectionResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'inflows' => 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'),
],
];
}
}

View file

@ -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();
}

View file

@ -17,6 +17,7 @@
* @property Carbon $date
* @property string $description
* @property bool $is_projected
* @method static create(array $array)
*/
class Draw extends Model
{

View file

@ -18,6 +18,7 @@
* @property Carbon $date
* @property string $description
* @property bool $is_projected
* @method static create(array $array)
*/
class Outflow extends Model
{

View file

@ -9,6 +9,7 @@
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property Collection<Bucket> $buckets
* @method static create(array $data)
*/

View file

@ -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 */

View file

@ -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<Draw> 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));
}
}

View file

@ -0,0 +1,145 @@
<?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;
}
}

View file

@ -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,
};
}
}

View file

@ -0,0 +1,97 @@
<?php
namespace Database\Factories;
use App\Enums\StreamFrequencyEnum;
use App\Enums\StreamTypeEnum;
use App\Models\Scenario;
use App\Models\Stream;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Stream>
*/
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,
]);
}
}

View file

@ -1,5 +1,7 @@
<?php
use App\Enums\StreamFrequencyEnum;
use App\Enums\StreamTypeEnum;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@ -15,8 +17,8 @@ public function up(): void
$table->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();

View file

@ -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();

View file

@ -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());
}
}

View file

@ -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
}
}

View file

@ -0,0 +1,316 @@
<?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;
private Scenario $scenario;
protected function setUp(): void
{
parent::setUp();
$this->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
}
}