19 - Add apply distribution endpoint to persist bucket balances

This commit is contained in:
myrmidex 2026-03-22 02:15:22 +01:00
parent 52ec8bb2ac
commit f4e5a186fa
4 changed files with 245 additions and 0 deletions

View file

@ -0,0 +1,59 @@
<?php
namespace App\Actions;
use App\Models\Bucket;
use App\Models\Scenario;
use App\Services\Projection\PipelineAllocationService;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class ApplyDistributionAction
{
public function __construct(
private readonly PipelineAllocationService $pipelineAllocationService,
) {}
/**
* Apply an income distribution to bucket balances.
*
* Re-runs the allocation algorithm server-side and updates each bucket's
* starting_amount by adding the allocated amount.
*
* @return array{allocations: Collection<int, array{bucket_id: string, bucket_name: string, bucket_type: string, allocated_amount: int}>, total_allocated: int, unallocated: int}
*/
public function execute(Scenario $scenario, int $amount): array
{
$draws = $this->pipelineAllocationService->allocateInflow($scenario, $amount);
/** @var array<int, Bucket> $bucketLookup */
$bucketLookup = $scenario->buckets->keyBy('id')->all();
DB::transaction(function () use ($draws, $bucketLookup) {
foreach ($draws as $draw) {
$bucket = $bucketLookup[$draw->bucket_id];
$bucket->increment('starting_amount', (int) $draw->amount);
}
});
$allocations = $draws->map(function ($draw) use ($bucketLookup) {
$bucket = $bucketLookup[$draw->bucket_id];
return [
'bucket_id' => $bucket->uuid,
'bucket_name' => $bucket->name,
'bucket_type' => $bucket->type->value,
'allocated_amount' => (int) $draw->amount,
];
})->values();
/** @var int $totalAllocated */
$totalAllocated = $draws->sum('amount');
return [
'allocations' => $allocations,
'total_allocated' => $totalAllocated,
'unallocated' => $amount - $totalAllocated,
];
}
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Actions\ApplyDistributionAction;
use App\Http\Requests\CalculateProjectionRequest; use App\Http\Requests\CalculateProjectionRequest;
use App\Http\Requests\PreviewAllocationRequest; use App\Http\Requests\PreviewAllocationRequest;
use App\Http\Resources\ProjectionResource; use App\Http\Resources\ProjectionResource;
@ -17,6 +18,7 @@ class ProjectionController extends Controller
public function __construct( public function __construct(
private readonly ProjectionGeneratorService $projectionGeneratorService, private readonly ProjectionGeneratorService $projectionGeneratorService,
private readonly PipelineAllocationService $pipelineAllocationService, private readonly PipelineAllocationService $pipelineAllocationService,
private readonly ApplyDistributionAction $applyDistributionAction,
) {} ) {}
public function calculate(CalculateProjectionRequest $request, Scenario $scenario): ProjectionResource public function calculate(CalculateProjectionRequest $request, Scenario $scenario): ProjectionResource
@ -67,4 +69,19 @@ public function preview(PreviewAllocationRequest $request, Scenario $scenario):
'unallocated' => $amountInCents - $totalAllocated, 'unallocated' => $amountInCents - $totalAllocated,
]); ]);
} }
/**
* Apply an income distribution to bucket balances.
*
* Re-runs the allocation server-side and updates starting_amount for each bucket.
* All amounts in cents.
*/
public function apply(PreviewAllocationRequest $request, Scenario $scenario): JsonResponse
{
$amountInCents = (int) $request->input('amount');
$result = $this->applyDistributionAction->execute($scenario, $amountInCents);
return response()->json($result);
}
} }

View file

@ -39,6 +39,7 @@
// Projection routes (no auth required for MVP) // Projection routes (no auth required for MVP)
Route::post('/scenarios/{scenario}/projections/calculate', [ProjectionController::class, 'calculate'])->name('projections.calculate'); Route::post('/scenarios/{scenario}/projections/calculate', [ProjectionController::class, 'calculate'])->name('projections.calculate');
Route::post('/scenarios/{scenario}/projections/preview', [ProjectionController::class, 'preview'])->name('projections.preview'); Route::post('/scenarios/{scenario}/projections/preview', [ProjectionController::class, 'preview'])->name('projections.preview');
Route::post('/scenarios/{scenario}/projections/apply', [ProjectionController::class, 'apply'])->name('projections.apply');
// Auth dashboard (hidden for single-scenario MVP, re-enable later) // Auth dashboard (hidden for single-scenario MVP, re-enable later)
// Route::middleware(['auth', 'verified'])->group(function () { // Route::middleware(['auth', 'verified'])->group(function () {

View file

@ -0,0 +1,168 @@
<?php
namespace Tests\Feature;
use App\Models\Bucket;
use App\Models\Scenario;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ApplyDistributionTest extends TestCase
{
use RefreshDatabase;
private Scenario $scenario;
protected function setUp(): void
{
parent::setUp();
$this->scenario = Scenario::factory()->create();
}
public function test_apply_updates_bucket_starting_amounts(): void
{
Bucket::factory()->need()->fixedLimit(50000)->create([
'scenario_id' => $this->scenario->id,
'name' => 'Rent',
'priority' => 1,
'starting_amount' => 0,
]);
Bucket::factory()->want()->fixedLimit(30000)->create([
'scenario_id' => $this->scenario->id,
'name' => 'Fun',
'priority' => 2,
'starting_amount' => 0,
]);
$response = $this->postJson(
"/scenarios/{$this->scenario->uuid}/projections/apply",
['amount' => 70000]
);
$response->assertOk();
$this->assertDatabaseHas('buckets', [
'name' => 'Rent',
'starting_amount' => 50000,
]);
$this->assertDatabaseHas('buckets', [
'name' => 'Fun',
'starting_amount' => 20000,
]);
}
public function test_apply_is_additive_to_existing_balance(): void
{
Bucket::factory()->need()->fixedLimit(50000)->create([
'scenario_id' => $this->scenario->id,
'name' => 'Rent',
'priority' => 1,
'starting_amount' => 20000,
]);
$response = $this->postJson(
"/scenarios/{$this->scenario->uuid}/projections/apply",
['amount' => 15000]
);
$response->assertOk();
$this->assertDatabaseHas('buckets', [
'name' => 'Rent',
'starting_amount' => 35000,
]);
}
public function test_apply_returns_allocation_breakdown(): void
{
$bucket = Bucket::factory()->need()->fixedLimit(50000)->create([
'scenario_id' => $this->scenario->id,
'name' => 'Rent',
'priority' => 1,
'starting_amount' => 0,
]);
$response = $this->postJson(
"/scenarios/{$this->scenario->uuid}/projections/apply",
['amount' => 30000]
);
$response->assertOk();
$response->assertJsonPath('allocations.0.bucket_id', $bucket->uuid);
$response->assertJsonPath('allocations.0.bucket_name', 'Rent');
$response->assertJsonPath('allocations.0.allocated_amount', 30000);
$response->assertJsonPath('total_allocated', 30000);
$response->assertJsonPath('unallocated', 0);
}
public function test_apply_rejects_missing_amount(): void
{
$response = $this->postJson(
"/scenarios/{$this->scenario->uuid}/projections/apply",
[]
);
$response->assertUnprocessable();
$response->assertJsonValidationErrors('amount');
}
public function test_apply_rejects_zero_amount(): void
{
$response = $this->postJson(
"/scenarios/{$this->scenario->uuid}/projections/apply",
['amount' => 0]
);
$response->assertUnprocessable();
$response->assertJsonValidationErrors('amount');
}
public function test_apply_with_overflow_bucket_captures_remainder(): void
{
Bucket::factory()->need()->fixedLimit(20000)->create([
'scenario_id' => $this->scenario->id,
'name' => 'Rent',
'priority' => 1,
'starting_amount' => 0,
]);
Bucket::factory()->overflow()->create([
'scenario_id' => $this->scenario->id,
'name' => 'Overflow',
'priority' => 2,
'starting_amount' => 0,
]);
$response = $this->postJson(
"/scenarios/{$this->scenario->uuid}/projections/apply",
['amount' => 50000]
);
$response->assertOk();
$this->assertDatabaseHas('buckets', [
'name' => 'Rent',
'starting_amount' => 20000,
]);
$this->assertDatabaseHas('buckets', [
'name' => 'Overflow',
'starting_amount' => 30000,
]);
}
public function test_apply_with_no_buckets_returns_empty(): void
{
$response = $this->postJson(
"/scenarios/{$this->scenario->uuid}/projections/apply",
['amount' => 100000]
);
$response->assertOk();
$response->assertJsonPath('allocations', []);
$response->assertJsonPath('total_allocated', 0);
$response->assertJsonPath('unallocated', 100000);
}
}