critical priority medium complexity backend pending backend specialist Tier 2

Acceptance Criteria

ExpenseValidationService exposes a single public method: `ValidationResult validate(ExpenseDraft draft)`
ValidationResult contains a List<ExpenseValidationError> that is empty when the draft is valid
ValidationResult.isValid is true iff the error list is empty
ExpenseValidationError is a sealed class with variants: MutualExclusionViolation, MissingReceipt, DistanceBelowMinimum, DistanceAboveMaximum
All validators from tasks 002 and 003 are composed inside validate() — callers do not call individual validators
All errors present in a single draft are collected and returned together (not fail-fast after first error)
Service is registered as a Riverpod Provider and has no flutter/widgets imports
The service has no knowledge of BLoC, BuildContext, or any UI layer construct
Service can be instantiated in a plain Dart test without a Flutter widget tree
API surface is documented with Dart doc comments describing the contract

Technical Requirements

frameworks
Dart
Riverpod
data models
ExpenseDraft
ValidationResult
ExpenseValidationError
MutualExclusionViolation
MissingReceipt
DistanceOutOfRange
performance requirements
validate() must return synchronously — no Future or Stream
Full validation of a single draft must complete in under 5 ms
security requirements
Service must not log any PII from the draft (amounts, distances are acceptable; names/IDs are not)
Validation errors must not expose internal config values beyond what the user needs to correct the input

Execution Context

Execution Tier
Tier 2

Tier 2 - 518 tasks

Can start after Tier 1 completes

Implementation Notes

Define ExpenseValidationError as a sealed class using Dart 3 sealed keyword (or freezed union). ValidationResult should be an immutable value object — consider using freezed @freezed annotation for free copyWith, equality, and toString. The validate() method should collect errors by calling each private validator and appending non-null results to a list. Expose a Riverpod Provider (not a StateNotifier — the service is stateless).

Keep this file in `lib/features/travel_expense/domain/services/` to enforce clean architecture layer separation. Never import any package:flutter/... in this file.

Testing Requirements

Unit tests using flutter_test. Test the aggregation behaviour: a draft that violates both MissingReceipt and MutualExclusionViolation must return both errors in a single ValidationResult. Test that a clean draft returns ValidationResult.isValid == true. Test Riverpod provider registration: override config providers in a ProviderContainer and call validate() — confirms DI is correct.

No widget tests needed.

Component
Expense Validation Service
service high
Epic Risks (3)
high impact medium prob scope

Mutual exclusion rules are stored in the expense type catalogue's exclusive_groups field. If the catalogue schema or group definitions differ between HLF and Blindeforbundet, the validation service must handle multiple group configurations without hardcoding organisation-specific logic.

Mitigation & Contingency

Mitigation: Design the validation service to be purely data-driven: it reads exclusive_groups from the cached catalogue and enforces whichever groups are defined, with no hardcoded organisation names. Write parameterised unit tests covering at least 4 different catalogue configurations to verify generality.

Contingency: If an organisation requires non-standard exclusion semantics (e.g. partial exclusion within a group), introduce an exclusion_type field to the catalogue schema and extend the service, treating it as a catalogue configuration change rather than a code fork.

medium impact high prob technical

The attestation service subscribes to Supabase Realtime for live queue updates. On mobile, Realtime WebSocket connections can be dropped during network transitions, causing the coordinator queue to become stale without the user being aware.

Mitigation & Contingency

Mitigation: Implement connection lifecycle management: reconnect on network-change events, show a 'reconnecting' indicator when the subscription is broken, and perform a full queue refresh on reconnect rather than relying solely on delta events.

Contingency: Add a manual pull-to-refresh gesture on the attestation queue screen as a guaranteed fallback. If Realtime proves unreliable in production, switch to periodic polling (30-second interval) as a degraded but functional mode.

medium impact medium prob integration

If a peer mentor submits a draft while offline and then submits the same claim again after connectivity is restored (thinking the first attempt failed), duplicate claims may be persisted in Supabase.

Mitigation & Contingency

Mitigation: Assign a client-generated idempotency key (UUID) to each draft at creation time. The submission service sends this key as an upsert key to Supabase, preventing duplicate inserts. The draft is marked 'submitted' locally after first successful upload.

Contingency: Implement a server-side duplicate detection trigger on the expense_claims table checking (activity_id, claimant_id, created_date) within a 24-hour window and returning the existing record ID rather than inserting a duplicate.