Skip to content

Commit e932894

Browse files
committed
feat(phase-43): expense claim workflow API with full approval lifecycle
- ExpenseClaimApiController: create with items, submit, approve, reject, mark-paid - Auto-calculates total_amount via recalculateTotal() after item creation - Workflow states: draft -> submitted -> approved/rejected -> paid - Routes: /expense-claims CRUD + submit, approve, reject, mark-paid actions - 9 feature tests covering full lifecycle including rejection with reason Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent fc279bd commit e932894

4 files changed

Lines changed: 313 additions & 37 deletions

File tree

CLAUDE.md

Lines changed: 38 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -137,43 +137,44 @@ beforeEach(function () {
137137

138138
## Development Phases Completed
139139

140-
| Phase | Description | Status |
141-
| ----- | ---------------------------------------------------------------- | ------ |
142-
| 1–8 | Core modules, models, migrations, seeders, Inertia pages ||
143-
| 9 | REST API — 200+ endpoints across 40 modules ||
144-
| 10 | Demo data seeders for all 35 modules ||
145-
| 11 | WebSockets — Laravel Reverb + Echo ||
146-
| 12 | Queue jobs — invoice, low stock, payroll, bulk import ||
147-
| 13 | Mail notifications — invoice, low stock, payroll, approval ||
148-
| 14 | PDF generation — invoices, purchase orders, payslips ||
149-
| 15 | Import/Export — CSV/XLSX for products, contacts, invoices ||
150-
| 16 | Dashboard analytics — module stats + activity feed ||
151-
| 17 | Tenant isolation tests — 22 cross-tenant security tests ||
152-
| 18 | API rate limiting (60/min) + security headers ||
153-
| 19 | Global search — 7 modules, frontend component ||
154-
| 20 | Audit log — migration, trait, observer, API endpoint ||
155-
| 21 | GitHub Actions CI/CD — PHP tests + TS check + ESLint ||
156-
| 22 | Reports API — financial/inventory/HR + CLAUDE.md ||
157-
| 23 | In-app notifications — DB model, API, frontend bell ||
158-
| 24 | Scheduled Report Delivery — ReportSchedule model + job + mail ||
159-
| 25 | Health Checks & Metrics — /api/v1/health + /api/v1/metrics ||
160-
| 26 | Dashboard Widgets — per-user customizable widget layout ||
161-
| 27 | Email Template Management — CRUD + variable preview ||
162-
| 28 | Tenant Feature Flags — per-tenant feature toggle system ||
163-
| 29 | User Preferences — timezone, locale, UI density, etc. ||
164-
| 30 | Activity Feed API — filterable event stream from audit logs ||
165-
| 31 | Unified Calendar API — tasks, leaves, events, invoices ||
166-
| 32 | Financial Forecasting — revenue + cash-flow projections ||
167-
| 33 | Smart Alert Rules — threshold monitoring + notifications ||
168-
| 34 | Budget Management REST API — CRUD + activate + variance ||
169-
| 35 | Customer Credit Limits — per-contact limits, hold, check API ||
170-
| 36 | Product Variants REST API — attributes, variants, matrix view ||
171-
| 37 | Webhook Management REST API — CRUD, delivery log, ping, HMAC ||
172-
| 38 | API Token Management — named tokens with abilities and expiry ||
173-
| 39 | Inventory Reorder Suggestions — deficit calc + urgency levels ||
174-
| 40 | HR Leave Balance API — allocation, team view, year filters ||
175-
| 41 | CRM Pipeline Analytics — funnel, win rate, velocity, leaderboard ||
176-
| 42 | Project Time Tracking API — log hours, project summaries by user ||
140+
| Phase | Description | Status |
141+
| ----- | ----------------------------------------------------------------- | ------ |
142+
| 1–8 | Core modules, models, migrations, seeders, Inertia pages ||
143+
| 9 | REST API — 200+ endpoints across 40 modules ||
144+
| 10 | Demo data seeders for all 35 modules ||
145+
| 11 | WebSockets — Laravel Reverb + Echo ||
146+
| 12 | Queue jobs — invoice, low stock, payroll, bulk import ||
147+
| 13 | Mail notifications — invoice, low stock, payroll, approval ||
148+
| 14 | PDF generation — invoices, purchase orders, payslips ||
149+
| 15 | Import/Export — CSV/XLSX for products, contacts, invoices ||
150+
| 16 | Dashboard analytics — module stats + activity feed ||
151+
| 17 | Tenant isolation tests — 22 cross-tenant security tests ||
152+
| 18 | API rate limiting (60/min) + security headers ||
153+
| 19 | Global search — 7 modules, frontend component ||
154+
| 20 | Audit log — migration, trait, observer, API endpoint ||
155+
| 21 | GitHub Actions CI/CD — PHP tests + TS check + ESLint ||
156+
| 22 | Reports API — financial/inventory/HR + CLAUDE.md ||
157+
| 23 | In-app notifications — DB model, API, frontend bell ||
158+
| 24 | Scheduled Report Delivery — ReportSchedule model + job + mail ||
159+
| 25 | Health Checks & Metrics — /api/v1/health + /api/v1/metrics ||
160+
| 26 | Dashboard Widgets — per-user customizable widget layout ||
161+
| 27 | Email Template Management — CRUD + variable preview ||
162+
| 28 | Tenant Feature Flags — per-tenant feature toggle system ||
163+
| 29 | User Preferences — timezone, locale, UI density, etc. ||
164+
| 30 | Activity Feed API — filterable event stream from audit logs ||
165+
| 31 | Unified Calendar API — tasks, leaves, events, invoices ||
166+
| 32 | Financial Forecasting — revenue + cash-flow projections ||
167+
| 33 | Smart Alert Rules — threshold monitoring + notifications ||
168+
| 34 | Budget Management REST API — CRUD + activate + variance ||
169+
| 35 | Customer Credit Limits — per-contact limits, hold, check API ||
170+
| 36 | Product Variants REST API — attributes, variants, matrix view ||
171+
| 37 | Webhook Management REST API — CRUD, delivery log, ping, HMAC ||
172+
| 38 | API Token Management — named tokens with abilities and expiry ||
173+
| 39 | Inventory Reorder Suggestions — deficit calc + urgency levels ||
174+
| 40 | HR Leave Balance API — allocation, team view, year filters ||
175+
| 41 | CRM Pipeline Analytics — funnel, win rate, velocity, leaderboard ||
176+
| 42 | Project Time Tracking API — log hours, project summaries by user ||
177+
| 43 | Expense Claim Workflow API — submit, approve, reject, paid states ||
177178

178179
## File Locations Reference
179180

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Modules\HR\Models\ExpenseClaim;
6+
use App\Modules\HR\Models\ExpenseClaimItem;
7+
use Illuminate\Http\JsonResponse;
8+
use Illuminate\Http\Request;
9+
10+
class ExpenseClaimApiController extends ApiController
11+
{
12+
public function index(Request $request): JsonResponse
13+
{
14+
$tenantId = $this->tenantId($request);
15+
16+
$claims = ExpenseClaim::where('tenant_id', $tenantId)
17+
->when($request->status, fn ($q) => $q->where('status', $request->status))
18+
->when($request->employee_id, fn ($q) => $q->where('employee_id', $request->employee_id))
19+
->with(['employee:id,first_name,last_name'])
20+
->orderByDesc('created_at')
21+
->paginate(20);
22+
23+
return $this->paginated($claims);
24+
}
25+
26+
public function store(Request $request): JsonResponse
27+
{
28+
$tenantId = $this->tenantId($request);
29+
30+
$data = $request->validate([
31+
'employee_id' => ['required', 'exists:employees,id'],
32+
'title' => ['required', 'string', 'max:255'],
33+
'description' => ['nullable', 'string'],
34+
'notes' => ['nullable', 'string'],
35+
'items' => ['required', 'array', 'min:1'],
36+
'items.*.category' => ['required', 'string', 'max:50'],
37+
'items.*.description' => ['required', 'string'],
38+
'items.*.amount' => ['required', 'numeric', 'min:0.01'],
39+
'items.*.expense_date' => ['required', 'date'],
40+
]);
41+
42+
$claim = ExpenseClaim::create([
43+
'tenant_id' => $tenantId,
44+
'employee_id' => $data['employee_id'],
45+
'title' => $data['title'],
46+
'description' => $data['description'] ?? null,
47+
'notes' => $data['notes'] ?? null,
48+
'status' => 'draft',
49+
'total_amount' => 0,
50+
]);
51+
52+
foreach ($data['items'] as $item) {
53+
ExpenseClaimItem::create([
54+
'tenant_id' => $tenantId,
55+
'expense_claim_id' => $claim->id,
56+
...$item,
57+
]);
58+
}
59+
60+
$claim->recalculateTotal();
61+
62+
return $this->success($claim->load('items'), 201);
63+
}
64+
65+
public function show(ExpenseClaim $expenseClaim): JsonResponse
66+
{
67+
return $this->success($expenseClaim->load(['items', 'employee:id,first_name,last_name', 'approvedBy:id,name']));
68+
}
69+
70+
public function submit(ExpenseClaim $expenseClaim): JsonResponse
71+
{
72+
$expenseClaim->submit();
73+
return $this->success($expenseClaim->fresh());
74+
}
75+
76+
public function approve(Request $request, ExpenseClaim $expenseClaim): JsonResponse
77+
{
78+
$expenseClaim->approve($request->user());
79+
return $this->success($expenseClaim->fresh());
80+
}
81+
82+
public function reject(Request $request, ExpenseClaim $expenseClaim): JsonResponse
83+
{
84+
$data = $request->validate([
85+
'reason' => ['required', 'string', 'max:500'],
86+
]);
87+
88+
$expenseClaim->reject($data['reason']);
89+
return $this->success($expenseClaim->fresh());
90+
}
91+
92+
public function markPaid(ExpenseClaim $expenseClaim): JsonResponse
93+
{
94+
$expenseClaim->markPaid();
95+
return $this->success($expenseClaim->fresh());
96+
}
97+
98+
public function destroy(ExpenseClaim $expenseClaim): JsonResponse
99+
{
100+
$expenseClaim->delete();
101+
return $this->success(['message' => 'Expense claim deleted.']);
102+
}
103+
104+
private function tenantId(Request $request): int
105+
{
106+
return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
107+
}
108+
}

erp/routes/api.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,18 @@
448448
});
449449
Route::get('products/{product}/matrix', [\App\Http\Controllers\Api\V1\ProductVariantController::class, 'matrix']);
450450

451+
// Expense Claims Workflow
452+
Route::prefix('expense-claims')->group(function () {
453+
Route::get('/', [\App\Http\Controllers\Api\V1\ExpenseClaimApiController::class, 'index']);
454+
Route::post('/', [\App\Http\Controllers\Api\V1\ExpenseClaimApiController::class, 'store']);
455+
Route::get('/{expenseClaim}', [\App\Http\Controllers\Api\V1\ExpenseClaimApiController::class, 'show']);
456+
Route::delete('/{expenseClaim}', [\App\Http\Controllers\Api\V1\ExpenseClaimApiController::class, 'destroy']);
457+
Route::post('/{expenseClaim}/submit', [\App\Http\Controllers\Api\V1\ExpenseClaimApiController::class, 'submit']);
458+
Route::post('/{expenseClaim}/approve', [\App\Http\Controllers\Api\V1\ExpenseClaimApiController::class, 'approve']);
459+
Route::post('/{expenseClaim}/reject', [\App\Http\Controllers\Api\V1\ExpenseClaimApiController::class, 'reject']);
460+
Route::post('/{expenseClaim}/mark-paid', [\App\Http\Controllers\Api\V1\ExpenseClaimApiController::class, 'markPaid']);
461+
});
462+
451463
// Time Tracking
452464
Route::prefix('time-entries')->group(function () {
453465
Route::get('/', [\App\Http\Controllers\Api\V1\TimeTrackingController::class, 'index']);
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\Tenant;
5+
use App\Modules\HR\Models\Employee;
6+
use App\Modules\HR\Models\ExpenseClaim;
7+
use App\Modules\HR\Models\ExpenseClaimItem;
8+
use Database\Seeders\RolePermissionSeeder;
9+
10+
beforeEach(function () {
11+
$this->seed(RolePermissionSeeder::class);
12+
$this->tenant = Tenant::create(['name' => 'Expense Co', 'slug' => 'expense-co-' . uniqid()]);
13+
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
14+
$this->user->assignRole('super-admin');
15+
$this->token = $this->user->createToken('test')->plainTextToken;
16+
app()->instance('tenant', $this->tenant);
17+
18+
$this->employee = Employee::create([
19+
'tenant_id' => $this->tenant->id,
20+
'first_name' => 'Alice',
21+
'last_name' => 'Smith',
22+
'employee_number' => 'EMP-EXP-' . uniqid(),
23+
'email' => 'alice-exp-' . uniqid() . '@example.com',
24+
'position' => 'Developer',
25+
'status' => 'active',
26+
'hire_date' => now()->subYear()->toDateString(),
27+
]);
28+
});
29+
30+
function makeExpenseClaim(array $attrs = []): ExpenseClaim
31+
{
32+
$claim = ExpenseClaim::create([
33+
'tenant_id' => test()->tenant->id,
34+
'employee_id' => test()->employee->id,
35+
'title' => 'Business Travel',
36+
'status' => 'draft',
37+
'total_amount' => 0,
38+
...$attrs,
39+
]);
40+
41+
ExpenseClaimItem::create([
42+
'tenant_id' => test()->tenant->id,
43+
'expense_claim_id' => $claim->id,
44+
'category' => 'travel',
45+
'description' => 'Flight ticket',
46+
'amount' => 250.00,
47+
'expense_date' => now()->toDateString(),
48+
]);
49+
50+
$claim->recalculateTotal();
51+
return $claim;
52+
}
53+
54+
test('can list expense claims', function () {
55+
makeExpenseClaim();
56+
57+
$this->withToken($this->token)
58+
->getJson('/api/v1/expense-claims')
59+
->assertStatus(200);
60+
});
61+
62+
test('can create an expense claim with items', function () {
63+
$this->withToken($this->token)
64+
->postJson('/api/v1/expense-claims', [
65+
'employee_id' => $this->employee->id,
66+
'title' => 'Conference Trip',
67+
'items' => [
68+
[
69+
'category' => 'travel',
70+
'description' => 'Airfare',
71+
'amount' => 400.00,
72+
'expense_date' => now()->toDateString(),
73+
],
74+
[
75+
'category' => 'accommodation',
76+
'description' => 'Hotel 2 nights',
77+
'amount' => 200.00,
78+
'expense_date' => now()->toDateString(),
79+
],
80+
],
81+
])
82+
->assertStatus(201);
83+
84+
expect(ExpenseClaim::where('title', 'Conference Trip')->exists())->toBeTrue();
85+
$claim = ExpenseClaim::where('title', 'Conference Trip')->first();
86+
expect((float) $claim->total_amount)->toBe(600.0);
87+
});
88+
89+
test('can submit an expense claim', function () {
90+
$claim = makeExpenseClaim();
91+
92+
$this->withToken($this->token)
93+
->postJson("/api/v1/expense-claims/{$claim->id}/submit")
94+
->assertStatus(200);
95+
96+
expect($claim->fresh()->status)->toBe('submitted');
97+
});
98+
99+
test('can approve an expense claim', function () {
100+
$claim = makeExpenseClaim(['status' => 'submitted']);
101+
102+
$this->withToken($this->token)
103+
->postJson("/api/v1/expense-claims/{$claim->id}/approve")
104+
->assertStatus(200);
105+
106+
$fresh = $claim->fresh();
107+
expect($fresh->status)->toBe('approved');
108+
expect($fresh->approved_by)->toBe($this->user->id);
109+
});
110+
111+
test('can reject with reason', function () {
112+
$claim = makeExpenseClaim(['status' => 'submitted']);
113+
114+
$this->withToken($this->token)
115+
->postJson("/api/v1/expense-claims/{$claim->id}/reject", [
116+
'reason' => 'Missing receipts',
117+
])
118+
->assertStatus(200);
119+
120+
$fresh = $claim->fresh();
121+
expect($fresh->status)->toBe('rejected');
122+
expect($fresh->rejection_reason)->toBe('Missing receipts');
123+
});
124+
125+
test('reject requires reason', function () {
126+
$claim = makeExpenseClaim(['status' => 'submitted']);
127+
128+
$this->withToken($this->token)
129+
->postJson("/api/v1/expense-claims/{$claim->id}/reject", [])
130+
->assertStatus(422)
131+
->assertJsonValidationErrors(['reason']);
132+
});
133+
134+
test('can mark expense claim as paid', function () {
135+
$claim = makeExpenseClaim(['status' => 'approved']);
136+
137+
$this->withToken($this->token)
138+
->postJson("/api/v1/expense-claims/{$claim->id}/mark-paid")
139+
->assertStatus(200)
140+
->assertJsonPath('data.status', 'paid');
141+
});
142+
143+
test('can soft delete an expense claim', function () {
144+
$claim = makeExpenseClaim();
145+
146+
$this->withToken($this->token)
147+
->deleteJson("/api/v1/expense-claims/{$claim->id}")
148+
->assertStatus(200);
149+
150+
expect(ExpenseClaim::withTrashed()->find($claim->id)?->deleted_at)->not->toBeNull();
151+
});
152+
153+
test('requires authentication', function () {
154+
$this->getJson('/api/v1/expense-claims')->assertStatus(401);
155+
});

0 commit comments

Comments
 (0)