fix(transactions): repair pre-#1990 stranded rows through a dry-run-first, per-company RPC (#2350)

* fix(transactions): repair pre-#1990 stranded rows through a dry-run-first, per-company RPC

Rows marked as business before categorize failed closed (#1990) but never
given a verifikat sit as is_business = true with no anchor in any of the
three booking locations. The worklist predicate is is_business IS NULL, so
they are unbooked and invisible: silent missing lopande bokforing.

repair_stranded_transactions(p_company_id, p_dry_run, p_skip_locked,
p_actor, p_correlation_id) lists the stranded shape (dry run, default) or,
for one company, resets the same triple the engine's storno path resets
(is_business, category, reconciliation_method) so the rows return to Att
bokfora. The UPDATE re-asserts the full predicate in the same statement,
never touches a journal entry, and writes one BankTransactionStrandedRepaired
behandlingshistorik event per row in the same transaction. service_role
only; a write needs a company id and an actor.

scripts/repair-stranded-categorized-transactions.ts prints the per-company
breakdown split by sandbox and lock state, and writes only after a typed
confirmation that repeats the row count. The prod run is a founder decision
per company and is not part of this change.

Refs #2057

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv

* fix(transactions): leave locked-period rows alone by default in the stranded-row repair

Swedish compliance review on #2350: a row returned to Att bokfora inside a
locked or closed period cannot be booked in place (BFL 5 kap 5 § keeps
closed periods on the rattelse track), so reopening it for triage must be
an explicit operator choice. p_skip_locked now defaults to true; the script
lists those rows and resets them only with --include-locked. The pg test
covers both the default and the explicit override.

Refs #2350

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-06 19:59:32 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 3c033e466f
commit 92a734f2b3
5 changed files with 761 additions and 0 deletions
@@ -0,0 +1,187 @@
-- Issue #2057: one-shot, re-runnable repair for bank rows that were marked as
-- business before #1990 (categorize fails closed, 2026-08-27) but never got a
-- verifikat. Shape: is_business = true, journal_entry_id IS NULL,
-- is_ignored = false and no anchor in any of the three booking locations
-- (transactions.journal_entry_id, invoice_payments /
-- supplier_invoice_payments, transaction_voucher_links). The canonical
-- "att bokfora" predicate is is_business IS NULL AND is_ignored = false
-- (lib/worklist/categories.ts), so these rows are unbooked AND invisible:
-- silent missing lopande bokforing (BFL 5 kap 2 §).
--
-- The repair resets exactly the triple the engine's storno path resets
-- (lib/bookkeeping/engine.ts reverseEntry: is_business, category,
-- reconciliation_method) so the row returns to "Att bokfora" whole. It never
-- touches journal entries, never creates one, and leaves is_ignored alone.
--
-- Why an RPC and not a script-side UPDATE: PostgREST cannot express the
-- three NOT EXISTS legs inside an UPDATE's WHERE, so a script that selects
-- ids and then updates by id could clobber a row that was booked in between.
-- Here the UPDATE re-asserts the full predicate (via is_transaction_booked)
-- in the same statement, and the behandlingshistorik rows land in the same
-- transaction (BFL 5 kap 11 §: a bulk change to processing is logged, one
-- BankTransactionStrandedRepaired event per row, ids only).
--
-- Callable by service_role only: this is an operator repair driven by
-- scripts/repair-stranded-categorized-transactions.ts, never by a user
-- session, an API key or a loop. Dry run by default; a write requires a
-- single company id and an actor, so no call can reset the whole database.
-- Rows dated in a locked or closed period, or behind the company lock date,
-- are listed but left alone unless p_skip_locked is passed as false: a row
-- returned to Att bokfora there cannot be booked in place (BFL 5 kap 5 §
-- keeps closed periods on the rattelse track), so reopening it for triage
-- is an explicit operator choice, not the default.
INSERT INTO public.processing_event_types (event_type) VALUES
('BankTransactionStrandedRepaired')
ON CONFLICT (event_type) DO NOTHING;
CREATE OR REPLACE FUNCTION public.repair_stranded_transactions(
p_company_id uuid DEFAULT NULL,
p_dry_run boolean DEFAULT true,
p_skip_locked boolean DEFAULT true,
p_actor jsonb DEFAULT NULL,
p_correlation_id uuid DEFAULT NULL
)
RETURNS TABLE (
transaction_id uuid,
company_id uuid,
is_sandbox boolean,
transaction_date date,
amount numeric,
currency text,
previous_category text,
lock_state text,
repaired boolean
)
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = pg_catalog, public
AS $$
DECLARE
v_correlation_id uuid := COALESCE(p_correlation_id, gen_random_uuid());
BEGIN
IF NOT p_dry_run THEN
IF p_company_id IS NULL THEN
RAISE EXCEPTION 'repair_stranded_transactions: p_company_id is required for a write'
USING ERRCODE = '22023';
END IF;
IF p_actor IS NULL
OR jsonb_typeof(p_actor) <> 'object'
OR COALESCE(p_actor->>'type', '') = ''
OR COALESCE(p_actor->>'id', '') = '' THEN
RAISE EXCEPTION 'repair_stranded_transactions: p_actor {type, id} is required for a write'
USING ERRCODE = '22023';
END IF;
END IF;
RETURN QUERY
WITH candidates AS (
SELECT
t.id,
t.company_id,
COALESCE(cs.is_sandbox, false) AS is_sandbox,
t.date,
t.amount,
t.currency,
t.category,
t.reconciliation_method,
CASE
WHEN cs.bookkeeping_locked_through IS NOT NULL
AND t.date <= cs.bookkeeping_locked_through THEN 'company_lock_date'
WHEN fp.id IS NULL THEN 'no_period'
WHEN fp.is_closed THEN 'closed'
WHEN fp.locked_at IS NOT NULL THEN 'locked'
ELSE 'open'
END AS lock_state
FROM public.transactions t
LEFT JOIN public.company_settings cs ON cs.company_id = t.company_id
LEFT JOIN LATERAL (
SELECT p.id, p.is_closed, p.locked_at
FROM public.fiscal_periods p
WHERE p.company_id = t.company_id
AND p.period_start <= t.date
AND p.period_end >= t.date
ORDER BY p.period_start DESC
LIMIT 1
) fp ON true
WHERE (p_company_id IS NULL OR t.company_id = p_company_id)
AND t.is_business = true
AND t.is_ignored = false
AND t.journal_entry_id IS NULL
AND NOT public.is_transaction_booked(t.id)
),
targets AS (
SELECT c.*
FROM candidates c
WHERE NOT p_dry_run
AND (NOT p_skip_locked OR c.lock_state = 'open')
),
updated AS (
UPDATE public.transactions t
SET is_business = NULL,
category = NULL,
reconciliation_method = NULL,
updated_at = now()
FROM targets g
WHERE t.id = g.id
-- Re-asserted inside the write: a row booked between the scan and the
-- update keeps its booking.
AND t.is_business = true
AND t.is_ignored = false
AND t.journal_entry_id IS NULL
AND NOT public.is_transaction_booked(t.id)
RETURNING t.id
),
logged AS (
INSERT INTO public.processing_history
(company_id, correlation_id, aggregate_type, aggregate_id, event_type,
payload, actor, occurred_at)
SELECT
g.company_id,
v_correlation_id,
'BankTransaction',
g.id,
'BankTransactionStrandedRepaired',
jsonb_build_object(
'issue', 2057,
'lock_state', g.lock_state,
'previous', jsonb_build_object(
'is_business', true,
'category', g.category,
'reconciliation_method', g.reconciliation_method
),
'after', jsonb_build_object(
'is_business', NULL,
'category', NULL,
'reconciliation_method', NULL
)
),
p_actor,
now()
FROM targets g
JOIN updated u ON u.id = g.id
RETURNING aggregate_id
)
SELECT
c.id,
c.company_id,
c.is_sandbox,
c.date,
c.amount,
c.currency,
c.category,
c.lock_state,
(u.id IS NOT NULL) AS repaired
FROM candidates c
LEFT JOIN updated u ON u.id = c.id
ORDER BY c.company_id, c.date, c.id;
END;
$$;
COMMENT ON FUNCTION public.repair_stranded_transactions(uuid, boolean, boolean, jsonb, uuid) IS
'Issue #2057. Lists (dry run, default) or repairs, per company, bank rows stranded as is_business = true with no verifikat anchor, resetting is_business/category/reconciliation_method to NULL so they return to Att bokfora, and logs one BankTransactionStrandedRepaired event per row. Rows in locked or closed periods are skipped unless p_skip_locked = false. service_role only; a write requires p_company_id and p_actor.';
REVOKE ALL ON FUNCTION public.repair_stranded_transactions(uuid, boolean, boolean, jsonb, uuid) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.repair_stranded_transactions(uuid, boolean, boolean, jsonb, uuid) TO service_role;
NOTIFY pgrst, 'reload schema';