mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
459 lines
18 KiB
TypeScript
459 lines
18 KiB
TypeScript
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;
|
||
}
|
||
}
|