diff --git a/app/Models/Bucket.php b/app/Models/Bucket.php index 216c9c2..b00697b 100644 --- a/app/Models/Bucket.php +++ b/app/Models/Bucket.php @@ -22,6 +22,7 @@ * @property BucketAllocationTypeEnum $allocation_type * @property float $starting_amount * @property float $allocation_value + * @property float $buffer_multiplier * * @method static BucketFactory factory() */ @@ -38,6 +39,7 @@ class Bucket extends Model 'sort_order', 'allocation_type', 'allocation_value', + 'buffer_multiplier', 'starting_amount', ]; @@ -46,6 +48,7 @@ class Bucket extends Model 'priority' => 'integer', 'sort_order' => 'integer', 'allocation_value' => 'decimal:2', + 'buffer_multiplier' => 'decimal:2', 'starting_amount' => 'integer', 'allocation_type' => BucketAllocationTypeEnum::class, ]; @@ -99,6 +102,21 @@ public function getCurrentBalance(): int return $this->starting_amount + $totalDraws - $totalOutflows; } + /** + * Get the effective capacity including buffer. + * Formula: allocation_value * (1 + buffer_multiplier) + */ + public function getEffectiveCapacity(): float + { + if ($this->allocation_type !== BucketAllocationTypeEnum::FIXED_LIMIT) { + return PHP_FLOAT_MAX; + } + + $base = (float) ($this->allocation_value ?? 0); + + return round($base * (1 + (float) $this->buffer_multiplier), 2); + } + /** * Check if the bucket can accept more money (for fixed_limit buckets). */ @@ -108,7 +126,7 @@ public function hasAvailableSpace(): bool return true; } - return $this->getCurrentBalance() < $this->allocation_value; + return $this->getCurrentBalance() < $this->getEffectiveCapacity(); } /** @@ -120,7 +138,7 @@ public function getAvailableSpace(): float return PHP_FLOAT_MAX; } - return max(0, $this->allocation_value - $this->getCurrentBalance()); + return max(0, $this->getEffectiveCapacity() - $this->getCurrentBalance()); } /** diff --git a/database/factories/BucketFactory.php b/database/factories/BucketFactory.php index f7dada7..22d31bb 100644 --- a/database/factories/BucketFactory.php +++ b/database/factories/BucketFactory.php @@ -46,6 +46,7 @@ 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 ]; } @@ -119,6 +120,16 @@ public function overflow(): Factory ]); } + /** + * 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). */ diff --git a/tests/Unit/BucketTest.php b/tests/Unit/BucketTest.php index da29f3b..6dbf7f7 100644 --- a/tests/Unit/BucketTest.php +++ b/tests/Unit/BucketTest.php @@ -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.0, $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.0, $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.0, $bucket->getEffectiveCapacity()); + } + + public function test_effective_capacity_for_percentage_returns_php_float_max(): void + { + $scenario = Scenario::factory()->create(); + $bucket = Bucket::factory()->percentage(25)->create([ + 'scenario_id' => $scenario->id, + 'buffer_multiplier' => 1.0, + ]); + + $this->assertEquals(PHP_FLOAT_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_FLOAT_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.0, $bucket->getAvailableSpace()); + } }