critical priority low complexity database pending database specialist Tier 0

Acceptance Criteria

A claim_receipts table exists in Supabase with columns: id (uuid PK), claim_id (uuid FK → expense_claims), receipt_id (uuid FK → receipts), created_at (timestamptz), created_by (uuid FK → auth.users)
RLS is enabled on claim_receipts; SELECT/INSERT/DELETE policies restrict access so a user can only operate on rows where created_by = auth.uid() or where they own the parent claim
linkReceiptToClaim(claimId, receiptId) inserts a new row and returns the created ClaimReceipt entity; throws a typed DomainException if the claim or receipt does not exist or does not belong to the current user
getReceiptsForClaim(claimId) returns a List<Receipt> of all receipts linked to the claim; returns an empty list (not an error) when the claim has no receipts
removeReceiptFromClaim(claimId, receiptId) deletes the association row; throws if the association does not exist or the user does not have permission; does NOT delete the receipt file itself
All three methods propagate Supabase PostgrestException as typed repository exceptions (not raw exceptions)
Repository is tested against a local Supabase instance (or mock) covering the happy path and the unauthorized-access path
No raw SQL strings in Dart code — all queries use the Supabase Dart client fluent API

Technical Requirements

frameworks
Flutter
Supabase Dart client
apis
Supabase REST/PostgREST (via supabase_flutter package)
Supabase RLS policies (SQL)
data models
ClaimReceipt
Receipt
ExpenseClaim
performance requirements
getReceiptsForClaim must complete in under 500ms on a warm connection for claims with up to 20 receipts
Batch link operations (future-proofing) should use a single upsert call, not N inserts
security requirements
RLS policies must be the sole enforcement of data isolation — never filter by user ID only in Dart code
claim_id and receipt_id must be validated as valid UUIDs before the Supabase call to prevent malformed queries
created_by column populated from auth.uid() server-side via DEFAULT, not supplied by the client

Execution Context

Execution Tier
Tier 0

Tier 0 - 440 tasks

Implementation Notes

Follow the existing repository pattern in the codebase: abstract interface (ClaimReceiptRepository) + concrete Supabase implementation (SupabaseClaimReceiptRepository). Inject the SupabaseClient via constructor for testability. The RLS SQL policy for SELECT should be: CREATE POLICY select_own ON claim_receipts FOR SELECT USING (created_by = auth.uid()); similarly for DELETE. For INSERT, use a WITH CHECK instead of USING.

Consider a compound unique constraint on (claim_id, receipt_id) to prevent duplicate links — this also means linkReceiptToClaim can use upsert with onConflict: 'claim_id,receipt_id' for idempotency. Map PostgrestException status codes to domain exceptions in a private _handleError(PostgrestException) method shared across the class.

Testing Requirements

Write unit tests using a mocked SupabaseClient (implement a MockSupabaseClient with mocktail): (1) linkReceiptToClaim returns a ClaimReceipt on success, (2) linkReceiptToClaim throws RepositoryException on PostgrestException with code 23503 (FK violation), (3) getReceiptsForClaim returns empty list when query returns [], (4) removeReceiptFromClaim throws NotFoundException when delete affects 0 rows. Additionally, write an integration test against a local Supabase instance (docker-compose) that verifies RLS blocks a second user from reading or deleting another user's claim_receipts rows.

Run integration tests in CI using the Supabase CLI's local dev stack.

Component
Receipt Attachment Service
service high
Epic Risks (3)
high impact medium prob technical

Non-blocking upload creates a race condition: if the claim record is submitted and saved before the upload completes, the storage path may never be written to the claim_receipts table, leaving the claim with a missing receipt that was nonetheless required.

Mitigation & Contingency

Mitigation: Design the attachment service to queue a completion callback that writes the storage path to the claim record upon upload completion, even after the claim form has submitted. Use a local task queue with persistence to survive app backgrounding. Test the race condition explicitly with simulated slow uploads.

Contingency: If the async path association proves unreliable, fall back to blocking upload before claim submission with a clear progress indicator, accepting the UX trade-off in exchange for data integrity.

high impact medium prob scope

The offline capture requirement (cache locally, sync when connected) significantly increases state management complexity. If the offline queue is not durable, receipts captured without connectivity may be lost when the app is killed, causing claim submission failures users are not aware of.

Mitigation & Contingency

Mitigation: Persist the offline upload queue to local storage (e.g., Hive or SQLite) on every state transition. Implement background sync using WorkManager (Android) and BGTaskScheduler (iOS). Scope the initial delivery to online-only flow if offline sync cannot be adequately tested before release.

Contingency: Ship without offline support in the first release, displaying a clear 'Upload requires connection' message. Add offline sync as a follow-on task once the core online flow is validated in production.

medium impact low prob integration

The inline bottom sheet presentation within a multi-step wizard can conflict with existing modal navigation and back-button handling, particularly if the expense wizard itself uses nested navigation or custom route management.

Mitigation & Contingency

Mitigation: Review the expense wizard navigation architecture before implementation. Use showModalBottomSheet with barrier dismissal disabled to prevent accidental dismissal. Coordinate with the expense wizard team on modal stacking behavior and ensure the camera sheet does not interfere with wizard step transitions.

Contingency: If modal stacking causes navigation issues, present the camera sheet as a full-screen dialog using PageRouteBuilder with a transparent barrier, preserving wizard state via the existing Bloc while still appearing inline.