The repo-levelhitaji-erp-api/CLAUDE.mdmarks this as the single home for all financial-statement logic. See alsodocs/accounting-reports.mdfor 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 fromaccounting_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
- 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 hasrootType=ASSETbutnormalSide=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:
- Builds a parent-to-children map and identifies roots.
- Sorts siblings by
sortOrder, thencode, thenname. - 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. - Applies an
includeRowpredicate to drop empties while keeping ancestor groups whose descendants survive.
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 toJournalSourceTypeenum values viaVOUCHER_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:
includeOpeningdefaults true; opening rows are synthetic ledger lines the repo emits. - Running balance: only computed for single-account queries (an
accountId, or exactly one entry inaccountIds). It is seeded from the account’s signed opening balance and accumulated per row using the account’s ownnormalSide. Multi-account pulls returnrunningBalance: null. - Pagination: keyset/cursor based when
limitis supplied (getGeneralLedgerPage+parseGeneralLedgerCursor); the cursor encodesentryDate/dateCreated/idplus the carriedrunningBalanceso paging a single account keeps the balance continuous. Withoutlimitit returns all rows andnextCursor: null. - Summary:
summary.periodActivity(debit/credit),rowCount, and for single-account queriesopeningBalance+closingBalanceas{ amount, side }viatoBalanceSide. - Grouping:
groupByofaccount/party/voucherbuilds agroups[]block from repo subtotals (buildGLGroupsV2);dateandnoneleave it undefined (rows are already chronological). - Comparative:
prior-period(shift back by window length) orprior-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 bybuildTrialBalanceCacheKey(tenant, workspace, dates, root, hideZero, includeUnposted, comparative, bookCode). The cache is a plainMapon the service instance. - Per-window computation (
computeTrialBalanceWindow): loads active, non-merged accounts (optionally narrowed byaccountRoot), pullsgetTrialBalanceAggregates, then flattens. Opening/closing are derived from net (debit minus credit) and split back into debit/credit columns by sign. Leaf accounts with abalanceMustBeconstraint get anisOutOfBalanceflag when the closing balance lands on the wrong side. - Comparative:
prior-period/prior-yearcomputes a second window and attaches acomparativeblock 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 creditshould be ~0 for a balanced book.
Income Statement (P&L) — getIncomeStatement
- Snapshot-aware; on a snapshot hit,
periodStart/periodEndare rehydrated from the stored JSON strings back intoDate. - 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.01absolute 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
getAccountBalancesas 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_depreciationcontra-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 beforeperiodStart). 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 (
subtypecash/bank) accumulates intoopeningCash/closingCash. - Reconciliation:
reconciliationDelta = netChange minus (closingCash minus openingCash);reconcileswhen within0.01. comparativePeriodcomputes a second window and nests it undercomparative.
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 whosesubtypeisar(receivables) orap(payables). - Pulls all ledger lines on those accounts from the beginning of time to
asOf(includeReversals: false,includeOpening: true), groups bycontactId(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 boundaries30/60/90produceCurrent,1-30,31-60,61-90,>90. Callers may pass any cumulative boundary array (SACCO uses7/30/60/90); boundaries are clamped to >=1, sorted and deduped. - Reconciliation:
ledgerBalanceis the section-signed TB balance of the same AR/AP accounts atasOf;reconciliationDelta = sum(rows) minus ledgerBalance;reconcileswhen within0.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.
computeForMonthaggregatesaccounting_ledger_entriesfor the calendar month and upserts (ON CONFLICT ... DO UPDATE) the debit/credit totals pluscomputed_at.getSummariesForRangereads aYYYY-MMrange;invalidateMonthdeletes a month’s rows.- Event-driven invalidation:
@OnEvent(JOURNAL_POSTED_EVENT, { async: true })toonJournalPostedlooks up the entry’sentryDate, derives itsYYYY-MM(normalising the PGdatestring), and invalidates that month so the next read recomputes.
Note: PGdatecolumns hydrate as'YYYY-MM-DD'strings, notDateobjects — 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 inaccounting_report_snapshots, keyed by
(tenant_id, workspace_id, report_name, asOf).
Freeze — freezeAsOf
Triggered by POST api/accounting/reports/freeze-as-of.
- Default statement set when none requested:
trial-balance,balance-sheet,profit-and-loss,cash-flow,aged-receivables,aged-payables. periodStartdefaults to the first ofasOf’s month.- 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
runReportForSnapshotcomputes it and the payload is saved (insert or update) withfrozenById/frozenAt/ optionalperiodCode/notes. - Re-running
freezeAsOfoverwrites 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
asOfisCLOSEDorLOCKED.
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 anisSharedflag. - 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, defaultsUTC) andstatus(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 throw400. - Create/update register the cron in pg-boss via
boss.schedule(REPORT_EXPORT_QUEUE, cronExpr, job, { key: scheduleId, tz }); pausing/deleting callsboss.unschedule. The DB row is the source of truth — unschedule failures are swallowed. runScheduleNowdispatches 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
AccountingAuditServicewith 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 whenNODE_ENV=test. - Job shape (
ReportExportJob):workspaceId,tenantId,userId,reportName,params,format: 'csv',recipients, optionalpresetId/scheduleId/notes. - Enqueue dedup:
enqueuesets asingletonKey=report-export:<userId>:<reportName>:<FNV-1a hash of params>with a 60 ssingletonSecondswindow, 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 returnsnullon a suppressed duplicate and the API surfaces aduplicate:<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 toHrReportsService(optional dependency; throws ifHrAnalyticsModuleisn’t wired). - CSV row mapping:
hr-*exports usehrReportToCsvRows; everything else usesreportToCsvRows. - File name:
<reportName>-<UTC timestamp>.csv; uploaded viaFileStorageService.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.
- Dispatch (
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 viatoString()(no locale formatting); non-finite numbers andnull/undefinedbecome empty;Dateto ISO string.reportToCsvRows(reportName, payload)maps each statement payload to a flat matrix mirroring the frontend’sreport-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
partyTypeis resolved via the loaded contact map, but the code carries aTODOabout 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.formattype still admits'xlsx' | 'pdf'for forward compatibility, so don’t promise non-CSV scheduled delivery without re-checking. - HR export rendering depends on
HrAnalyticsModulebeing imported byAccountingModule(the processor’sHrReportsServiceis@Optional).