Mutual exclusion validation at persistence boundary
epic-expense-type-selection-core-services-task-013 — Add a server-side guard in ExpenseTypeRepository.persistFinalSelection that validates the submitted selection set against the mutual exclusion rules before executing the Supabase upsert. This is the safety net required by the epic spec — even if a client-side bug passes an invalid combination through the BLoC, the repository rejects it with a typed MutualExclusionViolationException before any write reaches the database. Log the violation for audit purposes.
Acceptance Criteria
Technical Requirements
Execution Context
Tier 4 - 323 tasks
Can start after Tier 3 completes
Implementation Notes
Define MutualExclusionViolationException as a typed exception class (not a generic Exception) so callers can distinguish it. The mutual exclusion rule set should be a shared constant or injected dependency — do not duplicate the rule list from the BLoC rule engine. A clean approach: extract a MutualExclusionRuleEngine class or static method that both the BLoC and the repository import. In persistFinalSelection, call the rule engine first; if it returns a violation, throw before opening the Supabase transaction.
For the audit log, use a dedicated Supabase table (e.g., expense_validation_audit) or append to an existing audit stream — do not log to console only. Use a try/finally pattern to ensure the audit write fires even when the exception propagates. Dart pattern: throw MutualExclusionViolationException(conflictingTypes: [ExpenseType.kilometre, ExpenseType.transit]) with a toString() that produces a human-readable message for downstream error handling.
Testing Requirements
Unit tests (task-015) cover this guard directly. This task itself must include a focused manual verification checklist: (1) trigger from BLoC with invalid selection and confirm exception type, (2) confirm audit log row written to Supabase, (3) confirm no partial write in the expense_type_selections table. Integration tests in task-016 further exercise the full flow. All tests use mock Supabase client — no real network calls in automated tests.
The per-km reimbursement rate and transit zone amounts must be read from org-specific configuration stored in Supabase. If the rate configuration table or RLS policies are not yet deployed when this epic runs, the calculation service cannot be completed and integration tests will fail.
Mitigation & Contingency
Mitigation: Define a RateConfigRepository interface and inject a stub implementation with default HLF rates from day one; write the real Supabase adapter in parallel and swap via dependency injection before merge.
Contingency: If org rate config is delayed beyond this epic's window, ship with the default-rate stub and log a prominent warning; calculate with defaults and surface a 'rates not confirmed' notice in the UI preview.
If the peer mentor opens an expense claim on two devices simultaneously, the local draft and the Supabase record may diverge. The repository's last-write-wins strategy could silently overwrite a valid selection with a stale one.
Mitigation & Contingency
Mitigation: Add an updated_at timestamp to the draft record and reject saves where the server timestamp is newer than the local copy; surface a conflict resolution prompt rather than silently overwriting.
Contingency: If conflict resolution UI is out of scope, fall back to server-authoritative reads on app foreground resume and discard local draft, notifying the user that their draft was refreshed from the server.