From a07461e5a39e45eba2ef2d8fd95028284d247d5f Mon Sep 17 00:00:00 2001 From: myrmidex Date: Wed, 31 Dec 2025 01:33:44 +0100 Subject: [PATCH] Extract allocation type to enum --- app/Actions/CreateBucketAction.php | 56 ++++++++++++---- app/Enums/BucketAllocationType.php | 42 ++++++++++++ app/Models/Bucket.php | 39 +++-------- database/factories/BucketFactory.php | 27 ++++---- tests/Unit/Actions/CreateBucketActionTest.php | 65 ++++++++----------- 5 files changed, 135 insertions(+), 94 deletions(-) create mode 100644 app/Enums/BucketAllocationType.php diff --git a/app/Actions/CreateBucketAction.php b/app/Actions/CreateBucketAction.php index 8aab24d..2175ba7 100644 --- a/app/Actions/CreateBucketAction.php +++ b/app/Actions/CreateBucketAction.php @@ -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; + } } \ No newline at end of file diff --git a/app/Enums/BucketAllocationType.php b/app/Enums/BucketAllocationType.php new file mode 100644 index 0000000..a8d5c69 --- /dev/null +++ b/app/Enums/BucketAllocationType.php @@ -0,0 +1,42 @@ + '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', + }; + } +} diff --git a/app/Models/Bucket.php b/app/Models/Bucket.php index d29f91e..152f0ed 100644 --- a/app/Models/Bucket.php +++ b/app/Models/Bucket.php @@ -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(); } } diff --git a/database/factories/BucketFactory.php b/database/factories/BucketFactory.php index b390035..daa2e1f 100644 --- a/database/factories/BucketFactory.php +++ b/database/factories/BucketFactory.php @@ -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, }; } } \ No newline at end of file diff --git a/tests/Unit/Actions/CreateBucketActionTest.php b/tests/Unit/Actions/CreateBucketActionTest.php index 6ac8b16..42782b8 100644 --- a/tests/Unit/Actions/CreateBucketActionTest.php +++ b/tests/Unit/Actions/CreateBucketActionTest.php @@ -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();