225 lines
9.6 KiB
JavaScript
225 lines
9.6 KiB
JavaScript
const { all, audit, now, one, run } = require('../db');
|
|
const { money } = require('../depreciation');
|
|
const { ruleSetForBook } = require('./ruleSets');
|
|
const { computeYear } = require('./reportEngine');
|
|
const { assetFromRow } = require('./assets');
|
|
const { pmBookLedger } = require('./pm');
|
|
const { getDefaults } = require('./glAccounts');
|
|
|
|
function bookFromRow(row) {
|
|
return {
|
|
...row,
|
|
enabled: Boolean(row.enabled),
|
|
is_primary: Boolean(row.is_primary)
|
|
};
|
|
}
|
|
|
|
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
|
|
WHERE ab.book_type = b.code AND ab.active = 1 AND a.entity_id = b.entity_id) AS asset_count
|
|
FROM books b
|
|
LEFT JOIN tax_rule_sets t ON t.id = b.tax_rule_set_id
|
|
WHERE (? IS NULL OR b.entity_id = ?)
|
|
ORDER BY b.sort_order, b.id`,
|
|
[companyId || null, companyId || null]
|
|
).map(bookFromRow);
|
|
const ruleSets = all('SELECT id, name, jurisdiction, version, active FROM tax_rule_sets ORDER BY active DESC, name')
|
|
.map((row) => ({ ...row, active: Boolean(row.active) }));
|
|
return { books, ruleSets };
|
|
}
|
|
|
|
function getBook(code, companyId) {
|
|
return bookFromRow(one('SELECT * FROM books WHERE (code = ? OR id = ?) AND (? IS NULL OR entity_id = ?)', [code, code, companyId || null, companyId || null]) || null) || null;
|
|
}
|
|
|
|
function createBook(body, userId, companyId) {
|
|
const code = String(body.code || '').trim().toUpperCase().replace(/[^A-Z0-9_]/g, '').slice(0, 20);
|
|
if (!code) throw Object.assign(new Error('Book code is required'), { status: 400 });
|
|
if (one('SELECT id FROM books WHERE code = ? AND entity_id = ?', [code, companyId])) {
|
|
throw Object.assign(new Error(`A book with code "${code}" already exists`), { status: 400 });
|
|
}
|
|
const ts = now();
|
|
const next = one('SELECT MAX(sort_order) AS max FROM books WHERE entity_id = ?', [companyId]);
|
|
const result = run(
|
|
`INSERT INTO books (entity_id, code, name, description, book_type, enabled, is_primary, tax_rule_set_id, default_method, default_convention, fiscal_year_end_month, sort_order, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
companyId,
|
|
code,
|
|
body.name || code,
|
|
body.description || null,
|
|
['financial', 'tax', 'internal'].includes(body.book_type) ? body.book_type : 'internal',
|
|
body.enabled === false ? 0 : 1,
|
|
body.tax_rule_set_id || null,
|
|
body.default_method || 'straight_line',
|
|
body.default_convention || 'half_year',
|
|
Number(body.fiscal_year_end_month) || 12,
|
|
(next?.max || 0) + 1,
|
|
ts, ts
|
|
]
|
|
);
|
|
audit(userId, 'create', 'book', result.lastInsertRowid, null, { code, name: body.name });
|
|
return bookFromRow(one('SELECT * FROM books WHERE id = ?', [result.lastInsertRowid]));
|
|
}
|
|
|
|
function deleteBook(id, userId, companyId) {
|
|
const before = one('SELECT * FROM books WHERE id = ? AND (? IS NULL OR entity_id = ?)', [id, companyId || null, companyId || null]);
|
|
if (!before) return false;
|
|
if (before.is_primary) throw Object.assign(new Error('The primary book cannot be deleted'), { status: 400 });
|
|
if (before.book_type === 'maintenance') throw Object.assign(new Error('The PM book cannot be deleted'), { status: 400 });
|
|
const used = one('SELECT COUNT(*) AS count FROM asset_books ab JOIN assets a ON a.id = ab.asset_id WHERE ab.book_type = ? AND a.entity_id = ?', [before.code, before.entity_id]).count;
|
|
if (used) throw Object.assign(new Error(`This book is used by ${used} asset record(s); disable it instead`), { status: 400 });
|
|
run('DELETE FROM books WHERE id = ?', [id]);
|
|
audit(userId, 'delete', 'book', id, before, null);
|
|
return true;
|
|
}
|
|
|
|
function updateBook(id, body, userId, companyId) {
|
|
const before = one('SELECT * FROM books WHERE id = ? AND (? IS NULL OR entity_id = ?)', [id, companyId || null, companyId || null]);
|
|
if (!before) return null;
|
|
run(
|
|
`UPDATE books SET
|
|
name = ?, description = ?, book_type = ?, enabled = ?, tax_rule_set_id = ?,
|
|
default_method = ?, default_convention = ?, fiscal_year_end_month = ?, updated_at = ?
|
|
WHERE id = ?`,
|
|
[
|
|
body.name ?? before.name,
|
|
body.description ?? before.description,
|
|
body.book_type ?? before.book_type,
|
|
body.enabled === undefined ? before.enabled : (body.enabled ? 1 : 0),
|
|
body.tax_rule_set_id === undefined ? before.tax_rule_set_id : (body.tax_rule_set_id || null),
|
|
body.default_method ?? before.default_method,
|
|
body.default_convention ?? before.default_convention,
|
|
body.fiscal_year_end_month ?? before.fiscal_year_end_month,
|
|
now(),
|
|
id
|
|
]
|
|
);
|
|
audit(userId, 'update', 'book', id, before, body);
|
|
return bookFromRow(one('SELECT * FROM books WHERE id = ?', [id]));
|
|
}
|
|
|
|
// General-ledger view of a book: per-asset basis, accumulated depreciation and
|
|
// current-year expense rolled up to GL accounts, plus an asset-level detail.
|
|
function bookLedger(code, year, companyId) {
|
|
const book = getBook(code, companyId);
|
|
if (!book) return null;
|
|
if (book.book_type === 'maintenance') {
|
|
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,
|
|
b.business_use_percent AS book_bup, b.investment_use_percent AS book_ivp, b.manual_depreciation
|
|
FROM assets a JOIN asset_books b ON b.asset_id = a.id
|
|
WHERE b.book_type = ? AND b.active = 1 AND (? IS NULL OR a.entity_id = ?)
|
|
ORDER BY a.asset_id`,
|
|
[code, companyId || null, companyId || null]
|
|
);
|
|
|
|
const accountMap = new Map();
|
|
const addLine = (account, type, debit, credit) => {
|
|
const key = `${type}::${account}`;
|
|
const entry = accountMap.get(key) || { account, type, debit: 0, credit: 0 };
|
|
entry.debit += debit;
|
|
entry.credit += credit;
|
|
accountMap.set(key, entry);
|
|
};
|
|
|
|
let totalCost = 0;
|
|
let totalAccum = 0;
|
|
let totalDepr = 0;
|
|
const assets = rows.map((row) => {
|
|
const asset = assetFromRow(row);
|
|
const bk = {
|
|
book_type: code,
|
|
cost: row.book_cost,
|
|
depreciation_method: row.depreciation_method,
|
|
convention: row.convention,
|
|
useful_life_months: row.book_life,
|
|
section_179_amount: row.section_179_amount,
|
|
bonus_percent: row.bonus_percent,
|
|
business_use_percent: row.book_bup,
|
|
investment_use_percent: row.book_ivp,
|
|
manual_depreciation: row.manual_depreciation
|
|
};
|
|
const c = computeYear(asset, bk, rules, year);
|
|
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);
|
|
if (c.depreciation) addLine(expenseAccount, 'Depreciation expense', c.depreciation, 0);
|
|
|
|
totalCost += c.cost;
|
|
totalAccum += c.accumulated_end;
|
|
totalDepr += c.depreciation;
|
|
|
|
return {
|
|
asset_id: asset.asset_id,
|
|
description: asset.description,
|
|
asset_account: assetAccount,
|
|
accumulated_account: accumAccount,
|
|
expense_account: expenseAccount,
|
|
cost: c.cost,
|
|
depreciation: c.depreciation,
|
|
accumulated: c.accumulated_end,
|
|
nbv: c.nbv_end
|
|
};
|
|
});
|
|
|
|
const accounts = [...accountMap.values()]
|
|
.map((entry) => ({ account: entry.account, type: entry.type, debit: money(entry.debit), credit: money(entry.credit) }))
|
|
.sort((a, b) => a.account.localeCompare(b.account) || a.type.localeCompare(b.type));
|
|
|
|
return {
|
|
book,
|
|
year,
|
|
rule_set: { id: book.tax_rule_set_id, name: book.tax_rule_set_name, version: book.tax_rule_set_version },
|
|
summary: {
|
|
assets: assets.length,
|
|
cost: money(totalCost),
|
|
accumulated: money(totalAccum),
|
|
depreciation: money(totalDepr),
|
|
nbv: money(totalCost - totalAccum)
|
|
},
|
|
accounts,
|
|
accountTotals: { debit: money(accounts.reduce((s, a) => s + a.debit, 0)), credit: money(accounts.reduce((s, a) => s + a.credit, 0)) },
|
|
assets
|
|
};
|
|
}
|
|
|
|
// Shape the ledger account rollup as a generic report for PDF/XLSX/CSV export.
|
|
function ledgerReport(code, year, companyId) {
|
|
const ledger = bookLedger(code, year, companyId);
|
|
if (!ledger) return null;
|
|
return {
|
|
title: `${ledger.book.name} general ledger — ${year}`,
|
|
generatedAt: now(),
|
|
options: { book: code, year },
|
|
columns: [
|
|
{ key: 'account', label: 'GL account', format: 'text' },
|
|
{ key: 'type', label: 'Account type', format: 'text' },
|
|
{ key: 'debit', label: 'Debit', format: 'currency' },
|
|
{ key: 'credit', label: 'Credit', format: 'currency' }
|
|
],
|
|
rows: ledger.accounts,
|
|
totals: ledger.accountTotals,
|
|
meta: {
|
|
note: ledger.kind === 'maintenance'
|
|
? `${ledger.summary.assets} asset(s) · ${ledger.summary.completions} service(s) · total PM spend ${ledger.summary.cost}`
|
|
: `Cost ${ledger.summary.cost} · Accumulated ${ledger.summary.accumulated} · NBV ${ledger.summary.nbv} · ${year} depreciation ${ledger.summary.depreciation}`
|
|
}
|
|
};
|
|
}
|
|
|
|
module.exports = { bookLedger, createBook, deleteBook, getBook, ledgerReport, listBooks, updateBook };
|