# 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 '\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 ```