actualbudget/actual · error · APIError
${debug}: category "${id}" is not an expense category
Error message
${debug}: category "${id}" is not an expense category What it means
validateExpenseCategory throws APIError when the category exists but is marked as an income category (is_income !== 0). Only expense categories are accepted by methods that categorize spending; income categories (like 'Income') live in a separate group and must not be used there.
Source
Thrown at packages/loot-core/src/server/api.ts:119
}
}
async function validateExpenseCategory(debug, id) {
if (id == null) {
throw APIError(`${debug}: category id is required`);
}
const row = await db.first<Pick<db.DbCategory, 'is_income'>>(
'SELECT is_income FROM categories WHERE id = ?',
[id],
);
if (!row) {
throw APIError(`${debug}: category "${id}" does not exist`);
}
if (row.is_income !== 0) {
throw APIError(`${debug}: category "${id}" is not an expense category`);
}
}
function checkFileOpen() {
if (!(prefs.getPrefs() || {}).id) {
throw APIError('No budget file is open');
}
}
let batchPromise = null;
handlers['api/batch-budget-start'] = async function () {
if (batchPromise) {
throw APIError('Cannot start a batch process: batch already started');
}
// If we are importing, all we need to do is start a raw database
// transaction. Updating spreadsheet cells doesn't go through theView on GitHub (pinned to d4334cb6e6)
Solutions
- Filter q.getCategories() results by checking the category's group is_income flag (or use category groups) and select only expense categories
- For income transactions, use the API path designed for income categorization instead
- Validate the chosen category is in an expense (non-income) group before calling the API
- In UIs, hide income-group categories from expense pickers
Example fix
// before
const categories = await q.getCategories();
await q.createTransaction({ accountId, date, amount, category: categories[0].id });
// APIError: ...is not an expense category
// after
const categories = await q.getCategories();
const expense = categories.find(c => !c.is_income);
await q.createTransaction({ accountId, date, amount, category: expense.id }); Defensive patterns
Strategy: validation
Validate before calling
const categories = await q.getCategories();
const cat = categories.find(c => c.id === categoryId);
if (cat && cat.is_income) {
throw new Error(`category ${categoryId} is an income category`);
} Type guard
function isExpenseCategory(cat) {
return cat != null && cat.is_income === 0;
} Prevention
- Filter category pickers to non-income groups for expense flows
- Never use categories[0] blindly — income groups often sort first
- Check the is_income flag before assigning a category in scripts
- Route income transactions through the appropriate income-handling API path
When it happens
Trigger: Passing an income category id (e.g. the 'Income' category or a user-created income group member) to transaction/expense APIs that call validateExpenseCategory, such as createTransaction with an income category or budget-amount methods restricted to expense categories.
Common situations: Picking categories[0] assuming it's an expense category when it's actually the income group; a category picker UI that doesn't filter by group type; scripts importing refund/income transactions with the Income category via the expense path.
Related errors
- ${debug}: category id is required
- ${debug}: category "${id}" does not exist
- Unknown payee name normalization: ${String(normalization)}
- `date` is required when adding a transaction
- Amount is invalid, must be an integer: ${trans.amount}
AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29).
Data as JSON: /api/errors/73624728c3f572ca.
Report an issue: GitHub.