high priority low complexity backend pending backend specialist Tier 1

Acceptance Criteria

ExpenseCalculationService.calculateTransitZone(CalculationInput input) returns a TransitZoneResult with: selectedZoneIds (List<String>), zoneAmounts (Map<String, double> — zone ID to NOK amount), totalReimbursementAmount (sum of all zone amounts), and missingZoneIds (List<String> — zones selected but not found in config)
When all requested zone IDs are found in config, missingZoneIds is empty and totalReimbursementAmount is the correct sum
When one or more zone IDs are not present in org config, those zones contribute 0.0 to the total, are listed in missingZoneIds, and the method still returns a result without throwing
When the input contains an empty zoneIds list, the method returns a TransitZoneResult with totalReimbursementAmount of 0.0
Multi-zone trips (e.g., zone A + zone B) produce totalReimbursementAmount equal to zoneAmounts['A']! + zoneAmounts['B']!
Zone lookup is case-insensitive on the zone identifier string
Total is rounded to 2 decimal places
If orgId has no transit zone config at all, the method returns zero gracefully (no exception) — this matches the 'returns zero when no zone config is present' requirement

Technical Requirements

frameworks
Flutter
Dart
Riverpod
apis
expense-type-config (zone ID to amount map per org)
data models
CalculationInput
TransitZoneResult
ExpenseTypeConfig
performance requirements
Zone lookup is a Map lookup — O(n) where n is number of selected zones, negligible for typical transit trips (1–4 zones)
security requirements
Zone amounts must be fetched from server-side config, not from client-provided amounts
Zone IDs must be validated against an allowlist from config to prevent injection of arbitrary zone strings

Execution Context

Execution Tier
Tier 1

Tier 1 - 540 tasks

Can start after Tier 0 completes

Implementation Notes

Transit zone config in ExpenseTypeConfigRepository should be modelled as Map> — outer key orgId, inner key zoneId, value is NOK amount. The calculateTransitZone method should fold over selectedZoneIds using the map lookup, accumulating both matched amounts and unmatched IDs. Case-insensitive lookup: normalize both the config keys and the input zone IDs to lowercase before comparison. The missingZoneIds field is important for UX: the BLoC can surface a warning like 'Zone X not configured — contact your administrator' without blocking submission.

This graceful degradation is explicitly required by the task description. Do not conflate transit zone with flat receipt (transit ticket) — transit zone is a zone-rate model (lookup table), flat receipt is a declared-amount model. Keep them as separate methods and result types.

Testing Requirements

Write unit tests using flutter_test covering: (1) single zone found in config — correct amount returned, (2) two zones — correct sum, (3) one known zone + one unknown zone — unknown in missingZoneIds, known contributes its amount, (4) all zones unknown — missingZoneIds contains all, total is 0.0, (5) empty zone list — total 0.0 no error, (6) org with no transit config at all — total 0.0 no error, (7) zone IDs differing only in case are matched (case-insensitive), (8) fractional zone amounts sum and round to 2dp correctly. Mock ExpenseTypeConfigRepository.

Component
Expense Calculation Service
service medium
Epic Risks (2)
high impact medium prob dependency

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.

medium impact low prob technical

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.