Add bucket starting amount
This commit is contained in:
parent
217fd679e2
commit
8df75b0a2a
15 changed files with 944 additions and 1 deletions
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
51
app/Models/Draw.php
Normal file
51
app/Models/Draw.php
Normal 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
48
app/Models/Inflow.php
Normal 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
58
app/Models/Outflow.php
Normal 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();
|
||||
}
|
||||
}
|
||||
69
app/Models/Traits/HasProjectionStatus.php
Normal file
69
app/Models/Traits/HasProjectionStatus.php
Normal 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;
|
||||
}
|
||||
}
|
||||
118
app/Services/Projection/PipelineAllocationService.php
Normal file
118
app/Services/Projection/PipelineAllocationService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
36
database/factories/DrawFactory.php
Normal file
36
database/factories/DrawFactory.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
40
database/factories/OutflowFactory.php
Normal file
40
database/factories/OutflowFactory.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
29
database/migrations/2025_12_30_234548_create_draws_table.php
Normal file
29
database/migrations/2025_12_30_234548_create_draws_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\BucketController;
|
||||
use App\Http\Controllers\ProjectionController;
|
||||
use App\Http\Controllers\ScenarioController;
|
||||
use App\Http\Controllers\StreamController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
|
@ -30,6 +31,9 @@
|
|||
Route::delete('/streams/{stream}', [StreamController::class, 'destroy'])->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');
|
||||
|
|
|
|||
82
tests/Unit/BucketTest.php
Normal file
82
tests/Unit/BucketTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
345
tests/Unit/PipelineAllocationServiceTest.php
Normal file
345
tests/Unit/PipelineAllocationServiceTest.php
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue