Back to ER Diagram
Finance Period Close

Finance Period Close Logic

Period-end close workflow with validation checks, accrual generation, and GL posting

PostgreSQL
14 Tables
Schema: finance
Period Lock Mechanism

Overview

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.

6
Close Phases
14
Finance Tables
Module-level
Period Locks
Automated
Accrual Generation

Status States

StatusDescriptionAllowed ActionsNext States
OPENNormal transaction processing; all modules can post entriesPost transactions, Create JEs, Run reportsPRE-CLOSE CHECKS
PRE-CLOSE CHECKSValidate all sub-modules have completed their period tasksRun checklists, Lock individual modulesPROVISIONALS
PROVISIONALSAuto-generate accruals for uninvoiced GRNs, unbilled DMBs, and pending expensesGenerate accruals, Run depreciationRECONCILIATION
RECONCILIATIONInter-company netting and sub-ledger to GL balancingIC reconciliation, TB verificationSOFT CLOSE
SOFT CLOSEWarnings on new transactions; existing pending items can still be processedComplete pending items, Warn on newHARD CLOSE
HARD CLOSEPeriod fully locked; no further entries allowed; financial statements generatedView, Audit, Generate reportsREOPENED (by Finance Controller only)

Period Lifecycle


OPEN

PRE-CLOSE CHECKS

PROVISIONALS

RECONCILIATION

SOFT CLOSE

HARD CLOSE

Key Principle

  • OPEN — Normal transaction processing; all modules can post entries
  • PRE-CLOSE CHECKS — Validate all sub-modules have completed their period tasks
  • PROVISIONALS — Auto-generate accruals for uninvoiced GRNs, unbilled DMBs, and pending expenses
  • RECONCILIATION — Inter-company netting and sub-ledger to GL balancing
  • SOFT CLOSE — Warnings on new transactions; existing pending items can still be processed
  • HARD CLOSE — Period fully locked; no further entries allowed; financial statements generated

Process Flow


PERIOD OPEN
All modules can post transactions freely

Procurement
Lock GRNs, pending: 0

Inventory
Stock recon, pending: 0

Payroll
Salary post, pending: 0

Subcontractor
DMB complete, pending: 0

AUTO-GENERATE ACCRUALS
Uninvoiced GRNs, Unbilled DMBs, Pending expenses, Prepaid amortisation

DEPRECIATION RUN
Fixed assets → Monthly depreciation JE per asset class

INTER-COMPANY RECONCILIATION
IC receivable vs IC payable per entity pair → Net to zero

TRIAL BALANCE CHECK
Total Debits == Total Credits | Sub-ledger == GL control a/c

SOFT CLOSE (warnings only)
New transactions → warning | Pending items can still be completed

HARD CLOSE (period locked)
All modules locked | fiscal_period.is_open = false | Financial statements generated

Per-Module Lock Sequence


Procurement
Lock #1

Inventory
Lock #2

Payroll
Lock #3

Subcontract
Lock #4

Finance
Lock #5

Database Tables Involved

finance.fiscal_period

  • period_id — PK, fiscal period master per entity
  • entity_id — FK → organization.company_entity
  • period_name — Period identifier (e.g., FY2025-M04)
  • start_date, end_date — Period date range
  • is_open — Boolean flag for open/closed state
  • closed_by — FK → auth.user who performed the close

finance.period_lock

  • lock_id — PK, module-level lock tracking per period
  • period_id — FK → finance.fiscal_period
  • module — Module name (procurement, inventory, payroll, etc.)
  • is_locked — Boolean flag for lock state
  • locked_by — FK → auth.user who locked the module

finance.provisional_entry

  • id — PK, month-end provisional (accrual) journal entries
  • entity_id — FK → organization.company_entity
  • entry_type — Type of provisional entry (grn_accrual, dmb_accrual, etc.)
  • amount — Accrual amount in base currency
  • is_reversed — Whether the accrual has been reversed in the next period

finance.journal_entry

  • je_id — PK, GL journal entries for all period-close postings
  • period_id — FK → finance.fiscal_period
  • je_type — Type (accrual, depreciation, closing, reversal)
  • source_module — Originating module (fixed_assets, procurement, etc.)
  • status — Entry status (draft, posted, reversed)

finance.intercompany_txn

  • id — PK, inter-company transactions requiring reconciliation
  • from_entity — FK → organization.company_entity (sender)
  • to_entity — FK → organization.company_entity (receiver)
  • amount — Transaction amount
  • status — Reconciliation status (pending, reconciled)

finance.financial_report

  • report_id — PK, generated financial statements
  • report_type — Type (trial_balance, profit_and_loss, balance_sheet, cash_flow)
  • parameters — JSONB with generation parameters
  • last_generated — Timestamp of last report generation

Step-by-Step Logic

1

Pre-Close Checklist per Module

Each 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.

2

Auto-Accrual Generation

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.

3

Depreciation Calculation

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.

4

Inter-Company Netting

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.

5

Trial Balance Verification

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.

6

Soft Close

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.

7

Hard Close

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.

8

Financial Statement Generation

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.

Code Implementation

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 };
  }
}

Validation Rules

RuleConditionAction
Sub-ledger GL BalanceAny sub-ledger (AP, AR, Inventory, FA) does not balance to its GL control accountBlock period close, flag imbalanced sub-ledger
No Pending ApprovalsUnapproved transactions exist in the closing periodBlock hard close, list pending items per module
Depreciation MandatoryDepreciation run has not been executed for the periodBlock close, require depreciation run first
IC Net ZeroInter-company transactions do not net to zero per entity pairBlock close, show IC imbalance report
Minimum JE per ModuleA module has zero journal entries for the periodWarning — confirm module had no activity

Automated Actions & Triggers

EventSourceAuto Action
Period Soft-Closedfinance.fiscal_periodBlock new transactions with warning; allow pending item completion
Period Hard-Closedfinance.fiscal_periodReject all backdated entries; lock all module period_lock records
Year-End Closefinance.fiscal_period (M12)Generate closing entries — transfer P&L balances to Retained Earnings
Period Reopenedfinance.fiscal_periodCreate audit trail entry with reason; unlock all module locks
All Modules Lockedfinance.period_lockAuto-trigger hard close when every module's lock is set to true

Integration Points

Upstream (Data Sources)

  • Procurement — GRN postings and PO accruals must be complete
  • Inventory — Stock reconciliation and valuation entries posted
  • Payroll — Salary, PF, ESI journal entries finalised
  • Subcontractor — DMB certifications and RA bill postings done
  • Fixed Assets — Depreciation and asset transfer entries posted
  • All Transactional Modules — Must complete their entries before close

Downstream (Consumers)

  • Financial Statements — Balance Sheet, P&L, Cash Flow generated on close
  • Audit Reports — Period-end audit trail and variance analysis
  • Tax Returns — GST returns, TDS filings, advance tax computations
  • Management Dashboards — KPI refresh with closed-period actuals
  • Statutory Filings — RoC, MCA, and regulatory compliance reports

Best Practices

Implementation Guidelines

  • Publish a close calendar with hard deadlines per module (e.g., Procurement by Day 3, Inventory by Day 4, Payroll by Day 5)
  • Assign module ownership for period tasks — each module head signs off before their lock is set
  • Maintain reconciliation checklists per module: bank reconciliation, vendor reconciliation, customer reconciliation, IC reconciliation
  • Year-end adjustments (provisions, write-offs, reclassifications) should be pre-approved and queued before close begins
  • Accrual generation must be idempotent — re-running produces the same result without duplicate entries
  • Keep a close status dashboard showing real-time progress of each module's pre-close tasks

Common Pitfalls

  • Skipping depreciation before close — leads to inaccurate P&L and asset values
  • Allowing backdated entries after soft close without proper controls — breaks period integrity
  • Not reversing prior-period accruals in the new period — double-counting expenses
  • Inter-company imbalances left unresolved — consolidation reports will not balance
  • Reopening closed periods without audit trail — regulatory and audit risk
  • Missing the close calendar deadline — cascading delays across all downstream reports