Skip to content

Commit 6c5562d

Browse files
committed
feat(hr): Phase 73 — Payroll Processing with payslip generation
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent d0dc070 commit 6c5562d

16 files changed

Lines changed: 787 additions & 357 deletions

erp/app/Modules/HR/Http/Controllers/PayrollController.php

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,108 @@
88
use App\Modules\HR\Models\Employee;
99
use App\Modules\HR\Models\PayrollItem;
1010
use App\Modules\HR\Models\PayrollRun;
11+
use App\Modules\HR\Models\Payslip;
1112
use Illuminate\Http\RedirectResponse;
13+
use Illuminate\Http\Request;
1214
use Illuminate\Support\Facades\DB;
1315
use Inertia\Inertia;
1416
use Inertia\Response;
1517

1618
class PayrollController extends Controller
1719
{
18-
public function index(): Response
20+
public function index(Request $request): Response
21+
{
22+
$this->authorize('viewAny', PayrollRun::class);
23+
24+
$query = PayrollRun::query()->latest('period_start');
25+
26+
if ($request->filled('status')) {
27+
$query->where('status', $request->input('status'));
28+
}
29+
30+
$runs = $query->paginate(15);
31+
32+
return Inertia::render('HR/Payroll/Index', [
33+
'runs' => $runs,
34+
'filters' => $request->only(['status']),
35+
]);
36+
}
37+
38+
public function create(): Response
39+
{
40+
return Inertia::render('HR/Payroll/Create');
41+
}
42+
43+
public function store(Request $request): RedirectResponse
44+
{
45+
$validated = $request->validate([
46+
'period_start' => ['required', 'date'],
47+
'period_end' => ['required', 'date', 'after_or_equal:period_start'],
48+
'run_date' => ['required', 'date'],
49+
'notes' => ['nullable', 'string'],
50+
]);
51+
52+
$run = PayrollRun::create([
53+
'tenant_id' => auth()->user()->tenant_id,
54+
'period_start' => $validated['period_start'],
55+
'period_end' => $validated['period_end'],
56+
'run_date' => $validated['run_date'],
57+
'notes' => $validated['notes'] ?? null,
58+
'status' => 'draft',
59+
]);
60+
61+
return redirect()->route('hr.payroll.show', $run);
62+
}
63+
64+
public function show(PayrollRun $payrollRun): Response
65+
{
66+
$payrollRun->load(['payslips.employee']);
67+
68+
return Inertia::render('HR/Payroll/Show', [
69+
'payrollRun' => $payrollRun,
70+
]);
71+
}
72+
73+
public function destroy(PayrollRun $payrollRun): RedirectResponse
74+
{
75+
$this->authorize('delete', $payrollRun);
76+
77+
$payrollRun->delete();
78+
79+
return redirect()->route('hr.payroll.index');
80+
}
81+
82+
public function generate(Request $request, PayrollRun $payrollRun): RedirectResponse
83+
{
84+
$this->authorize('update', $payrollRun);
85+
86+
$count = $payrollRun->generatePayslips();
87+
$payrollRun->recalculateTotals();
88+
89+
return back()->with('success', "Generated {$count} payslips.");
90+
}
91+
92+
public function approve(PayrollRun $payrollRun): RedirectResponse
93+
{
94+
$this->authorize('update', $payrollRun);
95+
96+
$payrollRun->approve(auth()->user());
97+
98+
return back()->with('success', 'Payroll run approved.');
99+
}
100+
101+
public function markPaid(PayrollRun $payrollRun): RedirectResponse
102+
{
103+
$this->authorize('update', $payrollRun);
104+
105+
$payrollRun->markPaid();
106+
107+
return back()->with('success', 'Payroll run marked as paid.');
108+
}
109+
110+
// ─── Legacy methods (backward-compat with existing PayrollTest.php) ───
111+
112+
public function legacyIndex(): Response
19113
{
20114
$this->authorize('viewAny', Employee::class);
21115

@@ -25,14 +119,15 @@ public function index(): Response
25119

26120
return Inertia::render('HR/Payroll/Index', [
27121
'runs' => PayrollRunResource::collection($runs),
122+
'filters' => [],
28123
'breadcrumbs' => [
29124
['label' => 'HR'],
30125
['label' => 'Payroll', 'href' => route('hr.payroll.index')],
31126
],
32127
]);
33128
}
34129

35-
public function create(): Response
130+
public function legacyCreate(): Response
36131
{
37132
$this->authorize('create', Employee::class);
38133

@@ -55,7 +150,7 @@ public function create(): Response
55150
]);
56151
}
57152

58-
public function store(StorePayrollRunRequest $request): RedirectResponse
153+
public function legacyStore(StorePayrollRunRequest $request): RedirectResponse
59154
{
60155
$this->authorize('create', Employee::class);
61156

@@ -73,7 +168,7 @@ public function store(StorePayrollRunRequest $request): RedirectResponse
73168
$items = $request->input('items', []);
74169

75170
foreach ($items as $item) {
76-
$gross = (float) ($item['gross_salary'] ?? 0);
171+
$gross = (float) ($item['gross_salary'] ?? 0);
77172
$deductions = (float) ($item['deductions'] ?? 0);
78173

79174
PayrollItem::create([
@@ -93,7 +188,7 @@ public function store(StorePayrollRunRequest $request): RedirectResponse
93188
->with('success', 'Payroll run created.');
94189
}
95190

96-
public function show(PayrollRun $payrollRun): Response
191+
public function legacyShow(PayrollRun $payrollRun): Response
97192
{
98193
$this->authorize('viewAny', Employee::class);
99194

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

Lines changed: 95 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,19 @@ class PayrollRun extends Model
1515
use SoftDeletes;
1616

1717
protected $fillable = [
18-
'tenant_id', 'period_start', 'period_end', 'status', 'notes', 'created_by',
18+
'tenant_id', 'period_start', 'period_end', 'run_date', 'status', 'notes', 'created_by',
1919
'period_label', 'total_gross', 'total_deductions', 'total_net',
20-
'employee_count', 'processed_at',
20+
'employee_count', 'processed_at', 'approved_by', 'approved_at',
2121
];
2222

2323
protected $casts = [
24-
'period_start' => 'date',
25-
'period_end' => 'date',
26-
'processed_at' => 'datetime',
27-
'total_gross' => 'decimal:2',
28-
'total_net' => 'decimal:2',
24+
'period_start' => 'date',
25+
'period_end' => 'date',
26+
'run_date' => 'date',
27+
'processed_at' => 'datetime',
28+
'approved_at' => 'datetime',
29+
'total_gross' => 'decimal:2',
30+
'total_net' => 'decimal:2',
2931
'total_deductions' => 'decimal:2',
3032
];
3133

@@ -36,11 +38,21 @@ public function items(): HasMany
3638
return $this->hasMany(PayrollItem::class);
3739
}
3840

41+
public function payslips(): HasMany
42+
{
43+
return $this->hasMany(Payslip::class);
44+
}
45+
3946
public function creator(): BelongsTo
4047
{
4148
return $this->belongsTo(User::class, 'created_by');
4249
}
4350

51+
public function approvedBy(): BelongsTo
52+
{
53+
return $this->belongsTo(User::class, 'approved_by');
54+
}
55+
4456
public function getTotalGrossAttribute(): float
4557
{
4658
// If items are loaded, compute from items; otherwise use stored value
@@ -59,7 +71,7 @@ public function getTotalNetAttribute(): float
5971
}
6072

6173
/**
62-
* Process the payroll run.
74+
* Process the payroll run (legacy method).
6375
* Computes totals from active employees' salary_amount for this tenant.
6476
*/
6577
public function process(): void
@@ -80,12 +92,82 @@ public function process(): void
8092
$employeeCount = $employees->count();
8193

8294
$this->update([
83-
'status' => 'processed',
84-
'total_gross' => $totalGross,
95+
'status' => 'processed',
96+
'total_gross' => $totalGross,
8597
'total_deductions' => $totalDeductions,
86-
'total_net' => $totalNet,
87-
'employee_count' => $employeeCount,
88-
'processed_at' => now(),
98+
'total_net' => $totalNet,
99+
'employee_count' => $employeeCount,
100+
'processed_at' => now(),
89101
]);
90102
}
103+
104+
/**
105+
* Approve the payroll run.
106+
*/
107+
public function approve(User $user): void
108+
{
109+
$this->status = 'approved';
110+
$this->approved_by = $user->id;
111+
$this->approved_at = now();
112+
$this->save();
113+
}
114+
115+
/**
116+
* Mark the payroll run as paid.
117+
*/
118+
public function markPaid(): void
119+
{
120+
$this->status = 'paid';
121+
$this->save();
122+
}
123+
124+
/**
125+
* Recalculate totals from payslips.
126+
*/
127+
public function recalculateTotals(): void
128+
{
129+
$payslips = $this->payslips()->get();
130+
$this->total_gross = $payslips->sum('gross_amount');
131+
$this->total_deductions = $payslips->sum('total_deductions');
132+
$this->total_net = $payslips->sum('net_amount');
133+
$this->save();
134+
}
135+
136+
/**
137+
* Generate payslips for all active employees with salary_amount > 0.
138+
* Returns count of payslips generated.
139+
*/
140+
public function generatePayslips(): int
141+
{
142+
$employees = Employee::withoutGlobalScopes()
143+
->where('tenant_id', $this->tenant_id)
144+
->where('status', 'active')
145+
->where('salary_amount', '>', 0)
146+
->get();
147+
148+
$count = 0;
149+
foreach ($employees as $employee) {
150+
$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+
]
167+
);
168+
$count++;
169+
}
170+
171+
return $count;
172+
}
91173
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
namespace App\Modules\HR\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 Payslip extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $table = 'payslips';
14+
15+
protected $fillable = [
16+
'tenant_id',
17+
'payroll_run_id',
18+
'employee_id',
19+
'gross_amount',
20+
'total_deductions',
21+
'net_amount',
22+
'tax_amount',
23+
'notes',
24+
];
25+
26+
protected $casts = [
27+
'gross_amount' => 'decimal:2',
28+
'total_deductions' => 'decimal:2',
29+
'net_amount' => 'decimal:2',
30+
'tax_amount' => 'decimal:2',
31+
];
32+
33+
public function payrollRun(): BelongsTo
34+
{
35+
return $this->belongsTo(PayrollRun::class);
36+
}
37+
38+
public function employee(): BelongsTo
39+
{
40+
return $this->belongsTo(Employee::class);
41+
}
42+
43+
public function getEffectiveTaxRateAttribute(): float
44+
{
45+
$gross = (float) $this->gross_amount;
46+
if ($gross <= 0) {
47+
return 0.0;
48+
}
49+
return round((float) $this->tax_amount / $gross * 100, 2);
50+
}
51+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\HR\Models\PayrollRun;
7+
8+
class PayrollPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('hr.view');
13+
}
14+
15+
public function view(User $user, PayrollRun $payrollRun): bool
16+
{
17+
return $user->can('hr.view');
18+
}
19+
20+
public function create(User $user): bool
21+
{
22+
return $user->can('hr.create');
23+
}
24+
25+
public function update(User $user, PayrollRun $payrollRun): bool
26+
{
27+
return $user->can('hr.create');
28+
}
29+
30+
public function delete(User $user, PayrollRun $payrollRun): bool
31+
{
32+
return $user->can('hr.delete');
33+
}
34+
}

0 commit comments

Comments
 (0)