{"record":{"id":"5f767d60ac91ff61","repo":"actualbudget/actual","slug":"merging-is-only-possible-with-2-transactions-but","errorCode":null,"errorMessage":"Merging is only possible with 2 transactions, but found ${JSON.stringify(transactions)}","messagePattern":"Merging is only possible with 2 transactions, but found (.+?)","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/loot-core/src/server/transactions/merge.ts","lineNumber":19,"sourceCode":"import { aqlQuery } from '#server/aql';\nimport * as db from '#server/db';\nimport { validForMergeExplanation } from '#shared/merge';\nimport { q } from '#shared/query';\nimport {\n  deleteTransaction as sharedDeleteTransaction,\n  ungroupTransactions,\n} from '#shared/transactions';\nimport type { TransactionEntity } from '#types/models';\n\nimport { batchUpdateTransactions } from '.';\n\nexport async function mergeTransactions(\n  transactions: Pick<TransactionEntity, 'id'>[],\n): Promise<TransactionEntity['id']> {\n  // make sure all values have ids\n  const txIds = transactions?.map(x => x?.id).filter(Boolean) || [];\n  if (txIds.length !== 2) {\n    throw new Error(\n      'Merging is only possible with 2 transactions, but found ' +\n        JSON.stringify(transactions),\n    );\n  }\n  const [a, b] = await mapAndValidateTransactions(txIds[0], txIds[1]);\n  const aTransferId = a.transfer_id;\n  const bTransferId = b.transfer_id;\n\n  // we don't need all the transfer logic if there are no transfers.\n  if (!aTransferId && !bTransferId) return mergeTransactionsNoTransfer(a, b);\n\n  const transferAccount = aTransferId ? a.payee : b.payee;\n\n  await setTransfers([a.id, b.id, aTransferId, bTransferId], null);\n  const transferId = await mergeTransfers(aTransferId, bTransferId);\n  const keptTxId = await mergeTransactionsNoTransfer(a, b);\n  if (transferId) {\n    await db.updateTransaction({","sourceCodeStart":1,"sourceCodeEnd":37,"githubUrl":"https://github.com/actualbudget/actual/blob/d4334cb6e6123f4d3bcea1ad6166608884c7e658/packages/loot-core/src/server/transactions/merge.ts#L1-L37","documentation":"mergeTransactions merges exactly two duplicate transactions into one. Before doing anything it maps the input to ids and filters out falsy values; if the resulting list is not exactly length 2 it throws, embedding the full JSON of the input array so you can see what was actually passed.","triggerScenarios":"Calling mergeTransactions with fewer or more than 2 transactions, passing transactions whose objects lack an 'id' property (filtered out by .filter(Boolean)), passing null/undefined in the array, or a UI selection bug allowing 3+ rows to be selected for merge.","commonSituations":"Bulk-selection UI allowing multi-row merge; table rows where the id field was named differently (transactionId) so id is undefined; passing the result of a multi-select instead of a pair.","solutions":["Ensure exactly two transaction objects with populated 'id' fields are passed.","Log the input array — the error message includes it — and fix the caller that built it.","If the UI allows multi-select, disable merge or enforce a 2-row selection limit before calling.","Verify the id field name matches TransactionEntity ('id'), not 'transactionId' or '_id'."],"exampleFix":"// before\nawait mergeTransactions(selectedRows); // may be 1, 3, or ids missing\n// after\nconst ids = selectedRows.map(r => r.id).filter(Boolean);\nif (ids.length !== 2) {\n  throw new Error(`Select exactly 2 duplicates to merge (got ${ids.length})`);\n}\nawait mergeTransactions(selectedRows.slice(0, 2));","handlingStrategy":"validation","validationCode":"function canMerge(transactions: { id?: string }[]): boolean {\n  return (transactions?.map(t => t?.id).filter(Boolean) || []).length === 2;\n}\nif (!canMerge(selected)) throw new Error('Merge requires exactly 2 transactions with ids');","typeGuard":"function isMergeablePair(txns: unknown): txns is [{ id: string }, { id: string }] {\n  return Array.isArray(txns) && txns.length === 2 &&\n    txns.every(t => typeof t === 'object' && t !== null && typeof (t as any).id === 'string');\n}","tryCatchPattern":"try {\n  await mergeTransactions(pair);\n} catch (e) {\n  if (e.message.startsWith('Merging is only possible with 2 transactions')) {\n    showError('Select exactly two duplicates to merge');\n  } else { throw e; }\n}","preventionTips":["Enforce a 2-row selection limit in the UI before invoking merge","Verify each selected row actually has an 'id' property (not transactionId/_id)","Filter null/undefined entries out of the selection before calling"],"tags":["validation","transactions","merge","argument-count"],"backgroundTag":"invalid-argument-count","analyzedSha":"d4334cb6e6123f4d3bcea1ad6166608884c7e658","analyzedAt":"2026-08-29T01:02:11.213Z","schemaVersion":2},"datasetVersion":"2026-08-29T02:17:18.158Z"}