Files
edr-platform/apps/finance-api/src/modules/reports/reports.service.ts
2026-08-25 00:11:39 +03:00

459 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Record<string, unknown>[]>(
`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<Record<string, unknown>[]>(
`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<Record<string, unknown>[]>(
`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<Record<string, unknown>[]>(
// 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<Record<string, unknown>[]>(
`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<Record<string, unknown>[]>(
`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<Record<string, unknown>[]>(
`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<Record<string, unknown>[]>(
`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;
}
}