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

13 — State Management (Fees Module)

Per-screen Cubits (Flutter/bloc; proposal, 00-shared/06) backed by FeesRepository + PaymentsRepository calling the endpoints in 12_API_Mapping.md. Module-wide rule: no optimistic money mutations — every amount/status change is server-confirmed. The only local "success" is the idempotency replay (409 treated as success).


1. FeesHomeCubit (fees home / dues summary)

stateDiagram-v2
    [*] --> initial
    initial --> loading : Load
    loading --> loadedDues : GET /fees/dues (page 1)
    loading --> loadedEmpty : meta.totalItems == 0
    loading --> error : 5xx / network
    loadedDues --> loadedEmpty : items empty (after refresh)
    loadedDues --> loadingMore : LoadMore
    loadingMore --> loadedDues : append (meta.hasNext)
    loadedDues --> loadedDues : Refresh (pull) | ChangeStatusFilter
    error --> loading : Retry
  • State: {status, rows: DuesRow[], page, hasNext, filter, summary}.
  • rows = {invoice: Invoice, due} exactly from GET /fees/dues (fees.service.ts:213-216); summary = client-grouped counts (overdue/partial/total due) — display only.
  • Events: Load, Refresh, LoadMore, ChangeFilter(status), Retry, PaymentRecorded(invoiceId) → re-fetch current page (invoice left/updated).
  • Caching: key fees:dues:{tenant}:{filter}, TTL 5 min, stale-while-revalidate (00-shared/06 §3.3); RefreshIndicator bypasses.

2. DuesListCubit (dues screen — full)

stateDiagram-v2
    [*] --> initial
    initial --> loading : Load
    loading --> loaded : rows (meta.hasNext)
    loaded --> empty : totalItems == 0
    loaded --> loading : Refresh also paginated (reset page=1)
    loaded --> loadingMore : LoadMore
    loaded --> loaded : CollectDone(invoiceId) → remove/refresh row
    error --> loading : Retry
  • Events: Load, Refresh, LoadMore, Retry, ChangeFilter(status), CollectDone(invoiceId, doc) → re-fetch row list.
  • Offline: cached dues + banner; actions (Collect) disabled.

3. StructuresListCubit (fee structures)

stateDiagram-v2
    [*] --> initial
    initial --> loading : Load(page)
    loading --> loaded : page data + meta
    loading --> errorEmpty : 400
    loaded --> loadingMore : LoadMore (page++)
    loaded --> loaded : Deleted(id) row removed | FilterChange
    loaded --> loaded : Created(doc) prepend
  • Server ignores q/sort on GET /fees/structures (fees.service.ts:60-72) — filter/search are client-side over loaded pages (OQ-3).
  • Delete: server-confirmed (DELETE /fees/structures/:id); remove row on 200; roster soft-delete copy in snackbar.
  • Caching: reference data TTL 24 h; RefreshIndicator bypasses.

4. FeeStructureDetailCubit

  • State: {status, structure?, items[], sumMismatch: bool}.
  • Load: GET /fees/structures/:id → doc; computes sumMismatch = Σitems ≠ total.
  • Events: Load(id), Refresh, EditDone (re-fetch after PATCH), Generated(invoiceDoc) → snackbar + optionally push student-invoices route.
  • 409 from generate is handled in a dialog-local GenerateInvoiceCubit below (never a state in this cubit).
  • No cache.

5. FeeStructureFormCubit (create + edit)

  • State: {mode, form{name, classId, academicYearId, items:[]LineItem, totalAmount, currency, dueDate, lateFee, isActive}, status: idle|submitting|error} — mirrors create-fee-structure.dto.ts:26-68 / update-fee-structure.dto.ts:15-54.
  • Create → POST /fees/structures → success → list refresh → detail.
  • Edit → PATCH /fees/structures/:id (immutable classId/academicYearId disabled).
  • totalAmount auto-synced to Σitems when line items change (editable override); sumMismatch shown.
  • Errors: 400 → fieldErrors (map details[].message, keep form); 404 → back.
  • No optimistic writes. Draft persistence (proposed)shared_preferences.

6. StudentInvoicesCubit

  • State: {status, studentId, invoices[]} (non-paginated: fees.service.ts:142-144).
  • Load: GET /fees/students/:studentId/invoices.
  • Row model: invoice fields + derived due = totalAmount − paidAmount (same formula as server dues, fees.service.ts:215) labeled as derived; chips per status.
  • Events: Load, Refresh, InvoiceUpdated ((planned) WS invoice.updated → re-fetch).

7. InvoiceDetailCubit (composition)

stateDiagram-v2
    [*] --> initial
    initial --> loadingInvoice : Load(invoiceDoc)
    loadingInvoice --> loadingPayments : + GET /payments/invoice/:id
    loadingPayments --> loaded : payments[] + invoice
    loadingPayments --> loadedSolo : 404 on payments (none recorded)
    loaded --> error : payments 5xx
    loaded --> loaded : Refresh
  • State: {status, invoice?, payments[]} — invoice doc passed from student/dues lists (no GET /fees/invoices/:id endpoint; OQ-3), payments from GET /payments/invoice/:invoiceId (payments.controller.ts:57-61).
  • PaymentRecorded event from PaymentCubit → re-fetch payments + refresh invoice status via list cue.
  • No cache; re-fetch on focus.

8. RecordPaymentCubit (critical)

stateDiagram-v2
    [*] --> idle
    idle --> submitting : Submit(form)
    submitting --> success(doc) : 201
    submitting --> replaySuccess(doc) : 409 (same idempotencyKey already exists)
    submitting --> conflictPaid : 409 "already paid or cancelled"
    submitting --> failed : 400 / 5xx / network
    error --> submitting : Retry (REUSES the same key)
  • State: {status, form{invoiceId, amount, paymentMethod, reference, idempotencyKey, paidAt, notes}, fieldErrors, invoice}.
  • Key invariant: idempotencyKey is generated ONCE per form life-cycle; retries after network failure reuse it — the server replays the original payment (fees.service.ts:148-152) → conflictSuccess and duplicate-ish success, never a double record.
  • Form mirrors record-payment.dto.ts:11-44; paymentMethod from PaymentMethod enum (payment.schema.ts:7-13).
  • 409 paid/cancelled → conflictPaid → close sheet + AppBanner(info) (fees.service.ts:155-160).
  • Overpay warning: amount > due → inline warning, allowed (OQ-2).
  • Offline: submit blocked.

9. PaymentsCubit (v2 list + refund)

  • List: GET /payments paginated (page/limit defaults 1/20, payments.controller.ts:45-49); status chips from PaymentStatus (payments/schemas/payment.schema.ts:18-26).
  • Detail: GET /payments/:id + POST /payments/refund (refund-payment.dto.ts); refund rules (payments.service.ts:74-111) → state {status, refundedAmount, remaining}.
  • Refund submit is server-confirmed; 409 "Only completed payments can be refunded." / "Refund amount exceeds payment amount." → banner.

10. ReceiptsCubit

  • List: GET /payments/receipts paginated; detail: GET /payments/receipts/:id (payments.controller.ts:63-73).
  • State: {status, receipts[], receipt?, printing: bool} — print/share (proposed) via RepaintBoundary capture → system share.

11. Shared cross-cutting

  • AuthCubit 401 → refresh; session expiry mid-payment → key retained in sheet state.
  • ConnectivityCubit: offline → cached reads (dues 5 min, structures 24 h, receipts 24 h), writes blocked, offline banner.
  • FeatureFlagsCubit gates (forward-looking) online-pay / QR receipt, and (planned) reminder surfaces per tenant.
  • Realtime: subscribe invoice.updated topic (planned) → notify Dues + InvoiceDetail cues to re-fetch (00-shared/06 §3.4).

12. Testing hooks (00-shared/06 §6)

  • Unit: RecordPaymentCubit 409→success, 409→conflictPaid, retry-same-key; money formatting/derived due; structure-form sum-mismatch; payments refund guards.
  • Widget: dues loading/empty/error; record-payment states; invoice detail composition incl. payments-404 path; receipts render.
  • Every cubit pair has widget-test loading/error/empty permutations.