{"record":{"id":"7043121e880fcca9","repo":"actualbudget/actual","slug":"cannot-transfer-between-income-and-expense-categor","errorCode":null,"errorMessage":"Cannot transfer between income and expense categories.","messagePattern":"Cannot transfer between income and expense categories\\.","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/loot-core/src/server/budget/app.ts","lineNumber":399,"sourceCode":"      throw new Error(`Category with id ${id} not found.`);\n    }\n\n    const transfer =\n      transferId &&\n      (await db.first<Pick<db.DbCategory, 'is_income'>>(\n        'SELECT is_income FROM categories WHERE id = ?',\n        [transferId],\n      ));\n\n    if (transferId && !transfer) {\n      throw new Error(`Transfer category with id ${transferId} not found.`);\n    } else if (\n      transferId &&\n      row &&\n      transfer &&\n      row.is_income !== transfer.is_income\n    ) {\n      throw new Error('Cannot transfer between income and expense categories.');\n    }\n\n    // Update spreadsheet values if it's an expense category\n    // TODO: We should do this for income too if it's a tracking budget\n    if (row.is_income === 0) {\n      if (transferId) {\n        await budget.doTransfer([id], transferId);\n      }\n    }\n\n    await db.deleteCategory({ id }, transferId);\n  });\n}\n\n// Server must return AQL entities not the raw DB data\nasync function getCategoryGroups({ hidden }: { hidden?: boolean } = {}) {\n  const baseQuery = q('category_groups').select('*');\n  const query = hidden === undefined ? baseQuery : baseQuery.filter({ hidden });","sourceCodeStart":381,"sourceCodeEnd":417,"githubUrl":"https://github.com/actualbudget/actual/blob/d4334cb6e6123f4d3bcea1ad6166608884c7e658/packages/loot-core/src/server/budget/app.ts#L381-L417","documentation":"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.","triggerScenarios":"deleteCategory(id, transferId) where one category has is_income=0 and the other is_income=1.","commonSituations":"UI or API callers letting users pick transfer targets across the income/expense split; programmatic cleanup scripts that pick an arbitrary target category.","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."],"exampleFix":"// before\nawait deleteCategory(expenseCatId, incomeTransferId); // mismatched kinds\n// after\nconst target = sameKindCategories.find(c => c.is_income === 0);\nawait deleteCategory(expenseCatId, target?.id ?? undefined);","handlingStrategy":"validation","validationCode":"const rows = await q('categories').filter({ id: { $in: [id, transferId] } }).select('*').execute();\nconst a = rows.find(r => r.id === id);\nconst b = rows.find(r => r.id === transferId);\nif (a && b && a.is_income !== b.is_income) throw new Error('Transfer must stay within income or expense categories');","typeGuard":null,"tryCatchPattern":"try {\n  await app.deleteCategory(id, transferId);\n} catch (e) {\n  if (e.message.includes('income and expense')) {\n    console.error('Pick a transfer target of the same kind (income vs expense).');\n  } else throw e;\n}","preventionTips":["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"],"tags":["budget","category","validation","income-expense"],"backgroundTag":"category-kind-mismatch","analyzedSha":"d4334cb6e6123f4d3bcea1ad6166608884c7e658","analyzedAt":"2026-08-29T01:02:11.213Z","schemaVersion":2},"datasetVersion":"2026-08-29T02:17:18.158Z"}