6.0 KiB
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 includesdiscounts,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
- Group detail at
/finance/groups/:uidis 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. - No
student_fees.pdf_blobcolumn — invoice rendering stays HTML- printable (the §10a invoices page). PDF generation flagged for §11. - 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
paymentstable only).