high priority medium complexity backend pending backend specialist Tier 1

Acceptance Criteria

ExpenseCalculationService.calculateFlatReceipt(CalculationInput input) returns a FlatReceiptResult with: declaredAmount, receiptRequired (true when declaredAmount >= 100 NOK), hasReceiptAttached (from input), isIncomplete (true when receiptRequired && !hasReceiptAttached), and requiresManualApproval (bool)
When declaredAmount >= 100 NOK and no receipt is attached, isIncomplete is true and the result is still returned (not an exception) — the BLoC layer decides whether to block submission
When declaredAmount < 100 NOK, receiptRequired is false and isIncomplete is false regardless of receipt attachment status
The 100 NOK threshold is read from org config (not hardcoded) so it can be overridden per organisation
requiresManualApproval is true when the expense type's approval routing config requires manual attestation (separate from the 50 km auto-approval rule which applies to mileage)
When declaredAmount is 0 or negative, the method returns a result with reimbursementAmount of 0 and logs a warning — it does not throw
The method correctly handles all flat receipt subtypes: tollroad, parking, transit ticket — differentiated by expenseTypeId in CalculationInput
Unit tests cover all threshold boundary cases (99 NOK, 100 NOK, 101 NOK)

Technical Requirements

frameworks
Flutter
Dart
Riverpod
apis
expense-type-config (receipt threshold, approval routing config)
data models
CalculationInput
FlatReceiptResult
ExpenseTypeConfig
performance requirements
Threshold comparison is a simple numeric check — no async operations
Method must be synchronous
security requirements
Receipt threshold (100 NOK) must come from server config, not client-provided value, to prevent threshold bypass
declaredAmount must be validated as non-negative finite double

Execution Context

Execution Tier
Tier 1

Tier 1 - 540 tasks

Can start after Tier 0 completes

Implementation Notes

Model the receipt threshold as ExpenseTypeConfig.receiptThresholdNok (double) with a default of 100.0. The isIncomplete flag is the key design decision here: the HLF requirement says 'flagged as incomplete rather than rejected immediately' — this means the calculation layer must not block the result, only annotate it. The BLoC layer will inspect isIncomplete across all line items before enabling the submit button. Keep the three flat receipt subtypes (tollroad, parking, transit ticket) handled by the same calculateFlatReceipt method — use expenseTypeId to determine config lookup key, not a separate method per type.

This avoids the feilkombinasjon problem (user selecting both km and bussbillett) being enforced at the calculation level — that mutual exclusion is enforced at the selection UI layer, not here. Avoid making FlatReceiptResult a subtype of PerKmResult — they are sibling sealed class subtypes of ExpenseLineItem.

Testing Requirements

Write unit tests using flutter_test covering: (1) amount 99 NOK — receiptRequired false, isIncomplete false, (2) amount exactly 100 NOK — receiptRequired true, isIncomplete depends on hasReceiptAttached, (3) amount 101 NOK without receipt — isIncomplete true, result still returned, (4) amount 101 NOK with receipt — isIncomplete false, (5) amount 0 NOK — returns zero result without exception, (6) org config with custom threshold (e.g., 200 NOK) is respected, (7) requiresManualApproval set to true when config specifies manual routing. Mock ExpenseTypeConfigRepository. Target 100% branch coverage on the threshold and receipt logic paths.

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.