actualbudget/actual · error

Merging is only possible with 2 transactions, but found

Error message

Merging is only possible with 2 transactions, but found ${JSON.stringify(transactions)}

What it means

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.

Solutions

  1. Ensure exactly two transaction objects with populated 'id' fields are passed.
  2. Log the input array — the error message includes it — and fix the caller that built it.
  3. If the UI allows multi-select, disable merge or enforce a 2-row selection limit before calling.
  4. Verify the id field name matches TransactionEntity ('id'), not 'transactionId' or '_id'.

Example fix

// before
await mergeTransactions(selectedRows); // may be 1, 3, or ids missing
// after
const ids = selectedRows.map(r => r.id).filter(Boolean);
if (ids.length !== 2) {
  throw new Error(`Select exactly 2 duplicates to merge (got ${ids.length})`);
}
await mergeTransactions(selectedRows.slice(0, 2));
Defensive patterns

Strategy: validation

Validate before calling

function canMerge(transactions: { id?: string }[]): boolean {
  return (transactions?.map(t => t?.id).filter(Boolean) || []).length === 2;
}
if (!canMerge(selected)) throw new Error('Merge requires exactly 2 transactions with ids');

Type guard

function isMergeablePair(txns: unknown): txns is [{ id: string }, { id: string }] {
  return Array.isArray(txns) && txns.length === 2 &&
    txns.every(t => typeof t === 'object' && t !== null && typeof (t as any).id === 'string');
}

Try / catch

try {
  await mergeTransactions(pair);
} catch (e) {
  if (e.message.startsWith('Merging is only possible with 2 transactions')) {
    showError('Select exactly two duplicates to merge');
  } else { throw e; }
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29). Data as JSON: /api/errors/5f767d60ac91ff61. Report an issue: GitHub.

Appendix: source

Thrown at packages/loot-core/src/server/transactions/merge.ts:19

import { aqlQuery } from '#server/aql';
import * as db from '#server/db';
import { validForMergeExplanation } from '#shared/merge';
import { q } from '#shared/query';
import {
  deleteTransaction as sharedDeleteTransaction,
  ungroupTransactions,
} from '#shared/transactions';
import type { TransactionEntity } from '#types/models';

import { batchUpdateTransactions } from '.';

export async function mergeTransactions(
  transactions: Pick<TransactionEntity, 'id'>[],
): Promise<TransactionEntity['id']> {
  // make sure all values have ids
  const txIds = transactions?.map(x => x?.id).filter(Boolean) || [];
  if (txIds.length !== 2) {
    throw new Error(
      'Merging is only possible with 2 transactions, but found ' +
        JSON.stringify(transactions),
    );
  }
  const [a, b] = await mapAndValidateTransactions(txIds[0], txIds[1]);
  const aTransferId = a.transfer_id;
  const bTransferId = b.transfer_id;

  // we don't need all the transfer logic if there are no transfers.
  if (!aTransferId && !bTransferId) return mergeTransactionsNoTransfer(a, b);

  const transferAccount = aTransferId ? a.payee : b.payee;

  await setTransfers([a.id, b.id, aTransferId, bTransferId], null);
  const transferId = await mergeTransfers(aTransferId, bTransferId);
  const keptTxId = await mergeTransactionsNoTransfer(a, b);
  if (transferId) {
    await db.updateTransaction({

View on GitHub (pinned to d4334cb6e6)