geocrop-platform./apps/nextgen/.harness/changelogs/2026-07-04.md

6.4 KiB
Raw Permalink Blame History

HR/Finance Phase 1 — Backend pre-step (B1B8)

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 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=

{
  "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

  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

# 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