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

15 — Flutter Implementation Guide (Fees Module)

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-216 dues; 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 amount Text styles.

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 in Hive/shared_preferences keyed fees:dues:{tenant}:{filter}, TTL 5 min (00-shared/06 §3.3); structure list TTL 24 h; receipts 24 h.
  • Render offline: FeesHomeCubit/DuesListCubit show cached dues + due snapshot with AppOfflineBanner("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.valuenever 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: AppMoney formatting (XAF 0-dec, USD 2-dec, number 0 → Settled, locales); RecordPaymentCubit (409 success/replay/paid, retry-same-key); derived due; structure-form sumMismatch; 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 + RepaintBoundary on receipt doc.
  • Invoice detail Future.wait (invoice + payments) — single skeleton.
  • Amount grid TextField does not rebuild the sheet on each keystroke (controller → ValueNotifier).
  • const constructors; memoize AmountText output; counting animation only under normal motion.

Proposals flagged to the team

  1. When server adds a single-invoice endpoint (GET /fees/invoices/:id) and a per-student dues endpoint, drop invoice-detail composition (OQ-3).
  2. When fees.* permission set expands (only fees.collect + payments/readership exist), wire all hidden actions to real perms (OQ-9).
  3. When the payment-reminder worker + endpoint land, enable Reminders screen and invoice.updated realtime (OQ-6).
  4. When late-fee behavior exists (field only today), render computed late fees (OQ-7).
  5. Analytics wiring waits shared AnalyticsService (00-shared/10 §8), events per 05 §Analytics.