diff --git a/app/Models/Bucket.php b/app/Models/Bucket.php index 4b7ae38..61ff889 100644 --- a/app/Models/Bucket.php +++ b/app/Models/Bucket.php @@ -10,10 +10,14 @@ use Illuminate\Database\Eloquent\Relations\HasMany; /** + * @property int $id * @property int $scenario_id * @property Scenario $scenario * @property string $name * @property int $priority + * @property BucketAllocationType $allocation_type + * @property float $starting_amount + * @property float $allocation_value */ class Bucket extends Model { diff --git a/app/Models/Draw.php b/app/Models/Draw.php new file mode 100644 index 0000000..8040ae0 --- /dev/null +++ b/app/Models/Draw.php @@ -0,0 +1,51 @@ + */ + use HasFactory; + use HasProjectionStatus; + use HasAmount; + + protected $fillable = [ + 'bucket_id', + 'amount', + 'date', + 'description', + 'is_projected', + ]; + + protected $casts = [ + 'amount' => 'integer', + 'date' => 'date', + 'is_projected' => 'boolean', + ]; + + public function bucket(): BelongsTo + { + return $this->belongsTo(Bucket::class); + } + + public function scenario(): BelongsTo + { + return $this->bucket->scenario(); + } +} diff --git a/app/Models/Inflow.php b/app/Models/Inflow.php new file mode 100644 index 0000000..918ea22 --- /dev/null +++ b/app/Models/Inflow.php @@ -0,0 +1,48 @@ + 'integer', + 'date' => 'date', + 'is_projected' => 'boolean', + ]; + + public function stream(): BelongsTo + { + return $this->belongsTo(Stream::class); + } + + public function scenario(): BelongsTo + { + return $this->stream->scenario(); + } +} diff --git a/app/Models/Outflow.php b/app/Models/Outflow.php new file mode 100644 index 0000000..f569416 --- /dev/null +++ b/app/Models/Outflow.php @@ -0,0 +1,58 @@ + */ + use HasFactory; + use HasProjectionStatus; + use HasAmount; + + protected $fillable = [ + 'stream_id', + 'bucket_id', + 'amount', + 'date', + 'description', + 'is_projected', + ]; + + protected $casts = [ + 'amount' => 'integer', + 'date' => 'date', + 'is_projected' => 'boolean', + ]; + + public function stream(): BelongsTo + { + return $this->belongsTo(Stream::class); + } + + public function bucket(): BelongsTo + { + return $this->belongsTo(Bucket::class); + } + + public function scenario(): BelongsTo + { + return $this->stream->scenario(); + } +} diff --git a/app/Models/Traits/HasProjectionStatus.php b/app/Models/Traits/HasProjectionStatus.php new file mode 100644 index 0000000..f23413c --- /dev/null +++ b/app/Models/Traits/HasProjectionStatus.php @@ -0,0 +1,69 @@ +fillable = array_merge($this->fillable ?? [], [ + 'is_projected', + ]); + + $this->casts = array_merge($this->casts ?? [], [ + 'is_projected' => 'boolean', + ]); + } + + /** + * Scope to filter projected transactions + */ + public function scopeProjected($query) + { + return $query->where('is_projected', true); + } + + /** + * Scope to filter actual transactions + */ + public function scopeActual($query) + { + return $query->where('is_projected', false); + } + + /** + * Check if this transaction is projected + */ + public function isProjected(): bool + { + return $this->is_projected; + } + + /** + * Check if this transaction is actual + */ + public function isActual(): bool + { + return ! $this->is_projected; + } + + /** + * Mark transaction as projected + */ + public function markAsProjected(): self + { + $this->update(['is_projected' => true]); + + return $this; + } + + /** + * Mark transaction as actual + */ + public function markAsActual(): self + { + $this->update(['is_projected' => false]); + + return $this; + } +} diff --git a/app/Services/Projection/PipelineAllocationService.php b/app/Services/Projection/PipelineAllocationService.php new file mode 100644 index 0000000..5835e37 --- /dev/null +++ b/app/Services/Projection/PipelineAllocationService.php @@ -0,0 +1,118 @@ + Collection of Draw models + */ + public function allocateInflow(Scenario $scenario, float $amount, ?Carbon $date = null, ?string $description = null): Collection + { + $draws = collect(); + + // Guard clauses + if ($amount <= 0) { + return $draws; + } + + // Get buckets ordered by priority + $buckets = $scenario->buckets() + ->orderBy('priority') + ->get(); + + if ($buckets->isEmpty()) { + return $draws; + } + + $priorityOrder = 1; + $remainingAmount = $amount; + $allocationDate = $date ?? now(); + + foreach ($buckets as $bucket) { + if ($remainingAmount <= 0) { + break; + } + + $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, + ]); + + // Add priority_order as a custom attribute for pipeline visualization + $draw->priority_order = $priorityOrder; + + $draws->push($draw); + $remainingAmount -= $allocation; + $priorityOrder++; + } + } + + return $draws; + } + + /** + * Calculate how much should be allocated to a specific bucket. + * + * @param Bucket $bucket + * @param float $remainingAmount + * @return float + */ + private function calculateBucketAllocation(Bucket $bucket, float $remainingAmount): float + { + return match ($bucket->allocation_type) { + BucketAllocationType::FIXED_LIMIT => $this->calculateFixedAllocation($bucket, $remainingAmount), + BucketAllocationType::PERCENTAGE => $this->calculatePercentageAllocation($bucket, $remainingAmount), + BucketAllocationType::UNLIMITED => $remainingAmount, // Takes all remaining + default => 0, + }; + } + + /** + * Calculate allocation for fixed limit buckets. + * + * @param Bucket $bucket + * @param float $remainingAmount + * @return float + */ + private function calculateFixedAllocation(Bucket $bucket, float $remainingAmount): float + { + $bucketCapacity = $bucket->allocation_value ?? 0; + $currentBalance = $bucket->getCurrentBalance(); + $availableSpace = max(0, $bucketCapacity - $currentBalance); + + return min($availableSpace, $remainingAmount); + } + + /** + * Calculate allocation for percentage buckets. + * + * @param Bucket $bucket + * @param float $remainingAmount + * @return float + */ + private function calculatePercentageAllocation(Bucket $bucket, float $remainingAmount): float + { + $percentage = $bucket->allocation_value ?? 0; + return $remainingAmount * ($percentage / 100); + } +} diff --git a/composer.json b/composer.json index 5ef4352..315ca85 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,7 @@ ], "license": "MIT", "require": { - "php": "^8.2", + "php": "^8.3", "inertiajs/inertia-laravel": "^2.0", "laravel/fortify": "^1.30", "laravel/framework": "^12.0", diff --git a/database/factories/DrawFactory.php b/database/factories/DrawFactory.php new file mode 100644 index 0000000..c98deaf --- /dev/null +++ b/database/factories/DrawFactory.php @@ -0,0 +1,36 @@ + + */ +class DrawFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'bucket_id' => null, + 'amount' => $this->faker->numberBetween(5000, 200000), // $50 to $2000 in cents + 'date' => $this->faker->dateTimeBetween('-6 months', 'now'), + 'description' => $this->faker->sentence(), + 'is_projected' => $this->faker->boolean(), + ]; + } + + public function bucket(Bucket $bucket): self + { + return $this->state(fn () => [ + 'bucket_id' => $bucket->id, + ]); + } +} diff --git a/database/factories/OutflowFactory.php b/database/factories/OutflowFactory.php new file mode 100644 index 0000000..aa6581d --- /dev/null +++ b/database/factories/OutflowFactory.php @@ -0,0 +1,40 @@ + + */ +class OutflowFactory extends Factory +{ + public function definition(): array + { + return [ + 'stream_id' => null, + 'bucket_id' => null, + 'amount' => $this->faker->numberBetween(2500, 100000), // $25 to $1000 in cents + 'date' => $this->faker->dateTimeBetween('-6 months', 'now'), + 'description' => $this->faker->sentence(), + 'is_projected' => $this->faker->boolean(), + ]; + } + + public function stream(Stream $stream): self + { + return $this->state(fn () => [ + 'stream_id' => $stream->id, + ]); + } + + public function bucket(Bucket $bucket): self + { + return $this->state(fn () => [ + 'bucket_id' => $bucket->id, + ]); + } +} diff --git a/database/migrations/2025_12_30_234435_create_inflows_table.php b/database/migrations/2025_12_30_234435_create_inflows_table.php new file mode 100644 index 0000000..1a55692 --- /dev/null +++ b/database/migrations/2025_12_30_234435_create_inflows_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('stream_id')->nullable()->constrained()->onDelete('set null'); + $table->unsignedBigInteger('amount'); + $table->date('date'); + $table->text('description')->nullable(); + $table->boolean('is_projected')->default(true); + $table->timestamps(); + + $table->index(['stream_id', 'date']); + $table->index('is_projected'); + }); + } + + public function down(): void + { + Schema::dropIfExists('inflows'); + } +}; diff --git a/database/migrations/2025_12_30_234514_create_outflows_table.php b/database/migrations/2025_12_30_234514_create_outflows_table.php new file mode 100644 index 0000000..f9757b6 --- /dev/null +++ b/database/migrations/2025_12_30_234514_create_outflows_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('stream_id')->nullable()->constrained()->onDelete('set null'); + $table->foreignId('bucket_id')->nullable()->constrained()->onDelete('set null'); + $table->unsignedBigInteger('amount'); + $table->date('date'); + $table->text('description')->nullable(); + $table->boolean('is_projected')->default(true); + $table->timestamps(); + + $table->index(['bucket_id', 'date']); + $table->index('is_projected'); + }); + } + + public function down(): void + { + Schema::dropIfExists('outflows'); + } +}; diff --git a/database/migrations/2025_12_30_234548_create_draws_table.php b/database/migrations/2025_12_30_234548_create_draws_table.php new file mode 100644 index 0000000..5835f47 --- /dev/null +++ b/database/migrations/2025_12_30_234548_create_draws_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('bucket_id')->constrained()->onDelete('cascade'); + $table->unsignedBigInteger('amount'); + $table->date('date'); + $table->text('description')->nullable(); + $table->boolean('is_projected')->default(true); + $table->timestamps(); + + $table->index(['bucket_id', 'date']); + $table->index('is_projected'); + }); + } + + public function down(): void + { + Schema::dropIfExists('draws'); + } +}; diff --git a/routes/web.php b/routes/web.php index 6b6d9d4..78779c4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ name('streams.destroy'); Route::patch('/streams/{stream}/toggle', [StreamController::class, 'toggle'])->name('streams.toggle'); +// 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'); diff --git a/tests/Unit/BucketTest.php b/tests/Unit/BucketTest.php new file mode 100644 index 0000000..c860038 --- /dev/null +++ b/tests/Unit/BucketTest.php @@ -0,0 +1,82 @@ +create(); + $bucket = Bucket::factory()->create([ + 'scenario_id' => $scenario->id, + 'starting_amount' => 100000, // $1000 in cents + 'allocation_type' => BucketAllocationType::UNLIMITED, + ]); + + // Create draws and outflows directly + Draw::create([ + 'bucket_id' => $bucket->id, + 'amount' => 50000, // $500 in cents + 'date' => now(), + 'description' => 'Test draw', + 'is_projected' => false, + ]); + + Outflow::create([ + 'stream_id' => null, // We'll make this nullable for test + 'bucket_id' => $bucket->id, + 'amount' => 20000, // $200 in cents + 'date' => now(), + 'description' => 'Test outflow', + 'is_projected' => false, + ]); + + // Act & Assert + // starting_amount (1000) + draws (500) - outflows (200) = 1300 + $this->assertEquals(1300.00, $bucket->getCurrentBalance()); + } + + public function test_current_balance_without_starting_amount_defaults_to_zero() + { + // Arrange + $scenario = Scenario::factory()->create(); + $bucket = Bucket::factory()->create([ + 'scenario_id' => $scenario->id, + 'starting_amount' => 0, // $0 in cents + 'allocation_type' => BucketAllocationType::UNLIMITED, + ]); + + // Create draws and outflows directly + Draw::create([ + 'bucket_id' => $bucket->id, + 'amount' => 30000, // $300 in cents + 'date' => now(), + 'description' => 'Test draw', + 'is_projected' => false, + ]); + + Outflow::create([ + 'stream_id' => null, // We'll make this nullable for test + 'bucket_id' => $bucket->id, + 'amount' => 10000, // $100 in cents + 'date' => now(), + 'description' => 'Test outflow', + 'is_projected' => false, + ]); + + // Act & Assert + // starting_amount (0) + draws (300) - outflows (100) = 200 + $this->assertEquals(200.00, $bucket->getCurrentBalance()); + } +} diff --git a/tests/Unit/PipelineAllocationServiceTest.php b/tests/Unit/PipelineAllocationServiceTest.php new file mode 100644 index 0000000..e3688f7 --- /dev/null +++ b/tests/Unit/PipelineAllocationServiceTest.php @@ -0,0 +1,345 @@ +service = new PipelineAllocationService(); + $this->scenario = Scenario::factory()->create(); + } + + public function test_allocates_to_single_fixed_bucket() + { + // Arrange: Single bucket with $500 limit + $bucket = Bucket::factory()->create([ + 'scenario_id' => $this->scenario->id, + 'name' => 'Emergency Fund', + 'allocation_type' => BucketAllocationType::FIXED_LIMIT, + 'allocation_value' => 500.00, + 'starting_amount' => 0, + 'priority' => 1 + ]); + + // Act: Allocate $300 + $draws = $this->service->allocateInflow($this->scenario, 300.00); + + // Assert: All money goes to emergency fund + $this->assertCount(1, $draws); + $this->assertEquals($bucket->id, $draws[0]->bucket_id); + $this->assertEquals(300.00, $draws[0]->amount); + $this->assertEquals(1, $draws[0]->priority_order); + } + + 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' => BucketAllocationType::FIXED_LIMIT, + 'allocation_value' => 200.00, + 'starting_amount' => 0, + 'priority' => 1 + ]); + $bucket2 = Bucket::factory()->create([ + 'scenario_id' => $this->scenario->id, + 'allocation_type' => BucketAllocationType::FIXED_LIMIT, + 'allocation_value' => 300.00, + 'starting_amount' => 0, + 'priority' => 2 + ]); + $bucket3 = Bucket::factory()->create([ + 'scenario_id' => $this->scenario->id, + 'allocation_type' => BucketAllocationType::FIXED_LIMIT, + 'allocation_value' => 150.00, + 'starting_amount' => 0, + 'priority' => 3 + ]); + + // Act: Allocate $550 (should fill bucket1 + bucket2 + partial bucket3) + $draws = $this->service->allocateInflow($this->scenario, 550.00); + + // Assert: Allocation follows priority order + $this->assertCount(3, $draws); + + // Bucket 1: fully filled + $this->assertEquals($bucket1->id, $draws[0]->bucket_id); + $this->assertEquals(200.00, $draws[0]->amount); + $this->assertEquals(1, $draws[0]->priority_order); + + // Bucket 2: fully filled + $this->assertEquals($bucket2->id, $draws[1]->bucket_id); + $this->assertEquals(300.00, $draws[1]->amount); + $this->assertEquals(2, $draws[1]->priority_order); + + // Bucket 3: partially filled + $this->assertEquals($bucket3->id, $draws[2]->bucket_id); + $this->assertEquals(50.00, $draws[2]->amount); + $this->assertEquals(3, $draws[2]->priority_order); + } + + 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' => BucketAllocationType::FIXED_LIMIT, + 'allocation_value' => 300.00, + 'starting_amount' => 0, + 'priority' => 1 + ]); + $percentageBucket = Bucket::factory()->create([ + 'scenario_id' => $this->scenario->id, + 'allocation_type' => BucketAllocationType::PERCENTAGE, + 'allocation_value' => 20.00, // 20% + 'starting_amount' => 0, + 'priority' => 2 + ]); + + // Act: Allocate $1000 + $draws = $this->service->allocateInflow($this->scenario, 1000.00); + + // Assert: Fixed gets $300, percentage gets 20% of remaining $700 = $140 + $this->assertCount(2, $draws); + $this->assertEquals(300.00, $draws[0]->amount); // Fixed bucket + $this->assertEquals(140.00, $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' => BucketAllocationType::FIXED_LIMIT, + 'allocation_value' => 500.00, + 'starting_amount' => 0, + 'priority' => 1 + ]); + $unlimitedBucket = Bucket::factory()->create([ + 'scenario_id' => $this->scenario->id, + 'allocation_type' => BucketAllocationType::UNLIMITED, + 'allocation_value' => null, + 'starting_amount' => 0, + 'priority' => 2 + ]); + + // Act: Allocate $1500 + $draws = $this->service->allocateInflow($this->scenario, 1500.00); + + // Assert: Fixed gets $500, unlimited gets remaining $1000 + $this->assertCount(2, $draws); + $this->assertEquals(500.00, $draws[0]->amount); + $this->assertEquals(1000.00, $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' => BucketAllocationType::FIXED_LIMIT, + 'allocation_value' => 0, // No capacity + 'starting_amount' => 0, + 'priority' => 1 + ]); + $normalBucket = Bucket::factory()->create([ + 'scenario_id' => $this->scenario->id, + 'allocation_type' => BucketAllocationType::FIXED_LIMIT, + 'allocation_value' => 300.00, + 'starting_amount' => 0, + 'priority' => 2 + ]); + + // Act: Allocate $200 + $draws = $this->service->allocateInflow($this->scenario, 200.00); + + // Assert: Only normal bucket gets allocation + $this->assertCount(1, $draws); + $this->assertEquals($normalBucket->id, $draws[0]->bucket_id); + $this->assertEquals(200.00, $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' => BucketAllocationType::FIXED_LIMIT, + 'allocation_value' => 1000.00, + 'starting_amount' => 0, + 'priority' => 1 + ]); + $percentage1 = Bucket::factory()->create([ + 'scenario_id' => $this->scenario->id, + 'allocation_type' => BucketAllocationType::PERCENTAGE, + 'allocation_value' => 15.00, // 15% + 'starting_amount' => 0, + 'priority' => 2 + ]); + $fixed2 = Bucket::factory()->create([ + 'scenario_id' => $this->scenario->id, + 'allocation_type' => BucketAllocationType::FIXED_LIMIT, + 'allocation_value' => 500.00, + 'starting_amount' => 0, + 'priority' => 3 + ]); + $percentage2 = Bucket::factory()->create([ + 'scenario_id' => $this->scenario->id, + 'allocation_type' => BucketAllocationType::PERCENTAGE, + 'allocation_value' => 25.00, // 25% + 'starting_amount' => 0, + 'priority' => 4 + ]); + $unlimited = Bucket::factory()->create([ + 'scenario_id' => $this->scenario->id, + 'allocation_type' => BucketAllocationType::UNLIMITED, + 'allocation_value' => null, + 'starting_amount' => 0, + 'priority' => 5 + ]); + + // Act: Allocate $5000 + $draws = $this->service->allocateInflow($this->scenario, 5000.00); + + // Assert: Complex allocation logic + $this->assertCount(5, $draws); + + // Fixed1: gets $1000 (full capacity) + $this->assertEquals(1000.00, $draws[0]->amount); + + // Percentage1: gets 15% of remaining $4000 = $600 + $this->assertEquals(600.00, $draws[1]->amount); + + // Fixed2: gets $500 (full capacity) + $this->assertEquals(500.00, $draws[2]->amount); + + // Remaining after fixed allocations: $5000 - $1000 - $600 - $500 = $2900 + // Percentage2: gets 25% of remaining $2900 = $725 + $this->assertEquals(725.00, $draws[3]->amount); + + // Unlimited: gets remaining $2900 - $725 = $2175 + $this->assertEquals(2175.00, $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, 1000.00); + + // Assert: No allocations made + $this->assertEmpty($draws); + } + + public function test_returns_empty_array_when_amount_is_zero() + { + // Arrange: Create bucket + Bucket::factory()->create([ + 'scenario_id' => $this->scenario->id, + 'allocation_type' => BucketAllocationType::FIXED_LIMIT, + 'allocation_value' => 500.00, + 'starting_amount' => 0, + 'priority' => 1 + ]); + + // Act: Allocate $0 + $draws = $this->service->allocateInflow($this->scenario, 0.00); + + // Assert: No allocations made + $this->assertEmpty($draws); + } + + public function test_handles_negative_amount_gracefully() + { + // Arrange: Create bucket + Bucket::factory()->create([ + 'scenario_id' => $this->scenario->id, + 'allocation_type' => BucketAllocationType::FIXED_LIMIT, + 'allocation_value' => 500.00, + 'starting_amount' => 0, + 'priority' => 1 + ]); + + // Act: Allocate negative amount + $draws = $this->service->allocateInflow($this->scenario, -100.00); + + // Assert: No allocations made + $this->assertEmpty($draws); + } + + public function test_respects_bucket_priority_order() + { + // Arrange: Buckets in non-sequential priority order + $bucket3 = Bucket::factory()->create([ + 'scenario_id' => $this->scenario->id, + 'allocation_type' => BucketAllocationType::FIXED_LIMIT, + 'allocation_value' => 100.00, + 'starting_amount' => 0, + 'priority' => 10 // Higher number + ]); + $bucket1 = Bucket::factory()->create([ + 'scenario_id' => $this->scenario->id, + 'allocation_type' => BucketAllocationType::FIXED_LIMIT, + 'allocation_value' => 200.00, + 'starting_amount' => 0, + 'priority' => 1 // Lower number (higher priority) + ]); + $bucket2 = Bucket::factory()->create([ + 'scenario_id' => $this->scenario->id, + 'allocation_type' => BucketAllocationType::FIXED_LIMIT, + 'allocation_value' => 150.00, + 'starting_amount' => 0, + 'priority' => 5 // Middle + ]); + + // Act: Allocate $250 + $draws = $this->service->allocateInflow($this->scenario, 250.00); + + // Assert: Priority order respected (1, 5, 10) + $this->assertCount(2, $draws); + $this->assertEquals($bucket1->id, $draws[0]->bucket_id); // Priority 1 first + $this->assertEquals(200.00, $draws[0]->amount); + $this->assertEquals($bucket2->id, $draws[1]->bucket_id); // Priority 5 second + $this->assertEquals(50.00, $draws[1]->amount); // Partial fill + } + + public function test_percentage_allocation_with_insufficient_remaining_amount() + { + // Arrange: Large fixed bucket + percentage bucket + $fixedBucket = Bucket::factory()->create([ + 'scenario_id' => $this->scenario->id, + 'allocation_type' => BucketAllocationType::FIXED_LIMIT, + 'allocation_value' => 950.00, + 'starting_amount' => 0, + 'priority' => 1 + ]); + $percentageBucket = Bucket::factory()->create([ + 'scenario_id' => $this->scenario->id, + 'allocation_type' => BucketAllocationType::PERCENTAGE, + 'allocation_value' => 20.00, // 20% + 'starting_amount' => 0, + 'priority' => 2 + ]); + + // Act: Allocate $1000 (only $50 left after fixed) + $draws = $this->service->allocateInflow($this->scenario, 1000.00); + + // Assert: Percentage gets 20% of remaining $50 = $10 + $this->assertCount(2, $draws); + $this->assertEquals(950.00, $draws[0]->amount); + $this->assertEquals(10.00, $draws[1]->amount); // 20% of $50 + } +}