Projection generation system
This commit is contained in:
parent
8df75b0a2a
commit
0d820c150c
24 changed files with 978 additions and 202 deletions
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
namespace App\Actions;
|
namespace App\Actions;
|
||||||
|
|
||||||
use App\Enums\BucketAllocationType;
|
use App\Enums\BucketAllocationTypeEnum;
|
||||||
use App\Models\Bucket;
|
use App\Models\Bucket;
|
||||||
use App\Models\Scenario;
|
use App\Models\Scenario;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
@ -13,7 +13,7 @@ class CreateBucketAction
|
||||||
public function execute(
|
public function execute(
|
||||||
Scenario $scenario,
|
Scenario $scenario,
|
||||||
string $name,
|
string $name,
|
||||||
BucketAllocationType $allocationType,
|
BucketAllocationTypeEnum $allocationType,
|
||||||
?float $allocationValue = null,
|
?float $allocationValue = null,
|
||||||
?int $priority = null
|
?int $priority = null
|
||||||
): Bucket {
|
): Bucket {
|
||||||
|
|
@ -21,7 +21,7 @@ public function execute(
|
||||||
$this->validateAllocationValue($allocationType, $allocationValue);
|
$this->validateAllocationValue($allocationType, $allocationValue);
|
||||||
|
|
||||||
// Set allocation_value to null for unlimited buckets
|
// Set allocation_value to null for unlimited buckets
|
||||||
if ($allocationType === BucketAllocationType::UNLIMITED) {
|
if ($allocationType === BucketAllocationTypeEnum::UNLIMITED) {
|
||||||
$allocationValue = null;
|
$allocationValue = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,10 +61,10 @@ public function execute(
|
||||||
/**
|
/**
|
||||||
* Validate allocation value based on allocation type.
|
* 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) {
|
switch ($allocationType) {
|
||||||
case BucketAllocationType::FIXED_LIMIT:
|
case BucketAllocationTypeEnum::FIXED_LIMIT:
|
||||||
if ($allocationValue === null) {
|
if ($allocationValue === null) {
|
||||||
throw new InvalidArgumentException('Fixed limit buckets require an allocation value');
|
throw new InvalidArgumentException('Fixed limit buckets require an allocation value');
|
||||||
}
|
}
|
||||||
|
|
@ -73,7 +73,7 @@ private function validateAllocationValue(BucketAllocationType $allocationType, ?
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case BucketAllocationType::PERCENTAGE:
|
case BucketAllocationTypeEnum::PERCENTAGE:
|
||||||
if ($allocationValue === null) {
|
if ($allocationValue === null) {
|
||||||
throw new InvalidArgumentException('Percentage buckets require an allocation value');
|
throw new InvalidArgumentException('Percentage buckets require an allocation value');
|
||||||
}
|
}
|
||||||
|
|
@ -82,7 +82,7 @@ private function validateAllocationValue(BucketAllocationType $allocationType, ?
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case BucketAllocationType::UNLIMITED:
|
case BucketAllocationTypeEnum::UNLIMITED:
|
||||||
// Unlimited buckets should not have an allocation value
|
// Unlimited buckets should not have an allocation value
|
||||||
// We'll set it to null in the main method regardless
|
// We'll set it to null in the main method regardless
|
||||||
break;
|
break;
|
||||||
|
|
@ -100,7 +100,7 @@ public function createDefaultBuckets(Scenario $scenario): array
|
||||||
$buckets[] = $this->execute(
|
$buckets[] = $this->execute(
|
||||||
$scenario,
|
$scenario,
|
||||||
'Monthly Expenses',
|
'Monthly Expenses',
|
||||||
BucketAllocationType::FIXED_LIMIT,
|
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
0,
|
0,
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
|
|
@ -109,7 +109,7 @@ public function createDefaultBuckets(Scenario $scenario): array
|
||||||
$buckets[] = $this->execute(
|
$buckets[] = $this->execute(
|
||||||
$scenario,
|
$scenario,
|
||||||
'Emergency Fund',
|
'Emergency Fund',
|
||||||
BucketAllocationType::FIXED_LIMIT,
|
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
0,
|
0,
|
||||||
2
|
2
|
||||||
);
|
);
|
||||||
|
|
@ -118,7 +118,7 @@ public function createDefaultBuckets(Scenario $scenario): array
|
||||||
$buckets[] = $this->execute(
|
$buckets[] = $this->execute(
|
||||||
$scenario,
|
$scenario,
|
||||||
'Investments',
|
'Investments',
|
||||||
BucketAllocationType::UNLIMITED,
|
BucketAllocationTypeEnum::UNLIMITED,
|
||||||
null,
|
null,
|
||||||
3
|
3
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
namespace App\Enums;
|
namespace App\Enums;
|
||||||
|
|
||||||
enum BucketAllocationType: string
|
enum BucketAllocationTypeEnum: string
|
||||||
{
|
{
|
||||||
case FIXED_LIMIT = 'fixed_limit';
|
case FIXED_LIMIT = 'fixed_limit';
|
||||||
case PERCENTAGE = 'percentage';
|
case PERCENTAGE = 'percentage';
|
||||||
54
app/Enums/StreamFrequencyEnum.php
Normal file
54
app/Enums/StreamFrequencyEnum.php
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Enums/StreamTypeEnum.php
Normal file
33
app/Enums/StreamTypeEnum.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Http/Controllers/ProjectionController.php
Normal file
30
app/Http/Controllers/ProjectionController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Http/Requests/CalculateProjectionRequest.php
Normal file
29
app/Http/Requests/CalculateProjectionRequest.php
Normal 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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Http/Resources/DrawResource.php
Normal file
23
app/Http/Resources/DrawResource.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Http/Resources/InflowResource.php
Normal file
22
app/Http/Resources/InflowResource.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Http/Resources/OutflowResource.php
Normal file
23
app/Http/Resources/OutflowResource.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Http/Resources/ProjectionResource.php
Normal file
24
app/Http/Resources/ProjectionResource.php
Normal 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'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Enums\BucketAllocationType;
|
use App\Enums\BucketAllocationTypeEnum;
|
||||||
use Database\Factories\BucketFactory;
|
use Database\Factories\BucketFactory;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
@ -15,9 +15,11 @@
|
||||||
* @property Scenario $scenario
|
* @property Scenario $scenario
|
||||||
* @property string $name
|
* @property string $name
|
||||||
* @property int $priority
|
* @property int $priority
|
||||||
* @property BucketAllocationType $allocation_type
|
* @property BucketAllocationTypeEnum $allocation_type
|
||||||
* @property float $starting_amount
|
* @property float $starting_amount
|
||||||
* @property float $allocation_value
|
* @property float $allocation_value
|
||||||
|
*
|
||||||
|
* @method static BucketFactory factory()
|
||||||
*/
|
*/
|
||||||
class Bucket extends Model
|
class Bucket extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -39,7 +41,7 @@ class Bucket extends Model
|
||||||
'sort_order' => 'integer',
|
'sort_order' => 'integer',
|
||||||
'allocation_value' => 'decimal:2',
|
'allocation_value' => 'decimal:2',
|
||||||
'starting_amount' => 'integer',
|
'starting_amount' => 'integer',
|
||||||
'allocation_type' => BucketAllocationType::class,
|
'allocation_type' => BucketAllocationTypeEnum::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
public function scenario(): BelongsTo
|
public function scenario(): BelongsTo
|
||||||
|
|
@ -83,12 +85,12 @@ public function scopeOrderedBySortOrder($query)
|
||||||
* Get the current balance of the bucket.
|
* Get the current balance of the bucket.
|
||||||
* Calculates starting amount plus total draws (money allocated to bucket) minus total outflows (money spent from 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');
|
$totalDraws = $this->draws()->sum('amount');
|
||||||
$totalOutflowsCents = $this->outflows()->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
|
public function hasAvailableSpace(): bool
|
||||||
{
|
{
|
||||||
if ($this->allocation_type !== BucketAllocationType::FIXED_LIMIT) {
|
if ($this->allocation_type !== BucketAllocationTypeEnum::FIXED_LIMIT) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,7 +110,7 @@ public function hasAvailableSpace(): bool
|
||||||
*/
|
*/
|
||||||
public function getAvailableSpace(): float
|
public function getAvailableSpace(): float
|
||||||
{
|
{
|
||||||
if ($this->allocation_type !== BucketAllocationType::FIXED_LIMIT) {
|
if ($this->allocation_type !== BucketAllocationTypeEnum::FIXED_LIMIT) {
|
||||||
return PHP_FLOAT_MAX;
|
return PHP_FLOAT_MAX;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,7 +140,7 @@ public static function validationRules($scenarioId = null): array
|
||||||
{
|
{
|
||||||
$rules = [
|
$rules = [
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'allocation_type' => 'required|in:' . implode(',', BucketAllocationType::values()),
|
'allocation_type' => 'required|in:' . implode(',', BucketAllocationTypeEnum::values()),
|
||||||
'priority' => 'required|integer|min:1',
|
'priority' => 'required|integer|min:1',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -153,7 +155,7 @@ public static function validationRules($scenarioId = null): array
|
||||||
/**
|
/**
|
||||||
* Get allocation value validation rules based on type.
|
* 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();
|
return $allocationType->getAllocationValueRules();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
* @property Carbon $date
|
* @property Carbon $date
|
||||||
* @property string $description
|
* @property string $description
|
||||||
* @property bool $is_projected
|
* @property bool $is_projected
|
||||||
|
* @method static create(array $array)
|
||||||
*/
|
*/
|
||||||
class Draw extends Model
|
class Draw extends Model
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
* @property Carbon $date
|
* @property Carbon $date
|
||||||
* @property string $description
|
* @property string $description
|
||||||
* @property bool $is_projected
|
* @property bool $is_projected
|
||||||
|
* @method static create(array $array)
|
||||||
*/
|
*/
|
||||||
class Outflow extends Model
|
class Outflow extends Model
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @property int $id
|
||||||
* @property Collection<Bucket> $buckets
|
* @property Collection<Bucket> $buckets
|
||||||
* @method static create(array $data)
|
* @method static create(array $data)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,22 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\StreamFrequencyEnum;
|
||||||
|
use App\Enums\StreamTypeEnum;
|
||||||
use App\Models\Traits\HasAmount;
|
use App\Models\Traits\HasAmount;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $amount
|
||||||
|
* @property StreamFrequencyEnum $frequency
|
||||||
|
* @property Carbon $start_date
|
||||||
|
*/
|
||||||
class Stream extends Model
|
class Stream extends Model
|
||||||
{
|
{
|
||||||
use HasAmount;
|
use HasFactory, 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';
|
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'scenario_id',
|
'scenario_id',
|
||||||
|
|
@ -33,6 +33,8 @@ class Stream extends Model
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
'type' => StreamTypeEnum::class,
|
||||||
|
'frequency' => StreamFrequencyEnum::class,
|
||||||
'amount' => 'integer',
|
'amount' => 'integer',
|
||||||
'start_date' => 'date',
|
'start_date' => 'date',
|
||||||
'end_date' => 'date',
|
'end_date' => 'date',
|
||||||
|
|
@ -44,7 +46,6 @@ public function scenario(): BelongsTo
|
||||||
return $this->belongsTo(Scenario::class);
|
return $this->belongsTo(Scenario::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public function bucket(): BelongsTo
|
public function bucket(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Bucket::class);
|
return $this->belongsTo(Bucket::class);
|
||||||
|
|
@ -52,51 +53,30 @@ public function bucket(): BelongsTo
|
||||||
|
|
||||||
public static function getTypes(): array
|
public static function getTypes(): array
|
||||||
{
|
{
|
||||||
return [
|
return StreamTypeEnum::labels();
|
||||||
self::TYPE_INCOME => 'Income',
|
|
||||||
self::TYPE_EXPENSE => 'Expense',
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getFrequencies(): array
|
public static function getFrequencies(): array
|
||||||
{
|
{
|
||||||
return [
|
return StreamFrequencyEnum::labels();
|
||||||
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',
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTypeLabel(): string
|
public function getTypeLabel(): string
|
||||||
{
|
{
|
||||||
return self::getTypes()[$this->type] ?? $this->type;
|
return $this->type?->label() ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getFrequencyLabel(): string
|
public function getFrequencyLabel(): string
|
||||||
{
|
{
|
||||||
return self::getFrequencies()[$this->frequency] ?? $this->frequency;
|
return $this->frequency?->label() ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getMonthlyEquivalent(): float
|
public function getMonthlyEquivalent(): float
|
||||||
{
|
{
|
||||||
switch ($this->frequency) {
|
if (!$this->frequency) {
|
||||||
case self::FREQUENCY_WEEKLY:
|
return 0;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
return $this->amount * $this->frequency->getMonthlyEquivalentMultiplier();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* SCOPES */
|
/* SCOPES */
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
namespace App\Services\Projection;
|
namespace App\Services\Projection;
|
||||||
|
|
||||||
use App\Enums\BucketAllocationType;
|
use App\Enums\BucketAllocationTypeEnum;
|
||||||
use App\Models\Bucket;
|
use App\Models\Bucket;
|
||||||
use App\Models\Draw;
|
use App\Models\Draw;
|
||||||
use App\Models\Scenario;
|
use App\Models\Scenario;
|
||||||
|
|
@ -15,12 +15,12 @@
|
||||||
* Allocate an inflow amount across scenario buckets according to priority rules.
|
* Allocate an inflow amount across scenario buckets according to priority rules.
|
||||||
*
|
*
|
||||||
* @param Scenario $scenario
|
* @param Scenario $scenario
|
||||||
* @param float $amount
|
* @param int $amount
|
||||||
* @param Carbon|null $date
|
* @param Carbon|null $date
|
||||||
* @param string|null $description
|
* @param string|null $description
|
||||||
* @return Collection<Draw> Collection of Draw models
|
* @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();
|
$draws = collect();
|
||||||
|
|
||||||
|
|
@ -58,9 +58,6 @@ public function allocateInflow(Scenario $scenario, float $amount, ?Carbon $date
|
||||||
'is_projected' => true,
|
'is_projected' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Add priority_order as a custom attribute for pipeline visualization
|
|
||||||
$draw->priority_order = $priorityOrder;
|
|
||||||
|
|
||||||
$draws->push($draw);
|
$draws->push($draw);
|
||||||
$remainingAmount -= $allocation;
|
$remainingAmount -= $allocation;
|
||||||
$priorityOrder++;
|
$priorityOrder++;
|
||||||
|
|
@ -74,15 +71,15 @@ public function allocateInflow(Scenario $scenario, float $amount, ?Carbon $date
|
||||||
* Calculate how much should be allocated to a specific bucket.
|
* Calculate how much should be allocated to a specific bucket.
|
||||||
*
|
*
|
||||||
* @param Bucket $bucket
|
* @param Bucket $bucket
|
||||||
* @param float $remainingAmount
|
* @param int $remainingAmount
|
||||||
* @return float
|
* @return int
|
||||||
*/
|
*/
|
||||||
private function calculateBucketAllocation(Bucket $bucket, float $remainingAmount): float
|
private function calculateBucketAllocation(Bucket $bucket, int $remainingAmount): int
|
||||||
{
|
{
|
||||||
return match ($bucket->allocation_type) {
|
return match ($bucket->allocation_type) {
|
||||||
BucketAllocationType::FIXED_LIMIT => $this->calculateFixedAllocation($bucket, $remainingAmount),
|
BucketAllocationTypeEnum::FIXED_LIMIT => $this->calculateFixedAllocation($bucket, $remainingAmount),
|
||||||
BucketAllocationType::PERCENTAGE => $this->calculatePercentageAllocation($bucket, $remainingAmount),
|
BucketAllocationTypeEnum::PERCENTAGE => $this->calculatePercentageAllocation($bucket, $remainingAmount),
|
||||||
BucketAllocationType::UNLIMITED => $remainingAmount, // Takes all remaining
|
BucketAllocationTypeEnum::UNLIMITED => $remainingAmount, // Takes all remaining
|
||||||
default => 0,
|
default => 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -91,12 +88,12 @@ private function calculateBucketAllocation(Bucket $bucket, float $remainingAmoun
|
||||||
* Calculate allocation for fixed limit buckets.
|
* Calculate allocation for fixed limit buckets.
|
||||||
*
|
*
|
||||||
* @param Bucket $bucket
|
* @param Bucket $bucket
|
||||||
* @param float $remainingAmount
|
* @param int $remainingAmount
|
||||||
* @return float
|
* @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();
|
$currentBalance = $bucket->getCurrentBalance();
|
||||||
$availableSpace = max(0, $bucketCapacity - $currentBalance);
|
$availableSpace = max(0, $bucketCapacity - $currentBalance);
|
||||||
|
|
||||||
|
|
@ -107,12 +104,12 @@ private function calculateFixedAllocation(Bucket $bucket, float $remainingAmount
|
||||||
* Calculate allocation for percentage buckets.
|
* Calculate allocation for percentage buckets.
|
||||||
*
|
*
|
||||||
* @param Bucket $bucket
|
* @param Bucket $bucket
|
||||||
* @param float $remainingAmount
|
* @param int $remainingAmount
|
||||||
* @return float
|
* @return int
|
||||||
*/
|
*/
|
||||||
private function calculatePercentageAllocation(Bucket $bucket, float $remainingAmount): float
|
private function calculatePercentageAllocation(Bucket $bucket, int $remainingAmount): int
|
||||||
{
|
{
|
||||||
$percentage = $bucket->allocation_value ?? 0;
|
$percentage = $bucket->allocation_value ?? 0;
|
||||||
return $remainingAmount * ($percentage / 100);
|
return (int)round($remainingAmount * ($percentage / 100));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
145
app/Services/Projection/ProjectionGeneratorService.php
Normal file
145
app/Services/Projection/ProjectionGeneratorService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
use App\Enums\BucketAllocationType;
|
use App\Enums\BucketAllocationTypeEnum;
|
||||||
use App\Models\Bucket;
|
use App\Models\Bucket;
|
||||||
use App\Models\Scenario;
|
use App\Models\Scenario;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
@ -15,9 +15,9 @@ class BucketFactory extends Factory
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
$allocationType = $this->faker->randomElement([
|
$allocationType = $this->faker->randomElement([
|
||||||
BucketAllocationType::FIXED_LIMIT,
|
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
BucketAllocationType::PERCENTAGE,
|
BucketAllocationTypeEnum::PERCENTAGE,
|
||||||
BucketAllocationType::UNLIMITED,
|
BucketAllocationTypeEnum::UNLIMITED,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
@ -52,7 +52,7 @@ public function fixedLimit($amount = null): Factory
|
||||||
$amount = $amount ?? $this->faker->numberBetween(500, 5000);
|
$amount = $amount ?? $this->faker->numberBetween(500, 5000);
|
||||||
|
|
||||||
return $this->state([
|
return $this->state([
|
||||||
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
'allocation_value' => $amount,
|
'allocation_value' => $amount,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -65,7 +65,7 @@ public function percentage($percentage = null): Factory
|
||||||
$percentage = $percentage ?? $this->faker->numberBetween(10, 50);
|
$percentage = $percentage ?? $this->faker->numberBetween(10, 50);
|
||||||
|
|
||||||
return $this->state([
|
return $this->state([
|
||||||
'allocation_type' => BucketAllocationType::PERCENTAGE,
|
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
|
||||||
'allocation_value' => $percentage,
|
'allocation_value' => $percentage,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -76,7 +76,7 @@ public function percentage($percentage = null): Factory
|
||||||
public function unlimited(): Factory
|
public function unlimited(): Factory
|
||||||
{
|
{
|
||||||
return $this->state([
|
return $this->state([
|
||||||
'allocation_type' => BucketAllocationType::UNLIMITED,
|
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
|
||||||
'allocation_value' => null,
|
'allocation_value' => null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +91,7 @@ public function defaultSet(): array
|
||||||
'name' => 'Monthly Expenses',
|
'name' => 'Monthly Expenses',
|
||||||
'priority' => 1,
|
'priority' => 1,
|
||||||
'sort_order' => 1,
|
'sort_order' => 1,
|
||||||
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
'allocation_value' => 0,
|
'allocation_value' => 0,
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
]),
|
]),
|
||||||
|
|
@ -99,7 +99,7 @@ public function defaultSet(): array
|
||||||
'name' => 'Emergency Fund',
|
'name' => 'Emergency Fund',
|
||||||
'priority' => 2,
|
'priority' => 2,
|
||||||
'sort_order' => 2,
|
'sort_order' => 2,
|
||||||
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
'allocation_value' => 0,
|
'allocation_value' => 0,
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
]),
|
]),
|
||||||
|
|
@ -107,7 +107,7 @@ public function defaultSet(): array
|
||||||
'name' => 'Investments',
|
'name' => 'Investments',
|
||||||
'priority' => 3,
|
'priority' => 3,
|
||||||
'sort_order' => 3,
|
'sort_order' => 3,
|
||||||
'allocation_type' => BucketAllocationType::UNLIMITED,
|
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
|
||||||
'allocation_value' => null,
|
'allocation_value' => null,
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
]),
|
]),
|
||||||
|
|
@ -117,12 +117,12 @@ public function defaultSet(): array
|
||||||
/**
|
/**
|
||||||
* Get allocation value based on type.
|
* Get allocation value based on type.
|
||||||
*/
|
*/
|
||||||
private function getAllocationValueForType(BucketAllocationType $type): ?float
|
private function getAllocationValueForType(BucketAllocationTypeEnum $type): ?float
|
||||||
{
|
{
|
||||||
return match($type) {
|
return match($type) {
|
||||||
BucketAllocationType::FIXED_LIMIT => $this->faker->numberBetween(100, 10000),
|
BucketAllocationTypeEnum::FIXED_LIMIT => $this->faker->numberBetween(100, 10000),
|
||||||
BucketAllocationType::PERCENTAGE => $this->faker->numberBetween(5, 50),
|
BucketAllocationTypeEnum::PERCENTAGE => $this->faker->numberBetween(5, 50),
|
||||||
BucketAllocationType::UNLIMITED => null,
|
BucketAllocationTypeEnum::UNLIMITED => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
97
database/factories/StreamFactory.php
Normal file
97
database/factories/StreamFactory.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\StreamFrequencyEnum;
|
||||||
|
use App\Enums\StreamTypeEnum;
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
@ -15,8 +17,8 @@ public function up(): void
|
||||||
$table->string('name');
|
$table->string('name');
|
||||||
$table->boolean('is_active')->default(true);
|
$table->boolean('is_active')->default(true);
|
||||||
$table->unsignedBigInteger('amount');
|
$table->unsignedBigInteger('amount');
|
||||||
$table->enum('type', ['income', 'expense']);
|
$table->enum('type', StreamTypeEnum::values());
|
||||||
$table->enum('frequency', ['once', 'weekly', 'biweekly', 'monthly', 'quarterly', 'yearly']);
|
$table->enum('frequency', StreamFrequencyEnum::values());
|
||||||
$table->date('start_date');
|
$table->date('start_date');
|
||||||
$table->date('end_date')->nullable();
|
$table->date('end_date')->nullable();
|
||||||
$table->text('description')->nullable();
|
$table->text('description')->nullable();
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
namespace Tests\Unit\Actions;
|
namespace Tests\Unit\Actions;
|
||||||
|
|
||||||
use App\Actions\CreateBucketAction;
|
use App\Actions\CreateBucketAction;
|
||||||
use App\Enums\BucketAllocationType;
|
use App\Enums\BucketAllocationTypeEnum;
|
||||||
use App\Models\Bucket;
|
use App\Models\Bucket;
|
||||||
use App\Models\Scenario;
|
use App\Models\Scenario;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
@ -29,13 +29,13 @@ public function test_can_create_fixed_limit_bucket(): void
|
||||||
$bucket = $this->action->execute(
|
$bucket = $this->action->execute(
|
||||||
$this->scenario,
|
$this->scenario,
|
||||||
'Test Bucket',
|
'Test Bucket',
|
||||||
BucketAllocationType::FIXED_LIMIT,
|
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
1000.00
|
1000.00
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertInstanceOf(Bucket::class, $bucket);
|
$this->assertInstanceOf(Bucket::class, $bucket);
|
||||||
$this->assertEquals('Test Bucket', $bucket->name);
|
$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(1000.00, $bucket->allocation_value);
|
||||||
$this->assertEquals(1, $bucket->priority);
|
$this->assertEquals(1, $bucket->priority);
|
||||||
$this->assertEquals(1, $bucket->sort_order);
|
$this->assertEquals(1, $bucket->sort_order);
|
||||||
|
|
@ -47,11 +47,11 @@ public function test_can_create_percentage_bucket(): void
|
||||||
$bucket = $this->action->execute(
|
$bucket = $this->action->execute(
|
||||||
$this->scenario,
|
$this->scenario,
|
||||||
'Percentage Bucket',
|
'Percentage Bucket',
|
||||||
BucketAllocationType::PERCENTAGE,
|
BucketAllocationTypeEnum::PERCENTAGE,
|
||||||
25.5
|
25.5
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->assertEquals(BucketAllocationType::PERCENTAGE, $bucket->allocation_type);
|
$this->assertEquals(BucketAllocationTypeEnum::PERCENTAGE, $bucket->allocation_type);
|
||||||
$this->assertEquals(25.5, $bucket->allocation_value);
|
$this->assertEquals(25.5, $bucket->allocation_value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,10 +60,10 @@ public function test_can_create_unlimited_bucket(): void
|
||||||
$bucket = $this->action->execute(
|
$bucket = $this->action->execute(
|
||||||
$this->scenario,
|
$this->scenario,
|
||||||
'Unlimited Bucket',
|
'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);
|
$this->assertNull($bucket->allocation_value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,11 +72,11 @@ public function test_unlimited_bucket_ignores_allocation_value(): void
|
||||||
$bucket = $this->action->execute(
|
$bucket = $this->action->execute(
|
||||||
$this->scenario,
|
$this->scenario,
|
||||||
'Unlimited Bucket',
|
'Unlimited Bucket',
|
||||||
BucketAllocationType::UNLIMITED,
|
BucketAllocationTypeEnum::UNLIMITED,
|
||||||
999.99 // This should be ignored and set to null
|
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);
|
$this->assertNull($bucket->allocation_value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,14 +85,14 @@ public function test_priority_auto_increments_when_not_specified(): void
|
||||||
$bucket1 = $this->action->execute(
|
$bucket1 = $this->action->execute(
|
||||||
$this->scenario,
|
$this->scenario,
|
||||||
'First Bucket',
|
'First Bucket',
|
||||||
BucketAllocationType::FIXED_LIMIT,
|
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
100
|
100
|
||||||
);
|
);
|
||||||
|
|
||||||
$bucket2 = $this->action->execute(
|
$bucket2 = $this->action->execute(
|
||||||
$this->scenario,
|
$this->scenario,
|
||||||
'Second Bucket',
|
'Second Bucket',
|
||||||
BucketAllocationType::FIXED_LIMIT,
|
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
200
|
200
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -105,7 +105,7 @@ public function test_can_specify_custom_priority(): void
|
||||||
$bucket = $this->action->execute(
|
$bucket = $this->action->execute(
|
||||||
$this->scenario,
|
$this->scenario,
|
||||||
'Priority Bucket',
|
'Priority Bucket',
|
||||||
BucketAllocationType::FIXED_LIMIT,
|
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
100,
|
100,
|
||||||
5
|
5
|
||||||
);
|
);
|
||||||
|
|
@ -116,12 +116,12 @@ public function test_can_specify_custom_priority(): void
|
||||||
public function test_existing_priorities_are_shifted_when_inserting(): void
|
public function test_existing_priorities_are_shifted_when_inserting(): void
|
||||||
{
|
{
|
||||||
// Create initial buckets
|
// Create initial buckets
|
||||||
$bucket1 = $this->action->execute($this->scenario, 'Bucket 1', BucketAllocationType::FIXED_LIMIT, 100, 1);
|
$bucket1 = $this->action->execute($this->scenario, 'Bucket 1', BucketAllocationTypeEnum::FIXED_LIMIT, 100, 1);
|
||||||
$bucket2 = $this->action->execute($this->scenario, 'Bucket 2', BucketAllocationType::FIXED_LIMIT, 200, 2);
|
$bucket2 = $this->action->execute($this->scenario, 'Bucket 2', BucketAllocationTypeEnum::FIXED_LIMIT, 200, 2);
|
||||||
$bucket3 = $this->action->execute($this->scenario, 'Bucket 3', BucketAllocationType::FIXED_LIMIT, 300, 3);
|
$bucket3 = $this->action->execute($this->scenario, 'Bucket 3', BucketAllocationTypeEnum::FIXED_LIMIT, 300, 3);
|
||||||
|
|
||||||
// Insert a bucket at priority 2
|
// 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
|
// Refresh models from database
|
||||||
$bucket1->refresh();
|
$bucket1->refresh();
|
||||||
|
|
@ -144,7 +144,7 @@ public function test_throws_exception_for_fixed_limit_without_allocation_value()
|
||||||
$this->action->execute(
|
$this->action->execute(
|
||||||
$this->scenario,
|
$this->scenario,
|
||||||
'Test Bucket',
|
'Test Bucket',
|
||||||
BucketAllocationType::FIXED_LIMIT,
|
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -157,7 +157,7 @@ public function test_throws_exception_for_negative_fixed_limit_value(): void
|
||||||
$this->action->execute(
|
$this->action->execute(
|
||||||
$this->scenario,
|
$this->scenario,
|
||||||
'Test Bucket',
|
'Test Bucket',
|
||||||
BucketAllocationType::FIXED_LIMIT,
|
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
-100
|
-100
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -170,7 +170,7 @@ public function test_throws_exception_for_percentage_without_allocation_value():
|
||||||
$this->action->execute(
|
$this->action->execute(
|
||||||
$this->scenario,
|
$this->scenario,
|
||||||
'Test Bucket',
|
'Test Bucket',
|
||||||
BucketAllocationType::PERCENTAGE,
|
BucketAllocationTypeEnum::PERCENTAGE,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -183,7 +183,7 @@ public function test_throws_exception_for_percentage_below_minimum(): void
|
||||||
$this->action->execute(
|
$this->action->execute(
|
||||||
$this->scenario,
|
$this->scenario,
|
||||||
'Test Bucket',
|
'Test Bucket',
|
||||||
BucketAllocationType::PERCENTAGE,
|
BucketAllocationTypeEnum::PERCENTAGE,
|
||||||
0.005
|
0.005
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -196,7 +196,7 @@ public function test_throws_exception_for_percentage_above_maximum(): void
|
||||||
$this->action->execute(
|
$this->action->execute(
|
||||||
$this->scenario,
|
$this->scenario,
|
||||||
'Test Bucket',
|
'Test Bucket',
|
||||||
BucketAllocationType::PERCENTAGE,
|
BucketAllocationTypeEnum::PERCENTAGE,
|
||||||
101
|
101
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -209,7 +209,7 @@ public function test_throws_exception_for_negative_priority(): void
|
||||||
$this->action->execute(
|
$this->action->execute(
|
||||||
$this->scenario,
|
$this->scenario,
|
||||||
'Test Bucket',
|
'Test Bucket',
|
||||||
BucketAllocationType::FIXED_LIMIT,
|
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
100,
|
100,
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
@ -226,19 +226,19 @@ public function test_create_default_buckets(): void
|
||||||
// Monthly Expenses
|
// Monthly Expenses
|
||||||
$this->assertEquals('Monthly Expenses', $buckets[0]->name);
|
$this->assertEquals('Monthly Expenses', $buckets[0]->name);
|
||||||
$this->assertEquals(1, $buckets[0]->priority);
|
$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);
|
$this->assertEquals(0, $buckets[0]->allocation_value);
|
||||||
|
|
||||||
// Emergency Fund
|
// Emergency Fund
|
||||||
$this->assertEquals('Emergency Fund', $buckets[1]->name);
|
$this->assertEquals('Emergency Fund', $buckets[1]->name);
|
||||||
$this->assertEquals(2, $buckets[1]->priority);
|
$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);
|
$this->assertEquals(0, $buckets[1]->allocation_value);
|
||||||
|
|
||||||
// Investments
|
// Investments
|
||||||
$this->assertEquals('Investments', $buckets[2]->name);
|
$this->assertEquals('Investments', $buckets[2]->name);
|
||||||
$this->assertEquals(3, $buckets[2]->priority);
|
$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);
|
$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
|
// This test ensures database consistency by creating multiple buckets
|
||||||
// and verifying they all exist with correct priorities
|
// 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 1', BucketAllocationTypeEnum::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 2', BucketAllocationTypeEnum::FIXED_LIMIT, 200, 1); // Insert at priority 1
|
||||||
|
|
||||||
// Both buckets should exist with correct priorities
|
// Both buckets should exist with correct priorities
|
||||||
$buckets = $this->scenario->buckets()->orderBy('priority')->get();
|
$buckets = $this->scenario->buckets()->orderBy('priority')->get();
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
namespace Tests\Unit;
|
namespace Tests\Unit;
|
||||||
|
|
||||||
use App\Enums\BucketAllocationType;
|
use App\Enums\BucketAllocationTypeEnum;
|
||||||
use App\Models\Bucket;
|
use App\Models\Bucket;
|
||||||
use App\Models\Draw;
|
use App\Models\Draw;
|
||||||
use App\Models\Outflow;
|
use App\Models\Outflow;
|
||||||
|
|
@ -21,7 +21,7 @@ public function test_current_balance_includes_starting_amount()
|
||||||
$bucket = Bucket::factory()->create([
|
$bucket = Bucket::factory()->create([
|
||||||
'scenario_id' => $scenario->id,
|
'scenario_id' => $scenario->id,
|
||||||
'starting_amount' => 100000, // $1000 in cents
|
'starting_amount' => 100000, // $1000 in cents
|
||||||
'allocation_type' => BucketAllocationType::UNLIMITED,
|
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Create draws and outflows directly
|
// Create draws and outflows directly
|
||||||
|
|
@ -43,8 +43,8 @@ public function test_current_balance_includes_starting_amount()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
// starting_amount (1000) + draws (500) - outflows (200) = 1300
|
// starting_amount (100000) + draws (50000) - outflows (20000) = 130000
|
||||||
$this->assertEquals(1300.00, $bucket->getCurrentBalance());
|
$this->assertEquals(130000, $bucket->getCurrentBalance());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_current_balance_without_starting_amount_defaults_to_zero()
|
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([
|
$bucket = Bucket::factory()->create([
|
||||||
'scenario_id' => $scenario->id,
|
'scenario_id' => $scenario->id,
|
||||||
'starting_amount' => 0, // $0 in cents
|
'starting_amount' => 0, // $0 in cents
|
||||||
'allocation_type' => BucketAllocationType::UNLIMITED,
|
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Create draws and outflows directly
|
// Create draws and outflows directly
|
||||||
|
|
@ -76,7 +76,7 @@ public function test_current_balance_without_starting_amount_defaults_to_zero()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
// starting_amount (0) + draws (300) - outflows (100) = 200
|
// starting_amount (0) + draws (30000) - outflows (10000) = 20000
|
||||||
$this->assertEquals(200.00, $bucket->getCurrentBalance());
|
$this->assertEquals(20000, $bucket->getCurrentBalance());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
namespace Tests\Unit;
|
namespace Tests\Unit;
|
||||||
|
|
||||||
use App\Enums\BucketAllocationType;
|
use App\Enums\BucketAllocationTypeEnum;
|
||||||
use App\Models\Bucket;
|
use App\Models\Bucket;
|
||||||
use App\Models\Scenario;
|
use App\Models\Scenario;
|
||||||
use App\Services\Projection\PipelineAllocationService;
|
use App\Services\Projection\PipelineAllocationService;
|
||||||
|
|
@ -29,20 +29,19 @@ public function test_allocates_to_single_fixed_bucket()
|
||||||
$bucket = Bucket::factory()->create([
|
$bucket = Bucket::factory()->create([
|
||||||
'scenario_id' => $this->scenario->id,
|
'scenario_id' => $this->scenario->id,
|
||||||
'name' => 'Emergency Fund',
|
'name' => 'Emergency Fund',
|
||||||
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
'allocation_value' => 500.00,
|
'allocation_value' => 50000,
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
'priority' => 1
|
'priority' => 1
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Act: Allocate $300
|
// 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
|
// Assert: All money goes to emergency fund
|
||||||
$this->assertCount(1, $draws);
|
$this->assertCount(1, $draws);
|
||||||
$this->assertEquals($bucket->id, $draws[0]->bucket_id);
|
$this->assertEquals($bucket->id, $draws[0]->bucket_id);
|
||||||
$this->assertEquals(300.00, $draws[0]->amount);
|
$this->assertEquals(30000, $draws[0]->amount);
|
||||||
$this->assertEquals(1, $draws[0]->priority_order);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_allocates_across_multiple_fixed_buckets_by_priority()
|
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
|
// Arrange: Three buckets with different priorities
|
||||||
$bucket1 = Bucket::factory()->create([
|
$bucket1 = Bucket::factory()->create([
|
||||||
'scenario_id' => $this->scenario->id,
|
'scenario_id' => $this->scenario->id,
|
||||||
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
'allocation_value' => 200.00,
|
'allocation_value' => 20000,
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
'priority' => 1
|
'priority' => 1
|
||||||
]);
|
]);
|
||||||
$bucket2 = Bucket::factory()->create([
|
$bucket2 = Bucket::factory()->create([
|
||||||
'scenario_id' => $this->scenario->id,
|
'scenario_id' => $this->scenario->id,
|
||||||
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
'allocation_value' => 300.00,
|
'allocation_value' => 30000,
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
'priority' => 2
|
'priority' => 2
|
||||||
]);
|
]);
|
||||||
$bucket3 = Bucket::factory()->create([
|
$bucket3 = Bucket::factory()->create([
|
||||||
'scenario_id' => $this->scenario->id,
|
'scenario_id' => $this->scenario->id,
|
||||||
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
'allocation_value' => 150.00,
|
'allocation_value' => 15000,
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
'priority' => 3
|
'priority' => 3
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Act: Allocate $550 (should fill bucket1 + bucket2 + partial bucket3)
|
// 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
|
// Assert: Allocation follows priority order
|
||||||
$this->assertCount(3, $draws);
|
$this->assertCount(3, $draws);
|
||||||
|
|
||||||
// Bucket 1: fully filled
|
// Bucket 1: fully filled
|
||||||
$this->assertEquals($bucket1->id, $draws[0]->bucket_id);
|
$this->assertEquals($bucket1->id, $draws[0]->bucket_id);
|
||||||
$this->assertEquals(200.00, $draws[0]->amount);
|
$this->assertEquals(20000, $draws[0]->amount);
|
||||||
$this->assertEquals(1, $draws[0]->priority_order);
|
|
||||||
|
|
||||||
// Bucket 2: fully filled
|
// Bucket 2: fully filled
|
||||||
$this->assertEquals($bucket2->id, $draws[1]->bucket_id);
|
$this->assertEquals($bucket2->id, $draws[1]->bucket_id);
|
||||||
$this->assertEquals(300.00, $draws[1]->amount);
|
$this->assertEquals(30000, $draws[1]->amount);
|
||||||
$this->assertEquals(2, $draws[1]->priority_order);
|
|
||||||
|
|
||||||
// Bucket 3: partially filled
|
// Bucket 3: partially filled
|
||||||
$this->assertEquals($bucket3->id, $draws[2]->bucket_id);
|
$this->assertEquals($bucket3->id, $draws[2]->bucket_id);
|
||||||
$this->assertEquals(50.00, $draws[2]->amount);
|
$this->assertEquals(5000, $draws[2]->amount);
|
||||||
$this->assertEquals(3, $draws[2]->priority_order);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_percentage_bucket_gets_percentage_of_remaining_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
|
// Arrange: Fixed bucket + percentage bucket
|
||||||
$fixedBucket = Bucket::factory()->create([
|
$fixedBucket = Bucket::factory()->create([
|
||||||
'scenario_id' => $this->scenario->id,
|
'scenario_id' => $this->scenario->id,
|
||||||
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
'allocation_value' => 300.00,
|
'allocation_value' => 30000,
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
'priority' => 1
|
'priority' => 1
|
||||||
]);
|
]);
|
||||||
$percentageBucket = Bucket::factory()->create([
|
$percentageBucket = Bucket::factory()->create([
|
||||||
'scenario_id' => $this->scenario->id,
|
'scenario_id' => $this->scenario->id,
|
||||||
'allocation_type' => BucketAllocationType::PERCENTAGE,
|
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
|
||||||
'allocation_value' => 20.00, // 20%
|
'allocation_value' => 20.00, // 20%
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
'priority' => 2
|
'priority' => 2
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Act: Allocate $1000
|
// 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
|
// Assert: Fixed gets $300, percentage gets 20% of remaining $700 = $140
|
||||||
$this->assertCount(2, $draws);
|
$this->assertCount(2, $draws);
|
||||||
$this->assertEquals(300.00, $draws[0]->amount); // Fixed bucket
|
$this->assertEquals(30000, $draws[0]->amount); // Fixed bucket
|
||||||
$this->assertEquals(140.00, $draws[1]->amount); // 20% of $700
|
$this->assertEquals(14000, $draws[1]->amount); // 20% of $700
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_unlimited_bucket_gets_all_remaining_amount()
|
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
|
// Arrange: Fixed + unlimited buckets
|
||||||
$fixedBucket = Bucket::factory()->create([
|
$fixedBucket = Bucket::factory()->create([
|
||||||
'scenario_id' => $this->scenario->id,
|
'scenario_id' => $this->scenario->id,
|
||||||
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
'allocation_value' => 500.00,
|
'allocation_value' => 50000,
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
'priority' => 1
|
'priority' => 1
|
||||||
]);
|
]);
|
||||||
$unlimitedBucket = Bucket::factory()->create([
|
$unlimitedBucket = Bucket::factory()->create([
|
||||||
'scenario_id' => $this->scenario->id,
|
'scenario_id' => $this->scenario->id,
|
||||||
'allocation_type' => BucketAllocationType::UNLIMITED,
|
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
|
||||||
'allocation_value' => null,
|
'allocation_value' => null,
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
'priority' => 2
|
'priority' => 2
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Act: Allocate $1500
|
// 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
|
// Assert: Fixed gets $500, unlimited gets remaining $1000
|
||||||
$this->assertCount(2, $draws);
|
$this->assertCount(2, $draws);
|
||||||
$this->assertEquals(500.00, $draws[0]->amount);
|
$this->assertEquals(50000, $draws[0]->amount);
|
||||||
$this->assertEquals(1000.00, $draws[1]->amount);
|
$this->assertEquals(100000, $draws[1]->amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_skips_buckets_with_zero_allocation()
|
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
|
// Arrange: Bucket that can't accept any money
|
||||||
$fullBucket = Bucket::factory()->create([
|
$fullBucket = Bucket::factory()->create([
|
||||||
'scenario_id' => $this->scenario->id,
|
'scenario_id' => $this->scenario->id,
|
||||||
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
'allocation_value' => 0, // No capacity
|
'allocation_value' => 0, // No capacity
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
'priority' => 1
|
'priority' => 1
|
||||||
]);
|
]);
|
||||||
$normalBucket = Bucket::factory()->create([
|
$normalBucket = Bucket::factory()->create([
|
||||||
'scenario_id' => $this->scenario->id,
|
'scenario_id' => $this->scenario->id,
|
||||||
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
'allocation_value' => 300.00,
|
'allocation_value' => 30000,
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
'priority' => 2
|
'priority' => 2
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Act: Allocate $200
|
// Act: Allocate $200
|
||||||
$draws = $this->service->allocateInflow($this->scenario, 200.00);
|
$draws = $this->service->allocateInflow($this->scenario, 20000);
|
||||||
|
|
||||||
// Assert: Only normal bucket gets allocation
|
// Assert: Only normal bucket gets allocation
|
||||||
$this->assertCount(1, $draws);
|
$this->assertCount(1, $draws);
|
||||||
$this->assertEquals($normalBucket->id, $draws[0]->bucket_id);
|
$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()
|
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
|
// Arrange: All bucket types in priority order
|
||||||
$fixed1 = Bucket::factory()->create([
|
$fixed1 = Bucket::factory()->create([
|
||||||
'scenario_id' => $this->scenario->id,
|
'scenario_id' => $this->scenario->id,
|
||||||
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
'allocation_value' => 1000.00,
|
'allocation_value' => 100000,
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
'priority' => 1
|
'priority' => 1
|
||||||
]);
|
]);
|
||||||
$percentage1 = Bucket::factory()->create([
|
$percentage1 = Bucket::factory()->create([
|
||||||
'scenario_id' => $this->scenario->id,
|
'scenario_id' => $this->scenario->id,
|
||||||
'allocation_type' => BucketAllocationType::PERCENTAGE,
|
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
|
||||||
'allocation_value' => 15.00, // 15%
|
'allocation_value' => 15.00, // 15%
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
'priority' => 2
|
'priority' => 2
|
||||||
]);
|
]);
|
||||||
$fixed2 = Bucket::factory()->create([
|
$fixed2 = Bucket::factory()->create([
|
||||||
'scenario_id' => $this->scenario->id,
|
'scenario_id' => $this->scenario->id,
|
||||||
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
'allocation_value' => 500.00,
|
'allocation_value' => 50000,
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
'priority' => 3
|
'priority' => 3
|
||||||
]);
|
]);
|
||||||
$percentage2 = Bucket::factory()->create([
|
$percentage2 = Bucket::factory()->create([
|
||||||
'scenario_id' => $this->scenario->id,
|
'scenario_id' => $this->scenario->id,
|
||||||
'allocation_type' => BucketAllocationType::PERCENTAGE,
|
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
|
||||||
'allocation_value' => 25.00, // 25%
|
'allocation_value' => 25.00, // 25%
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
'priority' => 4
|
'priority' => 4
|
||||||
]);
|
]);
|
||||||
$unlimited = Bucket::factory()->create([
|
$unlimited = Bucket::factory()->create([
|
||||||
'scenario_id' => $this->scenario->id,
|
'scenario_id' => $this->scenario->id,
|
||||||
'allocation_type' => BucketAllocationType::UNLIMITED,
|
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
|
||||||
'allocation_value' => null,
|
'allocation_value' => null,
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
'priority' => 5
|
'priority' => 5
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Act: Allocate $5000
|
// Act: Allocate $5000
|
||||||
$draws = $this->service->allocateInflow($this->scenario, 5000.00);
|
$draws = $this->service->allocateInflow($this->scenario, 500000);
|
||||||
|
|
||||||
// Assert: Complex allocation logic
|
// Assert: Complex allocation logic
|
||||||
$this->assertCount(5, $draws);
|
$this->assertCount(5, $draws);
|
||||||
|
|
||||||
// Fixed1: gets $1000 (full capacity)
|
// 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
|
// 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)
|
// 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
|
// Remaining after fixed allocations: $5000 - $1000 - $600 - $500 = $2900
|
||||||
// Percentage2: gets 25% of remaining $2900 = $725
|
// 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
|
// 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()
|
public function test_returns_empty_array_when_no_buckets()
|
||||||
{
|
{
|
||||||
// Act: Allocate to scenario with 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
|
// Assert: No allocations made
|
||||||
$this->assertEmpty($draws);
|
$this->assertEmpty($draws);
|
||||||
|
|
@ -249,14 +245,14 @@ public function test_returns_empty_array_when_amount_is_zero()
|
||||||
// Arrange: Create bucket
|
// Arrange: Create bucket
|
||||||
Bucket::factory()->create([
|
Bucket::factory()->create([
|
||||||
'scenario_id' => $this->scenario->id,
|
'scenario_id' => $this->scenario->id,
|
||||||
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
'allocation_value' => 500.00,
|
'allocation_value' => 50000,
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
'priority' => 1
|
'priority' => 1
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Act: Allocate $0
|
// Act: Allocate $0
|
||||||
$draws = $this->service->allocateInflow($this->scenario, 0.00);
|
$draws = $this->service->allocateInflow($this->scenario, 0);
|
||||||
|
|
||||||
// Assert: No allocations made
|
// Assert: No allocations made
|
||||||
$this->assertEmpty($draws);
|
$this->assertEmpty($draws);
|
||||||
|
|
@ -267,14 +263,14 @@ public function test_handles_negative_amount_gracefully()
|
||||||
// Arrange: Create bucket
|
// Arrange: Create bucket
|
||||||
Bucket::factory()->create([
|
Bucket::factory()->create([
|
||||||
'scenario_id' => $this->scenario->id,
|
'scenario_id' => $this->scenario->id,
|
||||||
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
'allocation_value' => 500.00,
|
'allocation_value' => 50000,
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
'priority' => 1
|
'priority' => 1
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Act: Allocate negative amount
|
// Act: Allocate negative amount
|
||||||
$draws = $this->service->allocateInflow($this->scenario, -100.00);
|
$draws = $this->service->allocateInflow($this->scenario, -10000);
|
||||||
|
|
||||||
// Assert: No allocations made
|
// Assert: No allocations made
|
||||||
$this->assertEmpty($draws);
|
$this->assertEmpty($draws);
|
||||||
|
|
@ -285,35 +281,35 @@ public function test_respects_bucket_priority_order()
|
||||||
// Arrange: Buckets in non-sequential priority order
|
// Arrange: Buckets in non-sequential priority order
|
||||||
$bucket3 = Bucket::factory()->create([
|
$bucket3 = Bucket::factory()->create([
|
||||||
'scenario_id' => $this->scenario->id,
|
'scenario_id' => $this->scenario->id,
|
||||||
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
'allocation_value' => 100.00,
|
'allocation_value' => 10000,
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
'priority' => 10 // Higher number
|
'priority' => 10 // Higher number
|
||||||
]);
|
]);
|
||||||
$bucket1 = Bucket::factory()->create([
|
$bucket1 = Bucket::factory()->create([
|
||||||
'scenario_id' => $this->scenario->id,
|
'scenario_id' => $this->scenario->id,
|
||||||
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
'allocation_value' => 200.00,
|
'allocation_value' => 20000,
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
'priority' => 1 // Lower number (higher priority)
|
'priority' => 1 // Lower number (higher priority)
|
||||||
]);
|
]);
|
||||||
$bucket2 = Bucket::factory()->create([
|
$bucket2 = Bucket::factory()->create([
|
||||||
'scenario_id' => $this->scenario->id,
|
'scenario_id' => $this->scenario->id,
|
||||||
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
'allocation_value' => 150.00,
|
'allocation_value' => 15000,
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
'priority' => 5 // Middle
|
'priority' => 5 // Middle
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Act: Allocate $250
|
// 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)
|
// Assert: Priority order respected (1, 5, 10)
|
||||||
$this->assertCount(2, $draws);
|
$this->assertCount(2, $draws);
|
||||||
$this->assertEquals($bucket1->id, $draws[0]->bucket_id); // Priority 1 first
|
$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($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()
|
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
|
// Arrange: Large fixed bucket + percentage bucket
|
||||||
$fixedBucket = Bucket::factory()->create([
|
$fixedBucket = Bucket::factory()->create([
|
||||||
'scenario_id' => $this->scenario->id,
|
'scenario_id' => $this->scenario->id,
|
||||||
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
|
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||||
'allocation_value' => 950.00,
|
'allocation_value' => 95000,
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
'priority' => 1
|
'priority' => 1
|
||||||
]);
|
]);
|
||||||
$percentageBucket = Bucket::factory()->create([
|
$percentageBucket = Bucket::factory()->create([
|
||||||
'scenario_id' => $this->scenario->id,
|
'scenario_id' => $this->scenario->id,
|
||||||
'allocation_type' => BucketAllocationType::PERCENTAGE,
|
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
|
||||||
'allocation_value' => 20.00, // 20%
|
'allocation_value' => 20.00, // 20%
|
||||||
'starting_amount' => 0,
|
'starting_amount' => 0,
|
||||||
'priority' => 2
|
'priority' => 2
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Act: Allocate $1000 (only $50 left after fixed)
|
// 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
|
// Assert: Percentage gets 20% of remaining $50 = $10
|
||||||
$this->assertCount(2, $draws);
|
$this->assertCount(2, $draws);
|
||||||
$this->assertEquals(950.00, $draws[0]->amount);
|
$this->assertEquals(95000, $draws[0]->amount);
|
||||||
$this->assertEquals(10.00, $draws[1]->amount); // 20% of $50
|
$this->assertEquals(1000, $draws[1]->amount); // 20% of $50
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
316
tests/Unit/ProjectionGeneratorServiceTest.php
Normal file
316
tests/Unit/ProjectionGeneratorServiceTest.php
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue