03 — User Journey (Fees Module)
- 1. Journey: Term kickoff — define fee structures
- 2. Journey: Issue an invoice to a student
- 3. Journey: Collect a payment at the till (fees module)
- 4. Journey: Pay online (payments module) (forward-looking)
- 5. Journey: Reminder + overdue lifecycle
(planned) - 6. Journey: Reconciliation (support)
- 7. Journey: Report on dues (admin)
(proposed)
End-to-end journeys mapped to the implemented API. Every step cites the exact endpoint + rule source. Async/reminder steps marked
(planned)where the worker wiring is incomplete (OQ-6/OQ-7 in 01).
1. Journey: Term kickoff — define fee structures
- Admin opens Fee Structures list →
GET /fees/structures?page&limit(fees.controller.ts:34-38), paginated,meta={page, limit, totalItems, totalPages, hasNext, hasPrevious}(pagination-query.dto.ts:32-55). - Create structure →
POST /fees/structures(fees.controller.ts:28-32) body{name, classId, academicYearId, items[], totalAmount, currency?, dueDate, lateFee?, isActive?}(create-fee-structure.dto.ts:26-68). Server stampsdueDateDate, defaultscurrency='XAF',lateFee=0,isActive=true(fee-structure.schema.ts:27-37). - Server emits
FeeStructureCreated→in-appqueue jobfee-structure-created(fees.service.ts:49-56,event-queue-map.ts:40). - List refreshes (200 envelope, doc incl.
_id, tenantId, createdAt…fromBaseSchema). - (planned) Duplicate a structure for the next class →
POST /fees/structures/:id/duplicate(IMPLEMENTATION_PLAN.md:205).
2. Journey: Issue an invoice to a student
- From a structure detail, admin picks a student →
POST /fees/invoices/generate(fees.controller.ts:58-62) body{studentId, feeStructureId, academicYearId, discounts?}(generate-invoice.dto.ts:24-42). - Server: 404 if structure missing; 409 if an invoice already exists for
(student, structure, year) ("Invoice already exists for this student and
term.") —
fees.service.ts:101-115; unique index backs it (invoice.schema.ts:58-60). totalAmount = max(0, structure.totalAmount − Σ discounts); statusissued,issuedAt=now,dueDatecopied from structure (fees.service.ts:117-130).InvoiceIssuedevent →emailsqueue jobsend-invoice(fees.service.ts:131-138,event-queue-map.ts:41).- Parent sees it under
GET /fees/students/:studentId/invoices(fees.controller.ts:64-68). - (planned) Bulk/async generation for a whole class →
INVOICE_GENERATE(queue.constants.ts:8) + FinanceWorker exist (finance.worker.ts:16-91) butgenerateInvoiceruns synchronously today (OQ-8).
3. Journey: Collect a payment at the till (fees module)
- Cashier opens Dues →
GET /fees/dues?page&limit— only statusissued|partial|overdue(fees.service.ts:190-212); each rowdue = totalAmount − paidAmount(fees.service.ts:213-216). - Taps "Record payment" →
POST /fees/invoices/:id/payments(fees.controller.ts:70-74) body{invoiceId, amount, paymentMethod, reference?, idempotencyKey, paidAt, notes?}(record-payment.dto.ts:11-44). - Server: replay with same key → returns existing payment, no double-count
(
fees.service.ts:148-152); invoice PAID/CANCELLED → 409 (fees.service.ts:155-160). - Status recomputed:
paidTotal >= total → paidelsepartial(fees.service.ts:168-176);PaymentCompleted→emails/send-receipt(fees.service.ts:178-185,event-queue-map.ts:42). - Dues row updates (or disappears when paid).
4. Journey: Pay online (payments module) (forward-looking)
- Parent taps "Pay" on an invoice →
POST /payments(payments.controller.ts:24-28) body{amount, currency?, gateway, gatewayTransactionId?, invoiceId?, invoiceType?, payerId?, payerEmail?, payerName?, description?}(process-payment.dto.ts:5-54). - Server:
transactionReference = TXN-{ts}-{uuid8}(payments.service.ts:215-217), payment stored inpayments_v2withstatus: completedby default (payments.service.ts:36-49). - Receipt auto-created
RCP-{ts}-{n}(payments.service.ts:180-198). - If
invoiceIdgiven →linkToInvoicerecomputes invoicepaidAmount+ status (payments.service.ts:200-213). - Gateway async path:
PATCH /payments/:transactionRef/reconcilebody{status:'success'|'failed'|…}→ completed/failed/pending (payments.controller.ts:36-43,payments.service.ts:113-134). - Refund →
POST /payments/refund(only completed; partial allowed) (payments.controller.ts:30-34,payments.service.ts:74-111). - Receipts read via
GET /payments/receipts[:id](payments.controller.ts:63-73).
5. Journey: Reminder + overdue lifecycle (planned)
- Daily
fee-remindercron0 8 * * *UTC onpayment-reminderqueue (scheduler.service.ts:91-97). FeeReminderJobscansissued|partialinvoices withnow ≤ dueDate ≤ now+3d, enqueuessend-payment-reminderjobs (fee-reminder.job.ts:16-42).- Daily
overdue-scancron0 6 * * *UTC oninvoice-generate(scheduler.service.ts:48-55);FinanceWorkermarksissued|partialinvoices past due asoverdue(finance.worker.ts:75-91). - Status today: crons registered, but
.execute()is never called and no worker listens onpayment-reminder→ deliveries/overdue marking not active (OQ-6). UI must renderoverduechips when the status arrives, and treat reminders as absent until wired.
6. Journey: Reconciliation (support)
GET /payments/invoice/:invoiceId(payments.controller.ts:57-61) vsGET /fees/students/:studentId/invoices— note the two payment collections (feespaymentsvspayments_v2) and two sumByInvoice implementations (fees sums all statusesfees/repositories/payment.repository.ts:23-35, payments sums onlycompletedpayments/repositories/payment.repository.ts:25-37) — OQ-5.- Mismatch handling: mark refunds/partials on the v2 side; invoice
paidAmountrecomputed on next post (linkToInvoice).
7. Journey: Report on dues (admin) (proposed)
- No dedicated report endpoint —
GET /fees/reportsfrom Fees.md:31 is not in the controller (fees.controller.ts:1-81). Client sums the paginated dues screen client-side today; a real report is(planned)(IMPLEMENTATION_PLAN.md:230"Reports";:211per-student statement). - Per-student statement →
GET /fees/students/:studentId/invoices+ per-invoiceGET /payments/invoice/:invoiceIdcomposed client-side(proposed).