@@ -0,0 +1,533 @@
const { all , audit , now , one , parseJson , run } = require ( '../db' ) ;
// Month-end / year-end close for the fixed-asset subledger, scoped per company + book. A close walks a
// checklist (additions, disposals, WIP capitalization, depreciation, impairments, reconciliation,
// reports, finalize), POSTS the real journal entries it generates into the stored GL layer (tagged
// `close:<id>` so they're idempotent and reversible), reconciles the register to the GL, and locks the
// period against backdated changes. Every action is audited. Top-level deps are db only; the GL/report
// services are lazy-required to avoid the cycle created by the lock guard below.
const MONTHS = [ 'Jan' , 'Feb' , 'Mar' , 'Apr' , 'May' , 'Jun' , 'Jul' , 'Aug' , 'Sep' , 'Oct' , 'Nov' , 'Dec' ] ;
const TOLERANCE = 0.01 ;
function round2 ( value ) {
return Math . round ( ( Number ( value ) || 0 ) * 100 ) / 100 ;
}
function pad ( n ) {
return String ( n ) . padStart ( 2 , '0' ) ;
}
// ---- Period shape & windows -------------------------------------------------
function fromRow ( row ) {
if ( ! row ) return null ;
return {
... row ,
steps : parseJson ( row . steps _json , { } ) ,
reconciliation : parseJson ( row . reconciliation _json , null ) ,
totals : parseJson ( row . totals _json , null ) ,
locked : row . status === 'closed'
} ;
}
// Date window the period covers. Month = the calendar month; year = the calendar fiscal year (the
// depreciation engine is calendar-year based, so year closes use 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:<id>) -----------------------
function clearPostings ( period , like ) {
run ( 'DELETE FROM gl_entries WHERE entity_id = ? AND source = ? AND external_id LIKE ?' , [ period . entity _id , source ( period ) , like ] ) ;
}
function postDepreciation ( period , userId , companyId ) {
if ( period . locked ) throw Object . assign ( new Error ( 'The period is closed. Reopen it to repost.' ) , { status : 409 } ) ;
const glEntries = require ( './glEntries' ) ;
const { getDefaults } = require ( './glAccounts' ) ;
const defaults = getDefaults ( companyId ) ;
const win = periodWindow ( period ) ;
const src = source ( period ) ;
clearPostings ( period , ` ${ src } :dep:% ` ) ; // idempotent re-post
const { rules , computeYear , rows } = assetRowsForBook ( period . book _code , companyId , 'in_service' ) ;
const fraction = period . period _type === 'month' ? 1 / 12 : 1 ;
const expenseLines = new Map ( ) ; // `${account}||${dept}` -> amount
const accumLines = new Map ( ) ; // account -> amount
for ( const { asset , book } of rows ) {
const c = computeYear ( asset , book , rules , period . fiscal _year ) ;
const dep = round2 ( c . depreciation * fraction ) ;
if ( dep <= 0 ) continue ; // fully depreciated or none — nothing to post
const expAcct = asset . gl _expense _account || defaults . expense _account ;
const accAcct = asset . gl _accumulated _account || defaults . accumulated _account ;
const dept = asset . department || 'Unassigned' ;
const ek = ` ${ expAcct } || ${ dept } ` ;
expenseLines . set ( ek , round2 ( ( expenseLines . get ( ek ) || 0 ) + dep ) ) ;
accumLines . set ( accAcct , round2 ( ( accumLines . get ( accAcct ) || 0 ) + dep ) ) ;
}
let debit = 0 ;
let credit = 0 ;
for ( const [ key , amount ] of expenseLines ) {
if ( amount <= 0 ) continue ;
const [ account , dept ] = key . split ( '||' ) ;
glEntries . insertEntry ( buildLine ( {
win , fiscal _year : period . fiscal _year , account , account _type : 'Depreciation expense' ,
description : ` Depreciation — ${ win . label } ${ dept !== 'Unassigned' ? ` / ${ dept } ` : '' } ` ,
reference : ` CLOSE- ${ period . id } ` , debit : amount , credit : 0 , external _id : ` ${ src } :dep:exp: ${ account } : ${ dept } `
} ) , period . book _code , userId , companyId , src ) ;
debit = round2 ( debit + amount ) ;
}
for ( const [ account , amount ] of accumLines ) {
if ( amount <= 0 ) continue ;
glEntries . insertEntry ( buildLine ( {
win , fiscal _year : period . fiscal _year , account , account _type : 'Accumulated depreciation' ,
description : ` Depreciation — ${ win . label } ` , reference : ` CLOSE- ${ period . id } ` ,
debit : 0 , credit : amount , external _id : ` ${ src } :dep:acc: ${ account } `
} ) , period . book _code , userId , companyId , src ) ;
credit = round2 ( credit + amount ) ;
}
const fresh = getPeriod ( period . id , companyId ) ;
setStepDone ( fresh , 'depreciation' , userId , ` Posted ${ debit } depreciation for ${ win . label } ` ) ;
saveSteps ( fresh , userId ) ;
const summary = { period : win . label , debit , credit , expense _lines : expenseLines . size , accumulated _lines : accumLines . size } ;
audit ( userId , 'post_depreciation' , 'close_period' , period . id , null , summary ) ;
return summary ;
}
// Post the GL entries for disposals recorded in the period (Dr accum + proceeds + loss / Cr cost + gain).
function postDisposalEntries ( period , userId , companyId ) {
if ( period . locked ) throw Object . assign ( new Error ( 'The period is closed. Reopen it to repost.' ) , { status : 409 } ) ;
const glEntries = require ( './glEntries' ) ;
const { getDefaults } = require ( './glAccounts' ) ;
const d = getDefaults ( companyId ) ;
const win = periodWindow ( period ) ;
const src = source ( period ) ;
clearPostings ( period , ` ${ src } :disp:% ` ) ;
const disposals = all (
` SELECT dz.*, a.acquisition_cost, a.gl_asset_account, a.gl_accumulated_account
FROM disposals dz JOIN assets a ON a.id = dz.asset_id
WHERE a.entity_id = ? AND dz.book_type = ? AND dz.disposal_date BETWEEN ? AND ? ` ,
[ companyId , period . book _code , win . start , win . end ]
) ;
let count = 0 ;
for ( const dz of disposals ) {
const assetAcct = dz . gl _asset _account || d . asset _account ;
const accumAcct = dz . gl _accumulated _account || d . accumulated _account ;
const cost = round2 ( dz . disposed _cost ) ;
const accum = round2 ( dz . accumulated _depreciation ) ;
const realized = round2 ( Number ( dz . disposal _price || 0 ) - Number ( dz . disposal _expense || 0 ) ) ;
const gainLoss = round2 ( dz . gain _loss ) ;
const lines = [
{ account : accumAcct , account _type : 'Accumulated depreciation' , debit : accum , credit : 0 , tag : 'accum' } ,
{ account : d . proceeds _account , account _type : 'Proceeds' , debit : realized , credit : 0 , tag : 'cash' } ,
{ account : assetAcct , account _type : 'Asset cost' , debit : 0 , credit : cost , tag : 'cost' }
] ;
if ( gainLoss < 0 ) lines . push ( { account : d . loss _account , account _type : 'Loss on disposal' , debit : round2 ( - gainLoss ) , credit : 0 , tag : 'loss' } ) ;
if ( gainLoss > 0 ) lines . push ( { account : d . gain _account , account _type : 'Gain on disposal' , debit : 0 , credit : gainLoss , tag : 'gain' } ) ;
for ( const l of lines ) {
if ( ! l . debit && ! l . credit ) continue ;
glEntries . insertEntry ( buildLine ( {
win , fiscal _year : period . fiscal _year , account : l . account , account _type : l . account _type ,
description : ` Disposal of asset ${ dz . asset _id } — ${ win . label } ` , reference : ` CLOSE- ${ period . id } ` ,
debit : l . debit , credit : l . credit , asset _id : dz . asset _id , external _id : ` ${ src } :disp: ${ dz . id } : ${ l . tag } `
} ) , period . book _code , userId , companyId , src ) ;
}
count += 1 ;
}
audit ( userId , 'post_disposals' , 'close_period' , period . id , null , { count , period : win . label } ) ;
return { posted : count } ;
}
function buildLine ( o ) {
return {
entry _date : o . win . end ,
fiscal _year : o . fiscal _year ,
account : o . account ,
account _type : o . account _type ,
description : o . description ,
reference : o . reference ,
debit : round2 ( o . debit ) ,
credit : round2 ( o . credit ) ,
asset _id : o . asset _id || null ,
external _id : o . external _id
} ;
}
// Capitalize a Construction-in-progress asset: place it in service and post Dr asset / Cr CWIP.
function capitalizeWip ( assetId , body , userId , companyId ) {
const glEntries = require ( './glEntries' ) ;
const { getDefaults } = require ( './glAccounts' ) ;
const asset = one ( 'SELECT * FROM assets WHERE id = ? AND entity_id = ?' , [ assetId , companyId ] ) ;
if ( ! asset ) return null ;
if ( asset . status !== 'cip' ) throw Object . assign ( new Error ( 'Only Construction-in-progress assets can be capitalized' ) , { status : 400 } ) ;
const inService = String ( body . in _service _date || now ( ) . slice ( 0 , 10 ) ) . slice ( 0 , 10 ) ;
const before = { status : asset . status , in _service _date : asset . in _service _date } ;
run ( 'UPDATE assets SET status = ?, in_service_date = ?, updated_at = ? WHERE id = ?' , [ 'in_service' , inService , now ( ) , assetId ] ) ;
const d = getDefaults ( companyId ) ;
const assetAcct = asset . gl _asset _account || d . asset _account ;
const cost = round2 ( asset . acquisition _cost ) ;
const primary = primaryBookCode ( companyId ) || 'GAAP' ;
const fy = Number ( inService . slice ( 0 , 4 ) ) ;
if ( cost > 0 ) {
glEntries . insertEntry ( buildLine ( { win : { end : inService } , fiscal _year : fy , account : assetAcct , account _type : 'Asset cost' , description : ` Capitalize WIP — ${ asset . asset _id } ` , reference : ` WIP- ${ assetId } ` , debit : cost , credit : 0 , asset _id : assetId , external _id : ` wip: ${ assetId } :asset ` } ) , primary , userId , companyId , ` wip: ${ assetId } ` ) ;
glEntries . insertEntry ( buildLine ( { win : { end : inService } , fiscal _year : fy , account : d . cwip _account , account _type : 'Construction in progress' , description : ` Capitalize WIP — ${ asset . asset _id } ` , reference : ` WIP- ${ assetId } ` , debit : 0 , credit : cost , asset _id : assetId , external _id : ` wip: ${ assetId } :cwip ` } ) , primary , userId , companyId , ` wip: ${ assetId } ` ) ;
}
audit ( userId , 'capitalize_wip' , 'asset' , assetId , before , { status : 'in_service' , in _service _date : inService , cost } ) ;
return one ( 'SELECT id, asset_id, status, in_service_date FROM assets WHERE id = ?' , [ assetId ] ) ;
}
// ---- Reconciliation ---------------------------------------------------------
// Compare the Fixed Asset Register (subledger) to the GL control accounts (posted entries), per account.
function reconcile ( period , companyId ) {
const { bookLedger } = require ( './books' ) ;
const { getDefaults } = require ( './glAccounts' ) ;
const d = getDefaults ( companyId ) ;
const win = periodWindow ( period ) ;
const ledger = bookLedger ( period . book _code , period . fiscal _year , companyId ) ;
const sub = ledger
? { cost : round2 ( ledger . summary . cost ) , accumulated : round2 ( ledger . summary . accumulated ) , nbv : round2 ( ledger . summary . nbv ) }
: { cost : 0 , accumulated : 0 , nbv : 0 } ;
// GL posted balances for the control accounts through the period end.
const balance = ( account , sign ) => {
if ( ! account ) return 0 ;
const row = one (
` SELECT IFNULL(SUM(debit), 0) AS d, IFNULL(SUM(credit), 0) AS c FROM gl_entries
WHERE entity_id = ? AND book_code = ? AND account = ? AND entry_date <= ? ` ,
[ companyId , period . book _code , account , win . end ]
) ;
return round2 ( sign * ( row . d - row . c ) ) ;
} ;
const glCost = balance ( d . asset _account , 1 ) ; // debit-normal
const glAccum = balance ( d . accumulated _account , - 1 ) ; // credit-normal
const glNbv = round2 ( glCost - glAccum ) ;
const lines = [
{ account : 'Asset cost' , subledger : sub . cost , gl : glCost , variance : round2 ( sub . cost - glCost ) } ,
{ account : 'Accumulated depreciation' , subledger : sub . accumulated , gl : glAccum , variance : round2 ( sub . accumulated - glAccum ) } ,
{ account : 'Net book value' , subledger : sub . nbv , gl : glNbv , variance : round2 ( sub . nbv - glNbv ) }
] ;
const maxVariance = Math . max ( ... lines . map ( ( l ) => Math . abs ( l . variance ) ) ) ;
const result = { window : win , subledger : sub , gl : { cost : glCost , accumulated : glAccum , nbv : glNbv } , lines , balanced : maxVariance < TOLERANCE , max _variance : round2 ( maxVariance ) , at : now ( ) } ;
run ( 'UPDATE close_periods SET reconciliation_json = ?, updated_at = ? WHERE id = ?' , [ JSON . stringify ( result ) , now ( ) , period . id ] ) ;
return result ;
}
// ---- Finalize / roll-forward / reopen ---------------------------------------
function rollForward ( period , userId , companyId ) {
const glEntries = require ( './glEntries' ) ;
const { getDefaults } = require ( './glAccounts' ) ;
const d = getDefaults ( companyId ) ;
const win = periodWindow ( period ) ;
const src = source ( period ) ;
clearPostings ( period , ` ${ src } :rollfwd:% ` ) ;
// Clear the year's posted depreciation expense into Retained Earnings.
const expense = one (
` SELECT IFNULL(SUM(debit), 0) AS d FROM gl_entries
WHERE entity_id = ? AND book_code = ? AND account_type = 'Depreciation expense' AND fiscal_year = ? ` ,
[ companyId , period . book _code , period . fiscal _year ]
) ;
const amount = round2 ( expense . d ) ;
if ( amount > 0 ) {
glEntries . insertEntry ( buildLine ( { win , fiscal _year : period . fiscal _year , account : d . retained _earnings _account , account _type : 'Retained earnings' , description : ` Year-end roll-forward — FY ${ period . fiscal _year } ` , reference : ` ROLLFWD- ${ period . id } ` , debit : amount , credit : 0 , external _id : ` ${ src } :rollfwd:re ` } ) , period . book _code , userId , companyId , src ) ;
glEntries . insertEntry ( buildLine ( { win , fiscal _year : period . fiscal _year , account : d . expense _account , account _type : 'Depreciation expense' , description : ` Close depreciation expense — FY ${ period . fiscal _year } ` , reference : ` ROLLFWD- ${ period . id } ` , debit : 0 , credit : amount , external _id : ` ${ src } :rollfwd:exp ` } ) , period . book _code , userId , companyId , src ) ;
}
audit ( userId , 'roll_forward' , 'close_period' , period . id , null , { period : win . label , amount } ) ;
return { amount } ;
}
function finalize ( period , userId , companyId , options = { } ) {
if ( period . locked ) throw Object . assign ( new Error ( 'The period is already closed' ) , { status : 409 } ) ;
// Gate: every required step must be marked done.
const missing = requiredSteps ( period . period _type ) . filter ( ( key ) => ! period . steps ? . [ key ] ? . done ) ;
if ( missing . length ) {
throw Object . assign ( new Error ( ` Complete these steps before finalizing: ${ missing . join ( ', ' ) } ` ) , { status : 400 , missing } ) ;
}
// Gate: reconciliation must have been run, and either balanced or explicitly acknowledged.
const recon = period . reconciliation ;
if ( ! recon ) throw Object . assign ( new Error ( 'Run the reconciliation before finalizing' ) , { status : 400 } ) ;
if ( ! recon . balanced && ! period . steps ? . reconcile ? . acknowledged && ! options . acknowledge _variance ) {
throw Object . assign ( new Error ( 'The subledger does not reconcile to the GL. Clear the variance or acknowledge it to finalize.' ) , { status : 400 , variance : recon . max _variance } ) ;
}
if ( period . period _type === 'year' ) rollForward ( period , userId , companyId ) ;
const { bookLedger } = require ( './books' ) ;
const ledger = bookLedger ( period . book _code , period . fiscal _year , companyId ) ;
const totals = ledger ? { cost : round2 ( ledger . summary . cost ) , accumulated : round2 ( ledger . summary . accumulated ) , nbv : round2 ( ledger . summary . nbv ) , assets : ledger . summary . assets } : null ;
run (
'UPDATE close_periods SET status = ?, totals_json = ?, closed_by = ?, closed_at = ?, updated_at = ? WHERE id = ?' ,
[ 'closed' , JSON . stringify ( totals ) , userId , now ( ) , now ( ) , period . id ]
) ;
audit ( userId , 'finalize' , 'close_period' , period . id , null , { period : periodWindow ( period ) . label , totals , acknowledged _variance : ! recon . balanced } ) ;
return getPeriod ( period . id , companyId ) ;
}
function reopen ( periodId , userId , companyId ) {
const period = getPeriod ( periodId , companyId ) ;
if ( ! period ) return null ;
if ( period . status !== 'closed' ) throw Object . assign ( new Error ( 'Only a closed period can be reopened' ) , { status : 400 } ) ;
// Reverse the close's postings so a re-finalize re-posts cleanly.
const removed = run ( 'DELETE FROM gl_entries WHERE entity_id = ? AND source = ?' , [ companyId , source ( period ) ] ) ;
run ( 'UPDATE close_periods SET status = ?, reopened_by = ?, reopened_at = ?, closed_by = NULL, closed_at = NULL, updated_at = ? WHERE id = ?' , [ 'in_progress' , userId , now ( ) , now ( ) , period . id ] ) ;
audit ( userId , 'reopen' , 'close_period' , period . id , { status : 'closed' } , { status : 'in_progress' , reversed _entries : removed . changes } ) ;
return getPeriod ( periodId , companyId ) ;
}
// ---- Lock guard (exported; called by financial-write services) --------------
function primaryBookCode ( companyId ) {
const row = one ( 'SELECT code FROM books WHERE entity_id = ? AND is_primary = 1 LIMIT 1' , [ companyId ] ) ;
return row ? row . code : null ;
}
// Throws if `date` falls inside a CLOSED period for (companyId, bookCode). No-op when nothing is closed,
// so existing flows are unaffected until a period is actually locked.
function assertPeriodOpen ( companyId , bookCode , date ) {
if ( ! companyId || ! bookCode || ! date ) return ;
const day = String ( date ) . slice ( 0 , 10 ) ;
const closed = all (
"SELECT * FROM close_periods WHERE entity_id = ? AND book_code = ? AND status = 'closed'" ,
[ companyId , bookCode ]
) ;
for ( const row of closed ) {
const win = periodWindow ( row ) ;
if ( day >= win . start && day <= win . end ) {
throw Object . assign ( new Error ( ` The accounting period ( ${ win . label } , ${ bookCode } ) is closed. Reopen it to make changes. ` ) , { status : 409 } ) ;
}
}
}
// Convenience guard for asset-level (subledger) writes: gated by the PRIMARY book's locked periods.
function assertSubledgerOpen ( companyId , date ) {
const primary = primaryBookCode ( companyId ) ;
if ( primary ) assertPeriodOpen ( companyId , primary , date ) ;
}
module . exports = {
assertPeriodOpen ,
assertSubledgerOpen ,
capitalizeWip ,
finalize ,
getOrStart ,
getPeriod ,
listPeriods ,
periodWindow ,
postDepreciation ,
postDisposalEntries ,
primaryBookCode ,
reconcile ,
recordStep ,
reopen ,
stepData ,
stepDefs
} ;