fix(cash-accounts): rebind movable transactions when remapping a PSD2 ledger (#2023)
When a PSD2 remap collides with an overflow duplicate cash account (1931 next to a promoted 1930), the unbooked, unmatched, non-anchored transactions on the duplicate are rebound onto the promoted row before the duplicate is resolved, so booking stops proposing the dead ledger. Rows that are booked or anchored via transaction_voucher_links / invoice_payments / supplier_invoice_payments stay put and the duplicate is demoted (never deleted) while any remain, as before. Supersedes #1756. Co-authored-by: Daniel Stenborg <daniel@stenborg.se>
This commit is contained in:
@@ -1338,4 +1338,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
@@ -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<string, unknown>; id: unknown }>
|
||||
deletes: unknown[]
|
||||
upserts: Array<Record<string, unknown>>
|
||||
rpcCalls: Array<{ fn: string; args: Record<string, unknown> }>
|
||||
/** .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<string, unknown>; 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> = {}): UpsertStub {
|
||||
return {
|
||||
updates: [],
|
||||
@@ -512,6 +531,9 @@ function makeUpsertStub(partial: Partial<UpsertStub> = {}): 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<string, unknown>) => {
|
||||
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<AnchorTable>(['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)
|
||||
|
||||
|
||||
@@ -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<number> {
|
||||
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<string>()
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user