Compare commits

...

63 commits

Author SHA1 Message Date
c3ce5b0b80 24 - Fix Vite manifest error in CI tests
Some checks failed
CI / ci (push) Successful in 7m53s
CI / ci (pull_request) Successful in 5m43s
Build and Push Docker Image / build (push) Failing after 7m4s
2026-03-30 00:20:40 +02:00
cf6f0c76c2 24 - Remove multi-scenario scaffolding
Some checks failed
CI / ci (push) Failing after 10m48s
CI / ci (pull_request) Failing after 10m4s
2026-03-30 00:01:05 +02:00
afc65b4f3a chore - Fix class_attributes_separation lint issues 2026-03-30 00:00:54 +02:00
98d355e811 2 - Fix stale edit modal and green bar threshold 2026-03-22 17:07:15 +01:00
a07d916663 2 - Show projected distribution on progress bars with yellow indicators 2026-03-22 16:59:53 +01:00
c2d2262488 2 - Add visible error feedback for distribution preview and save 2026-03-22 16:43:59 +01:00
46dc09783e 2 - Integrate DistributionLines SVG overlay into scenario page 2026-03-22 15:22:54 +01:00
7b92423fb9 2 - Create DistributionLines SVG connector component 2026-03-22 15:19:49 +01:00
5ac8d48727 2 - Add distribute button and wire up preview/apply endpoints 2026-03-22 15:15:56 +01:00
7938677950 2 - Restructure layout from grid to flex for distribution lines 2026-03-22 15:07:57 +01:00
4006c7d2f3 2 - Add DigitalProgressBar and extract BucketCard component 2026-03-22 03:28:06 +01:00
ced02a2ab2 2 - Clean up dead code and restyle inline edit components 2026-03-22 03:05:23 +01:00
a9d74e3eee 2 - Restyle settings panel as inline slide-out block 2026-03-22 02:59:40 +01:00
7d2e373ffd 2 - Add 80s digital theme and restyle scenario page 2026-03-22 02:55:46 +01:00
618d674793 2 - Fix scenario resource data wrapper in Inertia props 2026-03-22 02:36:32 +01:00
e5a02c3c32 2 - Fix Vite hot reload in dev container 2026-03-22 02:31:24 +01:00
a94aabe604 19 - Add double-click guard to save distribution handler 2026-03-22 02:20:18 +01:00
0d322cde06 chore - Remove broken DashboardTest referencing commented-out dashboard route 2026-03-22 02:20:14 +01:00
3e894503fe 19 - Add save distribution button to income preview component 2026-03-22 02:18:00 +01:00
f4e5a186fa 19 - Add apply distribution endpoint to persist bucket balances 2026-03-22 02:15:22 +01:00
52ec8bb2ac 18 - Allow setting allocation value after type change 2026-03-22 02:07:26 +01:00
aca9644c5b 18 - Add inline editing for name, allocation type, and allocation value 2026-03-22 02:05:43 +01:00
81bd3880ec 18 - Add text mode to InlineEditInput 2026-03-22 02:02:56 +01:00
24de58982a 17 - Deduplicate Scenario type to shared types 2026-03-22 01:11:11 +01:00
b98b6f6dd9 17 - Add settings panel with distribution mode toggle 2026-03-22 01:08:44 +01:00
a7f9799391 17 - Share scenario as Inertia shared data 2026-03-22 01:00:39 +01:00
ac3e6d2ff9 17 - Add scenario PATCH route with distribution_mode validation 2026-03-22 00:56:20 +01:00
246ca69f40 17 - Rewrite tests for phased distribution service 2026-03-21 23:55:21 +01:00
d45ca30151 17 - Rewrite PipelineAllocationService for phased distribution 2026-03-21 18:23:38 +01:00
f14057d6d9 17 - Add distribution_mode to Scenario 2026-03-21 18:09:56 +01:00
cf89ee7cd2 16 - Update frontend for cents-based API 2026-03-21 17:48:58 +01:00
d6f60ab987 16 - Update tests for integer allocation_value 2026-03-21 17:42:52 +01:00
3a5126f51c 16 - Pass raw storage units through API 2026-03-21 17:26:45 +01:00
4554f4e417 16 - Migrate allocation_value to integer and update model 2026-03-21 16:54:11 +01:00
d603fc6401 12 - Integrate distribution preview into scenario page 2026-03-21 11:49:45 +01:00
0bec678b5a 12 - Add income distribution preview component 2026-03-21 11:40:34 +01:00
a70dc036fe 12 - Add tests for allocation preview endpoint 2026-03-21 11:19:00 +01:00
4a6e69d33b 12 - Add allocation preview endpoint 2026-03-21 11:05:50 +01:00
4bf3aef610 13 - Fix allocation_value nulled on partial updates 2026-03-21 10:52:27 +01:00
a045ee6c23 13 - Integrate inline editing into bucket cards 2026-03-21 10:47:36 +01:00
073efc4bda 13 - Add InlineEdit components 2026-03-21 10:37:52 +01:00
df985091dd 13 - Add tests for partial bucket updates 2026-03-21 10:35:05 +01:00
969a88bc53 13 - Support partial updates and starting_amount in bucket API 2026-03-21 09:43:50 +01:00
969c97b165 15 - Update tests for single-scenario routing 2026-03-20 15:33:24 +01:00
962acdc6ae 15 - Clean up frontend for single-scenario page 2026-03-20 15:20:49 +01:00
873484db74 15 - Redirect root to single scenario and hide CRUD routes 2026-03-20 14:46:08 +01:00
fab5a5f4be 14 - Add empty state with bucket setup guidance 2026-03-20 13:08:19 +01:00
66fb866f42 5 - Clean up dead formatting code and tighten buffer validation 2026-03-20 00:45:48 +01:00
8f6de4aace 5 - Add buffer multiplier UI with preset and custom options 2026-03-20 00:41:47 +01:00
ed6be6249e 5 - Expose buffer multiplier in controller and resource API layer 2026-03-20 00:37:23 +01:00
ad55a38c90 5 - Add buffer multiplier parameter to CreateBucketAction 2026-03-20 00:31:04 +01:00
50b7caf1f9 5 - Integrate buffer multiplier into pipeline allocation service 2026-03-20 00:28:20 +01:00
772f4c1c5a 5 - Add buffer multiplier support to Bucket model with effective capacity 2026-03-20 00:25:44 +01:00
b4903cf3cf 5 - Add buffer_multiplier column to buckets table 2026-03-20 00:21:24 +01:00
d742b84343 6 - Consolidate allocation type constraints into BucketTypeEnum and remove dead code 2026-03-19 23:54:58 +01:00
d06b859652 4 - Harden overflow bucket invariants with server-side guards 2026-03-19 21:49:00 +01:00
faff18f82b 4 - Update PHPStan baseline for bucket type changes and stale UUID entries 2026-03-19 21:41:36 +01:00
2f51374e3a 4 - Update frontend with bucket type support and visual distinction 2026-03-19 21:35:27 +01:00
e5dc7b0e21 4 - Update BucketController with bucket type validation and constraints 2026-03-19 21:29:03 +01:00
da036ce97f 4 - Add bucket type parameter to CreateBucketAction and update CreateScenarioAction 2026-03-19 21:22:09 +01:00
fe5355d182 4 - Add BucketTypeEnum and update Bucket model, migration, factory, and resource 2026-03-19 21:14:36 +01:00
72cc6ff0b7 1 - Expose UUIDs in API responses and update frontend types 2026-03-19 21:01:56 +01:00
367f255200 1 - Add UUID support to all domain models 2026-03-19 20:34:47 +01:00
76 changed files with 3733 additions and 1454 deletions

View 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,
];
}
}

View file

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

View file

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

View file

@ -1,13 +0,0 @@
<?php
namespace App\Actions;
use App\Models\Scenario;
readonly class DeleteScenarioAction
{
public function execute(Scenario $scenario): void
{
$scenario->delete();
}
}

View file

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

View 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],
};
}
}

View 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');
}
}

View file

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

View file

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

View file

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

View file

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

View 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'],
];
}
}

View file

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

View file

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

View file

@ -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.',

View file

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

View file

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

View file

@ -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'),

View file

@ -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'),

View file

@ -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'),

View file

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

View file

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

View file

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

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

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

View file

@ -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',

View 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';
}
}

View file

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

View file

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

View file

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

View file

@ -73,7 +73,7 @@
|
*/
'home' => '/dashboard',
'home' => '/',
/*
|--------------------------------------------------------------------------

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

View file

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

View file

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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

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

View file

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

View file

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

View file

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

View 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);
}
}

View file

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

View file

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

View file

@ -26,6 +26,6 @@ public function test_new_users_can_register()
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
$response->assertRedirect('/');
}
}

View file

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

View 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);
}
}

View file

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

View 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);
}
}

View 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,
]);
}
}

View file

@ -6,5 +6,10 @@
abstract class TestCase extends BaseTestCase
{
//
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
}
}

View file

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

View file

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

View 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);
}
}

View file

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

View file

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

View 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));
}
}

View file

@ -32,5 +32,9 @@ export default defineConfig({
host: 'localhost',
clientPort: 5174,
},
watch: {
usePolling: true,
interval: 1000,
},
},
});