Add bucket starting amount

This commit is contained in:
myrmidex 2025-12-31 01:56:50 +01:00
parent 217fd679e2
commit 8df75b0a2a
15 changed files with 944 additions and 1 deletions

View file

@ -10,10 +10,14 @@
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
/** /**
* @property int $id
* @property int $scenario_id * @property int $scenario_id
* @property Scenario $scenario * @property Scenario $scenario
* @property string $name * @property string $name
* @property int $priority * @property int $priority
* @property BucketAllocationType $allocation_type
* @property float $starting_amount
* @property float $allocation_value
*/ */
class Bucket extends Model class Bucket extends Model
{ {

51
app/Models/Draw.php Normal file
View file

@ -0,0 +1,51 @@
<?php
namespace App\Models;
use App\Models\Traits\HasAmount;
use App\Models\Traits\HasProjectionStatus;
use Carbon\Carbon;
use Database\Factories\DrawFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property Bucket $bucket
* @property int $priority_order
* @property float $amount
* @property Carbon $date
* @property string $description
* @property bool $is_projected
*/
class Draw extends Model
{
/** @use HasFactory<DrawFactory> */
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();
}
}

48
app/Models/Inflow.php Normal file
View file

@ -0,0 +1,48 @@
<?php
namespace App\Models;
use App\Models\Traits\HasAmount;
use App\Models\Traits\HasProjectionStatus;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int $stream_id
* @property Stream $stream
* @property float $amount
* @property Carbon $date
* @property string $description
* @property bool $is_projected
*/
class Inflow extends Model
{
use HasProjectionStatus;
use HasAmount;
protected $fillable = [
'stream_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 scenario(): BelongsTo
{
return $this->stream->scenario();
}
}

58
app/Models/Outflow.php Normal file
View file

@ -0,0 +1,58 @@
<?php
namespace App\Models;
use App\Models\Traits\HasAmount;
use App\Models\Traits\HasProjectionStatus;
use Carbon\Carbon;
use Database\Factories\OutflowFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int $stream_id
* @property Stream $stream
* @property int $amount
* @property Carbon $date
* @property string $description
* @property bool $is_projected
*/
class Outflow extends Model
{
/** @use HasFactory<OutflowFactory> */
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();
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Models\Traits;
trait HasProjectionStatus
{
public function initializeHasProjectionStatus(): void
{
$this->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;
}
}

View file

@ -0,0 +1,118 @@
<?php
namespace App\Services\Projection;
use App\Enums\BucketAllocationType;
use App\Models\Bucket;
use App\Models\Draw;
use App\Models\Scenario;
use Carbon\Carbon;
use Illuminate\Support\Collection;
readonly class PipelineAllocationService
{
/**
* Allocate an inflow amount across scenario buckets according to priority rules.
*
* @param Scenario $scenario
* @param float $amount
* @param Carbon|null $date
* @param string|null $description
* @return Collection<Draw> 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);
}
}

View file

@ -9,7 +9,7 @@
], ],
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.3",
"inertiajs/inertia-laravel": "^2.0", "inertiajs/inertia-laravel": "^2.0",
"laravel/fortify": "^1.30", "laravel/fortify": "^1.30",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",

View file

@ -0,0 +1,36 @@
<?php
namespace Database\Factories;
use App\Models\Bucket;
use App\Models\Draw;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Draw>
*/
class DrawFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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,
]);
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace Database\Factories;
use App\Models\Bucket;
use App\Models\Outflow;
use App\Models\Stream;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Outflow>
*/
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,
]);
}
}

View file

@ -0,0 +1,29 @@
<?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::create('inflows', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,30 @@
<?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::create('outflows', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,29 @@
<?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::create('draws', function (Blueprint $table) {
$table->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');
}
};

View file

@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\BucketController; use App\Http\Controllers\BucketController;
use App\Http\Controllers\ProjectionController;
use App\Http\Controllers\ScenarioController; use App\Http\Controllers\ScenarioController;
use App\Http\Controllers\StreamController; use App\Http\Controllers\StreamController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -30,6 +31,9 @@
Route::delete('/streams/{stream}', [StreamController::class, 'destroy'])->name('streams.destroy'); Route::delete('/streams/{stream}', [StreamController::class, 'destroy'])->name('streams.destroy');
Route::patch('/streams/{stream}/toggle', [StreamController::class, 'toggle'])->name('streams.toggle'); 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::middleware(['auth', 'verified'])->group(function () {
Route::get('dashboard', function () { Route::get('dashboard', function () {
return Inertia::render('dashboard'); return Inertia::render('dashboard');

82
tests/Unit/BucketTest.php Normal file
View file

@ -0,0 +1,82 @@
<?php
namespace Tests\Unit;
use App\Enums\BucketAllocationType;
use App\Models\Bucket;
use App\Models\Draw;
use App\Models\Outflow;
use App\Models\Scenario;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class BucketTest extends TestCase
{
use RefreshDatabase;
public function test_current_balance_includes_starting_amount()
{
// Arrange
$scenario = Scenario::factory()->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());
}
}

View file

@ -0,0 +1,345 @@
<?php
namespace Tests\Unit;
use App\Enums\BucketAllocationType;
use App\Models\Bucket;
use App\Models\Scenario;
use App\Services\Projection\PipelineAllocationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PipelineAllocationServiceTest extends TestCase
{
use RefreshDatabase;
private PipelineAllocationService $service;
private Scenario $scenario;
protected function setUp(): void
{
parent::setUp();
$this->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
}
}