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:
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user