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
parent 3c033e466f
commit 92a734f2b3
5 changed files with 761 additions and 0 deletions
+1
View File
@@ -1623,4 +1623,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
+1
View File
@@ -46,6 +46,7 @@ type SupabaseClientLike = Pick<SupabaseClient, 'from'>
export const PROCESSING_EVENT_TYPES = [
'AttachmentsTruncated',
'BankTransactionDuplicateDismissed',
'BankTransactionStrandedRepaired',
'ChannelQuestionAnswered',
'ChannelQuestionAsked',
'ChannelQuestionExpired',
@@ -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 <uuid>
*
* 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 <uuid> --actor-user-id <uuid> --execute [--include-locked]
*
* Flags:
* --env <file> env file to load (default .env.local; the banner
* prints the URL so the target is never a guess)
* --company <uuid> restrict to one company (required with --execute)
* --actor-user-id <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 <uuid>: 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 <uuid> (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<RepairRow[]> {
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<string, number>
}
function summarize(rows: RepairRow[]): CompanySummary[] {
const map = new Map<string, CompanySummary>()
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, number>): string {
return Object.entries(byLock)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join(' ')
}
async function companyNames(ids: string[]): Promise<Map<string, string>> {
const names = new Map<string, string>()
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<string, string>) {
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<void> {
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 <uuid> --actor-user-id <uuid> --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)
})
@@ -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';
@@ -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<string> {
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')
})
})