# 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'|'group'|'class', scope_uid, academic_year?, due_date_offset_days?}`. Idempotent: matches `(student_id, plan_id, due_date)` so re-running is safe. Wrapped in `db.transaction()`. | | `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).