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:
@@ -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