15 — Flutter Implementation Guide (Fees Module)
- 1. Folder structure
- 2. Dependencies
- 3. Cubits
- 4. Money formatting (critical)
- 5. FeesRepository (dio)
- 7. Navigation (go_router)
- 8. Offline dues view (read path)
- 9. Record-sheet idempotency implementation
- 10. Theme
- 11. Localization keys
- 12. Testing
- 13. Performance
- Proposals flagged to the team
How to build the Fees feature in the Flutter client on top of 00-shared/11. Forward-looking spec; no client repo exists yet. Money handling is the center of gravity — read §2, §4, §8 first.
1. Folder structure
features/fees/
├── domain/
│ ├── models/
│ │ ├── fee_structure.dart # id, classId, academicYearId, items[],
│ │ │ # totalAmount, currency, dueDate, lateFee, isActive
│ │ ├── line_item.dart # name, amount
│ │ ├── invoice.dart # id, studentId, feeStructureId, academicYearId,
│ │ │ # totalAmount, paidAmount, status (InvoiceStatus),
│ │ │ # dueDate, issuedAt?, discounts[] + derived due
│ │ ├── discount.dart # name, amount
│ │ ├── fee_payment.dart # fees payment doc (invoiceId, amount, paymentMethod,
│ │ │ # reference?, idempotencyKey, paidAt, notes?, status)
│ │ ├── payment_v2.dart # v2 payment (transactionReference, amount, fee,
│ │ │ # refundedAmount, currency, gateway, status, payer…)
│ │ ├── receipt.dart # receiptNumber, amount, fee, currency, paymentMethod,
│ │ │ # payerName/Email, description, issuedAt
│ │ └── dues_models.dart # DuesItem { invoice, due }
│ └── exceptions/fees_exceptions.dart
│ # DuplicateInvoiceException(409), PaymentConflictException(409)
├── data/
│ ├── dto/
│ │ ├── create_fee_structure_dto.dart
│ │ ├── update_fee_structure_dto.dart
│ │ ├── generate_invoice_dto.dart
│ │ ├── record_payment_dto.dart # holds idempotencyKey
│ │ └── payment_v2_dto.dart # process + refund
│ └── repositories/
│ ├── fees_repository.dart
│ └── payments_repository.dart # v2 payments + receipts
└── presentation/
├── cubit/
│ ├── fees_home_cubit.dart
│ ├── dues_list_cubit.dart
│ ├── structures_list_cubit.dart
│ ├── fee_structure_detail_cubit.dart
│ ├── fee_structure_form_cubit.dart
│ ├── student_invoices_cubit.dart
│ ├── invoice_detail_cubit.dart
│ ├── record_payment_cubit.dart # idempotent submit
│ ├── generate_invoice_cubit.dart
│ ├── payments_cubit.dart # v2 list/refund
│ └── receipts_cubit.dart
├── pages/
│ ├── fees_home_page.dart
│ ├── dues_page.dart
│ ├── structures_page.dart
│ ├── fee_structure_form_page.dart
│ ├── student_invoices_page.dart
│ ├── invoice_detail_page.dart
│ ├── record_payment_sheet.dart
│ ├── payments_page.dart
│ └── receipt_page.dart
└── widgets/ (see `07_Component_Library.md §F`)
2. Dependencies
flutter_bloc, dio (AppDio, 00-shared/11 §5), go_router, get_it, intl
(money), cached_network_image (student avatars only), share_plus
(receipt/pdf share (proposed)), printing/pdf (receipt print — verify
license, wrap in ReceiptExporter so swappable). No chart lib in P0 for the dues
report — use fl_chart wrapper (00-shared/03 §AppCharts) when report lands
(proposed).
3. Cubits
Per 13_State_Management.md. The only cubit with real money semantics is
RecordPaymentCubit (idempotency key lifecycle) — keep it pure-Dart and
unit-testable (13 §8).
4. Money formatting (critical)
// Shared money formatter — used by EVERY amount widget (07 §A).
class AppMoney {
static String format(num value, String currency, {bool zeroAs = 'Settled'}) {
if (value == 0) return zeroAs; // never "-0"
final decimals = currency == 'XAF' ? 0 : 2; // CFA = no minor units (OQ-1)
return NumberFormat.currency(
locale: AppLocale.current.languageCode,
symbol: symbolFor(currency),
decimalDigits: decimals,
).format(value);
}
}
- Sources of truth: server fields only (
fees.service.ts:213-216dues;fee-structure.schema.ts:24-28;invoice.schema.ts:27-31;payments/schemas/payment.schema.ts:33-43). Never sum cross-invoice client-side for authoritative balance (OQ-3). - Currency resolution: invoice has no currency field — fall back structure
currency(context) → tenant config →XAF(OQ-1). - Always
FontFeature.tabularFigures()on amountTextstyles.
5. FeesRepository (dio)
class FeesRepository {
Future<Paginated<FeeStructure>> structures({int page = 1, int limit = 20}); // GET /fees/structures
Future<FeeStructure> structure(String id); // GET /fees/structures/:id
Future<FeeStructure> createStructure(CreateFeeStructureDto); // POST /fees/structures
Future<FeeStructure> updateStructure(String id, UpdateFeeStructureDto); // PATCH /fees/structures/:id
Future<void> deleteStructure(String id); // DELETE /fees/structures/:id
Future<Invoice> generateInvoice(GenerateInvoiceDto); // POST /fees/invoices/generate
// throws InvoiceAlreadyExistsException on 409
Future<List<Invoice>> studentInvoices(String studentId); // GET /fees/students/:id/invoices
Future<FeePayment> recordPayment(RecordPaymentDto dto); // POST /fees/invoices/:id/payments
// replay-safe via dto.idempotencyKey
Future<Paginated<DueItem>> dues({int page = 1}); // GET /fees/dues
}
PaymentsRepository: processPayment, refundPayment, reconcile,
byInvoice(invoiceId), payments(page), receipts(page), receipt(id) —
all mapped per 12_API_Mapping.md. AppDio parses envelope + throws
ApiException(code, status); 409 fee-specific exceptions in domain/exceptions.
7. Navigation (go_router)
/fees FeesHome
/fees/dues
/fees/structures
/fees/structures/new
/fees/structures/:id
/fees/structures/:id/edit
/fees/students/:studentId/invoices
/fees/invoices/:id (composed detail; deep-link pay /fees/invoices/:id/pay)
/fees/invoices/:id/pay (payment sheet/push)
/payments (v2 list)
/payments/receipts/:id
Guards: authGuard; role gates fees.collect (Accountant) for record-payment +
collect actions (planned) when server RBAC lands (permissions.constants.ts:31);
payments.refund (:83) gates refund. Deep links (proposed):
studylyon://fees/dues, studylyon://fees/invoices/:id,
studylyon://payments/receipts/:id; root URI table per 00-shared/11 §6.
8. Offline dues view (read path)
- Cache:
feesRepository.dues()results inHive/shared_preferenceskeyedfees:dues:{tenant}:{filter}, TTL 5 min (00-shared/06 §3.3); structure list TTL 24 h; receipts 24 h. - Render offline:
FeesHomeCubit/DuesListCubitshow cached dues +duesnapshot withAppOfflineBanner("Showing cached balances — last synced {ts}"). - Label cached amounts clearly as "may be stale": "Balances update after a payment is recorded." — never present stale money as current truth when offline.
- Writes offline are BLOCKED (
recordPayment,generateInvoice, refund): button disabled + banner. No fees offline write queue defined (00-shared/07 §10). - Pull-to-refresh always bypasses cache; on success supersedes cache; on network
failure keeps cache + error snackbar with
Retry.
9. Record-sheet idempotency implementation
final _key = _IdempotencyKey(); // generated once per sheet lifecycle
Future<void> submit() async {
try {
final res = await _repo.recordPayment(dto.copyWith(idempotencyKey: _key.value));
emit(success(res));
} on ApiException catch (e) {
if (e.code == 'DUPLICATE_RESOURCE' && e.message.contains('already paid'))
emit(conflictPaid()); // paid/cancelled — fees.service.ts:155-160
else if (e.code == 'DUPLICATE_RESOURCE')
emit(success(_fetchOriginal())); // replay of SAME key — fees.service.ts:148-152
else
emit(failed(e));
}
}
- Retry re-uses
_key.value— never regenerates (that would create a duplicate). - Discard sheet → drop key reference (server unique index still prevents future collisions).
10. Theme
AppTheme.light()/dark() unchanged; module adds constants only
(AppSpacing, AppRadius, AppMotion); 11 §2 maps widgets → tokens.
11. Localization keys
fees.home.title, fees.dues.title, fees.dues.total, fees.status.*
(draft/issued/partial/paid/overdue/cancelled/pending/processing/completed/failed/
refunded/partially_refunded/cancelled), fees.amount.due, fees.amount.paid,
fees.amount.settled, fees.payment.recorded, fees.invoice.exists,
fees.invoice.paid_cancelled, fees.money.overpay_warning, fees.offline.stale
(banner), fees.refund.*, fees.error.* code fallbacks (00-shared/07 §11).
12. Testing
- Unit:
AppMoneyformatting (XAF 0-dec, USD 2-dec,number 0 → Settled, locales);RecordPaymentCubit(409 success/replay/paid, retry-same-key); deriveddue; structure-formsumMismatch; refund guards. - Widget: every screen loading/error/empty; payment sheet keyboard; stale-dues banner; invoice detail composition (payments 404 path).
- Golden:
AmountText,StatusChip(all statuses × light/dark),InvoiceCard,DueRow,ReceiptDocument(07 §G). - Integration: structure → generate → partial → reconcile → refund loop; duplicate invoice; double-POST idempotency; offline dues then refresh.
- E2E (P0): full cashier loop on device cloud (
14_QA_Checklist.md).
13. Performance
- Paginated infinite scroll elsewhere rendders the whole dues/structures lists lazily;
ListView.builder+RepaintBoundaryon receipt doc. - Invoice detail
Future.wait(invoice + payments) — single skeleton. - Amount grid
TextFielddoes not rebuild the sheet on each keystroke (controller →ValueNotifier). constconstructors; memoizeAmountTextoutput; counting animation only under normal motion.
Proposals flagged to the team
- When server adds a single-invoice endpoint (
GET /fees/invoices/:id) and a per-student dues endpoint, drop invoice-detail composition (OQ-3). - When
fees.*permission set expands (onlyfees.collect+ payments/readership exist), wire all hidden actions to real perms (OQ-9). - When the payment-reminder worker + endpoint land, enable Reminders screen and
invoice.updatedrealtime (OQ-6). - When late-fee behavior exists (field only today), render computed late fees (OQ-7).
- Analytics wiring waits shared
AnalyticsService(00-shared/10 §8), events per05 §Analytics.