critical priority medium complexity backend pending backend specialist Tier 2

Acceptance Criteria

approveClaim() returns ClaimTransitionFailure(reason: invalidState) if the claim's current state is not ClaimStateSubmitted
rejectClaim() returns ClaimTransitionFailure(reason: invalidState) if the claim's current state is not ClaimStateSubmitted
Both methods return ClaimTransitionFailure(reason: insufficientPermissions) if the coordinatorUserId does not hold the coordinator role for the claim's organizational unit
approveClaim() persists ClaimApprovalDecision with decision=approved, actorUserId=coordinatorUserId, optional comment, timestamp=DateTime.now(UTC)
rejectClaim() persists ClaimApprovalDecision with decision=rejected, actorUserId=coordinatorUserId, justification text (nullable), timestamp=DateTime.now(UTC)
Both methods call ExpenseClaimStatusRepository.updateStatus() with the correct new state (ClaimStateApproved or ClaimStateRejected) carrying actorUserId and timestamp
Both methods record a ClaimAuditEvent with the appropriate eventType (coordinatorApproved / coordinatorRejected), actorUserId, timestamp, and optional comment/justification
approveClaim() returns ClaimTransitionSuccess(newState: ClaimStateApproved(...)) on success
rejectClaim() returns ClaimTransitionSuccess(newState: ClaimStateRejected(...)) on success
Repository exceptions are caught and returned as ClaimTransitionFailure(reason: repositoryError) — no exceptions propagate to callers
Decision persistence and status update are atomic — both succeed or both are rolled back

Technical Requirements

frameworks
Dart
Flutter
apis
UserRoleRepository.hasRole(userId, role: 'coordinator', organizationId)
ClaimApprovalDecisionRepository.saveDecision()
ExpenseClaimStatusRepository.updateStatus()
ClaimAuditEventRepository.record()
Supabase transaction support
data models
ClaimApprovalDecision
ClaimState
ClaimAuditEvent
UserRole
ClaimTransitionResult
performance requirements
Role check must use a cached role lookup (TTL 60s) to avoid an extra DB round-trip on every approval action
Decision + status update must be atomic via Supabase transaction; total latency must stay under 2 seconds
security requirements
Role verification must be performed server-side via Supabase RLS or a server function — client-side role checks are advisory only
A coordinator must only be able to approve/reject claims belonging to their own organizational unit; cross-org approval must return insufficientPermissions
justification text for rejectClaim() must not exceed 1000 characters; enforce at service layer before persistence
All approval/rejection actions must be non-repudiably logged with coordinatorUserId, timestamp, and claim ID for Bufdir audit compliance

Execution Context

Execution Tier
Tier 2

Tier 2 - 518 tasks

Can start after Tier 1 completes

Implementation Notes

Implement approveClaim() and rejectClaim() as separate methods on ApprovalWorkflowServiceImpl; they share common validation logic (state check + role check) that should be extracted into a private _validateCoordinatorTransition(claimId, coordinatorUserId) method returning Either or a similar early-exit pattern. For the atomic transaction, prefer a Supabase RPC (e.g., coordinator_approve_claim / coordinator_reject_claim) over client-side sequential calls — this avoids the state where the decision is saved but the status update failed. The role check should call UserRoleRepository.hasRole() which itself should query the user_roles table scoped to the organizational unit of the claim — not a global role. Ensure the comment/justification parameter is trimmed and capped before reaching the repository.

Add dartdoc to both methods explicitly stating the preconditions (claim must be in submitted state, actor must be coordinator) so future callers understand the contract.

Testing Requirements

Unit tests (flutter_test): mock UserRoleRepository, ClaimApprovalDecisionRepository, ExpenseClaimStatusRepository, ClaimAuditEventRepository. For approveClaim(): test (1) valid coordinator + submitted claim → returns approved state; (2) non-coordinator user → returns insufficientPermissions; (3) non-submitted claim → returns invalidState; (4) repository failure → returns repositoryError; (5) audit event recorded with coordinatorApproved type; (6) decision record has correct actor and optional comment. For rejectClaim(): mirror the same cases plus (7) justification text is persisted correctly; (8) justification over 1000 chars is rejected. Integration test: create a submitted claim in Supabase test environment, call approveClaim(), assert DB state is approved with correct coordinator ID and a matching audit event row.

Component
Approval Workflow Service
service high
Epic Risks (3)
high impact high prob technical

The ThresholdEvaluationService is described as shared Dart logic used both client-side and in the Edge Function. Supabase Edge Functions run Deno/TypeScript, not Dart, meaning the threshold logic must be maintained in two languages and can diverge, causing the server to reject legitimate client submissions.

Mitigation & Contingency

Mitigation: Implement the threshold logic as a single TypeScript module in the Edge Function and call it via a thin Dart HTTP client wrapper for client-side preview feedback only. The server is always authoritative; the client version is purely for UX (showing the user whether their claim will auto-approve before they submit).

Contingency: If dual-language maintenance is unavoidable, create a shared golden test file (JSON fixtures with inputs and expected outputs) that is run against both implementations in CI to detect divergence immediately.

medium impact medium prob technical

A peer mentor could double-tap the submit button or a network retry could trigger a duplicate submission, causing the ApprovalWorkflowService to attempt two concurrent state transitions from draft→submitted for the same claim, potentially resulting in two audit events or conflicting statuses.

Mitigation & Contingency

Mitigation: Implement idempotency in the ApprovalWorkflowService using a database-level unique constraint on (claim_id, from_status, to_status) per transition, combined with a UI-level submission lock (disable button after first tap until response returns).

Contingency: Add a deduplication check at the start of every state transition method that returns the existing state if an identical transition is already in progress or completed within the last 10 seconds.

high impact medium prob scope

Claims with multiple expense lines (e.g., mileage + parking) must have their combined total evaluated against the threshold. If individual lines are added asynchronously or the evaluation runs before all lines are persisted, the auto-approval decision may be computed on an incomplete set of expense lines.

Mitigation & Contingency

Mitigation: The Edge Function always fetches all expense lines from the database (not from the client payload) before computing the threshold decision. Define a clear claim submission contract that requires all expense lines to be persisted before the submit action is called.

Contingency: Add a validation step in ApprovalWorkflowService that counts expected vs. persisted expense lines before allowing the transition, returning a validation error if lines are missing.