Overview
Categories and subcategories provide a hierarchical system for organizing transactions. Each category can have multiple subcategories, and both support end-to-end encryption for category names while keeping colors and icons unencrypted for UI purposes.
Categories support both encrypted and unencrypted names during the migration period. The system automatically handles encryption when an account is unlocked.
Category Structure
Categories and subcategories have distinct but related structures:
backend/models/categories/index.ts
export interface Category {
id : string
account_id : string
name : string
color : string
icon : string | null
created_at : Date
updated_at ?: Date
subcategories ?: Subcategory []
// Encrypted fields (optional during transition)
name_encrypted ?: string
}
export interface Subcategory {
id : string
category_id : string
name : string
created_at : Date
updated_at ?: Date
// Encrypted fields (optional during transition)
name_encrypted ?: string
}
Category Fields
name : Display name (can be encrypted)
color : Hex color code for visual identification (not encrypted)
icon : Optional icon identifier (not encrypted)
subcategories : Child subcategories belonging to this category
Creating Categories
Client-Side: Category Creation
frontend/lib/api/categories.ts
import { encrypt } from '../crypto'
import { useCryptoStore } from '@/stores/cryptoStore'
// Usage in a component
const createCategory = async ( accountId : string , name : string , color : string ) => {
const accountKey = useCryptoStore . getState (). getAccountKey ( accountId )
if ( accountKey ) {
// Encrypt category name
const name_encrypted = await encrypt ( name , accountKey )
return api . post ( '/categories' , {
account_id: accountId ,
name: name , // Plaintext for backward compatibility
name_encrypted ,
color ,
icon: null ,
})
}
// Fallback: unencrypted
return api . post ( '/categories' , {
account_id: accountId ,
name ,
color ,
icon: null ,
})
}
Server-Side: Category Controller
backend/controllers/categories/category-controller.ts
export const createCategory = asyncHandler ( async ( req : Request , res : Response ) => {
const result = createCategorySchema . safeParse ( req . body )
if ( ! result . success ) {
const messages = result . error . issues . map ( i => i . message ). join ( ', ' )
throw new AppError ( messages , 400 )
}
const { account_id , name , name_encrypted , color , icon } = result . data
const safeName = sanitizeForStorage ( name )
const category = await CategoryRepository . create ( req . user ! . id , {
account_id ,
name: safeName ,
name_encrypted ,
color ,
icon ,
})
res . status ( 201 ). json ({
success: true ,
category ,
})
})
API Endpoint
POST /api/categories
Content-Type : application/json
X-CSRF-Token : <token>
{
"account_id" : "uuid" ,
"name" : "Groceries" ,
"name_encrypted" : "base64-encrypted-data" ,
"color" : "#10B981" ,
"icon" : "shopping-cart"
}
Creating Subcategories
Subcategory Controller
backend/controllers/subcategories/subcategory-controller.ts
export const createSubcategory = asyncHandler ( async ( req : Request , res : Response ) => {
const result = createSubcategorySchema . safeParse ( req . body )
if ( ! result . success ) {
const messages = result . error . issues . map ( i => i . message ). join ( ', ' )
throw new AppError ( messages , 400 )
}
const { category_id , name , name_encrypted } = result . data
const safeName = sanitizeForStorage ( name )
const subcategory = await SubcategoryRepository . create ( req . user ! . id , {
category_id ,
name: safeName ,
name_encrypted ,
})
res . status ( 201 ). json ({
success: true ,
subcategory ,
})
})
API Endpoint
POST /api/subcategories
Content-Type : application/json
X-CSRF-Token : <token>
{
"category_id" : "uuid" ,
"name" : "Supermarket" ,
"name_encrypted" : "base64-encrypted-data"
}
Viewing Categories
Get All Categories for an Account
backend/controllers/categories/category-controller.ts
export const getCategories = asyncHandler ( async ( req : Request , res : Response ) => {
const { account_id } = req . query
if ( ! account_id || typeof account_id !== 'string' ) {
throw new AppError ( 'account_id es requerido' , 400 )
}
const categories = await CategoryRepository . getByAccountId ( account_id , req . user ! . id )
res . status ( 200 ). json ({
success: true ,
categories ,
})
})
Get Single Category
backend/controllers/categories/category-controller.ts
export const getCategoryById = asyncHandler ( async ( req : Request , res : Response ) => {
const { id } = req . params
const category = await CategoryRepository . getById ( id , req . user ! . id )
if ( ! category ) {
throw new AppError ( 'Categoría no encontrada' , 404 )
}
res . status ( 200 ). json ({
success: true ,
category ,
})
})
Get Subcategories for a Category
backend/controllers/subcategories/subcategory-controller.ts
export const getSubcategories = asyncHandler ( async ( req : Request , res : Response ) => {
const { category_id } = req . query
if ( ! category_id || typeof category_id !== 'string' ) {
throw new AppError ( 'category_id es requerido' , 400 )
}
const subcategories = await SubcategoryRepository . getByCategoryId ( category_id , req . user ! . id )
res . status ( 200 ). json ({
success: true ,
subcategories ,
})
})
Updating Categories
Update Category
backend/controllers/categories/category-controller.ts
export const updateCategory = asyncHandler ( async ( req : Request , res : Response ) => {
const { id } = req . params
const result = updateCategorySchema . safeParse ( req . body )
if ( ! result . success ) {
const messages = result . error . issues . map ( i => i . message ). join ( ', ' )
throw new AppError ( messages , 400 )
}
const { name , name_encrypted , color , icon } = result . data
const safeName = name ? sanitizeForStorage ( name ) : undefined
const category = await CategoryRepository . update ( id , req . user ! . id , {
name: safeName ,
name_encrypted ,
color ,
icon ,
})
if ( ! category ) {
throw new AppError ( 'Categoría no encontrada' , 404 )
}
res . status ( 200 ). json ({
success: true ,
category ,
})
})
Update Subcategory
backend/controllers/subcategories/subcategory-controller.ts
export const updateSubcategory = asyncHandler ( async ( req : Request , res : Response ) => {
const { id } = req . params
const result = updateSubcategorySchema . safeParse ( req . body )
if ( ! result . success ) {
const messages = result . error . issues . map ( i => i . message ). join ( ', ' )
throw new AppError ( messages , 400 )
}
const { name , name_encrypted } = result . data
const safeName = name ? sanitizeForStorage ( name ) : undefined
const subcategory = await SubcategoryRepository . update ( id , req . user ! . id , {
name: safeName ,
name_encrypted ,
})
if ( ! subcategory ) {
throw new AppError ( 'Subcategoría no encontrada' , 404 )
}
res . status ( 200 ). json ({
success: true ,
subcategory ,
})
})
Deleting Categories
Delete Category
backend/controllers/categories/category-controller.ts
export const deleteCategory = asyncHandler ( async ( req : Request , res : Response ) => {
const { id } = req . params
const deleted = await CategoryRepository . delete ( id , req . user ! . id )
if ( ! deleted ) {
throw new AppError ( 'Categoría no encontrada' , 404 )
}
res . status ( 200 ). json ({
success: true ,
message: 'Categoría eliminada correctamente' ,
})
})
Delete Subcategory
backend/controllers/subcategories/subcategory-controller.ts
export const deleteSubcategory = asyncHandler ( async ( req : Request , res : Response ) => {
const { id } = req . params
const deleted = await SubcategoryRepository . delete ( id , req . user ! . id )
if ( ! deleted ) {
throw new AppError ( 'Subcategoría no encontrada' , 404 )
}
res . status ( 200 ). json ({
success: true ,
message: 'Subcategoría eliminada correctamente' ,
})
})
Deleting a category will affect all transactions assigned to that category. Consider reassigning transactions before deletion.
Managing Orphaned Transactions
When deleting categories, you can check for and reassign orphaned transactions:
Check Orphaned Count
backend/controllers/categories/category-controller.ts
export const getOrphanedCount = asyncHandler ( async ( req : Request , res : Response ) => {
const { id } = req . params
const count = await CategoryRepository . getOrphanedTransactionsCount ( id , req . user ! . id )
res . status ( 200 ). json ({
success: true ,
count ,
})
})
Reassign Transactions
backend/controllers/categories/category-controller.ts
export const reassignTransactions = asyncHandler ( async ( req : Request , res : Response ) => {
const { id : fromCategoryId } = req . params
const { to_category_id } = req . body
if ( ! to_category_id ) {
throw new AppError ( 'to_category_id es requerido' , 400 )
}
const count = await CategoryRepository . reassignTransactions (
fromCategoryId ,
to_category_id ,
req . user ! . id
)
res . status ( 200 ). json ({
success: true ,
message: `Transacciones reasignadas: ${ count } ` ,
reassignedCount: count ,
})
})
Color Coding System
Categories use color codes for visual identification:
Expense Categories
Income Categories
// Common expense category colors
const expenseColors = {
groceries: '#10B981' , // Green
transport: '#3B82F6' , // Blue
utilities: '#F59E0B' , // Amber
entertainment: '#8B5CF6' , // Purple
healthcare: '#EF4444' , // Red
}
// Common income category colors
const incomeColors = {
salary: '#059669' , // Emerald
freelance: '#0891B2' , // Cyan
investments: '#7C3AED' , // Violet
gifts: '#EC4899' , // Pink
}
Colors are stored as hex codes and are not encrypted . This allows the UI to display colored elements without requiring account decryption.
Category Icons
Categories can have optional icon identifiers:
// Example icon identifiers
const categoryIcons = {
groceries: 'shopping-cart' ,
transport: 'car' ,
utilities: 'home' ,
entertainment: 'film' ,
healthcare: 'heart' ,
salary: 'briefcase' ,
investments: 'trending-up' ,
}
API Routes
Category Routes
backend/routes/categories/category-routes.ts
import { Router } from 'express'
import {
getCategories ,
getCategoryById ,
createCategory ,
updateCategory ,
deleteCategory ,
getOrphanedCount ,
reassignTransactions ,
} from '../../controllers/categories/category-controller.js'
import { authenticateToken } from '../../middlewares/authenticateToken.js'
import { checkCSRF } from '../../middlewares/csrfMiddleware.js'
const router : Router = Router ()
router . use ( authenticateToken )
router . get ( '/' , getCategories )
router . get ( '/:id' , getCategoryById )
router . post ( '/' , checkCSRF , createCategory )
router . put ( '/:id' , checkCSRF , updateCategory )
router . delete ( '/:id' , checkCSRF , deleteCategory )
router . get ( '/:id/orphaned-count' , getOrphanedCount )
router . post ( '/:id/reassign' , checkCSRF , reassignTransactions )
Subcategory Routes
backend/routes/subcategories/subcategory-routes.ts
import { Router } from 'express'
import {
getSubcategories ,
getSubcategoryById ,
createSubcategory ,
updateSubcategory ,
deleteSubcategory ,
} from '../../controllers/subcategories/subcategory-controller.js'
import { authenticateToken } from '../../middlewares/authenticateToken.js'
import { checkCSRF } from '../../middlewares/csrfMiddleware.js'
const router : Router = Router ()
router . use ( authenticateToken )
router . get ( '/' , getSubcategories )
router . get ( '/:id' , getSubcategoryById )
router . post ( '/' , checkCSRF , createSubcategory )
router . put ( '/:id' , checkCSRF , updateSubcategory )
router . delete ( '/:id' , checkCSRF , deleteSubcategory )
Data Transfer Objects
Create Category DTO
backend/models/categories/index.ts
export interface CreateCategoryDTO {
account_id : string
name : string
color ?: string
icon ?: string
}
export interface CreateEncryptedCategoryDTO {
account_id : string
name_encrypted : string
color ?: string
icon ?: string
}
Update Category DTO
backend/models/categories/index.ts
export interface UpdateCategoryDTO {
name ?: string
color ?: string
icon ?: string
}
export interface UpdateEncryptedCategoryDTO {
name_encrypted ?: string
color ?: string
icon ?: string
}
Hierarchical Organization
Categories and subcategories form a two-level hierarchy:
Category: Groceries (#10B981)
├── Subcategory: Supermarket
├── Subcategory: Farmers Market
└── Subcategory: Convenience Store
Category: Transport (#3B82F6)
├── Subcategory: Gas
├── Subcategory: Public Transit
└── Subcategory: Parking
Create Parent Category
Create a top-level category with a name, color, and optional icon.
Add Subcategories
Create subcategories under the parent category for more granular organization.
Assign to Transactions
When creating transactions, assign them to specific subcategories. The parent category is automatically determined.
Track and Budget
Use categories for budgeting and subcategories for detailed spending analysis.
Best Practices
Use Consistent Colors Assign similar colors to related categories (e.g., all income categories in green shades).
Keep Subcategories Focused Each subcategory should represent a distinct spending type. Avoid overlapping subcategories.
Plan Before Deleting Always check orphaned transaction counts and reassign them before deleting categories.
Encrypt Sensitive Names Category names can reveal spending patterns. Use encryption for privacy-sensitive categories.
Integration with Transactions
Categories are referenced in transactions through the subcategory_id field:
// Transaction with category reference
const transaction = {
id: 'uuid' ,
account_id: 'uuid' ,
subcategory_id: 'uuid' , // Links to subcategory
date: '2026-03-05' ,
description: 'Weekly groceries' ,
amount: - 85.50 ,
}
// When fetched with JOIN, includes category info
const transactionWithDetails = {
... transaction ,
subcategory_name: 'Supermarket' ,
category_name: 'Groceries' ,
category_color: '#10B981' ,
}