diff --git a/server/app.js b/server/app.js index 4e0d716..acb2047 100644 --- a/server/app.js +++ b/server/app.js @@ -16,6 +16,7 @@ const assetRoutes = require('./routes/assets'); const assignmentRoutes = require('./routes/assignments'); const authRoutes = require('./routes/auth'); const bookRoutes = require('./routes/books'); +const closeRoutes = require('./routes/close'); const contactRoutes = require('./routes/contacts'); const dashboardRoutes = require('./routes/dashboard'); const dataPortabilityRoutes = require('./routes/dataPortability'); @@ -72,6 +73,7 @@ function createApp() { app.use('/api', assetRoutes); app.use('/api', alertRoutes); app.use('/api', bookRoutes); + app.use('/api', closeRoutes); app.use('/api', contactRoutes); app.use('/api', disposalRoutes); app.use('/api', leaseRoutes); diff --git a/server/db.js b/server/db.js index 105cffc..e2a36a2 100644 --- a/server/db.js +++ b/server/db.js @@ -460,9 +460,38 @@ function initialize() { expense_account TEXT, pm_expense_account TEXT, pm_clearing_account TEXT, + cwip_account TEXT, + gain_account TEXT, + loss_account TEXT, + proceeds_account TEXT, + retained_earnings_account TEXT, updated_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS close_periods ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_id INTEGER REFERENCES entities(id), + book_code TEXT NOT NULL, + period_type TEXT NOT NULL DEFAULT 'month', + fiscal_year INTEGER NOT NULL, + period_month INTEGER, + status TEXT NOT NULL DEFAULT 'open', + steps_json TEXT, + reconciliation_json TEXT, + totals_json TEXT, + notes TEXT, + started_by INTEGER REFERENCES users(id), + started_at TEXT, + closed_by INTEGER REFERENCES users(id), + closed_at TEXT, + reopened_by INTEGER REFERENCES users(id), + reopened_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(entity_id, book_code, period_type, fiscal_year, period_month) + ); + CREATE INDEX IF NOT EXISTS idx_close_periods_scope ON close_periods (entity_id, book_code, status); + CREATE TABLE IF NOT EXISTS alerts ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, @@ -847,6 +876,13 @@ function migrate() { rebuildReferenceValuesPerCompany(); rebuildAssetTemplatesPerCompany(); rebuildPartsPerCompany(); + // Period close: extra GL posting accounts + per-company capitalization threshold. + ensureColumn('gl_account_defaults', 'cwip_account', 'TEXT'); + ensureColumn('gl_account_defaults', 'gain_account', 'TEXT'); + ensureColumn('gl_account_defaults', 'loss_account', 'TEXT'); + ensureColumn('gl_account_defaults', 'proceeds_account', 'TEXT'); + ensureColumn('gl_account_defaults', 'retained_earnings_account', 'TEXT'); + ensureColumn('entities', 'capitalization_threshold', 'REAL NOT NULL DEFAULT 0'); backfillCompanyScope(); } @@ -1072,27 +1108,49 @@ function seedReferenceValuesForCompany(entityId) { // The defaults are the fallback accounts the depreciation/PM ledgers post to when an asset doesn't // carry its own. Called during initial seed and when a new company is created. function seedGlAccountsForCompany(entityId) { - if (!entityId || one('SELECT id FROM gl_accounts WHERE entity_id = ? LIMIT 1', [entityId])) return; + if (!entityId) return; const ts = now(); - const accounts = [ - ['1500', 'Fixed Assets', 'asset', 'debit', 'Asset cost / basis'], - ['1590', 'Accumulated Depreciation', 'contra_asset', 'credit', 'Accumulated depreciation (contra-asset)'], - ['2000', 'Maintenance Clearing', 'liability', 'credit', 'PM/maintenance clearing'], - ['6400', 'Depreciation Expense', 'expense', 'debit', 'Periodic depreciation expense'], - ['6450', 'Maintenance Expense', 'expense', 'debit', 'Preventative-maintenance spend'] - ]; - for (const [code, name, type, normal, description] of accounts) { - run( - `INSERT OR IGNORE INTO gl_accounts (entity_id, code, name, type, normal_balance, description, active, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`, - [entityId, code, name, type, normal, description, ts, ts] - ); + // Seed the starter chart only for a brand-new company, so deliberate later deletions aren't undone. + if (!one('SELECT id FROM gl_accounts WHERE entity_id = ? LIMIT 1', [entityId])) { + const accounts = [ + ['1010', 'Cash / Proceeds Clearing', 'asset', 'debit', 'Disposal proceeds clearing'], + ['1500', 'Fixed Assets', 'asset', 'debit', 'Asset cost / basis'], + ['1590', 'Accumulated Depreciation', 'contra_asset', 'credit', 'Accumulated depreciation (contra-asset)'], + ['1800', 'Construction in Progress', 'asset', 'debit', 'Capital work-in-progress (CWIP)'], + ['2000', 'Maintenance Clearing', 'liability', 'credit', 'PM/maintenance clearing'], + ['3900', 'Retained Earnings', 'equity', 'credit', 'Year-end depreciation roll-forward'], + ['6400', 'Depreciation Expense', 'expense', 'debit', 'Periodic depreciation expense'], + ['6450', 'Maintenance Expense', 'expense', 'debit', 'Preventative-maintenance spend'], + ['7200', 'Gain on Disposal', 'revenue', 'credit', 'Gain on asset disposal'], + ['7300', 'Loss on Disposal', 'expense', 'debit', 'Loss on asset disposal'] + ]; + for (const [code, name, type, normal, description] of accounts) { + run( + `INSERT OR IGNORE INTO gl_accounts (entity_id, code, name, type, normal_balance, description, active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`, + [entityId, code, name, type, normal, description, ts, ts] + ); + } } + // Ensure the per-company posting defaults exist; backfill the close-account columns for companies + // seeded before the close feature (the ledgers post to these account codes regardless of chart rows). run( - `INSERT OR IGNORE INTO gl_account_defaults (entity_id, asset_account, accumulated_account, expense_account, pm_expense_account, pm_clearing_account, updated_at) - VALUES (?, '1500', '1590', '6400', '6450', '2000', ?)`, + `INSERT OR IGNORE 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 (?, '1500', '1590', '6400', '6450', '2000', '1800', '7200', '7300', '1010', '3900', ?)`, [entityId, ts] ); + run( + `UPDATE gl_account_defaults SET + cwip_account = COALESCE(cwip_account, '1800'), + gain_account = COALESCE(gain_account, '7200'), + loss_account = COALESCE(loss_account, '7300'), + proceeds_account = COALESCE(proceeds_account, '1010'), + retained_earnings_account = COALESCE(retained_earnings_account, '3900') + WHERE entity_id = ?`, + [entityId] + ); } function seed() { diff --git a/server/routes/close.js b/server/routes/close.js new file mode 100644 index 0000000..01bb993 --- /dev/null +++ b/server/routes/close.js @@ -0,0 +1,79 @@ +const express = require('express'); +const { requireCapability } = require('../middleware/auth'); +const close = require('../services/closePeriods'); + +const router = express.Router(); +const reader = requireCapability('finance.view'); +const closer = requireCapability('finance.close'); + +// Period close (month-end / year-end). All scoped to the active company (req.companyId). + +router.get('/close-periods', reader, (req, res) => { + res.json({ periods: close.listPeriods(req.companyId, req.query.book || null) }); +}); + +// Step definitions for the wizard (month vs year). +router.get('/close-periods/step-defs', reader, (req, res) => { + res.json({ month: close.stepDefs('month'), year: close.stepDefs('year') }); +}); + +router.post('/close-periods/start', closer, (req, res, next) => { + try { + const period = close.getOrStart(req.companyId, req.body.book, req.body.period_type || 'month', req.body.fiscal_year, req.body.period_month, req.user.id); + res.status(201).json({ period, steps: close.stepDefs(period.period_type) }); + } catch (error) { + next(error); + } +}); + +router.get('/close-periods/:id', reader, (req, res) => { + const period = close.getPeriod(req.params.id, req.companyId); + if (!period) return res.status(404).json({ error: 'Close period not found' }); + return res.json({ period, steps: close.stepDefs(period.period_type), data: close.stepData(period, req.companyId) }); +}); + +function withPeriod(handler) { + return (req, res, next) => { + const period = close.getPeriod(req.params.id, req.companyId); + if (!period) return res.status(404).json({ error: 'Close period not found' }); + try { + return handler(period, req, res); + } catch (error) { + return next(error); + } + }; +} + +router.post('/close-periods/:id/steps/:key', closer, withPeriod((period, req, res) => { + res.json({ period: close.recordStep(period.id, req.params.key, req.body || {}, req.user.id, req.companyId) }); +})); + +router.post('/close-periods/:id/post-depreciation', closer, withPeriod((period, req, res) => { + const summary = close.postDepreciation(period, req.user.id, req.companyId); + res.json({ summary, period: close.getPeriod(period.id, req.companyId) }); +})); + +router.post('/close-periods/:id/post-disposals', closer, withPeriod((period, req, res) => { + res.json({ result: close.postDisposalEntries(period, req.user.id, req.companyId) }); +})); + +router.post('/close-periods/:id/reconcile', closer, withPeriod((period, req, res) => { + const reconciliation = close.reconcile(period, req.companyId); + res.json({ reconciliation, period: close.getPeriod(period.id, req.companyId) }); +})); + +router.post('/close-periods/:id/wip/:assetId/capitalize', closer, withPeriod((period, req, res) => { + const asset = close.capitalizeWip(Number(req.params.assetId), req.body || {}, req.user.id, req.companyId); + if (!asset) return res.status(404).json({ error: 'Asset not found' }); + return res.json({ asset }); +})); + +router.post('/close-periods/:id/finalize', closer, withPeriod((period, req, res) => { + res.json({ period: close.finalize(period, req.user.id, req.companyId, req.body || {}) }); +})); + +router.post('/close-periods/:id/reopen', closer, withPeriod((period, req, res) => { + res.json({ period: close.reopen(period.id, req.user.id, req.companyId) }); +})); + +module.exports = router; diff --git a/server/services/accessControl.js b/server/services/accessControl.js index 65bae02..31e4f77 100644 --- a/server/services/accessControl.js +++ b/server/services/accessControl.js @@ -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 = [ diff --git a/server/services/assets.js b/server/services/assets.js index 2b4be58..a05cabc 100644 --- a/server/services/assets.js +++ b/server/services/assets.js @@ -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). diff --git a/server/services/closePeriods.js b/server/services/closePeriods.js new file mode 100644 index 0000000..27c7f92 --- /dev/null +++ b/server/services/closePeriods.js @@ -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:` 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) { + 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 +}; diff --git a/server/services/disposals.js b/server/services/disposals.js index 6a24503..4338b48 100644 --- a/server/services/disposals.js +++ b/server/services/disposals.js @@ -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 (?, ?, ?, ?, ?, ?, ?, ?)`, diff --git a/server/services/glAccounts.js b/server/services/glAccounts.js index 4aa6993..afddc4f 100644 --- a/server/services/glAccounts.js +++ b/server/services/glAccounts.js @@ -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); diff --git a/server/services/glEntries.js b/server/services/glEntries.js index 3aa6c1e..99f89e0 100644 --- a/server/services/glEntries.js +++ b/server/services/glEntries.js @@ -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 diff --git a/server/smoke-test.js b/server/smoke-test.js index 1a7f325..5417a9b 100644 --- a/server/smoke-test.js +++ b/server/smoke-test.js @@ -1881,6 +1881,91 @@ async function main() { const logForbidden = await fetch(`${baseUrl}/api/app-logs`, { headers: limitedAuth }); if (logForbidden.status !== 403) throw new Error('Application logs should require the admin.logs capability'); + // ---- Period close (month-end / year-end) ----------------------------------- + // Run the whole close in company A explicitly (bare `auth` resolves to the admin's first company, + // which isn't deterministically company A). Seed a depreciating GAAP asset so depreciation is non-zero. + const aHeaders = companyHeaders(companyA); + await request('/api/assets', { + method: 'POST', headers: aHeaders, + body: JSON.stringify({ + asset_id: `CLZ-${suffix}`, description: 'Close-test machine', category: 'Computer Equipment', + acquisition_cost: 12000, acquired_date: '2026-01-10', in_service_date: '2026-01-10', useful_life_months: 60, + books: [{ book_type: 'GAAP', active: true, depreciation_method: 'straight_line', convention: 'half_year', useful_life_months: 60, cost: 12000 }] + }) + }); + + const startClose = await request('/api/close-periods/start', { + method: 'POST', headers: aHeaders, + body: JSON.stringify({ book: 'GAAP', period_type: 'month', fiscal_year: 2026, period_month: 3 }) + }); + const closeId = startClose.period.id; + if (startClose.period.status !== 'in_progress') throw new Error('Close period did not start'); + + const postedSource = `close:${closeId}`; + const closeEntries = async () => (await request('/api/books/GAAP/gl-entries?year=2026', { headers: aHeaders })).entries.filter((e) => e.source === postedSource); + + // Post depreciation → balanced JE (Dr expense = Cr accumulated), tagged close:. + const dep = (await request(`/api/close-periods/${closeId}/post-depreciation`, { method: 'POST', headers: aHeaders })).summary; + if (!(dep.debit > 0) || dep.debit !== dep.credit) throw new Error(`Depreciation JE not balanced: ${JSON.stringify(dep)}`); + const firstPost = await closeEntries(); + if (!firstPost.length) throw new Error('Close did not post GL entries'); + + // Idempotent re-post: same entry count, not doubled. + await request(`/api/close-periods/${closeId}/post-depreciation`, { method: 'POST', headers: aHeaders }); + if ((await closeEntries()).length !== firstPost.length) throw new Error('Depreciation re-post was not idempotent'); + + // Reconcile, then mark the reconcile step done (acknowledging any opening variance). + const recon = (await request(`/api/close-periods/${closeId}/reconcile`, { method: 'POST', headers: aHeaders })).reconciliation; + if (!recon.lines || recon.lines.length !== 3) throw new Error('Reconciliation result malformed'); + await request(`/api/close-periods/${closeId}/steps/reconcile`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ done: true, acknowledged: true, notes: 'opening balance' }) }); + + // Finalize → period closed. + const finalized = (await request(`/api/close-periods/${closeId}/finalize`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ acknowledge_variance: true }) })).period; + if (finalized.status !== 'closed') throw new Error('Finalize did not close the period'); + + // Lock: a backdated GL entry into the closed month is rejected (409). + const lockedPost = await fetch(`${baseUrl}/api/books/GAAP/gl-entries`, { + method: 'POST', headers: { ...aHeaders, 'Content-Type': 'application/json' }, + body: JSON.stringify({ entry_date: '2026-03-15', account: '6400', debit: 10, credit: 0 }) + }); + if (lockedPost.status !== 409) throw new Error(`Backdated GL post into a closed period should be 409, got ${lockedPost.status}`); + + // Reopen → close JEs reversed, status in_progress, and the backdated write now succeeds. + const reopened = (await request(`/api/close-periods/${closeId}/reopen`, { method: 'POST', headers: aHeaders })).period; + if (reopened.status !== 'in_progress') throw new Error('Reopen did not reset status'); + if ((await closeEntries()).length !== 0) throw new Error('Reopen did not reverse the close postings'); + await request('/api/books/GAAP/gl-entries', { method: 'POST', headers: aHeaders, body: JSON.stringify({ entry_date: '2026-03-15', account: '6400', debit: 10, credit: 0 }) }); + + // Every close action is audited. + const closeAudit = await request('/api/audit-logs?entity_type=close_period', { headers: auth }); + for (const action of ['start', 'post_depreciation', 'finalize', 'reopen']) { + if (!closeAudit.rows.some((r) => r.action === action)) throw new Error(`Close action not audited: ${action}`); + } + + // Isolation: company B cannot read company A's close period. + const crossClose = await fetch(`${baseUrl}/api/close-periods/${closeId}`, { headers: companyHeaders(companyB) }); + if (crossClose.status !== 404) throw new Error('Cross-company close period should 404'); + + // Capitalize WIP: a Construction-in-progress asset moves into service + posts a JE. + const cip = (await request('/api/assets', { + method: 'POST', headers: aHeaders, + body: JSON.stringify({ asset_id: `WIP-${suffix}`, description: 'Build in progress', category: 'Computer Equipment', acquisition_cost: 5000, status: 'cip', acquired_date: '2026-02-01' }) + })).asset; + const capped = await request(`/api/close-periods/${closeId}/wip/${cip.id}/capitalize`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ in_service_date: '2026-06-01' }) }); + if (capped.asset.status !== 'in_service') throw new Error('WIP capitalization did not place the asset in service'); + + // Year close: finalize runs the roll-forward to Retained Earnings. + const yearClose = (await request('/api/close-periods/start', { method: 'POST', headers: aHeaders, body: JSON.stringify({ book: 'GAAP', period_type: 'year', fiscal_year: 2026 }) })).period; + await request(`/api/close-periods/${yearClose.id}/post-depreciation`, { method: 'POST', headers: aHeaders }); + await request(`/api/close-periods/${yearClose.id}/reconcile`, { method: 'POST', headers: aHeaders }); + for (const key of ['final_month_close', 'reconcile', 'physical_inventory']) { + await request(`/api/close-periods/${yearClose.id}/steps/${key}`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ done: true, acknowledged: true }) }); + } + await request(`/api/close-periods/${yearClose.id}/finalize`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ acknowledge_variance: true }) }); + const reEntries = (await request('/api/books/GAAP/gl-entries?year=2026', { headers: aHeaders })).entries + .filter((e) => e.source === `close:${yearClose.id}` && e.account_type === 'Retained earnings'); + if (!reEntries.length) throw new Error('Year-end roll-forward did not post a Retained Earnings entry'); + console.log('Smoke test passed'); } diff --git a/src/App.vue b/src/App.vue index 1b44cc4..c408e6e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -171,6 +171,10 @@ v-if="activeView === 'gl-journals'" :token="token" /> + +
+ + + + + + + + + + + + +
{{ col.label }}
+ {{ display(row[col.key], col.format) }} +
{{ empty }}
+
+ + + diff --git a/src/components/UserManual.vue b/src/components/UserManual.vue index 4033527..d690df4 100644 --- a/src/components/UserManual.vue +++ b/src/components/UserManual.vue @@ -374,6 +374,126 @@

+ +
+

Period close — month-end & year-end

+

+ Financial → Period Close is a guided wizard that walks you through closing an accounting + period for the fixed-asset subledger. It reviews the period's activity, posts the journal entries the + close generates into the general ledger, reconciles the Fixed Asset Register to the GL, and then + locks the period so it can't be changed after the fact. Every action is recorded in + Activity & Audit Trail. +

+ +

Scope: which book, which period

+
    +
  • A close runs against one book — pick it at the top of the screen; it defaults to your + primary book (the one that feeds the financial GL). Run a separate close for each book you + maintain (for example, close GAAP for the financial GL; tax books are reviewed at year-end).
  • +
  • Choose Month (a fiscal year + month) or Year (a full fiscal year), then + Start / Resume. A close in progress is saved, so you can leave and come back to it. The + Recent closes list shows every period with its status and a 🔒 lock badge.
  • +
+ +

The month-end checklist

+

Each step shows the live data for the period and lets you act, then mark it done:

+
    +
  1. Record additions — review capital purchases placed in service in the period. Any asset + below your capitalization threshold is flagged so you can confirm it belongs on the register.
  2. +
  3. Record disposals — review assets sold, scrapped, or retired in the period (cost, + accumulated depreciation, gain/loss). You can post the disposal journal entries here.
  4. +
  5. Capitalize WIP — review Construction-in-progress assets and capitalize + completed ones, which moves them into service and begins depreciation.
  6. +
  7. Process & post depreciation — compute the period's depreciation and post it to + the GL, allocated by department / cost center. Fully-depreciated assets that would still compute + depreciation are flagged for review.
  8. +
  9. Check for impairments — review flagged assets and record any impairment loss (from the + asset's Adjustments tab).
  10. +
  11. Reconcile subledger to GL — compare the register to the GL control accounts and clear or + acknowledge any variance (see below). Required to finalize.
  12. +
  13. Generate reports — run and file the Fixed Asset Register, Depreciation Summary, and + Additions / Disposals reports for your records.
  14. +
  15. Finalize & lock — freeze the period.
  16. +
+ +

What gets posted to the GL

+

+ The close writes real, balanced journal entries into the stored GL layer (visible under + Books → GL and Journals). The accounts come from your + GL posting defaults (per-asset overrides win where set): +

+ + + + + + + + +
EventDebitCredit
Period depreciation (by department)Depreciation ExpenseAccumulated Depreciation
Capitalize WIPFixed Asset costConstruction-in-progress (CWIP)
DisposalAccum. depreciation + Proceeds (+ Loss)Asset cost (+ Gain)
Year-end roll-forwardRetained EarningsDepreciation Expense
+ + Every close posting is tagged to its period, which makes posting idempotent: + re-running a step (for example, re-posting depreciation after a correction) replaces the prior entries rather + than duplicating them. Manage the accounts these entries use in + Books → GL and Journals → Chart of accounts (the + posting defaults: asset cost, accumulated depreciation, depreciation expense, CWIP, gain/loss, + proceeds clearing, and retained earnings). + + +

Reconciling the subledger to the GL

+

+ The Reconcile step compares the Fixed Asset Register (the subledger) to the GL + control-account balances — cost, accumulated depreciation, and + net book value — and shows the variance per account. A green Balanced badge means + they agree; otherwise the variance is shown so you can clear it (post an adjusting entry) or + acknowledge it and finalize anyway. +

+ + When you first adopt closes, the GL won't yet hold the asset's full history, so the register + (life-to-date) and the GL (only what's been posted) will differ — that opening variance is expected. Post an + opening-balance entry to clear it, or acknowledge it. Once you run closes forward period over period, the two + stay in step. + + +

Finalizing & the period lock

+

+ Finalize & lock becomes available once the required steps are done and the reconciliation + has been run. Finalizing snapshots the period's totals and sets the period to Closed. A + closed period is locked: +

+
    +
  • Any change whose effective date falls inside the closed period is rejected — including + GL journal entries (for that book), and disposals, impairments, and asset + financial-date / cost edits (gated by the primary book's locked periods, since + that book is the subledger that feeds the GL).
  • +
  • Edits to non-financial fields (location, custodian, notes, and so on) are not blocked.
  • +
+ +

Year-end close

+

The year-end checklist includes everything above, plus:

+
    +
  • Final month-end close — confirm the final month is closed and its depreciation posted.
  • +
  • Physical inventory — confirm you've verified high-value / mobile assets still exist.
  • +
  • Update tax books — recompute tax depreciation (MACRS / §179) and review book-vs-tax + differences.
  • +
  • Roll-forward — finalizing a year close automatically posts the roll-forward + that clears the year's depreciation expense into Retained Earnings and locks the year.
  • +
+ +

Reopening a closed period

+

+ If you must correct a closed period, use Reopen (requires the close permission). Reopening + reverses all of that close's posted entries, returns the period to in progress, + and unlocks it so corrections can be made — then you re-run and finalize again. The reopen, the reversed + entries, and the re-close are all audited. +

+ + Running the close needs the “Run month-end / year-end close & reopen periods” capability + (included in the Finance role). Because closing posts to the GL and locks the books, keep this permission to + the people responsible for the close. + +
+

Tax rule sets

@@ -1067,6 +1187,7 @@ export default { { id: 'lifecycle', title: 'Asset lifecycle', icon: 'mdi-cash-remove', keywords: 'dispose disposal sale retire gain loss lease amortization impairment adjustment write down' }, { id: 'barcodes', title: 'Barcodes & scanning', icon: 'mdi-barcode-scan', keywords: 'barcode label print scan camera tag' }, { id: 'books', title: 'Books, depreciation & tax', icon: 'mdi-book-open-variant', keywords: 'book gaap federal depreciation general ledger gl method convention 179 bonus reports journal entry entries import export csv excel conflict merge history zone section 179' }, + { id: 'close', title: 'Period close (month & year-end)', icon: 'mdi-calendar-lock', keywords: 'close month end year end period lock reconcile subledger gl roll forward retained earnings depreciation post reopen finalize wip capitalize disposal impairment audit' }, { id: 'taxrules', title: 'Tax rule sets', icon: 'mdi-scale-balance', keywords: 'tax rules jurisdiction method limits activate version' }, { id: 'templates', title: 'Templates', icon: 'mdi-form-select', keywords: 'template defaults custom fields data entry' }, { id: 'pm', title: 'Preventative maintenance', icon: 'mdi-wrench-clock', keywords: 'pm maintenance plan steps complete close signature ratings guidance photos schedule meter usage reading hours lifespan wear curve dynamic nightly recompute' }, diff --git a/src/constants.js b/src/constants.js index d31165b..0181554 100644 --- a/src/constants.js +++ b/src/constants.js @@ -161,6 +161,7 @@ export const navItems = [ children: [ { key: 'books', label: 'Books', icon: 'mdi-book-open-variant' }, { key: 'gl-journals', label: 'GL and Journals', icon: 'mdi-book-account-outline' }, + { key: 'close', label: 'Period Close', icon: 'mdi-calendar-lock' }, { key: 'tax-rules', label: 'Tax rules', icon: 'mdi-scale-balance' } ] }, diff --git a/src/views/CloseView.vue b/src/views/CloseView.vue new file mode 100644 index 0000000..b3fa567 --- /dev/null +++ b/src/views/CloseView.vue @@ -0,0 +1,383 @@ + + + + +