actualbudget/actual · error

Cannot transfer between income and expense categories.

Error message

Cannot transfer between income and expense categories.

What it means

A deletion transfer must stay within the same income/expense kind; moving balances from an expense category to an income category (or vice versa) is rejected because the budget math cannot represent it.

Source

Thrown at packages/loot-core/src/server/budget/app.ts:399

      throw new Error(`Category with id ${id} not found.`);
    }

    const transfer =
      transferId &&
      (await db.first<Pick<db.DbCategory, 'is_income'>>(
        'SELECT is_income FROM categories WHERE id = ?',
        [transferId],
      ));

    if (transferId && !transfer) {
      throw new Error(`Transfer category with id ${transferId} not found.`);
    } else if (
      transferId &&
      row &&
      transfer &&
      row.is_income !== transfer.is_income
    ) {
      throw new Error('Cannot transfer between income and expense categories.');
    }

    // Update spreadsheet values if it's an expense category
    // TODO: We should do this for income too if it's a tracking budget
    if (row.is_income === 0) {
      if (transferId) {
        await budget.doTransfer([id], transferId);
      }
    }

    await db.deleteCategory({ id }, transferId);
  });
}

// Server must return AQL entities not the raw DB data
async function getCategoryGroups({ hidden }: { hidden?: boolean } = {}) {
  const baseQuery = q('category_groups').select('*');
  const query = hidden === undefined ? baseQuery : baseQuery.filter({ hidden });

View on GitHub (pinned to d4334cb6e6)

Solutions

  1. Choose a transfer category with the same is_income value as the deleted category.
  2. Show only same-kind categories in the transfer picker.
  3. If no same-kind target exists, omit transferId and let the balance go to Uncategorized.

Example fix

// before
await deleteCategory(expenseCatId, incomeTransferId); // mismatched kinds
// after
const target = sameKindCategories.find(c => c.is_income === 0);
await deleteCategory(expenseCatId, target?.id ?? undefined);
Defensive patterns

Strategy: validation

Validate before calling

const rows = await q('categories').filter({ id: { $in: [id, transferId] } }).select('*').execute();
const a = rows.find(r => r.id === id);
const b = rows.find(r => r.id === transferId);
if (a && b && a.is_income !== b.is_income) throw new Error('Transfer must stay within income or expense categories');

Try / catch

try {
  await app.deleteCategory(id, transferId);
} catch (e) {
  if (e.message.includes('income and expense')) {
    console.error('Pick a transfer target of the same kind (income vs expense).');
  } else throw e;
}

Prevention

When it happens

Trigger: deleteCategory(id, transferId) where one category has is_income=0 and the other is_income=1.

Common situations: UI or API callers letting users pick transfer targets across the income/expense split; programmatic cleanup scripts that pick an arbitrary target category.

Related errors


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