01 — Product Overview (Fees Module)
- 1. Purpose
- 2. Business goals
- 3. User goals
- 4. Stakeholders
- 5. Why this exists
- 6. Dependencies
- 7. Success metrics
- 8. Edge cases (contract level)
- 9. Assumptions (module)
- 10. Open questions (module-grain; global ledger in 00-shared/12)
- 11. Glossary (this module)
StudyLyon — multi-tenant ERP / School Management API. This package designs the Fees module client (Flutter, forward-looking spec) against the implemented NestJS backend. All endpoints, DTO fields, schemas, domain events, queue routes, permissions, and wire contracts are derived directly from
src/modules/fees/**,src/modules/payments/**,src/modules/scheduler/**,src/infrastructure/bullmq/**,src/infrastructure/workers/finance.worker.ts,src/modules/rbac/permissions.constants.ts,studylyon-blueprint/04-Modules/Fees.md, anddocs/IMPLEMENTATION_PLAN.md. No feature is invented — anything not present in source is flagged(planned)/(proposed)/(forward-looking)in Assumptions & Open Questions.
Heads-up: per the PRD, the mobile client is out of Phase 1; this package is the forward-looking spec the client will be built against later. Everything below is best-effort UI design on top of the current backend surface.
1. Purpose
Fees is the money lifecycle of a school: an admin defines a fee structure per
class + academic term (line items such as tuition/lab/transport, a total, currency,
due date, optional late fee); invoices are generated per student; payments are
recorded against invoices (offline cash/mobile-money via the fees module,
online gateway capture via the payments module); the system tracks dues
(outstanding balances) and — via the scheduler — flags overdue invoices and
enqueues payment reminders. Money rules come from
studylyon-blueprint/04-Modules/Fees.md; the code is the source of truth.
| Responsibility | Source |
|---|---|
| Fee structure CRUD (create/list/get/patch/soft-delete) | fees.controller.ts:28-56, fees.service.ts:42-98 |
| Generate one invoice per (student, feeStructure, academicYear) | fees.service.ts:101-140, unique index invoice.schema.ts:58-60 |
| List a student's invoices | fees.service.ts:142-144, invoice.repository.ts:17-21 |
Record a payment idempotently (idempotencyKey unique) | fees.service.ts:147-188, payment.schema.ts:40-41 |
Track dues (ISSUED/PARTIAL/OVERDUE, due = total − paid) | fees.service.ts:190-221 |
| Online payment capture + refund + reconcile + receipts | payments.controller.ts:24-73, payments.service.ts:31-217 |
Overdue detection (OVERDUE status) | finance.worker.ts:75-91 |
Reminder scan (due within 3 days) → payment-reminder queue | fee-reminder.job.ts:16-46 |
| Repeatable scheduler jobs (daily cron) | scheduler.service.ts:48-119 |
| Tenant scoping + soft-delete on every query | base.repository.ts:20-30 |
2. Business goals
| Goal | Measure |
|---|---|
| No invoice duplicates | unique index {tenantId, studentId, feeStructureId, academicYearId} + service 409 (invoice.schema.ts:58-60, fees.service.ts:107-113) |
| No double-counted payments | unique idempotencyKey; replay returns existing payment (payment.schema.ts:40-41, fees.service.ts:148-152) |
| Dues always computable | due = totalAmount − paidAmount per invoice (fees.service.ts:213-216) |
| Cross-tenant isolation | every query tenant-scoped via BaseRepository.scopedFilter (base.repository.ts:20-30) |
| Audit + notification trail | FeeStructureCreated / InvoiceIssued / PaymentCompleted events → BullMQ (event-queue-map.ts:40-42) |
3. User goals
- Admin / bursar (accounts): define a term's fee structure once; generate invoices per student; see outstanding dues per invoice.
- Accountant (cashier): collect cash/mobile-money against an invoice and post a payment; never double-post (idempotency key).
- Parent / payer: see child's invoices and dues; pay online through the payments module; read receipts.
- Org admin: financial oversight (dues report, payment history).
4. Stakeholders
Org admin, bursar/accounts staff, cashier (Accountant role), parents, students, the notification/email pipeline, the scheduler (overdue + reminders), audit/compliance (event log), QA + design + engineering.
5. Why this exists
Fee collection is a money path: the backend already enforces the important
invariants (one invoice per term, idempotent payments, soft-deleted structures,
tenant isolation). The Client's job is to present balances and payment statuses
authoritatively, never guess server state, and treat every write (payment, invoice
generation, waiver (planned)) as server-confirmed.
6. Dependencies
| Dependency | Role | Source |
|---|---|---|
| Payments module | Online capture (gateway), refund, reconcile, receipts | payments.controller.ts:21-73, payments_v2 schema |
| Notifications/emails | InvoiceIssued → send-invoice; PaymentCompleted → send-receipt | event-queue-map.ts:41-42 |
| Scheduler | overdue scan + fee-reminder crons | scheduler.service.ts:48-97 |
BullMQ invoice-generate | FinanceWorker (overdue marking; event handling) | finance.worker.ts:16-91 |
BullMQ payment-reminder | reminder fan-out jobs | fee-reminder.job.ts:26-39 |
| Collections | fee_structures, invoices, payments, payments_v2, receipts | *-schema.ts |
7. Success metrics
- Invoice generation (single student) round-trip < 2 s (sync path today).
- Duplicate-invoice attempt handled as 409 100% of the time (never a crash).
- Payment replay with same
idempotencyKeyreturns the original payment (no dup). - Zero cross-tenant leaks in any fees list/detail (
BaseRepositoryscope). - Dues balance renders exact:
totalAmount − paidAmountfrom server, not summed on the client.
8. Edge cases (contract level)
- Duplicate invoice (same student+structure+year) → 409
DUPLICATE_RESOURCE"Invoice already exists for this student and term." (fees.service.ts:107-113)- unique index.
- Pay on PAID/CANCELLED invoice → 409
DUPLICATE_RESOURCE"Invoice is already paid or cancelled." (fees.service.ts:155-160). - Unknown structure / invoice → 404
RESOURCE_NOT_FOUND(fees.service.ts:71-77,101-106). - Invalid Mongo id →
CastError→ 400VALIDATION_ERROR"Invalid resource identifier." (http-exception.filter.ts:47-48,91-95). - Delete structure is a soft delete (
softDelete,base.repository.ts:68-74); invoices are not cascaded. - Discounts reduce the invoice total (
totalAmount = max(0, structureTotal − Σdisc),fees.service.ts:117-119) — invoices total never below 0; the discount is snapshotted on the invoice asdiscounts[]. - Overpayment has no cap server-side —
@Min(0)only (record-payment.dto.ts:19);paidTotal >= totalAmountis what flips status to PAID (fees.service.ts:169-172). - Human currency is not integer-minor-enforced in code — blueprint says minor units
(Fees.md:63) but DTOs/schemas store plain
Number(fee-structure.schema.ts:19-28); the client must format/dealMinor and the server should floor (gap, OQ-1).
9. Assumptions (module)
- PRD: mobile client out of Phase 1. This design is a forward-looking client-side spec; the backend is the contracts authority and the scheduler/late-fee surface is partially wired (see OQ-6/OQ-7).
- Money appears as plain numbers (
Number) in every schema; the blueprint's "integer minor units" rule (Fees.md:63) is not enforced in code — treat server values as decimal units unless a conversion is established (OQ-1). - Payment posting uses an idempotency key on the fees side
(
record-payment.dto.ts:31-34) but the online payments module instead uses a server-generatedtransactionReference(payments.service.ts:215-217) — two idempotency models. InvoiceIssued/PaymentCompleted→emailsqueue (event-queue-map.ts:41,42) → email worker; the FinanceWorker also handles these event types oninvoice-generate(finance.worker.ts:49-68) but no. event is routed to that queue by the map — that branch is effectively idle today (OQ-5).- Reminder + overdue are partially wired: repeatable cron jobs are registered
(
scheduler.service.ts:48-97) butOverdueScanJob.execute/FeeReminderJob.executeare not invoked anywhere, and no worker listens onpayment-reminder(OQ-6). - Late fees KML:
FeeStructure.lateFeeexists (fee-structure.schema.ts:33-34) but nothing consumes it — no late-fee application in code (OQ-7). - Fees endpoints are guarded only by
JwtAuthGuard(fees.controller.ts:24) — no@Permissions()metadata; onlyfees.collectexists inpermissions.constants.ts:31and default Accountant role has only['fees.collect', 'student.read'](role.schema.ts:42-48). Real RBAC is(planned)(OQ-9).
10. Open questions (module-grain; global ledger in 00-shared/12)
| # | Item | Impact |
|---|---|---|
| OQ-1 | Money is plain Number in schemas; blueprint says minor units — no rounding guarantee | Display/format strategy; currency decimals |
| OQ-2 | Overpayment allowed (no cap) — paidTotal >= total flips PAID; excess is never refunded into fees UI | Overpay UX; catch-up ledger values |
| OQ-3 | GET /fees/dues returns only ISSUED/PARTIAL/OUVERDUE; no per-student dues endpoint; q and filtering of structured reports (planned); GET /fees/reports in Fees.md:31 removed | Report UI can only sum locally |
| OQ-4 | Payments module is a separate payments_v2 stack keyed on transactionReference, linked via invoiceId — two parallel collections | Which data feeds a "payments history" screen |
| OQ-5 | Two sumByInvoice implementations — fees sums all statuses, payments sums only completed (fees/repositories/payment.repository.ts:23-35 vs payments/repositories/payment.repository.ts:25-37) — refunded/failed could skew paidAmount | Reconciliation display |
| OQ-6 | Overdue/reminder jobs registered but their .execute() is never called; no payment-reminder worker | Reminder + overdue status "(planned)" |
| OQ-7 | Late fees: field exists, behavior absent | "Late fee" UI deferred |
| OQ-8 | Invoice generation is synchronous today; INVOICE_GENERATE queue + FinanceWorker exist but generation never enqueues | Bulk generation later (planned) |
| OQ-9 | RBAC: no @Permissions on fees endpoints; only fees.collect (+ payments.*, receipts.read) are in ALL_PERMISSIONS | Role-gated UI waits for perms |
| OQ-10 | Receipt print/share has no endpoint — receipt is a stored doc (receipt.schema.ts:9-40); print is client-side representation | Print/share receipt (proposed) |
11. Glossary (this module)
| Term | Meaning |
|---|---|
| Fee structure | fee_structures doc: per class+term, line-item items[], totalAmount, currency, dueDate, lateFee, isActive (fee-structure.schema.ts:8-38) |
| Invoice | invoices doc: snapshot of structure total + discounts, per student, status draft/issued/partial/paid/overdue/cancelled (invoice.schema.ts:7-51) |
| Payment (fees) | payments doc: amount against invoice, idempotencyKey unique, always completed today (payment.schema.ts:22-54) |
| Payment (payments module) | payments_v2 doc: gateway capture, transactionReference, status incl. partially_refunded (payments/schema/payment.schema.ts:28-77) |
| Receipt | receipts doc with receiptNumber RCP-{ts}-{n} (payments.service.ts:180-198) |
| Dues | outstanding invoice: due = totalAmount − paidAmount (fees.service.ts:213-216) |
| Envelope | {success, message, data, meta?, timestamp, requestId} (response-envelope.interceptor.ts:45-61) |