critical priority medium complexity backend pending backend specialist Tier 1

Acceptance Criteria

ExpenseCalculationService.calculatePerKm(CalculationInput input) returns a PerKmResult with: ratePerKm (from org config), declaredDistanceKm, effectiveDistanceKm (capped if cap is configured), reimbursementAmount (effectiveDistanceKm * ratePerKm), and capApplied (bool)
When declaredDistanceKm is 0, the method returns a PerKmResult with reimbursementAmount of 0.0 and does not throw
When the mileage expense type is inactive (isActive == false in config), the method throws an ExpenseTypeInactiveException with the expense type ID in the message
When no org rate is configured, the method throws an ExpensConfigNotFoundException with orgId and expenseTypeId
When declaredDistanceKm exceeds the org cap, effectiveDistanceKm is clamped to the cap and capApplied is true
Rate precision: reimbursementAmount is rounded to 2 decimal places using standard rounding (not truncation)
The method reads rates exclusively from the injected ExpenseTypeConfigRepository — no hardcoded rates
All exception types are documented with their trigger conditions in code comments

Technical Requirements

frameworks
Flutter
Dart
Riverpod
apis
expense-type-config (internal config repository/service)
data models
CalculationInput
PerKmResult
ExpenseCalculationResult
ExpenseTypeConfig
performance requirements
Config lookup must be synchronous or already-resolved (no async per-calculation round-trip to Supabase) — config is pre-loaded at service construction
Calculation for a single per-km entry must complete in under 1ms
security requirements
Org rate must be fetched from server-side config, not from client-provided input, to prevent rate manipulation
Input distance must be validated as a positive finite double before computation

Execution Context

Execution Tier
Tier 1

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.

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.