import { BadRequestException, Injectable } from "@nestjs/common"; import { DataSource } from "typeorm"; import type { ActorContext } from "../../common/current-actor.util"; import { LEDGER_CURRENCY, roundMoney } from "../../common/money"; /** * Module 4.6 — financial reports. * * Read-only, raw SQL, exactly the approach HR's 3.7 reports settled on: these * are cross-table aggregates over posted entries, and expressing them through * an ORM would obscure arithmetic that has to be readable to be trusted. * * Two rules hold throughout: * * 1. **Only POSTED entries count.** A draft has not happened. Every query * filters `status <> 'DRAFT'`, and REVERSED entries ARE included — a * reversal is a real transaction, and its own mirrored entry is what * cancels it out. Excluding reversed entries would leave the reversal * unmatched and unbalance the books. * * 2. **Sign by normal balance.** A debit-balance account reports * debits − credits; a credit-balance account the reverse. Reporting raw * debits − credits everywhere would show all revenue as negative. */ const num = (v: unknown): number => Number(v ?? 0); /** Posted, non-deleted, in this organization — the base every report shares. */ const POSTED = `e.status <> 'DRAFT' AND e.deleted_at IS NULL AND e.organization_id = $1`; export type TrialBalanceRow = { accountCode: string; accountName: { am: string; en: string }; accountType: string; normalBalance: string; debit: number; credit: number; }; @Injectable() export class ReportsService { constructor(private readonly dataSource: DataSource) {} /** * Trial balance — every account's net movement, in the column its balance * naturally falls in. * * The totals MUST match. If they do not, something has bypassed the journal * service (a hand-written UPDATE, a restore from a bad backup), and the * report says so rather than presenting two different numbers side by side * and leaving the reader to notice. */ async trialBalance( actor: ActorContext, dateFrom: string, dateTo: string, ): Promise<{ rows: TrialBalanceRow[]; totalDebit: number; totalCredit: number; balanced: boolean; difference: number; }> { const organizationId = this.requireOrganization(actor); const rows = await this.dataSource.query[]>( `SELECT a.code AS "accountCode", a.name AS "accountName", a.account_type AS "accountType", a.normal_balance AS "normalBalance", ROUND(COALESCE(SUM(l.debit), 0), 2) AS "debitTotal", ROUND(COALESCE(SUM(l.credit), 0), 2) AS "creditTotal" FROM finance.accounts a JOIN finance.journal_lines l ON l.account_id = a.id JOIN finance.journal_entries e ON e.id = l.journal_entry_id WHERE ${POSTED} AND e.entry_date >= $2::date AND e.entry_date <= $3::date AND a.deleted_at IS NULL GROUP BY a.id, a.code, a.name, a.account_type, a.normal_balance HAVING COALESCE(SUM(l.debit), 0) <> 0 OR COALESCE(SUM(l.credit), 0) <> 0 ORDER BY a.code ASC`, [organizationId, dateFrom, dateTo], ); const mapped: TrialBalanceRow[] = rows.map((row) => { const debitTotal = num(row.debitTotal); const creditTotal = num(row.creditTotal); const net = roundMoney(debitTotal - creditTotal); // Each account reports ONE side — its net, in the column it belongs in. // Showing gross debits and credits for every account would inflate both // totals and hide whether the account is actually in an odd position. return { accountCode: String(row.accountCode), accountName: row.accountName as { am: string; en: string }, accountType: String(row.accountType), normalBalance: String(row.normalBalance), debit: net > 0 ? net : 0, credit: net < 0 ? roundMoney(-net) : 0, }; }); const totalDebit = roundMoney(mapped.reduce((s, r) => s + r.debit, 0)); const totalCredit = roundMoney(mapped.reduce((s, r) => s + r.credit, 0)); const difference = roundMoney(totalDebit - totalCredit); return { rows: mapped, totalDebit, totalCredit, balanced: Math.abs(difference) < 0.005, difference, }; } /** * Profit and loss for a date range. * * `is_contra` is deliberately NOT applied as a sign flip here. Signing by the * account's normal balance already produces the right answer: a contra * revenue account (sales returns) accrues on the debit side, so * `credit − debit` comes out negative and reduces revenue on its own. * Flipping again would turn a deduction into an addition. The flag is * presentational — it tells the UI to indent the line, not the arithmetic to * invert it. */ async profitAndLoss(actor: ActorContext, dateFrom: string, dateTo: string) { const organizationId = this.requireOrganization(actor); const rows = await this.dataSource.query[]>( `SELECT a.code AS "accountCode", a.name AS "accountName", a.account_type AS "accountType", a.is_contra AS "isContra", ROUND(COALESCE(SUM( CASE WHEN a.normal_balance = 'CREDIT' THEN l.credit - l.debit ELSE l.debit - l.credit END), 0), 2) AS amount FROM finance.accounts a JOIN finance.journal_lines l ON l.account_id = a.id JOIN finance.journal_entries e ON e.id = l.journal_entry_id WHERE ${POSTED} AND e.entry_date >= $2::date AND e.entry_date <= $3::date AND a.account_type IN ('REVENUE','EXPENSE') AND a.deleted_at IS NULL GROUP BY a.id, a.code, a.name, a.account_type, a.is_contra HAVING COALESCE(SUM(l.debit), 0) <> 0 OR COALESCE(SUM(l.credit), 0) <> 0 ORDER BY a.code ASC`, [organizationId, dateFrom, dateTo], ); const signed = rows.map((row) => ({ accountCode: String(row.accountCode), accountName: row.accountName as { am: string; en: string }, accountType: String(row.accountType), isContra: Boolean(row.isContra), amount: num(row.amount), })); const revenue = signed.filter((r) => r.accountType === "REVENUE"); const expenses = signed.filter((r) => r.accountType === "EXPENSE"); const totalRevenue = roundMoney(revenue.reduce((s, r) => s + r.amount, 0)); const totalExpenses = roundMoney(expenses.reduce((s, r) => s + r.amount, 0)); return { dateFrom, dateTo, currency: LEDGER_CURRENCY, revenue, expenses, totalRevenue, totalExpenses, netResult: roundMoney(totalRevenue - totalExpenses), }; } /** * Balance sheet as at a date. * * Everything up to `asOf`, not a range — a balance sheet is a position, not a * movement. The accounting equation is checked and reported: assets must * equal liabilities plus equity plus the result earned so far, and that last * term is why the P&L has to be folded in rather than shown separately. * * As in the P&L, `is_contra` is NOT applied as a sign flip. Accumulated * depreciation is an ASSET-type account that carries a credit balance, so * signing by type already yields a negative figure that reduces total assets. * Flipping it as well made it positive and ADDED the depreciation to the * asset side — which is precisely how the equation came out wrong by twice * the accumulated depreciation. */ async balanceSheet(actor: ActorContext, asOf: string) { const organizationId = this.requireOrganization(actor); const rows = await this.dataSource.query[]>( `SELECT a.code AS "accountCode", a.name AS "accountName", a.account_type AS "accountType", a.is_contra AS "isContra", ROUND(COALESCE(SUM( CASE WHEN a.normal_balance = 'DEBIT' THEN l.debit - l.credit ELSE l.credit - l.debit END), 0), 2) AS balance FROM finance.accounts a JOIN finance.journal_lines l ON l.account_id = a.id JOIN finance.journal_entries e ON e.id = l.journal_entry_id WHERE ${POSTED} AND e.entry_date <= $2::date AND a.account_type IN ('ASSET','LIABILITY','EQUITY') AND a.deleted_at IS NULL GROUP BY a.id, a.code, a.name, a.account_type, a.is_contra HAVING COALESCE(SUM(l.debit), 0) <> 0 OR COALESCE(SUM(l.credit), 0) <> 0 ORDER BY a.code ASC`, [organizationId, asOf], ); const signed = rows.map((row) => ({ accountCode: String(row.accountCode), accountName: row.accountName as { am: string; en: string }, accountType: String(row.accountType), isContra: Boolean(row.isContra), balance: num(row.balance), })); const assets = signed.filter((r) => r.accountType === "ASSET"); const liabilities = signed.filter((r) => r.accountType === "LIABILITY"); const equity = signed.filter((r) => r.accountType === "EQUITY"); const totalAssets = roundMoney(assets.reduce((s, r) => s + r.balance, 0)); const totalLiabilities = roundMoney( liabilities.reduce((s, r) => s + r.balance, 0), ); const totalEquity = roundMoney(equity.reduce((s, r) => s + r.balance, 0)); // Revenue less expenses to date is part of equity but has not been closed // into it. Omitting it is why a balance sheet "does not balance". const [result] = await this.dataSource.query[]>( // Revenue counts positively, expenses negatively. No contra flip, for the // same reason as above — the natural side of the account already carries // the sign. `SELECT ROUND(COALESCE(SUM( CASE WHEN a.account_type = 'REVENUE' THEN l.credit - l.debit ELSE -(l.debit - l.credit) END), 0), 2) AS "netResult" FROM finance.accounts a JOIN finance.journal_lines l ON l.account_id = a.id JOIN finance.journal_entries e ON e.id = l.journal_entry_id WHERE ${POSTED} AND e.entry_date <= $2::date AND a.account_type IN ('REVENUE','EXPENSE') AND a.deleted_at IS NULL`, [organizationId, asOf], ); const retainedResult = num(result?.netResult); const equityWithResult = roundMoney(totalEquity + retainedResult); const difference = roundMoney( totalAssets - (totalLiabilities + equityWithResult), ); return { asOf, currency: LEDGER_CURRENCY, assets, liabilities, equity, totalAssets, totalLiabilities, totalEquity, /** Revenue less expenses to date — part of equity, not yet closed into it. */ retainedResult, equityWithResult, balanced: Math.abs(difference) < 0.005, difference, }; } /** * Cash movement over a range, by cash/bank account. * * A direct cash view rather than an indirect cash-flow statement: the * indirect method needs opening/closing working-capital positions that only * mean something once a full year has been posted, and presenting a * half-derived one would be worse than presenting the movements plainly. */ async cashMovement(actor: ActorContext, dateFrom: string, dateTo: string) { const organizationId = this.requireOrganization(actor); const rows = await this.dataSource.query[]>( `SELECT a.code AS "accountCode", a.name AS "accountName", ROUND(COALESCE(SUM( CASE WHEN e.entry_date < $2::date THEN l.debit - l.credit ELSE 0 END), 0), 2) AS "opening", ROUND(COALESCE(SUM( CASE WHEN e.entry_date BETWEEN $2::date AND $3::date THEN l.debit ELSE 0 END), 0), 2) AS "cashIn", ROUND(COALESCE(SUM( CASE WHEN e.entry_date BETWEEN $2::date AND $3::date THEN l.credit ELSE 0 END), 0), 2) AS "cashOut", ROUND(COALESCE(SUM( CASE WHEN e.entry_date <= $3::date THEN l.debit - l.credit ELSE 0 END), 0), 2) AS "closing" FROM finance.accounts a JOIN finance.journal_lines l ON l.account_id = a.id JOIN finance.journal_entries e ON e.id = l.journal_entry_id WHERE ${POSTED} AND a.account_type = 'ASSET' AND a.code LIKE '111%' AND a.deleted_at IS NULL GROUP BY a.id, a.code, a.name ORDER BY a.code ASC`, [organizationId, dateFrom, dateTo], ); const accounts = rows.map((row) => ({ accountCode: String(row.accountCode), accountName: row.accountName as { am: string; en: string }, opening: num(row.opening), cashIn: num(row.cashIn), cashOut: num(row.cashOut), closing: num(row.closing), })); return { dateFrom, dateTo, currency: LEDGER_CURRENCY, accounts, totalOpening: roundMoney(accounts.reduce((s, a) => s + a.opening, 0)), totalIn: roundMoney(accounts.reduce((s, a) => s + a.cashIn, 0)), totalOut: roundMoney(accounts.reduce((s, a) => s + a.cashOut, 0)), totalClosing: roundMoney(accounts.reduce((s, a) => s + a.closing, 0)), }; } /** * The general ledger for one account — every line, with a running balance. * * The running balance is computed in SQL with a window function rather than * in JavaScript, so a paged view still shows the correct balance at each row * instead of one that restarts at the top of every page. */ async generalLedger( actor: ActorContext, accountId: string, dateFrom: string, dateTo: string, ) { const organizationId = this.requireOrganization(actor); const [opening] = await this.dataSource.query[]>( `SELECT ROUND(COALESCE(SUM( CASE WHEN a.normal_balance = 'DEBIT' THEN l.debit - l.credit ELSE l.credit - l.debit END), 0), 2) AS "openingBalance" FROM finance.journal_lines l JOIN finance.journal_entries e ON e.id = l.journal_entry_id JOIN finance.accounts a ON a.id = l.account_id WHERE ${POSTED} AND l.account_id = $2 AND e.entry_date < $3::date`, [organizationId, accountId, dateFrom], ); const lines = await this.dataSource.query[]>( `SELECT e.entry_date::text AS "entryDate", e.entry_number AS "entryNumber", e.memo, e.journal_type AS "journalType", l.description, l.debit, l.credit, cc.code AS "costCenterCode", SUM(CASE WHEN a.normal_balance = 'DEBIT' THEN l.debit - l.credit ELSE l.credit - l.debit END) OVER (ORDER BY e.entry_date, e.entry_number, l.line_number ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS "movementToDate" FROM finance.journal_lines l JOIN finance.journal_entries e ON e.id = l.journal_entry_id JOIN finance.accounts a ON a.id = l.account_id LEFT JOIN finance.cost_centers cc ON cc.id = l.cost_center_id WHERE ${POSTED} AND l.account_id = $2 AND e.entry_date >= $3::date AND e.entry_date <= $4::date ORDER BY e.entry_date ASC, e.entry_number ASC, l.line_number ASC`, [organizationId, accountId, dateFrom, dateTo], ); const openingBalance = num(opening?.openingBalance); return { openingBalance, lines: lines.map((row) => ({ entryDate: String(row.entryDate), entryNumber: String(row.entryNumber), memo: String(row.memo ?? ""), journalType: String(row.journalType), description: row.description ? String(row.description) : null, costCenterCode: row.costCenterCode ? String(row.costCenterCode) : null, debit: num(row.debit), credit: num(row.credit), balance: roundMoney(openingBalance + num(row.movementToDate)), })), closingBalance: roundMoney( openingBalance + num(lines[lines.length - 1]?.movementToDate), ), }; } /** * Revenue by mapped category — what the revenue mappings are for. * * Grouped on the mapping's `revenue_category` rather than the account, so * several accounts rolling up to one commercial category report as one line. */ async revenueByCategory(actor: ActorContext, dateFrom: string, dateTo: string) { const organizationId = this.requireOrganization(actor); const rows = await this.dataSource.query[]>( `SELECT COALESCE(m.revenue_category, 'UNMAPPED') AS category, a.code AS "accountCode", a.name AS "accountName", ROUND(COALESCE(SUM(l.credit - l.debit), 0), 2) AS amount FROM finance.accounts a JOIN finance.journal_lines l ON l.account_id = a.id JOIN finance.journal_entries e ON e.id = l.journal_entry_id LEFT JOIN LATERAL ( SELECT rm.revenue_category FROM finance.revenue_mappings rm WHERE rm.account_id = a.id AND rm.organization_id = $1 AND rm.deleted_at IS NULL LIMIT 1 ) m ON TRUE WHERE ${POSTED} AND e.entry_date >= $2::date AND e.entry_date <= $3::date AND a.account_type = 'REVENUE' AND a.deleted_at IS NULL GROUP BY m.revenue_category, a.id, a.code, a.name HAVING COALESCE(SUM(l.credit - l.debit), 0) <> 0 ORDER BY 1 ASC, 2 ASC`, [organizationId, dateFrom, dateTo], ); return rows.map((row) => ({ category: String(row.category), accountCode: String(row.accountCode), accountName: row.accountName as { am: string; en: string }, amount: num(row.amount), })); } private requireOrganization(actor: ActorContext): string { if (!actor.organizationId) { throw new BadRequestException( "This account has no organization context, so it cannot read finance reports", ); } return actor.organizationId; } }