Muluhabt ERP modules

This commit is contained in:
Mulu Mehari
2026-08-25 00:11:39 +03:00
parent 5c2100e76d
commit 70171fa9d8
441 changed files with 68587 additions and 214 deletions

View File

@@ -0,0 +1,458 @@
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;
}
}