diff --git a/db/database.js b/db/database.js
index c7e398c..a4ed9e9 100644
--- a/db/database.js
+++ b/db/database.js
@@ -2835,6 +2835,55 @@ function runMigrations() {
console.log('[v0.87] spending: transactions.spending_category_id, spending_category_rules, spending_budgets');
}
+ },
+ {
+ version: 'v0.88',
+ description: 'categories: spending_enabled flag to separate bill vs spending categories',
+ dependsOn: ['v0.87'],
+ run: function() {
+ const cols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
+ if (!cols.includes('spending_enabled'))
+ db.exec('ALTER TABLE categories ADD COLUMN spending_enabled INTEGER NOT NULL DEFAULT 0');
+
+ // Mark the v0.87-seeded defaults as spending-enabled
+ const SPENDING_DEFAULTS = ['Groceries','Dining','Fuel & Transport','Shopping','Entertainment','Health','Travel','Other'];
+ const placeholder = SPENDING_DEFAULTS.map(() => '?').join(',');
+ db.exec(`UPDATE categories SET spending_enabled=1 WHERE is_seeded=1 AND name IN (${SPENDING_DEFAULTS.map(n => `'${n.replace("'", "''")}'`).join(',')})`);
+
+ // Mark any category already linked to a spending rule as spending-enabled
+ try {
+ db.exec(`
+ UPDATE categories SET spending_enabled=1
+ WHERE id IN (SELECT DISTINCT category_id FROM spending_category_rules)
+ `);
+ } catch { /* spending_category_rules may not exist on legacy paths */ }
+
+ console.log('[v0.88] categories.spending_enabled added, seeded defaults marked');
+ }
+ },
+ {
+ version: 'v0.89',
+ description: 'categories: seed spending defaults for users who had existing categories before v0.87',
+ dependsOn: ['v0.88'],
+ run: function() {
+ const SPENDING_DEFAULTS = [
+ 'Groceries','Dining','Fuel & Transport','Shopping',
+ 'Entertainment','Health','Travel','Other'
+ ];
+ const users = db.prepare("SELECT id FROM users WHERE role='user' AND active=1").all();
+ const insert = db.prepare(`
+ INSERT OR IGNORE INTO categories (user_id, name, sort_order, is_seeded, spending_enabled)
+ VALUES (?, ?, ?, 1, 1)
+ `);
+ let seeded = 0;
+ for (const user of users) {
+ const hasSpending = db.prepare('SELECT 1 FROM categories WHERE user_id=? AND spending_enabled=1 AND deleted_at IS NULL LIMIT 1').get(user.id);
+ if (!hasSpending) {
+ SPENDING_DEFAULTS.forEach((name, i) => { insert.run(user.id, name, 200 + i); seeded++; });
+ }
+ }
+ console.log(`[v0.89] spending defaults seeded for users missing them (${seeded} categories inserted)`);
+ }
}
];
diff --git a/routes/categories.js b/routes/categories.js
index a0d4dd0..d10b1a0 100644
--- a/routes/categories.js
+++ b/routes/categories.js
@@ -5,11 +5,12 @@ const { getDb, ensureUserDefaultCategories } = require('../db/database');
// GET /api/categories
router.get('/', (req, res) => {
+ try {
const db = getDb();
ensureUserDefaultCategories(req.user.id);
const categories = db.prepare(`
- SELECT id, user_id, name, sort_order, created_at, updated_at
+ SELECT id, user_id, name, sort_order, spending_enabled, created_at, updated_at
FROM categories
WHERE user_id = ?
AND deleted_at IS NULL
@@ -55,6 +56,7 @@ router.get('/', (req, res) => {
return {
...category,
+ spending_enabled: !!category.spending_enabled,
bill_count: activeBillCount,
active_bill_count: activeBillCount,
inactive_bill_count: inactiveBillCount,
@@ -65,6 +67,10 @@ router.get('/', (req, res) => {
});
res.json(shaped);
+ } catch (err) {
+ console.error('[categories GET]', err.message);
+ res.status(500).json({ error: 'Failed to load categories' });
+ }
});
// PUT /api/categories/reorder
@@ -126,21 +132,43 @@ router.post('/', (req, res) => {
// PUT /api/categories/:id
router.put('/:id', (req, res) => {
const db = getDb();
- const { name } = req.body;
+ const { name, spending_enabled } = req.body;
if (!name) return res.status(400).json(standardizeError('name is required', 'VALIDATION_ERROR', 'name'));
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);
if (!cat) return res.status(404).json(standardizeError('Category not found', 'NOT_FOUND', 'id'));
try {
- db.prepare("UPDATE categories SET name = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?")
- .run(name.trim(), req.params.id, req.user.id);
- res.json(db.prepare('SELECT * FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id));
+ 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); }
+ db.prepare(`UPDATE categories 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, spending_enabled, created_at, updated_at FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
+ res.json({ ...updated, spending_enabled: !!updated.spending_enabled });
} catch (e) {
- if (e.message.includes('UNIQUE')) {
+ if (e.message?.includes('UNIQUE')) {
return res.status(409).json(standardizeError('Category already exists', 'CONFLICT', 'name'));
}
- throw e;
+ console.error('[categories PUT]', e.message);
+ res.status(500).json({ error: 'Failed to update category' });
+ }
+});
+
+// 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' });
}
});
diff --git a/services/spendingService.js b/services/spendingService.js
index 3c66122..2bdc4eb 100644
--- a/services/spendingService.js
+++ b/services/spendingService.js
@@ -163,7 +163,9 @@ function categorizeTransaction(db, userId, txId, categoryId, saveMerchantRule =
VALUES (?, ?, ?)
ON CONFLICT(user_id, merchant) DO UPDATE SET category_id=excluded.category_id
`).run(userId, categoryId, merchant);
- // Apply this rule to all existing matching transactions
+ // Auto-mark this category as spending-enabled
+ db.prepare("UPDATE categories SET spending_enabled=1, updated_at=datetime('now') WHERE id=? AND user_id=?")
+ .run(categoryId, userId);
applySpendingCategoryRules(db, userId, merchant);
} catch { /* safe to ignore */ }
}
@@ -257,6 +259,11 @@ function addSpendingCategoryRule(db, userId, categoryId, merchant) {
VALUES (?, ?, ?)
ON CONFLICT(user_id, merchant) DO UPDATE SET category_id=excluded.category_id
`).run(userId, categoryId, normalized);
+ // Auto-mark this category as spending-enabled so it shows in the picker
+ try {
+ db.prepare("UPDATE categories SET spending_enabled=1, updated_at=datetime('now') WHERE id=? AND user_id=?")
+ .run(categoryId, userId);
+ } catch { /* non-critical */ }
applySpendingCategoryRules(db, userId, normalized);
}