19 - Add apply distribution endpoint to persist bucket balances
This commit is contained in:
parent
52ec8bb2ac
commit
f4e5a186fa
4 changed files with 245 additions and 0 deletions
59
app/Actions/ApplyDistributionAction.php
Normal file
59
app/Actions/ApplyDistributionAction.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\ApplyDistributionAction;
|
||||
use App\Http\Requests\CalculateProjectionRequest;
|
||||
use App\Http\Requests\PreviewAllocationRequest;
|
||||
use App\Http\Resources\ProjectionResource;
|
||||
|
|
@ -17,6 +18,7 @@ class ProjectionController extends Controller
|
|||
public function __construct(
|
||||
private readonly ProjectionGeneratorService $projectionGeneratorService,
|
||||
private readonly PipelineAllocationService $pipelineAllocationService,
|
||||
private readonly ApplyDistributionAction $applyDistributionAction,
|
||||
) {}
|
||||
|
||||
public function calculate(CalculateProjectionRequest $request, Scenario $scenario): ProjectionResource
|
||||
|
|
@ -67,4 +69,19 @@ public function preview(PreviewAllocationRequest $request, Scenario $scenario):
|
|||
'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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@
|
|||
// Projection routes (no auth required for MVP)
|
||||
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/apply', [ProjectionController::class, 'apply'])->name('projections.apply');
|
||||
|
||||
// Auth dashboard (hidden for single-scenario MVP, re-enable later)
|
||||
// Route::middleware(['auth', 'verified'])->group(function () {
|
||||
|
|
|
|||
168
tests/Feature/ApplyDistributionTest.php
Normal file
168
tests/Feature/ApplyDistributionTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue