@classytic/ledger 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -64
- package/dist/{categories-CclX7Q94.mjs → categories-FJlrvzcl.mjs} +0 -2
- package/dist/constants/index.d.mts +2 -2
- package/dist/constants/index.mjs +3 -3
- package/dist/core-8Xfnpn6g.d.mts +1 -2
- package/dist/country/index.d.mts +1 -1
- package/dist/country/index.mjs +0 -2
- package/dist/currencies-W8kQAkm0.mjs +0 -2
- package/dist/{idempotency.plugin-v9NQ_ta-.mjs → date-lock.plugin-C8kqPBjh.mjs} +51 -11
- package/dist/{engine-BzBMpWuy.d.mts → engine-DF-MtsEr.d.mts} +10 -6
- package/dist/{errors-B7yC-Jfw.mjs → errors-BoGUSUYL.mjs} +0 -2
- package/dist/exports/index.d.mts +1 -1
- package/dist/exports/index.mjs +1 -1
- package/dist/{exports-I5Xkq-9_.mjs → exports-DoGQQtMQ.mjs} +96 -77
- package/dist/{fiscal-close-L631E3De.mjs → fiscal-close-DmPV82e4.mjs} +1000 -286
- package/dist/{idempotency.plugin-CPxPt4vX.d.mts → idempotency.plugin-zU-GKJ0-.d.mts} +19 -17
- package/dist/{index-ZnSiqHYV.d.mts → index-CxZqRaOU.d.mts} +20 -6
- package/dist/{index-BPukb3L8.d.mts → index-J-XIbXH-.d.mts} +7 -8
- package/dist/index.d.mts +280 -58
- package/dist/index.mjs +123 -25
- package/dist/journal-entry.schema-B1CzLwC3.d.mts +103 -0
- package/dist/{journals-oH-FK3g8.mjs → journals-BcMn71Cq.mjs} +27 -6
- package/dist/{currencies-4WAbFRlw.d.mts → journals-DTipb_rz.d.mts} +16 -8
- package/dist/logger-UbTdBb1x.d.mts +1 -2
- package/dist/money.d.mts +1 -2
- package/dist/money.mjs +5 -5
- package/dist/plugins/index.d.mts +38 -2
- package/dist/plugins/index.mjs +57 -2
- package/dist/reconciliation.repository-DEybU_Ok.d.mts +135 -0
- package/dist/{account.repository-kDKwDt0I.mjs → reconciliation.repository-DgJEDVS-.mjs} +361 -210
- package/dist/{fiscal-period.schema-BQ5wsAq3.mjs → reconciliation.schema-KScbsXbY.mjs} +235 -90
- package/dist/reports/index.d.mts +2 -2
- package/dist/reports/index.mjs +2 -2
- package/dist/repositories/index.d.mts +2 -2
- package/dist/repositories/index.mjs +2 -2
- package/dist/schemas/index.d.mts +71 -2
- package/dist/schemas/index.mjs +2 -2
- package/dist/tenant-guard-CAxXoWuS.mjs +13 -0
- package/dist/trial-balance-DcQ0xj_4.d.mts +530 -0
- package/docs/reports.md +1 -1
- package/package.json +14 -6
- package/dist/account.repository-C7gwFLfM.d.mts +0 -29
- package/dist/account.repository-C7gwFLfM.d.mts.map +0 -1
- package/dist/account.repository-kDKwDt0I.mjs.map +0 -1
- package/dist/categories-CclX7Q94.mjs.map +0 -1
- package/dist/core-8Xfnpn6g.d.mts.map +0 -1
- package/dist/country/index.mjs.map +0 -1
- package/dist/currencies-4WAbFRlw.d.mts.map +0 -1
- package/dist/currencies-W8kQAkm0.mjs.map +0 -1
- package/dist/engine-BzBMpWuy.d.mts.map +0 -1
- package/dist/errors-B7yC-Jfw.mjs.map +0 -1
- package/dist/exports-I5Xkq-9_.mjs.map +0 -1
- package/dist/fiscal-close-L631E3De.mjs.map +0 -1
- package/dist/fiscal-close-dNlzB37y.d.mts +0 -270
- package/dist/fiscal-close-dNlzB37y.d.mts.map +0 -1
- package/dist/fiscal-period.schema-BQ5wsAq3.mjs.map +0 -1
- package/dist/fiscal-period.schema-BRdKAjrr.d.mts +0 -38
- package/dist/fiscal-period.schema-BRdKAjrr.d.mts.map +0 -1
- package/dist/idempotency.plugin-CPxPt4vX.d.mts.map +0 -1
- package/dist/idempotency.plugin-v9NQ_ta-.mjs.map +0 -1
- package/dist/index-BPukb3L8.d.mts.map +0 -1
- package/dist/index-ZnSiqHYV.d.mts.map +0 -1
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/journals-oH-FK3g8.mjs.map +0 -1
- package/dist/logger-UbTdBb1x.d.mts.map +0 -1
- package/dist/money.d.mts.map +0 -1
- package/dist/money.mjs.map +0 -1
- package/dist/session-Ba8E3Ufa.mjs +0 -84
- package/dist/session-Ba8E3Ufa.mjs.map +0 -1
|
@@ -1,10 +1,189 @@
|
|
|
1
|
-
import { n as Errors } from "./errors-
|
|
2
|
-
import {
|
|
3
|
-
import { i as extractMainType } from "./categories-
|
|
1
|
+
import { n as Errors } from "./errors-BoGUSUYL.mjs";
|
|
2
|
+
import { t as requireOrgScope } from "./tenant-guard-CAxXoWuS.mjs";
|
|
3
|
+
import { i as extractMainType } from "./categories-FJlrvzcl.mjs";
|
|
4
|
+
import mongoose from "mongoose";
|
|
5
|
+
//#region src/reports/aged-balance.ts
|
|
6
|
+
const DEFAULT_BUCKETS = [
|
|
7
|
+
{
|
|
8
|
+
label: "Current",
|
|
9
|
+
minDays: 0,
|
|
10
|
+
maxDays: 31
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
label: "31-60",
|
|
14
|
+
minDays: 31,
|
|
15
|
+
maxDays: 61
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
label: "61-90",
|
|
19
|
+
minDays: 61,
|
|
20
|
+
maxDays: 91
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
label: "90+",
|
|
24
|
+
minDays: 91,
|
|
25
|
+
maxDays: Infinity
|
|
26
|
+
}
|
|
27
|
+
];
|
|
28
|
+
async function generateAgedBalance(opts, params) {
|
|
29
|
+
const { AccountModel, JournalEntryModel, country, orgField } = opts;
|
|
30
|
+
requireOrgScope(orgField, params.organizationId);
|
|
31
|
+
const asOfDate = params.asOfDate ?? /* @__PURE__ */ new Date();
|
|
32
|
+
const buckets = params.buckets ?? DEFAULT_BUCKETS;
|
|
33
|
+
const bucketLabels = buckets.map((b) => b.label);
|
|
34
|
+
const dueDateField = params.dueDateField ?? "journalItems.dueDate";
|
|
35
|
+
const contactField = params.contactField;
|
|
36
|
+
const accountQuery = { active: true };
|
|
37
|
+
if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
|
|
38
|
+
let targetAccountIds;
|
|
39
|
+
if (params.accountIds && params.accountIds.length > 0) targetAccountIds = params.accountIds;
|
|
40
|
+
else {
|
|
41
|
+
const allAccounts = await AccountModel.find(accountQuery).lean();
|
|
42
|
+
const categoryPrefix = params.type === "receivable" ? "Balance Sheet-Asset" : "Balance Sheet-Liability";
|
|
43
|
+
targetAccountIds = allAccounts.filter((a) => {
|
|
44
|
+
const at = country.getAccountType(a.accountTypeCode);
|
|
45
|
+
return at && !at.isGroup && at.category.startsWith(categoryPrefix);
|
|
46
|
+
}).map((a) => a._id);
|
|
47
|
+
}
|
|
48
|
+
if (targetAccountIds.length === 0) return {
|
|
49
|
+
metadata: {
|
|
50
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
51
|
+
asOfDate: asOfDate.toISOString().split("T")[0],
|
|
52
|
+
type: params.type
|
|
53
|
+
},
|
|
54
|
+
bucketLabels,
|
|
55
|
+
rows: [],
|
|
56
|
+
totals: Object.fromEntries(bucketLabels.map((l) => [l, 0])),
|
|
57
|
+
grandTotal: 0
|
|
58
|
+
};
|
|
59
|
+
const allAccounts = await AccountModel.find(accountQuery).lean();
|
|
60
|
+
const accountLookup = new Map(allAccounts.map((a) => [String(a._id), a]));
|
|
61
|
+
const baseMatch = {
|
|
62
|
+
state: "posted",
|
|
63
|
+
date: { $lte: asOfDate }
|
|
64
|
+
};
|
|
65
|
+
if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
|
|
66
|
+
asOfDate.getTime();
|
|
67
|
+
const groupId = { account: "$journalItems.account" };
|
|
68
|
+
if (contactField) groupId.contact = `$${contactField}`;
|
|
69
|
+
const bucketBranches = buckets.map((b) => {
|
|
70
|
+
return {
|
|
71
|
+
case: b.maxDays === Infinity ? { $gte: ["$daysPastDue", b.minDays] } : { $and: [{ $gte: ["$daysPastDue", b.minDays] }, { $lt: ["$daysPastDue", b.maxDays] }] },
|
|
72
|
+
then: b.label
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
const pipeline = [
|
|
76
|
+
{ $match: baseMatch },
|
|
77
|
+
{ $unwind: "$journalItems" },
|
|
78
|
+
{ $match: { "journalItems.account": { $in: targetAccountIds } } },
|
|
79
|
+
{ $addFields: { daysPastDue: { $floor: { $divide: [{ $subtract: [asOfDate, { $ifNull: [`$${dueDateField}`, asOfDate] }] }, 1e3 * 60 * 60 * 24] } } } },
|
|
80
|
+
{ $addFields: { daysPastDue: { $max: ["$daysPastDue", 0] } } },
|
|
81
|
+
{ $addFields: { bucketLabel: { $switch: {
|
|
82
|
+
branches: bucketBranches,
|
|
83
|
+
default: bucketLabels[bucketLabels.length - 1]
|
|
84
|
+
} } } },
|
|
85
|
+
{ $addFields: { netAmount: params.type === "receivable" ? { $subtract: ["$journalItems.debit", "$journalItems.credit"] } : { $subtract: ["$journalItems.credit", "$journalItems.debit"] } } },
|
|
86
|
+
{ $group: {
|
|
87
|
+
_id: {
|
|
88
|
+
...groupId,
|
|
89
|
+
bucket: "$bucketLabel"
|
|
90
|
+
},
|
|
91
|
+
amount: { $sum: "$netAmount" }
|
|
92
|
+
} }
|
|
93
|
+
];
|
|
94
|
+
const results = await JournalEntryModel.aggregate(pipeline);
|
|
95
|
+
const rowKey = (accountId, contactId) => contactField ? `${String(accountId)}::${String(contactId ?? "")}` : String(accountId);
|
|
96
|
+
const rowMap = /* @__PURE__ */ new Map();
|
|
97
|
+
for (const r of results) {
|
|
98
|
+
const key = rowKey(r._id.account, r._id.contact);
|
|
99
|
+
if (!rowMap.has(key)) {
|
|
100
|
+
const acc = accountLookup.get(String(r._id.account));
|
|
101
|
+
rowMap.set(key, {
|
|
102
|
+
accountId: r._id.account,
|
|
103
|
+
accountName: acc?.name ?? "",
|
|
104
|
+
accountCode: acc?.accountNumber ?? "",
|
|
105
|
+
...contactField ? { contactId: r._id.contact } : {},
|
|
106
|
+
total: 0,
|
|
107
|
+
buckets: Object.fromEntries(bucketLabels.map((l) => [l, 0]))
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const row = rowMap.get(key);
|
|
111
|
+
if (row.buckets[r._id.bucket] !== void 0) row.buckets[r._id.bucket] += r.amount;
|
|
112
|
+
row.total += r.amount;
|
|
113
|
+
}
|
|
114
|
+
const rows = Array.from(rowMap.values()).sort((a, b) => a.accountCode.localeCompare(b.accountCode, void 0, { numeric: true }));
|
|
115
|
+
const totals = Object.fromEntries(bucketLabels.map((l) => [l, 0]));
|
|
116
|
+
let grandTotal = 0;
|
|
117
|
+
for (const row of rows) {
|
|
118
|
+
for (const label of bucketLabels) totals[label] += row.buckets[label];
|
|
119
|
+
grandTotal += row.total;
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
metadata: {
|
|
123
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
124
|
+
asOfDate: asOfDate.toISOString().split("T")[0],
|
|
125
|
+
type: params.type
|
|
126
|
+
},
|
|
127
|
+
bucketLabels,
|
|
128
|
+
rows,
|
|
129
|
+
totals,
|
|
130
|
+
grandTotal
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region src/utils/account-helpers.ts
|
|
135
|
+
/**
|
|
136
|
+
* Account Helper Utilities
|
|
137
|
+
*/
|
|
138
|
+
/**
|
|
139
|
+
* Check if an account type is a virtual tax sub-account.
|
|
140
|
+
* Returns true if the account's parent has `isVirtualTotal: true`.
|
|
141
|
+
* Works for any country pack — no code format assumptions.
|
|
142
|
+
*/
|
|
143
|
+
function isVirtualTaxAccount(accountType, accountMap) {
|
|
144
|
+
if (!accountType.parentCode) return false;
|
|
145
|
+
return accountMap.get(accountType.parentCode)?.isVirtualTotal === true;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Calculate a total from sub-accounts using the totalAccountTypes formula.
|
|
149
|
+
* @param formula - Array of { account, operation } instructions
|
|
150
|
+
* @param balanceMap - Map of account code → balance
|
|
151
|
+
*/
|
|
152
|
+
function calculateTotal(formula, balanceMap) {
|
|
153
|
+
let total = 0;
|
|
154
|
+
for (const item of formula) {
|
|
155
|
+
const balance = balanceMap.get(item.account) ?? 0;
|
|
156
|
+
total += item.operation === "+" ? balance : -balance;
|
|
157
|
+
}
|
|
158
|
+
return total;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Compute the ending balance for an account given its debits and credits.
|
|
162
|
+
* Uses the account's main type to determine normal balance direction.
|
|
163
|
+
*
|
|
164
|
+
* Assets & Expenses: debit - credit
|
|
165
|
+
* Liabilities, Equity & Income: credit - debit
|
|
166
|
+
*/
|
|
167
|
+
function computeEndingBalance(category, totalDebit, totalCredit) {
|
|
168
|
+
const mainType = extractMainType(category);
|
|
169
|
+
if (mainType === "Asset" || mainType === "Expense") return totalDebit - totalCredit;
|
|
170
|
+
return totalCredit - totalDebit;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Build a lookup map from an array of account types.
|
|
174
|
+
*/
|
|
175
|
+
function buildAccountTypeMap(accountTypes) {
|
|
176
|
+
const map = /* @__PURE__ */ new Map();
|
|
177
|
+
for (const at of accountTypes) map.set(at.code, at);
|
|
178
|
+
return map;
|
|
179
|
+
}
|
|
180
|
+
//#endregion
|
|
4
181
|
//#region src/utils/date-range.ts
|
|
5
182
|
/**
|
|
6
183
|
* Compute start/end dates from a date option + value.
|
|
7
184
|
*
|
|
185
|
+
* @throws {Error} If value is null/undefined/invalid for the given option
|
|
186
|
+
*
|
|
8
187
|
* Examples:
|
|
9
188
|
* getDateRange('month', '2025-03') → Mar 1 – Mar 31
|
|
10
189
|
* getDateRange('quarter', { quarter: 2, year: 2025 }) → Apr 1 – Jun 30
|
|
@@ -12,6 +191,7 @@ import { i as extractMainType } from "./categories-CclX7Q94.mjs";
|
|
|
12
191
|
* getDateRange('custom', { startDate, endDate })
|
|
13
192
|
*/
|
|
14
193
|
function getDateRange(option, value) {
|
|
194
|
+
if (value == null && (option === "month" || option === "quarter" || option === "year" || option === "custom")) throw new Error(`dateValue is required for dateOption "${option}"`);
|
|
15
195
|
switch (option) {
|
|
16
196
|
case "month": {
|
|
17
197
|
let year;
|
|
@@ -22,16 +202,21 @@ function getDateRange(option, value) {
|
|
|
22
202
|
month = parseInt(match[2], 10) - 1;
|
|
23
203
|
} else {
|
|
24
204
|
const date = new Date(value);
|
|
205
|
+
if (Number.isNaN(date.getTime())) throw new Error(`Invalid month value: ${String(value)}`);
|
|
25
206
|
year = date.getFullYear();
|
|
26
207
|
month = date.getMonth();
|
|
27
208
|
}
|
|
209
|
+
if (year < 1900 || year > 9999) throw new Error(`Year ${year} is out of valid range (1900–9999)`);
|
|
28
210
|
return {
|
|
29
211
|
startDate: new Date(year, month, 1),
|
|
30
212
|
endDate: new Date(year, month + 1, 0, 23, 59, 59, 999)
|
|
31
213
|
};
|
|
32
214
|
}
|
|
33
215
|
case "quarter": {
|
|
216
|
+
if (typeof value !== "object" || value === null) throw new Error("Quarter dateValue must be an object with { quarter, year }");
|
|
34
217
|
const { quarter, year } = value;
|
|
218
|
+
if (!Number.isInteger(quarter) || quarter < 1 || quarter > 4) throw new Error(`Invalid quarter: ${quarter}. Must be 1–4.`);
|
|
219
|
+
if (!Number.isInteger(year) || year < 1900 || year > 9999) throw new Error(`Invalid year: ${year}. Must be 1900–9999.`);
|
|
35
220
|
const startMonth = (quarter - 1) * 3;
|
|
36
221
|
return {
|
|
37
222
|
startDate: new Date(year, startMonth, 1),
|
|
@@ -40,17 +225,23 @@ function getDateRange(option, value) {
|
|
|
40
225
|
}
|
|
41
226
|
case "year": {
|
|
42
227
|
const year = typeof value === "number" ? value : parseInt(String(value), 10);
|
|
228
|
+
if (Number.isNaN(year) || year < 1900 || year > 9999) throw new Error(`Invalid year: ${String(value)}. Must be a number between 1900–9999.`);
|
|
43
229
|
return {
|
|
44
230
|
startDate: new Date(year, 0, 1),
|
|
45
231
|
endDate: new Date(year, 11, 31, 23, 59, 59, 999)
|
|
46
232
|
};
|
|
47
233
|
}
|
|
48
234
|
case "custom": {
|
|
49
|
-
|
|
50
|
-
const
|
|
235
|
+
if (typeof value !== "object" || value === null) throw new Error("Custom dateValue must be an object with { startDate, endDate }");
|
|
236
|
+
const { startDate: rawStart, endDate: rawEnd } = value;
|
|
237
|
+
if (!rawStart || !rawEnd) throw new Error("Custom date range requires both startDate and endDate");
|
|
238
|
+
const start = new Date(rawStart);
|
|
239
|
+
const end = new Date(rawEnd);
|
|
240
|
+
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) throw new Error("Custom date range contains invalid dates");
|
|
241
|
+
if (start > end) throw new Error("startDate must be before endDate");
|
|
51
242
|
if (end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0 && end.getMilliseconds() === 0) end.setHours(23, 59, 59, 999);
|
|
52
243
|
return {
|
|
53
|
-
startDate:
|
|
244
|
+
startDate: start,
|
|
54
245
|
endDate: end
|
|
55
246
|
};
|
|
56
247
|
}
|
|
@@ -107,154 +298,9 @@ function buildItemFilters(filters) {
|
|
|
107
298
|
return result;
|
|
108
299
|
}
|
|
109
300
|
//#endregion
|
|
110
|
-
//#region src/reports/trial-balance.ts
|
|
111
|
-
async function generateTrialBalance(opts, params) {
|
|
112
|
-
const { AccountModel, JournalEntryModel, country, orgField, fiscalYearStartMonth = 1 } = opts;
|
|
113
|
-
requireOrgScope(orgField, params.organizationId);
|
|
114
|
-
const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
|
|
115
|
-
const fiscalYearStart = getFiscalYearStart(startDate, fiscalYearStartMonth);
|
|
116
|
-
const itemFilters = buildItemFilters(params.filters);
|
|
117
|
-
const accountQuery = { active: true };
|
|
118
|
-
if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
|
|
119
|
-
const allAccounts = await AccountModel.find(accountQuery).lean();
|
|
120
|
-
const bsIds = [];
|
|
121
|
-
const isIds = [];
|
|
122
|
-
for (const acc of allAccounts) {
|
|
123
|
-
const at = country.getAccountType(acc.accountTypeCode);
|
|
124
|
-
if (!at || at.isGroup) continue;
|
|
125
|
-
if (at.category.startsWith("Balance Sheet")) bsIds.push(acc._id);
|
|
126
|
-
else if (at.category.startsWith("Income Statement")) isIds.push(acc._id);
|
|
127
|
-
}
|
|
128
|
-
const baseMatch = { state: "posted" };
|
|
129
|
-
if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
|
|
130
|
-
const accountFilter = params.accountId ? { "journalItems.account": params.accountId } : {};
|
|
131
|
-
const buildPipeline = (ids, dateFrom, dateTo) => [
|
|
132
|
-
{ $match: {
|
|
133
|
-
...baseMatch,
|
|
134
|
-
date: {
|
|
135
|
-
$gte: dateFrom,
|
|
136
|
-
$lt: dateTo
|
|
137
|
-
}
|
|
138
|
-
} },
|
|
139
|
-
{ $unwind: "$journalItems" },
|
|
140
|
-
{ $match: {
|
|
141
|
-
"journalItems.account": { $in: ids },
|
|
142
|
-
...accountFilter,
|
|
143
|
-
...itemFilters
|
|
144
|
-
} },
|
|
145
|
-
{ $group: {
|
|
146
|
-
_id: "$journalItems.account",
|
|
147
|
-
d: { $sum: "$journalItems.debit" },
|
|
148
|
-
c: { $sum: "$journalItems.credit" }
|
|
149
|
-
} }
|
|
150
|
-
];
|
|
151
|
-
const [bsInitial, isInitial, current] = await Promise.all([
|
|
152
|
-
bsIds.length ? JournalEntryModel.aggregate(buildPipeline(bsIds, /* @__PURE__ */ new Date(0), startDate)) : [],
|
|
153
|
-
isIds.length ? JournalEntryModel.aggregate(buildPipeline(isIds, fiscalYearStart, startDate)) : [],
|
|
154
|
-
JournalEntryModel.aggregate(buildPipeline([...bsIds, ...isIds], startDate, new Date(endDate.getTime() + 1)))
|
|
155
|
-
]);
|
|
156
|
-
const map = /* @__PURE__ */ new Map();
|
|
157
|
-
for (const r of [...bsInitial, ...isInitial]) {
|
|
158
|
-
const key = String(r._id);
|
|
159
|
-
map.set(key, {
|
|
160
|
-
iD: r.d,
|
|
161
|
-
iC: r.c,
|
|
162
|
-
cD: 0,
|
|
163
|
-
cC: 0
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
for (const r of current) {
|
|
167
|
-
const key = String(r._id);
|
|
168
|
-
const existing = map.get(key) ?? {
|
|
169
|
-
iD: 0,
|
|
170
|
-
iC: 0,
|
|
171
|
-
cD: 0,
|
|
172
|
-
cC: 0
|
|
173
|
-
};
|
|
174
|
-
existing.cD = r.d;
|
|
175
|
-
existing.cC = r.c;
|
|
176
|
-
map.set(key, existing);
|
|
177
|
-
}
|
|
178
|
-
const accountLookup = new Map(allAccounts.map((a) => [String(a._id), a]));
|
|
179
|
-
const rows = [];
|
|
180
|
-
for (const [id, bal] of map) {
|
|
181
|
-
const acc = accountLookup.get(id);
|
|
182
|
-
const net = bal.iD + bal.cD - (bal.iC + bal.cC);
|
|
183
|
-
rows.push({
|
|
184
|
-
account: acc ?? id,
|
|
185
|
-
initial: {
|
|
186
|
-
debit: bal.iD,
|
|
187
|
-
credit: bal.iC
|
|
188
|
-
},
|
|
189
|
-
current: {
|
|
190
|
-
debit: bal.cD,
|
|
191
|
-
credit: bal.cC
|
|
192
|
-
},
|
|
193
|
-
ending: net >= 0 ? {
|
|
194
|
-
debit: net,
|
|
195
|
-
credit: 0
|
|
196
|
-
} : {
|
|
197
|
-
debit: 0,
|
|
198
|
-
credit: Math.abs(net)
|
|
199
|
-
}
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
return {
|
|
203
|
-
rows,
|
|
204
|
-
period: {
|
|
205
|
-
startDate,
|
|
206
|
-
endDate
|
|
207
|
-
}
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
//#endregion
|
|
211
|
-
//#region src/utils/account-helpers.ts
|
|
212
|
-
/**
|
|
213
|
-
* Check if an account type is a virtual tax sub-account.
|
|
214
|
-
* Returns true if the account's parent has `isVirtualTotal: true`.
|
|
215
|
-
* Works for any country pack — no code format assumptions.
|
|
216
|
-
*/
|
|
217
|
-
function isVirtualTaxAccount(accountType, accountMap) {
|
|
218
|
-
if (!accountType.parentCode) return false;
|
|
219
|
-
return accountMap.get(accountType.parentCode)?.isVirtualTotal === true;
|
|
220
|
-
}
|
|
221
|
-
/**
|
|
222
|
-
* Calculate a total from sub-accounts using the totalAccountTypes formula.
|
|
223
|
-
* @param formula - Array of { account, operation } instructions
|
|
224
|
-
* @param balanceMap - Map of account code → balance
|
|
225
|
-
*/
|
|
226
|
-
function calculateTotal(formula, balanceMap) {
|
|
227
|
-
let total = 0;
|
|
228
|
-
for (const item of formula) {
|
|
229
|
-
const balance = balanceMap.get(item.account) ?? 0;
|
|
230
|
-
total += item.operation === "+" ? balance : -balance;
|
|
231
|
-
}
|
|
232
|
-
return total;
|
|
233
|
-
}
|
|
234
|
-
/**
|
|
235
|
-
* Compute the ending balance for an account given its debits and credits.
|
|
236
|
-
* Uses the account's main type to determine normal balance direction.
|
|
237
|
-
*
|
|
238
|
-
* Assets & Expenses: debit - credit
|
|
239
|
-
* Liabilities, Equity & Income: credit - debit
|
|
240
|
-
*/
|
|
241
|
-
function computeEndingBalance(category, totalDebit, totalCredit) {
|
|
242
|
-
const mainType = extractMainType(category);
|
|
243
|
-
if (mainType === "Asset" || mainType === "Expense") return totalDebit - totalCredit;
|
|
244
|
-
return totalCredit - totalDebit;
|
|
245
|
-
}
|
|
246
|
-
/**
|
|
247
|
-
* Build a lookup map from an array of account types.
|
|
248
|
-
*/
|
|
249
|
-
function buildAccountTypeMap(accountTypes) {
|
|
250
|
-
const map = /* @__PURE__ */ new Map();
|
|
251
|
-
for (const at of accountTypes) map.set(at.code, at);
|
|
252
|
-
return map;
|
|
253
|
-
}
|
|
254
|
-
//#endregion
|
|
255
301
|
//#region src/reports/balance-sheet.ts
|
|
256
302
|
async function generateBalanceSheet(opts, params) {
|
|
257
|
-
const { AccountModel, JournalEntryModel, country, orgField, fiscalYearStartMonth = 1,
|
|
303
|
+
const { AccountModel, JournalEntryModel, country, orgField, fiscalYearStartMonth = 1, retainedEarningsAccountCode = country.retainedEarningsAccountCode, retainedEarningsDisplayCode = country.retainedEarningsDisplayCode ?? retainedEarningsAccountCode, currentYearEarningsCode = country.currentYearEarningsCode ?? "3680" } = opts;
|
|
258
304
|
requireOrgScope(orgField, params.organizationId);
|
|
259
305
|
const { endDate } = getDateRange(params.dateOption, params.dateValue);
|
|
260
306
|
const fiscalYearStart = getFiscalYearStart(endDate, fiscalYearStartMonth);
|
|
@@ -266,13 +312,15 @@ async function generateBalanceSheet(opts, params) {
|
|
|
266
312
|
const at = country.getAccountType(a.accountTypeCode);
|
|
267
313
|
return at && !at.isGroup && at.category.startsWith("Balance Sheet");
|
|
268
314
|
}).map((a) => a._id);
|
|
315
|
+
const reAccountIds = retainedEarningsAccountCode ? allAccounts.filter((a) => a.accountTypeCode === retainedEarningsAccountCode).map((a) => a._id) : [];
|
|
316
|
+
const reAccountIdSet = new Set(reAccountIds.map(String));
|
|
269
317
|
const isIds = allAccounts.filter((a) => {
|
|
270
318
|
const at = country.getAccountType(a.accountTypeCode);
|
|
271
319
|
return at && !at.isGroup && !at.isTotal && at.category.startsWith("Income Statement");
|
|
272
320
|
}).map((a) => a._id);
|
|
273
321
|
const baseMatch = { state: "posted" };
|
|
274
322
|
if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
|
|
275
|
-
const [bsResults, netIncomeResults, priorRetainedResults] = await Promise.all([
|
|
323
|
+
const [bsResults, netIncomeResults, priorRetainedResults, reAccountResults] = await Promise.all([
|
|
276
324
|
JournalEntryModel.aggregate([
|
|
277
325
|
{ $match: {
|
|
278
326
|
...baseMatch,
|
|
@@ -323,10 +371,27 @@ async function generateBalanceSheet(opts, params) {
|
|
|
323
371
|
d: { $sum: "$journalItems.debit" },
|
|
324
372
|
c: { $sum: "$journalItems.credit" }
|
|
325
373
|
} }
|
|
326
|
-
])
|
|
374
|
+
]),
|
|
375
|
+
...reAccountIds.length > 0 ? [JournalEntryModel.aggregate([
|
|
376
|
+
{ $match: {
|
|
377
|
+
...baseMatch,
|
|
378
|
+
date: { $lte: endDate }
|
|
379
|
+
} },
|
|
380
|
+
{ $unwind: "$journalItems" },
|
|
381
|
+
{ $match: {
|
|
382
|
+
"journalItems.account": { $in: reAccountIds },
|
|
383
|
+
...itemFilters
|
|
384
|
+
} },
|
|
385
|
+
{ $group: {
|
|
386
|
+
_id: null,
|
|
387
|
+
d: { $sum: "$journalItems.debit" },
|
|
388
|
+
c: { $sum: "$journalItems.credit" }
|
|
389
|
+
} }
|
|
390
|
+
])] : [Promise.resolve([])]
|
|
327
391
|
]);
|
|
328
392
|
const netIncome = netIncomeResults.length > 0 ? netIncomeResults[0].c - netIncomeResults[0].d : 0;
|
|
329
|
-
const
|
|
393
|
+
const priorUnclosedPL = priorRetainedResults.length > 0 ? priorRetainedResults[0].c - priorRetainedResults[0].d : 0;
|
|
394
|
+
const priorRetained = (reAccountResults.length > 0 ? reAccountResults[0].c - reAccountResults[0].d : 0) + priorUnclosedPL;
|
|
330
395
|
const accountMap = new Map(allAccounts.map((a) => [String(a._id), a]));
|
|
331
396
|
const accountTypeMap = buildAccountTypeMap(country.accountTypes);
|
|
332
397
|
const balanceMap = /* @__PURE__ */ new Map();
|
|
@@ -354,6 +419,7 @@ async function generateBalanceSheet(opts, params) {
|
|
|
354
419
|
for (const r of bsResults) {
|
|
355
420
|
const acc = accountMap.get(String(r._id));
|
|
356
421
|
if (!acc) continue;
|
|
422
|
+
if (reAccountIdSet.has(String(r._id))) continue;
|
|
357
423
|
const at = country.getAccountType(acc.accountTypeCode);
|
|
358
424
|
if (!at) continue;
|
|
359
425
|
const mainType = extractMainType(at.category) ?? "Asset";
|
|
@@ -382,7 +448,7 @@ async function generateBalanceSheet(opts, params) {
|
|
|
382
448
|
accounts: [{
|
|
383
449
|
id: "prior-retained",
|
|
384
450
|
name: "Previous Years Retained Earnings",
|
|
385
|
-
code:
|
|
451
|
+
code: retainedEarningsDisplayCode ?? retainedEarningsAccountCode ?? "",
|
|
386
452
|
balance: priorRetained
|
|
387
453
|
}, {
|
|
388
454
|
id: "current-year",
|
|
@@ -397,13 +463,24 @@ async function generateBalanceSheet(opts, params) {
|
|
|
397
463
|
groupsMap.Equity[reGroup.name].accounts.push(...reGroup.accounts);
|
|
398
464
|
groupsMap.Equity[reGroup.name].total += reGroup.total;
|
|
399
465
|
}
|
|
466
|
+
const sortAccountsInGroups = (groups) => {
|
|
467
|
+
for (const g of Object.values(groups)) g.accounts.sort((a, b) => (a.code ?? "").localeCompare(b.code ?? "", void 0, { numeric: true }));
|
|
468
|
+
};
|
|
469
|
+
sortAccountsInGroups(groupsMap.Asset);
|
|
470
|
+
sortAccountsInGroups(groupsMap.Liability);
|
|
471
|
+
sortAccountsInGroups(groupsMap.Equity);
|
|
472
|
+
const sortGroupsByCode = (groups) => groups.sort((a, b) => {
|
|
473
|
+
const codeA = a.accounts[0]?.code ?? "";
|
|
474
|
+
const codeB = b.accounts[0]?.code ?? "";
|
|
475
|
+
return codeA.localeCompare(codeB, void 0, { numeric: true });
|
|
476
|
+
});
|
|
400
477
|
const pruneGroups = (groups) => Object.values(groups).map((g) => ({
|
|
401
478
|
...g,
|
|
402
479
|
accounts: g.accounts.filter((a) => a.balance !== 0 || a.isTotal || a.isCalculated)
|
|
403
480
|
})).filter((g) => g.accounts.length > 0 || g.total !== 0);
|
|
404
|
-
assets.groups = pruneGroups(groupsMap.Asset);
|
|
405
|
-
liabilities.groups = pruneGroups(groupsMap.Liability);
|
|
406
|
-
equity.groups = Object.values(groupsMap.Equity);
|
|
481
|
+
assets.groups = sortGroupsByCode(pruneGroups(groupsMap.Asset));
|
|
482
|
+
liabilities.groups = sortGroupsByCode(pruneGroups(groupsMap.Liability));
|
|
483
|
+
equity.groups = sortGroupsByCode(Object.values(groupsMap.Equity));
|
|
407
484
|
assets.total = assets.groups.reduce((s, g) => s + g.total, 0);
|
|
408
485
|
liabilities.total = liabilities.groups.reduce((s, g) => s + g.total, 0);
|
|
409
486
|
equity.total = equity.groups.reduce((s, g) => s + g.total, 0);
|
|
@@ -433,19 +510,41 @@ async function generateBalanceSheet(opts, params) {
|
|
|
433
510
|
};
|
|
434
511
|
}
|
|
435
512
|
//#endregion
|
|
436
|
-
//#region src/reports/
|
|
437
|
-
async function
|
|
438
|
-
const { AccountModel, JournalEntryModel, country, orgField } = opts;
|
|
513
|
+
//#region src/reports/budget-vs-actual.ts
|
|
514
|
+
async function generateBudgetVsActual(opts, params) {
|
|
515
|
+
const { AccountModel, JournalEntryModel, BudgetModel, country, orgField } = opts;
|
|
439
516
|
requireOrgScope(orgField, params.organizationId);
|
|
440
517
|
const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
518
|
+
const budgetQuery = {
|
|
519
|
+
periodStart: { $lte: endDate },
|
|
520
|
+
periodEnd: { $gte: startDate }
|
|
521
|
+
};
|
|
522
|
+
if (orgField && params.organizationId) budgetQuery[orgField] = params.organizationId;
|
|
523
|
+
if (params.accountIds && params.accountIds.length > 0) budgetQuery.account = { $in: params.accountIds };
|
|
524
|
+
const budgets = await BudgetModel.find(budgetQuery).lean();
|
|
525
|
+
if (budgets.length === 0) return {
|
|
526
|
+
metadata: {
|
|
527
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
528
|
+
periodStart: startDate.toISOString(),
|
|
529
|
+
periodEnd: endDate.toISOString()
|
|
530
|
+
},
|
|
531
|
+
rows: [],
|
|
532
|
+
summary: {
|
|
533
|
+
totalBudget: 0,
|
|
534
|
+
totalActual: 0,
|
|
535
|
+
totalVariance: 0
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
const budgetByAccount = /* @__PURE__ */ new Map();
|
|
539
|
+
for (const b of budgets) {
|
|
540
|
+
const key = String(b.account);
|
|
541
|
+
budgetByAccount.set(key, (budgetByAccount.get(key) ?? 0) + b.amount);
|
|
542
|
+
}
|
|
543
|
+
const accountIds = [...budgetByAccount.keys()];
|
|
544
|
+
const accountQuery = { _id: { $in: accountIds } };
|
|
545
|
+
if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
|
|
546
|
+
const accounts = await AccountModel.find(accountQuery).lean();
|
|
547
|
+
const accountMap = new Map(accounts.map((a) => [String(a._id), a]));
|
|
449
548
|
const baseMatch = {
|
|
450
549
|
state: "posted",
|
|
451
550
|
date: {
|
|
@@ -454,11 +553,105 @@ async function generateIncomeStatement(opts, params) {
|
|
|
454
553
|
}
|
|
455
554
|
};
|
|
456
555
|
if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
|
|
457
|
-
const
|
|
556
|
+
const pipeline = [
|
|
557
|
+
{ $match: baseMatch },
|
|
558
|
+
{ $unwind: "$journalItems" },
|
|
559
|
+
{ $match: { "journalItems.account": { $in: accountIds.map((id) => new mongoose.Types.ObjectId(id)) } } },
|
|
560
|
+
{ $group: {
|
|
561
|
+
_id: "$journalItems.account",
|
|
562
|
+
totalDebit: { $sum: "$journalItems.debit" },
|
|
563
|
+
totalCredit: { $sum: "$journalItems.credit" }
|
|
564
|
+
} }
|
|
565
|
+
];
|
|
566
|
+
const actuals = await JournalEntryModel.aggregate(pipeline);
|
|
567
|
+
const actualByAccount = /* @__PURE__ */ new Map();
|
|
568
|
+
for (const a of actuals) actualByAccount.set(String(a._id), {
|
|
569
|
+
debit: a.totalDebit,
|
|
570
|
+
credit: a.totalCredit
|
|
571
|
+
});
|
|
572
|
+
const rows = [];
|
|
573
|
+
for (const [accountId, budgetAmount] of budgetByAccount) {
|
|
574
|
+
const acc = accountMap.get(accountId);
|
|
575
|
+
if (!acc) continue;
|
|
576
|
+
const at = country.getAccountType(acc.accountTypeCode);
|
|
577
|
+
if (!at || at.isGroup) continue;
|
|
578
|
+
const actual = actualByAccount.get(accountId) ?? {
|
|
579
|
+
debit: 0,
|
|
580
|
+
credit: 0
|
|
581
|
+
};
|
|
582
|
+
const mainType = extractMainType(at.category);
|
|
583
|
+
let actualAmount;
|
|
584
|
+
if (mainType === "Income") actualAmount = actual.credit - actual.debit;
|
|
585
|
+
else if (mainType === "Expense") actualAmount = actual.debit - actual.credit;
|
|
586
|
+
else if (mainType === "Asset") actualAmount = actual.debit - actual.credit;
|
|
587
|
+
else actualAmount = actual.credit - actual.debit;
|
|
588
|
+
const variance = actualAmount - budgetAmount;
|
|
589
|
+
const variancePercent = budgetAmount !== 0 ? Math.round(variance / budgetAmount * 1e4) / 100 : 0;
|
|
590
|
+
rows.push({
|
|
591
|
+
accountId: acc._id,
|
|
592
|
+
accountName: acc.name,
|
|
593
|
+
accountCode: acc.accountNumber,
|
|
594
|
+
category: at.category,
|
|
595
|
+
budgetAmount,
|
|
596
|
+
actualAmount,
|
|
597
|
+
variance,
|
|
598
|
+
variancePercent
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
rows.sort((a, b) => a.accountCode.localeCompare(b.accountCode));
|
|
602
|
+
const summary = {
|
|
603
|
+
totalBudget: rows.reduce((s, r) => s + r.budgetAmount, 0),
|
|
604
|
+
totalActual: rows.reduce((s, r) => s + r.actualAmount, 0),
|
|
605
|
+
totalVariance: rows.reduce((s, r) => s + r.variance, 0)
|
|
606
|
+
};
|
|
607
|
+
return {
|
|
608
|
+
metadata: {
|
|
609
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
610
|
+
periodStart: startDate.toISOString(),
|
|
611
|
+
periodEnd: endDate.toISOString()
|
|
612
|
+
},
|
|
613
|
+
rows,
|
|
614
|
+
summary
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
//#endregion
|
|
618
|
+
//#region src/reports/cash-flow.ts
|
|
619
|
+
async function generateCashFlow(opts, params) {
|
|
620
|
+
const { AccountModel, JournalEntryModel, country, orgField } = opts;
|
|
621
|
+
requireOrgScope(orgField, params.organizationId);
|
|
622
|
+
const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
|
|
623
|
+
const itemFilters = buildItemFilters(params.filters);
|
|
624
|
+
const q = { active: true };
|
|
625
|
+
if (orgField && params.organizationId) q[orgField] = params.organizationId;
|
|
626
|
+
const allAccounts = await AccountModel.find(q).lean();
|
|
627
|
+
const accountCfMap = /* @__PURE__ */ new Map();
|
|
628
|
+
const accountMap = new Map(allAccounts.map((a) => [String(a._id), a]));
|
|
629
|
+
const cfAccountIds = [];
|
|
630
|
+
for (const acc of allAccounts) {
|
|
631
|
+
const at = country.getAccountType(acc.accountTypeCode);
|
|
632
|
+
if (!at || at.isGroup || at.isTotal) continue;
|
|
633
|
+
const cf = at.cashFlowCategory;
|
|
634
|
+
if (!cf) continue;
|
|
635
|
+
const normalized = cf.charAt(0).toUpperCase() + cf.slice(1);
|
|
636
|
+
accountCfMap.set(String(acc._id), {
|
|
637
|
+
category: at.category,
|
|
638
|
+
cfCategory: normalized
|
|
639
|
+
});
|
|
640
|
+
cfAccountIds.push(acc._id);
|
|
641
|
+
}
|
|
642
|
+
const baseMatch = {
|
|
643
|
+
state: "posted",
|
|
644
|
+
date: {
|
|
645
|
+
$gte: startDate,
|
|
646
|
+
$lte: endDate
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
|
|
650
|
+
const results = cfAccountIds.length > 0 ? await JournalEntryModel.aggregate([
|
|
458
651
|
{ $match: baseMatch },
|
|
459
652
|
{ $unwind: "$journalItems" },
|
|
460
653
|
{ $match: {
|
|
461
|
-
"journalItems.account": { $in:
|
|
654
|
+
"journalItems.account": { $in: cfAccountIds },
|
|
462
655
|
...itemFilters
|
|
463
656
|
} },
|
|
464
657
|
{ $group: {
|
|
@@ -466,65 +659,38 @@ async function generateIncomeStatement(opts, params) {
|
|
|
466
659
|
d: { $sum: "$journalItems.debit" },
|
|
467
660
|
c: { $sum: "$journalItems.credit" }
|
|
468
661
|
} }
|
|
469
|
-
]);
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
662
|
+
]) : [];
|
|
663
|
+
const flows = {
|
|
664
|
+
Operating: {
|
|
665
|
+
total: 0,
|
|
666
|
+
accounts: []
|
|
667
|
+
},
|
|
668
|
+
Investing: {
|
|
669
|
+
total: 0,
|
|
670
|
+
accounts: []
|
|
671
|
+
},
|
|
672
|
+
Financing: {
|
|
673
|
+
total: 0,
|
|
674
|
+
accounts: []
|
|
480
675
|
}
|
|
481
|
-
return at.name;
|
|
482
676
|
};
|
|
483
677
|
for (const r of results) {
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
const
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
name: groupName,
|
|
495
|
-
total: 0,
|
|
496
|
-
accounts: []
|
|
497
|
-
};
|
|
498
|
-
groups[groupName].accounts.push({
|
|
499
|
-
id: acc._id,
|
|
500
|
-
name: acc.name ?? at.name,
|
|
501
|
-
code: acc.accountNumber ?? at.code,
|
|
502
|
-
balance: netAmount
|
|
678
|
+
const accIdStr = String(r._id);
|
|
679
|
+
const meta = accountCfMap.get(accIdStr);
|
|
680
|
+
if (!meta) continue;
|
|
681
|
+
const amount = computeEndingBalance(meta.category, r.d, r.c);
|
|
682
|
+
const acc = accountMap.get(accIdStr);
|
|
683
|
+
const at = country.getAccountType(acc?.accountTypeCode);
|
|
684
|
+
flows[meta.cfCategory].accounts.push({
|
|
685
|
+
name: acc?.name ?? at?.name ?? "",
|
|
686
|
+
code: acc?.accountNumber ?? at?.code ?? "",
|
|
687
|
+
amount
|
|
503
688
|
});
|
|
504
|
-
|
|
689
|
+
flows[meta.cfCategory].total += amount;
|
|
505
690
|
}
|
|
506
|
-
const
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
total: Object.values(revenueGroups).reduce((s, g) => s + g.total, 0),
|
|
510
|
-
groups: Object.values(revenueGroups)
|
|
511
|
-
};
|
|
512
|
-
const expenses = {
|
|
513
|
-
name: labels.expenses ?? "Expenses",
|
|
514
|
-
total: Object.values(expenseGroups).reduce((s, g) => s + g.total, 0),
|
|
515
|
-
groups: Object.values(expenseGroups)
|
|
516
|
-
};
|
|
517
|
-
const cogsCode = country.cogsGroupCode;
|
|
518
|
-
const isCogs = (name) => cogsCode ? name === cogsCode : name === "Cost of Sales" || name === "Cost of Goods Sold";
|
|
519
|
-
const costOfSales = expenses.groups.find((g) => isCogs(g.name))?.total ?? 0;
|
|
520
|
-
const grossProfit = revenue.total - costOfSales;
|
|
521
|
-
const operatingIncome = grossProfit - expenses.groups.filter((g) => !isCogs(g.name)).reduce((s, g) => s + g.total, 0);
|
|
522
|
-
const netIncome = revenue.total - expenses.total;
|
|
523
|
-
const periodDisplay = params.dateOption === "year" ? `For the year ended ${endDate.toLocaleDateString("en-US", {
|
|
524
|
-
year: "numeric",
|
|
525
|
-
month: "long",
|
|
526
|
-
day: "numeric"
|
|
527
|
-
})}` : `${startDate.toLocaleDateString("en-US", {
|
|
691
|
+
for (const section of Object.values(flows)) section.accounts.sort((a, b) => a.code.localeCompare(b.code, void 0, { numeric: true }));
|
|
692
|
+
const netCashFlow = flows.Operating.total + flows.Investing.total + flows.Financing.total;
|
|
693
|
+
const periodDisplay = `${startDate.toLocaleDateString("en-US", {
|
|
528
694
|
month: "short",
|
|
529
695
|
day: "numeric"
|
|
530
696
|
})} – ${endDate.toLocaleDateString("en-US", {
|
|
@@ -540,12 +706,116 @@ async function generateIncomeStatement(opts, params) {
|
|
|
540
706
|
periodEnd: endDate.toISOString().split("T")[0],
|
|
541
707
|
displayPeriod: periodDisplay
|
|
542
708
|
},
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
709
|
+
operating: flows.Operating,
|
|
710
|
+
investing: flows.Investing,
|
|
711
|
+
financing: flows.Financing,
|
|
712
|
+
netCashFlow
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
//#endregion
|
|
716
|
+
//#region src/reports/dimension-breakdown.ts
|
|
717
|
+
async function generateDimensionBreakdown(opts, params) {
|
|
718
|
+
const { AccountModel, JournalEntryModel, country, orgField } = opts;
|
|
719
|
+
requireOrgScope(orgField, params.organizationId);
|
|
720
|
+
const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
|
|
721
|
+
const itemFilters = buildItemFilters(params.filters);
|
|
722
|
+
const accountQuery = { active: true };
|
|
723
|
+
if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
|
|
724
|
+
const allAccounts = await AccountModel.find(accountQuery).lean();
|
|
725
|
+
const accountIds = allAccounts.filter((a) => {
|
|
726
|
+
const at = country.getAccountType(a.accountTypeCode);
|
|
727
|
+
if (!at || at.isGroup || at.isTotal) return false;
|
|
728
|
+
if (params.accountCategory && at.category !== params.accountCategory) return false;
|
|
729
|
+
return true;
|
|
730
|
+
}).map((a) => a._id);
|
|
731
|
+
if (accountIds.length === 0) return {
|
|
732
|
+
metadata: {
|
|
733
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
734
|
+
dimension: params.dimension,
|
|
735
|
+
periodStart: startDate.toISOString().split("T")[0],
|
|
736
|
+
periodEnd: endDate.toISOString().split("T")[0]
|
|
737
|
+
},
|
|
738
|
+
rows: [],
|
|
739
|
+
grandTotal: 0
|
|
740
|
+
};
|
|
741
|
+
const accountMap = new Map(allAccounts.map((a) => [String(a._id), a]));
|
|
742
|
+
const dimensionPath = `journalItems.${params.dimension}`;
|
|
743
|
+
const baseMatch = {
|
|
744
|
+
state: "posted",
|
|
745
|
+
date: {
|
|
746
|
+
$gte: startDate,
|
|
747
|
+
$lte: endDate
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
|
|
751
|
+
const pipeline = [
|
|
752
|
+
{ $match: baseMatch },
|
|
753
|
+
{ $unwind: "$journalItems" },
|
|
754
|
+
{ $match: {
|
|
755
|
+
"journalItems.account": { $in: accountIds },
|
|
756
|
+
...itemFilters
|
|
757
|
+
} },
|
|
758
|
+
{ $group: {
|
|
759
|
+
_id: {
|
|
760
|
+
dimension: `$${dimensionPath}`,
|
|
761
|
+
account: "$journalItems.account"
|
|
762
|
+
},
|
|
763
|
+
d: { $sum: "$journalItems.debit" },
|
|
764
|
+
c: { $sum: "$journalItems.credit" }
|
|
765
|
+
} }
|
|
766
|
+
];
|
|
767
|
+
const results = await JournalEntryModel.aggregate(pipeline);
|
|
768
|
+
const dimensionMap = /* @__PURE__ */ new Map();
|
|
769
|
+
for (const r of results) {
|
|
770
|
+
const dimKey = r._id.dimension == null ? "__null__" : String(r._id.dimension);
|
|
771
|
+
const accKey = String(r._id.account);
|
|
772
|
+
if (!dimensionMap.has(dimKey)) dimensionMap.set(dimKey, /* @__PURE__ */ new Map());
|
|
773
|
+
dimensionMap.get(dimKey)?.set(accKey, {
|
|
774
|
+
d: r.d,
|
|
775
|
+
c: r.c
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
const rows = [];
|
|
779
|
+
const sortedDimKeys = [...dimensionMap.keys()].sort((a, b) => {
|
|
780
|
+
if (a === "__null__") return 1;
|
|
781
|
+
if (b === "__null__") return -1;
|
|
782
|
+
return a.localeCompare(b);
|
|
783
|
+
});
|
|
784
|
+
for (const dimKey of sortedDimKeys) {
|
|
785
|
+
const accountBalances = dimensionMap.get(dimKey);
|
|
786
|
+
const dimensionValue = dimKey === "__null__" ? null : results.find((r) => String(r._id.dimension) === dimKey)?._id.dimension ?? null;
|
|
787
|
+
const accounts = [];
|
|
788
|
+
let total = 0;
|
|
789
|
+
for (const [accId, bal] of accountBalances) {
|
|
790
|
+
const acc = accountMap.get(accId);
|
|
791
|
+
if (!acc) continue;
|
|
792
|
+
const at = country.getAccountType(acc.accountTypeCode);
|
|
793
|
+
const balance = at && (at.category === "Income Statement-Income" || at.category === "Balance Sheet-Liability" || at.category === "Balance Sheet-Equity") ? bal.c - bal.d : bal.d - bal.c;
|
|
794
|
+
accounts.push({
|
|
795
|
+
id: acc._id,
|
|
796
|
+
name: acc.name ?? at?.name ?? "",
|
|
797
|
+
code: acc.accountNumber ?? at?.code ?? "",
|
|
798
|
+
balance
|
|
799
|
+
});
|
|
800
|
+
total += balance;
|
|
801
|
+
}
|
|
802
|
+
accounts.sort((a, b) => a.code.localeCompare(b.code, void 0, { numeric: true }));
|
|
803
|
+
rows.push({
|
|
804
|
+
dimensionValue,
|
|
805
|
+
accounts,
|
|
806
|
+
total
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
const grandTotal = rows.reduce((s, r) => s + r.total, 0);
|
|
810
|
+
return {
|
|
811
|
+
metadata: {
|
|
812
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
813
|
+
dimension: params.dimension,
|
|
814
|
+
periodStart: startDate.toISOString().split("T")[0],
|
|
815
|
+
periodEnd: endDate.toISOString().split("T")[0]
|
|
816
|
+
},
|
|
817
|
+
rows,
|
|
818
|
+
grandTotal
|
|
549
819
|
};
|
|
550
820
|
}
|
|
551
821
|
//#endregion
|
|
@@ -576,6 +846,11 @@ async function generateGeneralLedger(opts, params) {
|
|
|
576
846
|
endDate
|
|
577
847
|
}
|
|
578
848
|
};
|
|
849
|
+
filtered.sort((a, b) => {
|
|
850
|
+
const codeA = a.acc.accountNumber ?? a.at.code;
|
|
851
|
+
const codeB = b.acc.accountNumber ?? b.at.code;
|
|
852
|
+
return codeA.localeCompare(codeB, void 0, { numeric: true });
|
|
853
|
+
});
|
|
579
854
|
const bsAccountIds = [];
|
|
580
855
|
const isAccountIds = [];
|
|
581
856
|
const allAccountIds = [];
|
|
@@ -674,7 +949,26 @@ async function generateGeneralLedger(opts, params) {
|
|
|
674
949
|
closingBalance: runningBalance
|
|
675
950
|
});
|
|
676
951
|
}
|
|
952
|
+
const periodDisplay = params.dateOption === "year" ? `For the year ended ${endDate.toLocaleDateString("en-US", {
|
|
953
|
+
year: "numeric",
|
|
954
|
+
month: "long",
|
|
955
|
+
day: "numeric"
|
|
956
|
+
})}` : `${startDate.toLocaleDateString("en-US", {
|
|
957
|
+
month: "short",
|
|
958
|
+
day: "numeric"
|
|
959
|
+
})} – ${endDate.toLocaleDateString("en-US", {
|
|
960
|
+
year: "numeric",
|
|
961
|
+
month: "short",
|
|
962
|
+
day: "numeric"
|
|
963
|
+
})}`;
|
|
677
964
|
return {
|
|
965
|
+
metadata: {
|
|
966
|
+
businessName: params.businessName,
|
|
967
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
968
|
+
periodStart: startDate.toISOString().split("T")[0],
|
|
969
|
+
periodEnd: endDate.toISOString().split("T")[0],
|
|
970
|
+
displayPeriod: periodDisplay
|
|
971
|
+
},
|
|
678
972
|
accounts: glAccounts,
|
|
679
973
|
period: {
|
|
680
974
|
startDate,
|
|
@@ -683,8 +977,8 @@ async function generateGeneralLedger(opts, params) {
|
|
|
683
977
|
};
|
|
684
978
|
}
|
|
685
979
|
//#endregion
|
|
686
|
-
//#region src/reports/
|
|
687
|
-
async function
|
|
980
|
+
//#region src/reports/income-statement.ts
|
|
981
|
+
async function generateIncomeStatement(opts, params) {
|
|
688
982
|
const { AccountModel, JournalEntryModel, country, orgField } = opts;
|
|
689
983
|
requireOrgScope(orgField, params.organizationId);
|
|
690
984
|
const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
|
|
@@ -692,22 +986,11 @@ async function generateCashFlow(opts, params) {
|
|
|
692
986
|
const q = { active: true };
|
|
693
987
|
if (orgField && params.organizationId) q[orgField] = params.organizationId;
|
|
694
988
|
const allAccounts = await AccountModel.find(q).lean();
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
if (!at || at.isGroup || at.isTotal) continue;
|
|
701
|
-
const cf = at.cashFlowCategory;
|
|
702
|
-
if (!cf) continue;
|
|
703
|
-
const normalized = cf.charAt(0).toUpperCase() + cf.slice(1);
|
|
704
|
-
accountCfMap.set(String(acc._id), {
|
|
705
|
-
category: at.category,
|
|
706
|
-
cfCategory: normalized
|
|
707
|
-
});
|
|
708
|
-
cfAccountIds.push(acc._id);
|
|
709
|
-
}
|
|
710
|
-
const baseMatch = {
|
|
989
|
+
const isIds = allAccounts.filter((a) => {
|
|
990
|
+
const at = country.getAccountType(a.accountTypeCode);
|
|
991
|
+
return at && !at.isGroup && !at.isTotal && at.category.startsWith("Income Statement");
|
|
992
|
+
}).map((a) => a._id);
|
|
993
|
+
const baseMatch = {
|
|
711
994
|
state: "posted",
|
|
712
995
|
date: {
|
|
713
996
|
$gte: startDate,
|
|
@@ -715,11 +998,11 @@ async function generateCashFlow(opts, params) {
|
|
|
715
998
|
}
|
|
716
999
|
};
|
|
717
1000
|
if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
|
|
718
|
-
const results =
|
|
1001
|
+
const results = await JournalEntryModel.aggregate([
|
|
719
1002
|
{ $match: baseMatch },
|
|
720
1003
|
{ $unwind: "$journalItems" },
|
|
721
1004
|
{ $match: {
|
|
722
|
-
"journalItems.account": { $in:
|
|
1005
|
+
"journalItems.account": { $in: isIds },
|
|
723
1006
|
...itemFilters
|
|
724
1007
|
} },
|
|
725
1008
|
{ $group: {
|
|
@@ -727,37 +1010,399 @@ async function generateCashFlow(opts, params) {
|
|
|
727
1010
|
d: { $sum: "$journalItems.debit" },
|
|
728
1011
|
c: { $sum: "$journalItems.credit" }
|
|
729
1012
|
} }
|
|
730
|
-
])
|
|
731
|
-
const
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
1013
|
+
]);
|
|
1014
|
+
const accountMap = new Map(allAccounts.map((a) => [String(a._id), a]));
|
|
1015
|
+
const revenueGroups = {};
|
|
1016
|
+
const expenseGroups = {};
|
|
1017
|
+
const resolveGroupName = (at) => {
|
|
1018
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1019
|
+
let current = at.parentCode ? country.getAccountType(at.parentCode) : void 0;
|
|
1020
|
+
while (current && !visited.has(current.code)) {
|
|
1021
|
+
if (current.isGroup) return current.name;
|
|
1022
|
+
visited.add(current.code);
|
|
1023
|
+
current = current.parentCode ? country.getAccountType(current.parentCode) : void 0;
|
|
1024
|
+
}
|
|
1025
|
+
return at.name;
|
|
1026
|
+
};
|
|
1027
|
+
for (const r of results) {
|
|
1028
|
+
const acc = accountMap.get(String(r._id));
|
|
1029
|
+
if (!acc) continue;
|
|
1030
|
+
const at = country.getAccountType(acc.accountTypeCode);
|
|
1031
|
+
if (!at) continue;
|
|
1032
|
+
const mainType = extractMainType(at.category);
|
|
1033
|
+
const netAmount = mainType === "Income" ? r.c - r.d : r.d - r.c;
|
|
1034
|
+
if (netAmount === 0) continue;
|
|
1035
|
+
const groupName = resolveGroupName(at);
|
|
1036
|
+
const groups = mainType === "Income" ? revenueGroups : expenseGroups;
|
|
1037
|
+
if (!(groupName in groups)) groups[groupName] = {
|
|
1038
|
+
name: groupName,
|
|
737
1039
|
total: 0,
|
|
738
1040
|
accounts: []
|
|
1041
|
+
};
|
|
1042
|
+
groups[groupName].accounts.push({
|
|
1043
|
+
id: acc._id,
|
|
1044
|
+
name: acc.name ?? at.name,
|
|
1045
|
+
code: acc.accountNumber ?? at.code,
|
|
1046
|
+
balance: netAmount
|
|
1047
|
+
});
|
|
1048
|
+
groups[groupName].total += netAmount;
|
|
1049
|
+
}
|
|
1050
|
+
const sortGroups = (groups) => {
|
|
1051
|
+
const sorted = Object.values(groups);
|
|
1052
|
+
for (const g of sorted) g.accounts.sort((a, b) => (a.code ?? "").localeCompare(b.code ?? "", void 0, { numeric: true }));
|
|
1053
|
+
sorted.sort((a, b) => {
|
|
1054
|
+
const codeA = a.accounts[0]?.code ?? "";
|
|
1055
|
+
const codeB = b.accounts[0]?.code ?? "";
|
|
1056
|
+
return codeA.localeCompare(codeB, void 0, { numeric: true });
|
|
1057
|
+
});
|
|
1058
|
+
return sorted;
|
|
1059
|
+
};
|
|
1060
|
+
const labels = country.reportLabels ?? {};
|
|
1061
|
+
const revenue = {
|
|
1062
|
+
name: labels.revenue ?? "Revenue",
|
|
1063
|
+
total: Object.values(revenueGroups).reduce((s, g) => s + g.total, 0),
|
|
1064
|
+
groups: sortGroups(revenueGroups)
|
|
1065
|
+
};
|
|
1066
|
+
const expenses = {
|
|
1067
|
+
name: labels.expenses ?? "Expenses",
|
|
1068
|
+
total: Object.values(expenseGroups).reduce((s, g) => s + g.total, 0),
|
|
1069
|
+
groups: sortGroups(expenseGroups)
|
|
1070
|
+
};
|
|
1071
|
+
const cogsCode = country.cogsGroupCode;
|
|
1072
|
+
const isCogs = (name) => cogsCode ? name === cogsCode : name === "Cost of Sales" || name === "Cost of Goods Sold";
|
|
1073
|
+
const costOfSales = expenses.groups.find((g) => isCogs(g.name))?.total ?? 0;
|
|
1074
|
+
const grossProfit = revenue.total - costOfSales;
|
|
1075
|
+
const operatingIncome = grossProfit - expenses.groups.filter((g) => !isCogs(g.name)).reduce((s, g) => s + g.total, 0);
|
|
1076
|
+
const netIncome = revenue.total - expenses.total;
|
|
1077
|
+
const periodDisplay = params.dateOption === "year" ? `For the year ended ${endDate.toLocaleDateString("en-US", {
|
|
1078
|
+
year: "numeric",
|
|
1079
|
+
month: "long",
|
|
1080
|
+
day: "numeric"
|
|
1081
|
+
})}` : `${startDate.toLocaleDateString("en-US", {
|
|
1082
|
+
month: "short",
|
|
1083
|
+
day: "numeric"
|
|
1084
|
+
})} – ${endDate.toLocaleDateString("en-US", {
|
|
1085
|
+
year: "numeric",
|
|
1086
|
+
month: "short",
|
|
1087
|
+
day: "numeric"
|
|
1088
|
+
})}`;
|
|
1089
|
+
return {
|
|
1090
|
+
metadata: {
|
|
1091
|
+
businessName: params.businessName,
|
|
1092
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1093
|
+
periodStart: startDate.toISOString().split("T")[0],
|
|
1094
|
+
periodEnd: endDate.toISOString().split("T")[0],
|
|
1095
|
+
displayPeriod: periodDisplay
|
|
739
1096
|
},
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
1097
|
+
revenue,
|
|
1098
|
+
costOfSales,
|
|
1099
|
+
grossProfit,
|
|
1100
|
+
expenses,
|
|
1101
|
+
operatingIncome,
|
|
1102
|
+
netIncome
|
|
744
1103
|
};
|
|
1104
|
+
}
|
|
1105
|
+
//#endregion
|
|
1106
|
+
//#region src/utils/revaluation.ts
|
|
1107
|
+
/**
|
|
1108
|
+
* Compute revaluation results for a set of accounts at new exchange rates.
|
|
1109
|
+
*
|
|
1110
|
+
* For each account, finds the matching rate by currency, computes the
|
|
1111
|
+
* revalued base amount, and determines the gain/loss.
|
|
1112
|
+
* Accounts with zero gain/loss are excluded from the results.
|
|
1113
|
+
*
|
|
1114
|
+
* @param accounts - Foreign-currency account balances at historical rates
|
|
1115
|
+
* @param rates - New exchange rates to revalue against
|
|
1116
|
+
* @param baseCurrency - The functional/base currency code (accounts in this currency are skipped)
|
|
1117
|
+
*/
|
|
1118
|
+
function computeRevaluation(accounts, rates, baseCurrency) {
|
|
1119
|
+
const rateMap = new Map(rates.map((r) => [r.currency, r.rate]));
|
|
1120
|
+
const results = [];
|
|
1121
|
+
for (const acct of accounts) {
|
|
1122
|
+
if (acct.currency === baseCurrency) continue;
|
|
1123
|
+
const rate = rateMap.get(acct.currency);
|
|
1124
|
+
if (rate === void 0) continue;
|
|
1125
|
+
const revaluedBase = Math.round(acct.foreignBalance * rate);
|
|
1126
|
+
const gainLoss = revaluedBase - acct.baseBalance;
|
|
1127
|
+
if (gainLoss === 0) continue;
|
|
1128
|
+
results.push({
|
|
1129
|
+
accountId: acct.accountId,
|
|
1130
|
+
accountName: acct.accountName,
|
|
1131
|
+
accountCode: acct.accountCode,
|
|
1132
|
+
currency: acct.currency,
|
|
1133
|
+
foreignBalance: acct.foreignBalance,
|
|
1134
|
+
historicalBase: acct.baseBalance,
|
|
1135
|
+
revaluedBase,
|
|
1136
|
+
gainLoss
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
return results;
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Build a balanced revaluation journal entry from revaluation results.
|
|
1143
|
+
*
|
|
1144
|
+
* For each result with a non-zero gain/loss:
|
|
1145
|
+
* - Gain (positive gainLoss): Debit the account, Credit the unrealized gain/loss account
|
|
1146
|
+
* - Loss (negative gainLoss): Credit the account, Debit the unrealized gain/loss account
|
|
1147
|
+
*
|
|
1148
|
+
* @param results - Revaluation results from computeRevaluation
|
|
1149
|
+
* @param unrealizedGainLossAccountId - The account to book the offsetting entry against
|
|
1150
|
+
* @param date - Date for the revaluation entry
|
|
1151
|
+
*/
|
|
1152
|
+
function buildRevaluationEntry(results, unrealizedGainLossAccountId, date) {
|
|
1153
|
+
const journalItems = [];
|
|
1154
|
+
let totalDebit = 0;
|
|
1155
|
+
let totalCredit = 0;
|
|
745
1156
|
for (const r of results) {
|
|
746
|
-
|
|
747
|
-
const
|
|
748
|
-
if (
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
1157
|
+
if (r.gainLoss === 0) continue;
|
|
1158
|
+
const absAmount = Math.abs(r.gainLoss);
|
|
1159
|
+
if (r.gainLoss > 0) {
|
|
1160
|
+
journalItems.push({
|
|
1161
|
+
account: r.accountId,
|
|
1162
|
+
debit: absAmount,
|
|
1163
|
+
credit: 0,
|
|
1164
|
+
originalDebit: 0,
|
|
1165
|
+
originalCredit: 0,
|
|
1166
|
+
label: `FX revaluation ${r.currency} — gain`
|
|
1167
|
+
});
|
|
1168
|
+
journalItems.push({
|
|
1169
|
+
account: unrealizedGainLossAccountId,
|
|
1170
|
+
debit: 0,
|
|
1171
|
+
credit: absAmount,
|
|
1172
|
+
originalDebit: 0,
|
|
1173
|
+
originalCredit: 0,
|
|
1174
|
+
label: `FX revaluation ${r.currency} — gain`
|
|
1175
|
+
});
|
|
1176
|
+
} else {
|
|
1177
|
+
journalItems.push({
|
|
1178
|
+
account: r.accountId,
|
|
1179
|
+
debit: 0,
|
|
1180
|
+
credit: absAmount,
|
|
1181
|
+
originalDebit: 0,
|
|
1182
|
+
originalCredit: 0,
|
|
1183
|
+
label: `FX revaluation ${r.currency} — loss`
|
|
1184
|
+
});
|
|
1185
|
+
journalItems.push({
|
|
1186
|
+
account: unrealizedGainLossAccountId,
|
|
1187
|
+
debit: absAmount,
|
|
1188
|
+
credit: 0,
|
|
1189
|
+
originalDebit: 0,
|
|
1190
|
+
originalCredit: 0,
|
|
1191
|
+
label: `FX revaluation ${r.currency} — loss`
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
totalDebit += absAmount;
|
|
1195
|
+
totalCredit += absAmount;
|
|
1196
|
+
}
|
|
1197
|
+
const dateStr = date.toISOString().split("T")[0];
|
|
1198
|
+
return {
|
|
1199
|
+
journalItems,
|
|
1200
|
+
totalDebit,
|
|
1201
|
+
totalCredit,
|
|
1202
|
+
label: `Foreign exchange revaluation — ${dateStr}`
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
//#endregion
|
|
1206
|
+
//#region src/reports/revaluation.ts
|
|
1207
|
+
/**
|
|
1208
|
+
* Generate a foreign exchange revaluation report.
|
|
1209
|
+
*
|
|
1210
|
+
* 1. Finds all accounts with a `currency` field (foreign-currency accounts)
|
|
1211
|
+
* 2. Filters to balance sheet accounts only (not P&L)
|
|
1212
|
+
* 3. Aggregates foreign-currency and base-currency balances from posted entries
|
|
1213
|
+
* 4. Computes gain/loss at the new rates
|
|
1214
|
+
* 5. Optionally creates and saves a balanced journal entry
|
|
1215
|
+
*/
|
|
1216
|
+
async function generateRevaluation(opts, params) {
|
|
1217
|
+
const { AccountModel, JournalEntryModel, country, orgField, baseCurrency } = opts;
|
|
1218
|
+
requireOrgScope(orgField, params.organizationId);
|
|
1219
|
+
const accountQuery = {
|
|
1220
|
+
active: true,
|
|
1221
|
+
currency: {
|
|
1222
|
+
$exists: true,
|
|
1223
|
+
$ne: null
|
|
1224
|
+
}
|
|
1225
|
+
};
|
|
1226
|
+
if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
|
|
1227
|
+
const bsAccounts = (await AccountModel.find(accountQuery).lean()).filter((a) => {
|
|
1228
|
+
const at = country.getAccountType(a.accountTypeCode);
|
|
1229
|
+
return at && !at.isGroup && at.category.startsWith("Balance Sheet");
|
|
1230
|
+
});
|
|
1231
|
+
if (bsAccounts.length === 0) return {
|
|
1232
|
+
metadata: {
|
|
1233
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1234
|
+
asOfDate: params.asOfDate.toISOString().split("T")[0],
|
|
1235
|
+
baseCurrency
|
|
1236
|
+
},
|
|
1237
|
+
results: [],
|
|
1238
|
+
totalGainLoss: 0
|
|
1239
|
+
};
|
|
1240
|
+
const bsAccountIds = bsAccounts.map((a) => a._id);
|
|
1241
|
+
const baseMatch = {
|
|
1242
|
+
state: "posted",
|
|
1243
|
+
date: { $lte: params.asOfDate }
|
|
1244
|
+
};
|
|
1245
|
+
if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
|
|
1246
|
+
const balanceResults = await JournalEntryModel.aggregate([
|
|
1247
|
+
{ $match: baseMatch },
|
|
1248
|
+
{ $unwind: "$journalItems" },
|
|
1249
|
+
{ $match: { "journalItems.account": { $in: bsAccountIds } } },
|
|
1250
|
+
{ $group: {
|
|
1251
|
+
_id: "$journalItems.account",
|
|
1252
|
+
debit: { $sum: "$journalItems.debit" },
|
|
1253
|
+
credit: { $sum: "$journalItems.credit" },
|
|
1254
|
+
originalDebit: { $sum: { $ifNull: ["$journalItems.originalDebit", 0] } },
|
|
1255
|
+
originalCredit: { $sum: { $ifNull: ["$journalItems.originalCredit", 0] } }
|
|
1256
|
+
} }
|
|
1257
|
+
]);
|
|
1258
|
+
const accountMap = new Map(bsAccounts.map((a) => [String(a._id), a]));
|
|
1259
|
+
const accountBalances = [];
|
|
1260
|
+
for (const r of balanceResults) {
|
|
1261
|
+
const acct = accountMap.get(String(r._id));
|
|
1262
|
+
if (!acct) continue;
|
|
1263
|
+
const at = country.getAccountType(acct.accountTypeCode);
|
|
1264
|
+
if (!at) continue;
|
|
1265
|
+
accountBalances.push({
|
|
1266
|
+
accountId: r._id,
|
|
1267
|
+
accountName: acct.name ?? at.name,
|
|
1268
|
+
accountCode: acct.accountNumber ?? at.code,
|
|
1269
|
+
currency: acct.currency,
|
|
1270
|
+
foreignBalance: r.originalDebit - r.originalCredit,
|
|
1271
|
+
baseBalance: r.debit - r.credit,
|
|
1272
|
+
category: at.category
|
|
756
1273
|
});
|
|
757
|
-
flows[meta.cfCategory].total += amount;
|
|
758
1274
|
}
|
|
759
|
-
const
|
|
760
|
-
const
|
|
1275
|
+
const results = computeRevaluation(accountBalances, params.rates, baseCurrency);
|
|
1276
|
+
const totalGainLoss = results.reduce((sum, r) => sum + r.gainLoss, 0);
|
|
1277
|
+
let entryId;
|
|
1278
|
+
if (params.generateEntry && results.length > 0) {
|
|
1279
|
+
const entryData = buildRevaluationEntry(results, params.unrealizedGainLossAccountId, params.asOfDate);
|
|
1280
|
+
const doc = {
|
|
1281
|
+
journalType: "GENERAL",
|
|
1282
|
+
state: "posted",
|
|
1283
|
+
date: params.asOfDate,
|
|
1284
|
+
label: entryData.label,
|
|
1285
|
+
journalItems: entryData.journalItems,
|
|
1286
|
+
totalDebit: entryData.totalDebit,
|
|
1287
|
+
totalCredit: entryData.totalCredit
|
|
1288
|
+
};
|
|
1289
|
+
if (orgField && params.organizationId) doc[orgField] = params.organizationId;
|
|
1290
|
+
entryId = (await JournalEntryModel.create(doc))._id;
|
|
1291
|
+
}
|
|
1292
|
+
return {
|
|
1293
|
+
metadata: {
|
|
1294
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1295
|
+
asOfDate: params.asOfDate.toISOString().split("T")[0],
|
|
1296
|
+
baseCurrency
|
|
1297
|
+
},
|
|
1298
|
+
results,
|
|
1299
|
+
totalGainLoss,
|
|
1300
|
+
...entryId !== void 0 ? { entryId } : {}
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
//#endregion
|
|
1304
|
+
//#region src/reports/trial-balance.ts
|
|
1305
|
+
async function generateTrialBalance(opts, params) {
|
|
1306
|
+
const { AccountModel, JournalEntryModel, country, orgField, fiscalYearStartMonth = 1 } = opts;
|
|
1307
|
+
requireOrgScope(orgField, params.organizationId);
|
|
1308
|
+
const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
|
|
1309
|
+
const fiscalYearStart = getFiscalYearStart(startDate, fiscalYearStartMonth);
|
|
1310
|
+
const itemFilters = buildItemFilters(params.filters);
|
|
1311
|
+
const accountQuery = { active: true };
|
|
1312
|
+
if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
|
|
1313
|
+
const allAccounts = await AccountModel.find(accountQuery).lean();
|
|
1314
|
+
const bsIds = [];
|
|
1315
|
+
const isIds = [];
|
|
1316
|
+
for (const acc of allAccounts) {
|
|
1317
|
+
const at = country.getAccountType(acc.accountTypeCode);
|
|
1318
|
+
if (!at || at.isGroup) continue;
|
|
1319
|
+
if (at.category.startsWith("Balance Sheet")) bsIds.push(acc._id);
|
|
1320
|
+
else if (at.category.startsWith("Income Statement")) isIds.push(acc._id);
|
|
1321
|
+
}
|
|
1322
|
+
const baseMatch = { state: "posted" };
|
|
1323
|
+
if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
|
|
1324
|
+
const accountFilter = params.accountId ? { "journalItems.account": params.accountId } : {};
|
|
1325
|
+
const buildPipeline = (ids, dateFrom, dateTo) => [
|
|
1326
|
+
{ $match: {
|
|
1327
|
+
...baseMatch,
|
|
1328
|
+
date: {
|
|
1329
|
+
$gte: dateFrom,
|
|
1330
|
+
$lt: dateTo
|
|
1331
|
+
}
|
|
1332
|
+
} },
|
|
1333
|
+
{ $unwind: "$journalItems" },
|
|
1334
|
+
{ $match: {
|
|
1335
|
+
"journalItems.account": { $in: ids },
|
|
1336
|
+
...accountFilter,
|
|
1337
|
+
...itemFilters
|
|
1338
|
+
} },
|
|
1339
|
+
{ $group: {
|
|
1340
|
+
_id: "$journalItems.account",
|
|
1341
|
+
d: { $sum: "$journalItems.debit" },
|
|
1342
|
+
c: { $sum: "$journalItems.credit" }
|
|
1343
|
+
} }
|
|
1344
|
+
];
|
|
1345
|
+
const [bsInitial, isInitial, current] = await Promise.all([
|
|
1346
|
+
bsIds.length ? JournalEntryModel.aggregate(buildPipeline(bsIds, /* @__PURE__ */ new Date(0), startDate)) : [],
|
|
1347
|
+
isIds.length ? JournalEntryModel.aggregate(buildPipeline(isIds, fiscalYearStart, startDate)) : [],
|
|
1348
|
+
JournalEntryModel.aggregate(buildPipeline([...bsIds, ...isIds], startDate, new Date(endDate.getTime() + 1)))
|
|
1349
|
+
]);
|
|
1350
|
+
const map = /* @__PURE__ */ new Map();
|
|
1351
|
+
for (const r of [...bsInitial, ...isInitial]) {
|
|
1352
|
+
const key = String(r._id);
|
|
1353
|
+
map.set(key, {
|
|
1354
|
+
iD: r.d,
|
|
1355
|
+
iC: r.c,
|
|
1356
|
+
cD: 0,
|
|
1357
|
+
cC: 0
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
for (const r of current) {
|
|
1361
|
+
const key = String(r._id);
|
|
1362
|
+
const existing = map.get(key) ?? {
|
|
1363
|
+
iD: 0,
|
|
1364
|
+
iC: 0,
|
|
1365
|
+
cD: 0,
|
|
1366
|
+
cC: 0
|
|
1367
|
+
};
|
|
1368
|
+
existing.cD = r.d;
|
|
1369
|
+
existing.cC = r.c;
|
|
1370
|
+
map.set(key, existing);
|
|
1371
|
+
}
|
|
1372
|
+
const accountLookup = new Map(allAccounts.map((a) => [String(a._id), a]));
|
|
1373
|
+
const rows = [];
|
|
1374
|
+
for (const [id, bal] of map) {
|
|
1375
|
+
const acc = accountLookup.get(id);
|
|
1376
|
+
const net = bal.iD + bal.cD - (bal.iC + bal.cC);
|
|
1377
|
+
rows.push({
|
|
1378
|
+
account: acc ?? id,
|
|
1379
|
+
initial: {
|
|
1380
|
+
debit: bal.iD,
|
|
1381
|
+
credit: bal.iC
|
|
1382
|
+
},
|
|
1383
|
+
current: {
|
|
1384
|
+
debit: bal.cD,
|
|
1385
|
+
credit: bal.cC
|
|
1386
|
+
},
|
|
1387
|
+
ending: net >= 0 ? {
|
|
1388
|
+
debit: net,
|
|
1389
|
+
credit: 0
|
|
1390
|
+
} : {
|
|
1391
|
+
debit: 0,
|
|
1392
|
+
credit: Math.abs(net)
|
|
1393
|
+
}
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
rows.sort((a, b) => {
|
|
1397
|
+
const codeA = a.account?.accountNumber ?? a.account?.accountTypeCode ?? "";
|
|
1398
|
+
const codeB = b.account?.accountNumber ?? b.account?.accountTypeCode ?? "";
|
|
1399
|
+
return codeA.localeCompare(codeB, void 0, { numeric: true });
|
|
1400
|
+
});
|
|
1401
|
+
const periodDisplay = params.dateOption === "year" ? `For the year ended ${endDate.toLocaleDateString("en-US", {
|
|
1402
|
+
year: "numeric",
|
|
1403
|
+
month: "long",
|
|
1404
|
+
day: "numeric"
|
|
1405
|
+
})}` : `${startDate.toLocaleDateString("en-US", {
|
|
761
1406
|
month: "short",
|
|
762
1407
|
day: "numeric"
|
|
763
1408
|
})} – ${endDate.toLocaleDateString("en-US", {
|
|
@@ -773,16 +1418,87 @@ async function generateCashFlow(opts, params) {
|
|
|
773
1418
|
periodEnd: endDate.toISOString().split("T")[0],
|
|
774
1419
|
displayPeriod: periodDisplay
|
|
775
1420
|
},
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
1421
|
+
rows,
|
|
1422
|
+
period: {
|
|
1423
|
+
startDate,
|
|
1424
|
+
endDate
|
|
1425
|
+
}
|
|
780
1426
|
};
|
|
781
1427
|
}
|
|
782
1428
|
//#endregion
|
|
1429
|
+
//#region src/utils/logger.ts
|
|
1430
|
+
/** Default console-based implementation */
|
|
1431
|
+
const defaultLogger = {
|
|
1432
|
+
warn: (msg, meta) => console.warn(`[accounting] ${msg}`, meta ?? ""),
|
|
1433
|
+
error: (msg, meta) => console.error(`[accounting] ${msg}`, meta ?? ""),
|
|
1434
|
+
info: (msg, meta) => console.info(`[accounting] ${msg}`, meta ?? "")
|
|
1435
|
+
};
|
|
1436
|
+
//#endregion
|
|
1437
|
+
//#region src/utils/session.ts
|
|
1438
|
+
/**
|
|
1439
|
+
* Acquire a session: uses external if provided, otherwise creates an internal one.
|
|
1440
|
+
* Returns { session, ownSession } so callers can commit/abort/end appropriately.
|
|
1441
|
+
*
|
|
1442
|
+
* When transactions are unavailable (no replica set / standalone), returns
|
|
1443
|
+
* session=null and the function runs without transactional safety.
|
|
1444
|
+
*/
|
|
1445
|
+
async function acquireSession(db, externalSession, logger = defaultLogger) {
|
|
1446
|
+
if (externalSession) return {
|
|
1447
|
+
session: externalSession,
|
|
1448
|
+
ownSession: false
|
|
1449
|
+
};
|
|
1450
|
+
try {
|
|
1451
|
+
const session = await db.startSession();
|
|
1452
|
+
try {
|
|
1453
|
+
const conn = db;
|
|
1454
|
+
if ((conn.getClient?.() ?? conn.client)?.topology?.description?.type === "Single") {
|
|
1455
|
+
session.endSession();
|
|
1456
|
+
logger.warn("Transactions unavailable (standalone MongoDB). Operation is not atomic.");
|
|
1457
|
+
return {
|
|
1458
|
+
session: null,
|
|
1459
|
+
ownSession: false
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
} catch {}
|
|
1463
|
+
try {
|
|
1464
|
+
session.startTransaction();
|
|
1465
|
+
return {
|
|
1466
|
+
session,
|
|
1467
|
+
ownSession: true
|
|
1468
|
+
};
|
|
1469
|
+
} catch (err) {
|
|
1470
|
+
session.endSession();
|
|
1471
|
+
logger.warn("Transactions unavailable (no replica set). Operation is not atomic.", { error: err.message });
|
|
1472
|
+
return {
|
|
1473
|
+
session: null,
|
|
1474
|
+
ownSession: false
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
} catch {
|
|
1478
|
+
return {
|
|
1479
|
+
session: null,
|
|
1480
|
+
ownSession: false
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1485
|
+
* Finalize an owned session: commit or abort, then always end.
|
|
1486
|
+
*/
|
|
1487
|
+
async function finalizeSession(session, ownSession, success) {
|
|
1488
|
+
if (!ownSession || !session) return;
|
|
1489
|
+
try {
|
|
1490
|
+
if (success && session.inTransaction()) await session.commitTransaction();
|
|
1491
|
+
else if (!success && session.inTransaction()) try {
|
|
1492
|
+
await session.abortTransaction();
|
|
1493
|
+
} catch {}
|
|
1494
|
+
} finally {
|
|
1495
|
+
session.endSession();
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
//#endregion
|
|
783
1499
|
//#region src/reports/fiscal-close.ts
|
|
784
1500
|
async function closeFiscalPeriod(opts, params) {
|
|
785
|
-
const { AccountModel, JournalEntryModel, FiscalPeriodModel, country, orgField,
|
|
1501
|
+
const { AccountModel, JournalEntryModel, FiscalPeriodModel, country, orgField, retainedEarningsAccountCode = country.retainedEarningsAccountCode ?? "3600", logger = defaultLogger } = opts;
|
|
786
1502
|
const { periodId, organizationId, closedBy } = params;
|
|
787
1503
|
requireOrgScope(orgField, organizationId);
|
|
788
1504
|
const { session, ownSession } = await acquireSession(AccountModel.db, params.session, logger);
|
|
@@ -804,7 +1520,7 @@ async function closeFiscalPeriod(opts, params) {
|
|
|
804
1520
|
for (const acc of allAccounts) {
|
|
805
1521
|
const at = country.getAccountType(acc.accountTypeCode);
|
|
806
1522
|
if (!at) continue;
|
|
807
|
-
if (acc.accountTypeCode ===
|
|
1523
|
+
if (acc.accountTypeCode === retainedEarningsAccountCode) retainedEarningsId = acc._id;
|
|
808
1524
|
if (at.isGroup || at.isTotal) continue;
|
|
809
1525
|
if (at.category.startsWith("Income Statement")) isAccounts.push({
|
|
810
1526
|
id: acc._id,
|
|
@@ -812,7 +1528,7 @@ async function closeFiscalPeriod(opts, params) {
|
|
|
812
1528
|
isIncome: at.category === "Income Statement-Income"
|
|
813
1529
|
});
|
|
814
1530
|
}
|
|
815
|
-
if (!retainedEarningsId) throw Errors.fiscal(`Retained earnings account (code: ${
|
|
1531
|
+
if (!retainedEarningsId) throw Errors.fiscal(`Retained earnings account (code: ${retainedEarningsAccountCode}) not found. Create this account before closing the fiscal period.`);
|
|
816
1532
|
const baseMatch = {
|
|
817
1533
|
state: "posted",
|
|
818
1534
|
date: {
|
|
@@ -934,6 +1650,4 @@ async function reopenFiscalPeriod(opts, params) {
|
|
|
934
1650
|
}
|
|
935
1651
|
}
|
|
936
1652
|
//#endregion
|
|
937
|
-
export {
|
|
938
|
-
|
|
939
|
-
//# sourceMappingURL=fiscal-close-L631E3De.mjs.map
|
|
1653
|
+
export { DEFAULT_BUCKETS as C, isVirtualTaxAccount as S, getDateRange as _, defaultLogger as a, calculateTotal as b, buildRevaluationEntry as c, generateGeneralLedger as d, generateDimensionBreakdown as f, buildItemFilters as g, generateBalanceSheet as h, finalizeSession as i, computeRevaluation as l, generateBudgetVsActual as m, reopenFiscalPeriod as n, generateTrialBalance as o, generateCashFlow as p, acquireSession as r, generateRevaluation as s, closeFiscalPeriod as t, generateIncomeStatement as u, getFiscalYearStart as v, generateAgedBalance as w, computeEndingBalance as x, buildAccountTypeMap as y };
|