116 lines
6.0 KiB
Markdown
116 lines
6.0 KiB
Markdown
# 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).
|