From 15e93c1e45bd430704701f3c6181ea2535c8bc37 Mon Sep 17 00:00:00 2001 From: mpuckett Date: Mon, 8 Jun 2026 11:43:02 -0500 Subject: [PATCH] Updated GL system allows changing account defaults and add/updating journal entries --- server/db.js | 60 +- server/routes/books.js | 39 ++ server/services/books.js | 17 +- server/services/companies.js | 5 +- server/services/glAccounts.js | 105 ++++ server/services/pm.js | 6 +- server/smoke-test.js | 30 + src/App.vue | 13 +- src/components/AuditTrailSettings.vue | 4 + src/components/ChangePasswordCard.vue | 3 + src/components/CompanySettings.vue | 4 + src/components/PasswordPolicySettings.vue | 3 + src/components/TeamsSettings.vue | 3 + src/constants.js | 1 + src/views/BooksView.vue | 487 +-------------- src/views/GlJournalsView.vue | 695 ++++++++++++++++++++++ 16 files changed, 980 insertions(+), 495 deletions(-) create mode 100644 server/services/glAccounts.js create mode 100644 src/views/GlJournalsView.vue diff --git a/server/db.js b/server/db.js index e5f7448..105cffc 100644 --- a/server/db.js +++ b/server/db.js @@ -438,6 +438,31 @@ function initialize() { CREATE INDEX IF NOT EXISTS idx_gl_entries_scope ON gl_entries (entity_id, book_code, fiscal_year); CREATE INDEX IF NOT EXISTS idx_gl_entries_extid ON gl_entries (entity_id, book_code, external_id); + CREATE TABLE IF NOT EXISTS gl_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_id INTEGER REFERENCES entities(id), + code TEXT NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'asset', + normal_balance TEXT NOT NULL DEFAULT 'debit', + description TEXT, + active INTEGER NOT NULL DEFAULT 1, + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(entity_id, code) + ); + + CREATE TABLE IF NOT EXISTS gl_account_defaults ( + entity_id INTEGER PRIMARY KEY REFERENCES entities(id) ON DELETE CASCADE, + asset_account TEXT, + accumulated_account TEXT, + expense_account TEXT, + pm_expense_account TEXT, + pm_clearing_account TEXT, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS alerts ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, @@ -957,6 +982,8 @@ function backfillCompanyScope() { if (!one('SELECT 1 AS x FROM user_companies LIMIT 1')) { run('INSERT OR IGNORE INTO user_companies (user_id, entity_id) SELECT u.id, e.id FROM users u CROSS JOIN entities e'); } + // Seed a starter chart of accounts + GL posting defaults for any existing company that lacks one. + for (const company of all('SELECT id FROM entities')) seedGlAccountsForCompany(company.id); } // Custom roles require the legacy CHECK(role IN (...)) constraint on `users` to be removed. @@ -1041,6 +1068,33 @@ function seedReferenceValuesForCompany(entityId) { } } +// Seed a starter chart of accounts and the GL posting defaults for a company (idempotent per company). +// The defaults are the fallback accounts the depreciation/PM ledgers post to when an asset doesn't +// carry its own. Called during initial seed and when a new company is created. +function seedGlAccountsForCompany(entityId) { + if (!entityId || one('SELECT id FROM gl_accounts WHERE entity_id = ? LIMIT 1', [entityId])) return; + const ts = now(); + const accounts = [ + ['1500', 'Fixed Assets', 'asset', 'debit', 'Asset cost / basis'], + ['1590', 'Accumulated Depreciation', 'contra_asset', 'credit', 'Accumulated depreciation (contra-asset)'], + ['2000', 'Maintenance Clearing', 'liability', 'credit', 'PM/maintenance clearing'], + ['6400', 'Depreciation Expense', 'expense', 'debit', 'Periodic depreciation expense'], + ['6450', 'Maintenance Expense', 'expense', 'debit', 'Preventative-maintenance spend'] + ]; + for (const [code, name, type, normal, description] of accounts) { + run( + `INSERT OR IGNORE INTO gl_accounts (entity_id, code, name, type, normal_balance, description, active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`, + [entityId, code, name, type, normal, description, ts, ts] + ); + } + run( + `INSERT OR IGNORE INTO gl_account_defaults (entity_id, asset_account, accumulated_account, expense_account, pm_expense_account, pm_clearing_account, updated_at) + VALUES (?, '1500', '1590', '6400', '6450', '2000', ?)`, + [entityId, ts] + ); +} + function seed() { const createdAt = now(); @@ -1170,7 +1224,10 @@ function seed() { } const refEntity = one('SELECT id FROM entities ORDER BY id LIMIT 1'); - if (refEntity) seedReferenceValuesForCompany(refEntity.id); + if (refEntity) { + seedReferenceValuesForCompany(refEntity.id); + seedGlAccountsForCompany(refEntity.id); + } run( `INSERT OR IGNORE INTO employees ( @@ -1301,6 +1358,7 @@ module.exports = { parseJson, run, seedBooksForCompany, + seedGlAccountsForCompany, seedReferenceValuesForCompany, tx }; diff --git a/server/routes/books.js b/server/routes/books.js index d736e44..2b327df 100644 --- a/server/routes/books.js +++ b/server/routes/books.js @@ -5,6 +5,7 @@ const { bookLedger, createBook, deleteBook, ledgerReport, listBooks, updateBook const { exportReport } = require('../services/reportExport'); const { rowsFromImport, extensionForUpload } = require('../services/importExport'); const glEntries = require('../services/glEntries'); +const glAccounts = require('../services/glAccounts'); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024 } }); const router = express.Router(); @@ -49,6 +50,44 @@ router.post('/books/:code/ledger/export', async (req, res) => { return res.send(output.body); }); +// ---- GL chart of accounts + posting defaults (full audit) ------------------- + +router.get('/gl-accounts', ledgerReader, (req, res) => { + res.json({ accounts: glAccounts.listAccounts(req.companyId), defaults: glAccounts.getDefaults(req.companyId) }); +}); + +router.post('/gl-accounts', ledgerEditor, (req, res, next) => { + try { + res.status(201).json({ account: glAccounts.createAccount(req.body, req.user.id, req.companyId) }); + } catch (error) { + next(error); + } +}); + +router.put('/gl-accounts/:id', ledgerEditor, (req, res, next) => { + try { + const account = glAccounts.updateAccount(req.params.id, req.body, req.user.id, req.companyId); + if (!account) return res.status(404).json({ error: 'GL account not found' }); + return res.json({ account }); + } catch (error) { + return next(error); + } +}); + +router.delete('/gl-accounts/:id', ledgerEditor, (req, res) => { + if (!glAccounts.deleteAccount(req.params.id, req.user.id, req.companyId)) return res.status(404).json({ error: 'GL account not found' }); + return res.status(204).end(); +}); + +// GL posting defaults (the fallback accounts the ledgers post to). +router.get('/gl-account-defaults', ledgerReader, (req, res) => { + res.json({ defaults: glAccounts.getDefaults(req.companyId) }); +}); + +router.put('/gl-account-defaults', ledgerEditor, (req, res) => { + res.json({ defaults: glAccounts.saveDefaults(req.body, req.user.id, req.companyId) }); +}); + // ---- GL journal entries (stored, editable; full audit) ---------------------- function glFilters(query, code) { diff --git a/server/services/books.js b/server/services/books.js index 6b5cfa7..cb1c557 100644 --- a/server/services/books.js +++ b/server/services/books.js @@ -4,12 +4,7 @@ const { ruleSetForBook } = require('./ruleSets'); const { computeYear } = require('./reportEngine'); const { assetFromRow } = require('./assets'); const { pmBookLedger } = require('./pm'); - -const DEFAULT_ACCOUNTS = { - asset: '1500', - accumulated: '1590', - expense: '6400' -}; +const { getDefaults } = require('./glAccounts'); function bookFromRow(row) { return { @@ -20,6 +15,9 @@ function bookFromRow(row) { } function listBooks(companyId) { + // book.code is unique only per company, and asset_books link by code string — so the asset_count + // subquery joins through to the asset and matches a.entity_id = b.entity_id to count only this + // company's assets on this company's book. const books = all( `SELECT b.*, t.name AS tax_rule_set_name, t.version AS tax_rule_set_version, (SELECT COUNT(*) FROM asset_books ab JOIN assets a ON a.id = ab.asset_id @@ -115,6 +113,7 @@ function bookLedger(code, year, companyId) { return { ...pmBookLedger(year, companyId), book }; } const rules = ruleSetForBook(code); + const defaults = getDefaults(companyId); // per-company fallback accounts (admin-configurable) const rows = all( `SELECT a.*, b.cost AS book_cost, b.depreciation_method, b.convention, b.useful_life_months AS book_life, b.section_179_amount, b.bonus_percent, @@ -152,9 +151,9 @@ function bookLedger(code, year, companyId) { manual_depreciation: row.manual_depreciation }; const c = computeYear(asset, bk, rules, year); - const assetAccount = asset.gl_asset_account || DEFAULT_ACCOUNTS.asset; - const accumAccount = asset.gl_accumulated_account || DEFAULT_ACCOUNTS.accumulated; - const expenseAccount = asset.gl_expense_account || DEFAULT_ACCOUNTS.expense; + const assetAccount = asset.gl_asset_account || defaults.asset_account; + const accumAccount = asset.gl_accumulated_account || defaults.accumulated_account; + const expenseAccount = asset.gl_expense_account || defaults.expense_account; addLine(assetAccount, 'Asset cost', c.cost, 0); addLine(accumAccount, 'Accumulated depreciation', 0, c.accumulated_end); diff --git a/server/services/companies.js b/server/services/companies.js index 3f44234..6096f20 100644 --- a/server/services/companies.js +++ b/server/services/companies.js @@ -1,4 +1,4 @@ -const { all, audit, now, one, run, seedBooksForCompany, seedReferenceValuesForCompany } = require('../db'); +const { all, audit, now, one, run, seedBooksForCompany, seedGlAccountsForCompany, seedReferenceValuesForCompany } = require('../db'); // Companies are stored in the `entities` table. Each gets its own books/GL, PM plans, alerts, and // (in phase 2) reference data. Disposing a company is a soft status change that preserves all history. @@ -29,9 +29,10 @@ function createCompany(body, userId) { Number(body.fiscal_year_end_month || 12), body.base_currency || 'USD', ts, ts ] ); - // A new company starts with the standard book set + reference data (categories/locations/depts). + // A new company starts with the standard book set, reference data, and chart of accounts + defaults. seedBooksForCompany(result.lastInsertRowid); seedReferenceValuesForCompany(result.lastInsertRowid); + seedGlAccountsForCompany(result.lastInsertRowid); audit(userId, 'create', 'entity', result.lastInsertRowid, null, { name }); return getCompany(result.lastInsertRowid); } diff --git a/server/services/glAccounts.js b/server/services/glAccounts.js new file mode 100644 index 0000000..4aa6993 --- /dev/null +++ b/server/services/glAccounts.js @@ -0,0 +1,105 @@ +const { all, audit, now, one, run } = require('../db'); + +// Per-company chart of accounts plus the GL "posting defaults" — the fallback accounts the +// depreciation and PM ledgers post to when an asset doesn't carry its own. Every change (account +// create/update/delete and a defaults change) is logged via audit() for the Activity & Audit Trail. + +const TYPES = ['asset', 'contra_asset', 'liability', 'equity', 'revenue', 'expense']; +const BALANCES = ['debit', 'credit']; +// Used as a last-resort fallback if a company somehow has no defaults row (seed normally creates one). +const FALLBACK_DEFAULTS = { asset_account: '1500', accumulated_account: '1590', expense_account: '6400', pm_expense_account: '6450', pm_clearing_account: '2000' }; + +function fromRow(row) { + if (!row) return null; + return { ...row, active: Boolean(row.active) }; +} + +function listAccounts(companyId) { + return all('SELECT * FROM gl_accounts WHERE entity_id = ? ORDER BY code', [companyId]).map(fromRow); +} + +function getAccount(id, companyId) { + return fromRow(one('SELECT * FROM gl_accounts WHERE id = ? AND entity_id = ?', [id, companyId])); +} + +function normalize(body) { + return { + code: String(body.code || '').trim(), + name: String(body.name || '').trim(), + type: TYPES.includes(body.type) ? body.type : 'asset', + normal_balance: BALANCES.includes(body.normal_balance) ? body.normal_balance : 'debit', + description: body.description ? String(body.description).trim() : null, + active: body.active === false ? 0 : 1 + }; +} + +function createAccount(body, userId, companyId) { + const input = normalize(body); + if (!input.code) throw Object.assign(new Error('An account code is required'), { status: 400 }); + if (!input.name) throw Object.assign(new Error('An account name is required'), { status: 400 }); + if (one('SELECT id FROM gl_accounts WHERE entity_id = ? AND code = ?', [companyId, input.code])) { + throw Object.assign(new Error(`Account ${input.code} already exists`), { status: 400 }); + } + const ts = now(); + const result = run( + `INSERT INTO gl_accounts (entity_id, code, name, type, normal_balance, description, active, created_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [companyId, input.code, input.name, input.type, input.normal_balance, input.description, input.active, userId, ts, ts] + ); + const account = getAccount(result.lastInsertRowid, companyId); + audit(userId, 'create', 'gl_account', result.lastInsertRowid, null, account); + return account; +} + +function updateAccount(id, body, userId, companyId) { + const before = getAccount(id, companyId); + if (!before) return null; + const input = normalize({ ...before, ...body }); + if (input.code !== before.code && one('SELECT id FROM gl_accounts WHERE entity_id = ? AND code = ? AND id <> ?', [companyId, input.code, id])) { + throw Object.assign(new Error(`Account ${input.code} already exists`), { status: 400 }); + } + run( + 'UPDATE gl_accounts SET code = ?, name = ?, type = ?, normal_balance = ?, description = ?, active = ?, updated_at = ? WHERE id = ?', + [input.code, input.name, input.type, input.normal_balance, input.description, input.active, now(), id] + ); + const after = getAccount(id, companyId); + audit(userId, 'update', 'gl_account', id, before, after); + return after; +} + +function deleteAccount(id, userId, companyId) { + const before = getAccount(id, companyId); + if (!before) return false; + run('DELETE FROM gl_accounts WHERE id = ?', [id]); + audit(userId, 'delete', 'gl_account', id, before, null); + return true; +} + +// ---- Posting defaults ------------------------------------------------------- + +function getDefaults(companyId) { + const row = one('SELECT * FROM gl_account_defaults WHERE entity_id = ?', [companyId]); + if (!row) return { entity_id: companyId, ...FALLBACK_DEFAULTS }; + return row; +} + +function saveDefaults(body, userId, companyId) { + const before = getDefaults(companyId); + const keys = ['asset_account', 'accumulated_account', 'expense_account', 'pm_expense_account', 'pm_clearing_account']; + const next = {}; + for (const key of keys) next[key] = body[key] !== undefined ? (String(body[key] || '').trim() || null) : (before[key] || null); + run( + `INSERT INTO gl_account_defaults (entity_id, asset_account, accumulated_account, expense_account, pm_expense_account, pm_clearing_account, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(entity_id) DO UPDATE SET + asset_account = excluded.asset_account, accumulated_account = excluded.accumulated_account, + expense_account = excluded.expense_account, pm_expense_account = excluded.pm_expense_account, + pm_clearing_account = excluded.pm_clearing_account, updated_at = excluded.updated_at`, + [companyId, next.asset_account, next.accumulated_account, next.expense_account, next.pm_expense_account, next.pm_clearing_account, now()] + ); + const after = getDefaults(companyId); + audit(userId, 'update', 'gl_account_defaults', companyId, before, after); + return after; +} + +module.exports = { TYPES, createAccount, deleteAccount, getAccount, getDefaults, listAccounts, saveDefaults, updateAccount }; diff --git a/server/services/pm.js b/server/services/pm.js index 3c01596..bc16e75 100644 --- a/server/services/pm.js +++ b/server/services/pm.js @@ -635,9 +635,11 @@ function pmBookLedger(year, companyId) { count += 1; } const assets = Object.values(byAsset).map((a) => ({ ...a, cost: round2(a.cost) })).sort((a, b) => b.cost - a.cost); + // Post PM spend to the company's configured maintenance expense / clearing accounts (admin-set defaults). + const defaults = require('./glAccounts').getDefaults(companyId); const accounts = [ - { account: '6450', type: 'PM / maintenance expense', debit: round2(total), credit: 0 }, - { account: '2000', type: 'Maintenance clearing', debit: 0, credit: round2(total) } + { account: defaults.pm_expense_account || '6450', type: 'PM / maintenance expense', debit: round2(total), credit: 0 }, + { account: defaults.pm_clearing_account || '2000', type: 'Maintenance clearing', debit: 0, credit: round2(total) } ]; return { year: Number(year), diff --git a/server/smoke-test.js b/server/smoke-test.js index 642b378..dc4f78f 100644 --- a/server/smoke-test.js +++ b/server/smoke-test.js @@ -1839,6 +1839,36 @@ async function main() { const glB = (await request('/api/books/GAAP/gl-entries', { headers: companyHeaders(companyB) })).entries; if (glB.some((e) => e.id === glEntry.id)) throw new Error('Company B sees company A GL entries'); + // ---- GL chart of accounts + posting defaults ------------------------------- + const seededChart = await request('/api/gl-accounts', { headers: companyHeaders(companyA) }); + if (seededChart.accounts.length < 5 || !seededChart.defaults.asset_account) throw new Error('Chart of accounts / posting defaults were not seeded'); + + const newAcct = (await request('/api/gl-accounts', { + method: 'POST', headers: companyHeaders(companyA), + body: JSON.stringify({ code: `1501-${suffix}`, name: 'Equipment — Co A', type: 'asset', normal_balance: 'debit' }) + })).account; + if (!newAcct || newAcct.entity_id !== companyA) throw new Error('GL account was not created/scoped'); + await request(`/api/gl-accounts/${newAcct.id}`, { method: 'PUT', headers: companyHeaders(companyA), body: JSON.stringify({ name: 'Equipment — revised' }) }); + + // Point the asset-cost posting default at the new account and confirm the ledger posts to it. + await request('/api/gl-account-defaults', { method: 'PUT', headers: companyHeaders(companyA), body: JSON.stringify({ asset_account: `1501-${suffix}` }) }); + const ledgerA = (await request('/api/books/GAAP/ledger?year=2026', { headers: companyHeaders(companyA) })).ledger; + if (!ledgerA.accounts.some((a) => a.account === `1501-${suffix}` && a.type === 'Asset cost')) { + throw new Error('Ledger did not use the configured default asset account'); + } + + // GL account + defaults changes are audited. + const acctAudit = await request('/api/audit-logs?entity_type=gl_account', { headers: auth }); + if (acctAudit.total < 2) throw new Error('GL account changes were not audited'); + if ((await request('/api/audit-logs?entity_type=gl_account_defaults', { headers: auth })).total < 1) { + throw new Error('GL account-default change was not audited'); + } + + // Isolation: company B has its own seeded chart, not company A's custom account. + const acctsB = (await request('/api/gl-accounts', { headers: companyHeaders(companyB) })).accounts; + if (!acctsB.length) throw new Error('Company B chart of accounts was not seeded'); + if (acctsB.some((a) => a.code === `1501-${suffix}`)) throw new Error('Company B sees company A GL account'); + console.log('Smoke test passed'); } diff --git a/src/App.vue b/src/App.vue index fbcb60c..528f362 100644 --- a/src/App.vue +++ b/src/App.vue @@ -86,6 +86,8 @@ + + import { apiRequest } from '../services/api'; +// Self-service password change. Reused in two places: the forced-change screen shown at login when the +// password is expired/admin-reset (`forced`), and the account menu's "Change password" dialog. It +// fetches the live policy rules to display, then PUTs to /api/profile/password. export default { props: { token: { type: String, required: true }, diff --git a/src/components/CompanySettings.vue b/src/components/CompanySettings.vue index d0e596b..14b8a7e 100644 --- a/src/components/CompanySettings.vue +++ b/src/components/CompanySettings.vue @@ -91,6 +91,10 @@ import DataTable from './DataTable.vue'; import { apiRequest } from '../services/api'; +// Admin company management (Admin → Application Configuration). Lists every company including disposed +// ones (?includeDisposed=1), supports add/edit, and soft dispose/restore. Emits `updated` so App.vue +// can refresh the top-bar company switcher. + function blankCompany() { return { id: null, name: '', legal_name: '', federal_tax_id: '', state_tax_id: '', local_tax_id: '', base_currency: 'USD', fiscal_year_end_month: 12 }; } diff --git a/src/components/PasswordPolicySettings.vue b/src/components/PasswordPolicySettings.vue index 0347ab5..71f6587 100644 --- a/src/components/PasswordPolicySettings.vue +++ b/src/components/PasswordPolicySettings.vue @@ -103,6 +103,9 @@ - - diff --git a/src/views/GlJournalsView.vue b/src/views/GlJournalsView.vue new file mode 100644 index 0000000..336740d --- /dev/null +++ b/src/views/GlJournalsView.vue @@ -0,0 +1,695 @@ + + + + +