diff --git a/DECISIONS.md b/DECISIONS.md index 1a68b3ab..28e21b93 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1338,4 +1338,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-28] /migrate SIE guard skips company-info-only runs (all entity flags false) and the wizard derives "SIE already imported" from the preview OR this session's successful /import-sie results: company info writes no accounts, balances or subledger rows, so the BFL rationale does not apply; and the one-shot preview went stale after phase 1 succeeded and phase 2 failed, falsely blocking an entities-only retry (#2000 review). [2026-08-28] get_vat_ruta_source_lines (the VAT ruta drill-down) now applies the same four exclusions as get_vat_declaration_totals (the filed figure): posted closing entries, source_type 'vat_settlement', the two kontantmetod year-end reversals, and settlement-SHAPED entries (a line on a ruta account plus a line on 2650/1650). It previously filtered on company, status and date only, so expanding a ruta listed verifikat that are not in the number it claims to explain, with no total on the panel to reveal the mismatch. Measured on prod 2026-08-28: 322 posted/reversed entries carrying 26xx lines across 214 companies sit in those excluded classes. A momsdeklaration is räkenskapsinformation (BFL 5 kap.) and this drill-down is what substantiates a filed figure, so the two must agree exactly. The exclusion CTEs are lifted VERBATIM from the figure rather than re-derived: any divergence reintroduces exactly this bug, and an identical copy is easy to diff when the figure changes. Settlement-shape is detected against journal_entry_lines directly instead of through the figure's vat_lines CTE, which is EQUIVALENT not a shortcut (p_ruta_accounts = VAT_ACCOUNTS and p_net_accounts = ['2650','1650'] are both strict subsets of the figure's p_accounts, so restricting to vat_lines first cannot change which entries match); that keeps p_accounts meaning "the accounts of the ruta being expanded" without a fourth account parameter. opening_balance entries are deliberately NOT excluded: the figure exempts them from `shaped`, which keeps their lines IN the totals, so dropping them here would break the equality in the other direction (pinned by its own test). VAT_ACCOUNTS is now exported from lib/reports/vat-declaration.ts so the route detects shape from the same list the figure uses; a second copy is what let the two disagree. DROP + CREATE OR REPLACE, not CREATE OR REPLACE alone: the signature gains p_ruta_accounts/p_net_accounts and adding parameters registers a second overload PostgREST cannot choose between (trap documented in 20260421140000); OR REPLACE on the new arity keeps the file re-runnable. Verified the new pg test actually catches the bug by reinstalling the old body and watching 3 of 4 tests fail with the real misreporting (2611: drill-down 250/240 vs figure 0/200), then restoring. [2026-08-28] Bankavstamning NULL-link fix scoped to transfer legs with contradicting sign (20260828220000): the naive rule (NULL counts only for the primary account) and the formula-only variant (drop far-leg-settled vouchers from unexplained) were both simulated against prod and rejected; the naive rule worsened 4 of 11 affected cards (worst -37 000 kr false alarm on single-leg vouchers with no user action available), the formula variant blew up healthy cards by up to 474 550 kr. The shipped three-condition rule changes 24 vouchers on 7 cards in 6 companies, all verified per-card. +[2026-08-29] PR #1756 replacement (rebind on PSD2 remap, amends the 2026-07-09 #916 entry): when upsertFromPsd2 resolves a duplicate row for the same connection+uid, the duplicate's MOVABLE transactions (unbooked, unmatched, not anchored via transaction_voucher_links or a payment row: the #1570 single-row move gate) are rebound onto the promoted row BEFORE the duplicate is resolved, so categorize/booking proposes the ledger the user just mapped instead of the overflow slot; a duplicate that still holds booked or anchored rows is demoted to manual as before and never deleted (their vouchers carry the old 19xx line, and the #1643 orphan guards handle the released twin). The contributor's unconditional rebind-all-then-delete was narrowed for that reason. [2026-08-29] Database errors now keep their SQLSTATE: new lib/errors/db-error.ts (dbError/errorCauseTag), applied at the 54 `throw new Error(\`Database error: ${err.message}\`)` sites in the MCP server AND, far more importantly, at lib/supabase/fetch-all.ts:74 where `throw new Error(error.message)` was the single highest-traffic strip point in the codebase (31 callers; every paginated read). isTransientFailure() checks the driver code FIRST and 57014 (statement timeout) is already in TRANSIENT_SQLSTATES, so discarding it turned a retryable timeout into UNKNOWN_ERROR ("Något gick fel. Försök igen."), which an agent cannot dispatch on. Traced end to end: gnubok_query_journal -> fetchEntryLines -> fetchAllRows (code stripped here) -> the tool's own sanitizeDbError, which ALREADY had a correct TRANSIENT_ERROR branch with a "retry or narrow with date_from/date_to" hint that could never fire because getStructuredError saw an anonymous Error. Measured on prod over 60 days with bot actors excluded: 1 024 real-agent failures, 645 UNKNOWN_ERROR across 60 actors and 57 companies; query_journal failed 164 times at p50 8 110 ms while every other failing tool sat at 1-315 ms; 82 retry streaks, 462 wasted repeat calls, 53.1% of error calls inside a streak. fetch-all passes context=null so the driver message stays VERBATIM (sanitizeDbError and other callers match on the existing text; this change adds the code, it does not reword). Attaching `code` is safe because extractCode() only accepts /^[A-Z_]+$/ and every SQLSTATE/PostgREST code contains digits, so it cannot hijack the application error registry (pinned by a test). dbError also never renders the literal "undefined": a driver-level failure with no message produced "Database error: undefined", the string that made these unsearchable. errorCauseTag() returns a PII-safe SQLSTATE for telemetry; the raw driver message can quote row values in a constraint violation and belongs in the server log, never in event_log. NOT ratcheted: check:types reports 538 vs baseline 539 because main fixed an unrelated error in own-account-detector.test.ts after the baseline was set; the gate only fails on an INCREASE, so the baseline is left alone rather than adding unrelated churn to this diff. diff --git a/lib/cash-accounts/__tests__/service.test.ts b/lib/cash-accounts/__tests__/service.test.ts index 68576818..feb8731d 100644 --- a/lib/cash-accounts/__tests__/service.test.ts +++ b/lib/cash-accounts/__tests__/service.test.ts @@ -493,18 +493,37 @@ interface UpsertStub { connections?: ConnRow[] /** Existing row for (company_id, bank_connection_id, external_uid) on another ledger. */ ownRow?: { id: string; is_primary: boolean } | null - /** Whether the duplicate ownRow has linked transactions. */ - ownHasTransactions?: boolean + /** + * Transactions bound to the duplicate ownRow. `booked` rows carry a + * journal_entry_id (or a confirmed invoice match); `anchor` rows are bound + * to a verifikat WITHOUT journal_entry_id through the named table (bulk-book + * N>1 via transaction_voucher_links, multi-allocation via a payment row). + * The mock mutates this list as rebinds land, so the demote-or-delete probe + * sees the post-rebind state like the real table would. + */ + ownTransactions: OwnTx[] upsertError?: { message: string } | null + /** Fail the pre-check SELECT on this anchor table. */ + anchorError?: { table: AnchorTable; message: string } | null + /** Fail the rebind UPDATE on transactions. */ + rebindError?: { message: string } | null // Captured writes: updates: Array<{ payload: Record; id: unknown }> deletes: unknown[] upserts: Array> rpcCalls: Array<{ fn: string; args: Record }> - /** .eq() filters applied to the linked-transactions probe. */ + /** Filters applied to the transactions SELECTs (candidate scan + probe). */ transactionFilters: Array<{ col: string; value: unknown }> + /** Rebind UPDATEs from the overflow duplicate onto the promoted row, with their filters. */ + txRebinds: Array<{ payload: Record; filters: TxFilter[] }> + /** Anchor tables consulted before the rebind, the ids they were probed with and their eq filters. */ + anchorProbes: Array<{ table: string; ids: string[]; filters: Array<{ col: string; value: unknown }> }> } +type AnchorTable = 'transaction_voucher_links' | 'invoice_payments' | 'supplier_invoice_payments' +type OwnTx = { id: string; booked?: boolean; anchor?: AnchorTable } +type TxFilter = { op: 'eq' | 'is' | 'in'; col: string; value: unknown } + function makeUpsertStub(partial: Partial = {}): UpsertStub { return { updates: [], @@ -512,6 +531,9 @@ function makeUpsertStub(partial: Partial = {}): UpsertStub { upserts: [], rpcCalls: [], transactionFilters: [], + txRebinds: [], + anchorProbes: [], + ownTransactions: [], ...partial, } } @@ -536,18 +558,89 @@ function makeUpsertSupabase(stub: UpsertStub) { } } if (table === 'transactions') { - const chain = { - select: vi.fn(() => chain), + const selectChain = { eq: vi.fn((col: string, value: unknown) => { stub.transactionFilters.push({ col, value }) - return chain + return selectChain }), - limit: vi.fn(() => + is: vi.fn((col: string, value: unknown) => { + stub.transactionFilters.push({ col, value }) + return selectChain + }), + order: vi.fn(() => selectChain), + // Candidate scan (movable rows): mirrors the .is(null) column gate. + range: vi.fn((from: number, to: number) => Promise.resolve({ - data: stub.ownHasTransactions ? [{ id: 'tx-1' }] : [], + data: stub.ownTransactions + .filter((t) => !t.booked) + .slice(from, to + 1) + .map((t) => ({ id: t.id })), error: null, }), ), + // Demote-or-delete probe: anything still bound after the rebind. + limit: vi.fn(() => + Promise.resolve({ + data: stub.ownTransactions.slice(0, 1).map((t) => ({ id: t.id })), + error: null, + }), + ), + } + return { + select: vi.fn(() => selectChain), + update: vi.fn((payload: Record) => { + const rebind = { payload, filters: [] as TxFilter[] } + stub.txRebinds.push(rebind) + let ids: string[] = [] + const chain = { + eq: vi.fn((col: string, value: unknown) => { + rebind.filters.push({ op: 'eq', col, value }) + return chain + }), + is: vi.fn((col: string, value: unknown) => { + rebind.filters.push({ op: 'is', col, value }) + return chain + }), + in: vi.fn((col: string, value: string[]) => { + rebind.filters.push({ op: 'in', col, value }) + ids = value + return chain + }), + select: vi.fn(() => { + if (stub.rebindError) { + return Promise.resolve({ data: null, error: stub.rebindError }) + } + const moved = stub.ownTransactions.filter((t) => ids.includes(t.id) && !t.booked) + stub.ownTransactions = stub.ownTransactions.filter((t) => !moved.includes(t)) + return Promise.resolve({ data: moved.map((t) => ({ id: t.id })), error: null }) + }), + } + return chain + }), + } + } + if ( + table === 'transaction_voucher_links' || + table === 'invoice_payments' || + table === 'supplier_invoice_payments' + ) { + const filters: Array<{ col: string; value: unknown }> = [] + const chain = { + select: vi.fn(() => chain), + eq: vi.fn((col: string, value: unknown) => { + filters.push({ col, value }) + return chain + }), + in: vi.fn((_col: string, ids: string[]) => { + stub.anchorProbes.push({ table, ids, filters }) + if (stub.anchorError && stub.anchorError.table === table) { + return Promise.resolve({ data: null, error: { message: stub.anchorError.message } }) + } + const rows = stub.ownTransactions + .filter((t) => t.anchor === table && ids.includes(t.id)) + .map((t) => ({ transaction_id: t.id })) + return Promise.resolve({ data: rows, error: null }) + }), } return chain } @@ -748,25 +841,80 @@ describe('upsertFromPsd2', () => { holder: { id: 'row-old', bank_connection_id: 'conn-old' }, connections: [{ id: 'conn-old', status: 'revoked' }], ownRow: { id: 'row-dup', is_primary: false }, - ownHasTransactions: false, }) await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT) expect(stub.deletes).toEqual(['row-dup']) + expect(stub.txRebinds).toHaveLength(0) expect(stub.updates).toHaveLength(1) expect(stub.updates[0].id).toBe('row-old') expect(stub.rpcCalls).toHaveLength(0) }) - it('demotes (not deletes) a duplicate that has linked transactions', async () => { + it('rebinds linked transactions then deletes the overflow duplicate', async () => { + // The reconnect callback mirrored uid-1 onto 1931 and the sync imported + // rows there; the user then mapped the IBAN to 1930. Unbooked rows follow + // the promoted ledger so booking proposes 1930, and the emptied 1931 + // mirror is removed. const stub = makeUpsertStub({ holder: { id: 'row-old', bank_connection_id: 'conn-old' }, connections: [{ id: 'conn-old', status: 'revoked' }], ownRow: { id: 'row-dup', is_primary: false }, - ownHasTransactions: true, + ownTransactions: [{ id: 'tx-1' }, { id: 'tx-2' }], }) await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT) + expect(stub.txRebinds).toHaveLength(1) + expect(stub.txRebinds[0].payload).toEqual({ cash_account_id: 'row-old' }) + // The movable gate (#1570) is re-asserted on the UPDATE itself. + expect(stub.txRebinds[0].filters).toEqual( + expect.arrayContaining([ + { op: 'eq', col: 'company_id', value: 'c1' }, + { op: 'eq', col: 'cash_account_id', value: 'row-dup' }, + { op: 'in', col: 'id', value: ['tx-1', 'tx-2'] }, + { op: 'is', col: 'journal_entry_id', value: null }, + { op: 'is', col: 'invoice_id', value: null }, + { op: 'is', col: 'supplier_invoice_id', value: null }, + ]), + ) + // Junction/payment anchors (no journal_entry_id on the tx) were checked, + // each scoped by company (the callback path runs with a service-role + // client, where RLS does not apply). + expect(stub.anchorProbes.map((p) => p.table).sort()).toEqual([ + 'invoice_payments', + 'supplier_invoice_payments', + 'transaction_voucher_links', + ]) + for (const probe of stub.anchorProbes) { + expect(probe.filters).toEqual([{ col: 'company_id', value: 'c1' }]) + } + expect(stub.deletes).toEqual(['row-dup']) + expect(stub.updates).toHaveLength(1) + expect(stub.updates[0].id).toBe('row-old') + expect(stub.updates[0].payload).toMatchObject({ bank_connection_id: 'conn-new' }) + }) + + it('demotes (not deletes) the duplicate when booked or voucher-anchored rows remain', async () => { + // tx-booked has a journal_entry_id and tx-anchored a transaction_voucher_links + // row: both vouchers carry the old 19xx line, so they stay on the duplicate, + // which survives as a released manual twin (the #1643 guards handle it). + const stub = makeUpsertStub({ + holder: { id: 'row-old', bank_connection_id: 'conn-old' }, + connections: [{ id: 'conn-old', status: 'revoked' }], + ownRow: { id: 'row-dup', is_primary: false }, + ownTransactions: [ + { id: 'tx-movable' }, + { id: 'tx-booked', booked: true }, + { id: 'tx-anchored', anchor: 'transaction_voucher_links' }, + ], + }) + await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT) + + // Only the movable row moved; the anchored one was excluded by the pre-check. + expect(stub.txRebinds).toHaveLength(1) + expect(stub.txRebinds[0].filters).toContainEqual({ op: 'in', col: 'id', value: ['tx-movable'] }) + expect(stub.ownTransactions.map((t) => t.id).sort()).toEqual(['tx-anchored', 'tx-booked']) + expect(stub.deletes).toHaveLength(0) expect(stub.updates).toHaveLength(2) // First write releases the duplicate's PSD2 binding, preserving the row @@ -778,12 +926,126 @@ describe('upsertFromPsd2', () => { expect(stub.updates[1].payload).toMatchObject({ bank_connection_id: 'conn-new' }) }) + it('skips the rebind UPDATE when every bound row is booked', async () => { + const stub = makeUpsertStub({ + holder: { id: 'row-old', bank_connection_id: 'conn-old' }, + connections: [{ id: 'conn-old', status: 'revoked' }], + ownRow: { id: 'row-dup', is_primary: false }, + ownTransactions: [{ id: 'tx-booked', booked: true }], + }) + await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT) + + expect(stub.txRebinds).toHaveLength(0) + expect(stub.anchorProbes).toHaveLength(0) + expect(stub.deletes).toHaveLength(0) + expect(stub.updates[0]).toEqual({ + id: 'row-dup', + payload: { bank_connection_id: null, external_uid: null }, + }) + }) + + it.each(['invoice_payments', 'supplier_invoice_payments'])( + 'keeps a row anchored through %s on the duplicate and demotes it', + async (table) => { + // Multi-allocation (match_batch_allocate) anchors through a payment row + // with journal_entry_id NULL on the transaction, so the column gate alone + // would move it. The pre-check must exclude it. + const stub = makeUpsertStub({ + holder: { id: 'row-old', bank_connection_id: 'conn-old' }, + connections: [{ id: 'conn-old', status: 'revoked' }], + ownRow: { id: 'row-dup', is_primary: false }, + ownTransactions: [{ id: 'tx-movable' }, { id: 'tx-paid', anchor: table }], + }) + await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT) + + expect(stub.txRebinds).toHaveLength(1) + expect(stub.txRebinds[0].filters).toContainEqual({ + op: 'in', + col: 'id', + value: ['tx-movable'], + }) + expect(stub.ownTransactions.map((t) => t.id)).toEqual(['tx-paid']) + expect(stub.deletes).toHaveLength(0) + expect(stub.updates[0]).toEqual({ + id: 'row-dup', + payload: { bank_connection_id: null, external_uid: null }, + }) + expect(stub.updates[1].id).toBe('row-old') + }, + ) + + it('aborts before any cash_accounts write when an anchor pre-check fails', async () => { + const stub = makeUpsertStub({ + holder: { id: 'row-old', bank_connection_id: 'conn-old' }, + connections: [{ id: 'conn-old', status: 'revoked' }], + ownRow: { id: 'row-dup', is_primary: true }, + ownTransactions: [{ id: 'tx-1' }], + anchorError: { table: 'supplier_invoice_payments', message: 'probe boom' }, + }) + await expect( + upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT), + ).rejects.toThrow(/cash_accounts upsert failed: probe boom/) + + expect(stub.txRebinds).toHaveLength(0) + expect(stub.updates).toHaveLength(0) + expect(stub.deletes).toHaveLength(0) + expect(stub.upserts).toHaveLength(0) + expect(stub.rpcCalls).toHaveLength(0) + }) + + it('aborts before any cash_accounts write when the rebind UPDATE fails', async () => { + const stub = makeUpsertStub({ + holder: { id: 'row-old', bank_connection_id: 'conn-old' }, + connections: [{ id: 'conn-old', status: 'revoked' }], + ownRow: { id: 'row-dup', is_primary: true }, + ownTransactions: [{ id: 'tx-1' }], + rebindError: { message: 'rebind boom' }, + }) + await expect( + upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT), + ).rejects.toThrow(/cash_accounts upsert failed: rebind boom/) + + expect(stub.txRebinds).toHaveLength(1) + expect(stub.ownTransactions.map((t) => t.id)).toEqual(['tx-1']) + expect(stub.updates).toHaveLength(0) + expect(stub.deletes).toHaveLength(0) + expect(stub.upserts).toHaveLength(0) + expect(stub.rpcCalls).toHaveLength(0) + }) + + it('chunks the anchor probes and the rebind UPDATE at 100 ids', async () => { + const ids = Array.from({ length: 150 }, (_, i) => `tx-${String(i).padStart(3, '0')}`) + const stub = makeUpsertStub({ + holder: { id: 'row-old', bank_connection_id: 'conn-old' }, + connections: [{ id: 'conn-old', status: 'revoked' }], + ownRow: { id: 'row-dup', is_primary: false }, + ownTransactions: ids.map((id) => ({ id })), + }) + await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT) + + // 3 anchor tables x 2 chunks, each chunk probed against every table. + expect(stub.anchorProbes).toHaveLength(6) + for (const table of [ + 'transaction_voucher_links', + 'invoice_payments', + 'supplier_invoice_payments', + ]) { + const probes = stub.anchorProbes.filter((p) => p.table === table) + expect(probes.map((p) => p.ids)).toEqual([ids.slice(0, 100), ids.slice(100)]) + } + const inLists = stub.txRebinds.map( + (r) => r.filters.find((f) => f.op === 'in' && f.col === 'id')?.value, + ) + expect(inLists).toEqual([ids.slice(0, 100), ids.slice(100)]) + expect(stub.ownTransactions).toHaveLength(0) + expect(stub.deletes).toEqual(['row-dup']) + }) + it('scopes the duplicate linked-transactions probe by company (service-role defense in depth)', async () => { const stub = makeUpsertStub({ holder: { id: 'row-old', bank_connection_id: 'conn-old' }, connections: [{ id: 'conn-old', status: 'revoked' }], ownRow: { id: 'row-dup', is_primary: false }, - ownHasTransactions: false, }) await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT) @@ -800,7 +1062,6 @@ describe('upsertFromPsd2', () => { holder: { id: 'row-old', bank_connection_id: 'conn-old' }, connections: [{ id: 'conn-old', status: 'revoked' }], ownRow: { id: 'row-dup', is_primary: true }, - ownHasTransactions: false, }) await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT) @@ -820,7 +1081,7 @@ describe('upsertFromPsd2', () => { holder: { id: 'row-old', bank_connection_id: 'conn-old' }, connections: [{ id: 'conn-old', status: 'revoked' }], ownRow: { id: 'row-dup', is_primary: true }, - ownHasTransactions: true, + ownTransactions: [{ id: 'tx-booked', booked: true }], }) await upsertFromPsd2(makeUpsertSupabase(stub), 'c1', UPSERT_INPUT) diff --git a/lib/cash-accounts/service.ts b/lib/cash-accounts/service.ts index 71a921f1..3d1ad3c4 100644 --- a/lib/cash-accounts/service.ts +++ b/lib/cash-accounts/service.ts @@ -3,6 +3,7 @@ import type { CashAccount, CashAccountSource, MappingResult } from '@/types' import { createLogger } from '@/lib/logger' import { syncMappedAccounts } from '@/lib/import/account-sync' import { getBASReference } from '@/lib/bookkeeping/bas-reference' +import { fetchAllRows } from '@/lib/supabase/fetch-all' const log = createLogger('cash-accounts') @@ -989,6 +990,104 @@ export async function resolvePsd2LedgerAccount( return { ledgerAccount: allocated, reuseCashAccountId: null, source: 'allocated' } } +/** Max transaction ids per `.in()` filter when rebinding: keeps the request URL short. */ +const REBIND_ID_CHUNK_SIZE = 100 + +function chunkIds(ids: readonly string[], size: number): string[][] { + const out: string[][] = [] + for (let i = 0; i < ids.length; i += size) out.push(ids.slice(i, i + size)) + return out +} + +/** + * Rebind the MOVABLE transactions of one cash_accounts row onto another. + * + * Movable mirrors PATCH /api/transactions/[id]/cash-account (#1570): NOT + * booked (journal_entry_id), NOT confirmed-matched (invoice_id / + * supplier_invoice_id) and NOT anchored to a verifikat through + * transaction_voucher_links or an invoice/supplier-invoice payment row (both + * anchor without setting journal_entry_id on the transaction; see + * lib/transactions/is-booked.ts). An anchored row's voucher carries the 19xx + * line that records which ledger the money moved on, so its binding stays. + * + * The anchor tables cannot be expressed as a NOT EXISTS in a PostgREST update + * filter, so they are consulted in a pre-check; the column gate is re-asserted + * on the UPDATE itself against a concurrent book or auto-match. + * + * Runs before the target row is promoted and none of rebind / demote-or-delete + * / promote is transactional (PostgREST calls). Safe because the two rows share + * (bank_connection_id, external_uid), so their transactions already carry the + * currency the promote is about to write onto the target. + * + * @returns the number of rows rebound + */ +async function rebindMovableTransactions( + supabase: SupabaseClient, + companyId: string, + fromCashAccountId: string, + toCashAccountId: string, +): Promise { + const candidates = await fetchAllRows<{ id: string }>(({ from, to }) => + supabase + .from('transactions') + .select('id') + .eq('company_id', companyId) + .eq('cash_account_id', fromCashAccountId) + .is('journal_entry_id', null) + .is('invoice_id', null) + .is('supplier_invoice_id', null) + .order('id', { ascending: true }) + .range(from, to), + ) + if (candidates.length === 0) return 0 + + const candidateIds = candidates.map((row) => row.id) + const anchored = new Set() + for (const chunk of chunkIds(candidateIds, REBIND_ID_CHUNK_SIZE)) { + const anchorRows = await Promise.all([ + supabase + .from('transaction_voucher_links') + .select('transaction_id') + .eq('company_id', companyId) + .in('transaction_id', chunk), + supabase + .from('invoice_payments') + .select('transaction_id') + .eq('company_id', companyId) + .in('transaction_id', chunk), + supabase + .from('supplier_invoice_payments') + .select('transaction_id') + .eq('company_id', companyId) + .in('transaction_id', chunk), + ]) + for (const { data, error } of anchorRows) { + if (error) throw new Error(error.message) + for (const row of (data ?? []) as Array<{ transaction_id: string | null }>) { + if (row.transaction_id) anchored.add(row.transaction_id) + } + } + } + + const movableIds = candidateIds.filter((id) => !anchored.has(id)) + let moved = 0 + for (const chunk of chunkIds(movableIds, REBIND_ID_CHUNK_SIZE)) { + const { data, error } = await supabase + .from('transactions') + .update({ cash_account_id: toCashAccountId }) + .eq('company_id', companyId) + .eq('cash_account_id', fromCashAccountId) + .in('id', chunk) + .is('journal_entry_id', null) + .is('invoice_id', null) + .is('supplier_invoice_id', null) + .select('id') + if (error) throw new Error(error.message) + moved += (data ?? []).length + } + return moved +} + /** * Upsert a PSD2-sourced cash account during connection callback / sync. Keyed on * (company_id, bank_connection_id, external_uid). When the row exists, balance @@ -1100,10 +1199,47 @@ export async function upsertFromPsd2( const typedOwn = ownRow as { id: string; is_primary: boolean } | null let transferPrimary = false if (typedOwn) { - // With linked transactions the duplicate is demoted to a plain manual - // row (deleting it would SET NULL those transactions' cash_account_id - // links). Without any, it is a leftover mirror from the broken reconnect - // and is deleted outright so its overflow slot frees up. + // The duplicate is the overflow mirror (e.g. 1931) a broken reconnect + // left behind; the promoted holder (e.g. 1930) is the ledger the user + // mapped. Movable transactions (unbooked, unmatched, not anchored to a + // verifikat) rebind onto the promoted row so categorize/booking proposes + // that ledger. Booked or anchored rows keep their binding: their + // vouchers carry the old 19xx line. + let movedCount = 0 + try { + movedCount = await rebindMovableTransactions( + supabase, + companyId, + typedOwn.id, + promotableRowId, + ) + } catch (rebindError) { + const message = rebindError instanceof Error ? rebindError.message : String(rebindError) + log.error('upsertFromPsd2 duplicate transaction rebind failed', { + companyId, + bankConnectionId: input.bank_connection_id, + externalUid: input.external_uid, + error: message, + }) + throw new Error(`cash_accounts upsert failed: ${message}`) + } + if (movedCount > 0) { + // Behandlingshistorik (BFNAR 2013:2 kap 8): light-touch for a + // pre-verifikat staging binding, the same weight as the single-row + // move in PATCH /api/transactions/[id]/cash-account. + log.info('upsertFromPsd2 rebound movable transactions to promoted cash account', { + companyId, + fromCashAccountId: typedOwn.id, + toCashAccountId: promotableRowId, + movedCount, + }) + } + + // Rows still bound after the rebind are booked or anchored: the + // duplicate then survives as a demoted manual row (deleting it would SET + // NULL those transactions' cash_account_id links; the #1643 orphan + // guards handle the released twin). With nothing bound it is a leftover + // mirror and is deleted outright so its overflow slot frees up. const { data: linkedTx, error: linkedTxError } = await supabase .from('transactions') .select('id') @@ -1180,6 +1316,9 @@ export async function upsertFromPsd2( return } // Holder row vanished between SELECT and UPDATE: fall through to upsert. + // Any rows the rebind above already moved onto it were SET NULL by the + // transactions.cash_account_id FK when it went; the next sync re-ingests + // them under the fresh row (the same self-heal as a deleted twin). } const { error } = await supabase