13 — State Management (Fees Module)
- 1. FeesHomeCubit (fees home / dues summary)
- 2. DuesListCubit (dues screen — full)
- 3. StructuresListCubit (fee structures)
- 4. FeeStructureDetailCubit
- 5. FeeStructureFormCubit (create + edit)
- 6. StudentInvoicesCubit
- 7. InvoiceDetailCubit (composition)
- 8. RecordPaymentCubit (critical)
- 9. PaymentsCubit (v2 list + refund)
- 10. ReceiptsCubit
- 11. Shared cross-cutting
- 12. Testing hooks (
00-shared/06 §6)
Per-screen Cubits (Flutter/bloc; proposal, 00-shared/06) backed by
FeesRepository+PaymentsRepositorycalling 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 fromGET /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);RefreshIndicatorbypasses.
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/sortonGET /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;
RefreshIndicatorbypasses.
4. FeeStructureDetailCubit
- State:
{status, structure?, items[], sumMismatch: bool}. - Load:
GET /fees/structures/:id→ doc; computessumMismatch = Σ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}— mirrorscreate-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(immutableclassId/academicYearIddisabled). totalAmountauto-synced to Σitems when line items change (editable override);sumMismatchshown.- Errors: 400 →
fieldErrors(mapdetails[].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)WSinvoice.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 (noGET /fees/invoices/:idendpoint; OQ-3), payments fromGET /payments/invoice/:invoiceId(payments.controller.ts:57-61). PaymentRecordedevent 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:
idempotencyKeyis generated ONCE per form life-cycle; retries after network failure reuse it — the server replays the original payment (fees.service.ts:148-152) →conflictSuccessandduplicate-ish success, never a double record. - Form mirrors
record-payment.dto.ts:11-44;paymentMethodfromPaymentMethodenum (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 /paymentspaginated (page/limit defaults 1/20,payments.controller.ts:45-49); status chips fromPaymentStatus(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/receiptspaginated; detail:GET /payments/receipts/:id(payments.controller.ts:63-73). - State:
{status, receipts[], receipt?, printing: bool}— print/share(proposed)viaRepaintBoundarycapture → system share.
11. Shared cross-cutting
AuthCubit401 → 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.FeatureFlagsCubitgates(forward-looking)online-pay / QR receipt, and(planned)reminder surfaces per tenant.- Realtime: subscribe
invoice.updatedtopic(planned)→ notify Dues + InvoiceDetail cues to re-fetch (00-shared/06 §3.4).
12. Testing hooks (00-shared/06 §6)
- Unit:
RecordPaymentCubit409→success, 409→conflictPaid, retry-same-key; money formatting/deriveddue; 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.