Updated GL system allows changing account defaults and add/updating journal entries

This commit is contained in:
2026-06-08 11:43:02 -05:00
parent 582720bf01
commit 15e93c1e45
16 changed files with 980 additions and 495 deletions

View File

@@ -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);

View File

@@ -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);
}

View 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 };

View File

@@ -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),