critical priority medium complexity backend pending backend specialist Tier 3

Acceptance Criteria

If ExpenseDraft has no receipt image, upload step is skipped and submission continues
If receipt image is present, ReceiptStorageAdapter.upload() is called before database persistence
On successful upload, the returned storage URL is attached to the expense payload before persistence
On upload failure, a ReceiptUploadError is returned and no database write is performed
ReceiptUploadError carries enough context for the caller to retry the upload without prompting the user to re-select the image
Upload is not retried automatically by the service — retry is the caller's responsibility
Receipt file size exceeding the configured maximum (e.g. 10 MB) returns a ReceiptTooLarge typed error before attempting the upload
Accepted file types (JPEG, PNG, PDF) are validated before upload — invalid types return ReceiptInvalidFormat
Upload uses Supabase Storage via ReceiptStorageAdapter abstraction — service does not call Supabase SDK directly
Unit tests use a mock ReceiptStorageAdapter to simulate success, network failure, and size/format errors

Technical Requirements

frameworks
Flutter
Riverpod
Dart
apis
Supabase Storage API (via ReceiptStorageAdapter abstraction)
data models
ExpenseDraft
ReceiptUploadResult
ReceiptUploadError
ReceiptTooLarge
ReceiptInvalidFormat
performance requirements
Upload must stream bytes, not buffer entire image in memory, to support large receipts on low-RAM devices
Upload timeout must be configurable (default 30 seconds)
security requirements
Receipt images must be uploaded to a private Supabase Storage bucket — not public
Storage path must include the user's organisation ID and a UUID to prevent path guessing
Presigned URL or RLS policy must restrict access to the owning user and org admins

Execution Context

Execution Tier
Tier 3

Tier 3 - 413 tasks

Can start after Tier 2 completes

Implementation Notes

Define ReceiptStorageAdapter as an abstract class (interface) in the domain layer. The concrete Supabase implementation lives in the infrastructure layer. Inject via Riverpod so tests can swap it out. Store receipt at path: `receipts/{org_id}/{user_id}/{uuid}.{ext}`.

Return the full storage URL (or a signed URL if bucket is private) from the adapter. Use Dart's `path` package to extract file extension for format validation. Do not compress images in this service — compression is a UI-layer concern before the image reaches the service. The method signature should be: `Future _uploadReceiptIfPresent(ExpenseDraft draft)` returning a sealed result type.

Testing Requirements

Unit tests using flutter_test with a mock ReceiptStorageAdapter (implement the interface, return controlled responses). Test cases: (1) no receipt → upload not called, payload unchanged; (2) receipt present, upload succeeds → URL attached to payload; (3) upload throws network error → ReceiptUploadError returned, no DB call; (4) file too large → ReceiptTooLarge before upload attempt; (5) invalid format → ReceiptInvalidFormat before upload attempt. Integration test against Supabase local emulator to validate bucket permissions and URL format.

Component
Expense Submission 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.