diff --git a/app/Actions/ApplyDistributionAction.php b/app/Actions/ApplyDistributionAction.php new file mode 100644 index 0000000..2670062 --- /dev/null +++ b/app/Actions/ApplyDistributionAction.php @@ -0,0 +1,59 @@ +, total_allocated: int, unallocated: int} + */ + public function execute(Scenario $scenario, int $amount): array + { + $draws = $this->pipelineAllocationService->allocateInflow($scenario, $amount); + + /** @var array $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, + ]; + } +} diff --git a/app/Http/Controllers/ProjectionController.php b/app/Http/Controllers/ProjectionController.php index 62445c1..3a4e9f0 100644 --- a/app/Http/Controllers/ProjectionController.php +++ b/app/Http/Controllers/ProjectionController.php @@ -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); + } } diff --git a/routes/web.php b/routes/web.php index 4e98b5c..b69e1a0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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 () { diff --git a/tests/Feature/ApplyDistributionTest.php b/tests/Feature/ApplyDistributionTest.php new file mode 100644 index 0000000..2e13d3f --- /dev/null +++ b/tests/Feature/ApplyDistributionTest.php @@ -0,0 +1,168 @@ +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); + } +}