Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

06 — Screen Specifications (Fees Module)

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): XAF 0 decimals, USD 2 decimals, client-side AppMoney helper (see 15_Flutter_Implementation_Guide.md §4).


0. Amount display & currency rules (module-wide)

  • Every amount (totalAmount, paidAmount, due, payment amount, refundedAmount, receipt amount, fee, structure items[].amount, totalAmount, lateFee, discounts) is a plain Number on 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 (default XAF, fee-structure.schema.ts:27-28) → copied conceptually to invoice; v2 payments default USD (payments/schemas/payment.schema.ts:42-43), receipts default USD (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 — due comes from the server (fees.service.ts:213-216). Client-side totals only for display (structure items vs totalAmount sanity check).
  • Formatting: NumberFormat.currency from intl, locale from app; XAF = 0 decimals (CFA has no minor units), USD = 2 decimals; always FontFeature.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"; error AppErrorState + retry; offline banner + cached dues (TTL 5 min, 00-shared/06 §3.3).
  • A11y: stat tiles = Semantics(label: "Total due {amount}"); row = one Semantics(button).
  • Dark/light: token-only; overdue amounts use error in 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: AppDialog confirm — copy must say soft-delete: "Delete this fee structure? Existing invoices are kept." → DELETE /fees/structures/:id (fees.service.ts:95-98) → row fade-out Mo.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; N shortcut = 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) → inline AppBanner(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"
  • due is not on this endpoint — the client derives display via totalAmount − 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):

StatusLightDarkIcon
draftneutral outlineneutral outlinedescription
issuedprimaryContainerprimaryContainermail
partialtertiaryContainertertiary-tintedhourglass
paidsecondaryContainersecondary-tintedcheck_circle
overdueerrorContainererrorContainerschedule
cancelledneutralneutralcancel

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 (paidAt ISO IsDateString, 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); if status == paid show 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 resulting paidAmount preview.
  • 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"
  • due value 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, cancelledpayments/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; include fee, 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|partial and now ≤ 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

ScreenLoadingRefreshRealtime
Fees homeAppSkeletonpull(planned) WS invoice.updated (00-shared/07 §8) → re-fetch dues
Structures listskeletonpullre-fetch on focus
Structure detailskeletonpull
Student invoicesskeletonpullre-fetch on focus (payment may land)
Invoice detailskeleton (invoice+payments parallel)pullinvoice.updated (planned)
Duesskeletonpullinvoice.updated (planned)
Payments listskeletonpull
Record paymentbutton 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).