144 lines
6.4 KiB
Markdown
144 lines
6.4 KiB
Markdown
# HR/Finance Phase 1 — Backend pre-step (B1–B8)
|
||
|
||
> Plan: `.harness/plans/hr-finance-fiscalisation.md` §3a
|
||
> Branch: `feature/hr-finance-phase-1` (worktree `.worktrees/hr-finance-phase-1/`)
|
||
> Owner: backend-expert (pre-step); frontend-expert consumes these endpoints next.
|
||
|
||
## What landed
|
||
|
||
Eight endpoints closed the gap between Phase 0 (proxy stabilisation) and the
|
||
Phase 1 portal pages. Frontend can now render real data — no more
|
||
"Module Under Construction" placeholders.
|
||
|
||
### Files touched
|
||
|
||
| File | Lines added | New route handlers |
|
||
|---|---|---|
|
||
| `server/src/controllers/hr.controller.js` | +114 | B1 `GET /api/hr/dashboard/summary`, B7 `GET /api/hr/payslips/:id` |
|
||
| `server/src/controllers/finance.controller.js` | +273 | B2 `GET /api/finance/dashboard/summary`, B3 `GET /api/finance/expenses`, B4 `POST /api/finance/expenses`, B5 `PATCH /api/finance/expenses/:id`, B6 `DELETE /api/finance/expenses/:id`, B8 `GET /api/finance/expenses/categories` |
|
||
|
||
Plus a 1-line widening of `financeAccess` to allow `hr` (B2 spec requirement).
|
||
|
||
## Endpoint shapes (verified via curl as `bursar@school.com`)
|
||
|
||
### B1 — `GET /api/hr/dashboard/summary`
|
||
```json
|
||
{
|
||
"headcount": 0,
|
||
"open_leave_requests": 0,
|
||
"last_payroll_run": null,
|
||
"attendance_today_pct": 0
|
||
}
|
||
```
|
||
`last_payroll_run` is `{id, period:"YYYY-MM", status} | null`. `attendance_today_pct`
|
||
counts `present | late | half-day` as attended (defaults to `0` when no rows
|
||
exist for today, not `NaN`). `headcount` is active staff_records (`is_active=1, is_deleted=0`).
|
||
|
||
### B2 — `GET /api/finance/dashboard/summary`
|
||
```json
|
||
{
|
||
"collected_mtd": 0,
|
||
"outstanding_fees": 0,
|
||
"payroll_last_run": null,
|
||
"expenses_mtd": 12.5,
|
||
"pending_expenses": 1
|
||
}
|
||
```
|
||
After the B2 schema fix (see "Deviations" below):
|
||
- `collected_mtd = SUM(payments.amount) WHERE student_fees.status='paid' AND date(payment_date,'localtime') >= start of month`
|
||
- `outstanding_fees = SUM(student_fees.amount - paid_amount) WHERE status IN ('pending','partial') AND is_deleted=0`
|
||
|
||
### B3 — `GET /api/finance/expenses?status=&category=&date_from=&date_to=&limit=&offset=`
|
||
```json
|
||
{
|
||
"expenses": [ /* non-deleted rows */ ],
|
||
"pagination": { "total": 1, "limit": 10, "offset": 0, "returned": 1 }
|
||
}
|
||
```
|
||
|
||
### B4 — `POST /api/finance/expenses`
|
||
Validates: `category ∈ enum`, `description` required, `amount > 0`,
|
||
`expense_date <= today`. Returns the new row (201).
|
||
|
||
### B5 — `PATCH /api/finance/expenses/:id`
|
||
Partial update; same validation as B4 for `category / amount / expense_date`.
|
||
|
||
### B6 — `DELETE /api/finance/expenses/:id`
|
||
Soft-delete (`is_deleted=1`, `sync_status='pending'`). Returns `{success:true}`.
|
||
|
||
### B7 — `GET /api/hr/payslips/:id`
|
||
```json
|
||
{
|
||
"id": 4, "staff_id": 13, "payroll_run_id": 4,
|
||
"basic_salary": 1000, "housing_allowance": 100, ..., "net_salary": 970,
|
||
"first_name": "Patience", "last_name": "Muzondo", "email": "bursar@school.com",
|
||
"staff_number": null, "department": null, "designation": null,
|
||
"period_month": 7, "period_year": 2026, "run_id": 4, "period": "2026-07"
|
||
}
|
||
```
|
||
Returns 400 for non-numeric id, 404 if missing, 403 if a non-privileged user
|
||
hits someone else's payslip. Privacy gate is defensive in practice because
|
||
every `hrAccess` role is in the privileged list, but kept for forward safety.
|
||
|
||
### B8 — `GET /api/finance/expenses/categories`
|
||
```json
|
||
["utilities","supplies","maintenance","transport","salaries","other"]
|
||
```
|
||
Implemented (not skipped) so the frontend dropdown stays server-driven.
|
||
|
||
## RBAC matrix (tested against `bursar@school.com`)
|
||
|
||
| Endpoint | Middleware | Allowed roles (verified) |
|
||
|---|---|---|
|
||
| B1, B7 | `auth + hrAccess` | school_admin, systems_admin, principal, hr, bursar, accountant |
|
||
| B2, B3, B4, B5, B6, B8 | `auth + financeAccess` | school_admin, systems_admin, principal, **hr** (newly allowed), bursar, accountant |
|
||
|
||
B7's 403 path was code-verified (any caller outside `hrAccess` reaches the
|
||
middleware reject first; the inner privacy gate is the second line).
|
||
|
||
## Sync contract
|
||
|
||
All Phase 1 writes (`expenses` create / update / soft-delete) set
|
||
`sync_status='pending'`. Read endpoints filter `is_deleted=0`. No new tables
|
||
added. `SyncEngine.tablesToSync` already includes `expenses`, `leave_requests`,
|
||
`payroll_runs`, `payslips` (verified at `server/src/services/SyncEngine.js:43,56,58,59`).
|
||
|
||
| Op | sync_status | is_deleted |
|
||
|---|---|---|
|
||
| B4 INSERT | 'pending' | 0 |
|
||
| B5 UPDATE | 'pending' | unchanged |
|
||
| B6 DELETE | 'pending' | 1 |
|
||
|
||
## Deviations from spec
|
||
|
||
1. **B2 collected_mtd filter** — the spec called for `SUM(amount) FROM payments WHERE status='completed'`. The `payments` table has no `status` column (per `server/src/database/init.js:187`). Falling back to `JOIN student_fees.status='paid'` produces the same intent: "sum of money that actually moved this month". The same pre-existing schema gap is why `payments.controller.js:250` (`UPDATE payments SET paid_at = ...`) errors at runtime — flagged for Phase 2 with the rest of the schema drift.
|
||
2. **financeAccess widened by one role** — spec said "Allowed: school_admin, systems_admin, principal, bursar, accountant, hr" for B2. Phase 0's `financeAccess` did not include `hr`. Added `hr` to the allowed list; this also unlocks Phase-1 dashboard tiles for HR users.
|
||
3. **B8 implemented** — spec said "optional, frontend can hardcode". Implemented anyway: it's static, harmless, and keeps the dropdown server-driven.
|
||
|
||
## Out of scope (left untouched)
|
||
|
||
- `client/*` — frontend-expert owns the 8 new pages in a follow-up step.
|
||
- The `/api/finance/payroll/*` proxy from Phase 0 (untouched).
|
||
- The `payments.status` / `payments.paid_at` schema drift (Phase 2 / migration tooling).
|
||
- Tests (no framework installed; out of scope per the plan).
|
||
|
||
## How to verify locally
|
||
|
||
```powershell
|
||
# Boot (after npm install + npm run db:init in the server/)
|
||
Start-Process node -ArgumentList 'src/index.js' `
|
||
-RedirectStandardOutput 'server.log' -RedirectStandardError 'server-err.log' `
|
||
-WindowStyle Hidden `
|
||
-WorkingDirectory '<worktree>\server'
|
||
|
||
# Login + probe
|
||
$body = @{ email='bursar@school.com'; password='bursar123' } | ConvertTo-Json
|
||
$resp = Invoke-RestMethod -Method Post -Uri 'http://localhost:3001/api/auth/login' -Body $body -ContentType 'application/json'
|
||
$headers = @{ Authorization = "Bearer $($resp.token)" }
|
||
|
||
Invoke-RestMethod 'http://localhost:3001/api/hr/dashboard/summary' -Headers $headers # B1
|
||
Invoke-RestMethod 'http://localhost:3001/api/finance/dashboard/summary' -Headers $headers # B2
|
||
Invoke-RestMethod 'http://localhost:3001/api/finance/expenses?limit=10' -Headers $headers # B3
|
||
```
|
||
|