Skip to content

Commit 5c04833

Browse files
committed
feat(finance): Phase 97 — Expense Claims Management with approval workflow
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent e422d7d commit 5c04833

14 files changed

Lines changed: 971 additions & 0 deletions

File tree

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\ExpenseClaim;
7+
use App\Modules\Finance\Models\ExpenseItem;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Inertia\Inertia;
11+
use Inertia\Response;
12+
13+
class ExpenseClaimController extends Controller
14+
{
15+
public function index(Request $request): Response
16+
{
17+
$this->authorize('viewAny', ExpenseClaim::class);
18+
19+
$query = ExpenseClaim::with(['submittedBy']);
20+
21+
if ($request->filled('status')) {
22+
$query->where('status', $request->status);
23+
}
24+
25+
$claims = $query->latest()->paginate(20)->withQueryString();
26+
27+
return Inertia::render('Finance/ExpenseClaims/Index', [
28+
'claims' => $claims,
29+
'filters' => $request->only('status'),
30+
]);
31+
}
32+
33+
public function create(): Response
34+
{
35+
$this->authorize('create', ExpenseClaim::class);
36+
37+
return Inertia::render('Finance/ExpenseClaims/Create');
38+
}
39+
40+
public function store(Request $request): RedirectResponse
41+
{
42+
$this->authorize('create', ExpenseClaim::class);
43+
44+
$data = $request->validate([
45+
'claim_date' => ['required', 'date'],
46+
'currency' => ['nullable', 'string', 'max:3'],
47+
'notes' => ['nullable', 'string'],
48+
'items' => ['required', 'array', 'min:1'],
49+
'items.*.category' => ['required', 'string'],
50+
'items.*.expense_date'=> ['required', 'date'],
51+
'items.*.description' => ['required', 'string'],
52+
'items.*.amount' => ['required', 'numeric', 'min:0.01'],
53+
]);
54+
55+
$tenantId = app('tenant')->id;
56+
57+
$claim = ExpenseClaim::create([
58+
'tenant_id' => $tenantId,
59+
'reference' => ExpenseClaim::generateReference(),
60+
'submitted_by' => auth()->id(),
61+
'status' => 'draft',
62+
'claim_date' => $data['claim_date'],
63+
'currency' => $data['currency'] ?? 'USD',
64+
'notes' => $data['notes'] ?? null,
65+
'total_amount' => 0,
66+
]);
67+
68+
foreach ($data['items'] as $item) {
69+
ExpenseItem::create([
70+
'tenant_id' => $tenantId,
71+
'expense_claim_id' => $claim->id,
72+
'category' => $item['category'],
73+
'expense_date' => $item['expense_date'],
74+
'description' => $item['description'],
75+
'amount' => $item['amount'],
76+
'receipt_url' => $item['receipt_url'] ?? null,
77+
]);
78+
}
79+
80+
$claim->recalculate();
81+
82+
return redirect()->route('finance.expense-claims.show', $claim);
83+
}
84+
85+
public function show(ExpenseClaim $expenseClaim): Response
86+
{
87+
$this->authorize('view', $expenseClaim);
88+
89+
$expenseClaim->load(['submittedBy', 'approvedBy', 'items']);
90+
91+
return Inertia::render('Finance/ExpenseClaims/Show', [
92+
'claim' => $expenseClaim,
93+
]);
94+
}
95+
96+
public function destroy(ExpenseClaim $expenseClaim): RedirectResponse
97+
{
98+
$this->authorize('delete', $expenseClaim);
99+
100+
$expenseClaim->delete();
101+
102+
return redirect()->route('finance.expense-claims.index');
103+
}
104+
105+
public function submit(ExpenseClaim $expenseClaim): RedirectResponse
106+
{
107+
$this->authorize('update', $expenseClaim);
108+
109+
$expenseClaim->submit();
110+
111+
return back()->with('success', 'Claim submitted.');
112+
}
113+
114+
public function approve(ExpenseClaim $expenseClaim): RedirectResponse
115+
{
116+
$this->authorize('update', $expenseClaim);
117+
118+
$expenseClaim->approve(auth()->id());
119+
120+
return back()->with('success', 'Claim approved.');
121+
}
122+
123+
public function reject(ExpenseClaim $expenseClaim): RedirectResponse
124+
{
125+
$this->authorize('update', $expenseClaim);
126+
127+
$expenseClaim->reject();
128+
129+
return back()->with('success', 'Claim rejected.');
130+
}
131+
132+
public function markPaid(ExpenseClaim $expenseClaim): RedirectResponse
133+
{
134+
$this->authorize('update', $expenseClaim);
135+
136+
$expenseClaim->markPaid();
137+
138+
return back()->with('success', 'Claim marked as paid.');
139+
}
140+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Database\Eloquent\Relations\HasMany;
10+
use Illuminate\Database\Eloquent\SoftDeletes;
11+
12+
class ExpenseClaim extends Model
13+
{
14+
use BelongsToTenant;
15+
use SoftDeletes;
16+
17+
protected $fillable = [
18+
'tenant_id',
19+
'reference',
20+
'submitted_by',
21+
'approved_by',
22+
'status',
23+
'claim_date',
24+
'currency',
25+
'total_amount',
26+
'notes',
27+
'submitted_at',
28+
'approved_at',
29+
'paid_at',
30+
];
31+
32+
protected $casts = [
33+
'claim_date' => 'date',
34+
'submitted_at' => 'datetime',
35+
'approved_at' => 'datetime',
36+
'paid_at' => 'datetime',
37+
'total_amount' => 'float',
38+
];
39+
40+
public function submittedBy(): BelongsTo
41+
{
42+
return $this->belongsTo(User::class, 'submitted_by');
43+
}
44+
45+
public function approvedBy(): BelongsTo
46+
{
47+
return $this->belongsTo(User::class, 'approved_by');
48+
}
49+
50+
public function items(): HasMany
51+
{
52+
return $this->hasMany(ExpenseItem::class);
53+
}
54+
55+
public static function generateReference(): string
56+
{
57+
return 'EXP-' . strtoupper(uniqid());
58+
}
59+
60+
public function submit(): void
61+
{
62+
$this->status = 'submitted';
63+
$this->submitted_at = now();
64+
$this->save();
65+
}
66+
67+
public function approve(int $userId): void
68+
{
69+
$this->status = 'approved';
70+
$this->approved_by = $userId;
71+
$this->approved_at = now();
72+
$this->save();
73+
}
74+
75+
public function reject(): void
76+
{
77+
$this->status = 'rejected';
78+
$this->save();
79+
}
80+
81+
public function markPaid(): void
82+
{
83+
$this->status = 'paid';
84+
$this->paid_at = now();
85+
$this->save();
86+
}
87+
88+
public function recalculate(): void
89+
{
90+
$this->total_amount = $this->items()->sum('amount');
91+
$this->save();
92+
}
93+
94+
public function getIsEditableAttribute(): bool
95+
{
96+
return $this->status === 'draft';
97+
}
98+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
class ExpenseItem extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $fillable = [
14+
'tenant_id',
15+
'expense_claim_id',
16+
'category',
17+
'expense_date',
18+
'description',
19+
'amount',
20+
'receipt_url',
21+
];
22+
23+
protected $casts = [
24+
'expense_date' => 'date',
25+
'amount' => 'float',
26+
];
27+
28+
public function claim(): BelongsTo
29+
{
30+
return $this->belongsTo(ExpenseClaim::class);
31+
}
32+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Policies;
4+
5+
use App\Models\User;
6+
7+
class ExpenseClaimPolicy
8+
{
9+
public function viewAny(User $user): bool { return $user->hasPermissionTo('finance.view'); }
10+
public function view(User $user, $model): bool { return $user->hasPermissionTo('finance.view'); }
11+
public function create(User $user): bool { return $user->hasPermissionTo('finance.create'); }
12+
public function update(User $user, $model): bool { return $user->hasPermissionTo('finance.create'); }
13+
public function delete(User $user, $model): bool { return $user->hasPermissionTo('finance.delete'); }
14+
}

erp/app/Modules/Finance/Providers/FinanceServiceProvider.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@
8282
use App\Modules\Finance\Models\SupportTicket;
8383
use App\Modules\Finance\Models\TicketComment;
8484
use App\Modules\Finance\Policies\SupportTicketPolicy;
85+
use App\Modules\Finance\Models\ExpenseClaim;
86+
use App\Modules\Finance\Models\ExpenseItem;
87+
use App\Modules\Finance\Policies\ExpenseClaimPolicy;
8588
use Illuminate\Support\Facades\Gate;
8689
use Illuminate\Support\ServiceProvider;
8790

@@ -148,6 +151,9 @@ public function boot(): void
148151
Gate::policy(SupportTicket::class, SupportTicketPolicy::class);
149152
Gate::policy(TicketComment::class, SupportTicketPolicy::class);
150153

154+
155+
Gate::policy(ExpenseClaim::class, ExpenseClaimPolicy::class);
156+
Gate::policy(ExpenseItem::class, ExpenseClaimPolicy::class);
151157
if ($this->app->runningInConsole()) {
152158
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);
153159
}

erp/app/Modules/Finance/routes/finance.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,3 +307,13 @@
307307

308308
});
309309

310+
311+
// Expense Claims — custom actions BEFORE resource
312+
use App\Modules\Finance\Http\Controllers\ExpenseClaimController;
313+
Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () {
314+
Route::post('expense-claims/{expenseClaim}/submit', [ExpenseClaimController::class, 'submit'])->name('expense-claims.submit');
315+
Route::post('expense-claims/{expenseClaim}/approve', [ExpenseClaimController::class, 'approve'])->name('expense-claims.approve');
316+
Route::post('expense-claims/{expenseClaim}/reject', [ExpenseClaimController::class, 'reject'])->name('expense-claims.reject');
317+
Route::post('expense-claims/{expenseClaim}/mark-paid',[ExpenseClaimController::class, 'markPaid'])->name('expense-claims.mark-paid');
318+
Route::resource('expense-claims', ExpenseClaimController::class)->only(['index', 'create', 'store', 'show', 'destroy']);
319+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
Schema::dropIfExists('expense_claim_items');
12+
Schema::dropIfExists('expense_items');
13+
Schema::dropIfExists('expense_claims');
14+
15+
Schema::create('expense_claims', function (Blueprint $table) {
16+
$table->id();
17+
$table->unsignedBigInteger('tenant_id');
18+
$table->string('reference')->unique();
19+
$table->unsignedBigInteger('submitted_by');
20+
$table->unsignedBigInteger('approved_by')->nullable();
21+
$table->enum('status', ['draft', 'submitted', 'approved', 'rejected', 'paid'])->default('draft');
22+
$table->date('claim_date');
23+
$table->string('currency', 3)->default('USD');
24+
$table->decimal('total_amount', 15, 2)->default(0);
25+
$table->text('notes')->nullable();
26+
$table->timestamp('submitted_at')->nullable();
27+
$table->timestamp('approved_at')->nullable();
28+
$table->timestamp('paid_at')->nullable();
29+
$table->timestamps();
30+
$table->softDeletes();
31+
});
32+
}
33+
34+
public function down(): void
35+
{
36+
Schema::dropIfExists('expense_claims');
37+
}
38+
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
Schema::create('expense_items', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->unsignedBigInteger('expense_claim_id');
15+
$table->string('category');
16+
$table->date('expense_date');
17+
$table->string('description');
18+
$table->decimal('amount', 15, 2);
19+
$table->string('receipt_url')->nullable();
20+
$table->timestamps();
21+
});
22+
}
23+
24+
public function down(): void
25+
{
26+
Schema::dropIfExists('expense_items');
27+
}
28+
};

erp/resources/js/Components/Layout/Sidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ const navItems: NavItem[] = [
139139
{ label: 'Loyalty Programs', href: '/finance/loyalty-programs', icon: <span /> },
140140
{ label: 'CRM / Leads', href: '/finance/leads', icon: <span /> },
141141
{ label: 'Support Tickets', href: '/finance/support-tickets', icon: <span /> },
142+
{ label: 'Expense Claims', href: '/finance/expense-claims', icon: <span /> },
142143
],
143144
},
144145
{

0 commit comments

Comments
 (0)