From 92a734f2b3094423d45a0d9f412a0811ca8faaf3 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:59:32 +0200 Subject: [PATCH] fix(transactions): repair pre-#1990 stranded rows through a dry-run-first, per-company RPC (#2350) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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 Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv --------- Co-authored-by: Claude Fable 5.1 --- DECISIONS.md | 1 + lib/processing-history/append.ts | 1 + ...epair-stranded-categorized-transactions.ts | 293 ++++++++++++++++++ ...906170107_repair_stranded_transactions.sql | 187 +++++++++++ .../repair-stranded-transactions.pg.test.ts | 279 +++++++++++++++++ 5 files changed, 761 insertions(+) create mode 100644 scripts/repair-stranded-categorized-transactions.ts create mode 100644 supabase/migrations/20260906170107_repair_stranded_transactions.sql create mode 100644 tests/pg/repair-stranded-transactions.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index acbdc6f5..f45bac6e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1623,4 +1623,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-06] Recurring invoice month phase (yearly in February, quarterly Feb/May/Aug/Nov) is exposed as a first/next invoice date (start_date on create, next_run_date on update; web dialog + MCP), not the reporter's "months offset" dropdown: an offset is a derived value relative to now that changes meaning when the interval changes, while the date maps one-to-one onto the next_run_date column that already anchors the phase, so no migration and no per-interval range rules. A date off the day_of_month grid is refused (400) instead of normalized, because the cron advances from the due date and an off-grid first run would drift back to day_of_month on the second run. [2026-09-06] Voucher series names live on the existing Verifikationsserier list in settings, not a separate group: the list already enumerates the letters in use, and a name belongs next to the letter it names. Rows are the union of used, configured and named letters so a freshly assigned series can be named before its first verifikat. [2026-09-06] Declined the request to import only SIE accounts with IB, UB or saldo <> 0: an inactive account is a harmless row in the chart, and dropping accounts breaks re-imports of later years that reference them. The chart imports whole. +[2026-09-06] Issue #2057 (pre-#1990 stranded rows, is_business = true with no verifikat anchor, invisible in Att bokfora): repaired through a service_role-only RPC repair_stranded_transactions(p_company_id, p_dry_run, p_skip_locked, p_actor) driven by scripts/repair-stranded-categorized-transactions.ts, not a script-side UPDATE and not a worklist predicate change. PostgREST cannot re-assert the three NOT EXISTS anchor legs inside an UPDATE, so a select-then-update script could clobber a row booked in between; the RPC re-checks is_transaction_booked() in the same statement and writes one BankTransactionStrandedRepaired behandlingshistorik event per row in the same transaction. Widening the att-bokfora predicate to "not booked" was rejected: is_business IS NULL is load-bearing (lockPeriod guard, badges, idx_transactions_company_unbooked, RPCs, MCP), the 2026-08-27 decision chose fail-closed over a fourth state, and a predicate change would resurface every company's rows at once with no per-company decision and no audit record. The write is dry-run by default, needs one company id plus an actor, skips sandbox companies unless asked, and skips rows in locked or closed periods unless asked (Swedish review on PR #2350: a row returned to Att bokfora there cannot be booked in place, BFL 5 kap 5 §, so reopening it for triage is an explicit choice). The prod run waits for the founder's go per company. [2026-09-06] reverseEntry releases the bank rows of a reversed verifikat through one RPC, release_reversed_entry_transactions (migration 20260906172540): a single data-modifying CTE nulls the pointer column (journal_entry_id, is_business, category, reconciliation_method) of every transaction that pointed at the reversed entry AND drops those rows' transaction_voucher_links to other verifikat (a residual booking's role 'other' anchor to the small residual verifikat, #2061, Option B of the issue). Chosen over teaching each role-blind gate (bulk_book_transactions' junction EXISTS, fetchJunctionLinkedTxIds behind the unmatched list, is_transaction_booked(), the reconciliation bridge) the bank_line-only rule: that forks the meaning of "booked" per surface and keeps counting a row as matched while the ledger explains a few kronor of it. First cut was two PostgREST statements (read ids, reset, delete); CodeRabbit's point that a failed read or a link created between the reset and the delete recreates the half-anchored row was right, so the reset and the drop moved into one statement under the UPDATE's row locks. SECURITY INVOKER so RLS and the writer-role trigger apply exactly as to the direct statements. Links to the reversed entry itself stay with the engine's junction cleanup (bulk-book N=1 writes a pointer and a bank_line row to the same entry). The residual verifikat stays posted and surfaces as unmatched, which is honest because its main sibling is gone. The 2026-08-29 bank_line-only re-booking guards stay as defense for rows left behind earlier; measured 2026-09-06 on prod, zero non-bank_line junction rows exist, so nothing needs repair. transaction_voucher_links is a mutable reconciliation index (ON DELETE CASCADE from both sides, plain member delete policy, koppla-bort deletes it freely), not rakenskapsinformation, so a hard delete does not touch the BFL audit chain. diff --git a/lib/processing-history/append.ts b/lib/processing-history/append.ts index 64b9533f..96b15cab 100644 --- a/lib/processing-history/append.ts +++ b/lib/processing-history/append.ts @@ -46,6 +46,7 @@ type SupabaseClientLike = Pick export const PROCESSING_EVENT_TYPES = [ 'AttachmentsTruncated', 'BankTransactionDuplicateDismissed', + 'BankTransactionStrandedRepaired', 'ChannelQuestionAnswered', 'ChannelQuestionAsked', 'ChannelQuestionExpired', diff --git a/scripts/repair-stranded-categorized-transactions.ts b/scripts/repair-stranded-categorized-transactions.ts new file mode 100644 index 00000000..b9674421 --- /dev/null +++ b/scripts/repair-stranded-categorized-transactions.ts @@ -0,0 +1,293 @@ +#!/usr/bin/env npx tsx +/** + * Issue #2057: repair bank rows that were marked as business before #1990 + * (categorize fails closed) but never got a verifikat. They sit as + * is_business = true, journal_entry_id NULL, is_ignored = false with no + * anchor in any of the three booking locations, and the worklist predicate + * (is_business IS NULL) hides them: unbooked and invisible. + * + * The work happens in the repair_stranded_transactions RPC (migration + * 20260906170107): the UPDATE re-asserts the full predicate in the same + * statement, resets the same triple the engine's storno path resets + * (is_business, category, reconciliation_method -> NULL) so the rows return + * to "Att bokfora", never touches a journal entry, and writes one + * BankTransactionStrandedRepaired behandlingshistorik event per row in the + * same transaction. This script only lists, confirms and drives it. + * + * Dry run by default, for every company or one: + * + * npx tsx scripts/repair-stranded-categorized-transactions.ts + * npx tsx scripts/repair-stranded-categorized-transactions.ts --company + * + * A write needs ONE company, the actor to record, and a typed confirmation + * that repeats the row count of a fresh dry run: + * + * npx tsx scripts/repair-stranded-categorized-transactions.ts --company --actor-user-id --execute [--include-locked] + * + * Flags: + * --env env file to load (default .env.local; the banner + * prints the URL so the target is never a guess) + * --company restrict to one company (required with --execute) + * --actor-user-id the person running the repair, recorded as the actor + * --execute write; without it nothing is changed + * --include-locked also reset rows whose date falls in a locked or + * closed period, or behind the company lock date; by + * default those are listed and left alone (they could + * not be booked in place anyway, BFL 5 kap 5 §) + * --include-sandbox allow a sandbox company (is_sandbox = true); those are + * normally left to cleanup_sandbox_user + * --verbose print every row in the dry run + * + * Never run by a loop: the founder decides per company (issue #2057). + */ + +import { config } from 'dotenv' +import { createInterface } from 'node:readline/promises' +import { randomUUID } from 'node:crypto' +import { createClient, type SupabaseClient } from '@supabase/supabase-js' + +function arg(name: string): string | undefined { + const i = process.argv.indexOf(`--${name}`) + return i >= 0 ? process.argv[i + 1] : undefined +} +function flag(name: string): boolean { + return process.argv.includes(`--${name}`) +} + +const ENV_FILE = arg('env') ?? '.env.local' +config({ path: ENV_FILE }) + +const COMPANY_ID = arg('company') ?? null +const ACTOR_USER_ID = arg('actor-user-id') ?? null +const EXECUTE = flag('execute') +const INCLUDE_LOCKED = flag('include-locked') +const INCLUDE_SANDBOX = flag('include-sandbox') +const VERBOSE = flag('verbose') + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL +const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY +if (!supabaseUrl || !serviceRoleKey) { + console.error(`Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in ${ENV_FILE}`) + process.exit(1) +} +if (COMPANY_ID && !UUID_RE.test(COMPANY_ID)) { + console.error('--company must be a uuid') + process.exit(1) +} +if (EXECUTE && !COMPANY_ID) { + console.error('--execute needs --company : the repair is decided one company at a time') + process.exit(1) +} +if (EXECUTE && (!ACTOR_USER_ID || !UUID_RE.test(ACTOR_USER_ID))) { + console.error('--execute needs --actor-user-id (recorded in behandlingshistorik)') + process.exit(1) +} + +interface RepairRow { + transaction_id: string + company_id: string + is_sandbox: boolean + transaction_date: string + amount: number | string + currency: string + previous_category: string | null + lock_state: 'open' | 'locked' | 'closed' | 'company_lock_date' | 'no_period' + repaired: boolean +} + +const supabase: SupabaseClient = createClient(supabaseUrl, serviceRoleKey, { + auth: { persistSession: false, autoRefreshToken: false }, +}) + +/** Set-returning RPCs are paged by PostgREST like tables: read every page. */ +async function callRepair(params: { + companyId: string | null + dryRun: boolean + actor?: { type: 'user'; id: string; label: string } + correlationId?: string +}): Promise { + const PAGE = 1000 + const rows: RepairRow[] = [] + // A write is a single call: paging a data-modifying RPC would re-run it. + const maxPages = params.dryRun ? 1000 : 1 + for (let page = 0; page < maxPages; page++) { + const from = page * PAGE + const { data, error } = await supabase + .rpc('repair_stranded_transactions', { + p_company_id: params.companyId, + p_dry_run: params.dryRun, + p_skip_locked: !INCLUDE_LOCKED, + p_actor: params.actor ?? null, + p_correlation_id: params.correlationId ?? null, + }) + .range(from, from + PAGE - 1) + if (error) throw new Error(`repair_stranded_transactions failed: ${error.message}`) + const chunk = (data ?? []) as RepairRow[] + rows.push(...chunk) + if (chunk.length < PAGE) break + } + return rows +} + +interface CompanySummary { + companyId: string + isSandbox: boolean + rows: number + firstDate: string + lastDate: string + byLock: Record +} + +function summarize(rows: RepairRow[]): CompanySummary[] { + const map = new Map() + for (const r of rows) { + let s = map.get(r.company_id) + if (!s) { + s = { + companyId: r.company_id, + isSandbox: r.is_sandbox, + rows: 0, + firstDate: r.transaction_date, + lastDate: r.transaction_date, + byLock: {}, + } + map.set(r.company_id, s) + } + s.rows += 1 + if (r.transaction_date < s.firstDate) s.firstDate = r.transaction_date + if (r.transaction_date > s.lastDate) s.lastDate = r.transaction_date + s.byLock[r.lock_state] = (s.byLock[r.lock_state] ?? 0) + 1 + } + return [...map.values()].sort((a, b) => b.rows - a.rows) +} + +function lockLine(byLock: Record): string { + return Object.entries(byLock) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}=${v}`) + .join(' ') +} + +async function companyNames(ids: string[]): Promise> { + const names = new Map() + for (let i = 0; i < ids.length; i += 100) { + const { data, error } = await supabase + .from('companies') + .select('id, name') + .in('id', ids.slice(i, i + 100)) + if (error) throw new Error(`companies lookup failed: ${error.message}`) + for (const c of (data ?? []) as Array<{ id: string; name: string | null }>) { + names.set(c.id, c.name ?? '') + } + } + return names +} + +function printBreakdown(title: string, summaries: CompanySummary[], names: Map) { + const total = summaries.reduce((n, s) => n + s.rows, 0) + console.log(`\n${title}: ${total} rows in ${summaries.length} companies`) + for (const s of summaries) { + console.log( + ` ${s.companyId} ${String(s.rows).padStart(4)} rows ${s.firstDate}..${s.lastDate} ` + + `[${lockLine(s.byLock)}] ${names.get(s.companyId) ?? ''}`, + ) + } +} + +async function confirm(expectedCount: number): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }) + try { + const answer = await rl.question( + `\nType "REPAIR ${expectedCount}" to reset these ${expectedCount} rows to Att bokfora: `, + ) + if (answer.trim() !== `REPAIR ${expectedCount}`) { + console.log('Aborted, nothing written.') + process.exit(2) + } + } finally { + rl.close() + } +} + +async function main() { + console.log('---------------------------------------------------------') + console.log('Stranded categorized transactions repair (issue #2057)') + console.log('---------------------------------------------------------') + console.log('Env file :', ENV_FILE) + console.log('Supabase URL:', supabaseUrl) + console.log('Company :', COMPANY_ID ?? '(all)') + console.log('Locked rows :', INCLUDE_LOCKED ? 'INCLUDED' : 'listed, left alone') + console.log('Mode :', EXECUTE ? 'EXECUTE (writes)' : 'DRY RUN (no writes)') + console.log('---------------------------------------------------------') + + const dry = await callRepair({ companyId: COMPANY_ID, dryRun: true }) + const inScope = INCLUDE_LOCKED ? dry : dry.filter((r) => r.lock_state === 'open') + const real = summarize(inScope.filter((r) => !r.is_sandbox)) + const sandbox = summarize(inScope.filter((r) => r.is_sandbox)) + const names = await companyNames([...real, ...sandbox].map((s) => s.companyId)) + + printBreakdown('Non-sandbox companies', real, names) + printBreakdown('Sandbox companies (left to cleanup_sandbox_user)', sandbox, names) + if (!INCLUDE_LOCKED) { + const skipped = dry.length - inScope.length + if (skipped > 0) { + console.log(` +${skipped} rows sit in locked/closed periods or behind the company lock date and are left alone (pass --include-locked to reset them too).`) + } + } + if (VERBOSE) { + console.log('\nRows:') + for (const r of inScope) { + console.log( + ` ${r.company_id} ${r.transaction_id} ${r.transaction_date} ${r.amount} ${r.currency} ` + + `${r.previous_category ?? '-'} ${r.lock_state}`, + ) + } + } + + if (!EXECUTE) { + console.log('\nDry run only. Re-run with --company --actor-user-id --execute to write.') + return + } + + const target = inScope + if (target.length === 0) { + console.log('\nNothing to repair for this company.') + return + } + if (target.some((r) => r.is_sandbox) && !INCLUDE_SANDBOX) { + console.error('\nThis is a sandbox company. Pass --include-sandbox to repair it anyway.') + process.exit(1) + } + + await confirm(target.length) + + const correlationId = randomUUID() + const written = await callRepair({ + companyId: COMPANY_ID, + dryRun: false, + actor: { + type: 'user', + id: ACTOR_USER_ID as string, + label: 'scripts/repair-stranded-categorized-transactions.ts (#2057)', + }, + correlationId, + }) + const repaired = written.filter((r) => r.repaired) + const skippedByRace = written.filter((r) => !r.repaired && (INCLUDE_LOCKED || r.lock_state === 'open')) + console.log(`\nRepaired ${repaired.length} rows (correlation ${correlationId}).`) + if (skippedByRace.length > 0) { + console.log(`${skippedByRace.length} rows were booked or changed between the dry run and the write and were left alone.`) + } + + const after = await callRepair({ companyId: COMPANY_ID, dryRun: true }) + const remaining = INCLUDE_LOCKED ? after : after.filter((r) => r.lock_state === 'open') + console.log(`Remaining stranded rows for this company: ${remaining.length}`) +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err) + process.exit(1) +}) diff --git a/supabase/migrations/20260906170107_repair_stranded_transactions.sql b/supabase/migrations/20260906170107_repair_stranded_transactions.sql new file mode 100644 index 00000000..3771f3f5 --- /dev/null +++ b/supabase/migrations/20260906170107_repair_stranded_transactions.sql @@ -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'; diff --git a/tests/pg/repair-stranded-transactions.pg.test.ts b/tests/pg/repair-stranded-transactions.pg.test.ts new file mode 100644 index 00000000..3c0ca0d2 --- /dev/null +++ b/tests/pg/repair-stranded-transactions.pg.test.ts @@ -0,0 +1,279 @@ +import { randomUUID } from 'node:crypto' +import type { PoolClient } from 'pg' +import { describe, expect, it } from 'vitest' +import { + insertFiscalPeriod, + insertPostedJournalEntry, + insertTransaction, + seedCompany, +} from '@/tests/pg/fixtures' +import { getPool, runAsServiceRole, withUserContext } from '@/tests/pg/setup' + +/** + * Covers 20260906170107_repair_stranded_transactions (issue #2057). + * + * A row stranded before #1990 (is_business = true, no verifikat anchor in any + * of the three booking locations, not ignored) is the ONLY shape the RPC + * touches. Rows anchored through journal_entry_id or transaction_voucher_links, + * untriaged rows, ignored rows and private rows are left alone. The write + * resets the storno triple (is_business, category, reconciliation_method), + * keeps is_ignored, logs one BankTransactionStrandedRepaired event per row, + * and is idempotent. Dry run is the default and writes nothing. Rows in a + * locked or closed period are listed but skipped unless p_skip_locked is + * false. A write needs a company id and an actor. Only service_role may + * execute it. + */ + +interface PgError extends Error { + code?: string +} + +const ACTOR = JSON.stringify({ type: 'user', id: randomUUID(), label: 'pg-real' }) + +async function insertStranded(params: { + companyId: string + userId: string + date?: string + category?: string + isIgnored?: boolean + isBusiness?: boolean | null +}): Promise { + const id = await insertTransaction({ + companyId: params.companyId, + userId: params.userId, + date: params.date ?? '2026-06-01', + isIgnored: params.isIgnored ?? false, + }) + await getPool().query( + `UPDATE public.transactions + SET is_business = $2, category = $3, reconciliation_method = 'manual' + WHERE id = $1`, + [id, params.isBusiness === undefined ? true : params.isBusiness, params.category ?? 'expense_office'], + ) + return id +} + +async function txState(id: string) { + const { rows } = await getPool().query<{ + is_business: boolean | null + category: string | null + reconciliation_method: string | null + is_ignored: boolean + journal_entry_id: string | null + }>( + `SELECT is_business, category, reconciliation_method, is_ignored, journal_entry_id + FROM public.transactions WHERE id = $1`, + [id], + ) + return rows[0] +} + +async function callRepair( + client: PoolClient, + params: { companyId: string | null; dryRun: boolean; skipLocked?: boolean; actor?: string | null }, +) { + const { rows } = await client.query<{ + transaction_id: string + company_id: string + is_sandbox: boolean + lock_state: string + previous_category: string | null + repaired: boolean + }>( + `SELECT transaction_id, company_id, is_sandbox, lock_state, previous_category, repaired + FROM public.repair_stranded_transactions($1, $2, $3, $4::jsonb, NULL)`, + [ + params.companyId, + params.dryRun, + params.skipLocked ?? true, + params.actor === undefined ? ACTOR : params.actor, + ], + ) + return rows +} + +describe('repair_stranded_transactions (issue #2057)', () => { + it('lists only the stranded shape and repairs it with a behandlingshistorik record, idempotently', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + + const stranded = await insertStranded({ companyId, userId, category: 'expense_office' }) + const untriaged = await insertTransaction({ companyId, userId }) + const ignored = await insertStranded({ companyId, userId, isIgnored: true }) + const privateRow = await insertStranded({ companyId, userId, isBusiness: false }) + + const entryId = await insertPostedJournalEntry({ userId, companyId, fiscalPeriodId }) + const direct = await insertStranded({ companyId, userId }) + await getPool().query(`UPDATE public.transactions SET journal_entry_id = $2 WHERE id = $1`, [ + direct, + entryId, + ]) + + const viaJunction = await insertStranded({ companyId, userId }) + await getPool().query( + `INSERT INTO public.transaction_voucher_links + (user_id, company_id, transaction_id, journal_entry_id, allocated_amount, role) + VALUES ($1, $2, $3, $4, -100, 'other')`, + [userId, companyId, viaJunction, entryId], + ) + + // Dry run: the stranded row only, nothing written. + const dry = await runAsServiceRole((c) => callRepair(c, { companyId, dryRun: true })) + expect(dry.map((r) => r.transaction_id)).toEqual([stranded]) + expect(dry[0]).toMatchObject({ + company_id: companyId, + is_sandbox: false, + lock_state: 'open', + previous_category: 'expense_office', + repaired: false, + }) + expect((await txState(stranded)).is_business).toBe(true) + + // Write: the storno triple is reset, is_ignored stays, the rest untouched. + const written = await runAsServiceRole((c) => callRepair(c, { companyId, dryRun: false })) + expect(written).toHaveLength(1) + expect(written[0]).toMatchObject({ transaction_id: stranded, repaired: true }) + expect(await txState(stranded)).toEqual({ + is_business: null, + category: null, + reconciliation_method: null, + is_ignored: false, + journal_entry_id: null, + }) + expect((await txState(untriaged)).is_business).toBeNull() + expect((await txState(ignored)).is_business).toBe(true) + expect((await txState(privateRow)).is_business).toBe(false) + expect((await txState(direct)).is_business).toBe(true) + expect((await txState(direct)).journal_entry_id).toBe(entryId) + expect((await txState(viaJunction)).is_business).toBe(true) + + // The journal entry is untouched. + const { rows: entries } = await getPool().query<{ status: string }>( + `SELECT status FROM public.journal_entries WHERE id = $1`, + [entryId], + ) + expect(entries[0].status).toBe('posted') + + // One event per repaired row, ids and the previous triple only. + const { rows: history } = await getPool().query<{ + aggregate_type: string + company_id: string + payload: { previous: { category: string }; issue: number } + actor: { type: string } + }>( + `SELECT aggregate_type, company_id, payload, actor + FROM public.processing_history + WHERE event_type = 'BankTransactionStrandedRepaired' AND aggregate_id = $1`, + [stranded], + ) + expect(history).toHaveLength(1) + expect(history[0]).toMatchObject({ + aggregate_type: 'BankTransaction', + company_id: companyId, + payload: { issue: 2057, previous: { category: 'expense_office' } }, + actor: { type: 'user' }, + }) + + // Idempotent: a second write finds nothing. + const again = await runAsServiceRole((c) => callRepair(c, { companyId, dryRun: false })) + expect(again).toHaveLength(0) + }) + + it('names the lock state, skips locked rows by default and resets them only on request', async () => { + const { userId, companyId } = await seedCompany() + await insertFiscalPeriod({ + userId, + companyId, + name: '2025', + periodStart: '2025-01-01', + periodEnd: '2025-12-31', + isClosed: true, + }) + const inClosed = await insertStranded({ companyId, userId, date: '2025-03-10' }) + const inOpen = await insertStranded({ companyId, userId, date: '2026-03-10' }) + const noPeriod = await insertStranded({ companyId, userId, date: '2020-03-10' }) + + const dry = await runAsServiceRole((c) => callRepair(c, { companyId, dryRun: true })) + const byId = new Map(dry.map((r) => [r.transaction_id, r.lock_state])) + expect(byId.get(inClosed)).toBe('closed') + expect(byId.get(inOpen)).toBe('open') + expect(byId.get(noPeriod)).toBe('no_period') + + // Default write: only the open row moves; closed and no-period rows stay. + const written = await runAsServiceRole((c) => callRepair(c, { companyId, dryRun: false })) + expect(written.filter((r) => r.repaired).map((r) => r.transaction_id)).toEqual([inOpen]) + expect((await txState(inClosed)).is_business).toBe(true) + expect((await txState(noPeriod)).is_business).toBe(true) + expect((await txState(inOpen)).is_business).toBeNull() + + // Explicit p_skip_locked = false resets the rest as well. + const forced = await runAsServiceRole((c) => + callRepair(c, { companyId, dryRun: false, skipLocked: false }), + ) + expect(forced.filter((r) => r.repaired).map((r) => r.transaction_id).sort()).toEqual( + [inClosed, noPeriod].sort(), + ) + expect((await txState(inClosed)).is_business).toBeNull() + expect((await txState(noPeriod)).is_business).toBeNull() + }) + + it('scopes a write to one company and flags sandbox companies', async () => { + const a = await seedCompany() + const b = await seedCompany() + await getPool().query( + `INSERT INTO public.company_settings (user_id, company_id, is_sandbox) + VALUES ($1, $2, true) + ON CONFLICT (company_id) DO UPDATE SET is_sandbox = true`, + [b.userId, b.companyId], + ) + const rowA = await insertStranded({ companyId: a.companyId, userId: a.userId }) + const rowB = await insertStranded({ companyId: b.companyId, userId: b.userId }) + + const all = await runAsServiceRole((c) => callRepair(c, { companyId: null, dryRun: true })) + expect(all.find((r) => r.transaction_id === rowA)?.is_sandbox).toBe(false) + expect(all.find((r) => r.transaction_id === rowB)?.is_sandbox).toBe(true) + + await runAsServiceRole((c) => callRepair(c, { companyId: a.companyId, dryRun: false })) + expect((await txState(rowA)).is_business).toBeNull() + expect((await txState(rowB)).is_business).toBe(true) + }) + + it('refuses a write without a company id or without an actor', async () => { + const { companyId } = await seedCompany() + const noCompany = await runAsServiceRole((c) => + callRepair(c, { companyId: null, dryRun: false }).then( + () => null, + (e: PgError) => e, + ), + ) + expect(noCompany?.code).toBe('22023') + const noActor = await runAsServiceRole((c) => + callRepair(c, { companyId, dryRun: false, actor: null }).then( + () => null, + (e: PgError) => e, + ), + ) + expect(noActor?.code).toBe('22023') + }) + + it('is executable by service_role only', async () => { + const { userId, companyId } = await seedCompany() + const { rows } = await getPool().query<{ role: string; can: boolean }>( + `SELECT r AS role, + has_function_privilege(r, 'public.repair_stranded_transactions(uuid, boolean, boolean, jsonb, uuid)', 'EXECUTE') AS can + FROM unnest(ARRAY['anon', 'authenticated', 'service_role']) AS r`, + ) + expect(Object.fromEntries(rows.map((r) => [r.role, r.can]))).toEqual({ + anon: false, + authenticated: false, + service_role: true, + }) + + const denied = await withUserContext(userId, (c) => + callRepair(c, { companyId, dryRun: true }).then( + () => null, + (e: PgError) => e, + ), + ) + expect(denied?.code).toBe('42501') + }) +})