Files
MixedAssets/server/services/closePeriods.js
2026-06-10 02:09:25 -05:00

535 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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) {
require('./midQuarter').clearCache(); // status cip→in_service changes the placed-in-service set
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
};