# 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).
