06 — Screen Specifications (Fees Module)
- 0. Amount display & currency rules (module-wide)
- 1. Fees Home
/fees(Dues summary) - 2. Fee Structures List
/fees/structures - 3. Fee Structure Detail
/fees/structures/:id - 4. Student Invoices
/fees/students/:studentId/invoices - 5. Invoice Detail
/fees/invoices/:id - 6. Record Payment Sheet
/fees/invoices/:id/pay - 7. Dues List
/fees/dues - 8. Payments (v2) list + detail (shared surface)
- 9. Receipts
/payments/receipts,/payments/receipts/:id - 10. Reminders
(planned)/fees/reminders - 11. Cross-cutting: loading / refresh / realtime
Production-grade specs for each Fees screen: chrome, states, components, micro-interactions, a11y, responsive, dark/light, keyboard, motion. Tokens:
Sp= 00-shared/02 §3,Ty= typography §2,El= elevation §5,R= radius §4,Mo= motion §6. Components reference 00-shared/03 and 07_Component_Library.md (module). Money = server plain-Number values formatted per currency (OQ-1):XAF0 decimals,USD2 decimals, client-sideAppMoneyhelper (see 15_Flutter_Implementation_Guide.md §4).
0. Amount display & currency rules (module-wide)
- Every amount (
totalAmount,paidAmount,due, paymentamount,refundedAmount, receiptamount,fee, structureitems[].amount,totalAmount,lateFee, discounts) is a plainNumberon the wire (fee-structure.schema.ts:19-28,invoice.schema.ts:27-31,fees/schemas/payment.schema.ts:27-28,payments/schemas/payment.schema.ts:33-43,receipt.schema.ts:18-25). - Currency source: structure
currency(defaultXAF,fee-structure.schema.ts:27-28) → copied conceptually to invoice; v2 payments defaultUSD(payments/schemas/payment.schema.ts:42-43), receipts defaultUSD(receipt.schema.ts:24-25). Invoice has no currency field — render from the structure context or tenant config (OQ-1). - Rule: never sum on the client for authoritative balances —
duecomes from the server (fees.service.ts:213-216). Client-side totals only for display (structure items vstotalAmountsanity check). - Formatting:
NumberFormat.currencyfromintl, locale from app;XAF= 0 decimals (CFA has no minor units),USD= 2 decimals; alwaysFontFeature.tabularFigures()(00-shared/02 §2 mono); ≥ 3 trailing digits group with locale separators. - Zero dues: show "Settled" state, never "-0".
1. Fees Home /fees (Dues summary)
Chrome: AppBar "Fees" (titleLarge, El-e1); body = RefreshIndicator +
ListView: AppStatTile row (Total due, Overdue count, Partial count — computed
from the dues page + client grouping), then "Outstanding" section header + dues
preview rows; FAB AppFAB.extended "Collect payment".
Dues preview row:
[student/class ref] [status chip: issued/partial/overdue]
Structure name · due date bodySmall onSurfaceVariant
due amount (mono, tabular) titleMedium; overdue → error + icon schedule
- Meta: from dues rows
{invoice, due}(fees.service.ts:213-216). - States: skeleton; empty
AppEmptyState"All settled — nothing outstanding"; errorAppErrorState+ retry; offline banner + cached dues (TTL 5 min,00-shared/06 §3.3). - A11y: stat tiles =
Semantics(label: "Total due {amount}"); row = oneSemantics(button). - Dark/light: token-only; overdue amounts use
errorin both modes, never color-only (09 §9).
2. Fee Structures List /fees/structures
Chrome: AppBar "Fee structures" + AppSearchBar (client-side filter over the
paginated feed — server ignores q here, pagination-query.dto.ts:21-29);
AppChips filter (All / Active / Archived via isActive,
fee-structure.schema.ts:36-37); body = RefreshIndicator + ListView.builder
infinite scroll (page/limit default 20, max 100, pagination-query.dto.ts:13-19);
FAB "New structure".
Structure card:
[Name] [isActive badge: Active/Archived]
Class · Year bodySmall onSurfaceVariant
totalAmount (mono) · currency titleMedium
Due {dueDate} · {N} items · late fee {lateFee} bodySmall
- Row tap → detail; trailing
AppMenu(Edit / Duplicate(planned)/ Delete). - Delete flow:
AppDialogconfirm — copy must say soft-delete: "Delete this fee structure? Existing invoices are kept." →DELETE /fees/structures/:id(fees.service.ts:95-98) → row fade-outMo.m-base, snackbar "Deleted". - States: skeleton; empty (
AppEmptyState"No fee structures yet"); error 5xx + requestId; offline banner + cache (reference data TTL 24 h,00-shared/06 §3.3). - A11y: card one
Semantics(button)"{name}, {class}, total {amount}"; badges text-first. - Tablet/desktop: master-detail ≥ 840 dp;
Nshortcut = new structure.
3. Fee Structure Detail /fees/structures/:id
Chrome: AppBar "Structure" + trailing menu (Edit / Delete); ScrollView,
sections Sp.24.
Header card (surfaceContainerLow, r-lg, e-1)
name headlineMedium + [Active/Archived badge]
chips: class · year · currency
Info card
AppInfoRow: Total ({totalAmount}), Due date, Late fee ({lateFee}), Status
Line items card
header "Line items ({N})" — AppSectionHeader
rows: item name | amount (mono) (items[] fee-structure.schema.ts:18-22)
sum row: "Sum of items" vs totalAmount → mismatch AppBanner(warning) when
Σitems ≠ totalAmount (server does not validate the sum)
Actions
FilledButton "Generate invoice" → dialog; OutlinedButton "Edit"
Generate invoice dialog — student picker (AppDropdown from Students module),
optional discounts[] editor (name + amount ≥ 0, generate-invoice.dto.ts:13-22):
- Submit →
POST /fees/invoices/generate(fees.controller.ts:58-62). - 409
DUPLICATE_RESOURCE"Invoice already exists for this student and term." (fees.service.ts:107-115) → inlineAppBanner(warning)"An invoice already exists for this student and term" + link to it; dialog stays open. - 404 (structure deleted mid-flight) → close +
AppSnackbar(error)+ back. - Success →
AppSnackbar"Invoice issued" → navigate student invoices.
States: skeleton; 404 → AppEmptyState "This structure was removed or is not
available."; offline → banner + cached detail.
4. Student Invoices /fees/students/:studentId/invoices
Chrome: AppBar "Invoices · {student name}" (name from Students module); body =
RefreshIndicator + list (non-paginated array — render all,
fees.service.ts:142-144).
Invoice card:
[structure/term name] [status chip: draft|issued|partial|paid|
overdue|cancelled — invoice.schema.ts:7-14]
Academic year · Due {dueDate} bodySmall; overdue → error + icon schedule
Total {totalAmount} · Paid {paidAmount} bodyMedium
Due {due - server value} titleMedium mono; 0 → "Settled"
dueis not on this endpoint — the client derives display viatotalAmount − paidAmount(same formula the server uses,fees.service.ts:215) and shows it as derived, or routes to Dues for authoritative value (OQ-3).- Row tap → Invoice Detail.
- States: skeleton; empty
AppEmptyState"No invoices for this student"; error; offline banner + cache (5 min). - A11y: chips text-first; amount announced with currency.
5. Invoice Detail /fees/invoices/:id
Chrome: AppBar "Invoice" + menu (Refresh, Print/Share receipt (proposed));
ScrollView sections Sp.24. Data source: invoice doc (from the student
invoices list — no single-invoice endpoint, OQ-3) + payment history via
GET /payments/invoice/:invoiceId (payments.controller.ts:57-61).
Header card (surfaceContainerLow, r-lg, e-1)
"Invoice" + status chip (status enum invoice.schema.ts:7-14)
Total displaySmall mono (totalAmount)
Paid bodyLarge mono (paidAmount)
Due titleLarge mono (due, server-computed where available)
Amount rows card
AppInfoRow: Issued (issuedAt), Due date (dueDate; overdue → error styling),
Academic year, Structure (ref), Student (ref)
Discounts card (when discounts.length > 0 — invoice.schema.ts:46-50)
rows: discount name | −amount (mono); note "Reduces total at issue time"
Payment history card (v2 payments by invoiceId)
rows: transactionReference · gateway · amount · status chip
(PaymentStatus enum payments/schemas/payment.schema.ts:18-26)
empty: "No payments recorded yet."
Receipt access `(proposed)`
OutlinedButton "View receipt" → /payments/receipts/:id (receipt.schema.ts:9-40)
Actions
FilledButton "Record payment" → sheet (when status issued|partial|overdue)
FilledButton.tonal "Pay online" (forward-looking) → payments module flow
(both hidden when paid|cancelled — server 409s those anyway, fees.service.ts:155-160)
Status chip palette (module-wide):
| Status | Light | Dark | Icon |
|---|---|---|---|
draft | neutral outline | neutral outline | description |
issued | primaryContainer | primaryContainer | |
partial | tertiaryContainer | tertiary-tinted | hourglass |
paid | secondaryContainer | secondary-tinted | check_circle |
overdue | errorContainer | errorContainer | schedule |
cancelled | neutral | neutral | cancel |
Chips always show text label + icon + color (09 §9 — never color-only).
Payment history empty-vs-error: 404 on GET /payments/invoice/:id → "No
payments" (the v2 collection may have none); 5xx → AppErrorState inline with
retry.
States: skeleton (invoice + payments in Future.wait); 404/removed →
AppEmptyState; offline → banner + cached detail.
6. Record Payment Sheet /fees/invoices/:id/pay
Chrome: full-height AppBottomSheet (or page on tablet): "Record payment",
drag handle; fields per 08 §3; FilledButton fullWidth "Record payment".
Flow & states:
- Idle → form prefilled:
amount = due(editable ≥ 0,record-payment.dto.ts:19), method selector (5 options,record-payment.dto.ts:21-25), date = today (paidAtISOIsDateString,record-payment.dto.ts:37-38), optional reference- notes.
submitting→ button spinner, all inputs disabled, anti-double-submit (client + idempotency key as second line of defense).- Idempotency key: generated once per form attempt (
uuid); on retry after a network failure the same key is reused — server replays the original payment instead of duplicating (fees.service.ts:148-152). - Success (200/201): close sheet → snackbar "Payment recorded" →
invoice detail refreshed (server re-derived
paidAmount/status,fees.service.ts:168-176); ifstatus == paidshow success banner "Invoice fully paid". - 409 "Invoice is already paid or cancelled." (
fees.service.ts:155-160) → close sheet,AppBanner(info)"This invoice is already paid/cancelled", refresh. - 409 replay (same key): identical to success — the returned doc is the original payment; treat as success, not error.
- 400: field errors mapped (
details[].message,http-exception.filter.ts:103-108). - Overpay guard (client, advisory): warn when
amount > due("Amount exceeds remaining balance") but allow — server has no cap (OQ-2); show resultingpaidAmountpreview. - Offline: blocked — write-op, no offline queue defined for fees (only reads
cached;
00-shared/07 §10). - A11y: live region announces "Recording payment"; success/error announced.
7. Dues List /fees/dues
Chrome: AppBar "Dues" + AppChips filter (All / Issued / Partial / Overdue —
the exact status set the server queries: fees.service.ts:194-201); body =
RefreshIndicator + ListView.builder infinite scroll (meta.hasNext,
pagination-query.dto.ts:47-54); FAB "Collect payment".
Row (DuesRow):
[student ref] [status chip]
Structure name · due date bodySmall
due amount (mono, tabular) titleMedium — overdue → error + schedule icon
[Collect icon-button] primary, tooltip "Record payment"
duevalue from server (fees.service.ts:213-216); never recompute.- Row tap → Invoice Detail; Collect → Record Payment sheet (route
/fees/invoices/:id/pay). - Infinite scroll: bottom spinner + "end of list" footer; pull-to-refresh resets page 1.
- States: skeleton; empty "All settled — nothing outstanding."; error; offline banner + cached dues (5 min).
- Analytics:
(proposed)dues_collect_tap etc.
8. Payments (v2) list + detail (shared surface)
List /payments: GET /payments?page&limit (payments.controller.ts:45-49);
rows: transactionReference (mono), amount, gateway, status chip (pending,
processing, completed, failed, refunded, partially_refunded, cancelled
— payments/schemas/payment.schema.ts:18-26), createdAt. Filter by status
client-side. Row tap → detail.
Detail /payments/:id: GET /payments/:id (payments.controller.ts:51-55);
info rows: ref, amount, fee, refundedAmount, currency, gateway,
gatewayTransactionId, gatewayResponse, invoiceId, payer (payerName,
payerEmail), description, status. Actions when completed: Refund dialog
(POST /payments/refund, refund-payment.dto.ts:4-19; rules:
payments.service.ts:74-111 — only completed refundable, partial allowed, total
refunded can't exceed amount; errors 404/409 surface as banners).
9. Receipts /payments/receipts, /payments/receipts/:id
List: GET /payments/receipts (payments.controller.ts:63-67); rows:
receiptNumber (mono), amount, paymentMethod, issuedAt; tap → detail.
Detail (print/share receipt (proposed)): GET /payments/receipts/:id
(payments.controller.ts:69-73) rendered as a document card:
┌─────────────────────────────────────────┐
│ RECEIPT RCP-… │ (receiptNumber, mono)
│ {org name} (tenant context) │
│ Amount: {amount} {currency} (mono) │
│ Fee: {fee} (mono) │
│ Method: {paymentMethod} │
│ Payer: {payerName} / {payerEmail} │
│ For: {description} │
│ Invoice: {invoiceId} │
│ Issued: {issuedAt} │
│ Reference: {paymentId} │
└─────────────────────────────────────────┘
- Fields exactly per
receipt.schema.ts:9-40; amount mono tabular; includefee,currency,paymentMethod,payerName,payerEmail,description,issuedAt. - Print: capture via
RepaintBoundary→ share sheet (PDF/image) — no print endpoint exists (OQ-10); QR for paper tracking(forward-looking). - States: skeleton; 404 "Receipt not found." (
payments.service.ts:174-178).
10. Reminders (planned) /fees/reminders
- Backend: none. Mirror of the reminder scan query client-side
(
issued|partialandnow ≤ dueDate ≤ now+3d,fee-reminder.job.ts:16-23) as a read-only list marked(planned); no actions. Replaced by a real endpoint + worker later.
11. Cross-cutting: loading / refresh / realtime
| Screen | Loading | Refresh | Realtime |
|---|---|---|---|
| Fees home | AppSkeleton | pull | (planned) WS invoice.updated (00-shared/07 §8) → re-fetch dues |
| Structures list | skeleton | pull | re-fetch on focus |
| Structure detail | skeleton | pull | — |
| Student invoices | skeleton | pull | re-fetch on focus (payment may land) |
| Invoice detail | skeleton (invoice+payments parallel) | pull | invoice.updated (planned) |
| Dues | skeleton | pull | invoice.updated (planned) |
| Payments list | skeleton | pull | — |
| Record payment | button spinner | — | — |
Error mapping (all screens): 401 → refresh → sessionExpired; 403 →
AppErrorState(403); 404 → empty state; 429 → countdown banner, no auto-retry;
5xx → generic + requestId + retry (00-shared/06 §5).
Keyboard (forms): .next through fields, .done on last, Ctrl/Cmd+Enter
submit (desktop).
Reduced motion: Mo.m-instant everywhere (00-shared/02 §6).