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 the

View on GitHub (pinned to d4334cb6e6)

Solutions

  1. Filter q.getCategories() results by checking the category's group is_income flag (or use category groups) and select only expense categories
  2. For income transactions, use the API path designed for income categorization instead
  3. Validate the chosen category is in an expense (non-income) group before calling the API
  4. 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

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


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