Skip to content

Commit 497f072

Browse files
committed
feat(phase-34): budget management rest api with variance analysis
- BudgetApiController: list (with status/year filters), create, show (with lines), delete - activate endpoint: transitions draft → active, sets approved_by + approved_at - close endpoint: transitions active → closed - variance endpoint: recalculates actual spend and returns budgeted/actual/variance/utilization_percent/is_exceeded - Routes: GET/POST /api/v1/budgets, GET/DELETE /{budget}, POST /{budget}/activate|close, GET /{budget}/variance - Leverages existing Budget model's recalculate() and accessor methods - 8 feature tests: listing, creating, validation, activate, close, variance, delete, auth Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent f732929 commit 497f072

3 files changed

Lines changed: 226 additions & 0 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Modules\Finance\Models\Budget;
6+
use App\Modules\Finance\Models\BudgetLine;
7+
use Illuminate\Http\JsonResponse;
8+
use Illuminate\Http\Request;
9+
10+
class BudgetApiController extends ApiController
11+
{
12+
public function index(Request $request): JsonResponse
13+
{
14+
$tenantId = $this->tenantId($request);
15+
16+
$budgets = Budget::where('tenant_id', $tenantId)
17+
->when($request->status, fn ($q) => $q->where('status', $request->status))
18+
->when($request->year, fn ($q) => $q->where('fiscal_year', $request->year))
19+
->withCount('lines')
20+
->orderByDesc('fiscal_year')
21+
->paginate(20);
22+
23+
return $this->paginated($budgets);
24+
}
25+
26+
public function store(Request $request): JsonResponse
27+
{
28+
$tenantId = $this->tenantId($request);
29+
30+
$data = $request->validate([
31+
'name' => ['required', 'string', 'max:255'],
32+
'fiscal_year' => ['required', 'integer', 'min:2000'],
33+
'total_amount' => ['required', 'numeric', 'min:0'],
34+
'department' => ['nullable', 'string', 'max:255'],
35+
'notes' => ['nullable', 'string'],
36+
'start_date' => ['nullable', 'date'],
37+
'end_date' => ['nullable', 'date'],
38+
]);
39+
40+
$budget = Budget::create([
41+
...$data,
42+
'tenant_id' => $tenantId,
43+
'created_by' => $request->user()->id,
44+
'year' => $data['fiscal_year'],
45+
'status' => 'draft',
46+
'budget_type' => 'annual',
47+
'period_type' => 'annual',
48+
]);
49+
50+
return $this->success($budget, 201);
51+
}
52+
53+
public function show(Request $request, Budget $budget): JsonResponse
54+
{
55+
return $this->success($budget->load('lines'));
56+
}
57+
58+
public function activate(Request $request, Budget $budget): JsonResponse
59+
{
60+
$budget->activate($request->user()->id);
61+
return $this->success($budget->fresh());
62+
}
63+
64+
public function close(Budget $budget): JsonResponse
65+
{
66+
$budget->close();
67+
return $this->success($budget->fresh());
68+
}
69+
70+
public function destroy(Budget $budget): JsonResponse
71+
{
72+
$budget->delete();
73+
return $this->success(['message' => 'Budget deleted.']);
74+
}
75+
76+
public function variance(Request $request, Budget $budget): JsonResponse
77+
{
78+
$budget->recalculate();
79+
80+
return $this->success([
81+
'budget' => $budget->fresh(),
82+
'total_budgeted' => $budget->total_amount,
83+
'total_actual' => $budget->spent_amount,
84+
'variance' => $budget->remaining_amount,
85+
'utilization_percent' => $budget->utilization_percent,
86+
'is_exceeded' => $budget->is_exceeded,
87+
]);
88+
}
89+
90+
private function tenantId(Request $request): int
91+
{
92+
return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
93+
}
94+
}

erp/routes/api.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,17 @@
346346
Route::post('/mark-all-read', [\App\Http\Controllers\Api\V1\NotificationController::class, 'markAllRead']);
347347
});
348348

349+
// Budget Management API
350+
Route::prefix('budgets')->group(function () {
351+
Route::get('/', [\App\Http\Controllers\Api\V1\BudgetApiController::class, 'index']);
352+
Route::post('/', [\App\Http\Controllers\Api\V1\BudgetApiController::class, 'store']);
353+
Route::get('/{budget}', [\App\Http\Controllers\Api\V1\BudgetApiController::class, 'show']);
354+
Route::delete('/{budget}', [\App\Http\Controllers\Api\V1\BudgetApiController::class, 'destroy']);
355+
Route::post('/{budget}/activate', [\App\Http\Controllers\Api\V1\BudgetApiController::class, 'activate']);
356+
Route::post('/{budget}/close', [\App\Http\Controllers\Api\V1\BudgetApiController::class, 'close']);
357+
Route::get('/{budget}/variance', [\App\Http\Controllers\Api\V1\BudgetApiController::class, 'variance']);
358+
});
359+
349360
// Smart Alert Rules
350361
Route::prefix('alert-rules')->group(function () {
351362
Route::get('/', [\App\Http\Controllers\Api\V1\AlertRuleController::class, 'index']);
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\Tenant;
5+
use App\Modules\Finance\Models\Budget;
6+
use Database\Seeders\RolePermissionSeeder;
7+
8+
beforeEach(function () {
9+
$this->seed(RolePermissionSeeder::class);
10+
$this->tenant = Tenant::create(['name' => 'Budget API Co', 'slug' => 'budget-api-' . uniqid()]);
11+
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
12+
$this->user->assignRole('super-admin');
13+
$this->token = $this->user->createToken('test')->plainTextToken;
14+
app()->instance('tenant', $this->tenant);
15+
});
16+
17+
test('can list budgets via api', function () {
18+
Budget::create([
19+
'tenant_id' => $this->tenant->id,
20+
'name' => 'Annual Budget 2025',
21+
'fiscal_year' => 2025,
22+
'year' => 2025,
23+
'total_amount' => 100000,
24+
'created_by' => $this->user->id,
25+
'status' => 'active',
26+
]);
27+
28+
$this->withToken($this->token)->getJson('/api/v1/budgets')
29+
->assertStatus(200)
30+
->assertJsonPath('data.0.name', 'Annual Budget 2025');
31+
});
32+
33+
test('can create a budget via api', function () {
34+
$response = $this->withToken($this->token)->postJson('/api/v1/budgets', [
35+
'name' => 'Q1 Marketing Budget',
36+
'fiscal_year' => 2025,
37+
'total_amount' => 50000,
38+
'department' => 'Marketing',
39+
]);
40+
41+
$response->assertStatus(201);
42+
expect(Budget::where('name', 'Q1 Marketing Budget')->exists())->toBeTrue();
43+
});
44+
45+
test('store validates required fields', function () {
46+
$this->withToken($this->token)->postJson('/api/v1/budgets', [])
47+
->assertStatus(422)
48+
->assertJsonValidationErrors(['name', 'fiscal_year', 'total_amount']);
49+
});
50+
51+
test('can activate a budget', function () {
52+
$budget = Budget::create([
53+
'tenant_id' => $this->tenant->id,
54+
'name' => 'Draft Budget',
55+
'fiscal_year' => 2025,
56+
'year' => 2025,
57+
'total_amount' => 10000,
58+
'created_by' => $this->user->id,
59+
'status' => 'draft',
60+
]);
61+
62+
$this->withToken($this->token)->postJson("/api/v1/budgets/{$budget->id}/activate")
63+
->assertStatus(200);
64+
65+
expect($budget->fresh()->status)->toBe('active');
66+
});
67+
68+
test('can close a budget', function () {
69+
$budget = Budget::create([
70+
'tenant_id' => $this->tenant->id,
71+
'name' => 'Active Budget',
72+
'fiscal_year' => 2024,
73+
'year' => 2024,
74+
'total_amount' => 10000,
75+
'created_by' => $this->user->id,
76+
'status' => 'active',
77+
]);
78+
79+
$this->withToken($this->token)->postJson("/api/v1/budgets/{$budget->id}/close")
80+
->assertStatus(200);
81+
82+
expect($budget->fresh()->status)->toBe('closed');
83+
});
84+
85+
test('variance endpoint returns budget vs actual data', function () {
86+
$budget = Budget::create([
87+
'tenant_id' => $this->tenant->id,
88+
'name' => 'Variance Test',
89+
'fiscal_year' => 2025,
90+
'year' => 2025,
91+
'total_amount' => 20000,
92+
'created_by' => $this->user->id,
93+
'status' => 'active',
94+
]);
95+
96+
$response = $this->withToken($this->token)->getJson("/api/v1/budgets/{$budget->id}/variance");
97+
$response->assertStatus(200);
98+
$data = $response->json('data');
99+
expect($data)->toHaveKeys(['total_budgeted', 'total_actual', 'variance', 'utilization_percent', 'is_exceeded']);
100+
expect($data['total_budgeted'])->toBe('20000.00');
101+
});
102+
103+
test('can delete a budget', function () {
104+
$budget = Budget::create([
105+
'tenant_id' => $this->tenant->id,
106+
'name' => 'Delete Me',
107+
'fiscal_year' => 2025,
108+
'year' => 2025,
109+
'total_amount' => 1000,
110+
'created_by' => $this->user->id,
111+
]);
112+
113+
$this->withToken($this->token)->deleteJson("/api/v1/budgets/{$budget->id}")
114+
->assertStatus(200);
115+
116+
expect(Budget::withTrashed()->find($budget->id)?->deleted_at)->not->toBeNull();
117+
});
118+
119+
test('requires authentication', function () {
120+
$this->getJson('/api/v1/budgets')->assertStatus(401);
121+
});

0 commit comments

Comments
 (0)