critical priority medium complexity api pending api specialist Tier 3

Acceptance Criteria

persistFinalSelection(claimContextId, selectedTypeIds, ExpenseCalculationResult) upserts a row to the expense_claims table in Supabase using the authenticated Supabase client
The upsert uses claimContextId as the conflict key so re-submitting the same claim updates the existing row rather than creating a duplicate
All line items from ExpenseCalculationResult are mapped to the Xledger accounting export fields (account_code, amount, vat_code, description) and stored in an expense_line_items sub-table or JSONB column
All line items are also mapped to the Dynamics 365 export fields (ledger_dimension, transaction_currency, amount_mst) and stored in a dynamics_export_payload JSONB column
The upsert and all related line item writes are wrapped in a Supabase transaction (or RPC function) so a failure midway leaves no partial rows
Row-Level Security on the expense_claims table is enforced: the write succeeds only if the authenticated user's organisation matches the claim's organisation_id
After a successful persist, clearDraft(claimContextId) is called to remove the local draft
If the Supabase write fails (network error, RLS violation, constraint violation), the method throws a typed ExpensePersistenceException with the error detail; the local draft is NOT cleared
The method is idempotent: calling it twice with the same claimContextId and data produces the same database state as calling it once
Integration tests verify: successful upsert creates correct row, re-upsert updates without duplication, RLS rejection throws ExpensePersistenceException, partial failure leaves no orphaned line items

Technical Requirements

frameworks
Flutter
supabase_flutter
apis
Supabase PostgreSQL 15 REST/PostgREST
Xledger REST API (field mapping only — no direct call from this task)
Microsoft Dynamics 365 REST API (field mapping only — no direct call from this task)
data models
activity_type
activity
claim_event
performance requirements
Upsert must complete within 3 seconds on a standard mobile connection — use a single RPC call rather than multiple sequential PostgREST requests to minimise round-trips
Line item serialisation must occur on an isolate or be lightweight enough to not block the UI thread
security requirements
The Supabase service role key must NEVER be used from the mobile client — use only the anon key with RLS enforcement
Organisation context must be derived from the authenticated JWT claims, not from a client-supplied field, to prevent cross-org writes
Expense amounts are financial data — ensure TLS is enforced on all Supabase calls (default in supabase_flutter)
The Xledger and Dynamics field mappings must be validated server-side via an Edge Function before export — do not trust client-side mapping alone
All PII in expense records (peer mentor name, amounts) is subject to GDPR — ensure the expense_claims table has appropriate RLS and is excluded from public schema exposure

Execution Context

Execution Tier
Tier 3

Tier 3 - 413 tasks

Can start after Tier 2 completes

Implementation Notes

Implement persistFinalSelection as an async method in ExpenseTypeRepository. Use supabase.rpc('finalise_expense_claim', params: {...}) rather than individual PostgREST upserts — this lets the database handle the transaction boundary atomically. Define a Supabase Edge Function or database function 'finalise_expense_claim' that accepts the full claim payload and performs the upsert + line item insert in a single transaction. The Dart client only makes one network call.

For Xledger field mapping, define a static XledgerLineItemMapper class that converts an ExpenseCalculationLineItem to a map of Xledger-specific fields — keep this mapping tested independently of the repository. Do the same for DynamicsLineItemMapper. This separation makes it easy to update mappings when Xledger or Dynamics schema changes without touching the repository. The workshop notes confirm Blindeforbundet uses Xledger and HLF uses Dynamics — the mapper must be parameterised by organisation to produce the correct output format per org.

Testing Requirements

Integration tests using a Supabase test project (or local Supabase via Docker). Test cases: (1) valid finalisation upserts correct row with all fields populated, (2) re-upsert with same claimContextId updates existing row — no duplicate rows, (3) upsert with mismatched organisation_id is rejected by RLS and throws ExpensePersistenceException, (4) simulated network failure throws ExpensePersistenceException and local draft is preserved, (5) Xledger field mapping produces correct account_code and vat_code for each expense type. Mock the Xledger and Dynamics clients in unit tests; use real Supabase in integration tests. Verify clearDraft is called after successful persist using a spy on the draft repository.

Component
Expense Type Repository
data low
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.