Added Wizard for Month/Year End Close

This commit is contained in:
2026-06-09 18:48:52 -05:00
parent 9a9f890494
commit 599465bdac
15 changed files with 1374 additions and 25 deletions

View File

@@ -20,6 +20,7 @@ const CAPABILITIES = [
// Finance
{ key: 'finance.view', label: 'View reports, books & tax rules', group: 'Finance' },
{ key: 'finance.manage', label: 'Manage books, tax rules, disposals & leases', group: 'Finance' },
{ key: 'finance.close', label: 'Run month-end / year-end close & reopen periods', group: 'Finance' },
{ key: 'reports.save', label: 'Save & delete reports', group: 'Finance' },
// Configuration
{ key: 'config.manage', label: 'Manage templates, categories & ID templates', group: 'Configuration' },
@@ -41,7 +42,7 @@ const WILDCARD = '*';
const FINANCE_CAPS = [
'assets.view', 'assets.edit', 'assets.delete', 'assets.bulk', 'assets.assign',
'pm.view', 'pm.manage', 'pm.complete', 'parts.manage', 'alerts.manage',
'contacts.manage', 'finance.view', 'finance.manage', 'reports.save', 'config.manage'
'contacts.manage', 'finance.view', 'finance.manage', 'finance.close', 'reports.save', 'config.manage'
];
const OPERATIONS_CAPS = [

View File

@@ -270,6 +270,8 @@ function createAsset(body, userId, companyId) {
const created = now();
const input = normalizeAssetInput(body);
if (companyId) input.entity_id = companyId; // new assets belong to the active company
// A new capitalization can't land in a closed period (gated by the primary book's locks).
require('./closePeriods').assertSubledgerOpen(input.entity_id, input.in_service_date || input.acquired_date);
const result = tx(() => {
input.asset_id = resolveNewAssetId(input); // category ID template, else default sequence
const insert = run(
@@ -293,6 +295,14 @@ function updateAsset(id, body, userId, companyId) {
const input = normalizeAssetInput({ ...before, ...body });
if (companyId) input.entity_id = companyId; // assets stay within their company in phase 1
// Block changes to the financial dates/cost that would fall in a closed period (either the old or the
// new value), so a locked period's subledger can't be altered. Other field edits are unaffected.
const financialChanged = ['in_service_date', 'acquired_date', 'acquisition_cost'].some((f) => String(before[f] ?? '') !== String(input[f] ?? ''));
if (financialChanged) {
const guard = require('./closePeriods');
guard.assertSubledgerOpen(input.entity_id, before.in_service_date || before.acquired_date);
guard.assertSubledgerOpen(input.entity_id, input.in_service_date || input.acquired_date);
}
// When depreciation-critical fields change, the caller chooses when the change applies.
// 'placed_in_service' rebuilds the whole schedule by clearing prior depreciation;
// 'going_forward' keeps depreciation already recorded (the stored prior depreciation).

View File

@@ -0,0 +1,533 @@
const { all, audit, now, one, parseJson, run } = require('../db');
// Month-end / year-end close for the fixed-asset subledger, scoped per company + book. A close walks a
// checklist (additions, disposals, WIP capitalization, depreciation, impairments, reconciliation,
// reports, finalize), POSTS the real journal entries it generates into the stored GL layer (tagged
// `close:<id>` so they're idempotent and reversible), reconciles the register to the GL, and locks the
// period against backdated changes. Every action is audited. Top-level deps are db only; the GL/report
// services are lazy-required to avoid the cycle created by the lock guard below.
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const TOLERANCE = 0.01;
function round2(value) {
return Math.round((Number(value) || 0) * 100) / 100;
}
function pad(n) {
return String(n).padStart(2, '0');
}
// ---- Period shape & windows -------------------------------------------------
function fromRow(row) {
if (!row) return null;
return {
...row,
steps: parseJson(row.steps_json, {}),
reconciliation: parseJson(row.reconciliation_json, null),
totals: parseJson(row.totals_json, null),
locked: row.status === 'closed'
};
}
// Date window the period covers. Month = the calendar month; year = the calendar fiscal year (the
// depreciation engine is calendar-year based, so year closes use JanDec of fiscal_year).
function periodWindow(period) {
if (period.period_type === 'month') {
const m = period.period_month;
return {
start: `${period.fiscal_year}-${pad(m)}-01`,
end: new Date(Date.UTC(period.fiscal_year, m, 0)).toISOString().slice(0, 10),
label: `${MONTHS[m - 1]} ${period.fiscal_year}`
};
}
return { start: `${period.fiscal_year}-01-01`, end: `${period.fiscal_year}-12-31`, label: `FY ${period.fiscal_year}` };
}
function source(period) {
return `close:${period.id}`;
}
// ---- Step definitions -------------------------------------------------------
function stepDefs(periodType) {
const month = [
{ key: 'additions', title: 'Record additions', description: 'Verify capital purchases in the period meet the capitalization threshold and are recorded.', required: false },
{ key: 'disposals', title: 'Record disposals', description: 'Remove assets sold, scrapped, or retired in the period; recognize gain/loss.', required: false },
{ key: 'wip', title: 'Capitalize WIP', description: 'Move completed Construction-in-progress assets into service.', required: false },
{ key: 'depreciation', title: 'Process & post depreciation', description: 'Compute and post the period depreciation expense, allocated by department.', required: true },
{ key: 'impairments', title: 'Check for impairments', description: 'Assess assets for impairment and record any loss.', required: false },
{ key: 'reconcile', title: 'Reconcile subledger to GL', description: 'Match the Fixed Asset Register to the GL control accounts; clear variances.', required: true },
{ key: 'reports', title: 'Generate reports', description: 'Run the Fixed Asset Register, Depreciation Summary, and Additions/Disposals reports.', required: false }
];
if (periodType === 'month') return month;
return [
{ key: 'final_month_close', title: 'Final month-end close', description: 'Confirm the final month period is closed and the final depreciation posted.', required: true },
...month,
{ key: 'physical_inventory', title: 'Physical inventory', description: 'Physically verify high-value / mobile assets still exist and are in use.', required: true },
{ key: 'tax_books', title: 'Update tax books', description: 'Recompute tax depreciation (MACRS / §179) and review book-vs-tax differences.', required: false }
// roll_forward is performed automatically during finalize for a year close.
];
}
function requiredSteps(periodType) {
return stepDefs(periodType).filter((s) => s.required).map((s) => s.key);
}
// ---- Lifecycle --------------------------------------------------------------
function listPeriods(companyId, bookCode) {
const rows = all(
`SELECT * FROM close_periods WHERE entity_id = ? AND (? IS NULL OR book_code = ?)
ORDER BY fiscal_year DESC, period_month DESC, id DESC`,
[companyId, bookCode || null, bookCode || null]
);
return rows.map(fromRow);
}
function getPeriod(id, companyId) {
return fromRow(one('SELECT * FROM close_periods WHERE id = ? AND entity_id = ?', [id, companyId]));
}
function getOrStart(companyId, bookCode, periodType, fiscalYear, periodMonth, userId) {
const month = periodType === 'month' ? Number(periodMonth) : null;
if (periodType === 'month' && (!month || month < 1 || month > 12)) {
throw Object.assign(new Error('A valid month (1-12) is required for a month close'), { status: 400 });
}
const existing = one(
`SELECT * FROM close_periods WHERE entity_id = ? AND book_code = ? AND period_type = ? AND fiscal_year = ? AND IFNULL(period_month, 0) = ?`,
[companyId, bookCode, periodType, Number(fiscalYear), month || 0]
);
if (existing) {
if (existing.status === 'open') {
run('UPDATE close_periods SET status = ?, started_by = ?, started_at = ?, updated_at = ? WHERE id = ?', ['in_progress', userId, now(), now(), existing.id]);
}
return getPeriod(existing.id, companyId);
}
const ts = now();
const result = run(
`INSERT INTO close_periods (entity_id, book_code, period_type, fiscal_year, period_month, status, steps_json, started_by, started_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'in_progress', '{}', ?, ?, ?, ?)`,
[companyId, bookCode, periodType, Number(fiscalYear), month, userId, ts, ts, ts]
);
audit(userId, 'start', 'close_period', result.lastInsertRowid, null, { book: bookCode, periodType, fiscalYear, periodMonth: month });
return getPeriod(result.lastInsertRowid, companyId);
}
function saveSteps(period, userId) {
run('UPDATE close_periods SET steps_json = ?, updated_at = ? WHERE id = ?', [JSON.stringify(period.steps), now(), period.id]);
}
function setStepDone(period, key, userId, notes) {
period.steps = period.steps || {};
period.steps[key] = { done: true, by: userId, at: now(), notes: notes || null };
}
function recordStep(periodId, key, payload, userId, companyId) {
const period = getPeriod(periodId, companyId);
if (!period) return null;
if (period.locked) throw Object.assign(new Error('The period is closed. Reopen it to make changes.'), { status: 409 });
period.steps = period.steps || {};
period.steps[key] = { done: payload.done !== false, by: userId, at: now(), notes: payload.notes || null, acknowledged: Boolean(payload.acknowledged) };
saveSteps(period, userId);
audit(userId, 'step', 'close_period', period.id, null, { step: key, ...period.steps[key] });
return getPeriod(periodId, companyId);
}
// ---- Step data (live review for the wizard) ---------------------------------
function assetRowsForBook(bookCode, companyId, status) {
const { computeYear } = require('./reportEngine');
const { ruleSetForBook } = require('./ruleSets');
const { assetFromRow } = require('./assets');
const rules = ruleSetForBook(bookCode);
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 a.entity_id = ? AND (? IS NULL OR a.status = ?)
ORDER BY a.asset_id`,
[bookCode, companyId, status || null, status || null]
);
return { rules, computeYear, rows: rows.map((row) => ({ asset: assetFromRow(row), book: bookObject(row) })) };
}
function bookObject(row) {
return {
book_type: row.book_type,
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
};
}
function stepData(period, companyId) {
const win = periodWindow(period);
const company = one('SELECT capitalization_threshold FROM entities WHERE id = ?', [companyId]);
const threshold = Number(company?.capitalization_threshold || 0);
const additions = all(
`SELECT id, asset_id, description, category, department, acquisition_cost, in_service_date, acquired_date, status
FROM assets WHERE entity_id = ? AND status != 'cip'
AND ((in_service_date BETWEEN ? AND ?) OR (acquired_date BETWEEN ? AND ?))
ORDER BY COALESCE(in_service_date, acquired_date)`,
[companyId, win.start, win.end, win.start, win.end]
).map((a) => ({ ...a, below_threshold: threshold > 0 && Number(a.acquisition_cost) < threshold }));
const disposals = all(
`SELECT d.id, d.asset_id AS asset_pk, a.asset_id, a.description, d.disposal_date, d.method,
d.disposed_cost, d.accumulated_depreciation, d.net_book_value, d.disposal_price, d.disposal_expense, d.gain_loss
FROM disposals d JOIN assets a ON a.id = d.asset_id
WHERE a.entity_id = ? AND d.book_type = ? AND d.disposal_date BETWEEN ? AND ?
ORDER BY d.disposal_date`,
[companyId, period.book_code, win.start, win.end]
);
const wip = all(
`SELECT id, asset_id, description, category, department, acquisition_cost, acquired_date
FROM assets WHERE entity_id = ? AND status = 'cip' ORDER BY acquired_date`,
[companyId]
);
// Depreciation preview for the period (annual / 12 for a month close), grouped by department.
const { rules, computeYear, rows } = assetRowsForBook(period.book_code, companyId, 'in_service');
const fraction = period.period_type === 'month' ? 1 / 12 : 1;
const byDept = new Map();
const stillDepreciating = [];
let periodDep = 0;
for (const { asset, book } of rows) {
const c = computeYear(asset, book, rules, period.fiscal_year);
const dep = round2(c.depreciation * fraction);
const dept = asset.department || 'Unassigned';
if (dep > 0) {
byDept.set(dept, round2((byDept.get(dept) || 0) + dep));
periodDep += dep;
}
// Fully depreciated (NBV at/under salvage) yet a method still produces depreciation → flag for review.
if (c.nbv_end <= Number(asset.salvage_value || 0) + TOLERANCE && dep > 0) {
stillDepreciating.push({ asset_id: asset.asset_id, description: asset.description, depreciation: dep });
}
}
const depreciation = {
period: win.label,
total: round2(periodDep),
by_department: [...byDept.entries()].map(([department, amount]) => ({ department, amount })),
fully_depreciated_still_running: stillDepreciating
};
// Impairment candidates: simple heuristic — assets flagged poor condition that aren't disposed.
const impairments = all(
`SELECT id, asset_id, description, category, condition, acquisition_cost
FROM assets WHERE entity_id = ? AND status = 'in_service' AND condition IN ('poor', 'salvage', 'damaged')
ORDER BY asset_id`,
[companyId]
);
return {
window: win,
threshold,
additions,
disposals,
wip,
depreciation,
impairments,
reconciliation: period.reconciliation
};
}
// ---- Posting (idempotent; tagged source = close:<id>) -----------------------
function clearPostings(period, like) {
run('DELETE FROM gl_entries WHERE entity_id = ? AND source = ? AND external_id LIKE ?', [period.entity_id, source(period), like]);
}
function postDepreciation(period, userId, companyId) {
if (period.locked) throw Object.assign(new Error('The period is closed. Reopen it to repost.'), { status: 409 });
const glEntries = require('./glEntries');
const { getDefaults } = require('./glAccounts');
const defaults = getDefaults(companyId);
const win = periodWindow(period);
const src = source(period);
clearPostings(period, `${src}:dep:%`); // idempotent re-post
const { rules, computeYear, rows } = assetRowsForBook(period.book_code, companyId, 'in_service');
const fraction = period.period_type === 'month' ? 1 / 12 : 1;
const expenseLines = new Map(); // `${account}||${dept}` -> amount
const accumLines = new Map(); // account -> amount
for (const { asset, book } of rows) {
const c = computeYear(asset, book, rules, period.fiscal_year);
const dep = round2(c.depreciation * fraction);
if (dep <= 0) continue; // fully depreciated or none — nothing to post
const expAcct = asset.gl_expense_account || defaults.expense_account;
const accAcct = asset.gl_accumulated_account || defaults.accumulated_account;
const dept = asset.department || 'Unassigned';
const ek = `${expAcct}||${dept}`;
expenseLines.set(ek, round2((expenseLines.get(ek) || 0) + dep));
accumLines.set(accAcct, round2((accumLines.get(accAcct) || 0) + dep));
}
let debit = 0;
let credit = 0;
for (const [key, amount] of expenseLines) {
if (amount <= 0) continue;
const [account, dept] = key.split('||');
glEntries.insertEntry(buildLine({
win, fiscal_year: period.fiscal_year, account, account_type: 'Depreciation expense',
description: `Depreciation — ${win.label}${dept !== 'Unassigned' ? ` / ${dept}` : ''}`,
reference: `CLOSE-${period.id}`, debit: amount, credit: 0, external_id: `${src}:dep:exp:${account}:${dept}`
}), period.book_code, userId, companyId, src);
debit = round2(debit + amount);
}
for (const [account, amount] of accumLines) {
if (amount <= 0) continue;
glEntries.insertEntry(buildLine({
win, fiscal_year: period.fiscal_year, account, account_type: 'Accumulated depreciation',
description: `Depreciation — ${win.label}`, reference: `CLOSE-${period.id}`,
debit: 0, credit: amount, external_id: `${src}:dep:acc:${account}`
}), period.book_code, userId, companyId, src);
credit = round2(credit + amount);
}
const fresh = getPeriod(period.id, companyId);
setStepDone(fresh, 'depreciation', userId, `Posted ${debit} depreciation for ${win.label}`);
saveSteps(fresh, userId);
const summary = { period: win.label, debit, credit, expense_lines: expenseLines.size, accumulated_lines: accumLines.size };
audit(userId, 'post_depreciation', 'close_period', period.id, null, summary);
return summary;
}
// Post the GL entries for disposals recorded in the period (Dr accum + proceeds + loss / Cr cost + gain).
function postDisposalEntries(period, userId, companyId) {
if (period.locked) throw Object.assign(new Error('The period is closed. Reopen it to repost.'), { status: 409 });
const glEntries = require('./glEntries');
const { getDefaults } = require('./glAccounts');
const d = getDefaults(companyId);
const win = periodWindow(period);
const src = source(period);
clearPostings(period, `${src}:disp:%`);
const disposals = all(
`SELECT dz.*, a.acquisition_cost, a.gl_asset_account, a.gl_accumulated_account
FROM disposals dz JOIN assets a ON a.id = dz.asset_id
WHERE a.entity_id = ? AND dz.book_type = ? AND dz.disposal_date BETWEEN ? AND ?`,
[companyId, period.book_code, win.start, win.end]
);
let count = 0;
for (const dz of disposals) {
const assetAcct = dz.gl_asset_account || d.asset_account;
const accumAcct = dz.gl_accumulated_account || d.accumulated_account;
const cost = round2(dz.disposed_cost);
const accum = round2(dz.accumulated_depreciation);
const realized = round2(Number(dz.disposal_price || 0) - Number(dz.disposal_expense || 0));
const gainLoss = round2(dz.gain_loss);
const lines = [
{ account: accumAcct, account_type: 'Accumulated depreciation', debit: accum, credit: 0, tag: 'accum' },
{ account: d.proceeds_account, account_type: 'Proceeds', debit: realized, credit: 0, tag: 'cash' },
{ account: assetAcct, account_type: 'Asset cost', debit: 0, credit: cost, tag: 'cost' }
];
if (gainLoss < 0) lines.push({ account: d.loss_account, account_type: 'Loss on disposal', debit: round2(-gainLoss), credit: 0, tag: 'loss' });
if (gainLoss > 0) lines.push({ account: d.gain_account, account_type: 'Gain on disposal', debit: 0, credit: gainLoss, tag: 'gain' });
for (const l of lines) {
if (!l.debit && !l.credit) continue;
glEntries.insertEntry(buildLine({
win, fiscal_year: period.fiscal_year, account: l.account, account_type: l.account_type,
description: `Disposal of asset ${dz.asset_id}${win.label}`, reference: `CLOSE-${period.id}`,
debit: l.debit, credit: l.credit, asset_id: dz.asset_id, external_id: `${src}:disp:${dz.id}:${l.tag}`
}), period.book_code, userId, companyId, src);
}
count += 1;
}
audit(userId, 'post_disposals', 'close_period', period.id, null, { count, period: win.label });
return { posted: count };
}
function buildLine(o) {
return {
entry_date: o.win.end,
fiscal_year: o.fiscal_year,
account: o.account,
account_type: o.account_type,
description: o.description,
reference: o.reference,
debit: round2(o.debit),
credit: round2(o.credit),
asset_id: o.asset_id || null,
external_id: o.external_id
};
}
// Capitalize a Construction-in-progress asset: place it in service and post Dr asset / Cr CWIP.
function capitalizeWip(assetId, body, userId, companyId) {
const glEntries = require('./glEntries');
const { getDefaults } = require('./glAccounts');
const asset = one('SELECT * FROM assets WHERE id = ? AND entity_id = ?', [assetId, companyId]);
if (!asset) return null;
if (asset.status !== 'cip') throw Object.assign(new Error('Only Construction-in-progress assets can be capitalized'), { status: 400 });
const inService = String(body.in_service_date || now().slice(0, 10)).slice(0, 10);
const before = { status: asset.status, in_service_date: asset.in_service_date };
run('UPDATE assets SET status = ?, in_service_date = ?, updated_at = ? WHERE id = ?', ['in_service', inService, now(), assetId]);
const d = getDefaults(companyId);
const assetAcct = asset.gl_asset_account || d.asset_account;
const cost = round2(asset.acquisition_cost);
const primary = primaryBookCode(companyId) || 'GAAP';
const fy = Number(inService.slice(0, 4));
if (cost > 0) {
glEntries.insertEntry(buildLine({ win: { end: inService }, fiscal_year: fy, account: assetAcct, account_type: 'Asset cost', description: `Capitalize WIP — ${asset.asset_id}`, reference: `WIP-${assetId}`, debit: cost, credit: 0, asset_id: assetId, external_id: `wip:${assetId}:asset` }), primary, userId, companyId, `wip:${assetId}`);
glEntries.insertEntry(buildLine({ win: { end: inService }, fiscal_year: fy, account: d.cwip_account, account_type: 'Construction in progress', description: `Capitalize WIP — ${asset.asset_id}`, reference: `WIP-${assetId}`, debit: 0, credit: cost, asset_id: assetId, external_id: `wip:${assetId}:cwip` }), primary, userId, companyId, `wip:${assetId}`);
}
audit(userId, 'capitalize_wip', 'asset', assetId, before, { status: 'in_service', in_service_date: inService, cost });
return one('SELECT id, asset_id, status, in_service_date FROM assets WHERE id = ?', [assetId]);
}
// ---- Reconciliation ---------------------------------------------------------
// Compare the Fixed Asset Register (subledger) to the GL control accounts (posted entries), per account.
function reconcile(period, companyId) {
const { bookLedger } = require('./books');
const { getDefaults } = require('./glAccounts');
const d = getDefaults(companyId);
const win = periodWindow(period);
const ledger = bookLedger(period.book_code, period.fiscal_year, companyId);
const sub = ledger
? { cost: round2(ledger.summary.cost), accumulated: round2(ledger.summary.accumulated), nbv: round2(ledger.summary.nbv) }
: { cost: 0, accumulated: 0, nbv: 0 };
// GL posted balances for the control accounts through the period end.
const balance = (account, sign) => {
if (!account) return 0;
const row = one(
`SELECT IFNULL(SUM(debit), 0) AS d, IFNULL(SUM(credit), 0) AS c FROM gl_entries
WHERE entity_id = ? AND book_code = ? AND account = ? AND entry_date <= ?`,
[companyId, period.book_code, account, win.end]
);
return round2(sign * (row.d - row.c));
};
const glCost = balance(d.asset_account, 1); // debit-normal
const glAccum = balance(d.accumulated_account, -1); // credit-normal
const glNbv = round2(glCost - glAccum);
const lines = [
{ account: 'Asset cost', subledger: sub.cost, gl: glCost, variance: round2(sub.cost - glCost) },
{ account: 'Accumulated depreciation', subledger: sub.accumulated, gl: glAccum, variance: round2(sub.accumulated - glAccum) },
{ account: 'Net book value', subledger: sub.nbv, gl: glNbv, variance: round2(sub.nbv - glNbv) }
];
const maxVariance = Math.max(...lines.map((l) => Math.abs(l.variance)));
const result = { window: win, subledger: sub, gl: { cost: glCost, accumulated: glAccum, nbv: glNbv }, lines, balanced: maxVariance < TOLERANCE, max_variance: round2(maxVariance), at: now() };
run('UPDATE close_periods SET reconciliation_json = ?, updated_at = ? WHERE id = ?', [JSON.stringify(result), now(), period.id]);
return result;
}
// ---- Finalize / roll-forward / reopen ---------------------------------------
function rollForward(period, userId, companyId) {
const glEntries = require('./glEntries');
const { getDefaults } = require('./glAccounts');
const d = getDefaults(companyId);
const win = periodWindow(period);
const src = source(period);
clearPostings(period, `${src}:rollfwd:%`);
// Clear the year's posted depreciation expense into Retained Earnings.
const expense = one(
`SELECT IFNULL(SUM(debit), 0) AS d FROM gl_entries
WHERE entity_id = ? AND book_code = ? AND account_type = 'Depreciation expense' AND fiscal_year = ?`,
[companyId, period.book_code, period.fiscal_year]
);
const amount = round2(expense.d);
if (amount > 0) {
glEntries.insertEntry(buildLine({ win, fiscal_year: period.fiscal_year, account: d.retained_earnings_account, account_type: 'Retained earnings', description: `Year-end roll-forward — FY ${period.fiscal_year}`, reference: `ROLLFWD-${period.id}`, debit: amount, credit: 0, external_id: `${src}:rollfwd:re` }), period.book_code, userId, companyId, src);
glEntries.insertEntry(buildLine({ win, fiscal_year: period.fiscal_year, account: d.expense_account, account_type: 'Depreciation expense', description: `Close depreciation expense — FY ${period.fiscal_year}`, reference: `ROLLFWD-${period.id}`, debit: 0, credit: amount, external_id: `${src}:rollfwd:exp` }), period.book_code, userId, companyId, src);
}
audit(userId, 'roll_forward', 'close_period', period.id, null, { period: win.label, amount });
return { amount };
}
function finalize(period, userId, companyId, options = {}) {
if (period.locked) throw Object.assign(new Error('The period is already closed'), { status: 409 });
// Gate: every required step must be marked done.
const missing = requiredSteps(period.period_type).filter((key) => !period.steps?.[key]?.done);
if (missing.length) {
throw Object.assign(new Error(`Complete these steps before finalizing: ${missing.join(', ')}`), { status: 400, missing });
}
// Gate: reconciliation must have been run, and either balanced or explicitly acknowledged.
const recon = period.reconciliation;
if (!recon) throw Object.assign(new Error('Run the reconciliation before finalizing'), { status: 400 });
if (!recon.balanced && !period.steps?.reconcile?.acknowledged && !options.acknowledge_variance) {
throw Object.assign(new Error('The subledger does not reconcile to the GL. Clear the variance or acknowledge it to finalize.'), { status: 400, variance: recon.max_variance });
}
if (period.period_type === 'year') rollForward(period, userId, companyId);
const { bookLedger } = require('./books');
const ledger = bookLedger(period.book_code, period.fiscal_year, companyId);
const totals = ledger ? { cost: round2(ledger.summary.cost), accumulated: round2(ledger.summary.accumulated), nbv: round2(ledger.summary.nbv), assets: ledger.summary.assets } : null;
run(
'UPDATE close_periods SET status = ?, totals_json = ?, closed_by = ?, closed_at = ?, updated_at = ? WHERE id = ?',
['closed', JSON.stringify(totals), userId, now(), now(), period.id]
);
audit(userId, 'finalize', 'close_period', period.id, null, { period: periodWindow(period).label, totals, acknowledged_variance: !recon.balanced });
return getPeriod(period.id, companyId);
}
function reopen(periodId, userId, companyId) {
const period = getPeriod(periodId, companyId);
if (!period) return null;
if (period.status !== 'closed') throw Object.assign(new Error('Only a closed period can be reopened'), { status: 400 });
// Reverse the close's postings so a re-finalize re-posts cleanly.
const removed = run('DELETE FROM gl_entries WHERE entity_id = ? AND source = ?', [companyId, source(period)]);
run('UPDATE close_periods SET status = ?, reopened_by = ?, reopened_at = ?, closed_by = NULL, closed_at = NULL, updated_at = ? WHERE id = ?', ['in_progress', userId, now(), now(), period.id]);
audit(userId, 'reopen', 'close_period', period.id, { status: 'closed' }, { status: 'in_progress', reversed_entries: removed.changes });
return getPeriod(periodId, companyId);
}
// ---- Lock guard (exported; called by financial-write services) --------------
function primaryBookCode(companyId) {
const row = one('SELECT code FROM books WHERE entity_id = ? AND is_primary = 1 LIMIT 1', [companyId]);
return row ? row.code : null;
}
// Throws if `date` falls inside a CLOSED period for (companyId, bookCode). No-op when nothing is closed,
// so existing flows are unaffected until a period is actually locked.
function assertPeriodOpen(companyId, bookCode, date) {
if (!companyId || !bookCode || !date) return;
const day = String(date).slice(0, 10);
const closed = all(
"SELECT * FROM close_periods WHERE entity_id = ? AND book_code = ? AND status = 'closed'",
[companyId, bookCode]
);
for (const row of closed) {
const win = periodWindow(row);
if (day >= win.start && day <= win.end) {
throw Object.assign(new Error(`The accounting period (${win.label}, ${bookCode}) is closed. Reopen it to make changes.`), { status: 409 });
}
}
}
// Convenience guard for asset-level (subledger) writes: gated by the PRIMARY book's locked periods.
function assertSubledgerOpen(companyId, date) {
const primary = primaryBookCode(companyId);
if (primary) assertPeriodOpen(companyId, primary, date);
}
module.exports = {
assertPeriodOpen,
assertSubledgerOpen,
capitalizeWip,
finalize,
getOrStart,
getPeriod,
listPeriods,
periodWindow,
postDepreciation,
postDisposalEntries,
primaryBookCode,
reconcile,
recordStep,
reopen,
stepData,
stepDefs
};

View File

@@ -5,6 +5,13 @@ const { hydrateAsset } = require('./assets');
const DISPOSAL_METHODS = ['sale', 'exchange', 'involuntary_conversion', 'like_kind', 'abandonment'];
// Reject a subledger write whose effective date falls in a closed period (primary book). No-op until a
// period is actually locked. Lazy-required to avoid the closePeriods↔services cycle.
function guardSubledger(assetId, date) {
const row = one('SELECT entity_id FROM assets WHERE id = ?', [assetId]);
if (row && date) require('./closePeriods').assertSubledgerOpen(row.entity_id, date);
}
function yearOf(value) {
if (!value) return new Date().getFullYear();
return new Date(`${value}T00:00:00`).getFullYear();
@@ -76,6 +83,7 @@ function recordDisposal(assetId, input, userId) {
const asset = hydrateAsset(assetId);
if (!asset) return null;
const result = computeDisposal(asset, input);
guardSubledger(assetId, result.disposal_date);
const timestamp = now();
return tx(() => {
@@ -121,6 +129,7 @@ function recordDisposal(assetId, input, userId) {
function reverseDisposal(assetId, disposalId, userId) {
const disposal = one('SELECT * FROM disposals WHERE id = ? AND asset_id = ?', [disposalId, assetId]);
if (!disposal) return null;
guardSubledger(assetId, disposal.disposal_date);
const timestamp = now();
return tx(() => {
@@ -149,6 +158,7 @@ function recordAdjustment(assetId, input, userId) {
const asset = one('SELECT id FROM assets WHERE id = ?', [assetId]);
if (!asset) return null;
const timestamp = now();
guardSubledger(assetId, input.adjustment_date || timestamp.slice(0, 10));
const result = run(
`INSERT INTO asset_adjustments (asset_id, adjustment_date, type, amount, book_type, reason, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,

View File

@@ -7,7 +7,13 @@ const { all, audit, now, one, run } = require('../db');
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' };
const FALLBACK_DEFAULTS = {
asset_account: '1500', accumulated_account: '1590', expense_account: '6400',
pm_expense_account: '6450', pm_clearing_account: '2000',
cwip_account: '1800', gain_account: '7200', loss_account: '7300',
proceeds_account: '1010', retained_earnings_account: '3900'
};
const DEFAULT_KEYS = Object.keys(FALLBACK_DEFAULTS);
function fromRow(row) {
if (!row) return null;
@@ -85,17 +91,22 @@ function getDefaults(companyId) {
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);
for (const key of DEFAULT_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 (?, ?, ?, ?, ?, ?, ?)
`INSERT INTO gl_account_defaults
(entity_id, asset_account, accumulated_account, expense_account, pm_expense_account, pm_clearing_account,
cwip_account, gain_account, loss_account, proceeds_account, retained_earnings_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()]
pm_clearing_account = excluded.pm_clearing_account, cwip_account = excluded.cwip_account,
gain_account = excluded.gain_account, loss_account = excluded.loss_account,
proceeds_account = excluded.proceeds_account, retained_earnings_account = excluded.retained_earnings_account,
updated_at = excluded.updated_at`,
[companyId, next.asset_account, next.accumulated_account, next.expense_account, next.pm_expense_account, next.pm_clearing_account,
next.cwip_account, next.gain_account, next.loss_account, next.proceeds_account, next.retained_earnings_account, now()]
);
const after = getDefaults(companyId);
audit(userId, 'update', 'gl_account_defaults', companyId, before, after);

View File

@@ -130,13 +130,18 @@ function insertEntry(entry, book, userId, companyId, source) {
}
function createEntry(book, body, userId, companyId) {
return insertEntry(validateEntry(body), book, userId, companyId, 'manual');
const entry = validateEntry(body);
require('./closePeriods').assertPeriodOpen(companyId, book, entry.entry_date);
return insertEntry(entry, book, userId, companyId, 'manual');
}
function updateEntry(id, body, userId, companyId) {
const before = getEntry(id, companyId);
if (!before) return null;
const entry = validateEntry({ ...before, ...body });
// Block edits dated in (or moving into) a closed period for this entry's book.
require('./closePeriods').assertPeriodOpen(companyId, before.book_code, before.entry_date);
require('./closePeriods').assertPeriodOpen(companyId, before.book_code, entry.entry_date);
run(
`UPDATE gl_entries SET entry_date = ?, fiscal_year = ?, account = ?, account_type = ?, description = ?,
reference = ?, debit = ?, credit = ?, asset_id = ?, external_id = ?, updated_at = ? WHERE id = ?`,
@@ -153,6 +158,7 @@ function updateEntry(id, body, userId, companyId) {
function deleteEntry(id, userId, companyId) {
const before = getEntry(id, companyId);
if (!before) return false;
require('./closePeriods').assertPeriodOpen(companyId, before.book_code, before.entry_date);
run('DELETE FROM gl_entries WHERE id = ?', [id]);
audit(userId, 'delete', 'gl_entry', id, before, null);
return true;
@@ -280,6 +286,7 @@ module.exports = {
entriesReport,
entryHistory,
getEntry,
insertEntry,
listEntries,
previewImport,
updateEntry