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

