# FCE(T) Gombe — HR & Payroll System
## Technical Documentation

> Audience: developers, IT administrators and technical maintainers.
> For end-user instructions see **USER_GUIDE.md**.

---

## 1. Overview

The HR & Payroll System is a web application that manages staff records and runs monthly payroll for a tertiary institution (Federal College of Education (Technical), Gombe). It computes Nigerian statutory deductions (PAYE, Pension, NHF), produces the full suite of official payroll reports, and provides an employee self-service (ESS) portal.

Core capabilities:

- Employee master records with effective-dated **grade/promotion history**.
- Configurable **salary tables** (structure → grade → step), effective-dated.
- Configurable **pay components** (earnings, deductions, statutory) with per-employee enrolments.
- **Config-driven PAYE** (Consolidated Relief Allowance, graduated bands, minimum tax), effective-dated and versioned.
- Monthly **payroll runs** producing immutable payslips with a full line-level audit trail.
- Bulk **monthly amount uploads** for earnings and deductions (by file number).
- A suite of **official PDF reports** (summaries, journal, schedules, vouchers, payslips, pay record cards), with background generation for large reports.
- **Master data** management (departments, banks, pension houses, unions) with import.
- Role-based access, full audit logging, and an ESS portal with change requests.

---

## 2. Technology stack

| Layer | Technology |
|---|---|
| Language / runtime | PHP **8.3+** (deployed on 8.4) |
| Framework | Laravel **13** |
| Front end | Inertia.js **2** + React **19** + TypeScript **5.7** |
| Styling | Tailwind CSS **4** |
| Build | Vite **6** |
| Database | MySQL |
| Queue | Laravel queue on the **database** driver |
| PDF | `barryvdh/laravel-dompdf` (dompdf) |
| Spreadsheets | `maatwebsite/excel` (PhpSpreadsheet) |
| Authorization | `spatie/laravel-permission` (roles) |
| Audit trail | `owen-it/laravel-auditing` |
| Route helper (JS) | `tightenco/ziggy` |

The front end is a single-page app driven by Inertia: Laravel controllers return `Inertia::render('page', props)`, and React page components live in `resources/js/pages/`.

---

## 3. Roles & access control

Four back-office roles plus self-service employees. `ADMIN` implicitly sees everything.

| Role | Purpose |
|---|---|
| **ADMIN** | Full access to all modules and configuration. |
| **HR** | Employee records, promotions, master data, change-request review, ESS accounts, amount uploads. |
| **BURSARY** | Payroll runs, pay components, salary tables, tax tables, amount uploads, reports. |
| **AUDITOR** | Read-only access to employees, payroll and reports. |
| **Employee (ESS)** | Own payslips and change requests via the portal. |

Route protection uses the `role:` middleware (e.g. `role:ADMIN|BURSARY`). Controllers additionally gate write actions with `hasAnyRole([...])`. The sidebar shows each item only to roles that may use it.

---

## 4. Domain model

### 4.1 Entity map

```
User ──1:1── Employee ──┬── EmployeeGrading (grade/promotion timeline)
                        ├── BankAccount / PensionAccount
                        ├── ComponentEnrolment ── PayComponent
                        └── Payslip ── PayslipLine
Department 1:N Employee
SalaryStructure 1:N SalaryGrade 1:N SalaryStep     (effective-dated)
PayrollRun 1:N Payslip 1:N PayslipLine
TaxTable 1:N TaxBand                                 (effective-dated, versioned)
ReportFile (background-generated report artifacts)
Bank / PensionHouse / Union / Department            (master/lookup data)
ChangeRequest (ESS change requests)
```

### 4.2 Key tables

- **employees** — `file_number` (primary staff identifier, unique), `employee_code` (IPPIS, optional, unique when present), names, `department_id`, `salary_structure`, `grade_level`, `salary_step` (current-grade cache), personal fields, `union_name`, `employment_status`, `date_of_join`. Soft-deletes.
- **employee_gradings** — the effective-dated grade timeline: `effective_from`, `salary_structure`, `grade_level`, `salary_step`, `type` (INITIAL/PROMOTION/INCREMENT/CONVERSION/DEMOTION/CORRECTION), `reference`, `reason`, `created_by`.
- **salary_structures / salary_grades / salary_steps** — the pay scale. A `salary_step` holds `consolidated_amount` and `effective_from`/`effective_to`.
- **pay_components** — earning/deduction/statutory definitions: `code` (immutable), `type`, `calc_method` (FIXED/PERCENT_OF/GRADE_TABLE/FORMULA/LOAN), `rate`, `amount`, `taxable`, `pensionable`, `tax_exempt`, `active`, `effective_from`/`effective_to`.
- **component_enrolments** — per-employee amounts for a component: `amount`, `active`, `effective_from`, `effective_to`. Unique on `(employee_id, pay_component_id, effective_from)`.
- **tax_tables / tax_bands** — PAYE ruleset: `cra_fixed_min`, `cra_alt_percent`, `cra_gross_percent`, `min_tax_percent`, `effective_from`/`effective_to`, `active`, plus graduated `tax_bands` (`width`, `rate`, `sort`).
- **payroll_runs** — `period` (YYYY-MM), `period_start/end`, `pay_date`, `effective_date`, `status`, `statutory_ruleset`, aggregate totals.
- **payslips** — an immutable snapshot per employee per run: identity + grade snapshot, bank/pension snapshot, `basic`, `gross`, `taxable_income`, `tax`, deduction totals, `net_pay`.
- **payslip_lines** — one row per component on a payslip: `code`, `name`, `type`, `base`, `rate`, `amount`, `sort`.
- **report_files** — background report generation tracking: `key`, `payroll_run_id`/`employee_id`/`year`, `status` (PENDING/PROCESSING/READY/FAILED), `path`, `filename`, `size`.
- **banks / pension_houses / unions / departments** — master/lookup data with `active` flags.

### 4.3 The effective-dating pattern

Time-varying data uses one of two effective-dating shapes, applied consistently:

1. **Window** (`effective_from` + nullable `effective_to`) — salary steps, component enrolments, tax tables. The `scopeEffectiveOn($date)` helper selects rows whose window contains the date (`effective_from <= date AND (effective_to IS NULL OR effective_to > date)`).
2. **Timeline** (`effective_from` only) — employee gradings. The row in force is the one with the greatest `effective_from <= date` (`scopeInForceOn($date)`).

This makes historical runs reproducible and lets future-dated changes (e.g. a scheduled promotion or a new tax table) activate automatically on their date.

---

## 5. Payroll calculation

The pipeline lives in `app/Services/Payroll/`.

### 5.1 `PayrollCalculator`

`run(PayrollRun $run)` orchestrates a run:

1. Resolve the run date (`effective_date`) and the PAYE strategy for the run's ruleset + date.
2. Preload lookup maps for the date: the **salary table**, and each employee's **grade in force** (from `employee_gradings`).
3. Stream eligible employees (Active status, optional structure filter) in chunks; for each, `computeForEmployee(...)` builds the payslip; results are written in a transaction (`Payslip` + bulk-inserted `PayslipLine`s).
4. Persist run totals and set status to `CALCULATED`.

`computeForEmployee(employee, salary, date, grading)` computes one payslip:

1. **Grade resolution** — use the effective `grading` (fallback to the employee's current columns) for `structure|grade|step`.
2. **Base pay** — look up `consolidated_amount` in the salary map; flag `unpriced` if zero.
3. **Earnings** — sum enrolled EARNING components; accumulate `taxableGross` (taxable ones) and `pensionableBase` (pensionable ones).
4. **Statutory** — Pension = `pensionableBase × rate`; NHF = `basic × rate`; PAYE via the strategy.
5. **Tax-exempt relief** — sum enrolled DEDUCTION components flagged `tax_exempt` (NHIS, life assurance) and pass as additional PAYE relief.
6. **Voluntary deductions** — sum enrolled DEDUCTION components.
7. **Totals** — gross, total deductions, net; snapshot identity/grade/bank/pension onto the payslip.

**Duplicate-safe enrolments.** If an employee has more than one enrolment effective for the same component (e.g. a month-scoped upload layered over a standing amount), the calculator keeps only the **most specific** one (latest `effective_from`, then latest id) rather than summing. This is what lets a monthly upload override a standing amount without double counting.

### 5.2 PAYE strategy (config-driven)

`PayeStrategy` is a versioned interface. `PitaPayeStrategy` implements the Nigerian PITA Sixth Schedule:

```
CRA   = max(cra_fixed_min, cra_alt_percent × gross) + cra_gross_percent × gross
relief = CRA + pension + NHF + other-relief (NHIS, life assurance)
taxable = max(0, gross − relief)
tax    = graduated bands applied to taxable, then floored at min_tax_percent × gross
```

The strategy's bands and parameters are **not hardcoded**: `PayeStrategyFactory::make(code, date)` loads the `TaxTable` in force for the run's ruleset and date and constructs the strategy from it. If no table exists it falls back to built-in defaults, so the engine is safe pre-configuration. Because tables are effective-dated, changing PAYE law is a data change (a new `TaxTable`), and historical runs reproduce their original figures.

Employer pension (for PFA reports) is **derived** at report time from a configurable rate (`config('payroll.pension.employer_rate')`, default 10%) applied to the pensionable base — it is not deducted from pay.

### 5.3 Reproducibility & immutability

- Payslips are **immutable snapshots**: identity, grade, bank/pension and every amount are stored on the payslip/lines at calculation time. Editing a master record later never alters an existing payslip.
- To reflect a change (promotion, uploaded amount, tax table) in an existing run, the run must be **recalculated** (only while unlocked). Locked statuses are `APPROVED`, `PAID`, `CLOSED`.

---

## 6. Reports

Two layers under `app/Services/Payroll/`:

- **`ReportBuilder`** — turns a run (or a year/employee) into report matrices: departmental & bank summaries, payroll journal (double-entry, balances to gross), PFA summary, per-component deduction schedules, per-PFA remittance schedules, payment vouchers, departmental register/listing, yearly summary, and the pay record card.
- **`ReportRenderer`** — the single source of truth mapping a report **key** + context (run / employee+year) to a Blade view, view data, filename and orientation. Both the synchronous download controller and the background job use it so they never diverge.

### 6.1 Delivery

- **Light reports** (aggregate/summary) stream directly as PDFs from `ReportController` (dompdf, ~1–3s).
- **Large per-member reports** (departmental listing, deduction schedules, PFA schedules, bulk payslips) iterate thousands of rows and exceed a web request's limits, so they are generated by the **`GenerateReport`** queued job to `storage/app/reports/`, tracked in `report_files`, and downloaded when `READY`. The UI polls status and offers a download.

Report Blade templates live in `resources/views/reports/` (a shared `layout`, a generic `table`, a `multi-table`, plus bespoke `journal`, `vouchers`, `departmental-listing`, `payslips`). Amount-in-words for the journal and vouchers is produced by `app/Support/AmountInWords.php`.

Individual payslips are rendered separately by `PayslipController` / `payslips/pdf.blade.php`.

---

## 7. Bulk data operations

All imports use `maatwebsite/excel` with a common report-back shape (created/updated/skipped + a per-row error list).

- **Employee import** — `EmployeeBulkImport`, keyed on file number.
- **Master-data import** — `MasterDataImport`, generic Name/Active upsert per lookup type.
- **Monthly amount upload** — `EnrolmentAmountImport`, positional `File Number | Amount`. Creates **month-scoped** enrolments (`effective_from` = 1st of the month, `effective_to` = 1st of next month) that override any standing amount for that month and auto-clear afterwards. Driven by `AmountUploadController` with a `{type}` route segment (`earnings` | `deductions`); base-pay (GRADE_TABLE) components are excluded. A **"Make upload-only"** action deactivates a component's open-ended (standing) enrolments so it only applies in uploaded months.

---

## 8. Background jobs & scheduling

Queue driver is **database**, so a worker must run to process jobs:

```bash
php artisan queue:work
```

Jobs:

- **`CalculatePayrollRun`** — computes a run off the request cycle; sets `PROCESSING → CALCULATED` (or `FAILED`).
- **`GenerateReport`** — renders a large report PDF to storage; updates the `report_files` record.

Scheduled tasks (`routes/console.php`, run by `php artisan schedule:run` via cron):

- **`promotions:apply-due`** (daily 00:30) — refreshes each employee's current-grade cache from any grading now in force (future-dated promotions activate automatically). Payroll is already correct without it; this keeps the displayed current grade accurate.

---

## 9. Configuration

`config/payroll.php` (env-overridable):

| Key | Env | Default |
|---|---|---|
| `organisation` | `PAYROLL_ORGANISATION` | Federal College of Education (Technical) Gombe |
| `organisation_short` | `PAYROLL_ORGANISATION_SHORT` | FCE(T) Gombe |
| `address` | `PAYROLL_ADDRESS` | P.M.B. 60, Gombe, Gombe State |
| `pension.employer_rate` | `PAYROLL_PENSION_EMPLOYER_RATE` | 0.10 |
| `signatories` | — | Payroll Officer, Bursar, Internal Audit, Provost |
| `brand` | `PAYROLL_BRAND_COLOR` | #CC3300 |

**Brand colour** is injected as the CSS variable `--brand` in the page `<head>`, so it can be changed via env without rebuilding assets. It drives primary buttons, links, active nav and focus rings (light & dark). After changing env in production, run `php artisan config:clear`.

Auth: public self-registration is **disabled** (staff accounts are provisioned by HR/Admin via the ESS invitation flow).

---

## 10. Project layout

```
app/
  Console/Commands/        BuildPayrollConfig, RunPayroll, ApplyDuePromotions, ...
  Exports/                 *TemplateExport, MultiSheetExport, TableSheet
  Http/Controllers/        Employee, Payroll, Payslip, Report, AmountUpload,
                           Promotion, MasterData, TaxTable, PayComponent, ...
  Imports/                 EmployeeBulkImport, MasterDataImport, EnrolmentAmountImport, ...
  Jobs/                    CalculatePayrollRun, GenerateReport
  Models/                  (see §4)
  Services/Payroll/        PayrollCalculator, PayeStrategy(+Factory, Pita),
                           ReportBuilder, ReportRenderer
  Support/                 AmountInWords
config/payroll.php
database/migrations/       schema + backfills
database/seeders/          Roles, MasterData, TaxTable, ReliefComponents, ...
resources/js/pages/        Inertia React pages (employees, payroll, config, reports, ...)
resources/views/           app.blade.php, reports/*, payslips/pdf.blade.php
routes/                    web.php, auth.php, settings.php, console.php
```

---

## 11. Environment & operations

**Runtime.** Requires PHP 8.4 (the project's Composer platform target is 8.3+, but the deployed environment uses 8.4 via Laravel Herd). Use the Herd PHP binary for all `artisan`/`composer` commands in this environment.

**Database.** MySQL; database name `hrpayroll`. Run migrations and seeders:

```bash
php artisan migrate --force
php artisan db:seed --force        # roles, master data, tax table, relief components
```

**Front-end build.** Compile assets with Vite (the project does not gate on `tsc`; the Vite build is the source of truth):

```bash
npm ci
npm run build
```

**Workers & schedule (production).** Run a queue worker (payroll calculation and report generation depend on it) and the scheduler:

```bash
php artisan queue:work --tries=1
* * * * * php artisan schedule:run   # cron
```

**Initial payroll configuration.** `php artisan payroll:build-config {path-to-template.xlsx}` derives salary tables, pay components and per-employee enrolments from the source workbook.

---

## 12. Auditing & data integrity

- Most domain models implement `owen-it/laravel-auditing`, recording field-level changes with the acting user — a complete change history for compliance.
- Structured histories exist where they matter for payroll: **grade** (`employee_gradings`) and **PAYE ruleset** (`tax_tables`).
- Payslips and their lines are immutable once calculated; correctness of historical figures is guaranteed by snapshotting + effective-dating rather than by preventing edits to master data.

---

## 13. Extending the system

- **New report** — add a `ReportBuilder` method + a Blade view, register the key in `ReportRenderer`, and (if large) add it to `ReportRenderer::HEAVY` and the background UI.
- **New PAYE regime** — add a `TaxTable` (data only) for a new effective date; add a new `PayeStrategy` implementation only if the *shape* of the calculation changes (not just the numbers).
- **New pay component** — create it in the Pay Components UI; enrol employees or upload monthly amounts. No code change.
- **New lookup type** — extend `MasterDataController`'s type map + the corresponding model/migration.
```
