Extract allocation type to enum

This commit is contained in:
myrmidex 2025-12-31 01:33:44 +01:00
parent e23ee84ce8
commit a07461e5a3
5 changed files with 135 additions and 94 deletions

View file

@ -2,6 +2,7 @@
namespace App\Actions;
use App\Enums\BucketAllocationType;
use App\Models\Bucket;
use App\Models\Scenario;
use Illuminate\Support\Facades\DB;
@ -12,21 +13,15 @@ class CreateBucketAction
public function execute(
Scenario $scenario,
string $name,
string $allocationType,
BucketAllocationType $allocationType,
?float $allocationValue = null,
?int $priority = null
): Bucket {
// Validate allocation type
$validTypes = [Bucket::TYPE_FIXED_LIMIT, Bucket::TYPE_PERCENTAGE, Bucket::TYPE_UNLIMITED];
if (!in_array($allocationType, $validTypes)) {
throw new InvalidArgumentException("Invalid allocation type: {$allocationType}");
}
// Validate allocation value based on type
$this->validateAllocationValue($allocationType, $allocationValue);
// Set allocation_value to null for unlimited buckets
if ($allocationType === Bucket::TYPE_UNLIMITED) {
if ($allocationType === BucketAllocationType::UNLIMITED) {
$allocationValue = null;
}
@ -66,10 +61,10 @@ public function execute(
/**
* Validate allocation value based on allocation type.
*/
private function validateAllocationValue(string $allocationType, ?float $allocationValue): void
private function validateAllocationValue(BucketAllocationType $allocationType, ?float $allocationValue): void
{
switch ($allocationType) {
case Bucket::TYPE_FIXED_LIMIT:
case BucketAllocationType::FIXED_LIMIT:
if ($allocationValue === null) {
throw new InvalidArgumentException('Fixed limit buckets require an allocation value');
}
@ -78,7 +73,7 @@ private function validateAllocationValue(string $allocationType, ?float $allocat
}
break;
case Bucket::TYPE_PERCENTAGE:
case BucketAllocationType::PERCENTAGE:
if ($allocationValue === null) {
throw new InvalidArgumentException('Percentage buckets require an allocation value');
}
@ -87,10 +82,47 @@ private function validateAllocationValue(string $allocationType, ?float $allocat
}
break;
case Bucket::TYPE_UNLIMITED:
case BucketAllocationType::UNLIMITED:
// Unlimited buckets should not have an allocation value
// We'll set it to null in the main method regardless
break;
}
}
/**
* Create default buckets for a scenario.
*/
public function createDefaultBuckets(Scenario $scenario): array
{
$buckets = [];
// Monthly Expenses - Fixed limit, priority 1
$buckets[] = $this->execute(
$scenario,
'Monthly Expenses',
BucketAllocationType::FIXED_LIMIT,
0,
1
);
// Emergency Fund - Fixed limit, priority 2
$buckets[] = $this->execute(
$scenario,
'Emergency Fund',
BucketAllocationType::FIXED_LIMIT,
0,
2
);
// Investments - Unlimited, priority 3
$buckets[] = $this->execute(
$scenario,
'Investments',
BucketAllocationType::UNLIMITED,
null,
3
);
return $buckets;
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Enums;
enum BucketAllocationType: string
{
case FIXED_LIMIT = 'fixed_limit';
case PERCENTAGE = 'percentage';
case UNLIMITED = 'unlimited';
public function getLabel(): string
{
return match($this) {
self::FIXED_LIMIT => 'Fixed Limit',
self::PERCENTAGE => 'Percentage',
self::UNLIMITED => 'Unlimited',
};
}
public static function values(): array
{
return array_column(self::cases(), 'value');
}
public function getAllocationValueRules(): array
{
return match($this) {
self::FIXED_LIMIT => ['required', 'numeric', 'min:0'],
self::PERCENTAGE => ['required', 'numeric', 'min:0.01', 'max:100'],
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

@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\BucketAllocationType;
use Database\Factories\BucketFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -34,11 +35,6 @@ class Bucket extends Model
'allocation_value' => 'decimal:2',
];
// TODO Extract to Enum
const string TYPE_FIXED_LIMIT = 'fixed_limit';
const string TYPE_PERCENTAGE = 'percentage';
const string TYPE_UNLIMITED = 'unlimited';
public function scenario(): BelongsTo
{
return $this->belongsTo(Scenario::class);
@ -95,7 +91,7 @@ public function getCurrentBalance(): float
*/
public function hasAvailableSpace(): bool
{
if ($this->allocation_type !== self::TYPE_FIXED_LIMIT) {
if ($this->allocation_type !== BucketAllocationType::FIXED_LIMIT) {
return true;
}
@ -107,7 +103,7 @@ public function hasAvailableSpace(): bool
*/
public function getAvailableSpace(): float
{
if ($this->allocation_type !== self::TYPE_FIXED_LIMIT) {
if ($this->allocation_type !== BucketAllocationType::FIXED_LIMIT) {
return PHP_FLOAT_MAX;
}
@ -119,12 +115,7 @@ public function getAvailableSpace(): float
*/
public function getAllocationTypeLabel(): string
{
return match($this->allocation_type) {
self::TYPE_FIXED_LIMIT => 'Fixed Limit',
self::TYPE_PERCENTAGE => 'Percentage',
self::TYPE_UNLIMITED => 'Unlimited',
default => 'Unknown',
};
return $this->allocation_type->getLabel();
}
/**
@ -132,12 +123,7 @@ public function getAllocationTypeLabel(): string
*/
public function getFormattedAllocationValue(): string
{
return match($this->allocation_type) {
self::TYPE_FIXED_LIMIT => '$' . number_format($this->allocation_value, 2),
self::TYPE_PERCENTAGE => number_format($this->allocation_value, 2) . '%',
self::TYPE_UNLIMITED => 'All remaining',
default => '-',
};
return $this->allocation_type->formatValue($this->allocation_value);
}
/**
@ -147,11 +133,7 @@ public static function validationRules($scenarioId = null): array
{
$rules = [
'name' => 'required|string|max:255',
'allocation_type' => 'required|in:' . implode(',', [
self::TYPE_FIXED_LIMIT,
self::TYPE_PERCENTAGE,
self::TYPE_UNLIMITED,
]),
'allocation_type' => 'required|in:' . implode(',', BucketAllocationType::values()),
'priority' => 'required|integer|min:1',
];
@ -166,13 +148,8 @@ public static function validationRules($scenarioId = null): array
/**
* Get allocation value validation rules based on type.
*/
public static function allocationValueRules($allocationType): array
public static function allocationValueRules(BucketAllocationType $allocationType): array
{
return match($allocationType) {
self::TYPE_FIXED_LIMIT => ['required', 'numeric', 'min:0'],
self::TYPE_PERCENTAGE => ['required', 'numeric', 'min:0.01', 'max:100'],
self::TYPE_UNLIMITED => ['nullable'],
default => ['nullable'],
};
return $allocationType->getAllocationValueRules();
}
}

View file

@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Enums\BucketAllocationType;
use App\Models\Bucket;
use App\Models\Scenario;
use Illuminate\Database\Eloquent\Factories\Factory;
@ -14,9 +15,9 @@ class BucketFactory extends Factory
public function definition(): array
{
$allocationType = $this->faker->randomElement([
Bucket::TYPE_FIXED_LIMIT,
Bucket::TYPE_PERCENTAGE,
Bucket::TYPE_UNLIMITED,
BucketAllocationType::FIXED_LIMIT,
BucketAllocationType::PERCENTAGE,
BucketAllocationType::UNLIMITED,
]);
return [
@ -50,7 +51,7 @@ public function fixedLimit($amount = null): Factory
$amount = $amount ?? $this->faker->numberBetween(500, 5000);
return $this->state([
'allocation_type' => Bucket::TYPE_FIXED_LIMIT,
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
'allocation_value' => $amount,
]);
}
@ -63,7 +64,7 @@ public function percentage($percentage = null): Factory
$percentage = $percentage ?? $this->faker->numberBetween(10, 50);
return $this->state([
'allocation_type' => Bucket::TYPE_PERCENTAGE,
'allocation_type' => BucketAllocationType::PERCENTAGE,
'allocation_value' => $percentage,
]);
}
@ -74,7 +75,7 @@ public function percentage($percentage = null): Factory
public function unlimited(): Factory
{
return $this->state([
'allocation_type' => Bucket::TYPE_UNLIMITED,
'allocation_type' => BucketAllocationType::UNLIMITED,
'allocation_value' => null,
]);
}
@ -89,21 +90,21 @@ public function defaultSet(): array
'name' => 'Monthly Expenses',
'priority' => 1,
'sort_order' => 1,
'allocation_type' => Bucket::TYPE_FIXED_LIMIT,
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
'allocation_value' => 0,
]),
$this->state([
'name' => 'Emergency Fund',
'priority' => 2,
'sort_order' => 2,
'allocation_type' => Bucket::TYPE_FIXED_LIMIT,
'allocation_type' => BucketAllocationType::FIXED_LIMIT,
'allocation_value' => 0,
]),
$this->state([
'name' => 'Investments',
'priority' => 3,
'sort_order' => 3,
'allocation_type' => Bucket::TYPE_UNLIMITED,
'allocation_type' => BucketAllocationType::UNLIMITED,
'allocation_value' => null,
]),
];
@ -112,12 +113,12 @@ public function defaultSet(): array
/**
* Get allocation value based on type.
*/
private function getAllocationValueForType(string $type): ?float
private function getAllocationValueForType(BucketAllocationType $type): ?float
{
return match($type) {
Bucket::TYPE_FIXED_LIMIT => $this->faker->numberBetween(100, 10000),
Bucket::TYPE_PERCENTAGE => $this->faker->numberBetween(5, 50),
Bucket::TYPE_UNLIMITED => null,
BucketAllocationType::FIXED_LIMIT => $this->faker->numberBetween(100, 10000),
BucketAllocationType::PERCENTAGE => $this->faker->numberBetween(5, 50),
BucketAllocationType::UNLIMITED => null,
};
}
}

View file

@ -3,6 +3,7 @@
namespace Tests\Unit\Actions;
use App\Actions\CreateBucketAction;
use App\Enums\BucketAllocationType;
use App\Models\Bucket;
use App\Models\Scenario;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -28,13 +29,13 @@ public function test_can_create_fixed_limit_bucket(): void
$bucket = $this->action->execute(
$this->scenario,
'Test Bucket',
Bucket::TYPE_FIXED_LIMIT,
BucketAllocationType::FIXED_LIMIT,
1000.00
);
$this->assertInstanceOf(Bucket::class, $bucket);
$this->assertEquals('Test Bucket', $bucket->name);
$this->assertEquals(Bucket::TYPE_FIXED_LIMIT, $bucket->allocation_type);
$this->assertEquals(BucketAllocationType::FIXED_LIMIT, $bucket->allocation_type);
$this->assertEquals(1000.00, $bucket->allocation_value);
$this->assertEquals(1, $bucket->priority);
$this->assertEquals(1, $bucket->sort_order);
@ -46,11 +47,11 @@ public function test_can_create_percentage_bucket(): void
$bucket = $this->action->execute(
$this->scenario,
'Percentage Bucket',
Bucket::TYPE_PERCENTAGE,
BucketAllocationType::PERCENTAGE,
25.5
);
$this->assertEquals(Bucket::TYPE_PERCENTAGE, $bucket->allocation_type);
$this->assertEquals(BucketAllocationType::PERCENTAGE, $bucket->allocation_type);
$this->assertEquals(25.5, $bucket->allocation_value);
}
@ -59,10 +60,10 @@ public function test_can_create_unlimited_bucket(): void
$bucket = $this->action->execute(
$this->scenario,
'Unlimited Bucket',
Bucket::TYPE_UNLIMITED
BucketAllocationType::UNLIMITED
);
$this->assertEquals(Bucket::TYPE_UNLIMITED, $bucket->allocation_type);
$this->assertEquals(BucketAllocationType::UNLIMITED, $bucket->allocation_type);
$this->assertNull($bucket->allocation_value);
}
@ -71,11 +72,11 @@ public function test_unlimited_bucket_ignores_allocation_value(): void
$bucket = $this->action->execute(
$this->scenario,
'Unlimited Bucket',
Bucket::TYPE_UNLIMITED,
BucketAllocationType::UNLIMITED,
999.99 // This should be ignored and set to null
);
$this->assertEquals(Bucket::TYPE_UNLIMITED, $bucket->allocation_type);
$this->assertEquals(BucketAllocationType::UNLIMITED, $bucket->allocation_type);
$this->assertNull($bucket->allocation_value);
}
@ -84,14 +85,14 @@ public function test_priority_auto_increments_when_not_specified(): void
$bucket1 = $this->action->execute(
$this->scenario,
'First Bucket',
Bucket::TYPE_FIXED_LIMIT,
BucketAllocationType::FIXED_LIMIT,
100
);
$bucket2 = $this->action->execute(
$this->scenario,
'Second Bucket',
Bucket::TYPE_FIXED_LIMIT,
BucketAllocationType::FIXED_LIMIT,
200
);
@ -104,7 +105,7 @@ public function test_can_specify_custom_priority(): void
$bucket = $this->action->execute(
$this->scenario,
'Priority Bucket',
Bucket::TYPE_FIXED_LIMIT,
BucketAllocationType::FIXED_LIMIT,
100,
5
);
@ -115,12 +116,12 @@ public function test_can_specify_custom_priority(): void
public function test_existing_priorities_are_shifted_when_inserting(): void
{
// Create initial buckets
$bucket1 = $this->action->execute($this->scenario, 'Bucket 1', Bucket::TYPE_FIXED_LIMIT, 100, 1);
$bucket2 = $this->action->execute($this->scenario, 'Bucket 2', Bucket::TYPE_FIXED_LIMIT, 200, 2);
$bucket3 = $this->action->execute($this->scenario, 'Bucket 3', Bucket::TYPE_FIXED_LIMIT, 300, 3);
$bucket1 = $this->action->execute($this->scenario, 'Bucket 1', BucketAllocationType::FIXED_LIMIT, 100, 1);
$bucket2 = $this->action->execute($this->scenario, 'Bucket 2', BucketAllocationType::FIXED_LIMIT, 200, 2);
$bucket3 = $this->action->execute($this->scenario, 'Bucket 3', BucketAllocationType::FIXED_LIMIT, 300, 3);
// Insert a bucket at priority 2
$newBucket = $this->action->execute($this->scenario, 'New Bucket', Bucket::TYPE_FIXED_LIMIT, 150, 2);
$newBucket = $this->action->execute($this->scenario, 'New Bucket', BucketAllocationType::FIXED_LIMIT, 150, 2);
// Refresh models from database
$bucket1->refresh();
@ -134,18 +135,6 @@ public function test_existing_priorities_are_shifted_when_inserting(): void
$this->assertEquals(4, $bucket3->priority); // Shifted from 3 to 4
}
public function test_throws_exception_for_invalid_allocation_type(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid allocation type: invalid_type');
$this->action->execute(
$this->scenario,
'Test Bucket',
'invalid_type',
100
);
}
public function test_throws_exception_for_fixed_limit_without_allocation_value(): void
{
@ -155,7 +144,7 @@ public function test_throws_exception_for_fixed_limit_without_allocation_value()
$this->action->execute(
$this->scenario,
'Test Bucket',
Bucket::TYPE_FIXED_LIMIT,
BucketAllocationType::FIXED_LIMIT,
null
);
}
@ -168,7 +157,7 @@ public function test_throws_exception_for_negative_fixed_limit_value(): void
$this->action->execute(
$this->scenario,
'Test Bucket',
Bucket::TYPE_FIXED_LIMIT,
BucketAllocationType::FIXED_LIMIT,
-100
);
}
@ -181,7 +170,7 @@ public function test_throws_exception_for_percentage_without_allocation_value():
$this->action->execute(
$this->scenario,
'Test Bucket',
Bucket::TYPE_PERCENTAGE,
BucketAllocationType::PERCENTAGE,
null
);
}
@ -194,7 +183,7 @@ public function test_throws_exception_for_percentage_below_minimum(): void
$this->action->execute(
$this->scenario,
'Test Bucket',
Bucket::TYPE_PERCENTAGE,
BucketAllocationType::PERCENTAGE,
0.005
);
}
@ -207,7 +196,7 @@ public function test_throws_exception_for_percentage_above_maximum(): void
$this->action->execute(
$this->scenario,
'Test Bucket',
Bucket::TYPE_PERCENTAGE,
BucketAllocationType::PERCENTAGE,
101
);
}
@ -220,7 +209,7 @@ public function test_throws_exception_for_negative_priority(): void
$this->action->execute(
$this->scenario,
'Test Bucket',
Bucket::TYPE_FIXED_LIMIT,
BucketAllocationType::FIXED_LIMIT,
100,
0
);
@ -237,19 +226,19 @@ public function test_create_default_buckets(): void
// Monthly Expenses
$this->assertEquals('Monthly Expenses', $buckets[0]->name);
$this->assertEquals(1, $buckets[0]->priority);
$this->assertEquals(Bucket::TYPE_FIXED_LIMIT, $buckets[0]->allocation_type);
$this->assertEquals(BucketAllocationType::FIXED_LIMIT, $buckets[0]->allocation_type);
$this->assertEquals(0, $buckets[0]->allocation_value);
// Emergency Fund
$this->assertEquals('Emergency Fund', $buckets[1]->name);
$this->assertEquals(2, $buckets[1]->priority);
$this->assertEquals(Bucket::TYPE_FIXED_LIMIT, $buckets[1]->allocation_type);
$this->assertEquals(BucketAllocationType::FIXED_LIMIT, $buckets[1]->allocation_type);
$this->assertEquals(0, $buckets[1]->allocation_value);
// Investments
$this->assertEquals('Investments', $buckets[2]->name);
$this->assertEquals(3, $buckets[2]->priority);
$this->assertEquals(Bucket::TYPE_UNLIMITED, $buckets[2]->allocation_type);
$this->assertEquals(BucketAllocationType::UNLIMITED, $buckets[2]->allocation_type);
$this->assertNull($buckets[2]->allocation_value);
}
@ -257,8 +246,8 @@ public function test_creates_buckets_in_database_transaction(): void
{
// This test ensures database consistency by creating multiple buckets
// and verifying they all exist with correct priorities
$this->action->execute($this->scenario, 'Bucket 1', Bucket::TYPE_FIXED_LIMIT, 100, 1);
$this->action->execute($this->scenario, 'Bucket 2', Bucket::TYPE_FIXED_LIMIT, 200, 1); // Insert at priority 1
$this->action->execute($this->scenario, 'Bucket 1', BucketAllocationType::FIXED_LIMIT, 100, 1);
$this->action->execute($this->scenario, 'Bucket 2', BucketAllocationType::FIXED_LIMIT, 200, 1); // Insert at priority 1
// Both buckets should exist with correct priorities
$buckets = $this->scenario->buckets()->orderBy('priority')->get();