Implement per-km mileage reimbursement formula
epic-expense-type-selection-core-services-task-002 — Implement the per-kilometre rate calculation within ExpenseCalculationService. The formula must read the org-specific rate from expense-type-config, multiply by declared distance, apply any org-defined caps, and populate a typed PerKmResult line item in the ExpenseCalculationResult. Include handling for zero-distance edge case and validation that the mileage type is active before computing.
Acceptance Criteria
Technical Requirements
Execution Context
Tier 1 - 540 tasks
Can start after Tier 0 completes
Implementation Notes
Inject ExpenseTypeConfigRepository via Riverpod — do not make the calculation service fetch config directly from Supabase. The config repository should cache org config at app startup or on demand, so calculatePerKm remains synchronous. Use double arithmetic for the calculation but apply (result * 100).round() / 100 for 2dp rounding — avoid toStringAsFixed which returns a String. The cap logic should be: effectiveDistance = min(declaredDistance, orgCap ??
double.infinity). Define ExpenseTypeInactiveException and ExpenseConfigNotFoundException as typed exception classes extending Exception — do not use generic Exception or string throws. Keep the method pure: given the same CalculationInput and config, it always returns the same result. Do not persist or log inside the calculation — the caller (aggregation method or BLoC) handles persistence.
Testing Requirements
Write unit tests using flutter_test with a mocked ExpenseTypeConfigRepository. Test cases: (1) standard calculation — 30 km at 4.90 NOK/km with no cap returns 147.00, (2) capped distance — 80 km with 50 km cap returns 50 * rate with capApplied true, (3) zero distance returns 0.0 without error, (4) inactive expense type throws ExpenseTypeInactiveException, (5) missing config throws ExpenseConfigNotFoundException, (6) fractional km (12.5 km) produces correctly rounded result, (7) rate with many decimal places rounds to 2dp. Mock ExpenseTypeConfigRepository using Riverpod override or Dart mockito. No integration tests required at this stage.
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.