Files
accounted/supabase/migrations/20260722134114_pending_operations_failed_partial_status.sql
T
Jakob Wennberg 3e1ea29d02 fix(pending-ops): record posted ids and land failed_partial instead of clean rejected after partial commits (#842) (#1110)
Multi-step executors (match_transaction_invoice, credit_invoice) post an
irreversible voucher or persist a credit note and then run later fallible
steps. A failure there previously marked the whole op status=rejected,
hiding the posted entity and its id from operators.

- new migration 20260722134114: add failed_partial to the
  pending_operations status CHECK and treat it as terminal in both
  immutability triggers (immutable, undeletable, never re-claimable)
- PartialCommitError + ExecutorResult.partialPostedIds carry the posted
  ids; the dispatcher writes status=failed_partial with
  result_data.posted_ids and returns code=partial_commit
- instrument only the two named executors; hoist the read-only
  settlement-account resolution above the storno in the match executor
- consumer sweep: status union + query schema widened, failed_partial
  folds into the Avvisade tab with a badge and posted-ids detail line,
  bulk/reject routes and MCP tools message it explicitly, worklist and
  expiry sweep intentionally untouched (not pending work)
- tests: pg-real coverage for the new terminal semantics, dispatcher unit
  tests for both partial paths plus byte-for-byte regression guards

Fixes #842

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 18:33:49 +02:00

87 lines
3.9 KiB
PL/PgSQL

-- Migration: add 'failed_partial' terminal status to pending_operations.
--
-- Issue #842 (follow-up deferred from PR #841): several multi-step executors
-- post an irreversible voucher and then perform a later fallible step (e.g.
-- match_transaction_invoice posts a storno before building the payment JE;
-- credit_invoice persists the credit note before posting its JE). When the
-- later step failed, the dispatcher marked the WHOLE op 'rejected', which
-- misrepresents reality: an immutable voucher/credit note already exists.
--
-- 'failed_partial' is the honest terminal state for that case: the operation
-- did NOT complete, but side-effects were posted and their ids are recorded
-- in result_data.posted_ids so an operator can find the orphaned voucher.
--
-- Semantics:
-- - terminal: rows are immutable and undeletable once in this state, same
-- as 'committed'/'rejected' (BFL 7 kap.: the posted underlag and the
-- record of what happened must be unalterable)
-- - NOT re-committable: the CAS claim only picks up status = 'pending',
-- which already excludes it; the immutability trigger below additionally
-- blocks any status rewrite
-- - NOT pending work: worklist/pending counts filter on 'pending' only
-- =============================================================================
-- 1. pending_operations_status_check: add 'failed_partial'
-- =============================================================================
-- Same drop + re-add pattern as 20260504100000 used when adding 'committing'.
ALTER TABLE public.pending_operations
DROP CONSTRAINT IF EXISTS pending_operations_status_check;
ALTER TABLE public.pending_operations
ADD CONSTRAINT pending_operations_status_check
CHECK (status IN ('pending', 'committing', 'committed', 'rejected', 'failed_partial'));
-- =============================================================================
-- 2. terminal-state immutability: treat 'failed_partial' as terminal
-- =============================================================================
-- Replaces the functions from 20260504100000 (never edit that migration) so
-- the UPDATE/DELETE blockers cover the new terminal state too.
CREATE OR REPLACE FUNCTION public.enforce_pending_operations_immutability()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.status IN ('committed', 'rejected', 'failed_partial') THEN
RAISE EXCEPTION
'pending_operations row % is in terminal state % and cannot be modified (BFL 7 kap.)',
OLD.id, OLD.status
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS pending_operations_immutability ON public.pending_operations;
CREATE TRIGGER pending_operations_immutability
BEFORE UPDATE ON public.pending_operations
FOR EACH ROW
EXECUTE FUNCTION public.enforce_pending_operations_immutability();
CREATE OR REPLACE FUNCTION public.enforce_pending_operations_no_delete()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.status IN ('committed', 'rejected', 'failed_partial') THEN
RAISE EXCEPTION
'pending_operations row % is in terminal state % and cannot be deleted (BFL 7 kap.)',
OLD.id, OLD.status
USING ERRCODE = 'check_violation';
END IF;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS pending_operations_no_delete ON public.pending_operations;
CREATE TRIGGER pending_operations_no_delete
BEFORE DELETE ON public.pending_operations
FOR EACH ROW
EXECUTE FUNCTION public.enforce_pending_operations_no_delete();
-- Note: pending_ops_auto_commit_status (20260430120000) constrained
-- auto_committed_at to status = 'committed', but it was dropped together with
-- the auto-commit feature in 20260505190027, so there is nothing to expand
-- for the new status.
-- =============================================================================
-- 3. PostgREST schema reload
-- =============================================================================
NOTIFY pgrst, 'reload schema';