Skip to content

Commit a13a767

Browse files
committed
feat: Salary Structure Engine for HR payroll — 10 tests passing
- SalaryStructure model with rules-based compute() supporting fixed, percentage_of_basic, percentage_of_gross, percentage_of_rule types - SalaryRule and PayslipLine models; Employee/Payslip/PayrollRun updated - PayrollRun::generatePayslips() uses attached salary structure with flat fallback - SalaryStructureController (CRUD + nested storeRule/destroyRule) - Salary structure routes added to hr.php; kanban/move-stage added to crm.php - React pages: HR/SalaryStructures/Index and Show - 4 migrations: salary_structures, salary_rules, payslip_lines, employee FK https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 00878a4 commit a13a767

17 files changed

Lines changed: 563 additions & 18 deletions

erp/app/Modules/CRM/routes/crm.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
Route::post('leads/{lead}/mark-won', [CrmLeadController::class, 'markWon'])->name('leads.mark-won');
2525
Route::post('leads/{lead}/mark-lost', [CrmLeadController::class, 'markLost'])->name('leads.mark-lost');
2626
Route::post('leads/{lead}/convert', [CrmLeadController::class, 'convert'])->name('leads.convert');
27+
Route::get('pipeline/kanban', [CrmLeadController::class, 'kanban'])->name('pipeline.kanban');
28+
Route::patch('leads/{lead}/move-stage', [CrmLeadController::class, 'moveStage'])->name('leads.move-stage');
2729
Route::resource('leads', CrmLeadController::class);
2830

2931
// Activities (nested under leads + standalone actions)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?php
2+
namespace App\Modules\HR\Http\Controllers;
3+
use App\Http\Controllers\Controller;
4+
use App\Modules\HR\Models\SalaryRule;
5+
use App\Modules\HR\Models\SalaryStructure;
6+
use Illuminate\Http\RedirectResponse;
7+
use Illuminate\Http\Request;
8+
use Inertia\Inertia;
9+
use Inertia\Response;
10+
11+
class SalaryStructureController extends Controller
12+
{
13+
public function index(): Response
14+
{
15+
return Inertia::render('HR/SalaryStructures/Index', [
16+
'structures' => SalaryStructure::withCount('rules')->orderBy('name')->paginate(20),
17+
]);
18+
}
19+
20+
public function show(SalaryStructure $salaryStructure): Response
21+
{
22+
$salaryStructure->load('rules');
23+
return Inertia::render('HR/SalaryStructures/Show', [
24+
'structure' => $salaryStructure,
25+
'rules' => $salaryStructure->rules,
26+
]);
27+
}
28+
29+
public function store(Request $request): RedirectResponse
30+
{
31+
$data = $request->validate([
32+
'name' => 'required|string|max:100',
33+
'code' => 'required|string|max:50|unique:salary_structures,code',
34+
'description' => 'nullable|string',
35+
]);
36+
$structure = SalaryStructure::create([...$data, 'tenant_id' => auth()->user()->tenant_id]);
37+
return redirect()->route('hr.salary-structures.show', $structure)->with('success', 'Salary structure created.');
38+
}
39+
40+
public function update(Request $request, SalaryStructure $salaryStructure): RedirectResponse
41+
{
42+
$salaryStructure->update($request->validate(['name' => 'required|string|max:100', 'description' => 'nullable|string', 'is_active' => 'boolean']));
43+
return redirect()->back()->with('success', 'Structure updated.');
44+
}
45+
46+
public function destroy(SalaryStructure $salaryStructure): RedirectResponse
47+
{
48+
$salaryStructure->delete();
49+
return redirect()->route('hr.salary-structures.index')->with('success', 'Structure deleted.');
50+
}
51+
52+
public function storeRule(Request $request, SalaryStructure $salaryStructure): RedirectResponse
53+
{
54+
$data = $request->validate([
55+
'name' => 'required|string|max:100', 'code' => 'required|string|max:50',
56+
'category' => 'required|in:earnings,deductions,net', 'sequence' => 'required|integer|min:1',
57+
'amount_type' => 'required|in:fixed,percentage_of_basic,percentage_of_gross,percentage_of_rule',
58+
'amount' => 'nullable|numeric|min:0', 'percentage' => 'nullable|numeric|min:0|max:100',
59+
'base_rule_code' => 'nullable|string|max:50', 'description' => 'nullable|string',
60+
]);
61+
SalaryRule::create([...$data, 'tenant_id' => auth()->user()->tenant_id, 'structure_id' => $salaryStructure->id]);
62+
return redirect()->back()->with('success', 'Rule added.');
63+
}
64+
65+
public function destroyRule(SalaryStructure $salaryStructure, SalaryRule $rule): RedirectResponse
66+
{
67+
$rule->delete();
68+
return redirect()->back()->with('success', 'Rule removed.');
69+
}
70+
}

erp/app/Modules/HR/Models/Employee.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class Employee extends Model
1818
'tenant_id', 'user_id', 'department_id', 'employee_number',
1919
'first_name', 'last_name', 'email', 'phone', 'position',
2020
'employment_type', 'status', 'start_date', 'hire_date', 'end_date',
21-
'salary_type', 'salary_amount', 'salary_grade_id',
21+
'salary_type', 'salary_amount', 'salary_grade_id', 'salary_structure_id',
2222
];
2323

2424
protected $casts = [
@@ -42,6 +42,11 @@ public function salaryGrade(): BelongsTo
4242
return $this->belongsTo(SalaryGrade::class);
4343
}
4444

45+
public function salaryStructure(): BelongsTo
46+
{
47+
return $this->belongsTo(SalaryStructure::class, 'salary_structure_id');
48+
}
49+
4550
public function leaveRequests(): HasMany
4651
{
4752
return $this->hasMany(LeaveRequest::class);

erp/app/Modules/HR/Models/PayrollRun.php

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -143,31 +143,43 @@ public function generatePayslips(): int
143143
->where('tenant_id', $this->tenant_id)
144144
->where('status', 'active')
145145
->where('salary_amount', '>', 0)
146+
->with('salaryStructure.rules')
146147
->get();
147148

148149
$count = 0;
149150
foreach ($employees as $employee) {
150151
$gross = (float) $employee->salary_amount;
151-
$tax = round($gross * 0.10, 2);
152-
$deductions = $tax;
153-
$net = $gross - $deductions;
154-
155-
Payslip::updateOrCreate(
156-
[
157-
'payroll_run_id' => $this->id,
158-
'employee_id' => $employee->id,
159-
],
160-
[
161-
'tenant_id' => $this->tenant_id,
162-
'gross_amount' => $gross,
163-
'tax_amount' => $tax,
164-
'total_deductions' => $deductions,
165-
'net_amount' => $net,
166-
]
152+
$lines = [];
153+
$deductions = 0.0;
154+
155+
if ($employee->salaryStructure) {
156+
$lines = $employee->salaryStructure->compute($employee);
157+
$gross = collect($lines)->where('category', 'earnings')->sum('amount');
158+
$deductions = collect($lines)->where('category', 'deductions')->sum('amount');
159+
} else {
160+
$tax = round($gross * 0.10, 2);
161+
$deductions = $tax;
162+
$lines = [
163+
['salary_rule_id' => null, 'code' => 'BASIC', 'name' => 'Basic Salary', 'category' => 'earnings', 'sequence' => 10, 'amount' => $gross],
164+
['salary_rule_id' => null, 'code' => 'TAX', 'name' => 'Income Tax', 'category' => 'deductions', 'sequence' => 20, 'amount' => $tax],
165+
];
166+
}
167+
168+
$net = $gross - $deductions;
169+
$taxLine = collect($lines)->firstWhere('code', 'TAX');
170+
$tax = $taxLine ? (float) $taxLine['amount'] : $deductions;
171+
172+
$payslip = Payslip::updateOrCreate(
173+
['payroll_run_id' => $this->id, 'employee_id' => $employee->id],
174+
['tenant_id' => $this->tenant_id, 'gross_amount' => $gross, 'tax_amount' => $tax, 'total_deductions' => $deductions, 'net_amount' => $net]
167175
);
176+
177+
$payslip->lines()->delete();
178+
foreach ($lines as $line) {
179+
$payslip->lines()->create($line);
180+
}
168181
$count++;
169182
}
170-
171183
return $count;
172184
}
173185
}

erp/app/Modules/HR/Models/Payslip.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use App\Modules\Core\Traits\BelongsToTenant;
66
use Illuminate\Database\Eloquent\Model;
77
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
use Illuminate\Database\Eloquent\Relations\HasMany;
89

910
class Payslip extends Model
1011
{
@@ -40,6 +41,11 @@ public function employee(): BelongsTo
4041
return $this->belongsTo(Employee::class);
4142
}
4243

44+
public function lines(): HasMany
45+
{
46+
return $this->hasMany(PayslipLine::class)->orderBy('sequence');
47+
}
48+
4349
public function getEffectiveTaxRateAttribute(): float
4450
{
4551
$gross = (float) $this->gross_amount;
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
namespace App\Modules\HR\Models;
3+
use Illuminate\Database\Eloquent\Model;
4+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
5+
6+
class PayslipLine extends Model
7+
{
8+
protected $fillable = ['payslip_id','salary_rule_id','code','name','category','sequence','amount'];
9+
protected $casts = ['amount' => 'decimal:2'];
10+
public function payslip(): BelongsTo { return $this->belongsTo(Payslip::class); }
11+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
namespace App\Modules\HR\Models;
3+
use App\Modules\Core\Traits\BelongsToTenant;
4+
use Illuminate\Database\Eloquent\Model;
5+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
6+
7+
class SalaryRule extends Model
8+
{
9+
use BelongsToTenant;
10+
protected $fillable = ['tenant_id','structure_id','name','code','category','sequence','amount_type','amount','percentage','base_rule_code','description','is_active'];
11+
protected $casts = ['amount' => 'float', 'percentage' => 'float', 'is_active' => 'boolean'];
12+
public function structure(): BelongsTo { return $this->belongsTo(SalaryStructure::class, 'structure_id'); }
13+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
namespace App\Modules\HR\Models;
3+
use App\Modules\Core\Traits\BelongsToTenant;
4+
use Illuminate\Database\Eloquent\Model;
5+
use Illuminate\Database\Eloquent\Relations\HasMany;
6+
use Illuminate\Database\Eloquent\SoftDeletes;
7+
8+
class SalaryStructure extends Model
9+
{
10+
use BelongsToTenant, SoftDeletes;
11+
protected $fillable = ['tenant_id','name','code','description','is_active'];
12+
protected $casts = ['is_active' => 'boolean'];
13+
14+
public function rules(): HasMany { return $this->hasMany(SalaryRule::class, 'structure_id')->orderBy('sequence'); }
15+
16+
public function compute(Employee $employee): array
17+
{
18+
$rules = $this->rules()->where('is_active', true)->get();
19+
$computed = [];
20+
$lines = [];
21+
foreach ($rules as $rule) {
22+
$amount = $this->computeRule($rule, $employee, $computed);
23+
$computed[$rule->code] = $amount;
24+
$lines[] = [
25+
'salary_rule_id' => $rule->id,
26+
'code' => $rule->code,
27+
'name' => $rule->name,
28+
'category' => $rule->category,
29+
'sequence' => $rule->sequence,
30+
'amount' => round($amount, 2),
31+
];
32+
}
33+
return $lines;
34+
}
35+
36+
private function computeRule(SalaryRule $rule, Employee $employee, array $computed): float
37+
{
38+
return match ($rule->amount_type) {
39+
'fixed' => (float) $rule->amount,
40+
'percentage_of_basic' => (float) $employee->salary_amount * (float) $rule->percentage / 100,
41+
'percentage_of_gross' => $this->sumEarnings($computed) * (float) $rule->percentage / 100,
42+
'percentage_of_rule' => ($computed[$rule->base_rule_code] ?? 0) * (float) $rule->percentage / 100,
43+
default => 0.0,
44+
};
45+
}
46+
47+
private function sumEarnings(array $computed): float
48+
{
49+
$earningCodes = $this->rules()->where('category', 'earnings')->pluck('code');
50+
return (float) $earningCodes->sum(fn ($code) => $computed[$code] ?? 0);
51+
}
52+
}

erp/app/Modules/HR/routes/hr.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,14 @@
251251
Route::resource('position-changes', EmployeePositionChangeController::class)->only(['index', 'store', 'show', 'destroy']);
252252
});
253253

254+
// Salary Structures
255+
use App\Modules\HR\Http\Controllers\SalaryStructureController;
256+
Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () {
257+
Route::post('salary-structures/{salaryStructure}/rules', [SalaryStructureController::class, 'storeRule'])->name('salary-structures.rules.store');
258+
Route::delete('salary-structures/{salaryStructure}/rules/{rule}', [SalaryStructureController::class, 'destroyRule'])->name('salary-structures.rules.destroy');
259+
Route::resource('salary-structures', SalaryStructureController::class)->except(['create', 'edit']);
260+
});
261+
254262
// Salary Grades
255263
use App\Modules\HR\Http\Controllers\SalaryGradeController;
256264
Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () {

erp/app/Modules/PM/routes/pm.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212

1313
// Task complete action BEFORE resource
1414
Route::post('projects/{project}/tasks/{task}/complete', [TaskController::class, 'complete'])->name('tasks.complete');
15+
Route::get('projects/{project}/tasks/kanban', [TaskController::class, 'kanban'])->name('projects.tasks.kanban');
16+
Route::patch('projects/{project}/tasks/{task}/move-status', [TaskController::class, 'moveStatus'])->name('projects.tasks.move-status');
17+
Route::get('projects/{project}/tasks/calendar', [TaskController::class, 'calendar'])->name('projects.tasks.calendar');
1518

1619
// Kanban and Calendar views (must be before resource routes)
1720
Route::get('projects/{project}/tasks/kanban', [TaskController::class, 'kanban'])->name('projects.tasks.kanban');

0 commit comments

Comments
 (0)