Period-end close workflow with validation checks, accrual generation, and GL posting
InfraTraq implements a comprehensive Finance Period Close Workflow that manages month-end and year-end closing processes. The system enforces module-level period locks, auto-generates provisional entries and accruals, performs inter-company reconciliation, and produces financial reports — ensuring that every entity's books are closed accurately and on schedule.
| Status | Description | Allowed Actions | Next States |
|---|---|---|---|
| OPEN | Normal transaction processing; all modules can post entries | Post transactions, Create JEs, Run reports | PRE-CLOSE CHECKS |
| PRE-CLOSE CHECKS | Validate all sub-modules have completed their period tasks | Run checklists, Lock individual modules | PROVISIONALS |
| PROVISIONALS | Auto-generate accruals for uninvoiced GRNs, unbilled DMBs, and pending expenses | Generate accruals, Run depreciation | RECONCILIATION |
| RECONCILIATION | Inter-company netting and sub-ledger to GL balancing | IC reconciliation, TB verification | SOFT CLOSE |
| SOFT CLOSE | Warnings on new transactions; existing pending items can still be processed | Complete pending items, Warn on new | HARD CLOSE |
| HARD CLOSE | Period fully locked; no further entries allowed; financial statements generated | View, Audit, Generate reports | REOPENED (by Finance Controller only) |
period_id — PK, fiscal period master per entityentity_id — FK → organization.company_entityperiod_name — Period identifier (e.g., FY2025-M04)start_date, end_date — Period date rangeis_open — Boolean flag for open/closed stateclosed_by — FK → auth.user who performed the closelock_id — PK, module-level lock tracking per periodperiod_id — FK → finance.fiscal_periodmodule — Module name (procurement, inventory, payroll, etc.)is_locked — Boolean flag for lock statelocked_by — FK → auth.user who locked the moduleid — PK, month-end provisional (accrual) journal entriesentity_id — FK → organization.company_entityentry_type — Type of provisional entry (grn_accrual, dmb_accrual, etc.)amount — Accrual amount in base currencyis_reversed — Whether the accrual has been reversed in the next periodje_id — PK, GL journal entries for all period-close postingsperiod_id — FK → finance.fiscal_periodje_type — Type (accrual, depreciation, closing, reversal)source_module — Originating module (fixed_assets, procurement, etc.)status — Entry status (draft, posted, reversed)id — PK, inter-company transactions requiring reconciliationfrom_entity — FK → organization.company_entity (sender)to_entity — FK → organization.company_entity (receiver)amount — Transaction amountstatus — Reconciliation status (pending, reconciled)report_id — PK, generated financial statementsreport_type — Type (trial_balance, profit_and_loss, balance_sheet, cash_flow)parameters — JSONB with generation parameterslast_generated — Timestamp of last report generationEach module owner runs their pre-close checklist: Procurement verifies all GRNs are posted, Inventory completes stock reconciliation, Payroll finalises salary postings, Subcontractor confirms all DMBs are measured. The system tracks completion via period_lock records per module.
System auto-generates accrual entries for: uninvoiced GRNs (goods received but vendor invoice not yet booked), unbilled DMBs (work measured but subcontractor bill not raised), and other pending expenses. Creates provisional_entry records with corresponding journal_entry postings — Dr Expense, Cr Accrual Liability.
Fixed asset depreciation run calculates monthly depreciation per asset class using the configured method (SLM/WDV). Posts depreciation journal entries: Dr Depreciation Expense, Cr Accumulated Depreciation. Must complete before period close to ensure accurate P&L.
For multi-entity setups, the system reconciles intercompany_txn records between entity pairs. IC receivables at Entity A must match IC payables at Entity B. Any mismatches are flagged for manual resolution. Netting entries are posted to bring IC balances to zero.
System verifies that total debits equal total credits across all GL accounts for the period. Sub-ledger control account balances (AP, AR, Inventory, Fixed Assets) are reconciled against their respective GL control accounts. Any imbalance blocks the close process.
Period enters soft close state: new transactions trigger a warning but are not blocked. Existing pending items (unapproved invoices, draft JEs) can still be processed. This grace window allows stragglers to complete while signalling that the period is closing.
Period is hard closed: fiscal_period.is_open = false, all period_lock records set to is_locked = true. No further entries of any kind are accepted. Backdated entries into this period are rejected system-wide. Only a Finance Controller with explicit authorization can reopen.
Upon hard close, the system auto-generates financial_report records: Trial Balance, Profit & Loss Statement, Balance Sheet, Cash Flow Statement. For year-end close, closing entries transfer P&L balances to Retained Earnings. Reports are stored with parameters and generation timestamps.
class PeriodCloseService { /** Run pre-close checks for all modules in the period */ async runPreCloseChecks(periodId) { const period = await FiscalPeriod.findById(periodId); const modules = ['procurement', 'inventory', 'payroll', 'subcontractor', 'finance']; const results = []; for (const mod of modules) { const pending = await PendingItems.count({ period_id: periodId, module: mod }); const unapproved = await Approvals.count({ period_id: periodId, module: mod, status: 'pending' }); results.push({ module: mod, pending, unapproved, ready: pending === 0 && unapproved === 0 }); } return { periodId, allReady: results.every(r => r.ready), modules: results }; } /** Auto-generate accruals for uninvoiced GRNs and unbilled DMBs */ async generateAccruals(periodId) { const period = await FiscalPeriod.findById(periodId); // Uninvoiced GRNs const grns = await GRN.findAll({ entity_id: period.entity_id, date_between: [period.start_date, period.end_date], invoice_status: 'uninvoiced' }); for (const grn of grns) { await ProvisionalEntry.create({ entity_id: period.entity_id, entry_type: 'grn_accrual', amount: grn.total_amount, is_reversed: false }); } // Unbilled DMBs const dmbs = await DMB.findAll({ entity_id: period.entity_id, date_between: [period.start_date, period.end_date], bill_status: 'unbilled' }); for (const dmb of dmbs) { await ProvisionalEntry.create({ entity_id: period.entity_id, entry_type: 'dmb_accrual', amount: dmb.certified_amount, is_reversed: false }); } // Post accrual journal entries await JournalService.postPeriodAccruals(periodId); return { grn_accruals: grns.length, dmb_accruals: dmbs.length }; } /** Run depreciation for all fixed assets in the period */ async runDepreciation(periodId) { const period = await FiscalPeriod.findById(periodId); const assets = await FixedAsset.findAll({ entity_id: period.entity_id, status: 'active' }); for (const asset of assets) { const depnAmount = asset.method === 'SLM' ? asset.depreciable_value / asset.useful_life_months : asset.wdv * asset.rate / 12; await JournalEntry.create({ period_id: periodId, je_type: 'depreciation', source_module: 'fixed_assets', status: 'posted' }); await asset.update({ accumulated_depn: literal('accumulated_depn + ' + depnAmount) }); } return { assets_processed: assets.length }; } /** Reconcile inter-company transactions for the period */ async reconcileIntercompany(periodId) { const period = await FiscalPeriod.findById(periodId); const txns = await IntercompanyTxn.findAll({ period_id: periodId, status: 'pending' }); const pairs = groupByEntityPair(txns); for (const [pairKey, pairTxns] of Object.entries(pairs)) { const netAmount = pairTxns.reduce((sum, t) => sum + t.amount, 0); if (Math.abs(netAmount) > 0.01) throw new ICImbalanceError(pairKey, netAmount); await IntercompanyTxn.updateAll(pairTxns.map(t => t.id), { status: 'reconciled' }); } return { pairs_reconciled: Object.keys(pairs).length }; } /** Soft close — warn on new transactions but allow pending completions */ async softClose(periodId) { const checks = await this.runPreCloseChecks(periodId); if (!checks.allReady) { const pending = checks.modules.filter(m => !m.ready); return { status: 'soft_closed_with_warnings', warnings: pending }; } await FiscalPeriod.update(periodId, { status: 'soft_closed' }); return { status: 'soft_closed', warnings: [] }; } /** Hard close — lock period completely, no further entries allowed */ async hardClose(periodId) { const modules = ['procurement', 'inventory', 'payroll', 'subcontractor', 'finance']; for (const mod of modules) { await PeriodLock.upsert({ period_id: periodId, module: mod, is_locked: true, locked_by: currentUser() }); } await FiscalPeriod.update(periodId, { is_open: false, closed_by: currentUser() }); await AuditTrail.log('period_hard_close', { period_id: periodId }); await this.generateFinancials(periodId); return { status: 'hard_closed', period_id: periodId }; } /** Generate financial statements after hard close */ async generateFinancials(periodId) { const reportTypes = ['trial_balance', 'profit_and_loss', 'balance_sheet', 'cash_flow']; for (const type of reportTypes) { await FinancialReport.create({ report_type: type, parameters: { period_id: periodId }, last_generated: new Date() }); } // Year-end: generate closing entries (P&L → Retained Earnings) const period = await FiscalPeriod.findById(periodId); if (period.period_name.endsWith('M12')) { await JournalService.postClosingEntries(periodId); } return { reports_generated: reportTypes.length }; } /** Reopen a previously closed period (requires authorization) */ async reopenPeriod(periodId, reason) { if (!currentUser().hasRole('FINANCE_CONTROLLER')) { throw new UnauthorizedError('Only Finance Controller can reopen periods'); } await FiscalPeriod.update(periodId, { is_open: true, closed_by: null }); await PeriodLock.updateAll({ period_id: periodId }, { is_locked: false }); await AuditTrail.log('period_reopened', { period_id: periodId, reason, reopened_by: currentUser() }); return { status: 'reopened', period_id: periodId, reason }; } }
| Rule | Condition | Action |
|---|---|---|
| Sub-ledger GL Balance | Any sub-ledger (AP, AR, Inventory, FA) does not balance to its GL control account | Block period close, flag imbalanced sub-ledger |
| No Pending Approvals | Unapproved transactions exist in the closing period | Block hard close, list pending items per module |
| Depreciation Mandatory | Depreciation run has not been executed for the period | Block close, require depreciation run first |
| IC Net Zero | Inter-company transactions do not net to zero per entity pair | Block close, show IC imbalance report |
| Minimum JE per Module | A module has zero journal entries for the period | Warning — confirm module had no activity |
| Event | Source | Auto Action |
|---|---|---|
| Period Soft-Closed | finance.fiscal_period | Block new transactions with warning; allow pending item completion |
| Period Hard-Closed | finance.fiscal_period | Reject all backdated entries; lock all module period_lock records |
| Year-End Close | finance.fiscal_period (M12) | Generate closing entries — transfer P&L balances to Retained Earnings |
| Period Reopened | finance.fiscal_period | Create audit trail entry with reason; unlock all module locks |
| All Modules Locked | finance.period_lock | Auto-trigger hard close when every module's lock is set to true |