20 KiB
HR-Finance Fiscalisation — Phase Plan
Plan for the integration of the HR Manager and Bursar (Finance) portals. Tracks all phases from proxy stabilisation through dashboard wiring.
Status legend: ✅ landed on dev · 🔄 in flight · ⬜ not started
Branch model: dev is the integration branch; feature branches off dev, merge back to dev after code-reviewer PASS.
Phase 1 status: ✅ §3a (Sprint 1a) merged to dev at 79c1482. ✅ §3b (Sprint 1b) merged to dev at 91261cd.
Phase 2 status: 🔄 §10a Sprint 2a in flight on feature/finance-phase-2 (operational core). §10b (fee engine) and §10c (UI + portal wiring) scoped below.
§0 — Conventions
- All work in a worktree branched from
dev. No direct edits in main checkout. - Frontend page template (5-file checklist, see
.harness/docs/conventions.md):client/src/store/<module>.ts(Zustand slice wrapping the shared axiosapi)client/src/pages/<area>/<Page>.tsx(the page component)- Route added in
client/src/App.tsxgetRoutes()with<ProtectedRoute allowedRoles> - Nav entry added in
client/src/components/Nav.tsxNAV_CONFIG(per role) - Changelog entry in
.harness/changelogs/YYYY-MM-DD.md
- Backend template (see
IMPLEMENTATION_SUMMARY.md§1): inlineauthmiddleware, FK pragma on, all writes setsync_status='pending'. - Sync contract: every SQL table has
last_synced_at,sync_status DEFAULT 'pending',is_deleted DEFAULT 0,uid TEXT UNIQUE. Tables added totablesToSyncinSyncEngine.js(FK dependency order, parents first). - Roles (active UI):
school_admin,systems_admin,principal,hr,bursar,teacher,student,parent,librarian,clubs_head, accountant (now active in §10). Demo accounts inserver/src/database/init.js.
§3 — Phase 1: HR + Finance Portal UIs
See above — fully landed.
§10 — Phase 2: Operational Finance + Fees Engine
Goal: Turn the Bursar/Accountant role from "looks at expense receipts" into a real back-office. Build:
- Operational core — formal invoices, suppliers/vendors, chart of accounts, bank accounts + reconciliation, school trips + trip payments.
- Fees engine — student groups, payment plans with installments, discounts (per-student or per-group), automatic fee-line generation.
- Portal + views — Accountant + Bursar back-office pages; Student + Parent portal pages that surface the same data (their own fees, installments, discounts, trip bills).
Branch model: one branch per sub-sprint, fast-forwarded into dev between sub-sprints.
| Sub-sprint | Branch | Goal |
|---|---|---|
| §10a | feature/finance-phase-2 (2a slice) |
Schema for everything + operational controllers (invoices, suppliers, chart of accounts, banking, trips, trip_payments) + tax-friendly CRUD UI for the operational core |
| §10b | feature/finance-phase-2b |
Fee engine: student_groups + student_group_members; fee_plans + fee_plan_installments; discounts; auto-generate student_fees from a plan; apply plan/discount to a student or group |
| §10c | feature/finance-phase-2c |
Accountant + Bursar portal pages; Student + Parent fees page rewrite to show installments + discounts + trip bills; route + nav wiring; SCREENS.md refresh |
Each sub-sprint gets its own 5-file-changeset; promote to dev after build-green.
RBAC additions
| Endpoint family | Bursar | Accountant | Principal/Admin | Student/Parent |
|---|---|---|---|---|
/api/fees/groups, /api/fees/plans, /api/fees/discounts, /api/fees/groups/students/* |
CRUD | CRUD | CRUD | read-self |
/api/fees/students (list of student_fees) |
read-all | read-all | read-all | read-self / read-children |
/api/finance/invoices |
CRUD | CRUD | CRUD | read-self |
/api/finance/suppliers |
CRUD | CRUD | CRUD | — |
/api/finance/chart-of-accounts |
CRUD | CRUD | CRUD | — |
/api/finance/banking/accounts + /banking/reconciliations |
CRUD | CRUD | CRUD | — |
/api/finance/trips + /finance/trips/:id/students |
CRUD | CRUD | CRUD | read-self (own trips) |
/api/fees/invoices per-student |
yes | yes | yes | yes |
Accountant has the same operational write access as Bursar but does not own payroll — payroll stays HR-only (Phase 0 decision). Accountant focuses on fees/billing/invoices/reconciliation.
New demo accounts
| Password | Role | Phase 2 access | |
|---|---|---|---|
accountant@school.com |
accountant123 |
accountant |
All /api/fees/*, /api/finance/{invoices,suppliers,chart-of-accounts,banking,trips}/* |
Out of scope here (deferred to §11)
- ZIMRA FDMS / fiscalisation (Phase 3 — gated on test creds).
- Double-entry book-keeping engine (we track nominal accounts only; full GL postings deferred).
- Invoice PDF generation (we render printable HTML — flag a tiny PDF lib as a stretch goal in §11).
- Mobile-money autopay integrations beyond Paynow (no plans in repo yet).
- Cross-portal reporting (
/reportsenrichment) — Phase 2 was already deferred in §9. - Accountant / Librarian / Nurse dashboard rest-of-modules (Librarian already has books/issues/fines; this only adds the back-office half the user asked for).
Acceptance tests (Phase 2)
- Logged in as
accountant@school.com / accountant123,/finance/invoiceslists every student invoice with status + total + paid. - Logged in as
bursar@school.com,/finance/invoices/:uidshows the printable view that can be sent to a parent. - Logged in as
bursar@school.com,/finance/tripsshows trips with per-student cost and a "Mark student paid" button (POST /finance/trips/:id/students/:studentId/pay). - Logged in as
bursar@school.com,/finance/banking/reconciliationsshows this period's reconciliation status with unmatched-count tile. - Logged in as
accountant@school.com,/finance/supplierslists suppliers with active-accounts count and bulk-payment-shortcut (links to invoice creation). - Logged in as
bursar@school.com,/finance/plans/newaccepts "3 equal installments starting 2027-02-15" and POSTsfee_plan_installmentsrows; existingstudent_feesstays untouched (plans are templates — apply step in §10b). - Logged in as
parent@school.com,/parent/feesshows the child's fee lines grouped byfee_groupwith paid/outstanding totals and a Payment button. - Logged in as
student@school.com,/student/feeslists the student's own fees; for teacher logins the route falls back to/fees. - All new pages pass
npm run build, noalert()/window.prompt()in this code path. - Sync contract: every write goes through with
sync_status='pending'.tablesToSyncextended (see §10a).
Tables to add — full Phase 2 list (all 12)
Schema source of truth: server/src/database/init.js (idempotent CREATE TABLE IF NOT EXISTS + matching idempotent ALTER in the migrations array).
| Group | Table | Notes |
|---|---|---|
| Operational | suppliers |
vendors for ops expenses; uid, name, contact, email, address, tax_id, status (active/dormant) |
| Operational | chart_of_accounts |
accounting categories; uid, code, name, type (asset/liability/equity/revenue/expense), parent_id self-FK nullable, color (for tree UI) |
| Operational | bank_accounts |
uid, name, bank_name, account_number (last4 only), currency (USD/ZIG), balance REAL, is_active |
| Operational | bank_reconciliations |
uid, bank_account_id FK, period_start, period_end, statement_balance, book_balance, status (draft/balanced/discrepancy), reconciled_at, reconciled_by FK users |
| Operational | bank_reconciliation_lines |
uid, reconciliation_id FK, payment_id FK payments, statement_amount, match_status (matched/unmatched/ignored), notes |
| Operational | invoices |
uid, invoice_number UNIQUE, student_id FK users, fee_group_id FK nullable, plan_id FK nullable, discount_id FK nullable, subtotal, discount_amount, total, paid_amount, currency, status (draft/issued/paid/overdue/cancelled), issued_at, due_date, notes |
| Operational | trips |
uid, name, destination, departure_date, return_date, cost_per_student REAL, total_capacity, status (draft/published/closed/completed/cancelled), description |
| Operational | trip_payments |
uid, trip_id FK, student_id FK users, invoiced_amount, paid_amount, status (pending/partial/paid/refunded); UNIQUE (trip_id, student_id) |
| Fees engine | student_groups |
uid, name, description, type (class/trip/hostel/transport/club/custom), color, class_id nullable FK, capacity (nullable); class_id makes a group auto-track class enrollments |
| Fees engine | student_group_members |
uid, group_id FK, student_id FK users, joined_at; UNIQUE (group_id, student_id) |
| Fees engine | fee_plans |
uid, name, fee_group_id FK, total_amount, installments_count, start_date, end_date, academic_year, auto_apply_to_class_id nullable FK, notes |
| Fees engine | fee_plan_installments |
uid, fee_plan_id FK, sequence INT, label, amount, due_date |
| Fees engine | discounts |
uid, name, kind ('percent' |
Reuse-not-rebuild: student_fees, fee_groups, payments, hostels, vehicles, routes, transport_allocations already exist. We link extras (hostel, transport) by reusing fee_groups.type (hostel / transport) and student_fees rows.
Sync engine additions
Append to tablesToSync in server/src/services/SyncEngine.js (in FK dependency order):
suppliers, chart_of_accounts, bank_accounts, bank_reconciliations, bank_reconciliation_lines,
invoices, trips, trip_payments,
student_groups, student_group_members,
fee_plans, fee_plan_installments,
discounts
12 new sync-aware tables, parent-first order respected.
§10a — Sprint 2a: Operational core
Goal: Land schema for the full Phase 2 set + operational controllers + back-office UI for invoices/suppliers/chart-of-accounts/banking/trips.
Branch: feature/finance-phase-2 (worktree at .worktrees/finance-phase-2/).
Reins involved: backend-expert (schema + controllers) → frontend-expert (operational pages) → code-reviewer (gate).
Backend (in this branch's first commit(s))
| # | Endpoint | File | Notes |
|---|---|---|---|
| C1 | GET/POST/PATCH/DELETE /api/finance/suppliers |
new finance-suppliers.controller.js |
CRUD; soft-delete. |
| C2 | GET/POST/PATCH/DELETE /api/finance/chart-of-accounts |
new finance-coa.controller.js |
Tree-aware: GET supports ?parent_id= and a flat-list ?flat=true. PATCH for reorder. |
| C3 | GET/POST/PATCH /api/finance/banking/accounts |
new finance-banking.controller.js |
Per-account balance updates via receipt. |
| C4 | POST /api/finance/banking/accounts/:id/reconcile |
same | Body: statement lines (date, amount, ref). Server matches against unmatched payments rows by reference + amount. Returns matched/unmatched counts. |
| C5 | GET /api/finance/banking/reconciliations + GET /:uid + POST /:uid/resolve |
same | Reconciliation session; resolve transitions a line to "ignored". |
| C6 | GET/POST/PATCH /api/finance/invoices |
new finance-invoices.controller.js |
List (paginated, status/student filters), detail (lines joined), POST creates from a fee_group_id or plan_id, PATCH updates notes/status (no direct paid-totals edit; those update via payment webhooks). |
| C7 | POST /api/finance/invoices/:uid/send |
same | No-op mailer (logs and returns 200) — wire SMTP later. |
| C8 | GET/POST/PATCH /api/finance/trips |
new finance-trips.controller.js |
CRUD on trips. |
| C9 | GET/POST /api/finance/trips/:id/students |
same | Assign students to a trip (bulk, by class or manual). |
| C10 | POST /api/finance/trips/:id/students/:studentId/pay |
same | Records trip_payment with status paid (or partial if partial amount). |
| C11 | GET/POST/PATCH/DELETE /api/fees/discounts (pre-step for §10b, but schema-only here) |
extend fees.controller.js |
List/create/update/soft-delete; scope enforcement (server resolves scope_id). |
| C12 | GET/POST/PATCH /api/fees/plans + /plans/:uid/installments |
extend fees.controller.js |
CRUD on plans + nested installments. |
Frontend (in this branch's second commit)
| # | Page file | Route | Allowed roles | Backing API |
|---|---|---|---|---|
| 1 | client/src/pages/finance/Invoices.tsx |
/finance/invoices |
bursar/accountant/principal/admin | GET /api/finance/invoices |
| 2 | client/src/pages/finance/InvoiceView.tsx |
/finance/invoices/:uid |
bursar/accountant/principal/admin | GET /api/finance/invoices/:uid |
| 3 | client/src/pages/finance/Suppliers.tsx |
/finance/suppliers |
bursar/accountant/principal/admin | GET /api/finance/suppliers |
| 4 | client/src/pages/finance/ChartOfAccounts.tsx |
/finance/chart-of-accounts |
bursar/accountant/principal/admin | GET /api/finance/chart-of-accounts |
| 5 | client/src/pages/finance/BankingAccounts.tsx |
/finance/banking |
bursar/accountant/principal/admin | GET /api/finance/banking/accounts + reconciliations |
| 6 | client/src/pages/finance/BankingReconcile.tsx |
/finance/banking/reconciliations/:uid |
bursar/accountant/principal/admin | GET /api/finance/banking/reconciliations/:uid |
| 7 | client/src/pages/finance/Trips.tsx |
/finance/trips |
bursar/accountant/principal/admin/principal | GET /api/finance/trips |
| 8 | client/src/pages/finance/TripDetail.tsx |
/finance/trips/:id |
bursar/accountant/principal/admin | GET /api/finance/trips/:id + assign students |
Out of scope here
- Plan apply endpoint (
POST /api/fees/plans/:uid/apply) — §10b. - Group membership CRUD UI — §10b (we only seed groups + members here).
- Student/Parent view refresh — §10c.
§10b — Sprint 2b: Fee engine
Goal: Student groups (with auto-class sync), fee plans, installments, discounts, plan-apply endpoint (auto-generates student_fees from a plan).
Branch: feature/finance-phase-2b (worktree at .worktrees/finance-phase-2b/).
Backend
| # | Endpoint | File | Notes |
|---|---|---|---|
| D1 | GET/POST/PATCH/DELETE /api/fees/groups |
new fees-groups.controller.js OR append to fees.controller.js |
CRUD. type enforces (tuition/transport/hostel/exam/library/activity/other). |
| D2 | GET/POST/DELETE /api/fees/groups/:uid/members |
same | Bulk add (class_id shortcut → expands to all enrolled students). |
| D3 | POST /api/fees/plans/:uid/apply |
extend fees.controller.js |
Body: `{scope: 'student' |
| D4 | POST /api/fees/discounts/:uid/apply |
same | Body: {scope, id, academic_year?}. Walks recipients (scope=student: that student; scope=group: members; scope=fee_group: open student_fees for that fee_group in the academic year; scope=all: every active student). For each recipient, sets student_fees.discount_amount = computed (capped at fee amount; non-negative; idempotent per (student_fee, discount)). Discriminator stored in a student_fee_discounts join (NEW table) so a single fee can carry multiple discounts. |
| D5 | DELETE /api/fees/discounts/:uid/revoke |
same | Removes join rows + reverts student_fees.discount_amount. |
| D6 | new student_fee_discounts join table |
init.js | uid, student_fee_id FK, discount_id FK, amount_applied, applied_at, applied_by FK users; UNIQUE (student_fee_id, discount_id) |
Frontend
| # | Page file | Route | Allowed roles | Backing API |
|---|---|---|---|---|
| 9 | client/src/pages/finance/StudentGroups.tsx |
/finance/groups |
bursar/accountant/principal/admin | GET /api/fees/groups (a small note in plan explains naming collision — server route is /api/fees/groups) |
| 10 | client/src/pages/finance/StudentGroupDetail.tsx |
/finance/groups/:uid |
bursar/accountant/principal/admin | GET /api/fees/groups/:uid (extended to include members) |
| 11 | client/src/pages/finance/FeePlans.tsx |
/finance/plans |
bursar/accountant/principal/admin | GET /api/fees/plans |
| 12 | client/src/pages/finance/FeePlanDetail.tsx |
/finance/plans/:uid |
bursar/accountant/principal/admin | GET /api/fees/plans/:uid + POST /plans/:uid/apply |
| 13 | client/src/pages/finance/Discounts.tsx |
/finance/discounts |
bursar/accountant/principal/admin | GET /api/fees/discounts + apply/revoke |
Out of scope here
- Student-fee generation UX (the modal lives on the plan detail; new fee lines from a single click).
- Auto-apply to all new class joiners (nice-to-have; defer —
code-reviewerwould push back on this without a feature flag).
§10c — Sprint 2c: Portal + Student/Parent view
Goal: Accountant dashboard + Bursar dashboard tiles for trips / banking / invoices / groups; Student + Parent view surfaces installments + discounts + trip payments; final route + nav wiring.
Backend additions
GET /api/finance/dashboard/summary(already exists, §10c enriches the tiles to include pending reconciliation, overdue invoices, upcoming trips, plan install-due-soon count).GET /api/students/:uid/fees(helper) — joinsstudent_fees+fee_groups+ active discounts + active trip_payments for a single student. RBAC: admin/bursar/accountant/principal can fetch any; parent can fetch children; student can fetch self.
Frontend
| # | Page file | Route | Allowed roles | Backing API |
|---|---|---|---|---|
| 14 | rewrite client/src/pages/dashboard/FinanceDashboard.tsx |
/dashboard/finance |
bursar/accountant/principal/admin | enriched summary + new tiles |
| 15 | client/src/pages/dashboard/AccountantDashboard.tsx |
/dashboard/accountant |
accountant/bursar/principal/admin | lighter mirror of Finance dashboard with fee-engine focus |
| 16 | rewrite client/src/pages/parent/Fees.tsx |
/parent/fees |
parent | /api/fees/students?student_id=... + new /students/:uid/fees helper |
| 17 | client/src/pages/student/Fees.tsx |
/student/fees |
student | /api/fees/students + new helper |
Route + nav wiring
- Add
/dashboard/accountant,/finance/{invoices, suppliers, chart-of-accounts, banking, trips, groups, plans, discounts}toNAV_CONFIGforbursar(most) andaccountant(all). - Add
/finance/invoices/:uid,/finance/banking/reconciliations/:uid,/finance/groups/:uid,/finance/plans/:uid,/finance/trips/:idforbursar/accountant. - Add
/student/feesto thestudentnav block.
SCREENS.md
Refresh the Bursar + Accountant + Student + Parent tables with the new entries.
§6 — Phase 0 (landed)
feature/stabilise-financefromdevat75b74e7— landed ondevat030288c. Code-reviewer PASS. De-duplicated payroll routes (HR controller is now source of truth;/api/finance/payroll/*proxies to/api/hr/payroll-runsand/api/hr/payslips). Fixedclient/src/store/finance.ts(HR-aligned, typed, no/api/prefix). Dropped the deadexpensesslice. Allowedbursar/accountanton HR payroll routes. Seededbursar@school.com / bursar123.
§9 — Phase 2+ (deferred / covered in §10)
§10 absorbed all the formerly-§9 items the user asked for (cross-portal fee plumbing, paynow reconciliation scope, accountant dashboard skeleton). Remaining out-of-scope for Phase 2:
- Cross-portal reporting joins across expenses + payroll + fees (
/reportsenrichment) — pull into §11 once §10c lands and real data exists. - Test framework + CI —
.harness/AGENTS.mdopen follow-up —testerrein. - Phase 3 fiscalisation (ZIMRA FDMS) — gated on test creds.
- Full double-entry GL (chart of accounts exists in §10a as a nominal lookup; journal entries and trial balance deferred).
§11 — Stretch / post-Phase-2 (next sessions)
- PDF invoice rendering (lightweight; consider
pdfkitor server-side print stylesheet). - Paynow webhook extension to mark
trip_paymentspaid. - Burn-down reports for fee-collection period over period.
- Sibling discount + bursary tiers (sit on top of the
discountstable; defer until §10b is in user hands). - Bulk-import-from-Excel for student_groups + chart_of_accounts.