Skip to content

[Bug]: api.updateCategory throws TypeError on any partial update that omits name #8857

Description

@majidseifi

What happened?

api.updateCategory(id, fields) throws a TypeError for any partial update that does not include name, even though fields is documented and typed as a partial.

I was trying to move a category into a different group during a category restructure, without changing its name.

  • Expected: the category moves to the new group, name untouched.
  • Actual: TypeError: Cannot read properties of undefined (reading 'trim')

The same shape breaks a hidden-only update, and any other single-field update that omits name.

Sending the current name unchanged alongside the real change succeeds, which is the workaround I'm using:

await api.updateCategory(categoryId, { group_id: groupId, name: currentName }); // works

How can we reproduce the issue?

  1. Open any budget file via @actual-app/api (api.init(...) then api.downloadBudget(...)).
  2. Get a valid category id and a valid category-group id, e.g. from await api.getCategories() and await api.getCategoryGroups().
  3. Call updateCategory with a partial that omits name:
    await api.updateCategory(categoryId, { group_id: groupId });
  4. Expected: resolves; the category is now in the new group with its name unchanged.
    Actual: rejects with TypeError: Cannot read properties of undefined (reading 'trim').
  5. Confirm it is the omission of name that matters, by re-running with the name included — this succeeds:
    await api.updateCategory(categoryId, { group_id: groupId, name: currentName });

Nothing is written when it throws: the category is left untouched.

Root cause

updateCategory normalizes name unconditionally, before checking whether it was supplied:

async function updateCategory$1(category) {
  try {
    await updateCategory$2(categoryModel$1.toDb({
      ...category,
      name: category.name.trim()   // <-- throws when `name` was not passed
    }));

The public entry point preserves the absence of the key, so name really is undefined by the time it reaches the trim:

api.updateCategory(id, fields) → handlers["api/category-update"] → { id, ...categoryModel.fromExternal(fields) } → handlers["category-update"] → updateCategory$1.

Verified present in 26.8.1 and still present in 26.9.0 (dist/index.js:118200).

Scope

Category-only — I checked the two sibling handlers and neither has the pattern:

  • handlers["api/payee-update"] → payeeModel.fromExternal(fields), no unconditional name access
  • updateCategoryGroup$1 → categoryGroupModel$1.toDb(group), no trim

Suggested fix

Only normalize name when it is present, and build the update payload from the keys actually passed rather than a fixed field list:

await updateCategory$2(categoryModel$1.toDb({
  ...category,
  ...(typeof category.name === 'string' ? { name: category.name.trim() } : {}),
}));

Suggested tests

  • update group_id only → succeeds, name unchanged
  • update hidden only → succeeds, name unchanged
  • update name only → succeeds, group unchanged
  • update with empty fields {} → clean validation error, not a TypeError

Where are you hosting Actual?

Docker (self-hosted, actualbudget/actual-server 26.8.1)

Operating System

Linux

Additional environment

  • @actual-app/api 26.8.1; the offending line also confirmed by inspection in 26.9.0
  • Reached via the API, not the web UI, so no browser is involved

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions