Updated GL system allows changing account defaults and add/updating journal entries
This commit is contained in:
60
server/db.js
60
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
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
105
server/services/glAccounts.js
Normal file
105
server/services/glAccounts.js
Normal file
@@ -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 };
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user