> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hitaji360.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Financial Reports — Internals

> How the accounting service computes financial statements: the report-service architecture, ledger aggregation, period scoping, frozen snapshots, presets…

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

| File                                                         | Role                                                                                                                             |
| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| `src/accounting/services/financial-reports.service.ts`       | All statement computation (GL, TB, BS, P\&L, Cash Flow, Aged AR/AP, Cost-Center P\&L, Account Ledger) + snapshot freeze/resolve. |
| `src/accounting/services/period-summary.service.ts`          | Per-month per-account debit/credit rollup cache (`account_period_summaries`), event-invalidated.                                 |
| `src/accounting/services/report-presets.service.ts`          | Saved filter presets + scheduled CSV deliveries (pg-boss).                                                                       |
| `src/accounting/services/report-csv.ts`                      | Statement payload to flat CSV row matrix + RFC-4180 CSV writer.                                                                  |
| `src/accounting/jobs/report-export.queue.ts`                 | Queue name + `ReportExportJob` shape.                                                                                            |
| `src/accounting/jobs/report-export.processor.ts`             | pg-boss worker: render to CSV to object storage to email.                                                                        |
| `src/accounting/controllers/financial-reports.controller.ts` | `api/accounting/reports/*` HTTP surface.                                                                                         |
| `src/accounting/controllers/report-presets.controller.ts`    | `api/accounting/reports/presets/*` HTTP surface.                                                                                 |

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`

```ts theme={null}
private toSignedBalance(debit, credit, normalSide): number {
  return normalSide === DEBIT ? debit - credit : credit - debit;
}
```

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](#frozen-snapshots-as-of-close)).
* **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`).
