6.4 KiB
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
{
"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
{
"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 monthoutstanding_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=
{
"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
{
"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
["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
- B2 collected_mtd filter — the spec called for
SUM(amount) FROM payments WHERE status='completed'. Thepaymentstable has nostatuscolumn (perserver/src/database/init.js:187). Falling back toJOIN student_fees.status='paid'produces the same intent: "sum of money that actually moved this month". The same pre-existing schema gap is whypayments.controller.js:250(UPDATE payments SET paid_at = ...) errors at runtime — flagged for Phase 2 with the rest of the schema drift. - financeAccess widened by one role — spec said "Allowed: school_admin, systems_admin, principal, bursar, accountant, hr" for B2. Phase 0's
financeAccessdid not includehr. Addedhrto the allowed list; this also unlocks Phase-1 dashboard tiles for HR users. - 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_atschema drift (Phase 2 / migration tooling). - Tests (no framework installed; out of scope per the plan).
How to verify locally
# 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