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