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

6.0 KiB
Raw Blame History

Finance Phase 2b — Fee engine + student groups

Plan: .harness/plans/hr-finance-fiscalisation.md §10b Branch: feature/finance-phase-2b (worktree .worktrees/finance-phase-2b/) Owners: backend-expert (engine + apply) → frontend-expert (groups / plans / discounts management) → code-reviewer.

What landed

The fee engine that turns the §10a CRUD rows into actual parent-facing billing events. This is the load-bearing part of Phase 2: a plan or discount is just metadata until someone clicks "Apply".

Backend — server/src/controllers/finance-engine.controller.js

Mounted at /api/fees/engine. Endpoints:

Endpoint Notes
GET /api/fees/engine/groups list with member_count
GET /api/fees/engine/groups/:uid detail incl. members array
POST /api/fees/engine/groups create; respects class_id + auto_track_class to populate members automatically
PATCH /api/fees/engine/groups/:uid edit
DELETE /api/fees/engine/groups/:uid soft-delete the group + memberships
POST /api/fees/engine/groups/:uid/members bulk add by ids / class / all; UNIQUE collision = skip
DELETE /api/fees/engine/groups/:uid/members/:studentUid soft-delete a single membership
POST /api/fees/engine/plans/:uid/apply materialise student_fees rows: 1 row per (recipient × installment). Body: `{scope:'student'
POST /api/fees/engine/plans/:uid/revoke soft-delete every student_fees row whose plan_id matches and paid_amount = 0. Paid lines are kept.
POST /api/fees/engine/discounts/:uid/apply walk recipients per scope; insert / upsert student_fee_discounts rows; re-aggregate student_fees.discount_amount per affected fee line; defence-in-depth server cap defaults to 90% per line. Multiple discounts stack — caller orders.
POST /api/fees/engine/discounts/:uid/revoke soft-delete every student_fee_discounts row tied to the discount; re-aggregate each affected fee line.

Schema tweaks

Idempotent migration in init.js:

  • student_fees.plan_id INTEGER REFERENCES fee_plans(id)
  • student_fees.applied_by INTEGER REFERENCES users(id)
  • student_fees.academic_year TEXT

These allow revoke to find the rows by plan_id without a join, and let the operator see which academic year and which user materialised the fee line.

Sync contract

  • Every apply/revoke flips the touched rows' sync_status='pending'.
  • tablesToSync (§10a commit) already includes discounts, fee_plans, fee_plan_installments, student_groups, student_group_members, student_fee_discounts.

Frontend — 4 new pages + 1 new store slice

# Page file Route API used
1 client/src/pages/finance/StudentGroups.tsx /finance/groups useFinanceEngineStore.{fetchStudentGroups, createStudentGroup, deleteStudentGroup, addStudentGroupMembers}
2 client/src/pages/finance/StudentGroupDetail.tsx /finance/groups/:uid fetchStudentGroup, removeStudentGroupMember
3 client/src/pages/finance/FeePlans.tsx /finance/plans useFinanceStore.{fetchPlans, fetchPlan, ...} + useFinanceEngineStore.{applyFeePlan, revokeFeePlan}. Includes Apply-plan modal (class / group / student) and Revoke-confirm modal.
4 client/src/pages/finance/Discounts.tsx /finance/discounts useFinanceStore.{fetchDiscounts, ...} + useFinanceEngineStore.{applyDiscount, revokeDiscount}. Apply modal lets the operator override scope or cap_percent.

A new dedicated store slice — client/src/store/finance-engine.ts — keeps the engine-surface state separate from the operational core. This avoids further bloat of finance.ts and makes the engine actions easy to mock for tests later.

Wiring

  • client/src/App.tsx — 4 routes registered across school_admin / systems_admin / principal / bursar role guards; same allowedRoles set as §10a (accountant inherits through these arrays).
  • client/src/components/Nav.tsx — bursar block extended with Payment Plans / Discounts / Student Groups; accountant block likewise.
  • SCREENS.md — Bursar nav table widened; Accountant row extended.

Smoke

POST /api/fees/plans (3-installment Term 1 plan auto-splits)
  → uid b896…
POST /api/fees/engine/plans/:uid/apply
  body { scope:'student', scope_uid }
  → { scope:'student', recipients:1, installments_count:3, created:3, skipped:0 }
GET /api/fees/students?student_id=422
  → 3 fee rows, $300 each, due 2027-02-15 / 03-15 / 04-15

POST /api/fees/discounts (10% off, scope=all)
  → uid e69b…
POST /api/fees/engine/discounts/:uid/apply
  → { applied:1018, skipped:0, total_amount:11862.50, recipients:349 }
GET /api/fees/students?student_id=422
  → discount_amount:30 on every $300 row

POST /api/fees/engine/discounts/:uid/revoke
  → { removed:1018, re_aggregated:1018 }
  → discount_amount back to 0

Deviations from the plan

  1. Group detail at /finance/groups/:uid is a separate page from /finance/groups (the plan listed membership under the detail page). The listing page still has an "Add members" modal — clicking a row's eye icon navigates to the detail for member removal.
  2. No student_fees.pdf_blob column — invoice rendering stays HTML- printable (the §10a invoices page). PDF generation flagged for §11.
  3. Discount scope='student' server requires scope_id because the API used the same hard guard for all non-"all" scopes (matches the §10a CRUD). Future §11 polish: allow scope='student' to be applied without scope_id, inferring the recipient from the apply body.

Out of scope

  • Auto-apply discount on new class enrolments (flagged in §10b risks; deferred behind feature flag).
  • Burn-down reports for fee-collection period-over-period (§11).
  • PDF invoice generation (§11).
  • Migrate trip-receipts to bank reconciliation (§11; recon match in §10a currently scans payments table only).