actualbudget/actual · error

${validForMergeError} (one of: 'One of the provided transact

Error message

${validForMergeError} (one of: 'One of the provided transactions does not exist', 'Cannot merge transactions from different accounts', 'Cannot merge transactions with different amounts', 'Cannot merge transfers to different accounts')

What it means

mapAndValidateTransactions fetches both transactions and runs validForMergeExplanation to check merge preconditions. If any precondition fails (nonexistent transaction, different accounts, different amounts, or transfers to different accounts), it throws with that explanation directly as the message — the template here just documents the four possible texts.

Source

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

  const [aTransfer, bTransfer] = await mapAndValidateTransactions(
    aTransferId,
    bTransferId,
  );
  await setTransfers([aTransfer.id, bTransfer.id], null);
  return mergeTransactionsNoTransfer(aTransfer, bTransfer);
}

async function mapAndValidateTransactions(
  aId: TransactionEntity['id'],
  bId: TransactionEntity['id'],
): Promise<TransactionEntity[]> {
  // get most recent transactions
  const a: TransactionEntity = await db.getTransaction(aId);
  const b: TransactionEntity = await db.getTransaction(bId);

  const validForMergeError = validForMergeExplanation(a, b);
  if (validForMergeError) {
    throw new Error(validForMergeError);
  }
  return [a, b];
}

export async function mergeTransactionsNoTransfer(
  a: TransactionEntity,
  b: TransactionEntity,
): Promise<TransactionEntity['id']> {
  const { keep, drop } = determineKeepDrop(a, b);

  // Load subtransactions with a single query, then split by parent_id in memory
  const keepSubtransactions: TransactionEntity[] = [];
  const dropSubtransactions: TransactionEntity[] = [];
  const parents: string[] = [];
  if (keep.is_parent) parents.push(keep.id);
  if (drop.is_parent) parents.push(drop.id);

  let rows: TransactionEntity[] = [];

View on GitHub (pinned to d4334cb6e6)

Solutions

  1. Read the thrown message — it states exactly which precondition failed — and surface it to the user.
  2. Before merging, verify both transactions exist, share the same account, and have identical amounts.
  3. Refresh the transaction list before merging to avoid merging stale/deleted rows.
  4. Exclude transfer transactions with mismatched counterpart accounts from duplicate candidates, or merge them via mergeTransactionsNoTransfer when appropriate.

Example fix

// before
await mergeTransactions([{ id: aId }, { id: bId }]);
// after
const [a, b] = await Promise.all([getTransaction(aId), getTransaction(bId)]);
if (!a || !b) throw new Error('One of the transactions no longer exists');
if (a.account !== b.account || a.amount !== b.amount) {
  throw new Error('Transactions are not mergeable (account/amount differ)');
}
await mergeTransactions([{ id: aId }, { id: bId }]);
Defensive patterns

Strategy: validation

Validate before calling

const [a, b] = await Promise.all([getTransaction(aId), getTransaction(bId)]);
const mergeable = a && b && a.account === b.account && a.amount === b.amount;
if (!mergeable) throw new Error('Selected transactions are not valid merge candidates');

Type guard

function isMergeable(a: TransactionEntity | null, b: TransactionEntity | null): boolean {
  return !!a && !!b && a.account === b.account && a.amount === b.amount;
}

Try / catch

try {
  await mergeTransactions([{ id: aId }, { id: bId }]);
} catch (e) {
  if (/does not exist|different accounts|different amounts|different transfers/.test(e.message)) {
    showError(`Cannot merge: ${e.message}`);
  } else { throw e; }
}

Prevention

When it happens

Trigger: Merging two transactions that (a) reference deleted/nonexistent ids, (b) live in different accounts, (c) have different amounts, or (d) are transfers whose counterparties point at different accounts. Called via mergeTransactions on user-selected 'duplicate' pairs that are actually not mergeable.

Common situations: Duplicate-detection heuristics matching on payee/date but not amount or account; one side of the pair deleted between selection and merge (race); a transaction edited by another client after selection changing its amount.

Related errors


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