Compare commits
63 commits
bfa92f8f24
...
c3ce5b0b80
| Author | SHA1 | Date | |
|---|---|---|---|
| c3ce5b0b80 | |||
| cf6f0c76c2 | |||
| afc65b4f3a | |||
| 98d355e811 | |||
| a07d916663 | |||
| c2d2262488 | |||
| 46dc09783e | |||
| 7b92423fb9 | |||
| 5ac8d48727 | |||
| 7938677950 | |||
| 4006c7d2f3 | |||
| ced02a2ab2 | |||
| a9d74e3eee | |||
| 7d2e373ffd | |||
| 618d674793 | |||
| e5a02c3c32 | |||
| a94aabe604 | |||
| 0d322cde06 | |||
| 3e894503fe | |||
| f4e5a186fa | |||
| 52ec8bb2ac | |||
| aca9644c5b | |||
| 81bd3880ec | |||
| 24de58982a | |||
| b98b6f6dd9 | |||
| a7f9799391 | |||
| ac3e6d2ff9 | |||
| 246ca69f40 | |||
| d45ca30151 | |||
| f14057d6d9 | |||
| cf89ee7cd2 | |||
| d6f60ab987 | |||
| 3a5126f51c | |||
| 4554f4e417 | |||
| d603fc6401 | |||
| 0bec678b5a | |||
| a70dc036fe | |||
| 4a6e69d33b | |||
| 4bf3aef610 | |||
| a045ee6c23 | |||
| 073efc4bda | |||
| df985091dd | |||
| 969a88bc53 | |||
| 969c97b165 | |||
| 962acdc6ae | |||
| 873484db74 | |||
| fab5a5f4be | |||
| 66fb866f42 | |||
| 8f6de4aace | |||
| ed6be6249e | |||
| ad55a38c90 | |||
| 50b7caf1f9 | |||
| 772f4c1c5a | |||
| b4903cf3cf | |||
| d742b84343 | |||
| d06b859652 | |||
| faff18f82b | |||
| 2f51374e3a | |||
| e5dc7b0e21 | |||
| da036ce97f | |||
| fe5355d182 | |||
| 72cc6ff0b7 | |||
| 367f255200 |
76 changed files with 3733 additions and 1454 deletions
59
app/Actions/ApplyDistributionAction.php
Normal file
59
app/Actions/ApplyDistributionAction.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\Bucket;
|
||||
use App\Models\Scenario;
|
||||
use App\Services\Projection\PipelineAllocationService;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ApplyDistributionAction
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PipelineAllocationService $pipelineAllocationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Apply an income distribution to bucket balances.
|
||||
*
|
||||
* Re-runs the allocation algorithm server-side and updates each bucket's
|
||||
* starting_amount by adding the allocated amount.
|
||||
*
|
||||
* @return array{allocations: Collection<int, array{bucket_id: string, bucket_name: string, bucket_type: string, allocated_amount: int}>, total_allocated: int, unallocated: int}
|
||||
*/
|
||||
public function execute(Scenario $scenario, int $amount): array
|
||||
{
|
||||
$draws = $this->pipelineAllocationService->allocateInflow($scenario, $amount);
|
||||
|
||||
/** @var array<int, Bucket> $bucketLookup */
|
||||
$bucketLookup = $scenario->buckets->keyBy('id')->all();
|
||||
|
||||
DB::transaction(function () use ($draws, $bucketLookup) {
|
||||
foreach ($draws as $draw) {
|
||||
$bucket = $bucketLookup[$draw->bucket_id];
|
||||
$bucket->increment('starting_amount', (int) $draw->amount);
|
||||
}
|
||||
});
|
||||
|
||||
$allocations = $draws->map(function ($draw) use ($bucketLookup) {
|
||||
$bucket = $bucketLookup[$draw->bucket_id];
|
||||
|
||||
return [
|
||||
'bucket_id' => $bucket->uuid,
|
||||
'bucket_name' => $bucket->name,
|
||||
'bucket_type' => $bucket->type->value,
|
||||
'allocated_amount' => (int) $draw->amount,
|
||||
];
|
||||
})->values();
|
||||
|
||||
/** @var int $totalAllocated */
|
||||
$totalAllocated = $draws->sum('amount');
|
||||
|
||||
return [
|
||||
'allocations' => $allocations,
|
||||
'total_allocated' => $totalAllocated,
|
||||
'unallocated' => $amount - $totalAllocated,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Actions;
|
||||
|
||||
use App\Enums\BucketAllocationTypeEnum;
|
||||
use App\Enums\BucketTypeEnum;
|
||||
use App\Models\Bucket;
|
||||
use App\Models\Scenario;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
|
@ -14,18 +15,39 @@ public function execute(
|
|||
Scenario $scenario,
|
||||
string $name,
|
||||
BucketAllocationTypeEnum $allocationType,
|
||||
BucketTypeEnum $type = BucketTypeEnum::NEED,
|
||||
?float $allocationValue = null,
|
||||
?int $priority = null
|
||||
?int $priority = null,
|
||||
?float $bufferMultiplier = null,
|
||||
): Bucket {
|
||||
// Validate type + allocation type constraints
|
||||
$this->validateTypeConstraints($type, $allocationType);
|
||||
|
||||
// Enforce one overflow bucket per scenario
|
||||
if ($type === BucketTypeEnum::OVERFLOW && $scenario->buckets()->where('type', BucketTypeEnum::OVERFLOW->value)->exists()) {
|
||||
throw new InvalidArgumentException('A scenario can only have one overflow bucket');
|
||||
}
|
||||
|
||||
// Validate allocation value based on type
|
||||
$this->validateAllocationValue($allocationType, $allocationValue);
|
||||
|
||||
// Validate and normalize buffer multiplier
|
||||
$bufferMultiplier = $bufferMultiplier ?? 0.0;
|
||||
if ($bufferMultiplier < 0) {
|
||||
throw new InvalidArgumentException('Buffer multiplier must be non-negative');
|
||||
}
|
||||
|
||||
// Set allocation_value to null for unlimited buckets
|
||||
if ($allocationType === BucketAllocationTypeEnum::UNLIMITED) {
|
||||
$allocationValue = null;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($scenario, $name, $allocationType, $allocationValue, $priority) {
|
||||
// Buffer only applies to fixed_limit buckets
|
||||
if ($allocationType !== BucketAllocationTypeEnum::FIXED_LIMIT) {
|
||||
$bufferMultiplier = 0.0;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($scenario, $name, $allocationType, $allocationValue, $priority, $type, $bufferMultiplier) {
|
||||
// Determine priority (append to end if not specified)
|
||||
if ($priority === null) {
|
||||
$maxPriority = $scenario->buckets()->max('priority') ?? 0;
|
||||
|
|
@ -53,14 +75,28 @@ public function execute(
|
|||
// Create the bucket
|
||||
return $scenario->buckets()->create([
|
||||
'name' => $name,
|
||||
'type' => $type,
|
||||
'priority' => $priority,
|
||||
'sort_order' => $priority, // Start with sort_order matching priority
|
||||
'allocation_type' => $allocationType,
|
||||
'allocation_value' => $allocationValue,
|
||||
'buffer_multiplier' => $bufferMultiplier,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that bucket type and allocation type are compatible.
|
||||
*/
|
||||
private function validateTypeConstraints(BucketTypeEnum $type, BucketAllocationTypeEnum $allocationType): void
|
||||
{
|
||||
$allowedTypes = $type->getAllowedAllocationTypes();
|
||||
if (! in_array($allocationType, $allowedTypes, true)) {
|
||||
$allowed = implode(', ', array_map(fn ($t) => $t->value, $allowedTypes));
|
||||
throw new InvalidArgumentException("Invalid allocation type for {$type->value} bucket. Allowed: {$allowed}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate allocation value based on allocation type.
|
||||
*/
|
||||
|
|
@ -80,8 +116,8 @@ private function validateAllocationValue(BucketAllocationTypeEnum $allocationTyp
|
|||
if ($allocationValue === null) {
|
||||
throw new InvalidArgumentException('Percentage buckets require an allocation value');
|
||||
}
|
||||
if ($allocationValue < 0.01 || $allocationValue > 100) {
|
||||
throw new InvalidArgumentException('Percentage allocation value must be between 0.01 and 100');
|
||||
if ($allocationValue < 1 || $allocationValue > 10000) {
|
||||
throw new InvalidArgumentException('Percentage allocation value must be between 1 and 10000');
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -99,29 +135,32 @@ public function createDefaultBuckets(Scenario $scenario): array
|
|||
{
|
||||
$buckets = [];
|
||||
|
||||
// Monthly Expenses - Fixed limit, priority 1
|
||||
// Monthly Expenses - Need, fixed limit, priority 1
|
||||
$buckets[] = $this->execute(
|
||||
$scenario,
|
||||
'Monthly Expenses',
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
BucketTypeEnum::NEED,
|
||||
0,
|
||||
1
|
||||
);
|
||||
|
||||
// Emergency Fund - Fixed limit, priority 2
|
||||
// Emergency Fund - Need, fixed limit, priority 2
|
||||
$buckets[] = $this->execute(
|
||||
$scenario,
|
||||
'Emergency Fund',
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
BucketTypeEnum::NEED,
|
||||
0,
|
||||
2
|
||||
);
|
||||
|
||||
// Investments - Unlimited, priority 3
|
||||
// Overflow - Overflow, unlimited, priority 3
|
||||
$buckets[] = $this->execute(
|
||||
$scenario,
|
||||
'Investments',
|
||||
'Overflow',
|
||||
BucketAllocationTypeEnum::UNLIMITED,
|
||||
BucketTypeEnum::OVERFLOW,
|
||||
null,
|
||||
3
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Enums\BucketAllocationTypeEnum;
|
||||
use App\Models\Scenario;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
|
|
@ -24,8 +23,6 @@ public function execute(array $data): Scenario
|
|||
|
||||
private function createDefaultBuckets(Scenario $scenario): void
|
||||
{
|
||||
$this->createBucketAction->execute($scenario, 'Monthly Expenses', BucketAllocationTypeEnum::FIXED_LIMIT, 0, 1);
|
||||
$this->createBucketAction->execute($scenario, 'Emergency Fund', BucketAllocationTypeEnum::FIXED_LIMIT, 0, 2);
|
||||
$this->createBucketAction->execute($scenario, 'Investments', BucketAllocationTypeEnum::UNLIMITED, null, 3);
|
||||
$this->createBucketAction->createDefaultBuckets($scenario);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\Scenario;
|
||||
|
||||
readonly class DeleteScenarioAction
|
||||
{
|
||||
public function execute(Scenario $scenario): void
|
||||
{
|
||||
$scenario->delete();
|
||||
}
|
||||
}
|
||||
|
|
@ -25,18 +25,9 @@ public static function values(): array
|
|||
public function getAllocationValueRules(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::FIXED_LIMIT => ['required', 'numeric', 'min:0'],
|
||||
self::PERCENTAGE => ['required', 'numeric', 'min:0.01', 'max:100'],
|
||||
self::FIXED_LIMIT => ['required', 'integer', 'min:0'],
|
||||
self::PERCENTAGE => ['required', 'integer', 'min:1', 'max:10000'],
|
||||
self::UNLIMITED => ['nullable'],
|
||||
};
|
||||
}
|
||||
|
||||
public function formatValue(?float $value): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::FIXED_LIMIT => '$'.number_format($value ?? 0, 2),
|
||||
self::PERCENTAGE => number_format($value ?? 0, 2).'%',
|
||||
self::UNLIMITED => 'All remaining',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
37
app/Enums/BucketTypeEnum.php
Normal file
37
app/Enums/BucketTypeEnum.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum BucketTypeEnum: string
|
||||
{
|
||||
case NEED = 'need';
|
||||
case WANT = 'want';
|
||||
case OVERFLOW = 'overflow';
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::NEED => 'Need',
|
||||
self::WANT => 'Want',
|
||||
self::OVERFLOW => 'Overflow',
|
||||
};
|
||||
}
|
||||
|
||||
public static function values(): array
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get valid allocation types for this bucket type.
|
||||
*
|
||||
* @return BucketAllocationTypeEnum[]
|
||||
*/
|
||||
public function getAllowedAllocationTypes(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::NEED, self::WANT => [BucketAllocationTypeEnum::FIXED_LIMIT, BucketAllocationTypeEnum::PERCENTAGE],
|
||||
self::OVERFLOW => [BucketAllocationTypeEnum::UNLIMITED],
|
||||
};
|
||||
}
|
||||
}
|
||||
30
app/Enums/DistributionModeEnum.php
Normal file
30
app/Enums/DistributionModeEnum.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum DistributionModeEnum: string
|
||||
{
|
||||
case EVEN = 'even';
|
||||
case PRIORITY = 'priority';
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::EVEN => 'Even Split',
|
||||
self::PRIORITY => 'Priority Order',
|
||||
};
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::EVEN => 'Split evenly across buckets in each phase, respecting individual capacity',
|
||||
self::PRIORITY => 'Fill highest-priority bucket first, then next',
|
||||
};
|
||||
}
|
||||
|
||||
public static function values(): array
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@
|
|||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\CreateBucketAction;
|
||||
use App\Enums\BucketAllocationTypeEnum;
|
||||
use App\Enums\BucketTypeEnum;
|
||||
use App\Models\Bucket;
|
||||
use App\Models\Scenario;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
|
@ -17,21 +19,7 @@ public function index(Scenario $scenario): JsonResponse
|
|||
$buckets = $scenario->buckets()
|
||||
->orderedBySortOrder()
|
||||
->get()
|
||||
->map(function ($bucket) {
|
||||
return [
|
||||
'id' => $bucket->id,
|
||||
'name' => $bucket->name,
|
||||
'priority' => $bucket->priority,
|
||||
'sort_order' => $bucket->sort_order,
|
||||
'allocation_type' => $bucket->allocation_type,
|
||||
'allocation_value' => $bucket->allocation_value,
|
||||
'allocation_type_label' => $bucket->getAllocationTypeLabel(),
|
||||
'formatted_allocation_value' => $bucket->getFormattedAllocationValue(),
|
||||
'current_balance' => $bucket->getCurrentBalance(),
|
||||
'has_available_space' => $bucket->hasAvailableSpace(),
|
||||
'available_space' => $bucket->getAvailableSpace(),
|
||||
];
|
||||
});
|
||||
->map(fn ($bucket) => $this->formatBucketResponse($bucket));
|
||||
|
||||
return response()->json([
|
||||
'buckets' => $buckets,
|
||||
|
|
@ -42,23 +30,31 @@ public function store(Request $request, Scenario $scenario): JsonResponse
|
|||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'allocation_type' => 'required|in:'.implode(',', [
|
||||
Bucket::TYPE_FIXED_LIMIT,
|
||||
Bucket::TYPE_PERCENTAGE,
|
||||
Bucket::TYPE_UNLIMITED,
|
||||
]),
|
||||
'allocation_value' => 'nullable|numeric',
|
||||
'type' => 'required|in:'.implode(',', BucketTypeEnum::values()),
|
||||
'allocation_type' => 'required|in:'.implode(',', BucketAllocationTypeEnum::values()),
|
||||
'allocation_value' => 'nullable|integer',
|
||||
'buffer_multiplier' => 'sometimes|numeric|min:0',
|
||||
'priority' => 'nullable|integer|min:1',
|
||||
]);
|
||||
|
||||
$type = BucketTypeEnum::from($validated['type']);
|
||||
$allocationType = BucketAllocationTypeEnum::from($validated['allocation_type']);
|
||||
|
||||
$constraintError = $this->validateBucketTypeConstraints($type, $allocationType, $scenario);
|
||||
if ($constraintError) {
|
||||
return $constraintError;
|
||||
}
|
||||
|
||||
try {
|
||||
$createBucketAction = new CreateBucketAction;
|
||||
$bucket = $createBucketAction->execute(
|
||||
$scenario,
|
||||
$validated['name'],
|
||||
$validated['allocation_type'],
|
||||
$validated['allocation_value'],
|
||||
$validated['priority'] ?? null
|
||||
$allocationType,
|
||||
$type,
|
||||
$validated['allocation_value'] ?? null,
|
||||
$validated['priority'] ?? null,
|
||||
isset($validated['buffer_multiplier']) ? (float) $validated['buffer_multiplier'] : null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
|
|
@ -76,25 +72,50 @@ public function store(Request $request, Scenario $scenario): JsonResponse
|
|||
public function update(Request $request, Bucket $bucket): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'allocation_type' => 'required|in:'.implode(',', [
|
||||
Bucket::TYPE_FIXED_LIMIT,
|
||||
Bucket::TYPE_PERCENTAGE,
|
||||
Bucket::TYPE_UNLIMITED,
|
||||
]),
|
||||
'allocation_value' => 'nullable|numeric',
|
||||
'priority' => 'nullable|integer|min:1',
|
||||
'name' => 'sometimes|required|string|max:255',
|
||||
'type' => 'sometimes|required|in:'.implode(',', BucketTypeEnum::values()),
|
||||
'allocation_type' => 'sometimes|required|in:'.implode(',', BucketAllocationTypeEnum::values()),
|
||||
'allocation_value' => 'sometimes|nullable|integer',
|
||||
'buffer_multiplier' => 'sometimes|numeric|min:0',
|
||||
'starting_amount' => 'sometimes|integer|min:0',
|
||||
'priority' => 'sometimes|nullable|integer|min:1',
|
||||
]);
|
||||
|
||||
// Validate allocation_value based on allocation_type
|
||||
$allocationValueRules = Bucket::allocationValueRules($validated['allocation_type']);
|
||||
$request->validate([
|
||||
'allocation_value' => $allocationValueRules,
|
||||
]);
|
||||
$type = isset($validated['type']) ? BucketTypeEnum::from($validated['type']) : $bucket->type;
|
||||
$allocationType = isset($validated['allocation_type']) ? BucketAllocationTypeEnum::from($validated['allocation_type']) : $bucket->allocation_type;
|
||||
|
||||
// Set allocation_value to null for unlimited buckets
|
||||
if ($validated['allocation_type'] === Bucket::TYPE_UNLIMITED) {
|
||||
$validated['allocation_value'] = null;
|
||||
// Prevent changing overflow bucket's type away from overflow
|
||||
if (isset($validated['type']) && $bucket->type === BucketTypeEnum::OVERFLOW && $type !== BucketTypeEnum::OVERFLOW) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['type' => ['The overflow bucket\'s type cannot be changed.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Run type/allocation constraint validation when relevant fields change
|
||||
if (isset($validated['type']) || isset($validated['allocation_type'])) {
|
||||
$constraintError = $this->validateBucketTypeConstraints($type, $allocationType, $bucket->scenario, $bucket);
|
||||
if ($constraintError) {
|
||||
return $constraintError;
|
||||
}
|
||||
|
||||
// Set allocation_value to null for unlimited buckets
|
||||
if ($allocationType === BucketAllocationTypeEnum::UNLIMITED) {
|
||||
$validated['allocation_value'] = null;
|
||||
}
|
||||
|
||||
// Buffer only applies to fixed_limit buckets — always reset on type change
|
||||
if ($allocationType !== BucketAllocationTypeEnum::FIXED_LIMIT) {
|
||||
$validated['buffer_multiplier'] = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate allocation_value when it or allocation_type changes
|
||||
if (array_key_exists('allocation_value', $validated) || isset($validated['allocation_type'])) {
|
||||
$allocationValueRules = Bucket::allocationValueRules($allocationType);
|
||||
$request->validate([
|
||||
'allocation_value' => $allocationValueRules,
|
||||
]);
|
||||
}
|
||||
|
||||
// Handle priority change if needed
|
||||
|
|
@ -116,6 +137,12 @@ public function update(Request $request, Bucket $bucket): JsonResponse
|
|||
*/
|
||||
public function destroy(Bucket $bucket): JsonResponse
|
||||
{
|
||||
if ($bucket->type === BucketTypeEnum::OVERFLOW) {
|
||||
return response()->json([
|
||||
'message' => 'The overflow bucket cannot be deleted.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$scenarioId = $bucket->scenario_id;
|
||||
$deletedPriority = $bucket->priority;
|
||||
|
||||
|
|
@ -136,12 +163,12 @@ public function updatePriorities(Request $request, Scenario $scenario): JsonResp
|
|||
{
|
||||
$validated = $request->validate([
|
||||
'bucket_priorities' => 'required|array',
|
||||
'bucket_priorities.*.id' => 'required|exists:buckets,id',
|
||||
'bucket_priorities.*.id' => 'required|exists:buckets,uuid',
|
||||
'bucket_priorities.*.priority' => 'required|integer|min:1',
|
||||
]);
|
||||
|
||||
foreach ($validated['bucket_priorities'] as $bucketData) {
|
||||
$bucket = Bucket::find($bucketData['id']);
|
||||
$bucket = Bucket::where('uuid', $bucketData['id'])->first();
|
||||
if ($bucket && $bucket->scenario_id === $scenario->id) {
|
||||
$bucket->update([
|
||||
'priority' => $bucketData['priority'],
|
||||
|
|
@ -155,23 +182,66 @@ public function updatePriorities(Request $request, Scenario $scenario): JsonResp
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate bucket type constraints (type+allocation compatibility, one overflow per scenario).
|
||||
*/
|
||||
private function validateBucketTypeConstraints(
|
||||
BucketTypeEnum $type,
|
||||
BucketAllocationTypeEnum $allocationType,
|
||||
Scenario $scenario,
|
||||
?Bucket $excludeBucket = null
|
||||
): ?JsonResponse {
|
||||
$allowedTypes = $type->getAllowedAllocationTypes();
|
||||
if (! in_array($allocationType, $allowedTypes, true)) {
|
||||
$allowed = implode(', ', array_map(fn ($t) => $t->getLabel(), $allowedTypes));
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['allocation_type' => ["{$type->getLabel()} buckets only support: {$allowed}."]],
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($type === BucketTypeEnum::OVERFLOW) {
|
||||
$query = $scenario->buckets()->where('type', BucketTypeEnum::OVERFLOW->value);
|
||||
|
||||
if ($excludeBucket) {
|
||||
$query->where('id', '!=', $excludeBucket->id);
|
||||
}
|
||||
|
||||
if ($query->exists()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['type' => ['A scenario can only have one overflow bucket.']],
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bucket data for JSON response.
|
||||
* All amounts in storage units (cents for currency, basis points for percentages).
|
||||
* Frontend handles conversion to display units.
|
||||
*/
|
||||
private function formatBucketResponse(Bucket $bucket): array
|
||||
{
|
||||
return [
|
||||
'id' => $bucket->id,
|
||||
'id' => $bucket->uuid,
|
||||
'name' => $bucket->name,
|
||||
'type' => $bucket->type,
|
||||
'type_label' => $bucket->type->getLabel(),
|
||||
'priority' => $bucket->priority,
|
||||
'sort_order' => $bucket->sort_order,
|
||||
'allocation_type' => $bucket->allocation_type,
|
||||
'allocation_value' => $bucket->allocation_value,
|
||||
'allocation_type_label' => $bucket->getAllocationTypeLabel(),
|
||||
'formatted_allocation_value' => $bucket->getFormattedAllocationValue(),
|
||||
'buffer_multiplier' => (float) $bucket->buffer_multiplier,
|
||||
'effective_capacity' => $bucket->hasFiniteCapacity() ? $bucket->getEffectiveCapacity() : null,
|
||||
'starting_amount' => $bucket->starting_amount,
|
||||
'current_balance' => $bucket->getCurrentBalance(),
|
||||
'has_available_space' => $bucket->hasAvailableSpace(),
|
||||
'available_space' => $bucket->getAvailableSpace(),
|
||||
'available_space' => $bucket->hasFiniteCapacity() ? $bucket->getAvailableSpace() : null,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,16 +2,23 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\ApplyDistributionAction;
|
||||
use App\Http\Requests\CalculateProjectionRequest;
|
||||
use App\Http\Requests\PreviewAllocationRequest;
|
||||
use App\Http\Resources\ProjectionResource;
|
||||
use App\Models\Bucket;
|
||||
use App\Models\Scenario;
|
||||
use App\Services\Projection\PipelineAllocationService;
|
||||
use App\Services\Projection\ProjectionGeneratorService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class ProjectionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectionGeneratorService $projectionGeneratorService
|
||||
private readonly ProjectionGeneratorService $projectionGeneratorService,
|
||||
private readonly PipelineAllocationService $pipelineAllocationService,
|
||||
private readonly ApplyDistributionAction $applyDistributionAction,
|
||||
) {}
|
||||
|
||||
public function calculate(CalculateProjectionRequest $request, Scenario $scenario): ProjectionResource
|
||||
|
|
@ -27,4 +34,54 @@ public function calculate(CalculateProjectionRequest $request, Scenario $scenari
|
|||
|
||||
return new ProjectionResource($projections);
|
||||
}
|
||||
|
||||
/**
|
||||
* All amounts in cents. Frontend handles conversion to display units.
|
||||
*/
|
||||
public function preview(PreviewAllocationRequest $request, Scenario $scenario): JsonResponse
|
||||
{
|
||||
$amountInCents = (int) $request->input('amount');
|
||||
|
||||
$draws = $this->pipelineAllocationService->allocateInflow($scenario, $amountInCents);
|
||||
|
||||
/** @var array<int, Bucket> $bucketLookup */
|
||||
$bucketLookup = $scenario->buckets->keyBy('id')->all();
|
||||
|
||||
$allocations = $draws->map(function ($draw) use ($bucketLookup) {
|
||||
$bucket = $bucketLookup[$draw->bucket_id];
|
||||
|
||||
return [
|
||||
'bucket_id' => $bucket->uuid,
|
||||
'bucket_name' => $bucket->name,
|
||||
'bucket_type' => $bucket->type->value,
|
||||
'allocated_amount' => $draw->amount,
|
||||
'remaining_capacity' => $bucket->hasFiniteCapacity()
|
||||
? max(0, $bucket->getEffectiveCapacity() - $draw->amount)
|
||||
: null,
|
||||
];
|
||||
})->values();
|
||||
|
||||
$totalAllocated = $draws->sum('amount');
|
||||
|
||||
return response()->json([
|
||||
'allocations' => $allocations,
|
||||
'total_allocated' => $totalAllocated,
|
||||
'unallocated' => $amountInCents - $totalAllocated,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an income distribution to bucket balances.
|
||||
*
|
||||
* Re-runs the allocation server-side and updates starting_amount for each bucket.
|
||||
* All amounts in cents.
|
||||
*/
|
||||
public function apply(PreviewAllocationRequest $request, Scenario $scenario): JsonResponse
|
||||
{
|
||||
$amountInCents = (int) $request->input('amount');
|
||||
|
||||
$result = $this->applyDistributionAction->execute($scenario, $amountInCents);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,40 +2,21 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\CreateScenarioAction;
|
||||
use App\Actions\DeleteScenarioAction;
|
||||
use App\Actions\UpdateScenarioAction;
|
||||
use App\Http\Requests\StoreScenarioRequest;
|
||||
use App\Http\Requests\UpdateScenarioRequest;
|
||||
use App\Http\Resources\BucketResource;
|
||||
use App\Http\Resources\ScenarioResource;
|
||||
use App\Http\Resources\StreamResource;
|
||||
use App\Models\Scenario;
|
||||
use App\Repositories\ScenarioRepository;
|
||||
use App\Repositories\StreamRepository;
|
||||
use App\Services\Streams\StatsService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ScenarioController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ScenarioRepository $scenarioRepository,
|
||||
private readonly StreamRepository $streamRepository,
|
||||
private readonly CreateScenarioAction $createScenarioAction,
|
||||
private readonly UpdateScenarioAction $updateScenarioAction,
|
||||
private readonly DeleteScenarioAction $deleteScenarioAction,
|
||||
private readonly StatsService $statsService
|
||||
) {}
|
||||
|
||||
public function index(): Response
|
||||
{
|
||||
return Inertia::render('Scenarios/Index', [
|
||||
'scenarios' => ScenarioResource::collection($this->scenarioRepository->getAll()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Scenario $scenario): Response
|
||||
{
|
||||
$scenario->load(['buckets' => function ($query) {
|
||||
|
|
@ -43,47 +24,15 @@ public function show(Scenario $scenario): Response
|
|||
}]);
|
||||
|
||||
return Inertia::render('Scenarios/Show', [
|
||||
'scenario' => new ScenarioResource($scenario),
|
||||
'scenario' => ScenarioResource::make($scenario)->resolve(),
|
||||
'buckets' => BucketResource::collection($scenario->buckets),
|
||||
'streams' => StreamResource::collection($this->streamRepository->getForScenario($scenario)),
|
||||
'streamStats' => $this->statsService->getSummaryStats($scenario),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): Response
|
||||
{
|
||||
return Inertia::render('Scenarios/Create');
|
||||
}
|
||||
|
||||
public function store(StoreScenarioRequest $request): RedirectResponse
|
||||
{
|
||||
$scenario = $this->createScenarioAction->execute($request->validated());
|
||||
|
||||
return redirect()->route('scenarios.show', $scenario);
|
||||
}
|
||||
|
||||
public function edit(Scenario $scenario): Response
|
||||
{
|
||||
return Inertia::render('Scenarios/Edit', [
|
||||
'scenario' => new ScenarioResource($scenario),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateScenarioRequest $request, Scenario $scenario): RedirectResponse
|
||||
public function update(UpdateScenarioRequest $request, Scenario $scenario): JsonResponse
|
||||
{
|
||||
$this->updateScenarioAction->execute($scenario, $request->validated());
|
||||
|
||||
return redirect()
|
||||
->route('scenarios.show', $scenario)
|
||||
->with('success', 'Scenario updated successfully');
|
||||
}
|
||||
|
||||
public function destroy(Scenario $scenario): RedirectResponse
|
||||
{
|
||||
$this->deleteScenarioAction->execute($scenario);
|
||||
|
||||
return redirect()
|
||||
->route('scenarios.index')
|
||||
->with('success', 'Scenario deleted successfully');
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Http\Resources\ScenarioResource;
|
||||
use App\Models\Scenario;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
|
|
@ -46,6 +48,7 @@ public function share(Request $request): array
|
|||
'user' => $request->user(),
|
||||
],
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
'scenario' => fn () => ($scenario = Scenario::first()) ? ScenarioResource::make($scenario)->resolve() : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
app/Http/Requests/PreviewAllocationRequest.php
Normal file
20
app/Http/Requests/PreviewAllocationRequest.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PreviewAllocationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'amount' => ['required', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreScenarioRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
// In production, check if user is authenticated
|
||||
// For now, allow all requests
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255', 'min:1'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => 'A scenario name is required.',
|
||||
'name.min' => 'The scenario name must be at least 1 character.',
|
||||
'name.max' => 'The scenario name cannot exceed 255 characters.',
|
||||
];
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
// Trim the name
|
||||
if ($this->has('name')) {
|
||||
$this->merge([
|
||||
'name' => trim($this->name),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -41,7 +41,7 @@ public function rules(): array
|
|||
],
|
||||
'start_date' => ['required', 'date', 'date_format:Y-m-d'],
|
||||
'end_date' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:start_date'],
|
||||
'bucket_id' => ['nullable', 'exists:buckets,id'],
|
||||
'bucket_id' => ['nullable', 'exists:buckets,uuid'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
|
@ -67,7 +67,7 @@ public function withValidator($validator): void
|
|||
$scenario = $this->route('scenario');
|
||||
|
||||
$bucketBelongsToScenario = $scenario->buckets()
|
||||
->where('id', $this->bucket_id)
|
||||
->where('uuid', $this->bucket_id)
|
||||
->exists();
|
||||
|
||||
if (! $bucketBelongsToScenario) {
|
||||
|
|
|
|||
|
|
@ -2,29 +2,29 @@
|
|||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\DistributionModeEnum;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateScenarioRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
// In production, check if user owns the scenario
|
||||
// For now, allow all requests
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255', 'min:1'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'name' => ['sometimes', 'string', 'max:255', 'min:1'],
|
||||
'description' => ['sometimes', 'nullable', 'string', 'max:1000'],
|
||||
'distribution_mode' => ['sometimes', 'string', Rule::in(DistributionModeEnum::values())],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => 'A scenario name is required.',
|
||||
'name.min' => 'The scenario name must be at least 1 character.',
|
||||
'name.max' => 'The scenario name cannot exceed 255 characters.',
|
||||
'description.max' => 'The description cannot exceed 1000 characters.',
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ public function rules(): array
|
|||
],
|
||||
'start_date' => ['required', 'date', 'date_format:Y-m-d'],
|
||||
'end_date' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:start_date'],
|
||||
'bucket_id' => ['nullable', 'exists:buckets,id'],
|
||||
'bucket_id' => ['nullable', 'exists:buckets,uuid'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'is_active' => ['boolean'],
|
||||
];
|
||||
|
|
@ -61,7 +61,7 @@ public function withValidator($validator): void
|
|||
// Validate that the bucket belongs to the stream's scenario
|
||||
if ($this->bucket_id) {
|
||||
$bucketBelongsToScenario = $stream->scenario->buckets()
|
||||
->where('id', $this->bucket_id)
|
||||
->where('uuid', $this->bucket_id)
|
||||
->exists();
|
||||
|
||||
if (! $bucketBelongsToScenario) {
|
||||
|
|
|
|||
|
|
@ -7,20 +7,28 @@
|
|||
|
||||
class BucketResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* All amounts in storage units (cents for currency, basis points for percentages).
|
||||
* Frontend handles conversion to display units.
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'id' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
'type' => $this->type,
|
||||
'type_label' => $this->type->getLabel(),
|
||||
'priority' => $this->priority,
|
||||
'sort_order' => $this->sort_order,
|
||||
'allocation_type' => $this->allocation_type,
|
||||
'allocation_value' => $this->allocation_value,
|
||||
'allocation_type_label' => $this->getAllocationTypeLabel(),
|
||||
'formatted_allocation_value' => $this->getFormattedAllocationValue(),
|
||||
'buffer_multiplier' => (float) $this->buffer_multiplier,
|
||||
'effective_capacity' => $this->hasFiniteCapacity() ? $this->getEffectiveCapacity() : null,
|
||||
'starting_amount' => $this->starting_amount,
|
||||
'current_balance' => $this->getCurrentBalance(),
|
||||
'has_available_space' => $this->hasAvailableSpace(),
|
||||
'available_space' => $this->getAvailableSpace(),
|
||||
'available_space' => $this->hasFiniteCapacity() ? $this->getAvailableSpace() : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ class DrawResource extends JsonResource
|
|||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'bucket_id' => $this->bucket_id,
|
||||
'id' => $this->uuid,
|
||||
'bucket_id' => $this->bucket?->uuid,
|
||||
'amount' => $this->amount_currency,
|
||||
'formatted_amount' => $this->formatted_amount,
|
||||
'date' => $this->date->format('Y-m-d'),
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ class InflowResource extends JsonResource
|
|||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'stream_id' => $this->stream_id,
|
||||
'id' => $this->uuid,
|
||||
'stream_id' => $this->stream?->uuid,
|
||||
'amount' => $this->amount_currency,
|
||||
'formatted_amount' => $this->formatted_amount,
|
||||
'date' => $this->date->format('Y-m-d'),
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ class OutflowResource extends JsonResource
|
|||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'stream_id' => $this->stream_id,
|
||||
'bucket_id' => $this->bucket_id,
|
||||
'id' => $this->uuid,
|
||||
'stream_id' => $this->stream?->uuid,
|
||||
'bucket_id' => $this->bucket?->uuid,
|
||||
'amount' => $this->amount_currency,
|
||||
'formatted_amount' => $this->formatted_amount,
|
||||
'date' => $this->date->format('Y-m-d'),
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@ class ScenarioResource extends JsonResource
|
|||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'id' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'distribution_mode' => $this->distribution_mode->value,
|
||||
'distribution_mode_label' => $this->distribution_mode->getLabel(),
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ class StreamResource extends JsonResource
|
|||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'id' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
'type' => $this->type,
|
||||
'type_label' => $this->getTypeLabel(),
|
||||
|
|
@ -19,7 +19,7 @@ public function toArray(Request $request): array
|
|||
'frequency_label' => $this->getFrequencyLabel(),
|
||||
'start_date' => $this->start_date->format('Y-m-d'),
|
||||
'end_date' => $this->end_date?->format('Y-m-d'),
|
||||
'bucket_id' => $this->bucket_id,
|
||||
'bucket_id' => $this->bucket?->uuid,
|
||||
'bucket_name' => $this->bucket?->name,
|
||||
'description' => $this->description,
|
||||
'is_active' => $this->is_active,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Enums\BucketAllocationTypeEnum;
|
||||
use App\Enums\BucketTypeEnum;
|
||||
use App\Models\Traits\HasUuid;
|
||||
use Database\Factories\BucketFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
|
@ -11,35 +13,42 @@
|
|||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $uuid
|
||||
* @property int $scenario_id
|
||||
* @property BucketTypeEnum $type
|
||||
* @property Scenario $scenario
|
||||
* @property string $name
|
||||
* @property int $priority
|
||||
* @property BucketAllocationTypeEnum $allocation_type
|
||||
* @property float $starting_amount
|
||||
* @property float $allocation_value
|
||||
* @property int $starting_amount
|
||||
* @property int|null $allocation_value
|
||||
* @property float $buffer_multiplier
|
||||
*
|
||||
* @method static BucketFactory factory()
|
||||
*/
|
||||
class Bucket extends Model
|
||||
{
|
||||
/** @use HasFactory<BucketFactory> */
|
||||
use HasFactory;
|
||||
use HasFactory, HasUuid;
|
||||
|
||||
protected $fillable = [
|
||||
'scenario_id',
|
||||
'name',
|
||||
'type',
|
||||
'priority',
|
||||
'sort_order',
|
||||
'allocation_type',
|
||||
'allocation_value',
|
||||
'buffer_multiplier',
|
||||
'starting_amount',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'type' => BucketTypeEnum::class,
|
||||
'priority' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
'allocation_value' => 'decimal:2',
|
||||
'allocation_value' => 'integer',
|
||||
'buffer_multiplier' => 'decimal:2',
|
||||
'starting_amount' => 'integer',
|
||||
'allocation_type' => BucketAllocationTypeEnum::class,
|
||||
];
|
||||
|
|
@ -93,6 +102,21 @@ public function getCurrentBalance(): int
|
|||
return $this->starting_amount + $totalDraws - $totalOutflows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective capacity including buffer, in cents.
|
||||
* Formula: allocation_value * (1 + buffer_multiplier)
|
||||
*/
|
||||
public function getEffectiveCapacity(): int
|
||||
{
|
||||
if ($this->allocation_type !== BucketAllocationTypeEnum::FIXED_LIMIT) {
|
||||
return PHP_INT_MAX;
|
||||
}
|
||||
|
||||
$base = $this->allocation_value ?? 0;
|
||||
|
||||
return (int) round($base * (1 + (float) $this->buffer_multiplier));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the bucket can accept more money (for fixed_limit buckets).
|
||||
*/
|
||||
|
|
@ -102,19 +126,27 @@ public function hasAvailableSpace(): bool
|
|||
return true;
|
||||
}
|
||||
|
||||
return $this->getCurrentBalance() < $this->allocation_value;
|
||||
return $this->getCurrentBalance() < $this->getEffectiveCapacity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available space for fixed_limit buckets.
|
||||
* Get available space in cents for fixed_limit buckets.
|
||||
*/
|
||||
public function getAvailableSpace(): float
|
||||
public function getAvailableSpace(): int
|
||||
{
|
||||
if ($this->allocation_type !== BucketAllocationTypeEnum::FIXED_LIMIT) {
|
||||
return PHP_FLOAT_MAX;
|
||||
return PHP_INT_MAX;
|
||||
}
|
||||
|
||||
return max(0, $this->allocation_value - $this->getCurrentBalance());
|
||||
return max(0, $this->getEffectiveCapacity() - $this->getCurrentBalance());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this bucket has a finite capacity (fixed_limit only).
|
||||
*/
|
||||
public function hasFiniteCapacity(): bool
|
||||
{
|
||||
return $this->allocation_type === BucketAllocationTypeEnum::FIXED_LIMIT;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -125,33 +157,6 @@ public function getAllocationTypeLabel(): string
|
|||
return $this->allocation_type->getLabel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted allocation value for display.
|
||||
*/
|
||||
public function getFormattedAllocationValue(): string
|
||||
{
|
||||
return $this->allocation_type->formatValue($this->allocation_value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation rules for bucket creation/update.
|
||||
*/
|
||||
public static function validationRules($scenarioId = null): array
|
||||
{
|
||||
$rules = [
|
||||
'name' => 'required|string|max:255',
|
||||
'allocation_type' => 'required|in:'.implode(',', BucketAllocationTypeEnum::values()),
|
||||
'priority' => 'required|integer|min:1',
|
||||
];
|
||||
|
||||
// Add scenario-specific priority uniqueness if scenario ID provided
|
||||
if ($scenarioId) {
|
||||
$rules['priority'] .= '|unique:buckets,priority,NULL,id,scenario_id,'.$scenarioId;
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get allocation value validation rules based on type.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Models\Traits\HasAmount;
|
||||
use App\Models\Traits\HasProjectionStatus;
|
||||
use App\Models\Traits\HasUuid;
|
||||
use Carbon\Carbon;
|
||||
use Database\Factories\DrawFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
|
@ -11,6 +12,7 @@
|
|||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property string $uuid
|
||||
* @property Bucket $bucket
|
||||
* @property int $priority_order
|
||||
* @property float $amount
|
||||
|
|
@ -26,7 +28,9 @@ class Draw extends Model
|
|||
|
||||
/** @use HasFactory<DrawFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use HasProjectionStatus;
|
||||
use HasUuid;
|
||||
|
||||
protected $fillable = [
|
||||
'bucket_id',
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@
|
|||
|
||||
use App\Models\Traits\HasAmount;
|
||||
use App\Models\Traits\HasProjectionStatus;
|
||||
use App\Models\Traits\HasUuid;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $uuid
|
||||
* @property int $stream_id
|
||||
* @property Stream $stream
|
||||
* @property float $amount
|
||||
|
|
@ -21,6 +23,7 @@ class Inflow extends Model
|
|||
{
|
||||
use HasAmount;
|
||||
use HasProjectionStatus;
|
||||
use HasUuid;
|
||||
|
||||
protected $fillable = [
|
||||
'stream_id',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Models\Traits\HasAmount;
|
||||
use App\Models\Traits\HasProjectionStatus;
|
||||
use App\Models\Traits\HasUuid;
|
||||
use Carbon\Carbon;
|
||||
use Database\Factories\OutflowFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
|
@ -12,6 +13,7 @@
|
|||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $uuid
|
||||
* @property int $stream_id
|
||||
* @property Stream $stream
|
||||
* @property int $amount
|
||||
|
|
@ -27,7 +29,9 @@ class Outflow extends Model
|
|||
|
||||
/** @use HasFactory<OutflowFactory> */
|
||||
use HasFactory;
|
||||
|
||||
use HasProjectionStatus;
|
||||
use HasUuid;
|
||||
|
||||
protected $fillable = [
|
||||
'stream_id',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\DistributionModeEnum;
|
||||
use App\Models\Traits\HasUuid;
|
||||
use Database\Factories\ScenarioFactory;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
|
@ -10,6 +12,8 @@
|
|||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $uuid
|
||||
* @property DistributionModeEnum $distribution_mode
|
||||
* @property Collection<Bucket> $buckets
|
||||
*
|
||||
* @method static create(array $data)
|
||||
|
|
@ -17,13 +21,21 @@
|
|||
class Scenario extends Model
|
||||
{
|
||||
/** @use HasFactory<ScenarioFactory> */
|
||||
use HasFactory;
|
||||
use HasFactory, HasUuid;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
'distribution_mode',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'distribution_mode' => DistributionModeEnum::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function buckets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Bucket::class);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Enums\StreamFrequencyEnum;
|
||||
use App\Enums\StreamTypeEnum;
|
||||
use App\Models\Traits\HasAmount;
|
||||
use App\Models\Traits\HasUuid;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
|
@ -12,12 +13,13 @@
|
|||
|
||||
/**
|
||||
* @property int $amount
|
||||
* @property string $uuid
|
||||
* @property StreamFrequencyEnum $frequency
|
||||
* @property Carbon $start_date
|
||||
*/
|
||||
class Stream extends Model
|
||||
{
|
||||
use HasAmount, HasFactory;
|
||||
use HasAmount, HasFactory, HasUuid;
|
||||
|
||||
protected $fillable = [
|
||||
'scenario_id',
|
||||
|
|
|
|||
22
app/Models/Traits/HasUuid.php
Normal file
22
app/Models/Traits/HasUuid.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models\Traits;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
trait HasUuid
|
||||
{
|
||||
public static function bootHasUuid(): void
|
||||
{
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->uuid)) {
|
||||
$model->uuid = (string) Str::orderedUuid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'uuid';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\Scenario;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ScenarioRepository
|
||||
{
|
||||
public function getAll(): Collection
|
||||
{
|
||||
return Scenario::query()
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\Bucket;
|
||||
use App\Models\Scenario;
|
||||
use App\Models\Stream;
|
||||
use Illuminate\Support\Collection;
|
||||
|
|
@ -11,7 +12,7 @@ class StreamRepository
|
|||
public function getForScenario(Scenario $scenario): Collection
|
||||
{
|
||||
return $scenario->streams()
|
||||
->with('bucket:id,name')
|
||||
->with('bucket:id,uuid,name')
|
||||
->orderBy('type')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
|
@ -19,11 +20,15 @@ public function getForScenario(Scenario $scenario): Collection
|
|||
|
||||
public function create(Scenario $scenario, array $data): Stream
|
||||
{
|
||||
$this->resolveBucketId($data);
|
||||
|
||||
return $scenario->streams()->create($data);
|
||||
}
|
||||
|
||||
public function update(Stream $stream, array $data): Stream
|
||||
{
|
||||
$this->resolveBucketId($data);
|
||||
|
||||
$stream->update($data);
|
||||
|
||||
return $stream->fresh('bucket');
|
||||
|
|
@ -46,17 +51,24 @@ public function toggleActive(Stream $stream): Stream
|
|||
/**
|
||||
* Check if a bucket belongs to the scenario
|
||||
*/
|
||||
public function bucketBelongsToScenario(Scenario $scenario, ?int $bucketId): bool
|
||||
public function bucketBelongsToScenario(Scenario $scenario, ?string $bucketUuid): bool
|
||||
{
|
||||
if (! $bucketId) {
|
||||
if (! $bucketUuid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $scenario->buckets()
|
||||
->where('id', $bucketId)
|
||||
->where('uuid', $bucketUuid)
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function resolveBucketId(array &$data): void
|
||||
{
|
||||
if (! empty($data['bucket_id'])) {
|
||||
$data['bucket_id'] = Bucket::where('uuid', $data['bucket_id'])->value('id');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get streams grouped by type
|
||||
*/
|
||||
|
|
@ -64,12 +76,12 @@ public function getGroupedByType(Scenario $scenario): array
|
|||
{
|
||||
return [
|
||||
'income' => $scenario->streams()
|
||||
->with('bucket:id,name')
|
||||
->with('bucket:id,uuid,name')
|
||||
->byType(Stream::TYPE_INCOME)
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
'expense' => $scenario->streams()
|
||||
->with('bucket:id,name')
|
||||
->with('bucket:id,uuid,name')
|
||||
->byType(Stream::TYPE_EXPENSE)
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
namespace App\Services\Projection;
|
||||
|
||||
use App\Enums\BucketAllocationTypeEnum;
|
||||
use App\Enums\BucketTypeEnum;
|
||||
use App\Enums\DistributionModeEnum;
|
||||
use App\Models\Bucket;
|
||||
use App\Models\Draw;
|
||||
use App\Models\Scenario;
|
||||
|
|
@ -11,90 +12,204 @@
|
|||
|
||||
readonly class PipelineAllocationService
|
||||
{
|
||||
private const string CAP_BASE = 'base';
|
||||
|
||||
private const string CAP_FILL = 'fill';
|
||||
|
||||
/**
|
||||
* Allocate an inflow amount across scenario buckets according to priority rules.
|
||||
* Allocate an inflow amount across scenario buckets using phased distribution.
|
||||
*
|
||||
* @return Collection<Draw> Collection of Draw models
|
||||
* Phases:
|
||||
* 1. Needs to base (allocation_value)
|
||||
* 2. Wants to base (allocation_value)
|
||||
* 3. Needs to buffer (getEffectiveCapacity)
|
||||
* 4. Wants to buffer (getEffectiveCapacity)
|
||||
* 5. Overflow (all remaining)
|
||||
*
|
||||
* @return Collection<int, Draw> Collection of Draw models (unsaved)
|
||||
*/
|
||||
public function allocateInflow(Scenario $scenario, int $amount, ?Carbon $date = null, ?string $description = null): Collection
|
||||
{
|
||||
$draws = collect();
|
||||
|
||||
// Guard clauses
|
||||
if ($amount <= 0) {
|
||||
return $draws;
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Get buckets ordered by priority
|
||||
$buckets = $scenario->buckets()
|
||||
->orderBy('priority')
|
||||
->get();
|
||||
|
||||
if ($buckets->isEmpty()) {
|
||||
return $draws;
|
||||
return collect();
|
||||
}
|
||||
|
||||
$priorityOrder = 1;
|
||||
$remainingAmount = $amount;
|
||||
$mode = $scenario->distribution_mode ?? DistributionModeEnum::EVEN;
|
||||
$allocationDate = $date ?? now();
|
||||
$allocationDescription = $description ?? 'Allocation from inflow';
|
||||
|
||||
foreach ($buckets as $bucket) {
|
||||
if ($remainingAmount <= 0) {
|
||||
break;
|
||||
// Track cumulative allocations per bucket across phases
|
||||
/** @var array<int, int> $allocated bucket_id => total allocated cents */
|
||||
$allocated = [];
|
||||
|
||||
$needs = $buckets->filter(fn (Bucket $b) => $b->type === BucketTypeEnum::NEED);
|
||||
$wants = $buckets->filter(fn (Bucket $b) => $b->type === BucketTypeEnum::WANT);
|
||||
$overflow = $buckets->first(fn (Bucket $b) => $b->type === BucketTypeEnum::OVERFLOW);
|
||||
|
||||
$remaining = $amount;
|
||||
|
||||
// Phases 1-4: needs/wants to base/buffer
|
||||
$phases = [
|
||||
[$needs, self::CAP_BASE],
|
||||
[$wants, self::CAP_BASE],
|
||||
[$needs, self::CAP_FILL],
|
||||
[$wants, self::CAP_FILL],
|
||||
];
|
||||
|
||||
foreach ($phases as [$group, $cap]) {
|
||||
$remaining = $this->allocateToGroup($group, $remaining, $mode, $cap, $allocated);
|
||||
}
|
||||
|
||||
// Phase 5: Overflow
|
||||
if ($remaining > 0 && $overflow !== null) {
|
||||
$allocated[$overflow->id] = ($allocated[$overflow->id] ?? 0) + $remaining;
|
||||
}
|
||||
|
||||
// Build Draw models from cumulative allocations
|
||||
$draws = collect();
|
||||
foreach ($allocated as $bucketId => $totalAmount) {
|
||||
if ($totalAmount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$allocation = $this->calculateBucketAllocation($bucket, $remainingAmount);
|
||||
|
||||
if ($allocation > 0) {
|
||||
$draw = new Draw([
|
||||
'bucket_id' => $bucket->id,
|
||||
'amount' => $allocation,
|
||||
'date' => $allocationDate,
|
||||
'description' => $description ?? 'Allocation from inflow',
|
||||
'is_projected' => true,
|
||||
]);
|
||||
|
||||
$draws->push($draw);
|
||||
$remainingAmount -= $allocation;
|
||||
$priorityOrder++;
|
||||
}
|
||||
$draws->push(new Draw([
|
||||
'bucket_id' => $bucketId,
|
||||
'amount' => $totalAmount,
|
||||
'date' => $allocationDate,
|
||||
'description' => $allocationDescription,
|
||||
'is_projected' => true,
|
||||
]));
|
||||
}
|
||||
|
||||
return $draws;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate how much should be allocated to a specific bucket.
|
||||
* Allocate money to a group of buckets up to a capacity target.
|
||||
*
|
||||
* @param Collection<int, Bucket> $buckets Buckets ordered by priority
|
||||
* @param int $amount Available money in cents
|
||||
* @param DistributionModeEnum $mode Even or priority distribution
|
||||
* @param string $capMethod self::CAP_BASE = allocation_value, self::CAP_FILL = getEffectiveCapacity()
|
||||
* @param array<int, int> &$allocated Cumulative allocations per bucket (mutated)
|
||||
* @return int Remaining amount after allocation
|
||||
*/
|
||||
private function calculateBucketAllocation(Bucket $bucket, int $remainingAmount): int
|
||||
private function allocateToGroup(Collection $buckets, int $amount, DistributionModeEnum $mode, string $capMethod, array &$allocated): int
|
||||
{
|
||||
return match ($bucket->allocation_type) {
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT => $this->calculateFixedAllocation($bucket, $remainingAmount),
|
||||
BucketAllocationTypeEnum::PERCENTAGE => $this->calculatePercentageAllocation($bucket, $remainingAmount),
|
||||
BucketAllocationTypeEnum::UNLIMITED => $remainingAmount, // Takes all remaining
|
||||
default => 0,
|
||||
if ($amount <= 0 || $buckets->isEmpty()) {
|
||||
return $amount;
|
||||
}
|
||||
|
||||
return match ($mode) {
|
||||
DistributionModeEnum::PRIORITY => $this->allocatePriority($buckets, $amount, $capMethod, $allocated),
|
||||
DistributionModeEnum::EVEN => $this->allocateEven($buckets, $amount, $capMethod, $allocated),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate allocation for fixed limit buckets.
|
||||
* Priority mode: fill highest-priority bucket first, then next.
|
||||
*/
|
||||
private function calculateFixedAllocation(Bucket $bucket, int $remainingAmount): int
|
||||
private function allocatePriority(Collection $buckets, int $amount, string $capMethod, array &$allocated): int
|
||||
{
|
||||
$bucketCapacity = (int) ($bucket->allocation_value ?? 0);
|
||||
$currentBalance = $bucket->getCurrentBalance();
|
||||
$availableSpace = max(0, $bucketCapacity - $currentBalance);
|
||||
$remaining = $amount;
|
||||
|
||||
return min($availableSpace, $remainingAmount);
|
||||
foreach ($buckets as $bucket) {
|
||||
if ($remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
$space = $this->getAvailableSpace($bucket, $capMethod, $allocated);
|
||||
if ($space <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$allocation = min($space, $remaining);
|
||||
$allocated[$bucket->id] = ($allocated[$bucket->id] ?? 0) + $allocation;
|
||||
$remaining -= $allocation;
|
||||
}
|
||||
|
||||
return $remaining;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate allocation for percentage buckets.
|
||||
* Even mode: split evenly across eligible buckets, redistributing when one fills up.
|
||||
*/
|
||||
private function calculatePercentageAllocation(Bucket $bucket, int $remainingAmount): int
|
||||
private function allocateEven(Collection $buckets, int $amount, string $capMethod, array &$allocated): int
|
||||
{
|
||||
$percentage = $bucket->allocation_value ?? 0;
|
||||
$remaining = $amount;
|
||||
|
||||
return (int) round($remainingAmount * ($percentage / 100));
|
||||
// Filter to buckets that still have space
|
||||
$eligible = $buckets->filter(fn (Bucket $b) => $this->getAvailableSpace($b, $capMethod, $allocated) > 0);
|
||||
|
||||
while ($remaining > 0 && $eligible->isNotEmpty()) {
|
||||
$share = intdiv($remaining, $eligible->count());
|
||||
$remainder = $remaining % $eligible->count();
|
||||
|
||||
$spent = 0;
|
||||
$filledUp = [];
|
||||
|
||||
foreach ($eligible as $index => $bucket) {
|
||||
$bucketShare = $share + ($remainder > 0 ? 1 : 0);
|
||||
if ($remainder > 0) {
|
||||
$remainder--;
|
||||
}
|
||||
|
||||
if ($bucketShare <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
$space = $this->getAvailableSpace($bucket, $capMethod, $allocated);
|
||||
$allocation = min($bucketShare, $space);
|
||||
|
||||
if ($allocation > 0) {
|
||||
$allocated[$bucket->id] = ($allocated[$bucket->id] ?? 0) + $allocation;
|
||||
$spent += $allocation;
|
||||
}
|
||||
|
||||
if ($allocation >= $space) {
|
||||
$filledUp[] = $index;
|
||||
}
|
||||
}
|
||||
|
||||
$remaining -= $spent;
|
||||
|
||||
if (empty($filledUp)) {
|
||||
// No bucket filled up, so we're done (all money placed or share was 0)
|
||||
break;
|
||||
}
|
||||
|
||||
// Remove filled buckets and retry with remaining money
|
||||
$eligible = $eligible->forget($filledUp)->values();
|
||||
}
|
||||
|
||||
return $remaining;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available space for a bucket given the cap method and already-allocated amounts.
|
||||
*/
|
||||
private function getAvailableSpace(Bucket $bucket, string $capMethod, array $allocated): int
|
||||
{
|
||||
// Non-fixed-limit buckets (percentage, unlimited) have no meaningful cap in phased distribution
|
||||
if (! $bucket->hasFiniteCapacity()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$cap = $capMethod === self::CAP_BASE
|
||||
? ($bucket->allocation_value ?? 0)
|
||||
: $bucket->getEffectiveCapacity();
|
||||
|
||||
$currentBalance = $bucket->getCurrentBalance();
|
||||
$alreadyAllocated = $allocated[$bucket->id] ?? 0;
|
||||
|
||||
return max(0, $cap - $currentBalance - $alreadyAllocated);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@
|
|||
|
|
||||
*/
|
||||
|
||||
'home' => '/dashboard',
|
||||
'home' => '/',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\BucketAllocationTypeEnum;
|
||||
use App\Enums\BucketTypeEnum;
|
||||
use App\Models\Bucket;
|
||||
use App\Models\Scenario;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
|
@ -14,14 +15,19 @@ class BucketFactory extends Factory
|
|||
{
|
||||
public function definition(): array
|
||||
{
|
||||
// Unlimited excluded — use ->overflow() state modifier
|
||||
$allocationType = $this->faker->randomElement([
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
BucketAllocationTypeEnum::PERCENTAGE,
|
||||
BucketAllocationTypeEnum::UNLIMITED,
|
||||
]);
|
||||
|
||||
return [
|
||||
'scenario_id' => Scenario::factory(),
|
||||
// Overflow excluded — use ->overflow() state modifier
|
||||
'type' => $this->faker->randomElement([
|
||||
BucketTypeEnum::NEED,
|
||||
BucketTypeEnum::WANT,
|
||||
]),
|
||||
'name' => $this->faker->randomElement([
|
||||
'Monthly Expenses',
|
||||
'Emergency Fund',
|
||||
|
|
@ -40,33 +46,38 @@ public function definition(): array
|
|||
'sort_order' => $this->faker->numberBetween(0, 10),
|
||||
'allocation_type' => $allocationType,
|
||||
'allocation_value' => $this->getAllocationValueForType($allocationType),
|
||||
'buffer_multiplier' => 0,
|
||||
'starting_amount' => $this->faker->numberBetween(0, 100000), // $0 to $1000 in cents
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fixed limit bucket.
|
||||
*
|
||||
* @param int|null $amountInCents Capacity in cents (e.g., 50000 = $500)
|
||||
*/
|
||||
public function fixedLimit($amount = null): Factory
|
||||
public function fixedLimit(?int $amountInCents = null): Factory
|
||||
{
|
||||
$amount = $amount ?? $this->faker->numberBetween(500, 5000);
|
||||
$amountInCents = $amountInCents ?? $this->faker->numberBetween(50000, 500000);
|
||||
|
||||
return $this->state([
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
'allocation_value' => $amount,
|
||||
'allocation_value' => $amountInCents,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a percentage bucket.
|
||||
*
|
||||
* @param int|null $basisPoints Percentage in basis points (e.g., 2500 = 25%)
|
||||
*/
|
||||
public function percentage($percentage = null): Factory
|
||||
public function percentage(?int $basisPoints = null): Factory
|
||||
{
|
||||
$percentage = $percentage ?? $this->faker->numberBetween(10, 50);
|
||||
$basisPoints = $basisPoints ?? $this->faker->numberBetween(1000, 5000);
|
||||
|
||||
return $this->state([
|
||||
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
|
||||
'allocation_value' => $percentage,
|
||||
'allocation_value' => $basisPoints,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -82,13 +93,56 @@ public function unlimited(): Factory
|
|||
}
|
||||
|
||||
/**
|
||||
* Create default buckets set (Monthly Expenses, Emergency Fund, Investments).
|
||||
* Create a need bucket.
|
||||
*/
|
||||
public function need(): Factory
|
||||
{
|
||||
return $this->state([
|
||||
'type' => BucketTypeEnum::NEED,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a want bucket.
|
||||
*/
|
||||
public function want(): Factory
|
||||
{
|
||||
return $this->state([
|
||||
'type' => BucketTypeEnum::WANT,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an overflow bucket.
|
||||
*/
|
||||
public function overflow(): Factory
|
||||
{
|
||||
return $this->state([
|
||||
'type' => BucketTypeEnum::OVERFLOW,
|
||||
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
|
||||
'allocation_value' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a bucket with a buffer multiplier.
|
||||
*/
|
||||
public function withBuffer(float $multiplier): Factory
|
||||
{
|
||||
return $this->state([
|
||||
'buffer_multiplier' => $multiplier,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default buckets set (Monthly Expenses, Emergency Fund, Overflow).
|
||||
*/
|
||||
public function defaultSet(): array
|
||||
{
|
||||
return [
|
||||
$this->state([
|
||||
'name' => 'Monthly Expenses',
|
||||
'type' => BucketTypeEnum::NEED,
|
||||
'priority' => 1,
|
||||
'sort_order' => 1,
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
|
|
@ -97,6 +151,7 @@ public function defaultSet(): array
|
|||
]),
|
||||
$this->state([
|
||||
'name' => 'Emergency Fund',
|
||||
'type' => BucketTypeEnum::NEED,
|
||||
'priority' => 2,
|
||||
'sort_order' => 2,
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
|
|
@ -104,7 +159,8 @@ public function defaultSet(): array
|
|||
'starting_amount' => 0,
|
||||
]),
|
||||
$this->state([
|
||||
'name' => 'Investments',
|
||||
'name' => 'Overflow',
|
||||
'type' => BucketTypeEnum::OVERFLOW,
|
||||
'priority' => 3,
|
||||
'sort_order' => 3,
|
||||
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
|
||||
|
|
@ -117,11 +173,11 @@ public function defaultSet(): array
|
|||
/**
|
||||
* Get allocation value based on type.
|
||||
*/
|
||||
private function getAllocationValueForType(BucketAllocationTypeEnum $type): ?float
|
||||
private function getAllocationValueForType(BucketAllocationTypeEnum $type): ?int
|
||||
{
|
||||
return match ($type) {
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT => $this->faker->numberBetween(100, 10000),
|
||||
BucketAllocationTypeEnum::PERCENTAGE => $this->faker->numberBetween(5, 50),
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT => $this->faker->numberBetween(10000, 1000000),
|
||||
BucketAllocationTypeEnum::PERCENTAGE => $this->faker->numberBetween(500, 5000),
|
||||
BucketAllocationTypeEnum::UNLIMITED => null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\DistributionModeEnum;
|
||||
use App\Models\Scenario;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
|
|
@ -15,6 +16,12 @@ public function definition(): array
|
|||
return [
|
||||
'name' => $this->faker->words(2, true).' Budget',
|
||||
'description' => $this->faker->text,
|
||||
'distribution_mode' => DistributionModeEnum::EVEN,
|
||||
];
|
||||
}
|
||||
|
||||
public function priority(): static
|
||||
{
|
||||
return $this->state(fn () => ['distribution_mode' => DistributionModeEnum::PRIORITY]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\DistributionModeEnum;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
|
@ -10,8 +11,10 @@ public function up(): void
|
|||
{
|
||||
Schema::create('scenarios', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->string('distribution_mode')->default(DistributionModeEnum::EVEN->value);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\BucketTypeEnum;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
|
@ -10,15 +11,15 @@ public function up(): void
|
|||
{
|
||||
Schema::create('buckets', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid()->unique();
|
||||
$table->foreignId('scenario_id')->constrained()->onDelete('cascade');
|
||||
$table->enum('type', BucketTypeEnum::values())->default(BucketTypeEnum::NEED->value);
|
||||
$table->string('name');
|
||||
$table->integer('priority')->comment('Lower number = higher priority, 1 = first');
|
||||
$table->integer('sort_order')->default(0)->comment('For UI display ordering');
|
||||
$table->integer('priority');
|
||||
$table->integer('sort_order')->default(0);
|
||||
$table->enum('allocation_type', ['fixed_limit', 'percentage', 'unlimited']);
|
||||
$table->decimal('allocation_value', 10, 2)->nullable()
|
||||
->comment('Limit amount for fixed_limit, percentage for percentage type, NULL for unlimited');
|
||||
$table->unsignedBigInteger('starting_amount')->default(0)
|
||||
->comment('Initial amount in bucket in cents before any draws or outflows');
|
||||
$table->unsignedBigInteger('allocation_value')->nullable();
|
||||
$table->unsignedBigInteger('starting_amount')->default(0);
|
||||
$table->timestamps();
|
||||
|
||||
// Indexes for performance
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ public function up(): void
|
|||
{
|
||||
Schema::create('streams', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->foreignId('scenario_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('bucket_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('name');
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ public function up(): void
|
|||
{
|
||||
Schema::create('inflows', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->foreignId('stream_id')->nullable()->constrained()->onDelete('set null');
|
||||
$table->unsignedBigInteger('amount');
|
||||
$table->date('date');
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ public function up(): void
|
|||
{
|
||||
Schema::create('outflows', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->foreignId('stream_id')->nullable()->constrained()->onDelete('set null');
|
||||
$table->foreignId('bucket_id')->nullable()->constrained()->onDelete('set null');
|
||||
$table->unsignedBigInteger('amount');
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ public function up(): void
|
|||
{
|
||||
Schema::create('draws', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid('uuid')->unique();
|
||||
$table->foreignId('bucket_id')->constrained()->onDelete('cascade');
|
||||
$table->unsignedBigInteger('amount');
|
||||
$table->date('date');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('buckets', function (Blueprint $table) {
|
||||
$table->decimal('buffer_multiplier', 5, 2)
|
||||
->default(0)
|
||||
->after('allocation_value')
|
||||
->comment('Multiplier for buffer capacity on fixed_limit buckets. 0 = no buffer, 1 = 100% extra capacity');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('buckets', function (Blueprint $table) {
|
||||
$table->dropColumn('buffer_multiplier');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -2,8 +2,9 @@
|
|||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Actions\CreateScenarioAction;
|
||||
use App\Models\Scenario;
|
||||
use App\Models\User;
|
||||
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
|
|
@ -13,8 +14,6 @@ class DatabaseSeeder extends Seeder
|
|||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
|
||||
User::firstOrCreate(
|
||||
['email' => 'test@example.com'],
|
||||
[
|
||||
|
|
@ -23,5 +22,11 @@ public function run(): void
|
|||
'email_verified_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
if (Scenario::count() === 0) {
|
||||
app(CreateScenarioAction::class)->execute([
|
||||
'name' => 'My Budget',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,8 +36,6 @@ services:
|
|||
CACHE_DRIVER: "${CACHE_DRIVER:-file}"
|
||||
QUEUE_CONNECTION: "${QUEUE_CONNECTION:-sync}"
|
||||
MAIL_MAILER: "${MAIL_MAILER:-log}"
|
||||
VITE_HOST: "0.0.0.0"
|
||||
VITE_PORT: "5174"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -6,24 +6,6 @@ parameters:
|
|||
count: 1
|
||||
path: app/Actions/CreateBucketAction.php
|
||||
|
||||
-
|
||||
message: '#^Access to undefined constant App\\Models\\Bucket\:\:TYPE_FIXED_LIMIT\.$#'
|
||||
identifier: classConstant.notFound
|
||||
count: 2
|
||||
path: app/Http/Controllers/BucketController.php
|
||||
|
||||
-
|
||||
message: '#^Access to undefined constant App\\Models\\Bucket\:\:TYPE_PERCENTAGE\.$#'
|
||||
identifier: classConstant.notFound
|
||||
count: 2
|
||||
path: app/Http/Controllers/BucketController.php
|
||||
|
||||
-
|
||||
message: '#^Access to undefined constant App\\Models\\Bucket\:\:TYPE_UNLIMITED\.$#'
|
||||
identifier: classConstant.notFound
|
||||
count: 3
|
||||
path: app/Http/Controllers/BucketController.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Relations\\HasMany\:\:orderedBySortOrder\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
|
|
@ -145,7 +127,13 @@ parameters:
|
|||
path: app/Http/Resources/BucketResource.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\BucketResource\:\:\$id\.$#'
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\BucketResource\:\:\$type\.$#'
|
||||
identifier: property.notFound
|
||||
count: 2
|
||||
path: app/Http/Resources/BucketResource.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\BucketResource\:\:\$uuid\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Resources/BucketResource.php
|
||||
|
|
@ -187,7 +175,19 @@ parameters:
|
|||
path: app/Http/Resources/BucketResource.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method App\\Http\\Resources\\BucketResource\:\:getFormattedAllocationValue\(\)\.$#'
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\BucketResource\:\:\$buffer_multiplier\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Resources/BucketResource.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\BucketResource\:\:\$starting_amount\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Resources/BucketResource.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method App\\Http\\Resources\\BucketResource\:\:getEffectiveCapacity\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: app/Http/Resources/BucketResource.php
|
||||
|
|
@ -198,6 +198,12 @@ parameters:
|
|||
count: 1
|
||||
path: app/Http/Resources/BucketResource.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method App\\Http\\Resources\\BucketResource\:\:hasFiniteCapacity\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: app/Http/Resources/BucketResource.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\DrawResource\:\:\$amount_currency\.$#'
|
||||
identifier: property.notFound
|
||||
|
|
@ -205,7 +211,7 @@ parameters:
|
|||
path: app/Http/Resources/DrawResource.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\DrawResource\:\:\$bucket_id\.$#'
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\DrawResource\:\:\$bucket\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Resources/DrawResource.php
|
||||
|
|
@ -229,7 +235,7 @@ parameters:
|
|||
path: app/Http/Resources/DrawResource.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\DrawResource\:\:\$id\.$#'
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\DrawResource\:\:\$uuid\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Resources/DrawResource.php
|
||||
|
|
@ -265,7 +271,7 @@ parameters:
|
|||
path: app/Http/Resources/InflowResource.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\InflowResource\:\:\$id\.$#'
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\InflowResource\:\:\$uuid\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Resources/InflowResource.php
|
||||
|
|
@ -277,7 +283,7 @@ parameters:
|
|||
path: app/Http/Resources/InflowResource.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\InflowResource\:\:\$stream_id\.$#'
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\InflowResource\:\:\$stream\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Resources/InflowResource.php
|
||||
|
|
@ -289,7 +295,7 @@ parameters:
|
|||
path: app/Http/Resources/OutflowResource.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\OutflowResource\:\:\$bucket_id\.$#'
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\OutflowResource\:\:\$bucket\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Resources/OutflowResource.php
|
||||
|
|
@ -313,7 +319,7 @@ parameters:
|
|||
path: app/Http/Resources/OutflowResource.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\OutflowResource\:\:\$id\.$#'
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\OutflowResource\:\:\$uuid\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Resources/OutflowResource.php
|
||||
|
|
@ -325,11 +331,17 @@ parameters:
|
|||
path: app/Http/Resources/OutflowResource.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\OutflowResource\:\:\$stream_id\.$#'
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\OutflowResource\:\:\$stream\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Resources/OutflowResource.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\ScenarioResource\:\:\$distribution_mode\.$#'
|
||||
identifier: property.notFound
|
||||
count: 2
|
||||
path: app/Http/Resources/ScenarioResource.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\ScenarioResource\:\:\$created_at\.$#'
|
||||
identifier: property.notFound
|
||||
|
|
@ -343,7 +355,7 @@ parameters:
|
|||
path: app/Http/Resources/ScenarioResource.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\ScenarioResource\:\:\$id\.$#'
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\ScenarioResource\:\:\$uuid\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Resources/ScenarioResource.php
|
||||
|
|
@ -369,13 +381,7 @@ parameters:
|
|||
-
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\StreamResource\:\:\$bucket\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Resources/StreamResource.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\StreamResource\:\:\$bucket_id\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
count: 2
|
||||
path: app/Http/Resources/StreamResource.php
|
||||
|
||||
-
|
||||
|
|
@ -397,7 +403,7 @@ parameters:
|
|||
path: app/Http/Resources/StreamResource.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\StreamResource\:\:\$id\.$#'
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\StreamResource\:\:\$uuid\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Resources/StreamResource.php
|
||||
|
|
@ -444,11 +450,6 @@ parameters:
|
|||
count: 1
|
||||
path: app/Http/Resources/StreamResource.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Models\\Bucket\:\:getCurrentBalance\(\) should return int but returns float\.$#'
|
||||
identifier: return.type
|
||||
count: 1
|
||||
path: app/Models/Bucket.php
|
||||
|
||||
-
|
||||
message: '#^Property App\\Models\\Draw\:\:\$casts \(array\<string, string\>\) on left side of \?\? is not nullable\.$#'
|
||||
|
|
@ -537,21 +538,33 @@ parameters:
|
|||
-
|
||||
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$id\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
count: 2
|
||||
path: app/Services/Projection/PipelineAllocationService.php
|
||||
|
||||
-
|
||||
message: '#^Match arm comparison between App\\Enums\\BucketAllocationTypeEnum\:\:UNLIMITED and App\\Enums\\BucketAllocationTypeEnum\:\:UNLIMITED is always true\.$#'
|
||||
identifier: match.alwaysTrue
|
||||
count: 1
|
||||
message: '#^Parameter \#1 \$callback of method Illuminate\\Support\\Collection\<int,Illuminate\\Database\\Eloquent\\Model\>\:\:filter\(\) expects \(callable\(Illuminate\\Database\\Eloquent\\Model, int\)\: bool\)\|null, Closure\(App\\Models\\Bucket\)\: bool given\.$#'
|
||||
identifier: argument.type
|
||||
count: 2
|
||||
path: app/Services/Projection/PipelineAllocationService.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#1 \$bucket of method App\\Services\\Projection\\PipelineAllocationService\:\:calculateBucketAllocation\(\) expects App\\Models\\Bucket, Illuminate\\Database\\Eloquent\\Model given\.$#'
|
||||
message: '#^Parameter \#1 \$callback of method Illuminate\\Support\\Collection\<int,Illuminate\\Database\\Eloquent\\Model\>\:\:first\(\) expects \(callable\(Illuminate\\Database\\Eloquent\\Model, int\)\: bool\)\|null, Closure\(App\\Models\\Bucket\)\: bool given\.$#'
|
||||
identifier: argument.type
|
||||
count: 1
|
||||
path: app/Services/Projection/PipelineAllocationService.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#1 \$buckets of method App\\Services\\Projection\\PipelineAllocationService\:\:allocateToGroup\(\) expects Illuminate\\Support\\Collection\<int, App\\Models\\Bucket\>, Illuminate\\Database\\Eloquent\\Collection\<int, Illuminate\\Database\\Eloquent\\Model\> given\.$#'
|
||||
identifier: argument.type
|
||||
count: 1
|
||||
path: app/Services/Projection/PipelineAllocationService.php
|
||||
|
||||
-
|
||||
message: '#^Parameter &\$allocated by\-ref type of method App\\Services\\Projection\\PipelineAllocationService\:\:allocateToGroup\(\) expects array\<int, int\>, array given\.$#'
|
||||
identifier: parameterByRef.type
|
||||
count: 2
|
||||
path: app/Services/Projection/PipelineAllocationService.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$amount\.$#'
|
||||
identifier: property.notFound
|
||||
|
|
@ -600,6 +613,12 @@ parameters:
|
|||
count: 2
|
||||
path: app/Services/Streams/StatsService.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$type\.$#'
|
||||
identifier: property.notFound
|
||||
count: 3
|
||||
path: tests/Unit/Actions/CreateBucketActionTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Illuminate\\Database\\Eloquent\\Model\:\:\$allocation_type\.$#'
|
||||
identifier: property.notFound
|
||||
|
|
@ -624,8 +643,3 @@ parameters:
|
|||
count: 3
|
||||
path: tests/Unit/Actions/CreateBucketActionTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with float will always evaluate to false\.$#'
|
||||
identifier: method.impossibleType
|
||||
count: 2
|
||||
path: tests/Unit/Actions/CreateBucketActionTest.php
|
||||
|
|
|
|||
BIN
public/fonts/7segment.woff
Normal file
BIN
public/fonts/7segment.woff
Normal file
Binary file not shown.
|
|
@ -5,6 +5,26 @@
|
|||
@source '../views';
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
|
||||
@font-face {
|
||||
font-family: '7Segment';
|
||||
src: url('/fonts/7segment.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.font-digital {
|
||||
font-family: '7Segment', monospace;
|
||||
}
|
||||
|
||||
.glow-red {
|
||||
box-shadow: 0 0 20px rgba(239, 68, 68, 0.4);
|
||||
transition: box-shadow 300ms ease;
|
||||
}
|
||||
|
||||
.glow-red:hover {
|
||||
box-shadow: 0 0 25px rgba(239, 68, 68, 0.6);
|
||||
}
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
|
|
@ -61,80 +81,50 @@ @theme {
|
|||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
/* 80s Digital Theme — dark only */
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--background: oklch(0.1 0 0);
|
||||
--foreground: oklch(0.637 0.237 25.331);
|
||||
--card: oklch(0.12 0 0);
|
||||
--card-foreground: oklch(0.637 0.237 25.331);
|
||||
--popover: oklch(0.12 0 0);
|
||||
--popover-foreground: oklch(0.637 0.237 25.331);
|
||||
--primary: oklch(0.637 0.237 25.331);
|
||||
--primary-foreground: oklch(0.1 0 0);
|
||||
--secondary: oklch(0.2 0 0);
|
||||
--secondary-foreground: oklch(0.637 0.237 25.331);
|
||||
--muted: oklch(0.2 0 0);
|
||||
--muted-foreground: oklch(0.5 0.1 25);
|
||||
--accent: oklch(0.2 0 0);
|
||||
--accent-foreground: oklch(0.637 0.237 25.331);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.87 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.87 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.396 0.141 25.723);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.439 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.985 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.637 0.237 25.331);
|
||||
--input: oklch(0.2 0 0);
|
||||
--ring: oklch(0.637 0.237 25.331);
|
||||
--chart-1: oklch(0.637 0.237 25.331);
|
||||
--chart-2: oklch(0.75 0.18 70);
|
||||
--chart-3: oklch(0.7 0.15 145);
|
||||
--chart-4: oklch(0.75 0.15 55);
|
||||
--chart-5: oklch(0.5 0.1 25);
|
||||
--radius: 0;
|
||||
--sidebar: oklch(0.1 0 0);
|
||||
--sidebar-foreground: oklch(0.637 0.237 25.331);
|
||||
--sidebar-primary: oklch(0.637 0.237 25.331);
|
||||
--sidebar-primary-foreground: oklch(0.1 0 0);
|
||||
--sidebar-accent: oklch(0.2 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.637 0.237 25.331);
|
||||
--sidebar-border: oklch(0.637 0.237 25.331);
|
||||
--sidebar-ring: oklch(0.637 0.237 25.331);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentColor);
|
||||
}
|
||||
|
||||
body {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,38 @@
|
|||
import { Breadcrumbs } from '@/components/Breadcrumbs';
|
||||
import SettingsPanel from '@/components/SettingsPanel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function AppSidebarHeader({
|
||||
breadcrumbs = [],
|
||||
}: {
|
||||
breadcrumbs?: BreadcrumbItemType[];
|
||||
}) {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b border-sidebar-border/50 px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Breadcrumbs breadcrumbs={breadcrumbs} />
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
<Settings className="h-5 w-5 opacity-80" />
|
||||
<span className="sr-only">Settings</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SettingsPanel onOpenChange={setSettingsOpen} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
57
resources/js/components/BucketCard.tsx
Normal file
57
resources/js/components/BucketCard.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import DigitalProgressBar from '@/components/DigitalProgressBar';
|
||||
import { type Bucket } from '@/types';
|
||||
|
||||
const centsToDollars = (cents: number): number => cents / 100;
|
||||
const basisPointsToPercent = (bp: number): number => bp / 100;
|
||||
const formatDollars = (cents: number): string => `$${centsToDollars(cents).toFixed(0)}`;
|
||||
|
||||
export default function BucketCard({ bucket, projectedAmount }: { bucket: Bucket; projectedAmount?: number }) {
|
||||
const hasFiniteCapacity = bucket.allocation_type === 'fixed_limit' && bucket.effective_capacity !== null;
|
||||
|
||||
return (
|
||||
<div className="divide-y-4 divide-red-500">
|
||||
{/* Name */}
|
||||
<div className="px-3 py-1.5">
|
||||
<span className="text-red-500 font-mono font-bold uppercase tracking-wider text-sm truncate block">
|
||||
{bucket.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bars or text — fixed height */}
|
||||
<div className="px-3 py-3 h-14 flex items-center">
|
||||
{hasFiniteCapacity ? (
|
||||
<div className="w-full">
|
||||
<DigitalProgressBar
|
||||
current={bucket.current_balance}
|
||||
capacity={bucket.effective_capacity!}
|
||||
projected={projectedAmount}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full text-center">
|
||||
<span className="font-mono text-red-500/30 text-xs uppercase tracking-widest">
|
||||
{bucket.allocation_type === 'unlimited' ? '~ ALL REMAINING ~' : `~ ${basisPointsToPercent(bucket.allocation_value ?? 0).toFixed(2)}% ~`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="px-3 py-1.5 text-center">
|
||||
<span className="font-digital text-red-500 text-sm">
|
||||
{formatDollars(bucket.current_balance)}
|
||||
</span>
|
||||
{projectedAmount && projectedAmount > 0 && (
|
||||
<span className="font-digital text-yellow-500 text-sm">
|
||||
{' '}+{formatDollars(projectedAmount)}
|
||||
</span>
|
||||
)}
|
||||
{hasFiniteCapacity && (
|
||||
<span className="font-digital text-red-500/50 text-sm">
|
||||
{' '}/ {formatDollars(bucket.effective_capacity!)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
resources/js/components/DigitalProgressBar.tsx
Normal file
57
resources/js/components/DigitalProgressBar.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
interface DigitalProgressBarProps {
|
||||
current: number;
|
||||
capacity: number;
|
||||
projected?: number;
|
||||
}
|
||||
|
||||
const BAR_COUNT = 20;
|
||||
|
||||
function getGlow(index: number): string {
|
||||
if (index < 10) return '0 0 8px rgba(239, 68, 68, 0.6)';
|
||||
if (index < BAR_COUNT - 1) return '0 0 8px rgba(249, 115, 22, 0.6)';
|
||||
return '0 0 8px rgba(34, 197, 94, 0.6)';
|
||||
}
|
||||
|
||||
function getBarColor(index: number): string {
|
||||
if (index < 10) return 'bg-red-500';
|
||||
if (index < BAR_COUNT - 1) return 'bg-orange-500';
|
||||
return 'bg-green-500';
|
||||
}
|
||||
|
||||
const PROJECTED_GLOW = '0 0 8px rgba(234, 179, 8, 0.6)';
|
||||
|
||||
export default function DigitalProgressBar({ current, capacity, projected }: DigitalProgressBarProps) {
|
||||
const filledBars = capacity > 0
|
||||
? Math.min(Math.round((current / capacity) * BAR_COUNT), BAR_COUNT)
|
||||
: 0;
|
||||
const projectedBars = capacity > 0 && projected
|
||||
? Math.min(Math.round(((current + projected) / capacity) * BAR_COUNT), BAR_COUNT)
|
||||
: filledBars;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${BAR_COUNT}, 1fr)`,
|
||||
gap: '10px',
|
||||
height: '2rem',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: BAR_COUNT }, (_, i) => {
|
||||
const filled = i < filledBars;
|
||||
const isProjected = !filled && i < projectedBars;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
borderRadius: '3px',
|
||||
transition: 'background-color 300ms, box-shadow 300ms',
|
||||
boxShadow: filled ? getGlow(i) : isProjected ? PROJECTED_GLOW : 'none',
|
||||
}}
|
||||
className={filled ? getBarColor(i) : isProjected ? 'bg-yellow-500' : 'bg-red-500/10'}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
resources/js/components/DistributionLines.tsx
Normal file
149
resources/js/components/DistributionLines.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { type DistributionPreview } from '@/types';
|
||||
|
||||
interface Props {
|
||||
distribution: DistributionPreview;
|
||||
bucketRefs: React.RefObject<Map<string, HTMLElement> | null>;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
incomeRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
interface LinePosition {
|
||||
bucketId: string;
|
||||
amount: number;
|
||||
bucketY: number;
|
||||
bucketRight: number;
|
||||
incomeLeft: number;
|
||||
incomeY: number;
|
||||
}
|
||||
|
||||
const centsToDollars = (cents: number): string => `$${(cents / 100).toFixed(0)}`;
|
||||
|
||||
export default function DistributionLines({ distribution, bucketRefs, containerRef, incomeRef }: Props) {
|
||||
const [positions, setPositions] = useState<LinePosition[]>([]);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const measure = () => {
|
||||
const container = containerRef.current;
|
||||
const income = incomeRef.current;
|
||||
if (!container || !income) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const incomeRect = income.getBoundingClientRect();
|
||||
const incomeLeft = incomeRect.left - containerRect.left;
|
||||
const incomeCenterY = incomeRect.top + incomeRect.height / 2 - containerRect.top;
|
||||
|
||||
const lines: LinePosition[] = [];
|
||||
|
||||
for (const alloc of distribution.allocations) {
|
||||
const el = bucketRefs.current?.get(alloc.bucket_id);
|
||||
if (!el) continue;
|
||||
|
||||
const bucketRect = el.getBoundingClientRect();
|
||||
const bucketRight = bucketRect.right - containerRect.left;
|
||||
const bucketY = bucketRect.top + bucketRect.height / 2 - containerRect.top;
|
||||
|
||||
lines.push({
|
||||
bucketId: alloc.bucket_id,
|
||||
amount: alloc.allocated_amount,
|
||||
bucketY,
|
||||
bucketRight,
|
||||
incomeLeft,
|
||||
incomeY: incomeCenterY,
|
||||
});
|
||||
}
|
||||
|
||||
setPositions(lines);
|
||||
};
|
||||
|
||||
requestAnimationFrame(measure);
|
||||
|
||||
const observer = new ResizeObserver(measure);
|
||||
if (containerRef.current) observer.observe(containerRef.current);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [distribution, bucketRefs, containerRef, incomeRef]);
|
||||
|
||||
if (positions.length === 0) return null;
|
||||
|
||||
// Trunk X position: midpoint between rightmost bucket and income panel
|
||||
const trunkX = positions.length > 0
|
||||
? (Math.max(...positions.map(p => p.bucketRight)) + positions[0].incomeLeft) / 2
|
||||
: 0;
|
||||
const incomeY = positions[0]?.incomeY ?? 0;
|
||||
const allYs = [...positions.map(p => p.bucketY), incomeY];
|
||||
const trunkTop = Math.min(...allYs);
|
||||
const trunkBottom = Math.max(...allYs);
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
style={{ zIndex: 10 }}
|
||||
>
|
||||
{/* Vertical trunk from income to span all bucket Y positions */}
|
||||
<line
|
||||
x1={trunkX}
|
||||
y1={trunkTop}
|
||||
x2={trunkX}
|
||||
y2={trunkBottom}
|
||||
stroke="rgb(239, 68, 68)"
|
||||
strokeWidth={2}
|
||||
strokeOpacity={0.6}
|
||||
/>
|
||||
|
||||
{/* Horizontal line from trunk to income panel */}
|
||||
<line
|
||||
x1={trunkX}
|
||||
y1={positions[0].incomeY}
|
||||
x2={positions[0].incomeLeft}
|
||||
y2={positions[0].incomeY}
|
||||
stroke="rgb(239, 68, 68)"
|
||||
strokeWidth={2}
|
||||
strokeOpacity={0.6}
|
||||
/>
|
||||
|
||||
{/* Branch lines from trunk to each bucket */}
|
||||
{positions.map((pos) => {
|
||||
const isZero = pos.amount === 0;
|
||||
const opacity = isZero ? 0.2 : 0.8;
|
||||
const label = centsToDollars(pos.amount);
|
||||
|
||||
return (
|
||||
<g key={pos.bucketId}>
|
||||
{/* Horizontal branch: trunk → bucket */}
|
||||
<line
|
||||
x1={trunkX}
|
||||
y1={pos.bucketY}
|
||||
x2={pos.bucketRight + 8}
|
||||
y2={pos.bucketY}
|
||||
stroke="rgb(239, 68, 68)"
|
||||
strokeWidth={2}
|
||||
strokeOpacity={opacity}
|
||||
/>
|
||||
{/* Arrow tip */}
|
||||
<polygon
|
||||
points={`${pos.bucketRight + 8},${pos.bucketY - 4} ${pos.bucketRight},${pos.bucketY} ${pos.bucketRight + 8},${pos.bucketY + 4}`}
|
||||
fill="rgb(239, 68, 68)"
|
||||
fillOpacity={opacity}
|
||||
/>
|
||||
{/* Amount label */}
|
||||
<text
|
||||
x={(trunkX + pos.bucketRight) / 2}
|
||||
y={pos.bucketY - 8}
|
||||
textAnchor="middle"
|
||||
fill="rgb(239, 68, 68)"
|
||||
fillOpacity={opacity}
|
||||
fontSize={12}
|
||||
fontFamily="monospace"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
135
resources/js/components/InlineEditInput.tsx
Normal file
135
resources/js/components/InlineEditInput.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface BaseProps {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface NumberProps extends BaseProps {
|
||||
type?: 'number';
|
||||
value: number;
|
||||
onSave: (value: number) => Promise<void>;
|
||||
formatDisplay?: (value: number) => string;
|
||||
min?: number;
|
||||
step?: string;
|
||||
}
|
||||
|
||||
interface TextProps extends BaseProps {
|
||||
type: 'text';
|
||||
value: string;
|
||||
onSave: (value: string) => Promise<void>;
|
||||
formatDisplay?: (value: string) => string;
|
||||
}
|
||||
|
||||
type InlineEditInputProps = NumberProps | TextProps;
|
||||
|
||||
type Status = 'idle' | 'editing' | 'saving' | 'success' | 'error';
|
||||
|
||||
export default function InlineEditInput(props: InlineEditInputProps) {
|
||||
const { className = '', disabled = false } = props;
|
||||
const isText = props.type === 'text';
|
||||
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const savingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'editing' && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const startEditing = () => {
|
||||
if (disabled) return;
|
||||
setEditValue(String(props.value));
|
||||
setStatus('editing');
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setStatus('idle');
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (savingRef.current) return;
|
||||
|
||||
let parsedValue: string | number;
|
||||
if (isText) {
|
||||
const trimmed = editValue.trim();
|
||||
if (trimmed === '' || trimmed === props.value) {
|
||||
setStatus('idle');
|
||||
return;
|
||||
}
|
||||
parsedValue = trimmed;
|
||||
} else {
|
||||
const parsed = Number(editValue);
|
||||
if (isNaN(parsed) || parsed === props.value) {
|
||||
setStatus('idle');
|
||||
return;
|
||||
}
|
||||
parsedValue = parsed;
|
||||
}
|
||||
|
||||
savingRef.current = true;
|
||||
setStatus('saving');
|
||||
try {
|
||||
// Type assertion needed: TS can't correlate isText with the union branch
|
||||
await (props.onSave as (v: typeof parsedValue) => Promise<void>)(
|
||||
parsedValue,
|
||||
);
|
||||
setStatus('success');
|
||||
setTimeout(() => setStatus('idle'), 1500);
|
||||
} catch {
|
||||
setStatus('error');
|
||||
setTimeout(() => setStatus('idle'), 1500);
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
save();
|
||||
} else if (e.key === 'Escape') {
|
||||
cancel();
|
||||
}
|
||||
};
|
||||
|
||||
const displayValue = isText
|
||||
? (props.formatDisplay ?? String)(props.value)
|
||||
: (props.formatDisplay ?? ((v: number) => String(v)))(props.value);
|
||||
|
||||
if (status === 'editing' || status === 'saving') {
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type={isText ? 'text' : 'number'}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={save}
|
||||
onKeyDown={handleKeyDown}
|
||||
min={props.type !== 'text' ? props.min : undefined}
|
||||
step={props.type !== 'text' ? props.step : undefined}
|
||||
disabled={status === 'saving'}
|
||||
className={`border border-red-500 bg-black px-2 py-0.5 text-sm text-red-500 font-mono outline-none focus:ring-1 focus:ring-red-500 ${isText ? 'w-48' : 'w-24'} ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={startEditing}
|
||||
className={`inline-flex items-center gap-1 ${
|
||||
disabled
|
||||
? 'cursor-default text-red-500/30'
|
||||
: 'cursor-pointer px-1 py-0.5 hover:text-red-300'
|
||||
} ${className}`}
|
||||
>
|
||||
{displayValue}
|
||||
{status === 'success' && <span className="text-green-500 font-mono text-xs">OK</span>}
|
||||
{status === 'error' && <span className="text-red-500 font-mono text-xs">ERR</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
98
resources/js/components/InlineEditSelect.tsx
Normal file
98
resources/js/components/InlineEditSelect.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface InlineEditSelectProps {
|
||||
value: string;
|
||||
options: Option[];
|
||||
onSave: (value: string) => Promise<void>;
|
||||
displayLabel?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
type Status = 'idle' | 'editing' | 'saving' | 'success' | 'error';
|
||||
|
||||
export default function InlineEditSelect({
|
||||
value,
|
||||
options,
|
||||
onSave,
|
||||
displayLabel,
|
||||
className = '',
|
||||
disabled = false,
|
||||
}: InlineEditSelectProps) {
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const selectRef = useRef<HTMLSelectElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'editing' && selectRef.current) {
|
||||
selectRef.current.focus();
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const startEditing = () => {
|
||||
if (disabled) return;
|
||||
setStatus('editing');
|
||||
};
|
||||
|
||||
const handleChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newValue = e.target.value;
|
||||
if (newValue === value) {
|
||||
setStatus('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('saving');
|
||||
try {
|
||||
await onSave(newValue);
|
||||
setStatus('success');
|
||||
setTimeout(() => setStatus('idle'), 1500);
|
||||
} catch {
|
||||
setStatus('error');
|
||||
setTimeout(() => setStatus('idle'), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (status === 'editing') {
|
||||
setStatus('idle');
|
||||
}
|
||||
};
|
||||
|
||||
if (status === 'editing' || status === 'saving') {
|
||||
return (
|
||||
<select
|
||||
ref={selectRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
disabled={status === 'saving'}
|
||||
className={`border border-red-500 bg-black px-2 py-0.5 text-sm text-red-500 font-mono outline-none focus:ring-1 focus:ring-red-500 ${className}`}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={startEditing}
|
||||
className={`inline-flex items-center gap-1 ${
|
||||
disabled
|
||||
? 'cursor-default text-red-500/30'
|
||||
: 'cursor-pointer px-1 py-0.5 hover:text-red-300'
|
||||
} ${className}`}
|
||||
>
|
||||
{displayLabel || value}
|
||||
{status === 'success' && <span className="text-green-500 font-mono text-xs">OK</span>}
|
||||
{status === 'error' && <span className="text-red-500 font-mono text-xs">ERR</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
124
resources/js/components/SettingsPanel.tsx
Normal file
124
resources/js/components/SettingsPanel.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { csrfToken } from '@/lib/utils';
|
||||
import { type SharedData } from '@/types';
|
||||
import { router, usePage } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
type SaveStatus = 'idle' | 'saving' | 'success' | 'error';
|
||||
type DistributionMode = 'even' | 'priority';
|
||||
|
||||
interface DistributionOption {
|
||||
value: DistributionMode;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const distributionOptions: DistributionOption[] = [
|
||||
{
|
||||
value: 'even',
|
||||
label: 'EVEN SPLIT',
|
||||
description:
|
||||
'Split evenly across buckets in each phase, respecting individual capacity',
|
||||
},
|
||||
{
|
||||
value: 'priority',
|
||||
label: 'PRIORITY ORDER',
|
||||
description: 'Fill highest-priority bucket first, then next',
|
||||
},
|
||||
];
|
||||
|
||||
interface SettingsPanelProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function SettingsPanel({
|
||||
onOpenChange,
|
||||
}: SettingsPanelProps) {
|
||||
const { scenario } = usePage<SharedData>().props;
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
|
||||
|
||||
if (!scenario) return null;
|
||||
|
||||
const handleDistributionModeChange = async (value: DistributionMode) => {
|
||||
if (value === scenario.distribution_mode) return;
|
||||
|
||||
setSaveStatus('saving');
|
||||
try {
|
||||
const response = await fetch(`/scenarios/${scenario.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
},
|
||||
body: JSON.stringify({ distribution_mode: value }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update');
|
||||
|
||||
setSaveStatus('success');
|
||||
router.reload({ only: ['scenario', 'buckets'] });
|
||||
setTimeout(() => setSaveStatus('idle'), 1500);
|
||||
} catch {
|
||||
setSaveStatus('error');
|
||||
setTimeout(() => setSaveStatus('idle'), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-8 border-4 border-red-500 bg-black p-6 glow-red">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-mono font-bold tracking-wider uppercase text-red-500">
|
||||
SETTINGS
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="text-red-500/60 hover:text-red-500 font-mono text-sm transition-colors"
|
||||
>
|
||||
CLOSE
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<fieldset disabled={saveStatus === 'saving'}>
|
||||
<legend className="text-xs font-mono uppercase text-red-500/60">
|
||||
DISTRIBUTION MODE
|
||||
{saveStatus === 'success' && (
|
||||
<span className="ml-2 text-green-500">SAVED</span>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<span className="ml-2 text-red-500">FAILED</span>
|
||||
)}
|
||||
</legend>
|
||||
<div className="mt-3 space-y-2">
|
||||
{distributionOptions.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`flex cursor-pointer items-start gap-3 border-2 p-3 transition-colors ${
|
||||
scenario.distribution_mode === option.value
|
||||
? 'border-red-500 bg-red-500/10'
|
||||
: 'border-red-500/30 hover:border-red-500/60'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="distribution_mode"
|
||||
value={option.value}
|
||||
checked={scenario.distribution_mode === option.value}
|
||||
onChange={(e) =>
|
||||
handleDistributionModeChange(e.target.value as DistributionMode)
|
||||
}
|
||||
className="mt-0.5 accent-red-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-mono font-bold text-red-500">
|
||||
{option.label}
|
||||
</div>
|
||||
<div className="text-xs font-mono text-red-500/60">
|
||||
{option.description}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,3 +16,11 @@ export function isSameUrl(
|
|||
export function resolveUrl(url: NonNullable<InertiaLinkProps['href']>): string {
|
||||
return typeof url === 'string' ? url : url.url;
|
||||
}
|
||||
|
||||
export function csrfToken(): string {
|
||||
return (
|
||||
document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute('content') || ''
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,153 +0,0 @@
|
|||
import { Head, router } from '@inertiajs/react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface Scenario {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
scenarios: {
|
||||
data: Scenario[];
|
||||
};
|
||||
}
|
||||
|
||||
export default function Index({ scenarios }: Props) {
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [errors, setErrors] = useState<{ name?: string }>({});
|
||||
|
||||
const handleCreateScenario = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
router.post('/scenarios', { name }, {
|
||||
onSuccess: () => {
|
||||
setShowCreateForm(false);
|
||||
setName('');
|
||||
setErrors({});
|
||||
},
|
||||
onError: (errors) => {
|
||||
setErrors(errors);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setShowCreateForm(false);
|
||||
setName('');
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Budget Scenarios" />
|
||||
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Budget Scenarios</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Manage your budget projections with water-themed scenarios
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Create Scenario Form */}
|
||||
{showCreateForm && (
|
||||
<div className="mb-8 rounded-lg bg-white p-6 shadow" data-testid="scenario-form">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Create New Scenario</h2>
|
||||
<form onSubmit={handleCreateScenario}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
Scenario Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
data-testid="scenario-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-blue-500"
|
||||
placeholder="e.g., 2025 Budget"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="save-scenario"
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
Create Scenario
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="cancel-scenario"
|
||||
onClick={handleCancel}
|
||||
className="rounded-md border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Button */}
|
||||
{!showCreateForm && (
|
||||
<div className="mb-8">
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
data-testid="create-scenario-button"
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
+ New Scenario
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scenarios List */}
|
||||
{scenarios.data.length === 0 ? (
|
||||
<div className="rounded-lg bg-white p-12 text-center shadow">
|
||||
<div className="mx-auto h-12 w-12 text-gray-400">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-semibold text-gray-900">No scenarios yet</h3>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Create your first budget scenario to get started
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{scenarios.data.map((scenario) => (
|
||||
<div
|
||||
key={scenario.id}
|
||||
onClick={() => router.visit(`/scenarios/${scenario.id}`)}
|
||||
className="cursor-pointer rounded-lg bg-white p-6 shadow transition-shadow hover:shadow-lg"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{scenario.name}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Created {new Date(scenario.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
<div className="mt-4 flex items-center text-sm text-blue-600">
|
||||
View Details
|
||||
<svg className="ml-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
43
resources/js/types/index.d.ts
vendored
43
resources/js/types/index.d.ts
vendored
|
|
@ -22,10 +22,53 @@ export interface NavItem {
|
|||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface Bucket {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'need' | 'want' | 'overflow';
|
||||
type_label: string;
|
||||
priority: number;
|
||||
sort_order: number;
|
||||
allocation_type: string;
|
||||
allocation_value: number | null;
|
||||
allocation_type_label: string;
|
||||
buffer_multiplier: number;
|
||||
effective_capacity: number | null;
|
||||
starting_amount: number;
|
||||
current_balance: number;
|
||||
has_available_space: boolean;
|
||||
available_space: number | null;
|
||||
}
|
||||
|
||||
export interface Scenario {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
distribution_mode: 'even' | 'priority';
|
||||
distribution_mode_label: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AllocationPreview {
|
||||
bucket_id: string;
|
||||
bucket_name: string;
|
||||
bucket_type: string;
|
||||
allocated_amount: number;
|
||||
remaining_capacity: number | null;
|
||||
}
|
||||
|
||||
export interface DistributionPreview {
|
||||
allocations: AllocationPreview[];
|
||||
total_allocated: number;
|
||||
unallocated: number;
|
||||
}
|
||||
|
||||
export interface SharedData {
|
||||
name: string;
|
||||
quote: { message: string; author: string };
|
||||
auth: Auth;
|
||||
scenario: Scenario | null;
|
||||
sidebarOpen: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,17 +4,16 @@
|
|||
use App\Http\Controllers\ProjectionController;
|
||||
use App\Http\Controllers\ScenarioController;
|
||||
use App\Http\Controllers\StreamController;
|
||||
use App\Models\Scenario;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
|
||||
// Scenario routes (no auth required for MVP)
|
||||
Route::get('/', [ScenarioController::class, 'index'])->name('scenarios.index');
|
||||
Route::get('/scenarios/create', [ScenarioController::class, 'create'])->name('scenarios.create');
|
||||
Route::post('/scenarios', [ScenarioController::class, 'store'])->name('scenarios.store');
|
||||
// Single scenario: redirect to default
|
||||
Route::get('/', function () {
|
||||
return redirect()->route('scenarios.show', Scenario::firstOrFail());
|
||||
})->name('home');
|
||||
|
||||
Route::get('/scenarios/{scenario}', [ScenarioController::class, 'show'])->name('scenarios.show');
|
||||
Route::get('/scenarios/{scenario}/edit', [ScenarioController::class, 'edit'])->name('scenarios.edit');
|
||||
Route::patch('/scenarios/{scenario}', [ScenarioController::class, 'update'])->name('scenarios.update');
|
||||
Route::delete('/scenarios/{scenario}', [ScenarioController::class, 'destroy'])->name('scenarios.destroy');
|
||||
|
||||
// Bucket routes (no auth required for MVP)
|
||||
Route::get('/scenarios/{scenario}/buckets', [BucketController::class, 'index'])->name('buckets.index');
|
||||
|
|
@ -32,11 +31,7 @@
|
|||
|
||||
// Projection routes (no auth required for MVP)
|
||||
Route::post('/scenarios/{scenario}/projections/calculate', [ProjectionController::class, 'calculate'])->name('projections.calculate');
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('dashboard', function () {
|
||||
return Inertia::render('dashboard');
|
||||
})->name('dashboard');
|
||||
});
|
||||
Route::post('/scenarios/{scenario}/projections/preview', [ProjectionController::class, 'preview'])->name('projections.preview');
|
||||
Route::post('/scenarios/{scenario}/projections/apply', [ProjectionController::class, 'apply'])->name('projections.apply');
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
|
|
|
|||
168
tests/Feature/ApplyDistributionTest.php
Normal file
168
tests/Feature/ApplyDistributionTest.php
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Bucket;
|
||||
use App\Models\Scenario;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ApplyDistributionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Scenario $scenario;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->scenario = Scenario::factory()->create();
|
||||
}
|
||||
|
||||
public function test_apply_updates_bucket_starting_amounts(): void
|
||||
{
|
||||
Bucket::factory()->need()->fixedLimit(50000)->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'name' => 'Rent',
|
||||
'priority' => 1,
|
||||
'starting_amount' => 0,
|
||||
]);
|
||||
|
||||
Bucket::factory()->want()->fixedLimit(30000)->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'name' => 'Fun',
|
||||
'priority' => 2,
|
||||
'starting_amount' => 0,
|
||||
]);
|
||||
|
||||
$response = $this->postJson(
|
||||
"/scenarios/{$this->scenario->uuid}/projections/apply",
|
||||
['amount' => 70000]
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$this->assertDatabaseHas('buckets', [
|
||||
'name' => 'Rent',
|
||||
'starting_amount' => 50000,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('buckets', [
|
||||
'name' => 'Fun',
|
||||
'starting_amount' => 20000,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_apply_is_additive_to_existing_balance(): void
|
||||
{
|
||||
Bucket::factory()->need()->fixedLimit(50000)->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'name' => 'Rent',
|
||||
'priority' => 1,
|
||||
'starting_amount' => 20000,
|
||||
]);
|
||||
|
||||
$response = $this->postJson(
|
||||
"/scenarios/{$this->scenario->uuid}/projections/apply",
|
||||
['amount' => 15000]
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$this->assertDatabaseHas('buckets', [
|
||||
'name' => 'Rent',
|
||||
'starting_amount' => 35000,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_apply_returns_allocation_breakdown(): void
|
||||
{
|
||||
$bucket = Bucket::factory()->need()->fixedLimit(50000)->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'name' => 'Rent',
|
||||
'priority' => 1,
|
||||
'starting_amount' => 0,
|
||||
]);
|
||||
|
||||
$response = $this->postJson(
|
||||
"/scenarios/{$this->scenario->uuid}/projections/apply",
|
||||
['amount' => 30000]
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('allocations.0.bucket_id', $bucket->uuid);
|
||||
$response->assertJsonPath('allocations.0.bucket_name', 'Rent');
|
||||
$response->assertJsonPath('allocations.0.allocated_amount', 30000);
|
||||
$response->assertJsonPath('total_allocated', 30000);
|
||||
$response->assertJsonPath('unallocated', 0);
|
||||
}
|
||||
|
||||
public function test_apply_rejects_missing_amount(): void
|
||||
{
|
||||
$response = $this->postJson(
|
||||
"/scenarios/{$this->scenario->uuid}/projections/apply",
|
||||
[]
|
||||
);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors('amount');
|
||||
}
|
||||
|
||||
public function test_apply_rejects_zero_amount(): void
|
||||
{
|
||||
$response = $this->postJson(
|
||||
"/scenarios/{$this->scenario->uuid}/projections/apply",
|
||||
['amount' => 0]
|
||||
);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors('amount');
|
||||
}
|
||||
|
||||
public function test_apply_with_overflow_bucket_captures_remainder(): void
|
||||
{
|
||||
Bucket::factory()->need()->fixedLimit(20000)->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'name' => 'Rent',
|
||||
'priority' => 1,
|
||||
'starting_amount' => 0,
|
||||
]);
|
||||
|
||||
Bucket::factory()->overflow()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'name' => 'Overflow',
|
||||
'priority' => 2,
|
||||
'starting_amount' => 0,
|
||||
]);
|
||||
|
||||
$response = $this->postJson(
|
||||
"/scenarios/{$this->scenario->uuid}/projections/apply",
|
||||
['amount' => 50000]
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$this->assertDatabaseHas('buckets', [
|
||||
'name' => 'Rent',
|
||||
'starting_amount' => 20000,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('buckets', [
|
||||
'name' => 'Overflow',
|
||||
'starting_amount' => 30000,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_apply_with_no_buckets_returns_empty(): void
|
||||
{
|
||||
$response = $this->postJson(
|
||||
"/scenarios/{$this->scenario->uuid}/projections/apply",
|
||||
['amount' => 100000]
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('allocations', []);
|
||||
$response->assertJsonPath('total_allocated', 0);
|
||||
$response->assertJsonPath('unallocated', 100000);
|
||||
}
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ public function test_users_can_authenticate_using_the_login_screen()
|
|||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(route('dashboard', absolute: false));
|
||||
$response->assertRedirect('/');
|
||||
}
|
||||
|
||||
public function test_users_with_two_factor_enabled_are_redirected_to_two_factor_challenge()
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ public function test_email_can_be_verified()
|
|||
|
||||
Event::assertDispatched(Verified::class);
|
||||
$this->assertTrue($user->fresh()->hasVerifiedEmail());
|
||||
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
|
||||
$response->assertRedirect('/'.'?verified=1');
|
||||
}
|
||||
|
||||
public function test_email_is_not_verified_with_invalid_hash()
|
||||
|
|
@ -81,7 +81,7 @@ public function test_verified_user_is_redirected_to_dashboard_from_verification_
|
|||
|
||||
$response = $this->actingAs($user)->get(route('verification.notice'));
|
||||
|
||||
$response->assertRedirect(route('dashboard', absolute: false));
|
||||
$response->assertRedirect('/');
|
||||
}
|
||||
|
||||
public function test_already_verified_user_visiting_verification_link_is_redirected_without_firing_event_again(): void
|
||||
|
|
@ -99,7 +99,7 @@ public function test_already_verified_user_visiting_verification_link_is_redirec
|
|||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl)
|
||||
->assertRedirect(route('dashboard', absolute: false).'?verified=1');
|
||||
->assertRedirect('/'.'?verified=1');
|
||||
|
||||
$this->assertTrue($user->fresh()->hasVerifiedEmail());
|
||||
Event::assertNotDispatched(Verified::class);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,6 @@ public function test_new_users_can_register()
|
|||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(route('dashboard', absolute: false));
|
||||
$response->assertRedirect('/');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ public function test_does_not_send_verification_notification_if_email_is_verifie
|
|||
|
||||
$this->actingAs($user)
|
||||
->post(route('verification.send'))
|
||||
->assertRedirect(route('dashboard', absolute: false));
|
||||
->assertRedirect('/');
|
||||
|
||||
Notification::assertNothingSent();
|
||||
}
|
||||
|
|
|
|||
189
tests/Feature/BucketUpdateTest.php
Normal file
189
tests/Feature/BucketUpdateTest.php
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\BucketAllocationTypeEnum;
|
||||
use App\Enums\BucketTypeEnum;
|
||||
use App\Models\Bucket;
|
||||
use App\Models\Scenario;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class BucketUpdateTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Scenario $scenario;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->scenario = Scenario::factory()->create();
|
||||
}
|
||||
|
||||
public function test_can_update_starting_amount_only(): void
|
||||
{
|
||||
$bucket = Bucket::factory()->need()->fixedLimit(100000)->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->patchJson("/buckets/{$bucket->uuid}", [
|
||||
'starting_amount' => 500,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertDatabaseHas('buckets', [
|
||||
'id' => $bucket->id,
|
||||
'starting_amount' => 500,
|
||||
'name' => $bucket->name,
|
||||
'type' => $bucket->type->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_can_update_type_only(): void
|
||||
{
|
||||
$bucket = Bucket::factory()->need()->fixedLimit(100000)->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'priority' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->patchJson("/buckets/{$bucket->uuid}", [
|
||||
'type' => 'want',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertDatabaseHas('buckets', [
|
||||
'id' => $bucket->id,
|
||||
'type' => BucketTypeEnum::WANT->value,
|
||||
'name' => $bucket->name,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_can_update_buffer_multiplier_only(): void
|
||||
{
|
||||
$bucket = Bucket::factory()->need()->fixedLimit(100000)->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'buffer_multiplier' => 0,
|
||||
'priority' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->patchJson("/buckets/{$bucket->uuid}", [
|
||||
'buffer_multiplier' => 1.5,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertDatabaseHas('buckets', [
|
||||
'id' => $bucket->id,
|
||||
'buffer_multiplier' => 1.5,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_cannot_change_overflow_type(): void
|
||||
{
|
||||
$bucket = Bucket::factory()->overflow()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'priority' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->patchJson("/buckets/{$bucket->uuid}", [
|
||||
'type' => 'need',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors('type');
|
||||
}
|
||||
|
||||
public function test_starting_amount_rejects_negative(): void
|
||||
{
|
||||
$bucket = Bucket::factory()->need()->fixedLimit(100000)->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'priority' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->patchJson("/buckets/{$bucket->uuid}", [
|
||||
'starting_amount' => -100,
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors('starting_amount');
|
||||
}
|
||||
|
||||
public function test_buffer_multiplier_forced_to_zero_for_non_fixed_limit(): void
|
||||
{
|
||||
$bucket = Bucket::factory()->need()->fixedLimit(100000)->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'buffer_multiplier' => 1.5,
|
||||
'priority' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->patchJson("/buckets/{$bucket->uuid}", [
|
||||
'allocation_type' => 'percentage',
|
||||
'allocation_value' => 2500,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertDatabaseHas('buckets', [
|
||||
'id' => $bucket->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE->value,
|
||||
'buffer_multiplier' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_response_includes_starting_amount(): void
|
||||
{
|
||||
$bucket = Bucket::factory()->need()->fixedLimit(100000)->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'starting_amount' => 500,
|
||||
'priority' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->patchJson("/buckets/{$bucket->uuid}", [
|
||||
'starting_amount' => 750,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('bucket.starting_amount', 750);
|
||||
$response->assertJsonPath('bucket.current_balance', 750);
|
||||
}
|
||||
|
||||
public function test_can_update_name_only(): void
|
||||
{
|
||||
$bucket = Bucket::factory()->need()->fixedLimit(100000)->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'name' => 'Old Name',
|
||||
'priority' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->patchJson("/buckets/{$bucket->uuid}", [
|
||||
'name' => 'New Name',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertDatabaseHas('buckets', [
|
||||
'id' => $bucket->id,
|
||||
'name' => 'New Name',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_partial_update_does_not_null_other_fields(): void
|
||||
{
|
||||
$bucket = Bucket::factory()->need()->fixedLimit(100000)->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'starting_amount' => 500,
|
||||
'buffer_multiplier' => 1.5,
|
||||
'priority' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->patchJson("/buckets/{$bucket->uuid}", [
|
||||
'starting_amount' => 750,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$bucket->refresh();
|
||||
$this->assertEquals(750, $bucket->starting_amount);
|
||||
$this->assertEquals(100000, $bucket->allocation_value);
|
||||
$this->assertEquals(1.5, (float) $bucket->buffer_multiplier);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DashboardTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_guests_are_redirected_to_the_login_page()
|
||||
{
|
||||
$this->get(route('dashboard'))->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_authenticated_users_can_visit_the_dashboard()
|
||||
{
|
||||
$this->actingAs($user = User::factory()->create());
|
||||
|
||||
$this->get(route('dashboard'))->assertOk();
|
||||
}
|
||||
}
|
||||
212
tests/Feature/ProjectionPreviewTest.php
Normal file
212
tests/Feature/ProjectionPreviewTest.php
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Bucket;
|
||||
use App\Models\Scenario;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectionPreviewTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Scenario $scenario;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->scenario = Scenario::factory()->create();
|
||||
}
|
||||
|
||||
public function test_preview_returns_correct_allocations(): void
|
||||
{
|
||||
Bucket::factory()->need()->fixedLimit(50000)->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'name' => 'Rent',
|
||||
'priority' => 1,
|
||||
'starting_amount' => 0,
|
||||
]);
|
||||
|
||||
Bucket::factory()->want()->fixedLimit(30000)->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'name' => 'Fun',
|
||||
'priority' => 2,
|
||||
'starting_amount' => 0,
|
||||
]);
|
||||
|
||||
// Send 70000 cents = $700
|
||||
$response = $this->postJson(
|
||||
"/scenarios/{$this->scenario->uuid}/projections/preview",
|
||||
['amount' => 70000]
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonCount(2, 'allocations');
|
||||
$response->assertJsonPath('allocations.0.bucket_name', 'Rent');
|
||||
$response->assertJsonPath('allocations.0.allocated_amount', 50000);
|
||||
$response->assertJsonPath('allocations.1.bucket_name', 'Fun');
|
||||
$response->assertJsonPath('allocations.1.allocated_amount', 20000);
|
||||
$response->assertJsonPath('total_allocated', 70000);
|
||||
$response->assertJsonPath('unallocated', 0);
|
||||
}
|
||||
|
||||
public function test_preview_returns_remaining_capacity(): void
|
||||
{
|
||||
Bucket::factory()->need()->fixedLimit(50000)->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'name' => 'Savings',
|
||||
'priority' => 1,
|
||||
'starting_amount' => 0,
|
||||
]);
|
||||
|
||||
// Send 30000 cents = $300
|
||||
$response = $this->postJson(
|
||||
"/scenarios/{$this->scenario->uuid}/projections/preview",
|
||||
['amount' => 30000]
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('allocations.0.allocated_amount', 30000);
|
||||
$response->assertJsonPath('allocations.0.remaining_capacity', 20000);
|
||||
}
|
||||
|
||||
public function test_preview_returns_uuids_not_integer_ids(): void
|
||||
{
|
||||
$bucket = Bucket::factory()->need()->fixedLimit(50000)->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'priority' => 1,
|
||||
'starting_amount' => 0,
|
||||
]);
|
||||
|
||||
$response = $this->postJson(
|
||||
"/scenarios/{$this->scenario->uuid}/projections/preview",
|
||||
['amount' => 10000]
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('allocations.0.bucket_id', $bucket->uuid);
|
||||
}
|
||||
|
||||
public function test_preview_includes_bucket_type(): void
|
||||
{
|
||||
Bucket::factory()->need()->fixedLimit(50000)->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'priority' => 1,
|
||||
'starting_amount' => 0,
|
||||
]);
|
||||
|
||||
$response = $this->postJson(
|
||||
"/scenarios/{$this->scenario->uuid}/projections/preview",
|
||||
['amount' => 10000]
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('allocations.0.bucket_type', 'need');
|
||||
}
|
||||
|
||||
public function test_preview_rejects_missing_amount(): void
|
||||
{
|
||||
$response = $this->postJson(
|
||||
"/scenarios/{$this->scenario->uuid}/projections/preview",
|
||||
[]
|
||||
);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors('amount');
|
||||
}
|
||||
|
||||
public function test_preview_rejects_zero_amount(): void
|
||||
{
|
||||
$response = $this->postJson(
|
||||
"/scenarios/{$this->scenario->uuid}/projections/preview",
|
||||
['amount' => 0]
|
||||
);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors('amount');
|
||||
}
|
||||
|
||||
public function test_preview_rejects_negative_amount(): void
|
||||
{
|
||||
$response = $this->postJson(
|
||||
"/scenarios/{$this->scenario->uuid}/projections/preview",
|
||||
['amount' => -50]
|
||||
);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors('amount');
|
||||
}
|
||||
|
||||
public function test_preview_with_no_buckets_returns_empty_allocations(): void
|
||||
{
|
||||
$response = $this->postJson(
|
||||
"/scenarios/{$this->scenario->uuid}/projections/preview",
|
||||
['amount' => 100000]
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('allocations', []);
|
||||
$response->assertJsonPath('total_allocated', 0);
|
||||
$response->assertJsonPath('unallocated', 100000);
|
||||
}
|
||||
|
||||
public function test_preview_respects_priority_ordering(): void
|
||||
{
|
||||
Bucket::factory()->want()->fixedLimit(20000)->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'name' => 'Low Priority',
|
||||
'priority' => 3,
|
||||
'starting_amount' => 0,
|
||||
]);
|
||||
|
||||
Bucket::factory()->need()->fixedLimit(30000)->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'name' => 'High Priority',
|
||||
'priority' => 1,
|
||||
'starting_amount' => 0,
|
||||
]);
|
||||
|
||||
$response = $this->postJson(
|
||||
"/scenarios/{$this->scenario->uuid}/projections/preview",
|
||||
['amount' => 40000]
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('allocations.0.bucket_name', 'High Priority');
|
||||
$response->assertJsonPath('allocations.0.allocated_amount', 30000);
|
||||
$response->assertJsonPath('allocations.1.bucket_name', 'Low Priority');
|
||||
$response->assertJsonPath('allocations.1.allocated_amount', 10000);
|
||||
}
|
||||
|
||||
public function test_preview_with_overflow_bucket_captures_remainder(): void
|
||||
{
|
||||
Bucket::factory()->need()->fixedLimit(20000)->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'name' => 'Rent',
|
||||
'priority' => 1,
|
||||
'starting_amount' => 0,
|
||||
]);
|
||||
|
||||
Bucket::factory()->overflow()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'name' => 'Overflow',
|
||||
'priority' => 2,
|
||||
'starting_amount' => 0,
|
||||
]);
|
||||
|
||||
$response = $this->postJson(
|
||||
"/scenarios/{$this->scenario->uuid}/projections/preview",
|
||||
['amount' => 50000]
|
||||
);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('allocations.0.bucket_name', 'Rent');
|
||||
$response->assertJsonPath('allocations.0.allocated_amount', 20000);
|
||||
$response->assertJsonPath('allocations.1.bucket_name', 'Overflow');
|
||||
$response->assertJsonPath('allocations.1.allocated_amount', 30000);
|
||||
$response->assertJsonPath('allocations.1.remaining_capacity', null);
|
||||
$response->assertJsonPath('total_allocated', 50000);
|
||||
$response->assertJsonPath('unallocated', 0);
|
||||
}
|
||||
}
|
||||
74
tests/Feature/ScenarioUpdateTest.php
Normal file
74
tests/Feature/ScenarioUpdateTest.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\DistributionModeEnum;
|
||||
use App\Models\Scenario;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ScenarioUpdateTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Scenario $scenario;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->scenario = Scenario::factory()->create();
|
||||
}
|
||||
|
||||
public function test_can_update_distribution_mode(): void
|
||||
{
|
||||
$response = $this->patchJson("/scenarios/{$this->scenario->uuid}", [
|
||||
'distribution_mode' => 'priority',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertDatabaseHas('scenarios', [
|
||||
'id' => $this->scenario->id,
|
||||
'distribution_mode' => DistributionModeEnum::PRIORITY->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_rejects_invalid_distribution_mode(): void
|
||||
{
|
||||
$response = $this->patchJson("/scenarios/{$this->scenario->uuid}", [
|
||||
'distribution_mode' => 'invalid',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors(['distribution_mode']);
|
||||
}
|
||||
|
||||
public function test_can_update_distribution_mode_without_name(): void
|
||||
{
|
||||
$originalName = $this->scenario->name;
|
||||
|
||||
$response = $this->patchJson("/scenarios/{$this->scenario->uuid}", [
|
||||
'distribution_mode' => 'priority',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertDatabaseHas('scenarios', [
|
||||
'id' => $this->scenario->id,
|
||||
'name' => $originalName,
|
||||
'distribution_mode' => DistributionModeEnum::PRIORITY->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_can_update_name_without_distribution_mode(): void
|
||||
{
|
||||
$response = $this->patchJson("/scenarios/{$this->scenario->uuid}", [
|
||||
'name' => 'Updated Name',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertDatabaseHas('scenarios', [
|
||||
'id' => $this->scenario->id,
|
||||
'name' => 'Updated Name',
|
||||
'distribution_mode' => $this->scenario->distribution_mode->value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,5 +6,10 @@
|
|||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
//
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->withoutVite();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Actions\CreateBucketAction;
|
||||
use App\Enums\BucketAllocationTypeEnum;
|
||||
use App\Enums\BucketTypeEnum;
|
||||
use App\Models\Bucket;
|
||||
use App\Models\Scenario;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
|
@ -31,13 +32,14 @@ public function test_can_create_fixed_limit_bucket(): void
|
|||
$this->scenario,
|
||||
'Test Bucket',
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
1000.00
|
||||
allocationValue: 100000
|
||||
);
|
||||
|
||||
$this->assertInstanceOf(Bucket::class, $bucket);
|
||||
$this->assertEquals('Test Bucket', $bucket->name);
|
||||
$this->assertEquals(BucketTypeEnum::NEED, $bucket->type);
|
||||
$this->assertEquals(BucketAllocationTypeEnum::FIXED_LIMIT, $bucket->allocation_type);
|
||||
$this->assertEquals(1000.00, $bucket->allocation_value);
|
||||
$this->assertEquals(100000, $bucket->allocation_value);
|
||||
$this->assertEquals(1, $bucket->priority);
|
||||
$this->assertEquals(1, $bucket->sort_order);
|
||||
$this->assertEquals($this->scenario->id, $bucket->scenario_id);
|
||||
|
|
@ -49,52 +51,153 @@ public function test_can_create_percentage_bucket(): void
|
|||
$this->scenario,
|
||||
'Percentage Bucket',
|
||||
BucketAllocationTypeEnum::PERCENTAGE,
|
||||
25.5
|
||||
allocationValue: 2550
|
||||
);
|
||||
|
||||
$this->assertEquals(BucketAllocationTypeEnum::PERCENTAGE, $bucket->allocation_type);
|
||||
$this->assertEquals(25.5, $bucket->allocation_value);
|
||||
$this->assertEquals(2550, $bucket->allocation_value);
|
||||
}
|
||||
|
||||
public function test_can_create_unlimited_bucket(): void
|
||||
public function test_can_create_unlimited_overflow_bucket(): void
|
||||
{
|
||||
$bucket = $this->action->execute(
|
||||
$this->scenario,
|
||||
'Unlimited Bucket',
|
||||
BucketAllocationTypeEnum::UNLIMITED
|
||||
);
|
||||
|
||||
$this->assertEquals(BucketAllocationTypeEnum::UNLIMITED, $bucket->allocation_type);
|
||||
$this->assertNull($bucket->allocation_value);
|
||||
}
|
||||
|
||||
public function test_unlimited_bucket_ignores_allocation_value(): void
|
||||
{
|
||||
$bucket = $this->action->execute(
|
||||
$this->scenario,
|
||||
'Unlimited Bucket',
|
||||
'Overflow Bucket',
|
||||
BucketAllocationTypeEnum::UNLIMITED,
|
||||
999.99 // This should be ignored and set to null
|
||||
BucketTypeEnum::OVERFLOW
|
||||
);
|
||||
|
||||
$this->assertEquals(BucketTypeEnum::OVERFLOW, $bucket->type);
|
||||
$this->assertEquals(BucketAllocationTypeEnum::UNLIMITED, $bucket->allocation_type);
|
||||
$this->assertNull($bucket->allocation_value);
|
||||
}
|
||||
|
||||
public function test_unlimited_overflow_bucket_ignores_allocation_value(): void
|
||||
{
|
||||
$bucket = $this->action->execute(
|
||||
$this->scenario,
|
||||
'Overflow Bucket',
|
||||
BucketAllocationTypeEnum::UNLIMITED,
|
||||
BucketTypeEnum::OVERFLOW,
|
||||
99999
|
||||
);
|
||||
|
||||
$this->assertEquals(BucketAllocationTypeEnum::UNLIMITED, $bucket->allocation_type);
|
||||
$this->assertNull($bucket->allocation_value);
|
||||
}
|
||||
|
||||
public function test_can_create_need_bucket(): void
|
||||
{
|
||||
$bucket = $this->action->execute(
|
||||
$this->scenario,
|
||||
'Need Bucket',
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
BucketTypeEnum::NEED,
|
||||
500
|
||||
);
|
||||
|
||||
$this->assertEquals(BucketTypeEnum::NEED, $bucket->type);
|
||||
}
|
||||
|
||||
public function test_can_create_want_bucket(): void
|
||||
{
|
||||
$bucket = $this->action->execute(
|
||||
$this->scenario,
|
||||
'Want Bucket',
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
BucketTypeEnum::WANT,
|
||||
500
|
||||
);
|
||||
|
||||
$this->assertEquals(BucketTypeEnum::WANT, $bucket->type);
|
||||
}
|
||||
|
||||
public function test_default_type_is_need(): void
|
||||
{
|
||||
$bucket = $this->action->execute(
|
||||
$this->scenario,
|
||||
'Default Type Bucket',
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
allocationValue: 100
|
||||
);
|
||||
|
||||
$this->assertEquals(BucketTypeEnum::NEED, $bucket->type);
|
||||
}
|
||||
|
||||
public function test_overflow_bucket_must_use_unlimited_allocation(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Invalid allocation type for overflow bucket. Allowed: unlimited');
|
||||
|
||||
$this->action->execute(
|
||||
$this->scenario,
|
||||
'Bad Overflow',
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
BucketTypeEnum::OVERFLOW,
|
||||
100
|
||||
);
|
||||
}
|
||||
|
||||
public function test_non_overflow_bucket_cannot_use_unlimited_allocation(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Invalid allocation type for need bucket. Allowed: fixed_limit, percentage');
|
||||
|
||||
$this->action->execute(
|
||||
$this->scenario,
|
||||
'Bad Need Bucket',
|
||||
BucketAllocationTypeEnum::UNLIMITED,
|
||||
BucketTypeEnum::NEED
|
||||
);
|
||||
}
|
||||
|
||||
public function test_cannot_create_second_overflow_bucket(): void
|
||||
{
|
||||
$this->action->execute(
|
||||
$this->scenario,
|
||||
'First Overflow',
|
||||
BucketAllocationTypeEnum::UNLIMITED,
|
||||
BucketTypeEnum::OVERFLOW
|
||||
);
|
||||
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('A scenario can only have one overflow bucket');
|
||||
|
||||
$this->action->execute(
|
||||
$this->scenario,
|
||||
'Second Overflow',
|
||||
BucketAllocationTypeEnum::UNLIMITED,
|
||||
BucketTypeEnum::OVERFLOW
|
||||
);
|
||||
}
|
||||
|
||||
public function test_want_bucket_cannot_use_unlimited_allocation(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Invalid allocation type for want bucket. Allowed: fixed_limit, percentage');
|
||||
|
||||
$this->action->execute(
|
||||
$this->scenario,
|
||||
'Bad Want Bucket',
|
||||
BucketAllocationTypeEnum::UNLIMITED,
|
||||
BucketTypeEnum::WANT
|
||||
);
|
||||
}
|
||||
|
||||
public function test_priority_auto_increments_when_not_specified(): void
|
||||
{
|
||||
$bucket1 = $this->action->execute(
|
||||
$this->scenario,
|
||||
'First Bucket',
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
100
|
||||
allocationValue: 100
|
||||
);
|
||||
|
||||
$bucket2 = $this->action->execute(
|
||||
$this->scenario,
|
||||
'Second Bucket',
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
200
|
||||
allocationValue: 200
|
||||
);
|
||||
|
||||
$this->assertEquals(1, $bucket1->priority);
|
||||
|
|
@ -107,8 +210,8 @@ public function test_can_specify_custom_priority(): void
|
|||
$this->scenario,
|
||||
'Priority Bucket',
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
100,
|
||||
5
|
||||
allocationValue: 100,
|
||||
priority: 5
|
||||
);
|
||||
|
||||
$this->assertEquals(5, $bucket->priority);
|
||||
|
|
@ -117,12 +220,12 @@ public function test_can_specify_custom_priority(): void
|
|||
public function test_existing_priorities_are_shifted_when_inserting(): void
|
||||
{
|
||||
// Create initial buckets
|
||||
$bucket1 = $this->action->execute($this->scenario, 'Bucket 1', BucketAllocationTypeEnum::FIXED_LIMIT, 100, 1);
|
||||
$bucket2 = $this->action->execute($this->scenario, 'Bucket 2', BucketAllocationTypeEnum::FIXED_LIMIT, 200, 2);
|
||||
$bucket3 = $this->action->execute($this->scenario, 'Bucket 3', BucketAllocationTypeEnum::FIXED_LIMIT, 300, 3);
|
||||
$bucket1 = $this->action->execute($this->scenario, 'Bucket 1', BucketAllocationTypeEnum::FIXED_LIMIT, allocationValue: 100, priority: 1);
|
||||
$bucket2 = $this->action->execute($this->scenario, 'Bucket 2', BucketAllocationTypeEnum::FIXED_LIMIT, allocationValue: 200, priority: 2);
|
||||
$bucket3 = $this->action->execute($this->scenario, 'Bucket 3', BucketAllocationTypeEnum::FIXED_LIMIT, allocationValue: 300, priority: 3);
|
||||
|
||||
// Insert a bucket at priority 2
|
||||
$newBucket = $this->action->execute($this->scenario, 'New Bucket', BucketAllocationTypeEnum::FIXED_LIMIT, 150, 2);
|
||||
$newBucket = $this->action->execute($this->scenario, 'New Bucket', BucketAllocationTypeEnum::FIXED_LIMIT, allocationValue: 150, priority: 2);
|
||||
|
||||
// Refresh models from database
|
||||
$bucket1->refresh();
|
||||
|
|
@ -144,8 +247,7 @@ public function test_throws_exception_for_fixed_limit_without_allocation_value()
|
|||
$this->action->execute(
|
||||
$this->scenario,
|
||||
'Test Bucket',
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
null
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -158,7 +260,7 @@ public function test_throws_exception_for_negative_fixed_limit_value(): void
|
|||
$this->scenario,
|
||||
'Test Bucket',
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
-100
|
||||
allocationValue: -100
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -170,34 +272,33 @@ public function test_throws_exception_for_percentage_without_allocation_value():
|
|||
$this->action->execute(
|
||||
$this->scenario,
|
||||
'Test Bucket',
|
||||
BucketAllocationTypeEnum::PERCENTAGE,
|
||||
null
|
||||
BucketAllocationTypeEnum::PERCENTAGE
|
||||
);
|
||||
}
|
||||
|
||||
public function test_throws_exception_for_percentage_below_minimum(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Percentage allocation value must be between 0.01 and 100');
|
||||
$this->expectExceptionMessage('Percentage allocation value must be between 1 and 10000');
|
||||
|
||||
$this->action->execute(
|
||||
$this->scenario,
|
||||
'Test Bucket',
|
||||
BucketAllocationTypeEnum::PERCENTAGE,
|
||||
0.005
|
||||
allocationValue: 0
|
||||
);
|
||||
}
|
||||
|
||||
public function test_throws_exception_for_percentage_above_maximum(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Percentage allocation value must be between 0.01 and 100');
|
||||
$this->expectExceptionMessage('Percentage allocation value must be between 1 and 10000');
|
||||
|
||||
$this->action->execute(
|
||||
$this->scenario,
|
||||
'Test Bucket',
|
||||
BucketAllocationTypeEnum::PERCENTAGE,
|
||||
101
|
||||
allocationValue: 10001
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -210,8 +311,8 @@ public function test_throws_exception_for_negative_priority(): void
|
|||
$this->scenario,
|
||||
'Test Bucket',
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
100,
|
||||
0
|
||||
allocationValue: 100,
|
||||
priority: 0
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -223,20 +324,23 @@ public function test_create_default_buckets(): void
|
|||
|
||||
$this->assertCount(3, $buckets);
|
||||
|
||||
// Monthly Expenses
|
||||
// Monthly Expenses - Need
|
||||
$this->assertEquals('Monthly Expenses', $buckets[0]->name);
|
||||
$this->assertEquals(BucketTypeEnum::NEED, $buckets[0]->type);
|
||||
$this->assertEquals(1, $buckets[0]->priority);
|
||||
$this->assertEquals(BucketAllocationTypeEnum::FIXED_LIMIT, $buckets[0]->allocation_type);
|
||||
$this->assertEquals(0, $buckets[0]->allocation_value);
|
||||
|
||||
// Emergency Fund
|
||||
// Emergency Fund - Need
|
||||
$this->assertEquals('Emergency Fund', $buckets[1]->name);
|
||||
$this->assertEquals(BucketTypeEnum::NEED, $buckets[1]->type);
|
||||
$this->assertEquals(2, $buckets[1]->priority);
|
||||
$this->assertEquals(BucketAllocationTypeEnum::FIXED_LIMIT, $buckets[1]->allocation_type);
|
||||
$this->assertEquals(0, $buckets[1]->allocation_value);
|
||||
|
||||
// Investments
|
||||
$this->assertEquals('Investments', $buckets[2]->name);
|
||||
// Overflow
|
||||
$this->assertEquals('Overflow', $buckets[2]->name);
|
||||
$this->assertEquals(BucketTypeEnum::OVERFLOW, $buckets[2]->type);
|
||||
$this->assertEquals(3, $buckets[2]->priority);
|
||||
$this->assertEquals(BucketAllocationTypeEnum::UNLIMITED, $buckets[2]->allocation_type);
|
||||
$this->assertNull($buckets[2]->allocation_value);
|
||||
|
|
@ -246,8 +350,8 @@ public function test_creates_buckets_in_database_transaction(): void
|
|||
{
|
||||
// This test ensures database consistency by creating multiple buckets
|
||||
// and verifying they all exist with correct priorities
|
||||
$this->action->execute($this->scenario, 'Bucket 1', BucketAllocationTypeEnum::FIXED_LIMIT, 100, 1);
|
||||
$this->action->execute($this->scenario, 'Bucket 2', BucketAllocationTypeEnum::FIXED_LIMIT, 200, 1); // Insert at priority 1
|
||||
$this->action->execute($this->scenario, 'Bucket 1', BucketAllocationTypeEnum::FIXED_LIMIT, allocationValue: 100, priority: 1);
|
||||
$this->action->execute($this->scenario, 'Bucket 2', BucketAllocationTypeEnum::FIXED_LIMIT, allocationValue: 200, priority: 1); // Insert at priority 1
|
||||
|
||||
// Both buckets should exist with correct priorities
|
||||
$buckets = $this->scenario->buckets()->orderBy('priority')->get();
|
||||
|
|
@ -255,4 +359,69 @@ public function test_creates_buckets_in_database_transaction(): void
|
|||
$this->assertEquals('Bucket 2', $buckets[0]->name); // New bucket at priority 1
|
||||
$this->assertEquals('Bucket 1', $buckets[1]->name); // Original bucket shifted to priority 2
|
||||
}
|
||||
|
||||
public function test_can_create_fixed_limit_bucket_with_buffer_multiplier(): void
|
||||
{
|
||||
$bucket = $this->action->execute(
|
||||
$this->scenario,
|
||||
'Buffered Bucket',
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
allocationValue: 50000,
|
||||
bufferMultiplier: 1.5,
|
||||
);
|
||||
|
||||
$this->assertEquals('1.50', $bucket->buffer_multiplier);
|
||||
}
|
||||
|
||||
public function test_buffer_multiplier_defaults_to_zero(): void
|
||||
{
|
||||
$bucket = $this->action->execute(
|
||||
$this->scenario,
|
||||
'No Buffer Bucket',
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
allocationValue: 50000,
|
||||
);
|
||||
|
||||
$this->assertEquals('0.00', $bucket->buffer_multiplier);
|
||||
}
|
||||
|
||||
public function test_buffer_multiplier_forced_to_zero_for_percentage_bucket(): void
|
||||
{
|
||||
$bucket = $this->action->execute(
|
||||
$this->scenario,
|
||||
'Percentage Bucket',
|
||||
BucketAllocationTypeEnum::PERCENTAGE,
|
||||
allocationValue: 2500,
|
||||
bufferMultiplier: 1.0,
|
||||
);
|
||||
|
||||
$this->assertEquals('0.00', $bucket->buffer_multiplier);
|
||||
}
|
||||
|
||||
public function test_buffer_multiplier_forced_to_zero_for_unlimited_bucket(): void
|
||||
{
|
||||
$bucket = $this->action->execute(
|
||||
$this->scenario,
|
||||
'Unlimited Bucket',
|
||||
BucketAllocationTypeEnum::UNLIMITED,
|
||||
BucketTypeEnum::OVERFLOW,
|
||||
bufferMultiplier: 1.0,
|
||||
);
|
||||
|
||||
$this->assertEquals('0.00', $bucket->buffer_multiplier);
|
||||
}
|
||||
|
||||
public function test_throws_exception_for_negative_buffer_multiplier(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Buffer multiplier must be non-negative');
|
||||
|
||||
$this->action->execute(
|
||||
$this->scenario,
|
||||
'Bad Buffer',
|
||||
BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
allocationValue: 50000,
|
||||
bufferMultiplier: -0.5,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,4 +79,98 @@ public function test_current_balance_without_starting_amount_defaults_to_zero()
|
|||
// starting_amount (0) + draws (30000) - outflows (10000) = 20000
|
||||
$this->assertEquals(20000, $bucket->getCurrentBalance());
|
||||
}
|
||||
|
||||
public function test_effective_capacity_with_zero_buffer_equals_allocation_value(): void
|
||||
{
|
||||
$scenario = Scenario::factory()->create();
|
||||
$bucket = Bucket::factory()->fixedLimit(50000)->create([
|
||||
'scenario_id' => $scenario->id,
|
||||
'buffer_multiplier' => 0,
|
||||
]);
|
||||
|
||||
$this->assertEquals(50000, $bucket->getEffectiveCapacity());
|
||||
}
|
||||
|
||||
public function test_effective_capacity_with_full_buffer_doubles_capacity(): void
|
||||
{
|
||||
$scenario = Scenario::factory()->create();
|
||||
$bucket = Bucket::factory()->fixedLimit(50000)->create([
|
||||
'scenario_id' => $scenario->id,
|
||||
'buffer_multiplier' => 1.0,
|
||||
]);
|
||||
|
||||
$this->assertEquals(100000, $bucket->getEffectiveCapacity());
|
||||
}
|
||||
|
||||
public function test_effective_capacity_with_half_buffer(): void
|
||||
{
|
||||
$scenario = Scenario::factory()->create();
|
||||
$bucket = Bucket::factory()->fixedLimit(50000)->create([
|
||||
'scenario_id' => $scenario->id,
|
||||
'buffer_multiplier' => 0.5,
|
||||
]);
|
||||
|
||||
$this->assertEquals(75000, $bucket->getEffectiveCapacity());
|
||||
}
|
||||
|
||||
public function test_effective_capacity_for_percentage_returns_php_float_max(): void
|
||||
{
|
||||
$scenario = Scenario::factory()->create();
|
||||
$bucket = Bucket::factory()->percentage(2500)->create([
|
||||
'scenario_id' => $scenario->id,
|
||||
'buffer_multiplier' => 1.0,
|
||||
]);
|
||||
|
||||
$this->assertEquals(PHP_INT_MAX, $bucket->getEffectiveCapacity());
|
||||
}
|
||||
|
||||
public function test_effective_capacity_for_unlimited_returns_php_float_max(): void
|
||||
{
|
||||
$scenario = Scenario::factory()->create();
|
||||
$bucket = Bucket::factory()->unlimited()->create([
|
||||
'scenario_id' => $scenario->id,
|
||||
'buffer_multiplier' => 1.0,
|
||||
]);
|
||||
|
||||
$this->assertEquals(PHP_INT_MAX, $bucket->getEffectiveCapacity());
|
||||
}
|
||||
|
||||
public function test_has_available_space_uses_effective_capacity(): void
|
||||
{
|
||||
$scenario = Scenario::factory()->create();
|
||||
$bucket = Bucket::factory()->fixedLimit(50000)->create([
|
||||
'scenario_id' => $scenario->id,
|
||||
'starting_amount' => 50000,
|
||||
'buffer_multiplier' => 1.0,
|
||||
]);
|
||||
|
||||
// Balance is 50000, effective capacity is 100000 — still has space
|
||||
$this->assertTrue($bucket->hasAvailableSpace());
|
||||
}
|
||||
|
||||
public function test_has_available_space_false_when_at_effective_capacity(): void
|
||||
{
|
||||
$scenario = Scenario::factory()->create();
|
||||
$bucket = Bucket::factory()->fixedLimit(50000)->create([
|
||||
'scenario_id' => $scenario->id,
|
||||
'starting_amount' => 100000,
|
||||
'buffer_multiplier' => 1.0,
|
||||
]);
|
||||
|
||||
// Balance is 100000, effective capacity is 100000 — no space
|
||||
$this->assertFalse($bucket->hasAvailableSpace());
|
||||
}
|
||||
|
||||
public function test_get_available_space_uses_effective_capacity(): void
|
||||
{
|
||||
$scenario = Scenario::factory()->create();
|
||||
$bucket = Bucket::factory()->fixedLimit(50000)->create([
|
||||
'scenario_id' => $scenario->id,
|
||||
'starting_amount' => 30000,
|
||||
'buffer_multiplier' => 1.0,
|
||||
]);
|
||||
|
||||
// Effective capacity 100000 - balance 30000 = 70000 available
|
||||
$this->assertEquals(70000, $bucket->getAvailableSpace());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
60
tests/Unit/Enums/BucketTypeEnumTest.php
Normal file
60
tests/Unit/Enums/BucketTypeEnumTest.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Enums;
|
||||
|
||||
use App\Enums\BucketAllocationTypeEnum;
|
||||
use App\Enums\BucketTypeEnum;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BucketTypeEnumTest extends TestCase
|
||||
{
|
||||
public function test_has_correct_values(): void
|
||||
{
|
||||
$this->assertEquals('need', BucketTypeEnum::NEED->value);
|
||||
$this->assertEquals('want', BucketTypeEnum::WANT->value);
|
||||
$this->assertEquals('overflow', BucketTypeEnum::OVERFLOW->value);
|
||||
}
|
||||
|
||||
public function test_has_correct_labels(): void
|
||||
{
|
||||
$this->assertEquals('Need', BucketTypeEnum::NEED->getLabel());
|
||||
$this->assertEquals('Want', BucketTypeEnum::WANT->getLabel());
|
||||
$this->assertEquals('Overflow', BucketTypeEnum::OVERFLOW->getLabel());
|
||||
}
|
||||
|
||||
public function test_values_returns_all_string_values(): void
|
||||
{
|
||||
$values = BucketTypeEnum::values();
|
||||
|
||||
$this->assertCount(3, $values);
|
||||
$this->assertContains('need', $values);
|
||||
$this->assertContains('want', $values);
|
||||
$this->assertContains('overflow', $values);
|
||||
}
|
||||
|
||||
public function test_need_allows_fixed_limit_and_percentage(): void
|
||||
{
|
||||
$allowed = BucketTypeEnum::NEED->getAllowedAllocationTypes();
|
||||
|
||||
$this->assertCount(2, $allowed);
|
||||
$this->assertContains(BucketAllocationTypeEnum::FIXED_LIMIT, $allowed);
|
||||
$this->assertContains(BucketAllocationTypeEnum::PERCENTAGE, $allowed);
|
||||
}
|
||||
|
||||
public function test_want_allows_fixed_limit_and_percentage(): void
|
||||
{
|
||||
$allowed = BucketTypeEnum::WANT->getAllowedAllocationTypes();
|
||||
|
||||
$this->assertCount(2, $allowed);
|
||||
$this->assertContains(BucketAllocationTypeEnum::FIXED_LIMIT, $allowed);
|
||||
$this->assertContains(BucketAllocationTypeEnum::PERCENTAGE, $allowed);
|
||||
}
|
||||
|
||||
public function test_overflow_only_allows_unlimited(): void
|
||||
{
|
||||
$allowed = BucketTypeEnum::OVERFLOW->getAllowedAllocationTypes();
|
||||
|
||||
$this->assertCount(1, $allowed);
|
||||
$this->assertContains(BucketAllocationTypeEnum::UNLIMITED, $allowed);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
namespace Tests\Unit;
|
||||
|
||||
use App\Enums\BucketAllocationTypeEnum;
|
||||
use App\Enums\BucketTypeEnum;
|
||||
use App\Models\Bucket;
|
||||
use App\Models\Scenario;
|
||||
use App\Services\Projection\PipelineAllocationService;
|
||||
|
|
@ -24,319 +25,516 @@ protected function setUp(): void
|
|||
$this->scenario = Scenario::factory()->create();
|
||||
}
|
||||
|
||||
public function test_allocates_to_single_fixed_bucket()
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Guard clauses
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
public function test_returns_empty_collection_when_no_buckets(): void
|
||||
{
|
||||
// Arrange: Single bucket with $500 limit
|
||||
$bucket = Bucket::factory()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'name' => 'Emergency Fund',
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
'allocation_value' => 50000,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 1,
|
||||
]);
|
||||
|
||||
// Act: Allocate $300
|
||||
$draws = $this->service->allocateInflow($this->scenario, 30000);
|
||||
|
||||
// Assert: All money goes to emergency fund
|
||||
$this->assertCount(1, $draws);
|
||||
$this->assertEquals($bucket->id, $draws[0]->bucket_id);
|
||||
$this->assertEquals(30000, $draws[0]->amount);
|
||||
}
|
||||
|
||||
public function test_allocates_across_multiple_fixed_buckets_by_priority()
|
||||
{
|
||||
// Arrange: Three buckets with different priorities
|
||||
$bucket1 = Bucket::factory()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
'allocation_value' => 20000,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 1,
|
||||
]);
|
||||
$bucket2 = Bucket::factory()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
'allocation_value' => 30000,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 2,
|
||||
]);
|
||||
$bucket3 = Bucket::factory()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
'allocation_value' => 15000,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 3,
|
||||
]);
|
||||
|
||||
// Act: Allocate $550 (should fill bucket1 + bucket2 + partial bucket3)
|
||||
$draws = $this->service->allocateInflow($this->scenario, 55000);
|
||||
|
||||
// Assert: Allocation follows priority order
|
||||
$this->assertCount(3, $draws);
|
||||
|
||||
// Bucket 1: fully filled
|
||||
$this->assertEquals($bucket1->id, $draws[0]->bucket_id);
|
||||
$this->assertEquals(20000, $draws[0]->amount);
|
||||
|
||||
// Bucket 2: fully filled
|
||||
$this->assertEquals($bucket2->id, $draws[1]->bucket_id);
|
||||
$this->assertEquals(30000, $draws[1]->amount);
|
||||
|
||||
// Bucket 3: partially filled
|
||||
$this->assertEquals($bucket3->id, $draws[2]->bucket_id);
|
||||
$this->assertEquals(5000, $draws[2]->amount);
|
||||
}
|
||||
|
||||
public function test_percentage_bucket_gets_percentage_of_remaining_amount()
|
||||
{
|
||||
// Arrange: Fixed bucket + percentage bucket
|
||||
$fixedBucket = Bucket::factory()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
'allocation_value' => 30000,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 1,
|
||||
]);
|
||||
$percentageBucket = Bucket::factory()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
|
||||
'allocation_value' => 20.00, // 20%
|
||||
'starting_amount' => 0,
|
||||
'priority' => 2,
|
||||
]);
|
||||
|
||||
// Act: Allocate $1000
|
||||
$draws = $this->service->allocateInflow($this->scenario, 100000);
|
||||
|
||||
// Assert: Fixed gets $300, percentage gets 20% of remaining $700 = $140
|
||||
$this->assertCount(2, $draws);
|
||||
$this->assertEquals(30000, $draws[0]->amount); // Fixed bucket
|
||||
$this->assertEquals(14000, $draws[1]->amount); // 20% of $700
|
||||
}
|
||||
|
||||
public function test_unlimited_bucket_gets_all_remaining_amount()
|
||||
{
|
||||
// Arrange: Fixed + unlimited buckets
|
||||
$fixedBucket = Bucket::factory()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
'allocation_value' => 50000,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 1,
|
||||
]);
|
||||
$unlimitedBucket = Bucket::factory()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
|
||||
'allocation_value' => null,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 2,
|
||||
]);
|
||||
|
||||
// Act: Allocate $1500
|
||||
$draws = $this->service->allocateInflow($this->scenario, 150000);
|
||||
|
||||
// Assert: Fixed gets $500, unlimited gets remaining $1000
|
||||
$this->assertCount(2, $draws);
|
||||
$this->assertEquals(50000, $draws[0]->amount);
|
||||
$this->assertEquals(100000, $draws[1]->amount);
|
||||
}
|
||||
|
||||
public function test_skips_buckets_with_zero_allocation()
|
||||
{
|
||||
// Arrange: Bucket that can't accept any money
|
||||
$fullBucket = Bucket::factory()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
'allocation_value' => 0, // No capacity
|
||||
'starting_amount' => 0,
|
||||
'priority' => 1,
|
||||
]);
|
||||
$normalBucket = Bucket::factory()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
'allocation_value' => 30000,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 2,
|
||||
]);
|
||||
|
||||
// Act: Allocate $200
|
||||
$draws = $this->service->allocateInflow($this->scenario, 20000);
|
||||
|
||||
// Assert: Only normal bucket gets allocation
|
||||
$this->assertCount(1, $draws);
|
||||
$this->assertEquals($normalBucket->id, $draws[0]->bucket_id);
|
||||
$this->assertEquals(20000, $draws[0]->amount);
|
||||
}
|
||||
|
||||
public function test_handles_complex_mixed_bucket_scenario()
|
||||
{
|
||||
// Arrange: All bucket types in priority order
|
||||
$fixed1 = Bucket::factory()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
'allocation_value' => 100000,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 1,
|
||||
]);
|
||||
$percentage1 = Bucket::factory()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
|
||||
'allocation_value' => 15.00, // 15%
|
||||
'starting_amount' => 0,
|
||||
'priority' => 2,
|
||||
]);
|
||||
$fixed2 = Bucket::factory()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
'allocation_value' => 50000,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 3,
|
||||
]);
|
||||
$percentage2 = Bucket::factory()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
|
||||
'allocation_value' => 25.00, // 25%
|
||||
'starting_amount' => 0,
|
||||
'priority' => 4,
|
||||
]);
|
||||
$unlimited = Bucket::factory()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
|
||||
'allocation_value' => null,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 5,
|
||||
]);
|
||||
|
||||
// Act: Allocate $5000
|
||||
$draws = $this->service->allocateInflow($this->scenario, 500000);
|
||||
|
||||
// Assert: Complex allocation logic
|
||||
$this->assertCount(5, $draws);
|
||||
|
||||
// Fixed1: gets $1000 (full capacity)
|
||||
$this->assertEquals(100000, $draws[0]->amount);
|
||||
|
||||
// Percentage1: gets 15% of remaining $4000 = $600
|
||||
$this->assertEquals(60000, $draws[1]->amount);
|
||||
|
||||
// Fixed2: gets $500 (full capacity)
|
||||
$this->assertEquals(50000, $draws[2]->amount);
|
||||
|
||||
// Remaining after fixed allocations: $5000 - $1000 - $600 - $500 = $2900
|
||||
// Percentage2: gets 25% of remaining $2900 = $725
|
||||
$this->assertEquals(72500, $draws[3]->amount);
|
||||
|
||||
// Unlimited: gets remaining $2900 - $725 = $2175
|
||||
$this->assertEquals(217500, $draws[4]->amount);
|
||||
}
|
||||
|
||||
public function test_returns_empty_array_when_no_buckets()
|
||||
{
|
||||
// Act: Allocate to scenario with no buckets
|
||||
$draws = $this->service->allocateInflow($this->scenario, 100000);
|
||||
|
||||
// Assert: No allocations made
|
||||
$this->assertEmpty($draws);
|
||||
}
|
||||
|
||||
public function test_returns_empty_array_when_amount_is_zero()
|
||||
public function test_returns_empty_collection_when_amount_is_zero(): void
|
||||
{
|
||||
// Arrange: Create bucket
|
||||
Bucket::factory()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
'allocation_value' => 50000,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 1,
|
||||
]);
|
||||
$this->createNeedBucket(50000, priority: 1);
|
||||
|
||||
// Act: Allocate $0
|
||||
$draws = $this->service->allocateInflow($this->scenario, 0);
|
||||
|
||||
// Assert: No allocations made
|
||||
$this->assertEmpty($draws);
|
||||
}
|
||||
|
||||
public function test_handles_negative_amount_gracefully()
|
||||
public function test_returns_empty_collection_when_amount_is_negative(): void
|
||||
{
|
||||
// Arrange: Create bucket
|
||||
Bucket::factory()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
'allocation_value' => 50000,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 1,
|
||||
]);
|
||||
$this->createNeedBucket(50000, priority: 1);
|
||||
|
||||
// Act: Allocate negative amount
|
||||
$draws = $this->service->allocateInflow($this->scenario, -10000);
|
||||
|
||||
// Assert: No allocations made
|
||||
$this->assertEmpty($draws);
|
||||
}
|
||||
|
||||
public function test_respects_bucket_priority_order()
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Even mode — base phase
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
public function test_even_mode_splits_evenly_across_need_buckets(): void
|
||||
{
|
||||
// Arrange: Buckets in non-sequential priority order
|
||||
$bucket3 = Bucket::factory()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
'allocation_value' => 10000,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 10, // Higher number
|
||||
]);
|
||||
$bucket1 = Bucket::factory()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
'allocation_value' => 20000,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 1, // Lower number (higher priority)
|
||||
]);
|
||||
$bucket2 = Bucket::factory()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
'allocation_value' => 15000,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 5, // Middle
|
||||
]);
|
||||
// 3 need buckets, $500 base each, $900 income → $300 each
|
||||
$b1 = $this->createNeedBucket(50000, priority: 1);
|
||||
$b2 = $this->createNeedBucket(50000, priority: 2);
|
||||
$b3 = $this->createNeedBucket(50000, priority: 3);
|
||||
|
||||
// Act: Allocate $250
|
||||
$draws = $this->service->allocateInflow($this->scenario, 25000);
|
||||
$draws = $this->service->allocateInflow($this->scenario, 90000);
|
||||
|
||||
// Assert: Priority order respected (1, 5, 10)
|
||||
$this->assertCount(2, $draws);
|
||||
$this->assertEquals($bucket1->id, $draws[0]->bucket_id); // Priority 1 first
|
||||
$this->assertEquals(20000, $draws[0]->amount);
|
||||
$this->assertEquals($bucket2->id, $draws[1]->bucket_id); // Priority 5 second
|
||||
$this->assertEquals(5000, $draws[1]->amount); // Partial fill
|
||||
$this->assertCount(3, $draws);
|
||||
$this->assertDrawAmount($draws, $b1->id, 30000);
|
||||
$this->assertDrawAmount($draws, $b2->id, 30000);
|
||||
$this->assertDrawAmount($draws, $b3->id, 30000);
|
||||
}
|
||||
|
||||
public function test_percentage_allocation_with_insufficient_remaining_amount()
|
||||
public function test_even_mode_redistributes_when_bucket_fills_up(): void
|
||||
{
|
||||
// Arrange: Large fixed bucket + percentage bucket
|
||||
$fixedBucket = Bucket::factory()->create([
|
||||
// Bucket A: $200 base, Bucket B: $500 base, Bucket C: $500 base
|
||||
// $900 income → even share = $300 each → A caps at $200, excess $100 redistributed
|
||||
// B and C get $300 + $50 = $350 each
|
||||
$a = $this->createNeedBucket(20000, priority: 1);
|
||||
$b = $this->createNeedBucket(50000, priority: 2);
|
||||
$c = $this->createNeedBucket(50000, priority: 3);
|
||||
|
||||
$draws = $this->service->allocateInflow($this->scenario, 90000);
|
||||
|
||||
$this->assertCount(3, $draws);
|
||||
$this->assertDrawAmount($draws, $a->id, 20000);
|
||||
$this->assertDrawAmount($draws, $b->id, 35000);
|
||||
$this->assertDrawAmount($draws, $c->id, 35000);
|
||||
}
|
||||
|
||||
public function test_even_mode_needs_filled_before_wants(): void
|
||||
{
|
||||
// 2 need buckets ($500 base each), 1 want bucket ($500 base)
|
||||
// $800 income → needs get $400 each (even split), wants get $0
|
||||
$n1 = $this->createNeedBucket(50000, priority: 1);
|
||||
$n2 = $this->createNeedBucket(50000, priority: 2);
|
||||
$w1 = $this->createWantBucket(50000, priority: 3);
|
||||
|
||||
$draws = $this->service->allocateInflow($this->scenario, 80000);
|
||||
|
||||
$this->assertCount(2, $draws);
|
||||
$this->assertDrawAmount($draws, $n1->id, 40000);
|
||||
$this->assertDrawAmount($draws, $n2->id, 40000);
|
||||
}
|
||||
|
||||
public function test_even_mode_wants_filled_after_needs_base(): void
|
||||
{
|
||||
// 1 need ($300 base), 2 wants ($500 base each)
|
||||
// $1000 income → need gets $300 (base), wants split remaining $700 = $350 each
|
||||
$n = $this->createNeedBucket(30000, priority: 1);
|
||||
$w1 = $this->createWantBucket(50000, priority: 2);
|
||||
$w2 = $this->createWantBucket(50000, priority: 3);
|
||||
|
||||
$draws = $this->service->allocateInflow($this->scenario, 100000);
|
||||
|
||||
$this->assertCount(3, $draws);
|
||||
$this->assertDrawAmount($draws, $n->id, 30000);
|
||||
$this->assertDrawAmount($draws, $w1->id, 35000);
|
||||
$this->assertDrawAmount($draws, $w2->id, 35000);
|
||||
}
|
||||
|
||||
public function test_even_mode_remainder_cents_go_to_highest_priority(): void
|
||||
{
|
||||
// 3 need buckets, $500 base each, $100 income → $33/$33/$34 or similar
|
||||
// intdiv(10000, 3) = 3333, remainder = 1 → first bucket gets 3334
|
||||
$b1 = $this->createNeedBucket(50000, priority: 1);
|
||||
$b2 = $this->createNeedBucket(50000, priority: 2);
|
||||
$b3 = $this->createNeedBucket(50000, priority: 3);
|
||||
|
||||
$draws = $this->service->allocateInflow($this->scenario, 10000);
|
||||
|
||||
$this->assertCount(3, $draws);
|
||||
$this->assertDrawAmount($draws, $b1->id, 3334);
|
||||
$this->assertDrawAmount($draws, $b2->id, 3333);
|
||||
$this->assertDrawAmount($draws, $b3->id, 3333);
|
||||
}
|
||||
|
||||
public function test_even_mode_fewer_cents_than_buckets(): void
|
||||
{
|
||||
// 3 need buckets, 2 cents income → first 2 get 1 cent each, third gets nothing
|
||||
$b1 = $this->createNeedBucket(50000, priority: 1);
|
||||
$b2 = $this->createNeedBucket(50000, priority: 2);
|
||||
$b3 = $this->createNeedBucket(50000, priority: 3);
|
||||
|
||||
$draws = $this->service->allocateInflow($this->scenario, 2);
|
||||
|
||||
$this->assertCount(2, $draws);
|
||||
$this->assertDrawAmount($draws, $b1->id, 1);
|
||||
$this->assertDrawAmount($draws, $b2->id, 1);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Priority mode — base phase
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
public function test_priority_mode_fills_highest_priority_first(): void
|
||||
{
|
||||
$scenario = Scenario::factory()->priority()->create();
|
||||
|
||||
$n1 = $this->createNeedBucket(50000, priority: 1, scenarioId: $scenario->id);
|
||||
$n2 = $this->createNeedBucket(50000, priority: 2, scenarioId: $scenario->id);
|
||||
$w1 = $this->createWantBucket(50000, priority: 3, scenarioId: $scenario->id);
|
||||
|
||||
// $800 → need1 gets $500, need2 gets $300, want gets $0
|
||||
$draws = $this->service->allocateInflow($scenario, 80000);
|
||||
|
||||
$this->assertCount(2, $draws);
|
||||
$this->assertDrawAmount($draws, $n1->id, 50000);
|
||||
$this->assertDrawAmount($draws, $n2->id, 30000);
|
||||
}
|
||||
|
||||
public function test_priority_mode_needs_before_wants(): void
|
||||
{
|
||||
$scenario = Scenario::factory()->priority()->create();
|
||||
|
||||
$n1 = $this->createNeedBucket(50000, priority: 1, scenarioId: $scenario->id);
|
||||
$n2 = $this->createNeedBucket(30000, priority: 2, scenarioId: $scenario->id);
|
||||
$w1 = $this->createWantBucket(50000, priority: 3, scenarioId: $scenario->id);
|
||||
|
||||
// $1000 → need1 $500, need2 $300, want1 gets remaining $200
|
||||
$draws = $this->service->allocateInflow($scenario, 100000);
|
||||
|
||||
$this->assertCount(3, $draws);
|
||||
$this->assertDrawAmount($draws, $n1->id, 50000);
|
||||
$this->assertDrawAmount($draws, $n2->id, 30000);
|
||||
$this->assertDrawAmount($draws, $w1->id, 20000);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Buffer phases
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
public function test_base_filled_before_buffer(): void
|
||||
{
|
||||
// Need: $500 base, 0.5x buffer → $750 effective capacity
|
||||
// $2000 income → base phase: $500, buffer phase: $250, overflow: $1250
|
||||
$n = $this->createNeedBucket(50000, priority: 1, buffer: 0.5);
|
||||
$overflow = $this->createOverflowBucket(priority: 2);
|
||||
|
||||
$draws = $this->service->allocateInflow($this->scenario, 200000);
|
||||
|
||||
$this->assertCount(2, $draws);
|
||||
$this->assertDrawAmount($draws, $n->id, 75000); // base $500 + buffer $250
|
||||
$this->assertDrawAmount($draws, $overflow->id, 125000);
|
||||
}
|
||||
|
||||
public function test_need_buffer_filled_before_want_buffer(): void
|
||||
{
|
||||
// Need: $500 base, 1x buffer → $1000 effective
|
||||
// Want: $500 base, 1x buffer → $1000 effective
|
||||
// $1800 → need base $500, want base $500, need buffer $500, want gets $300 buffer
|
||||
$n = $this->createNeedBucket(50000, priority: 1, buffer: 1.0);
|
||||
$w = $this->createWantBucket(50000, priority: 2, buffer: 1.0);
|
||||
|
||||
$draws = $this->service->allocateInflow($this->scenario, 180000);
|
||||
|
||||
$this->assertCount(2, $draws);
|
||||
$this->assertDrawAmount($draws, $n->id, 100000); // full: base + buffer
|
||||
$this->assertDrawAmount($draws, $w->id, 80000); // base $500 + $300 buffer
|
||||
}
|
||||
|
||||
public function test_priority_mode_buffer_fills_by_priority(): void
|
||||
{
|
||||
$scenario = Scenario::factory()->priority()->create();
|
||||
|
||||
// Need1: $300 base, 1x buffer → $600 effective
|
||||
// Need2: $300 base, 1x buffer → $600 effective
|
||||
// $1000 income
|
||||
$n1 = $this->createNeedBucket(30000, priority: 1, buffer: 1.0, scenarioId: $scenario->id);
|
||||
$n2 = $this->createNeedBucket(30000, priority: 2, buffer: 1.0, scenarioId: $scenario->id);
|
||||
|
||||
$draws = $this->service->allocateInflow($scenario, 100000);
|
||||
|
||||
// Phase 1 (needs base, priority): n1=$300, n2=$300, remaining=$400
|
||||
// Phase 3 (needs buffer, priority): n1 buffer=$300, n2 gets $100, remaining=$0
|
||||
$this->assertCount(2, $draws);
|
||||
$this->assertDrawAmount($draws, $n1->id, 60000); // full base + full buffer
|
||||
$this->assertDrawAmount($draws, $n2->id, 40000); // full base + $100 buffer
|
||||
}
|
||||
|
||||
public function test_no_buffer_when_multiplier_is_zero(): void
|
||||
{
|
||||
// Need: $500 base, 0x buffer → $500 effective
|
||||
// $800 → need gets $500, overflow gets $300
|
||||
$n = $this->createNeedBucket(50000, priority: 1, buffer: 0);
|
||||
$overflow = $this->createOverflowBucket(priority: 2);
|
||||
|
||||
$draws = $this->service->allocateInflow($this->scenario, 80000);
|
||||
|
||||
$this->assertCount(2, $draws);
|
||||
$this->assertDrawAmount($draws, $n->id, 50000);
|
||||
$this->assertDrawAmount($draws, $overflow->id, 30000);
|
||||
}
|
||||
|
||||
public function test_partially_filled_bucket_respects_existing_balance(): void
|
||||
{
|
||||
// Need: $500 base, already has $300 → only $200 space in base phase
|
||||
$n = $this->createNeedBucket(50000, priority: 1, startingAmount: 30000);
|
||||
$overflow = $this->createOverflowBucket(priority: 2);
|
||||
|
||||
$draws = $this->service->allocateInflow($this->scenario, 80000);
|
||||
|
||||
$this->assertCount(2, $draws);
|
||||
$this->assertDrawAmount($draws, $n->id, 20000);
|
||||
$this->assertDrawAmount($draws, $overflow->id, 60000);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Overflow
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
public function test_overflow_captures_remainder(): void
|
||||
{
|
||||
$n = $this->createNeedBucket(30000, priority: 1);
|
||||
$overflow = $this->createOverflowBucket(priority: 2);
|
||||
|
||||
$draws = $this->service->allocateInflow($this->scenario, 100000);
|
||||
|
||||
$this->assertCount(2, $draws);
|
||||
$this->assertDrawAmount($draws, $n->id, 30000);
|
||||
$this->assertDrawAmount($draws, $overflow->id, 70000);
|
||||
}
|
||||
|
||||
public function test_all_buckets_full_everything_to_overflow(): void
|
||||
{
|
||||
$n = $this->createNeedBucket(10000, priority: 1, startingAmount: 10000); // already full
|
||||
$w = $this->createWantBucket(10000, priority: 2, startingAmount: 10000); // already full
|
||||
$overflow = $this->createOverflowBucket(priority: 3);
|
||||
|
||||
$draws = $this->service->allocateInflow($this->scenario, 50000);
|
||||
|
||||
$this->assertCount(1, $draws);
|
||||
$this->assertDrawAmount($draws, $overflow->id, 50000);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Edge cases
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
public function test_no_need_buckets_goes_straight_to_wants(): void
|
||||
{
|
||||
$w1 = $this->createWantBucket(50000, priority: 1);
|
||||
$w2 = $this->createWantBucket(50000, priority: 2);
|
||||
|
||||
$draws = $this->service->allocateInflow($this->scenario, 60000);
|
||||
|
||||
$this->assertCount(2, $draws);
|
||||
$this->assertDrawAmount($draws, $w1->id, 30000);
|
||||
$this->assertDrawAmount($draws, $w2->id, 30000);
|
||||
}
|
||||
|
||||
public function test_single_need_bucket_gets_all_up_to_capacity(): void
|
||||
{
|
||||
$n = $this->createNeedBucket(50000, priority: 1);
|
||||
|
||||
$draws = $this->service->allocateInflow($this->scenario, 30000);
|
||||
|
||||
$this->assertCount(1, $draws);
|
||||
$this->assertDrawAmount($draws, $n->id, 30000);
|
||||
}
|
||||
|
||||
public function test_allocates_to_single_need_bucket_with_buffer(): void
|
||||
{
|
||||
// $500 base, 1x buffer → $1000 effective, $1000 income → gets all
|
||||
$n = $this->createNeedBucket(50000, priority: 1, buffer: 1.0);
|
||||
|
||||
$draws = $this->service->allocateInflow($this->scenario, 100000);
|
||||
|
||||
$this->assertCount(1, $draws);
|
||||
$this->assertDrawAmount($draws, $n->id, 100000);
|
||||
}
|
||||
|
||||
public function test_skips_buckets_with_zero_allocation_value(): void
|
||||
{
|
||||
$zero = $this->createNeedBucket(0, priority: 1);
|
||||
$normal = $this->createNeedBucket(30000, priority: 2);
|
||||
|
||||
$draws = $this->service->allocateInflow($this->scenario, 20000);
|
||||
|
||||
$this->assertCount(1, $draws);
|
||||
$this->assertDrawAmount($draws, $normal->id, 20000);
|
||||
}
|
||||
|
||||
public function test_non_fixed_limit_buckets_are_skipped_in_phases(): void
|
||||
{
|
||||
// Percentage bucket as need → gets nothing (no meaningful cap in phased distribution)
|
||||
Bucket::factory()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
'allocation_value' => 95000,
|
||||
'type' => BucketTypeEnum::NEED,
|
||||
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
|
||||
'allocation_value' => 2500,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 1,
|
||||
]);
|
||||
$percentageBucket = Bucket::factory()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::PERCENTAGE,
|
||||
'allocation_value' => 20.00, // 20%
|
||||
'starting_amount' => 0,
|
||||
'priority' => 2,
|
||||
]);
|
||||
$overflow = $this->createOverflowBucket(priority: 2);
|
||||
|
||||
// Act: Allocate $1000 (only $50 left after fixed)
|
||||
$draws = $this->service->allocateInflow($this->scenario, 100000);
|
||||
$draws = $this->service->allocateInflow($this->scenario, 50000);
|
||||
|
||||
$this->assertCount(1, $draws);
|
||||
$this->assertDrawAmount($draws, $overflow->id, 50000);
|
||||
}
|
||||
|
||||
public function test_surplus_is_lost_when_no_overflow_bucket(): void
|
||||
{
|
||||
// $500 income, only $300 capacity → $200 surplus has nowhere to go
|
||||
$n = $this->createNeedBucket(30000, priority: 1);
|
||||
|
||||
$draws = $this->service->allocateInflow($this->scenario, 50000);
|
||||
|
||||
$this->assertCount(1, $draws);
|
||||
$this->assertDrawAmount($draws, $n->id, 30000);
|
||||
// Remaining $200 is silently dropped — no overflow bucket to catch it
|
||||
}
|
||||
|
||||
public function test_priority_order_respected_regardless_of_creation_order(): void
|
||||
{
|
||||
$scenario = Scenario::factory()->priority()->create();
|
||||
|
||||
// Create in reverse priority order
|
||||
$b3 = $this->createNeedBucket(10000, priority: 10, scenarioId: $scenario->id);
|
||||
$b1 = $this->createNeedBucket(20000, priority: 1, scenarioId: $scenario->id);
|
||||
$b2 = $this->createNeedBucket(15000, priority: 5, scenarioId: $scenario->id);
|
||||
|
||||
$draws = $this->service->allocateInflow($scenario, 25000);
|
||||
|
||||
// Assert: Percentage gets 20% of remaining $50 = $10
|
||||
$this->assertCount(2, $draws);
|
||||
$this->assertEquals(95000, $draws[0]->amount);
|
||||
$this->assertEquals(1000, $draws[1]->amount); // 20% of $50
|
||||
$this->assertDrawAmount($draws, $b1->id, 20000);
|
||||
$this->assertDrawAmount($draws, $b2->id, 5000);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Full pipeline integration
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
public function test_full_pipeline_even_mode(): void
|
||||
{
|
||||
// Need1: $500 base, 0.5x buffer → $750 effective
|
||||
// Need2: $200 base, no buffer → $200 effective
|
||||
// Want1: $400 base, no buffer → $400 effective
|
||||
// Overflow
|
||||
// $900 income — not enough to fill everything
|
||||
$n1 = $this->createNeedBucket(50000, priority: 1, buffer: 0.5);
|
||||
$n2 = $this->createNeedBucket(20000, priority: 2);
|
||||
$w1 = $this->createWantBucket(40000, priority: 3);
|
||||
$overflow = $this->createOverflowBucket(priority: 4);
|
||||
|
||||
$draws = $this->service->allocateInflow($this->scenario, 90000);
|
||||
|
||||
// Phase 1 (needs base, even): n1=$500 (capped), n2=$200 (capped), remaining=$200
|
||||
// Phase 2 (wants base, even): w1=$200 (partial of $400), remaining=$0
|
||||
|
||||
$this->assertCount(3, $draws);
|
||||
$this->assertDrawAmount($draws, $n1->id, 50000); // $500 base
|
||||
$this->assertDrawAmount($draws, $n2->id, 20000); // $200 base
|
||||
$this->assertDrawAmount($draws, $w1->id, 20000); // partial $200 of $400
|
||||
}
|
||||
|
||||
public function test_full_pipeline_priority_mode(): void
|
||||
{
|
||||
$scenario = Scenario::factory()->priority()->create();
|
||||
|
||||
// Need1: $500 base, 0.5x buffer → $750 effective
|
||||
// Need2: $400 base, no buffer → $400 effective
|
||||
// Want1: $300 base, no buffer → $300 effective
|
||||
// Overflow
|
||||
// $600 income — not enough to fill all need bases
|
||||
$n1 = $this->createNeedBucket(50000, priority: 1, buffer: 0.5, scenarioId: $scenario->id);
|
||||
$n2 = $this->createNeedBucket(40000, priority: 2, scenarioId: $scenario->id);
|
||||
$w1 = $this->createWantBucket(30000, priority: 3, scenarioId: $scenario->id);
|
||||
$overflow = $this->createOverflowBucket(priority: 4, scenarioId: $scenario->id);
|
||||
|
||||
$draws = $this->service->allocateInflow($scenario, 60000);
|
||||
|
||||
// Phase 1 (needs base, priority): n1=$500, n2=$100, remaining=$0
|
||||
// Even mode would split $600/2=$300 each — priority concentrates on n1 first
|
||||
$this->assertCount(2, $draws);
|
||||
$this->assertDrawAmount($draws, $n1->id, 50000);
|
||||
$this->assertDrawAmount($draws, $n2->id, 10000);
|
||||
}
|
||||
|
||||
public function test_even_and_priority_modes_produce_different_results(): void
|
||||
{
|
||||
// 2 need buckets: $500 and $300 base, $600 income
|
||||
// Even: $300 each → both fit. n1=$300, n2=$300.
|
||||
// Priority: n1 gets $500, n2 gets $100.
|
||||
$n1Even = $this->createNeedBucket(50000, priority: 1);
|
||||
$n2Even = $this->createNeedBucket(30000, priority: 2);
|
||||
|
||||
$evenDraws = $this->service->allocateInflow($this->scenario, 60000);
|
||||
|
||||
$this->assertCount(2, $evenDraws);
|
||||
$this->assertDrawAmount($evenDraws, $n1Even->id, 30000); // even: $300 each
|
||||
$this->assertDrawAmount($evenDraws, $n2Even->id, 30000);
|
||||
|
||||
// Same setup with priority mode
|
||||
$priorityScenario = Scenario::factory()->priority()->create();
|
||||
$n1Prio = $this->createNeedBucket(50000, priority: 1, scenarioId: $priorityScenario->id);
|
||||
$n2Prio = $this->createNeedBucket(30000, priority: 2, scenarioId: $priorityScenario->id);
|
||||
|
||||
$prioDraws = $this->service->allocateInflow($priorityScenario, 60000);
|
||||
|
||||
$this->assertCount(2, $prioDraws);
|
||||
$this->assertDrawAmount($prioDraws, $n1Prio->id, 50000); // priority: fills first
|
||||
$this->assertDrawAmount($prioDraws, $n2Prio->id, 10000); // gets remainder
|
||||
}
|
||||
|
||||
public function test_full_pipeline_flows_through_all_five_phases(): void
|
||||
{
|
||||
// Need: $200 base, 1x buffer → $400 effective
|
||||
// Want: $100 base, 1x buffer → $200 effective
|
||||
// Overflow
|
||||
// $800 income → enough to fill everything and hit overflow
|
||||
$n = $this->createNeedBucket(20000, priority: 1, buffer: 1.0);
|
||||
$w = $this->createWantBucket(10000, priority: 2, buffer: 1.0);
|
||||
$overflow = $this->createOverflowBucket(priority: 3);
|
||||
|
||||
$draws = $this->service->allocateInflow($this->scenario, 80000);
|
||||
|
||||
// Phase 1 (needs base): n=$200, remaining=$600
|
||||
// Phase 2 (wants base): w=$100, remaining=$500
|
||||
// Phase 3 (needs buffer): n=$200 buffer, remaining=$300
|
||||
// Phase 4 (wants buffer): w=$100 buffer, remaining=$200
|
||||
// Phase 5 (overflow): $200
|
||||
$this->assertCount(3, $draws);
|
||||
$this->assertDrawAmount($draws, $n->id, 40000); // $200 base + $200 buffer
|
||||
$this->assertDrawAmount($draws, $w->id, 20000); // $100 base + $100 buffer
|
||||
$this->assertDrawAmount($draws, $overflow->id, 20000); // remainder
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
private function createNeedBucket(
|
||||
int $allocationValue,
|
||||
int $priority,
|
||||
float $buffer = 0,
|
||||
int $startingAmount = 0,
|
||||
?int $scenarioId = null,
|
||||
): Bucket {
|
||||
return Bucket::factory()->need()->fixedLimit($allocationValue)->create([
|
||||
'scenario_id' => $scenarioId ?? $this->scenario->id,
|
||||
'priority' => $priority,
|
||||
'buffer_multiplier' => $buffer,
|
||||
'starting_amount' => $startingAmount,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createWantBucket(
|
||||
int $allocationValue,
|
||||
int $priority,
|
||||
float $buffer = 0,
|
||||
int $startingAmount = 0,
|
||||
?int $scenarioId = null,
|
||||
): Bucket {
|
||||
return Bucket::factory()->want()->fixedLimit($allocationValue)->create([
|
||||
'scenario_id' => $scenarioId ?? $this->scenario->id,
|
||||
'priority' => $priority,
|
||||
'buffer_multiplier' => $buffer,
|
||||
'starting_amount' => $startingAmount,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createOverflowBucket(int $priority, ?int $scenarioId = null): Bucket
|
||||
{
|
||||
return Bucket::factory()->overflow()->create([
|
||||
'scenario_id' => $scenarioId ?? $this->scenario->id,
|
||||
'priority' => $priority,
|
||||
]);
|
||||
}
|
||||
|
||||
private function assertDrawAmount($draws, int $bucketId, int $expectedAmount): void
|
||||
{
|
||||
$draw = $draws->first(fn ($d) => $d->bucket_id === $bucketId);
|
||||
$this->assertNotNull($draw, "No draw found for bucket {$bucketId}");
|
||||
$this->assertEquals($expectedAmount, $draw->amount, "Draw amount mismatch for bucket {$bucketId}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,18 +99,14 @@ public function test_generates_weekly_expense_stream_projections()
|
|||
public function test_allocates_income_immediately_to_buckets()
|
||||
{
|
||||
// Arrange: Income stream and buckets with priority
|
||||
$bucket1 = Bucket::factory()->create([
|
||||
$bucket1 = Bucket::factory()->need()->fixedLimit(30000)->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::FIXED_LIMIT,
|
||||
'allocation_value' => 30000,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 1,
|
||||
]);
|
||||
|
||||
$bucket2 = Bucket::factory()->create([
|
||||
$bucket2 = Bucket::factory()->overflow()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
|
||||
'allocation_value' => null,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 2,
|
||||
]);
|
||||
|
|
@ -196,9 +192,8 @@ public function test_handles_monthly_streams_correctly()
|
|||
public function test_processes_multiple_streams_on_same_day()
|
||||
{
|
||||
// Arrange: Two income streams that fire on the same day
|
||||
$bucket = Bucket::factory()->create([
|
||||
$bucket = Bucket::factory()->overflow()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 1,
|
||||
]);
|
||||
|
|
@ -235,9 +230,8 @@ public function test_processes_multiple_streams_on_same_day()
|
|||
public function test_handles_mixed_income_and_expense_streams()
|
||||
{
|
||||
// Arrange: Income and expense streams
|
||||
$bucket = Bucket::factory()->create([
|
||||
$bucket = Bucket::factory()->overflow()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 1,
|
||||
]);
|
||||
|
|
@ -277,9 +271,8 @@ public function test_handles_mixed_income_and_expense_streams()
|
|||
public function test_summary_calculations_are_accurate()
|
||||
{
|
||||
// Arrange: Income and expense streams
|
||||
$bucket = Bucket::factory()->create([
|
||||
$bucket = Bucket::factory()->overflow()->create([
|
||||
'scenario_id' => $this->scenario->id,
|
||||
'allocation_type' => BucketAllocationTypeEnum::UNLIMITED,
|
||||
'starting_amount' => 0,
|
||||
'priority' => 1,
|
||||
]);
|
||||
|
|
|
|||
59
tests/Unit/Traits/HasUuidTest.php
Normal file
59
tests/Unit/Traits/HasUuidTest.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Traits;
|
||||
|
||||
use App\Models\Bucket;
|
||||
use App\Models\Scenario;
|
||||
use App\Models\Stream;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class HasUuidTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_uuid_is_auto_generated_on_creation(): void
|
||||
{
|
||||
$scenario = Scenario::factory()->create();
|
||||
|
||||
$this->assertNotNull($scenario->uuid);
|
||||
$this->assertTrue(Str::isUuid($scenario->uuid));
|
||||
}
|
||||
|
||||
public function test_uuid_is_not_overwritten_when_provided(): void
|
||||
{
|
||||
$customUuid = (string) Str::uuid();
|
||||
|
||||
$scenario = Scenario::factory()->create(['uuid' => $customUuid]);
|
||||
|
||||
$this->assertEquals($customUuid, $scenario->uuid);
|
||||
}
|
||||
|
||||
public function test_route_key_name_returns_uuid(): void
|
||||
{
|
||||
$scenario = Scenario::factory()->create();
|
||||
|
||||
$this->assertEquals('uuid', $scenario->getRouteKeyName());
|
||||
}
|
||||
|
||||
public function test_each_model_gets_a_unique_uuid(): void
|
||||
{
|
||||
$a = Scenario::factory()->create();
|
||||
$b = Scenario::factory()->create();
|
||||
|
||||
$this->assertNotEquals($a->uuid, $b->uuid);
|
||||
}
|
||||
|
||||
public function test_all_uuid_models_have_correct_route_key_name(): void
|
||||
{
|
||||
$scenario = Scenario::factory()->create();
|
||||
$bucket = Bucket::factory()->create(['scenario_id' => $scenario->id]);
|
||||
$stream = Stream::factory()->create(['scenario_id' => $scenario->id]);
|
||||
|
||||
$this->assertEquals('uuid', $bucket->getRouteKeyName());
|
||||
$this->assertEquals('uuid', $stream->getRouteKeyName());
|
||||
$this->assertTrue(Str::isUuid($bucket->uuid));
|
||||
$this->assertTrue(Str::isUuid($stream->uuid));
|
||||
}
|
||||
}
|
||||
|
|
@ -32,5 +32,9 @@ export default defineConfig({
|
|||
host: 'localhost',
|
||||
clientPort: 5174,
|
||||
},
|
||||
watch: {
|
||||
usePolling: true,
|
||||
interval: 1000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue