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
AccountTransactionledger, grouped by thereportSectionenum stamped on each account'sAccountCategory. 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'sreportSectionand 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 theStockledger /Orderlines /Assetrows 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
reportSectionenum onAccountCategorythat decides which statement bucket each account rolls into, plus the/report/settingsadmin 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 isupdateAccountCategory(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. (
AccountCacheServiceexists but itssetBalanceis 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;balanceWithDrAnCrPostedforcesstatus = POSTED— only 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).cumulativevs 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 (fromDate→toDateonly). This is the accounting distinction between a stock measure (BS, point-in-time) and a flow measure (P&L, over a period).mapAccountGroupsums the group'sdebits,credits, and signedtotalAmount(viahelper.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 — financeTrialBalance → trialBalance()
| 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 — financeGlAccountReport → glAccountReport()
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 — financeGeneralLedgerReport → generalLedgerReport()
| 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 — financePNLReport → pnlReport()
| 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 − costOfGoodsSoldprofitBeforeInterestAndTax = grossProfit − expensesprofitBeforeTax = profitBeforeInterestAndTax − interestExpensenetPnL (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&L — financePNLMonthlyReport → pnlMonthlyReport({ 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 — financeBalanceSheetReport → balanceSheetReport()
| 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 + nonCurrentLiabilitiestotalEquity = 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:
totalAssetshould equaltotalEquityAndLiabilities. 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 — financeCashflowReport → cashflowReport()
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:truecategories: Depreciation & Amortization (split out ofBS_FIXED_ASSET + cashFlowAdjustmentby account-name match on "depreciation"/"amortization", using theircredits), 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_ASSETgroup, 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 — financeTaxReport → taxReport()
| 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 / inventoryReport → ReportService.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 — branchReport → ReportService.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 14ReportSectionvalues with human labels) + acashFlowAdjustmentSwitch. Each change firesupdateAccountCategory(_id, bankAccountCategory:{ reportSection? | cashFlowAdjustment? })immediately (no save button); optimistic local update +toast/message"Saved". Rows withcanUpdate === false(seeded/system categories) are disabled. Type multi-select + name search filter client-side;NONErows 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.cashFlowAdjustmentcontrols whether a category feeds the cash-flow non-cash add-back logic (§3.6). - Permission: guarded by
COMPANY_FINANCIALSview (pagegetServerSideProps+ 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 onFinanceReportResolver); download controllers are@ApiAuthorize()+setAccessToken(user._id). - Pages are SSR-guarded; the financial statements + settings sit under the
COMPANY_FINANCIALSmodule (view action), with the cash-flow page using aVIEW_CASHFLOWaction. 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 viaparentCompanyId+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 POSTEDOrders; the GL list is POSTED legs.) - BS = cumulative, P&L = period.
CUMULATIVE_SECTIONSdecides per section; mis-assigning a category'sreportSectionto the wrong family changes whether its balance is point-in-time or period-flow. reportSectionis the only knob. No per-account override, no hardcoded account-name matching in the statement core (the oldACCOUNT_NAMEmatching 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.witExchangeRateis 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
> $1mismatch only logsconsole.warn; the report still returns. There is no equivalent of the journalvalidateBalancedthrow on the read side. - Aging is whole-invoice by order date. No partial-balance splitting across buckets, and
dueDatedefaults toorderDate(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), notbankAccountPage(account layer) — both are wired; a rebuild should pick one (see account §9). - Consolidation flips company scoping mid-request.
mapSubsidiaryCompany()mutatescontextSvc(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
reportmodules.finance/report(statements) andreport(inventory/sales/store) are distinct services; the adminreportmodule surfaces both plus valuation (inventory/stock) and asset (assets controller) reports under one Reports menu. - Unassigned sections.
PNL_SALES_ADJUSTMENTSandPNL_TAX_EXPENSEare 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.