Skip to main content
How the accounting service computes financial statements: the report-service architecture, ledger aggregation, period scoping, frozen snapshots, presets and scheduled CSV delivery. Audience: engineers adding a new report, wiring another product into reporting, or tracing a reconciliation mismatch.
The repo-level hitaji-erp-api/CLAUDE.md marks this as the single home for all financial-statement logic. See also docs/accounting-reports.md for the reconciliation contracts and period-lock exception from the product/spec angle; this document is the code-level companion.

Component map

The service depends on three repositories — AccountingLedgerRepository (the heavy SQL aggregations), AccountsRepository (chart of accounts), and ContactsRepository (party names) — plus two TypeORM repos for AccountingReportSnapshot and AccountingPeriod.

Shared building blocks

Ledger as the single source of truth

Every statement reads from accounting_ledger_entries (joined to journal_entries). There is no separate cache table for the statements themselves; they are derived live from posted ledger lines. The ledger repository exposes purpose-built aggregations (getGeneralLedger, getTrialBalanceAggregates, getAccountBalances, getGeneralLedgerSummary, getCostCenterScopedAccountTotals, getGeneralLedgerGroupSubtotals), and the service composes them.

Normal-side signing — toSignedBalance

Balances are converted to a signed number relative to a normal side. Two distinct notions of normal side appear and must not be confused:
  • Account normalSide — the individual account’s own natural side. Used for running balances in the GL/Account Ledger.
  • Section normal side — derived from rootType: ASSET & EXPENSE are DEBIT; LIABILITY, EQUITY & INCOME are CREDIT. The Balance Sheet and Cash Flow use the section side so contra-accounts behave correctly. Example: Accumulated Depreciation has rootType=ASSET but normalSide=CREDIT; signing it on the ASSET (debit) section makes it correctly reduce total assets rather than inflate them.

Hierarchy flattening — flattenHierarchicalRows<T>

The chart of accounts is a tree (parentAccountId). This generic helper:
  1. Builds a parent-to-children map and identifies roots.
  2. Sorts siblings by sortOrder, then code, then name.
  3. Depth-first visits each account, building a row from (account, depth, childRows) so a group’s value is its own activity plus the rolled-up sum of its children.
  4. Applies an includeRow predicate to drop empties while keeping ancestor groups whose descendants survive.
Returned rows are a flat array in tree order with a depth field — the frontend re-indents from depth. Totals are always summed from leaves only (!isGroup) so group rollups aren’t double-counted.

Statement-by-statement

General Ledger — getGeneralLedger

The line-level transaction listing. Notable behaviour:
  • Voucher-type mapping: human labels (Invoice, Bill, Payment, etc.) are mapped to JournalSourceType enum values via VOUCHER_TYPE_TO_SOURCE_TYPE; unknown keys pass through unchanged.
  • Filters: account(s), contact/party, party type, source type/id/number, voucher types, book code, fund/matter reference and arbitrary dimensions. Party-type and party-id filtering is applied in-memory after the contact batch load; the rest push down to SQL.
  • Opening balance: includeOpening defaults true; opening rows are synthetic ledger lines the repo emits.
  • Running balance: only computed for single-account queries (an accountId, or exactly one entry in accountIds). It is seeded from the account’s signed opening balance and accumulated per row using the account’s own normalSide. Multi-account pulls return runningBalance: null.
  • Pagination: keyset/cursor based when limit is supplied (getGeneralLedgerPage + parseGeneralLedgerCursor); the cursor encodes entryDate/dateCreated/id plus the carried runningBalance so paging a single account keeps the balance continuous. Without limit it returns all rows and nextCursor: null.
  • Summary: summary.periodActivity (debit/credit), rowCount, and for single-account queries openingBalance + closingBalance as { amount, side } via toBalanceSide.
  • Grouping: groupBy of account / party / voucher builds a groups[] block from repo subtotals (buildGLGroupsV2); date and none leave it undefined (rows are already chronological).
  • Comparative: prior-period (shift back by window length) or prior-year (semantic minus-one-year) computes a comparative summary block.
getAccountLedger is a thin wrapper: it calls getGeneralLedger for one account and reshapes the rows into { date, entryNumber, description, debit, credit, balance } with signed opening/closing balances.

Trial Balance — getTrialBalance

Returns a flat node array (v2 shape, per master spec section 10.3) with opening / period / closing debit & credit per account.
  • Snapshot-aware (see Frozen snapshots).
  • In-process cache: results are memoized for TRIAL_BALANCE_CACHE_TTL_MS (60 s) keyed by buildTrialBalanceCacheKey (tenant, workspace, dates, root, hideZero, includeUnposted, comparative, bookCode). The cache is a plain Map on the service instance.
  • Per-window computation (computeTrialBalanceWindow): loads active, non-merged accounts (optionally narrowed by accountRoot), pulls getTrialBalanceAggregates, then flattens. Opening/closing are derived from net (debit minus credit) and split back into debit/credit columns by sign. Leaf accounts with a balanceMustBe constraint get an isOutOfBalance flag when the closing balance lands on the wrong side.
  • Comparative: prior-period / prior-year computes a second window and attaches a comparative block per node.
  • hideZero: drops leaves whose every opening/period/closing debit+credit is zero, promoting any ancestor group that still has surviving descendants.
  • Totals: summed from leaves’ closing debit/credit; difference = debit minus credit should be ~0 for a balanced book.

Income Statement (P&L) — getIncomeStatement

  • Snapshot-aware; on a snapshot hit, periodStart/periodEnd are rehydrated from the stored JSON strings back into Date.
  • Pulls all GL rows for the period (includeReversals: true), totals debit/credit per account, then flattens INCOME and EXPENSE roots separately.
  • Revenue amount = credit minus debit; expense amount = debit minus credit (each in its natural direction). Rows below 0.01 absolute are dropped.
  • netIncome = totalRevenue minus totalExpenses, both summed from leaves.

Cost-Center P&L — getCostCenterProfitAndLoss

Same revenue/expense/net shape as the income statement, but per-account totals come from getCostCenterScopedAccountTotals — GL lines tagged with the cost center’s dimensions.cost_center_id (and, when includeSubtree, its nested-set descendants). Payroll accruals, expense bills and petty cash all tag cost_center_id, making this GL-truth per cost center.
Not snapshot-aware by design: cost-center P&L is an analytical drill-down, not a statutory statement that gets frozen at close.

Balance Sheet — getBalanceSheet

  • Snapshot-aware; rehydrates asOfDate.
  • Loads getAccountBalances as of the date and signs each active account on its section normal side (see signing note above).
  • Retained earnings is computed on the fly by summing signed INCOME (plus) and EXPENSE (minus) balances — i.e. current-period net income folded into equity. It is added to total equity and returned separately as retainedEarnings.
  • Assets / Liabilities / Equity are each flattened trees; section totals come from leaves.
  • isBalanced = abs(totalAssets minus (totalLiabilities + totalEquity)) < 0.01.

Cash Flow (indirect method) — cashFlow / computeCashFlowWindow

  • Snapshot-aware.
  • Operating starts from P&L net income, then adds back depreciation (movement on accumulated_depreciation contra-asset) and working-capital deltas. Working-capital subtypes are an explicit set: ar, ap, tax_payable, inventory, customer_deposits, staff_advances, stock_received_not_billed, stock_adjustment.
  • For every non-cash, non-P&L account it computes delta = closingSigned minus openingSigned (opening taken one day before periodStart). Cash impact: asset increase to outflow (minus delta); liability/equity increase to inflow (plus delta).
  • Investing = movement on other (non-WC, non-A/D) asset accounts.
  • Financing = movement on non-WC liabilities + equity. Retained-earnings movement has the period net income stripped out (delta minus netIncome) to avoid double-counting what’s already in Operating.
  • Cash itself (subtype cash/bank) accumulates into openingCash / closingCash.
  • Reconciliation: reconciliationDelta = netChange minus (closingCash minus openingCash); reconciles when within 0.01.
  • comparativePeriod computes a second window and nests it under comparative.

Aged Receivables / Payables — agedReceivables / agedPayables to computeAged

Open-item FIFO ageing per counterparty.
  • Snapshot-aware (statement names aged-receivables / aged-payables).
  • Target accounts = the one supplied accountId, else all active non-group accounts whose subtype is ar (receivables) or ap (payables).
  • Pulls all ledger lines on those accounts from the beginning of time to asOf (includeReversals: false, includeOpening: true), groups by contactId (lines with none roll up under Unassigned).
  • Per party, lines are sorted chronologically and FIFO-applied: for AR a charge is debit minus credit (mirror for AP); positive charges open lines, negative amounts pay down the oldest open line first. Excess credit becomes a negative open line (overpayment) dated to the credit, landing in Current.
  • Each surviving open line is bucketed by age in days via findBucketIndex.
  • Buckets (normalizeAgedBuckets): default boundaries 30/60/90 produce Current, 1-30, 31-60, 61-90, >90. Callers may pass any cumulative boundary array (SACCO uses 7/30/60/90); boundaries are clamped to >=1, sorted and deduped.
  • Reconciliation: ledgerBalance is the section-signed TB balance of the same AR/AP accounts at asOf; reconciliationDelta = sum(rows) minus ledgerBalance; reconciles when within 0.01.

Period summaries (PeriodSummaryService)

A materialized per-month, per-account debit/credit rollup in account_period_summaries, keyed (tenant_id, workspace_id, period_key, account_id) where period_key is YYYY-MM.
  • computeForMonth aggregates accounting_ledger_entries for the calendar month and upserts (ON CONFLICT ... DO UPDATE) the debit/credit totals plus computed_at.
  • getSummariesForRange reads a YYYY-MM range; invalidateMonth deletes a month’s rows.
  • Event-driven invalidation: @OnEvent(JOURNAL_POSTED_EVENT, { async: true }) to onJournalPosted looks up the entry’s entryDate, derives its YYYY-MM (normalising the PG date string), and invalidates that month so the next read recomputes.
Note: PG date columns hydrate as 'YYYY-MM-DD' strings, not Date objects — the listener normalises before slicing the month prefix. This is a recurring gotcha across the statement code.

Frozen snapshots (as-of close)

Auditor-grade frozen statements live in accounting_report_snapshots, keyed by (tenant_id, workspace_id, report_name, asOf).

Freeze — freezeAsOf

Triggered by POST api/accounting/reports/freeze-as-of.
  1. Default statement set when none requested: trial-balance, balance-sheet, profit-and-loss, cash-flow, aged-receivables, aged-payables.
  2. periodStart defaults to the first of asOf’s month.
  3. For each statement: hard-delete any existing snapshot for the tuple first (so the inner call recomputes live rather than resolving the prior snapshot), then runReportForSnapshot computes it and the payload is saved (insert or update) with frozenById / frozenAt / optional periodCode / notes.
  4. Re-running freezeAsOf overwrites the previous snapshot.

Resolve — resolveSnapshot

Each snapshot-aware statement calls this before computing. It returns the stored payload only when both:
  • a snapshot row exists for (tenant, workspace, report, asOf), and
  • the accounting period containing asOf is CLOSED or LOCKED.
If the period is open / unconfigured, or no snapshot exists, it returns null and the statement recomputes live. This is the frozen-close guarantee: once a period is closed, the April TB stays the April TB even if a corrective journal lands in May — but an open period always reflects live ledger truth.

Presets & scheduled delivery (ReportPresetsService)

Backs api/accounting/reports/presets/*. Two entities: AccountingReportPreset (saved filter set) and AccountingReportSchedule (cron delivery on top of a preset).

Presets

  • A preset stores reportName + filtersJson + name/description, scoped to (tenant, workspace) and owned by a user, with an isShared flag.
  • Authorization split (security-hardened):
    • findPresetVisibleToUser — owner OR shared. READS only (load filters, list schedules).
    • findPresetOwnedByUser — owner only. All MUTATIONS (update/delete preset; create/update/delete/run-now schedule).
    • Both also narrow by workspaceId (assertPresetWorkspace) so a preset id from another workspace under the same tenant can’t be read or mutated cross-workspace (returns 404, not 403, to avoid leaking existence).
  • Deleting a preset cascades: its schedules are unscheduled in pg-boss and soft-deleted first.

Schedules

  • A schedule pins a cronExpr, format, recipientsJson, timezone (IANA, defaults UTC) and status (active/paused/failed) onto a preset.
  • Server constraint: only format: 'csv' is accepted — PDF/XLSX rendering is sync-only on the frontend today; non-csv creates throw 400.
  • Create/update register the cron in pg-boss via boss.schedule(REPORT_EXPORT_QUEUE, cronExpr, job, { key: scheduleId, tz }); pausing/deleting calls boss.unschedule. The DB row is the source of truth — unschedule failures are swallowed.
  • runScheduleNow dispatches a one-off job immediately via the export processor, bypassing the status check.
  • Audit: schedule create/update/delete/run-now are best-effort recorded through AccountingAuditService with before/after diffs. Audit failures never abort the mutation (the audit service is @Optional).

Async CSV export (ReportExportProcessor)

The shared render pipeline behind both ad-hoc exports (POST api/accounting/reports/queue-export) and scheduled deliveries.
  • Queue: pg-boss queue accounting.report-export (REPORT_EXPORT_QUEUE), running on the application Postgres (no Redis). The worker is skipped when NODE_ENV=test.
  • Job shape (ReportExportJob): workspaceId, tenantId, userId, reportName, params, format: 'csv', recipients, optional presetId/scheduleId/notes.
  • Enqueue dedup: enqueue sets a singletonKey = report-export:<userId>:<reportName>:<FNV-1a hash of params> with a 60 s singletonSeconds window, so a double-clicked Export button (or two scheduled runs colliding on the same minute) coalesce to one job. Recipients are excluded from the hash; pg-boss returns null on a suppressed duplicate and the API surfaces a duplicate:<key> job id. Jobs retry up to 3x with a 30 s delay.
  • handleJob: render the statement, map to CSV rows, upload to object storage, email a signed download link.
    • Dispatch (runReport) covers the six accounting statements and a set of HR/payroll exports (hr-salary-register, hr-leave-balance, hr-leave-ledger, hr-advance-summary, hr-bank-remittance, hr-income-tax-computation, hr-employee-exits, hr-employee-birthday, hr-provident-fund-deductions) delegated to HrReportsService (optional dependency; throws if HrAnalyticsModule isn’t wired).
    • CSV row mapping: hr-* exports use hrReportToCsvRows; everything else uses reportToCsvRows.
    • File name: <reportName>-<UTC timestamp>.csv; uploaded via FileStorageService.uploadBuffer, then a signed URL is generated.
    • With zero recipients the file is rendered and stored but no email is sent. Otherwise a templated email (accounting-report-export) is sent with the download link.

CSV generation (report-csv.ts)

  • toCsv(rows) is a dependency-free RFC-4180 writer: comma separator, CRLF line endings, double-quote wrapping only when a cell contains a quote, comma, CR or LF. Numbers stringify via toString() (no locale formatting); non-finite numbers and null/undefined become empty; Date to ISO string.
  • reportToCsvRows(reportName, payload) maps each statement payload to a flat matrix mirroring the frontend’s report-export.ts (so a scheduled CSV matches a one-off download). Mapped statements: trial-balance, balance-sheet, profit-and-loss, cash-flow, aged-receivables/aged-payables. Unknown names fall back to a two-row name/payload JSON dump.

Notes & caveats

  • The GL row’s partyType is resolved via the loaded contact map, but the code carries a TODO about batch-loading party type for the section-9.3 shape — large multi-party pulls may not fully resolve party type yet.
  • PDF/XLSX scheduled formats are rejected server-side today; the ScheduleDto.format type still admits 'xlsx' | 'pdf' for forward compatibility, so don’t promise non-CSV scheduled delivery without re-checking.
  • HR export rendering depends on HrAnalyticsModule being imported by AccountingModule (the processor’s HrReportsService is @Optional).