2026-05-03 19:51:57 -05:00
const express = require ( 'express' ) ;
2026-05-09 13:03:36 -05:00
const { standardizeError } = require ( '../middleware/errorFormatter' ) ;
2026-05-03 19:51:57 -05:00
const router = express . Router ( ) ;
const { getDb , ensureUserDefaultCategories } = require ( '../db/database' ) ;
2026-06-07 01:05:48 -05:00
const { accountingActiveSql } = require ( '../services/paymentAccountingService' ) ;
2026-05-03 19:51:57 -05:00
// GET /api/categories
router . get ( '/' , ( req , res ) => {
2026-06-04 20:01:51 -05:00
try {
2026-05-03 19:51:57 -05:00
const db = getDb ( ) ;
ensureUserDefaultCategories ( req . user . id ) ;
2026-05-04 16:38:03 -05:00
const categories = db . prepare ( `
2026-06-14 19:21:34 -05:00
SELECT id , user _id , name , sort _order , spending _enabled , group _id , created _at , updated _at
2026-05-04 16:38:03 -05:00
FROM categories
WHERE user _id = ?
2026-05-16 10:34:32 -05:00
AND deleted _at IS NULL
2026-05-30 20:04:50 -05:00
ORDER BY CASE WHEN sort _order IS NULL THEN 1 ELSE 0 END ,
sort _order ASC ,
name COLLATE NOCASE ASC
2026-05-04 16:38:03 -05:00
` ).all(req.user.id);
const billsByCategory = db . prepare ( `
SELECT
b . id ,
b . category _id ,
b . name ,
b . active ,
b . expected _amount ,
b . due _day ,
COUNT ( p . id ) AS payment _count ,
COALESCE ( SUM ( p . amount ) , 0 ) AS total _paid ,
MAX ( p . paid _date ) AS last _paid _date
FROM bills b
LEFT JOIN payments p
ON p . bill _id = b . id
AND p . deleted _at IS NULL
2026-06-07 01:05:48 -05:00
AND $ { accountingActiveSql ( 'p' ) }
2026-05-04 16:38:03 -05:00
WHERE b . user _id = ?
AND b . category _id = ?
2026-05-16 10:34:32 -05:00
AND b . deleted _at IS NULL
2026-05-04 16:38:03 -05:00
GROUP BY b . id
ORDER BY b . active DESC , b . due _day ASC , b . name COLLATE NOCASE ASC
` );
const shaped = categories . map ( category => {
const bills = billsByCategory . all ( req . user . id , category . id ) . map ( bill => ( {
... bill ,
active : ! ! bill . active ,
payment _count : Number ( bill . payment _count || 0 ) ,
total _paid : Number ( bill . total _paid || 0 ) ,
last _paid _date : bill . last _paid _date || null ,
} ) ) ;
const activeBillCount = bills . filter ( bill => bill . active ) . length ;
const inactiveBillCount = bills . length - activeBillCount ;
const paymentCount = bills . reduce ( ( sum , bill ) => sum + bill . payment _count , 0 ) ;
return {
... category ,
2026-06-04 20:01:51 -05:00
spending _enabled : ! ! category . spending _enabled ,
2026-05-04 16:38:03 -05:00
bill _count : activeBillCount ,
active _bill _count : activeBillCount ,
inactive _bill _count : inactiveBillCount ,
payment _count : paymentCount ,
bill _names : bills . map ( bill => bill . name ) ,
bills ,
} ;
} ) ;
res . json ( shaped ) ;
2026-06-04 20:01:51 -05:00
} catch ( err ) {
console . error ( '[categories GET]' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Failed to load categories' } ) ;
}
2026-05-03 19:51:57 -05:00
} ) ;
2026-05-30 20:04:50 -05:00
// PUT /api/categories/reorder
router . put ( '/reorder' , ( req , res ) => {
const db = getDb ( ) ;
const entries = Object . entries ( req . body || { } ) . map ( ( [ categoryId , sortOrder ] ) => ( {
categoryId : Number ( categoryId ) ,
sortOrder : Number ( sortOrder ) ,
} ) ) ;
if ( entries . length === 0 ) {
return res . status ( 400 ) . json ( standardizeError ( 'At least one category order is required' , 'VALIDATION_ERROR' , 'reorder' ) ) ;
}
const invalid = entries . find ( ( { categoryId , sortOrder } ) => (
! Number . isInteger ( categoryId ) || categoryId <= 0 || ! Number . isInteger ( sortOrder ) || sortOrder < 0
) ) ;
if ( invalid ) {
return res . status ( 400 ) . json ( standardizeError ( 'Reorder payload must map category ids to non-negative integer positions' , 'VALIDATION_ERROR' , 'reorder' ) ) ;
}
const ids = entries . map ( item => item . categoryId ) ;
const placeholders = ids . map ( ( ) => '?' ) . join ( ',' ) ;
const owned = db . prepare ( `
SELECT id
FROM categories
WHERE user _id = ? AND deleted _at IS NULL AND id IN ( $ { placeholders } )
` ).all(req.user.id, ...ids);
if ( owned . length !== ids . length ) {
return res . status ( 404 ) . json ( standardizeError ( 'One or more categories were not found' , 'NOT_FOUND' , 'category_id' ) ) ;
}
const update = db . prepare ( "UPDATE categories SET sort_order = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?" ) ;
db . transaction ( ( items ) => {
for ( const item of items ) update . run ( item . sortOrder , item . categoryId , req . user . id ) ;
} ) ( entries ) ;
res . json ( { success : true } ) ;
} ) ;
2026-05-03 19:51:57 -05:00
// POST /api/categories
router . post ( '/' , ( req , res ) => {
const db = getDb ( ) ;
const { name } = req . body ;
2026-05-09 13:03:36 -05:00
if ( ! name ) return res . status ( 400 ) . json ( standardizeError ( 'name is required' , 'VALIDATION_ERROR' , 'name' ) ) ;
2026-05-03 19:51:57 -05:00
try {
const result = db . prepare ( 'INSERT INTO categories (user_id, name) VALUES (?, ?)' ) . run ( req . user . id , name . trim ( ) ) ;
const created = db . prepare ( 'SELECT * FROM categories WHERE id = ?' ) . get ( result . lastInsertRowid ) ;
res . status ( 201 ) . json ( created ) ;
} catch ( e ) {
if ( e . message . includes ( 'UNIQUE' ) ) {
2026-05-09 13:03:36 -05:00
return res . status ( 409 ) . json ( standardizeError ( 'Category already exists' , 'CONFLICT' , 'name' ) ) ;
2026-05-03 19:51:57 -05:00
}
throw e ;
}
} ) ;
// PUT /api/categories/:id
router . put ( '/:id' , ( req , res ) => {
const db = getDb ( ) ;
2026-06-14 19:21:34 -05:00
const { name , spending _enabled , group _id } = req . body ;
2026-05-09 13:03:36 -05:00
if ( ! name ) return res . status ( 400 ) . json ( standardizeError ( 'name is required' , 'VALIDATION_ERROR' , 'name' ) ) ;
2026-05-03 19:51:57 -05:00
2026-05-16 10:34:32 -05:00
const cat = db . prepare ( 'SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( req . params . id , req . user . id ) ;
2026-05-09 13:03:36 -05:00
if ( ! cat ) return res . status ( 404 ) . json ( standardizeError ( 'Category not found' , 'NOT_FOUND' , 'id' ) ) ;
2026-05-03 19:51:57 -05:00
2026-06-14 19:21:34 -05:00
if ( group _id !== undefined && group _id !== null ) {
const group = db . prepare ( 'SELECT id FROM category_groups WHERE id = ? AND user_id = ?' ) . get ( group _id , req . user . id ) ;
if ( ! group ) return res . status ( 404 ) . json ( standardizeError ( 'Category group not found' , 'NOT_FOUND' , 'group_id' ) ) ;
}
2026-05-03 19:51:57 -05:00
try {
2026-06-04 20:01:51 -05:00
const fields = [ "name = ?" , "updated_at = datetime('now')" ] ;
const values = [ name . trim ( ) ] ;
if ( spending _enabled !== undefined ) { fields . push ( 'spending_enabled = ?' ) ; values . push ( spending _enabled ? 1 : 0 ) ; }
2026-06-14 19:21:34 -05:00
if ( group _id !== undefined ) { fields . push ( 'group_id = ?' ) ; values . push ( group _id ) ; }
2026-06-04 20:01:51 -05:00
db . prepare ( ` UPDATE categories SET ${ fields . join ( ', ' ) } WHERE id = ? AND user_id = ? ` )
. run ( ... values , req . params . id , req . user . id ) ;
2026-06-14 19:21:34 -05:00
const updated = db . prepare ( 'SELECT id, name, sort_order, spending_enabled, group_id, created_at, updated_at FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( req . params . id , req . user . id ) ;
2026-06-04 20:01:51 -05:00
res . json ( { ... updated , spending _enabled : ! ! updated . spending _enabled } ) ;
2026-05-03 19:51:57 -05:00
} catch ( e ) {
2026-06-04 20:01:51 -05:00
if ( e . message ? . includes ( 'UNIQUE' ) ) {
2026-05-09 13:03:36 -05:00
return res . status ( 409 ) . json ( standardizeError ( 'Category already exists' , 'CONFLICT' , 'name' ) ) ;
2026-05-03 19:51:57 -05:00
}
2026-06-04 20:01:51 -05:00
console . error ( '[categories PUT]' , e . message ) ;
res . status ( 500 ) . json ( { error : 'Failed to update category' } ) ;
}
} ) ;
2026-06-14 19:21:34 -05:00
// ── Category groups ──────────────────────────────────────────────────────────
// GET /api/category-groups
router . get ( '/groups' , ( req , res ) => {
const db = getDb ( ) ;
const groups = db . prepare ( `
SELECT id , name , sort _order , created _at , updated _at
FROM category _groups
WHERE user _id = ?
ORDER BY sort _order ASC , name COLLATE NOCASE ASC
` ).all(req.user.id);
res . json ( groups ) ;
} ) ;
// POST /api/category-groups
router . post ( '/groups' , ( req , res ) => {
const db = getDb ( ) ;
const { name } = req . body ;
if ( ! name ? . trim ( ) ) return res . status ( 400 ) . json ( standardizeError ( 'name is required' , 'VALIDATION_ERROR' , 'name' ) ) ;
try {
const maxOrder = db . prepare ( 'SELECT COALESCE(MAX(sort_order), -1) AS m FROM category_groups WHERE user_id = ?' ) . get ( req . user . id ) . m ;
const result = db . prepare ( 'INSERT INTO category_groups (user_id, name, sort_order) VALUES (?, ?, ?)' )
. run ( req . user . id , name . trim ( ) , maxOrder + 1 ) ;
const created = db . prepare ( 'SELECT id, name, sort_order, created_at, updated_at FROM category_groups WHERE id = ?' ) . get ( result . lastInsertRowid ) ;
res . status ( 201 ) . json ( created ) ;
} catch ( e ) {
if ( e . message ? . includes ( 'UNIQUE' ) ) {
return res . status ( 409 ) . json ( standardizeError ( 'Category group already exists' , 'CONFLICT' , 'name' ) ) ;
}
console . error ( '[category-groups POST]' , e . message ) ;
res . status ( 500 ) . json ( { error : 'Failed to create category group' } ) ;
}
} ) ;
// PUT /api/category-groups/:id
router . put ( '/groups/:id' , ( req , res ) => {
const db = getDb ( ) ;
const { name , sort _order } = req . body ;
const group = db . prepare ( 'SELECT id FROM category_groups WHERE id = ? AND user_id = ?' ) . get ( req . params . id , req . user . id ) ;
if ( ! group ) return res . status ( 404 ) . json ( standardizeError ( 'Category group not found' , 'NOT_FOUND' , 'id' ) ) ;
try {
const fields = [ "updated_at = datetime('now')" ] ;
const values = [ ] ;
if ( name !== undefined ) {
if ( ! name . trim ( ) ) return res . status ( 400 ) . json ( standardizeError ( 'name is required' , 'VALIDATION_ERROR' , 'name' ) ) ;
fields . push ( 'name = ?' ) ; values . push ( name . trim ( ) ) ;
}
if ( sort _order !== undefined ) { fields . push ( 'sort_order = ?' ) ; values . push ( Number ( sort _order ) ) ; }
db . prepare ( ` UPDATE category_groups SET ${ fields . join ( ', ' ) } WHERE id = ? AND user_id = ? ` )
. run ( ... values , req . params . id , req . user . id ) ;
const updated = db . prepare ( 'SELECT id, name, sort_order, created_at, updated_at FROM category_groups WHERE id = ? AND user_id = ?' ) . get ( req . params . id , req . user . id ) ;
res . json ( updated ) ;
} catch ( e ) {
if ( e . message ? . includes ( 'UNIQUE' ) ) {
return res . status ( 409 ) . json ( standardizeError ( 'Category group already exists' , 'CONFLICT' , 'name' ) ) ;
}
console . error ( '[category-groups PUT]' , e . message ) ;
res . status ( 500 ) . json ( { error : 'Failed to update category group' } ) ;
}
} ) ;
// DELETE /api/category-groups/:id
router . delete ( '/groups/:id' , ( req , res ) => {
const db = getDb ( ) ;
const group = db . prepare ( 'SELECT id FROM category_groups WHERE id = ? AND user_id = ?' ) . get ( req . params . id , req . user . id ) ;
if ( ! group ) return res . status ( 404 ) . json ( standardizeError ( 'Category group not found' , 'NOT_FOUND' , 'id' ) ) ;
db . prepare ( 'DELETE FROM category_groups WHERE id = ? AND user_id = ?' ) . run ( req . params . id , req . user . id ) ;
res . json ( { success : true } ) ;
} ) ;
2026-06-04 20:01:51 -05:00
// PATCH /api/categories/:id/spending — toggle spending_enabled without requiring name
router . patch ( '/:id/spending' , ( req , res ) => {
const db = getDb ( ) ;
const cat = db . prepare ( 'SELECT id, spending_enabled FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( req . params . id , req . user . id ) ;
if ( ! cat ) return res . status ( 404 ) . json ( standardizeError ( 'Category not found' , 'NOT_FOUND' , 'id' ) ) ;
const enabled = req . body ? . spending _enabled !== undefined ? ! ! req . body . spending _enabled : ! cat . spending _enabled ;
try {
db . prepare ( "UPDATE categories SET spending_enabled=?, updated_at=datetime('now') WHERE id=? AND user_id=?" )
. run ( enabled ? 1 : 0 , req . params . id , req . user . id ) ;
res . json ( { id : cat . id , spending _enabled : enabled } ) ;
} catch ( err ) {
console . error ( '[categories PATCH spending]' , err . message ) ;
res . status ( 500 ) . json ( { error : 'Failed to update category' } ) ;
2026-05-03 19:51:57 -05:00
}
} ) ;
// DELETE /api/categories/:id
router . delete ( '/:id' , ( req , res ) => {
const db = getDb ( ) ;
2026-05-16 10:34:32 -05:00
const cat = db . prepare ( 'SELECT id, name FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL' ) . get ( req . params . id , req . user . id ) ;
2026-05-09 13:03:36 -05:00
if ( ! cat ) return res . status ( 404 ) . json ( standardizeError ( 'Category not found' , 'NOT_FOUND' , 'id' ) ) ;
2026-05-04 20:12:57 -05:00
2026-05-16 10:34:32 -05:00
const deleted = db . prepare ( "UPDATE categories SET deleted_at = datetime('now'), updated_at = datetime('now') WHERE id = ? AND user_id = ? AND deleted_at IS NULL" )
. run ( req . params . id , req . user . id ) ;
2026-05-04 20:12:57 -05:00
2026-05-16 10:34:32 -05:00
res . json ( {
success : true ,
deleted : deleted . changes ,
deleted _category _id : cat . id ,
deleted _category _name : cat . name ,
recoverable _until _days : 30 ,
2026-05-04 20:12:57 -05:00
} ) ;
2026-05-16 10:34:32 -05:00
} ) ;
// POST /api/categories/:id/restore — undo category soft delete
router . post ( '/:id/restore' , ( req , res ) => {
const db = getDb ( ) ;
const cat = db . prepare ( 'SELECT id, name FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL' ) . get ( req . params . id , req . user . id ) ;
if ( ! cat ) return res . status ( 404 ) . json ( standardizeError ( 'Deleted category not found' , 'NOT_FOUND' , 'id' ) ) ;
2026-05-04 20:12:57 -05:00
2026-05-16 10:34:32 -05:00
db . prepare ( "UPDATE categories SET deleted_at = NULL, updated_at = datetime('now') WHERE id = ? AND user_id = ?" )
. run ( req . params . id , req . user . id ) ;
res . json ( db . prepare ( 'SELECT * FROM categories WHERE id = ? AND user_id = ?' ) . get ( req . params . id , req . user . id ) ) ;
2026-05-03 19:51:57 -05:00
} ) ;
module . exports = router ;