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:` 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 Jan–Dec 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:) ----------------------- 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 };