Reporting Framework — financial statements & operational reports over the GL ledger

The whole reporting framework reduces to one idea:

Every financial statement is a read-only aggregation over the AccountTransaction ledger, grouped by the reportSection enum stamped on each account's AccountCategory. There is no separate reporting store, no materialised statement, no posting that "writes a report". A report fetches the categories that belong to a section (findByReportSection), pulls the accounts under those categories, derives each account's balance from the ledger (balanceWithDrAnCrPosted, POSTED only), groups + signs them by account type, and computes the statement totals in memory. Change a category's reportSection and the same ledger reshapes into a different statement — that is the entire customisation model. Operational reports (inventory, stock valuation, store, assets, sales/daily) bypass the GL and aggregate the Stock ledger / Order lines / Asset rows instead.

Source: BE src/modules/finance/report/* (financial statements) · BE src/modules/report/* (inventory/sales/store) · BE src/modules/inventory/stock/stock.resolver.ts (valuation) · BE src/modules/assets/assets.controller.ts (asset reports) · BE src/modules/branch/branch.resolver.ts (branchReport) · Admin src/modules/report/* + src/modules/report-settings/* + src/pages/report/*

Cross-links: finance overview · account · category (report sections) · transaction · permissions · multi-tenancy · audit trail · inventory zerp-be/docs/inventory-stock-flow.md.


1. Purpose & scope

The reporting framework owns the read side of the ledger and stock systems. It is responsible for:

  • Financial statements (read-only, GL-driven): Trial Balance, General Ledger, P&L (Statement of Comprehensive Income), Balance Sheet (Statement of Financial Position), Cash Flow, Aged Receivable/Payable, Tax, GL Account report, AR/AP account (debtor/creditor) reports.
  • Report-section mapping: the reportSection enum on AccountCategory that decides which statement bucket each account rolls into, plus the /report/settings admin page that lets operators re-map it.
  • Operational reports (read-only, non-GL): inventory/daily report, store (branch) report, stock-movement summary, inventory valuation (FIFO / LIFO / AVCO / Standard / cost-layers), inventory count, sales report, asset register / acquisition / depreciation / disposal.
  • Export & print: XLSX (every financial statement + tax + GL + aged + assets) and PDF (aged receivable/payable via a web template render).

It explicitly does NOT:

  • Post anything. No report writes a ledger leg, stock row, or any mutation. All report resolvers are @Query; all controllers are @Get. The only write near reporting is updateAccountCategory (report-settings remapping), which lives in the category module, not in a report module.
  • Store balances or statements. Every number is recomputed per request from the ledger / stock ledger. (AccountCacheService exists but its setBalance is commented out — see account §8.)
  • Define the chart of accounts. Sections/types/normal-balance come from category; accounts from account.

There are two backend report modules (do not confuse them):

Module Path Drives
FinanceReportService finance/report/report.service.ts All financial statements (GL-driven). Resolver financeXxxReport queries + api/finance/download-* XLSX/PDF.
ReportService report/report.service.ts Inventory/daily report, sales report, store/branch report (stock-driven). Resolver dailyReport / salesReport / inventoryReport.

Valuation, asset, and store reports live in their own domain modules (inventory/stock, assets, branch) and are surfaced by the admin under the Reports menu.


2. The reportSection model — the spine of every financial statement

2.1 What reportSection is

AccountCategory.reportSection (string-enum, default NONE) is the single field that decides which financial-statement bucket a category's accounts roll into. It was introduced to replace hardcoded ACCOUNT_NAME string matching in the report service with a user-configurable enum (docs/superpowers/plans/2026-04-12-report-section-mapping.md). The enum (finance/finance.model.ts):

export enum ReportSection {
  // Balance Sheet — Assets
  BS_CURRENT_ASSET         = "BS_CURRENT_ASSET",
  BS_FIXED_ASSET           = "BS_FIXED_ASSET",
  // Balance Sheet — Liabilities
  BS_CURRENT_LIABILITY     = "BS_CURRENT_LIABILITY",
  BS_NON_CURRENT_LIABILITY = "BS_NON_CURRENT_LIABILITY",
  // Balance Sheet — Equity
  BS_EQUITY                = "BS_EQUITY",
  // P&L — Income
  PNL_REVENUE              = "PNL_REVENUE",
  PNL_SALES_ADJUSTMENTS    = "PNL_SALES_ADJUSTMENTS",
  PNL_OTHER_INCOME         = "PNL_OTHER_INCOME",
  // P&L — Expenses
  PNL_COST_OF_SALES        = "PNL_COST_OF_SALES",
  PNL_OPERATING_EXPENSE    = "PNL_OPERATING_EXPENSE",
  PNL_INTEREST_EXPENSE     = "PNL_INTEREST_EXPENSE",
  PNL_TAX_EXPENSE          = "PNL_TAX_EXPENSE",
  // Special
  BANK_AND_CASH            = "BANK_AND_CASH",
  NONE                     = "NONE"
}

The seed mapping (which category → which section, with cashFlowAdjustment/isContra flags) is documented in category §2–§4. Two sections (PNL_SALES_ADJUSTMENTS, PNL_TAX_EXPENSE) exist in the enum but the default seed does not assign them — assign them via /report/settings if those lines are needed.

2.2 The pivot — accountsBySection()

The single shared helper every section-based statement calls (finance/report/report.service.ts):

public async accountsBySection(
  section: ReportSection,
  filter: Partial<IFinananceReportInput>,
  options?: { cashFlowAdjustment?: boolean; witExchangeRate?: boolean }
): Promise<IGroupedAccount> {
  const { parentCompanyId } = this.contextSvc;
  const companyId = parentCompanyId ? undefined : this.companyId;

  // 1. categories whose reportSection == section (optionally filtered by cashFlowAdjustment)
  const categories = await this.accountCategorySvc.findByReportSection(
    section, companyId, parentCompanyId, options?.cashFlowAdjustment);
  if (!categories.length) return null;

  // 2. all postable accounts under those categories
  const accounts = await this.accountSvc.find({
    excludeAccountIds: filter.excludeAccountIds, parentCompanyId,
    categoryIds: categories.map((c) => c._id?.toString())
  });

  // 3. derive each account's balance from the ledger, sign + group it
  const mappedAccounts = await this.mapAccounts(
    accounts, filter, this.CUMULATIVE_SECTIONS.has(section), options?.witExchangeRate ?? false);
  return this.mapAccountGroup(mappedAccounts);
}

findByReportSection (finance/category/category.repository.ts) is a flat $match on { reportSection, deleted: { $ne: true }, companyId? , cashFlowAdjustment? }, with an extra $lookupCompany + company.parentId match when parentCompanyId is set (group/subsidiary reporting).

2.3 Per-account balance — mapAccounts()

For each account, in parallel:

const [blnc, totalDebitNCredit] = await Promise.all([
  this.accountSvc.balanceWithDrAnCrPosted(acct._id, { ...filter }, witExchangeRate),
  this.transactionsSvc.totalDrAnCr({ accountId: acct._id, status: POSTED, ...filter }, witExchangeRate)
]);
totalDebitNCredit.totalAmount = blnc
  ? (cumulative ? (blnc.closingBalance?.balance ?? blnc.balance) : blnc.balance)
  : 0;
  • balanceWithDrAnCrPosted forces status = POSTEDonly posted legs appear in financial statements (SAVED/draft entries are invisible to P&L, BS, TB, etc.). It maps debits/credits to a signed balance through the account-type normal-balance table (mapBalance, account §4: credit === "INCREASE" ? credits − debits : debits − credits).
  • cumulative vs period. CUMULATIVE_SECTIONS = { BS_CURRENT_ASSET, BS_FIXED_ASSET, BS_CURRENT_LIABILITY, BS_NON_CURRENT_LIABILITY, BS_EQUITY, BANK_AND_CASH }. Balance-sheet sections use the closing (cumulative-from-inception) balance; P&L sections use the period balance (fromDatetoDate only). This is the accounting distinction between a stock measure (BS, point-in-time) and a flow measure (P&L, over a period).
  • mapAccountGroup sums the group's debits, credits, and signed totalAmount (via helper.formatAmt).

2.4 Cross-cutting filters every report shares

All finance reports accept the same dimension filters via FinanceCommonReportInput: fromDate, toDate, and four analytical dimensions — cost center, department, class, analysis code — each as a single id, an id array, or a free-text search (costCenter/department/class/analysisCode). normalizeDimensionFilters() resolves a text search to ids via masterSvc.findByParentKey(key) (matching code/number/ref/name, active-only); an unmatched search injects the sentinel 000000000000000000000000 so the report returns empty rather than everything.

mapSubsidiaryCompany() runs first on every statement: if the current company has subsidiaries, it sets contextSvc.parentCompanyId and flips ignoreCompanyQuery=true, so the report aggregates across the whole company group (consolidated). Otherwise it stays single-company.


3. Financial statements — data source & aggregation per report

All financial-statement queries are on FinanceReportResolver (@ApGqlAuthorize), all read POSTED legs only, and all go through accountsBySection / balanceWithDrAnCrPosted unless noted.

3.1 Trial Balance — financeTrialBalancetrialBalance()

Source Every visible account (accountSvc.find({ canView:true })), balanceWithDrAnCrPosted per account over [fromDate, toDate], witExchangeRate=true.
Aggregation For each account: periodDebits/periodCredits/periodBalance from the period; openingBalance = balance.openingBalance.balance; closingBalance = opening + periodBalance. A uniform sign convention is applied: credit-normal accounts (LIABILITY, EQUITY, INCOME) are negated (sign=-1), debit-normal (ASSET, EXPENSE, …) stay positive, so opening + closing columns sum to zero without special-casing.
Filter Rows where all of debits/credits/closingBalance are 0 are dropped.
Totals debits = Σ periodDebits, credits = Σ periodCredits (straight sums), totalOpeningBalance/totalClosingBalance are straight sums of the signed rows.
Output FinanceTrialBalanceReponse { debits, credits, totalRecords, totalOpeningBalance, totalClosingBalance, data:[{ account, type, debits, credits, openingBalance, closingBalance }] }.

A correct trial balance has equal total debits and credits, and the signed opening/closing totals near zero — the sign trick is what makes that hold across mixed account types.

3.2 GL Account report — financeGlAccountReportglAccountReport()

The list endpoint behind the admin Accounts screen (not bankAccountPage — see account §9). Per account it computes balanceWithDrAnCrPosted(..., witExchangeRate=false) then multiplies by the FX rate from the account's currency to the company currency (exchangeSvc.rate), yielding debits, credits, balance = debits − credits in company currency. Output FinanceGlAccountReportResponse { debits, credits, balance, totalRecords, data:[{ account, type, debits, credits, balance }] }.

3.3 General Ledger — financeGeneralLedgerReportgeneralLedgerReport()

Source The raw ledger: transactionsSvc.page({ status:POSTED, accountId?, ...dimensions, skip, take }). This is the only financial report that returns individual transaction legs, not account rollups.
Aggregation totalDrAnCr(query, false) over the same query → totalDebit / totalCredit. The per-row running balance column comes from the transaction page resolver/repo (the report passes it through).
Output AccountTransactionPageResult ({ totalRecords, data:[AccountTransaction] }) plus totalDebit/totalCredit.
Admin general-ledger-table.tsx renders Date, Ref (deep-linked to the source document by kind+refId — e.g. JournalEntry→/finance/journals/:id, SalesInvoice→/order/:id, StockTransfer→/stock/transfer/:id), Account, Description, Cost Center / Class / Analysis Code, Debit, Credit, running Balance. Amounts shown as `amount × (exchangeRate

3.4 P&L / Statement of Comprehensive Income — financePNLReportpnlReport()

Source Seven accountsBySection calls, all period balances (P&L sections are not cumulative): PNL_REVENUE (sales), PNL_SALES_ADJUSTMENTS, PNL_OTHER_INCOME, PNL_COST_OF_SALES (COGS), PNL_OPERATING_EXPENSE (expenses), PNL_TAX_EXPENSE, PNL_INTEREST_EXPENSE. Default range when unbounded: fromDate = now − 150y start-of-year, toDate = endOfToday.
Aggregation (the actual math) grossProfit = sales − salesAdjustment − costOfGoodsSold
profitBeforeInterestAndTax = grossProfit − expenses
profitBeforeTax = profitBeforeInterestAndTax − interestExpense
netPnL (profitAfterInterestAndTax) = profitBeforeTax − taxExpense
Per-line Each section group lists its accounts filtered to totalAmount > 0, with totalAmount = helper.formatAmt(group.totalAmount).
Output FinancePNLReport { year, fromDate, toDate, sales, salesAdjustment, costOfGoodsSold, otherIncomes, expenses (each a FinanceReportAccountGroup), grossProfit, profitBeforeIntrestAndTax, profitBeforeTax, netPnL }.

Monthly P&LfinancePNLMonthlyReportpnlMonthlyReport({ year }) runs pnlReport 12× (one per calendar month, start-of-month→end-of-month) in parallel and returns months:[FinancePNLMonthlyColumn] plus year totals (Σ of each month's grossProfit/pbit/pbt/netPnL). Used by the P&L monthly table + the download-pnl-monthly XLSX.

3.5 Balance Sheet / Statement of Financial Position — financeBalanceSheetReportbalanceSheetReport()

Source Six accountsBySection calls, cumulative balances, witExchangeRate=true: BS_FIXED_ASSET, BS_CURRENT_ASSET, BANK_AND_CASH, BS_CURRENT_LIABILITY, BS_NON_CURRENT_LIABILITY, BS_EQUITY. Bank & Cash is merged into Current Assets (accounts + totals concatenated).
Retained earnings Two P&L runs: pnl = cumulative-from-inception (fromDate=undefined) and currentPnl = the requested range, both witExchangeRate=true. priorNetPnl = pnl.netPnL − currentPnl.netPnL is added to the Retained Earnings equity account (or surfaced as a virtual retained-earnings-virtual line if no such account exists). netIncome = currentPnl.netPnL.
Aggregation (the actual math) totalAsset = fixedAssets + currentAssets(+bankAndCash)
totalLiabilities = currentLiabilities + nonCurrentLiabilities
totalEquity = equity + pnl.netPnL (cumulative net income folded into equity)
totalEquityAndLiabilities = totalLiabilities + totalEquity
Per-line Each group filters accounts to totalAmount !== 0. Equity is split: Retained Earnings broken out into its own retainedEarnings group; remaining equity accounts in equity.
Output FinanceBalanceSheetReport { year, fromDate, toDate, totalAsset, fixedAssets, currentAssets, totalLiabilities, currentLiabilities, nonCurrentLiabilities, totalEquity, equity, retainedEarnings, netIncome, totalEquityAndLiabilities }.

Balance-sheet identity: totalAsset should equal totalEquityAndLiabilities. The framework does not assert it (unlike the journal-write balance check); it is achieved by folding cumulative net income (pnl.netPnL) and prior-period profit (priorNetPnl) into equity.

3.6 Cash Flow / Statement of Cash Flows — financeCashflowReportcashflowReport()

The most involved statement. Indirect method, three activity sections, comparing the current year to last year (fromDate − 1 year, full prior year) for working-capital deltas. Default fromDate = startOfYear(toDate).

Operating activities (getCashflowFromOperatingActivities):

  • Non-cash add-backs from cashFlowAdjustment:true categories: Depreciation & Amortization (split out of BS_FIXED_ASSET + cashFlowAdjustment by account-name match on "depreciation"/"amortization", using their credits), Loss on Disposal (PNL_OPERATING_EXPENSE + cashFlowAdjustment, negated), Gain on Disposal (PNL_OTHER_INCOME + cashFlowAdjustment).
  • Working-capital changes: increaseInCurrentAsset = lastYearCurrentAssets − currentAssets; increaseInCurrentLiability = currentLiabilities − lastYearCurrentLiabilities. Each is split into the positive/negative half (Increase/Decrease lines).
  • totalIncreaseDecrease = Σ of all the above.

Investing activities (getCashflowFromInvestingActivities):

  • BS_FIXED_ASSET group, contra accounts filtered out, then split by category name (filterAccountsByCategory): PP&E, Intangible Assets, Long-Term Investments, Long-Term Loans Receivable.
  • Purchases = −debits, Sales/Collections = credits. totalIncreaseDecrease = Σ.

Financing activities (getCashflowFromFinancingActivities):

  • BS_NON_CURRENT_LIABILITY (Long-Term Loans/Bonds/Notes Payable) + BS_EQUITY (Issued Stock, Treasury Stock, Dividends Declared, Dividends Expense) split by category name.
  • Paybacks/purchases = −debits, issuances/loans = credits. totalIncreaseDecrease = Σ.

Reconciliation: netIncreaseDecreaseInCash = Σ section.totalIncreaseDecrease. actualChange = cashAtEndOfYear − cashAtBeginningOfYear (from BANK_AND_CASH totals, this year vs last). If |netChange − actualChange| > 1.0 (CASH_FLOW_TOLERANCE = $1), it logs a console.warn (does not throw). Output FinanceCashflowReport { sections:[{sectionTitle, accounts:[group], totalIncreaseDecrease}], netIncreaseDecreaseInCash, cashAtBeginningOfYear, cashAtEndOfYear }.

3.7 Aged Receivable / Payable — financeAgedReceivableReport / financeAgedPayableReport

Source Not the GL — the Order collection directly. agedReportPipeline(companyId, kind, partyId?) $matches POSTED orders of kind = SalesInvoice (receivable) / PurchaseInvoice (payable), $lookups the counterparty user, projects ref, orderDate, totalAmount, totalAmountPaid, partyName/Email/Phone.
Aggregation Per invoice: amountDue = totalAmount − totalAmountPaid (skip if ≤ 0). calculateAgingBuckets(reportDate, dueDate, amountDue) slots the whole amount into one bucket by daysOverdue = reportDate − orderDate: ≤30 → bucket_0_30, ≤60 → 31_60, ≤90 → 61_90, else over_90. Invoices grouped by party; party totals + grand totals accumulated per bucket.
Output AgedReportResponse { parties:[{ partyId, partyName, contact, phone, invoices:[{invoiceNumber, invoiceDate, dueDate, bucket_*, amountDue}], totals }], grandTotals }.

Aging is whole-invoice, not split — an invoice's full balance lands in a single bucket based on its order date; there is no due-date field driving it (it defaults dueDate = orderDate).

3.8 Tax report — financeTaxReporttaxReport()

Source The tax legs of the ledger: transactionsSvc.find({ kind:TaxEntry, status:POSTED, taxId?, fromDate?, toDate?, ...dimensions }). (Tax legs are spawned automatically by transaction.create — see transaction.)
Source-type filter Optional sourceType (JOURNAL/NOTE/CASHBOOK/ASSET/SALES/PURCHASE/PAYMENT/ALL): keeps only tax legs whose sibling leg (same refId) is of the matching kind. Each row's source label/ref is resolved from the non-tax sibling leg.
Aggregation Per leg → { taxName, taxPercentage, taxAmount=amount, type, account, sourceType, sourceRef }. Summary: totalSalesTax (tax-type SALES, or fallback CREDIT legs), totalPurchaseTax (PURCHASE, or DEBIT), totalTaxAmount = Σ, netTaxPayable = totalSalesTax − totalPurchaseTax.
Output TaxReportResult { summary{ totalTaxAmount, totalSalesTax, totalPurchaseTax, netTaxPayable, totalRecords }, data:[TaxReportItem], totalRecords } (data paginated skip/take).

3.9 AR/AP account (debtor/creditor) reports — financeAccountsReport / financeAccountDetailedReport

Per-counterparty receivable/payable detail (admin Account report + Trade Receivable/Payable). accountSummaryReport / accountDetailReport iterate every user, resolve their debtor/creditor account (accountSvc.getUserAccount), and compute balanceWithDrAnCrPosted filtered by kind = SalesInvoice (AR) / PurchaseInvoice (AP) and payeeId = user._id. accountGroup (ACCOUNTS_RECEIVABLE | ACCOUNTS_PAYABLE) selects the kind and the payment key (credits for AR, debits for AP). Detailed variant also returns each user's individual POSTED transactions (amounts ×exchangeRate).


4. Operational reports — data source & aggregation

These bypass the GL entirely and read the inventory/stock/asset side.

4.1 Inventory / Daily / Sales report — dailyReport / salesReport / inventoryReportReportService.inventoryReport()

Source: Order rows in range (default fromDate = startOfYear, toDate = endOfToday) split into purchases (PurchaseInvoice) vs sales (SalesInvoice), plus stock adjustments. Per side it sums amount, gross weight/quantity, cash-in-hand (OrderPayment CASH), bank transfer (BANK), and outstanding balance = getAmount(order) − Σ payments. items groups every order line + adjustment by item, attaching on-hand stockSvc.balance and cost price. (The on-hand engine is Σ IN − Σ OUT over the Stock ledger — see zerp-be/docs/inventory-stock-flow.md.)

4.2 Store / Branch report — branchReportReportService.branchReport() (branch.resolver.ts)

Per branch (or all), per item: stockBalance = stockSvc.balance({ itemId, branchId, toDate }), stockValue = balance × costPrice, plus per-period purchase/sales quantity/amount/avg-rate from OrderItems, and profitMargin = (salesPrice − costPrice) / salesPrice × 100. Summary rolls up totalItems, totalStockValue, purchase/sales totals, and grossProfit = totalSales − totalPurchase.

4.3 Inventory valuation — fifoValuation / lifoValuation / avcoValuation / standardValuation (inventory/stock/stock.resolver.ts)

Per-item cost valuation by method, scoped to a branchId + company:

  • FIFO / LIFO{ itemId, totalRemainingQty, totalRemainingValue, layers:[FifoLayer] } (cost-layer model).
  • AVCO{ avgCost, balance, totalValue }.
  • Standard{ standardCost, balance, totalValue }.

Delegated to StockService.getFifoValuation / getLifoValuation / getAvcoValuation / getStandardValuation. The admin also has cost-layers, stock-movement summary, and inventory-count tables built from the same stock data.

4.4 Asset reports — api/assets/download* (assets/assets.controller.ts)

REST-only, XLSX. Endpoints: download/register, download/acquisition, download/depreciation, download/disposal, download, download/export (import-compatible). Each maps Asset rows (with summary.depreciation, summary.value, purchaseCost, department/location lookups) into report columns; e.g. register/acquisition net book value = purchaseCost − summary.depreciation. Depreciation values come from the asset module's depreciation engine (asset domain), not the GL.


5. Report settings / customization

Operators re-map the chart of accounts to statements at /report/settings (admin report-settings module; plan zerp-admin/docs/superpowers/plans/2026-04-12-report-settings.md).

  • Data: findAccountCategories(query:{}) → flat list with { _id, name, type, reportSection, cashFlowAdjustment, canUpdate }.
  • Edit: an editable table — SectionSelect (all 14 ReportSection values with human labels) + a cashFlowAdjustment Switch. Each change fires updateAccountCategory(_id, bankAccountCategory:{ reportSection? | cashFlowAdjustment? }) immediately (no save button); optimistic local update + toast/message "Saved". Rows with canUpdate === false (seeded/system categories) are disabled. Type multi-select + name search filter client-side; NONE rows get an "Unmapped" tag.
  • Effect: because every statement resolves through findByReportSection(section), remapping a category instantly changes which statement (and which line) its accounts appear on, with no migration and no data rewrite. cashFlowAdjustment controls whether a category feeds the cash-flow non-cash add-back logic (§3.6).
  • Permission: guarded by COMPANY_FINANCIALS view (page getServerSideProps + nav under Reports → company financials).

This is the customization story in full: the chart of accounts is the only configuration; the statements are pure functions of it + the ledger.


6. Export & print (XLSX / PDF)

6.1 GraphQL (on-screen) vs REST (download)

Each statement has two paths: a @Query for the screen (FinanceReportResolver) and a @Get controller for the file download (FinanceReportController, base api/finance, @ApiAuthorize). The controller re-runs the same service method then serialises to XLSX/PDF — so a download is always consistent with the screen.

6.2 XLSX downloads (finance/report/report.controller.ts)

All XLSX via ApBaseController.xlsDownloadResponse({ response, rows, fileName }). Date defaults: missing/NaN → first-of-month → today (resolveDates). Endpoints + statement title:

Endpoint Statement title in file Calls
GET api/finance/download-pnl "Statement of Comprehensive Income (SCI)" pnlReport
GET api/finance/download-pnl-monthly "Monthly Statement of Comprehensive Income (SCI)" pnlMonthlyReport (12 month columns + totals)
GET api/finance/download-balance-sheet "Statement of Financial Position (SFP)" balanceSheetReport
GET api/finance/download-trial-balance "Trial Balance" trialBalance
GET api/finance/download-cashflow "Statement of Cash Flow (SCF)" cashflowReport
GET api/finance/download-tax-report "Tax Report" (summary + rows) taxReport (take:10000)
GET api/finance/download-general-ledger "General Ledger Report" (running balance) generalLedgerReport (take:10000, sortOrder:ASC)
GET api/finance/download-aged-receivable "Aged Receivable Report" getAgedReceivableReport
GET api/finance/download-aged-payable "Aged Payable Report" getAgedPayableReport

Account/category XLSX (per-account, per-category, chart-of-accounts) is served by the account controller (api/account/...download) — see account §3. Asset XLSX via api/assets/download* (§4.4).

6.3 PDF downloads

Aged receivable/payable support downloadType=PDF: the controller calls ApBaseController.pdfDownloadResponse(res, title, url) where url = ${web_uri}/templates/report/aged-receivable?… — i.e. it renders the admin web print template page to PDF (headless render of the report HTML). The query string carries the same reportDate/partyId/dimension filters. Other statements are XLSX-only.

6.4 Admin download UX

ReportFilterBar (report/components/report-filter.tsx) renders the shared filter row (duration/date, cost-center/department/class/analysis-code multi-selects) + an ApDownloadButton2 offering PDF/XLSX, posting { ...filter } to the configured apiPath.{PDF,XLSX}. Each report page wires its own endpoints (e.g. Trial Balance → /finance/download-trial-balance).


7. Admin UI map

All under zerp-admin/src/pages/report/* (each thin page renders a module screen wrapped in MainLayout + SSR guard), driven by src/modules/report/context.tsx (useReportState) — the single context exposing every fetchXxx method and report state. Apollo hooks live in report/gql/query.ts (+ per-statement pnl/, balance-sheet/, cashflow/ sub-folders). Report-settings has its own report-settings module/context.

Page route Screen / table Backend op
report/trial-balance trial-balance/page.tsx, trial-balance-table financeTrialBalance
report/general-ledger general-ledger/page.tsx, general-ledger-table financeGeneralLedgerReport
report/pnl pnl/page.tsx, pnlTable / pnlMonthlyTable financePNLReport / financePNLMonthlyReport
report/balance-sheet balance-sheet/page.tsx, balanceSheetTable financeBalanceSheetReport
report/cashflow cashflow/page.tsx, cashflowTable financeCashflowReport
report/aged-receivable / report/aged-payable aged-receivable/page.tsx, aged-report-table financeAgedReceivableReport / …Payable…
report/tax tax/page.tsx, tax-table + tax-report-summary financeTaxReport
report/account (+ trade-receivable / trade-payable) account/page.tsx + detailed.tsx financeAccountsReport / financeAccountDetailedReport
report/inventory, inventory-count, cost-layers inventory/* inventoryReport / valuation queries
report/avco-valuation, lifo-valuation, standard-valuation, inventory-valuation-* inventory/*-valuation.tsx fifoValuation / lifoValuation / avcoValuation / standardValuation
report/stock-movement, stock-movement-summary stock/movement-summary.tsx stock queries
report/store store/page.tsx, store-table + store-report-summary branchReport
report/assets/{register,acquisition,depreciation,disposal} assets/*.tsx api/assets/download/*
report/settings report-settings/page.tsx + table findAccountCategories / updateAccountCategory

Every report page uses ReportFilterBar, summary cards (ApSummaryCard), and an ApTable; data fetch is useEffect(() => fetchXxx(filter), [filter]). No report page mutates anything except /report/settings.


8. Permissions

  • All finance-statement resolvers are @ApGqlAuthorize() (class-level on FinanceReportResolver); download controllers are @ApiAuthorize() + setAccessToken(user._id).
  • Pages are SSR-guarded; the financial statements + settings sit under the COMPANY_FINANCIALS module (view action), with the cash-flow page using a VIEW_CASHFLOW action. Inventory/store/asset report pages use their respective module guards.
  • Row-level scoping is inherited from the underlying repositories: financial statements read accounts/transactions through accountSvc/transactionsSvc, so a Customer/Supplier sees only their linked account's rows and non-admins see only what they created (see account §5, permissions). Consolidated (group) reporting is enabled via parentCompanyId + ignoreCompanyQuery (§2.4), not by a separate permission.

9. Gotchas & project-specific rules

  • POSTED-only. Every financial statement filters status = POSTED. Draft (SAVED) journal/contra/document entries do not appear in P&L, BS, TB, cash flow, tax, GL, or aged reports. (Aged uses POSTED Orders; the GL list is POSTED legs.)
  • BS = cumulative, P&L = period. CUMULATIVE_SECTIONS decides per section; mis-assigning a category's reportSection to the wrong family changes whether its balance is point-in-time or period-flow.
  • reportSection is the only knob. No per-account override, no hardcoded account-name matching in the statement core (the old ACCOUNT_NAME matching was replaced by sections). Cash-flow investing/financing still split by category name (filterAccountsByCategory), so renaming categories like "Property, Plant and Equipment (PP&E)" / "Long-Term Loans Payable" silently drops them from cash-flow sub-lines.
  • witExchangeRate is asymmetric. TB and BS sum with FX rate; GL-account multiplies the result by rate; P&L period sums depend on how each leg was stored (journal-kind legs are in account currency). Mixing kinds with rate can double-convert — the same footgun documented in account §9 / overview §1.3.
  • Cash-flow reconciliation is a warning, not a guard. A > $1 mismatch only logs console.warn; the report still returns. There is no equivalent of the journal validateBalanced throw on the read side.
  • Aging is whole-invoice by order date. No partial-balance splitting across buckets, and dueDate defaults to orderDate (no real due-date field on the order in the pipeline).
  • Two list paths for accounts. The admin Accounts screen renders from financeGlAccountReport (report layer), not bankAccountPage (account layer) — both are wired; a rebuild should pick one (see account §9).
  • Consolidation flips company scoping mid-request. mapSubsidiaryCompany() mutates contextSvc (parentCompanyId, ignoreCompanyQuery) at the top of each statement — a rebuild must preserve that the report runs after this flip, or single vs group totals will be wrong.
  • Two report modules. finance/report (statements) and report (inventory/sales/store) are distinct services; the admin report module surfaces both plus valuation (inventory/stock) and asset (assets controller) reports under one Reports menu.
  • Unassigned sections. PNL_SALES_ADJUSTMENTS and PNL_TAX_EXPENSE are queried by P&L but not seeded onto any category — those P&L lines are empty until an operator maps a category to them via /report/settings.