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
- Choose a transfer category with the same is_income value as the deleted category.
- Show only same-kind categories in the transfer picker.
- 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
- Filter transfer pickers by is_income matching the deleted category
- Never auto-select transfer targets across the income/expense split in scripts
- Test delete flows for both income and expense categories
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
- Invalid BUDGET_QUERY dimension: ${dimension}
- A category with the name "${category.name}" already exists.
- Category with id ${id} not found.
- Transfer category with id ${transferId} not found.
- Category "${raw}" is not found in available income categorie
AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29).
Data as JSON: /api/errors/7043121e880fcca9.
Report an issue: GitHub.