@classytic/ledger 0.1.5 → 0.3.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/{account.repository-Crf5DGO4.mjs → account.repository-BpkSd6q3.mjs} +190 -41
- package/dist/{categories-BNJBd4ze.mjs → categories-CclX7Q94.mjs} +0 -2
- package/dist/constants/index.d.mts +1 -1
- package/dist/constants/index.mjs +4 -5
- package/dist/{core-Cx0baosR.d.mts → core-8Xfnpn6g.d.mts} +1 -2
- package/dist/country/index.d.mts +2 -105
- package/dist/country/index.mjs +0 -2
- package/dist/{currencies-Bkn3FNkC.d.mts → currencies-4WAbFRlw.d.mts} +2 -3
- package/dist/{currencies-BBk3NwXn.mjs → currencies-W8kQAkm0.mjs} +0 -2
- package/dist/{idempotency.plugin-C6r8RI8d.mjs → date-lock.plugin-eYAJ9h_u.mjs} +50 -13
- package/dist/{engine-Cd73EOT6.d.mts → engine-Cn-9yerQ.d.mts} +38 -8
- package/dist/{errors-CeqRahE-.mjs → errors-B7yC-Jfw.mjs} +0 -2
- package/dist/exports/index.d.mts +2 -2
- package/dist/exports/index.mjs +2 -3
- package/dist/{universal-CMfrZ2hG.mjs → exports-I5Xkq-9_.mjs} +0 -7
- package/dist/{fiscal-close-DuXDgVvb.mjs → fiscal-close-B6LhQ10f.mjs} +742 -32
- package/dist/fiscal-period.schema-BMnlI9H5.d.mts +103 -0
- package/dist/{idempotency.plugin-BESs9YPD.d.mts → idempotency.plugin-B_CNsInz.d.mts} +19 -17
- package/dist/{universal-x33ZJODp.d.mts → index-BPukb3L8.d.mts} +1 -2
- package/dist/index-CxZqRaOU.d.mts +119 -0
- package/dist/index.d.mts +251 -29
- package/dist/index.mjs +124 -27
- package/dist/{journals-CI3Wb4EF.mjs → journals-oH-FK3g8.mjs} +0 -2
- package/dist/{logger-Cv6VVc4r.d.mts → logger-CbHWZl7v.d.mts} +1 -2
- package/dist/money.d.mts +1 -2
- package/dist/money.mjs +3 -3
- package/dist/plugins/index.d.mts +38 -2
- package/dist/plugins/index.mjs +57 -3
- package/dist/reconciliation.repository-CW4-8q90.d.mts +135 -0
- package/dist/{fiscal-period.schema-CbALaaKl.mjs → reconciliation.schema-BuetvZTd.mjs} +218 -30
- package/dist/reports/index.d.mts +2 -2
- package/dist/reports/index.mjs +2 -3
- package/dist/repositories/index.d.mts +2 -2
- package/dist/repositories/index.mjs +2 -3
- package/dist/revaluation-D9x0NE8w.d.mts +530 -0
- package/dist/schemas/index.d.mts +71 -2
- package/dist/schemas/index.mjs +2 -3
- package/dist/tenant-guard-Fm6AID_6.mjs +13 -0
- package/docs/reports.md +1 -1
- package/package.json +3 -3
- package/dist/account.repository-1C2sZvB2.d.mts +0 -29
- package/dist/account.repository-1C2sZvB2.d.mts.map +0 -1
- package/dist/account.repository-Crf5DGO4.mjs.map +0 -1
- package/dist/categories-BNJBd4ze.mjs.map +0 -1
- package/dist/core-Cx0baosR.d.mts.map +0 -1
- package/dist/country/index.d.mts.map +0 -1
- package/dist/country/index.mjs.map +0 -1
- package/dist/currencies-BBk3NwXn.mjs.map +0 -1
- package/dist/currencies-Bkn3FNkC.d.mts.map +0 -1
- package/dist/engine-Cd73EOT6.d.mts.map +0 -1
- package/dist/errors-CeqRahE-.mjs.map +0 -1
- package/dist/fiscal-close-CzUzpnMg.d.mts +0 -270
- package/dist/fiscal-close-CzUzpnMg.d.mts.map +0 -1
- package/dist/fiscal-close-DuXDgVvb.mjs.map +0 -1
- package/dist/fiscal-period.schema-CbALaaKl.mjs.map +0 -1
- package/dist/fiscal-period.schema-DI2scngu.d.mts +0 -38
- package/dist/fiscal-period.schema-DI2scngu.d.mts.map +0 -1
- package/dist/idempotency.plugin-BESs9YPD.d.mts.map +0 -1
- package/dist/idempotency.plugin-C6r8RI8d.mjs.map +0 -1
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/journals-CI3Wb4EF.mjs.map +0 -1
- package/dist/logger-Cv6VVc4r.d.mts.map +0 -1
- package/dist/money.d.mts.map +0 -1
- package/dist/money.mjs.map +0 -1
- package/dist/session-Dh0s6zG4.mjs +0 -87
- package/dist/session-Dh0s6zG4.mjs.map +0 -1
- package/dist/universal-CMfrZ2hG.mjs.map +0 -1
- package/dist/universal-x33ZJODp.d.mts.map +0 -1
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { n as Errors } from "./errors-
|
|
2
|
-
import {
|
|
3
|
-
import { i as extractMainType } from "./categories-
|
|
4
|
-
|
|
1
|
+
import { n as Errors } from "./errors-B7yC-Jfw.mjs";
|
|
2
|
+
import { t as requireOrgScope } from "./tenant-guard-Fm6AID_6.mjs";
|
|
3
|
+
import { i as extractMainType } from "./categories-CclX7Q94.mjs";
|
|
4
|
+
import mongoose from "mongoose";
|
|
5
5
|
//#region src/utils/date-range.ts
|
|
6
6
|
/**
|
|
7
7
|
* Compute start/end dates from a date option + value.
|
|
8
8
|
*
|
|
9
|
+
* @throws {Error} If value is null/undefined/invalid for the given option
|
|
10
|
+
*
|
|
9
11
|
* Examples:
|
|
10
12
|
* getDateRange('month', '2025-03') → Mar 1 – Mar 31
|
|
11
13
|
* getDateRange('quarter', { quarter: 2, year: 2025 }) → Apr 1 – Jun 30
|
|
@@ -13,6 +15,7 @@ import { i as extractMainType } from "./categories-BNJBd4ze.mjs";
|
|
|
13
15
|
* getDateRange('custom', { startDate, endDate })
|
|
14
16
|
*/
|
|
15
17
|
function getDateRange(option, value) {
|
|
18
|
+
if (value == null && (option === "month" || option === "quarter" || option === "year" || option === "custom")) throw new Error(`dateValue is required for dateOption "${option}"`);
|
|
16
19
|
switch (option) {
|
|
17
20
|
case "month": {
|
|
18
21
|
let year;
|
|
@@ -23,16 +26,21 @@ function getDateRange(option, value) {
|
|
|
23
26
|
month = parseInt(match[2], 10) - 1;
|
|
24
27
|
} else {
|
|
25
28
|
const date = new Date(value);
|
|
29
|
+
if (isNaN(date.getTime())) throw new Error(`Invalid month value: ${String(value)}`);
|
|
26
30
|
year = date.getFullYear();
|
|
27
31
|
month = date.getMonth();
|
|
28
32
|
}
|
|
33
|
+
if (year < 1900 || year > 9999) throw new Error(`Year ${year} is out of valid range (1900–9999)`);
|
|
29
34
|
return {
|
|
30
35
|
startDate: new Date(year, month, 1),
|
|
31
36
|
endDate: new Date(year, month + 1, 0, 23, 59, 59, 999)
|
|
32
37
|
};
|
|
33
38
|
}
|
|
34
39
|
case "quarter": {
|
|
40
|
+
if (typeof value !== "object" || value === null) throw new Error("Quarter dateValue must be an object with { quarter, year }");
|
|
35
41
|
const { quarter, year } = value;
|
|
42
|
+
if (!Number.isInteger(quarter) || quarter < 1 || quarter > 4) throw new Error(`Invalid quarter: ${quarter}. Must be 1–4.`);
|
|
43
|
+
if (!Number.isInteger(year) || year < 1900 || year > 9999) throw new Error(`Invalid year: ${year}. Must be 1900–9999.`);
|
|
36
44
|
const startMonth = (quarter - 1) * 3;
|
|
37
45
|
return {
|
|
38
46
|
startDate: new Date(year, startMonth, 1),
|
|
@@ -41,17 +49,23 @@ function getDateRange(option, value) {
|
|
|
41
49
|
}
|
|
42
50
|
case "year": {
|
|
43
51
|
const year = typeof value === "number" ? value : parseInt(String(value), 10);
|
|
52
|
+
if (isNaN(year) || year < 1900 || year > 9999) throw new Error(`Invalid year: ${String(value)}. Must be a number between 1900–9999.`);
|
|
44
53
|
return {
|
|
45
54
|
startDate: new Date(year, 0, 1),
|
|
46
55
|
endDate: new Date(year, 11, 31, 23, 59, 59, 999)
|
|
47
56
|
};
|
|
48
57
|
}
|
|
49
58
|
case "custom": {
|
|
50
|
-
|
|
51
|
-
const
|
|
59
|
+
if (typeof value !== "object" || value === null) throw new Error("Custom dateValue must be an object with { startDate, endDate }");
|
|
60
|
+
const { startDate: rawStart, endDate: rawEnd } = value;
|
|
61
|
+
if (!rawStart || !rawEnd) throw new Error("Custom date range requires both startDate and endDate");
|
|
62
|
+
const start = new Date(rawStart);
|
|
63
|
+
const end = new Date(rawEnd);
|
|
64
|
+
if (isNaN(start.getTime()) || isNaN(end.getTime())) throw new Error("Custom date range contains invalid dates");
|
|
65
|
+
if (start > end) throw new Error("startDate must be before endDate");
|
|
52
66
|
if (end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0 && end.getMilliseconds() === 0) end.setHours(23, 59, 59, 999);
|
|
53
67
|
return {
|
|
54
|
-
startDate:
|
|
68
|
+
startDate: start,
|
|
55
69
|
endDate: end
|
|
56
70
|
};
|
|
57
71
|
}
|
|
@@ -70,7 +84,6 @@ function getFiscalYearStart(date, fiscalStartMonth = 1) {
|
|
|
70
84
|
const year = date.getMonth() < month ? date.getFullYear() - 1 : date.getFullYear();
|
|
71
85
|
return new Date(year, month, 1);
|
|
72
86
|
}
|
|
73
|
-
|
|
74
87
|
//#endregion
|
|
75
88
|
//#region src/utils/filter-builder.ts
|
|
76
89
|
/**
|
|
@@ -108,7 +121,6 @@ function buildItemFilters(filters) {
|
|
|
108
121
|
}
|
|
109
122
|
return result;
|
|
110
123
|
}
|
|
111
|
-
|
|
112
124
|
//#endregion
|
|
113
125
|
//#region src/reports/trial-balance.ts
|
|
114
126
|
async function generateTrialBalance(opts, params) {
|
|
@@ -202,7 +214,31 @@ async function generateTrialBalance(opts, params) {
|
|
|
202
214
|
}
|
|
203
215
|
});
|
|
204
216
|
}
|
|
217
|
+
rows.sort((a, b) => {
|
|
218
|
+
const codeA = a.account?.accountNumber ?? a.account?.accountTypeCode ?? "";
|
|
219
|
+
const codeB = b.account?.accountNumber ?? b.account?.accountTypeCode ?? "";
|
|
220
|
+
return codeA.localeCompare(codeB, void 0, { numeric: true });
|
|
221
|
+
});
|
|
222
|
+
const periodDisplay = params.dateOption === "year" ? `For the year ended ${endDate.toLocaleDateString("en-US", {
|
|
223
|
+
year: "numeric",
|
|
224
|
+
month: "long",
|
|
225
|
+
day: "numeric"
|
|
226
|
+
})}` : `${startDate.toLocaleDateString("en-US", {
|
|
227
|
+
month: "short",
|
|
228
|
+
day: "numeric"
|
|
229
|
+
})} – ${endDate.toLocaleDateString("en-US", {
|
|
230
|
+
year: "numeric",
|
|
231
|
+
month: "short",
|
|
232
|
+
day: "numeric"
|
|
233
|
+
})}`;
|
|
205
234
|
return {
|
|
235
|
+
metadata: {
|
|
236
|
+
businessName: params.businessName,
|
|
237
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
238
|
+
periodStart: startDate.toISOString().split("T")[0],
|
|
239
|
+
periodEnd: endDate.toISOString().split("T")[0],
|
|
240
|
+
displayPeriod: periodDisplay
|
|
241
|
+
},
|
|
206
242
|
rows,
|
|
207
243
|
period: {
|
|
208
244
|
startDate,
|
|
@@ -210,7 +246,6 @@ async function generateTrialBalance(opts, params) {
|
|
|
210
246
|
}
|
|
211
247
|
};
|
|
212
248
|
}
|
|
213
|
-
|
|
214
249
|
//#endregion
|
|
215
250
|
//#region src/utils/account-helpers.ts
|
|
216
251
|
/**
|
|
@@ -255,11 +290,10 @@ function buildAccountTypeMap(accountTypes) {
|
|
|
255
290
|
for (const at of accountTypes) map.set(at.code, at);
|
|
256
291
|
return map;
|
|
257
292
|
}
|
|
258
|
-
|
|
259
293
|
//#endregion
|
|
260
294
|
//#region src/reports/balance-sheet.ts
|
|
261
295
|
async function generateBalanceSheet(opts, params) {
|
|
262
|
-
const { AccountModel, JournalEntryModel, country, orgField, fiscalYearStartMonth = 1,
|
|
296
|
+
const { AccountModel, JournalEntryModel, country, orgField, fiscalYearStartMonth = 1, retainedEarningsAccountCode = country.retainedEarningsAccountCode, retainedEarningsDisplayCode = country.retainedEarningsDisplayCode ?? retainedEarningsAccountCode, currentYearEarningsCode = country.currentYearEarningsCode ?? "3680" } = opts;
|
|
263
297
|
requireOrgScope(orgField, params.organizationId);
|
|
264
298
|
const { endDate } = getDateRange(params.dateOption, params.dateValue);
|
|
265
299
|
const fiscalYearStart = getFiscalYearStart(endDate, fiscalYearStartMonth);
|
|
@@ -271,13 +305,15 @@ async function generateBalanceSheet(opts, params) {
|
|
|
271
305
|
const at = country.getAccountType(a.accountTypeCode);
|
|
272
306
|
return at && !at.isGroup && at.category.startsWith("Balance Sheet");
|
|
273
307
|
}).map((a) => a._id);
|
|
308
|
+
const reAccountIds = retainedEarningsAccountCode ? allAccounts.filter((a) => a.accountTypeCode === retainedEarningsAccountCode).map((a) => a._id) : [];
|
|
309
|
+
const reAccountIdSet = new Set(reAccountIds.map(String));
|
|
274
310
|
const isIds = allAccounts.filter((a) => {
|
|
275
311
|
const at = country.getAccountType(a.accountTypeCode);
|
|
276
312
|
return at && !at.isGroup && !at.isTotal && at.category.startsWith("Income Statement");
|
|
277
313
|
}).map((a) => a._id);
|
|
278
314
|
const baseMatch = { state: "posted" };
|
|
279
315
|
if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
|
|
280
|
-
const [bsResults, netIncomeResults, priorRetainedResults] = await Promise.all([
|
|
316
|
+
const [bsResults, netIncomeResults, priorRetainedResults, reAccountResults] = await Promise.all([
|
|
281
317
|
JournalEntryModel.aggregate([
|
|
282
318
|
{ $match: {
|
|
283
319
|
...baseMatch,
|
|
@@ -328,10 +364,27 @@ async function generateBalanceSheet(opts, params) {
|
|
|
328
364
|
d: { $sum: "$journalItems.debit" },
|
|
329
365
|
c: { $sum: "$journalItems.credit" }
|
|
330
366
|
} }
|
|
331
|
-
])
|
|
367
|
+
]),
|
|
368
|
+
...reAccountIds.length > 0 ? [JournalEntryModel.aggregate([
|
|
369
|
+
{ $match: {
|
|
370
|
+
...baseMatch,
|
|
371
|
+
date: { $lte: endDate }
|
|
372
|
+
} },
|
|
373
|
+
{ $unwind: "$journalItems" },
|
|
374
|
+
{ $match: {
|
|
375
|
+
"journalItems.account": { $in: reAccountIds },
|
|
376
|
+
...itemFilters
|
|
377
|
+
} },
|
|
378
|
+
{ $group: {
|
|
379
|
+
_id: null,
|
|
380
|
+
d: { $sum: "$journalItems.debit" },
|
|
381
|
+
c: { $sum: "$journalItems.credit" }
|
|
382
|
+
} }
|
|
383
|
+
])] : [Promise.resolve([])]
|
|
332
384
|
]);
|
|
333
385
|
const netIncome = netIncomeResults.length > 0 ? netIncomeResults[0].c - netIncomeResults[0].d : 0;
|
|
334
|
-
const
|
|
386
|
+
const priorUnclosedPL = priorRetainedResults.length > 0 ? priorRetainedResults[0].c - priorRetainedResults[0].d : 0;
|
|
387
|
+
const priorRetained = (reAccountResults.length > 0 ? reAccountResults[0].c - reAccountResults[0].d : 0) + priorUnclosedPL;
|
|
335
388
|
const accountMap = new Map(allAccounts.map((a) => [String(a._id), a]));
|
|
336
389
|
const accountTypeMap = buildAccountTypeMap(country.accountTypes);
|
|
337
390
|
const balanceMap = /* @__PURE__ */ new Map();
|
|
@@ -359,6 +412,7 @@ async function generateBalanceSheet(opts, params) {
|
|
|
359
412
|
for (const r of bsResults) {
|
|
360
413
|
const acc = accountMap.get(String(r._id));
|
|
361
414
|
if (!acc) continue;
|
|
415
|
+
if (reAccountIdSet.has(String(r._id))) continue;
|
|
362
416
|
const at = country.getAccountType(acc.accountTypeCode);
|
|
363
417
|
if (!at) continue;
|
|
364
418
|
const mainType = extractMainType(at.category) ?? "Asset";
|
|
@@ -387,7 +441,7 @@ async function generateBalanceSheet(opts, params) {
|
|
|
387
441
|
accounts: [{
|
|
388
442
|
id: "prior-retained",
|
|
389
443
|
name: "Previous Years Retained Earnings",
|
|
390
|
-
code:
|
|
444
|
+
code: retainedEarningsDisplayCode ?? retainedEarningsAccountCode ?? "",
|
|
391
445
|
balance: priorRetained
|
|
392
446
|
}, {
|
|
393
447
|
id: "current-year",
|
|
@@ -402,13 +456,24 @@ async function generateBalanceSheet(opts, params) {
|
|
|
402
456
|
groupsMap.Equity[reGroup.name].accounts.push(...reGroup.accounts);
|
|
403
457
|
groupsMap.Equity[reGroup.name].total += reGroup.total;
|
|
404
458
|
}
|
|
459
|
+
const sortAccountsInGroups = (groups) => {
|
|
460
|
+
for (const g of Object.values(groups)) g.accounts.sort((a, b) => (a.code ?? "").localeCompare(b.code ?? "", void 0, { numeric: true }));
|
|
461
|
+
};
|
|
462
|
+
sortAccountsInGroups(groupsMap.Asset);
|
|
463
|
+
sortAccountsInGroups(groupsMap.Liability);
|
|
464
|
+
sortAccountsInGroups(groupsMap.Equity);
|
|
465
|
+
const sortGroupsByCode = (groups) => groups.sort((a, b) => {
|
|
466
|
+
const codeA = a.accounts[0]?.code ?? "";
|
|
467
|
+
const codeB = b.accounts[0]?.code ?? "";
|
|
468
|
+
return codeA.localeCompare(codeB, void 0, { numeric: true });
|
|
469
|
+
});
|
|
405
470
|
const pruneGroups = (groups) => Object.values(groups).map((g) => ({
|
|
406
471
|
...g,
|
|
407
472
|
accounts: g.accounts.filter((a) => a.balance !== 0 || a.isTotal || a.isCalculated)
|
|
408
473
|
})).filter((g) => g.accounts.length > 0 || g.total !== 0);
|
|
409
|
-
assets.groups = pruneGroups(groupsMap.Asset);
|
|
410
|
-
liabilities.groups = pruneGroups(groupsMap.Liability);
|
|
411
|
-
equity.groups = Object.values(groupsMap.Equity);
|
|
474
|
+
assets.groups = sortGroupsByCode(pruneGroups(groupsMap.Asset));
|
|
475
|
+
liabilities.groups = sortGroupsByCode(pruneGroups(groupsMap.Liability));
|
|
476
|
+
equity.groups = sortGroupsByCode(Object.values(groupsMap.Equity));
|
|
412
477
|
assets.total = assets.groups.reduce((s, g) => s + g.total, 0);
|
|
413
478
|
liabilities.total = liabilities.groups.reduce((s, g) => s + g.total, 0);
|
|
414
479
|
equity.total = equity.groups.reduce((s, g) => s + g.total, 0);
|
|
@@ -437,7 +502,6 @@ async function generateBalanceSheet(opts, params) {
|
|
|
437
502
|
}
|
|
438
503
|
};
|
|
439
504
|
}
|
|
440
|
-
|
|
441
505
|
//#endregion
|
|
442
506
|
//#region src/reports/income-statement.ts
|
|
443
507
|
async function generateIncomeStatement(opts, params) {
|
|
@@ -477,9 +541,11 @@ async function generateIncomeStatement(opts, params) {
|
|
|
477
541
|
const revenueGroups = {};
|
|
478
542
|
const expenseGroups = {};
|
|
479
543
|
const resolveGroupName = (at) => {
|
|
544
|
+
const visited = /* @__PURE__ */ new Set();
|
|
480
545
|
let current = at.parentCode ? country.getAccountType(at.parentCode) : void 0;
|
|
481
|
-
while (current) {
|
|
546
|
+
while (current && !visited.has(current.code)) {
|
|
482
547
|
if (current.isGroup) return current.name;
|
|
548
|
+
visited.add(current.code);
|
|
483
549
|
current = current.parentCode ? country.getAccountType(current.parentCode) : void 0;
|
|
484
550
|
}
|
|
485
551
|
return at.name;
|
|
@@ -507,16 +573,26 @@ async function generateIncomeStatement(opts, params) {
|
|
|
507
573
|
});
|
|
508
574
|
groups[groupName].total += netAmount;
|
|
509
575
|
}
|
|
576
|
+
const sortGroups = (groups) => {
|
|
577
|
+
const sorted = Object.values(groups);
|
|
578
|
+
for (const g of sorted) g.accounts.sort((a, b) => (a.code ?? "").localeCompare(b.code ?? "", void 0, { numeric: true }));
|
|
579
|
+
sorted.sort((a, b) => {
|
|
580
|
+
const codeA = a.accounts[0]?.code ?? "";
|
|
581
|
+
const codeB = b.accounts[0]?.code ?? "";
|
|
582
|
+
return codeA.localeCompare(codeB, void 0, { numeric: true });
|
|
583
|
+
});
|
|
584
|
+
return sorted;
|
|
585
|
+
};
|
|
510
586
|
const labels = country.reportLabels ?? {};
|
|
511
587
|
const revenue = {
|
|
512
588
|
name: labels.revenue ?? "Revenue",
|
|
513
589
|
total: Object.values(revenueGroups).reduce((s, g) => s + g.total, 0),
|
|
514
|
-
groups:
|
|
590
|
+
groups: sortGroups(revenueGroups)
|
|
515
591
|
};
|
|
516
592
|
const expenses = {
|
|
517
593
|
name: labels.expenses ?? "Expenses",
|
|
518
594
|
total: Object.values(expenseGroups).reduce((s, g) => s + g.total, 0),
|
|
519
|
-
groups:
|
|
595
|
+
groups: sortGroups(expenseGroups)
|
|
520
596
|
};
|
|
521
597
|
const cogsCode = country.cogsGroupCode;
|
|
522
598
|
const isCogs = (name) => cogsCode ? name === cogsCode : name === "Cost of Sales" || name === "Cost of Goods Sold";
|
|
@@ -552,7 +628,6 @@ async function generateIncomeStatement(opts, params) {
|
|
|
552
628
|
netIncome
|
|
553
629
|
};
|
|
554
630
|
}
|
|
555
|
-
|
|
556
631
|
//#endregion
|
|
557
632
|
//#region src/reports/general-ledger.ts
|
|
558
633
|
async function generateGeneralLedger(opts, params) {
|
|
@@ -581,6 +656,11 @@ async function generateGeneralLedger(opts, params) {
|
|
|
581
656
|
endDate
|
|
582
657
|
}
|
|
583
658
|
};
|
|
659
|
+
filtered.sort((a, b) => {
|
|
660
|
+
const codeA = a.acc.accountNumber ?? a.at.code;
|
|
661
|
+
const codeB = b.acc.accountNumber ?? b.at.code;
|
|
662
|
+
return codeA.localeCompare(codeB, void 0, { numeric: true });
|
|
663
|
+
});
|
|
584
664
|
const bsAccountIds = [];
|
|
585
665
|
const isAccountIds = [];
|
|
586
666
|
const allAccountIds = [];
|
|
@@ -679,7 +759,26 @@ async function generateGeneralLedger(opts, params) {
|
|
|
679
759
|
closingBalance: runningBalance
|
|
680
760
|
});
|
|
681
761
|
}
|
|
762
|
+
const periodDisplay = params.dateOption === "year" ? `For the year ended ${endDate.toLocaleDateString("en-US", {
|
|
763
|
+
year: "numeric",
|
|
764
|
+
month: "long",
|
|
765
|
+
day: "numeric"
|
|
766
|
+
})}` : `${startDate.toLocaleDateString("en-US", {
|
|
767
|
+
month: "short",
|
|
768
|
+
day: "numeric"
|
|
769
|
+
})} – ${endDate.toLocaleDateString("en-US", {
|
|
770
|
+
year: "numeric",
|
|
771
|
+
month: "short",
|
|
772
|
+
day: "numeric"
|
|
773
|
+
})}`;
|
|
682
774
|
return {
|
|
775
|
+
metadata: {
|
|
776
|
+
businessName: params.businessName,
|
|
777
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
778
|
+
periodStart: startDate.toISOString().split("T")[0],
|
|
779
|
+
periodEnd: endDate.toISOString().split("T")[0],
|
|
780
|
+
displayPeriod: periodDisplay
|
|
781
|
+
},
|
|
683
782
|
accounts: glAccounts,
|
|
684
783
|
period: {
|
|
685
784
|
startDate,
|
|
@@ -687,7 +786,6 @@ async function generateGeneralLedger(opts, params) {
|
|
|
687
786
|
}
|
|
688
787
|
};
|
|
689
788
|
}
|
|
690
|
-
|
|
691
789
|
//#endregion
|
|
692
790
|
//#region src/reports/cash-flow.ts
|
|
693
791
|
async function generateCashFlow(opts, params) {
|
|
@@ -762,6 +860,7 @@ async function generateCashFlow(opts, params) {
|
|
|
762
860
|
});
|
|
763
861
|
flows[meta.cfCategory].total += amount;
|
|
764
862
|
}
|
|
863
|
+
for (const section of Object.values(flows)) section.accounts.sort((a, b) => a.code.localeCompare(b.code, void 0, { numeric: true }));
|
|
765
864
|
const netCashFlow = flows.Operating.total + flows.Investing.total + flows.Financing.total;
|
|
766
865
|
const periodDisplay = `${startDate.toLocaleDateString("en-US", {
|
|
767
866
|
month: "short",
|
|
@@ -785,11 +884,624 @@ async function generateCashFlow(opts, params) {
|
|
|
785
884
|
netCashFlow
|
|
786
885
|
};
|
|
787
886
|
}
|
|
788
|
-
|
|
887
|
+
//#endregion
|
|
888
|
+
//#region src/reports/aged-balance.ts
|
|
889
|
+
const DEFAULT_BUCKETS = [
|
|
890
|
+
{
|
|
891
|
+
label: "Current",
|
|
892
|
+
minDays: 0,
|
|
893
|
+
maxDays: 31
|
|
894
|
+
},
|
|
895
|
+
{
|
|
896
|
+
label: "31-60",
|
|
897
|
+
minDays: 31,
|
|
898
|
+
maxDays: 61
|
|
899
|
+
},
|
|
900
|
+
{
|
|
901
|
+
label: "61-90",
|
|
902
|
+
minDays: 61,
|
|
903
|
+
maxDays: 91
|
|
904
|
+
},
|
|
905
|
+
{
|
|
906
|
+
label: "90+",
|
|
907
|
+
minDays: 91,
|
|
908
|
+
maxDays: Infinity
|
|
909
|
+
}
|
|
910
|
+
];
|
|
911
|
+
async function generateAgedBalance(opts, params) {
|
|
912
|
+
const { AccountModel, JournalEntryModel, country, orgField } = opts;
|
|
913
|
+
requireOrgScope(orgField, params.organizationId);
|
|
914
|
+
const asOfDate = params.asOfDate ?? /* @__PURE__ */ new Date();
|
|
915
|
+
const buckets = params.buckets ?? DEFAULT_BUCKETS;
|
|
916
|
+
const bucketLabels = buckets.map((b) => b.label);
|
|
917
|
+
const dueDateField = params.dueDateField ?? "journalItems.dueDate";
|
|
918
|
+
const contactField = params.contactField;
|
|
919
|
+
const accountQuery = { active: true };
|
|
920
|
+
if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
|
|
921
|
+
let targetAccountIds;
|
|
922
|
+
if (params.accountIds && params.accountIds.length > 0) targetAccountIds = params.accountIds;
|
|
923
|
+
else {
|
|
924
|
+
const allAccounts = await AccountModel.find(accountQuery).lean();
|
|
925
|
+
const categoryPrefix = params.type === "receivable" ? "Balance Sheet-Asset" : "Balance Sheet-Liability";
|
|
926
|
+
targetAccountIds = allAccounts.filter((a) => {
|
|
927
|
+
const at = country.getAccountType(a.accountTypeCode);
|
|
928
|
+
return at && !at.isGroup && at.category.startsWith(categoryPrefix);
|
|
929
|
+
}).map((a) => a._id);
|
|
930
|
+
}
|
|
931
|
+
if (targetAccountIds.length === 0) return {
|
|
932
|
+
metadata: {
|
|
933
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
934
|
+
asOfDate: asOfDate.toISOString().split("T")[0],
|
|
935
|
+
type: params.type
|
|
936
|
+
},
|
|
937
|
+
bucketLabels,
|
|
938
|
+
rows: [],
|
|
939
|
+
totals: Object.fromEntries(bucketLabels.map((l) => [l, 0])),
|
|
940
|
+
grandTotal: 0
|
|
941
|
+
};
|
|
942
|
+
const allAccounts = await AccountModel.find(accountQuery).lean();
|
|
943
|
+
const accountLookup = new Map(allAccounts.map((a) => [String(a._id), a]));
|
|
944
|
+
const baseMatch = {
|
|
945
|
+
state: "posted",
|
|
946
|
+
date: { $lte: asOfDate }
|
|
947
|
+
};
|
|
948
|
+
if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
|
|
949
|
+
asOfDate.getTime();
|
|
950
|
+
const groupId = { account: "$journalItems.account" };
|
|
951
|
+
if (contactField) groupId.contact = `$${contactField}`;
|
|
952
|
+
const bucketBranches = buckets.map((b) => {
|
|
953
|
+
return {
|
|
954
|
+
case: b.maxDays === Infinity ? { $gte: ["$daysPastDue", b.minDays] } : { $and: [{ $gte: ["$daysPastDue", b.minDays] }, { $lt: ["$daysPastDue", b.maxDays] }] },
|
|
955
|
+
then: b.label
|
|
956
|
+
};
|
|
957
|
+
});
|
|
958
|
+
const pipeline = [
|
|
959
|
+
{ $match: baseMatch },
|
|
960
|
+
{ $unwind: "$journalItems" },
|
|
961
|
+
{ $match: { "journalItems.account": { $in: targetAccountIds } } },
|
|
962
|
+
{ $addFields: { daysPastDue: { $floor: { $divide: [{ $subtract: [asOfDate, { $ifNull: [`$${dueDateField}`, asOfDate] }] }, 1e3 * 60 * 60 * 24] } } } },
|
|
963
|
+
{ $addFields: { daysPastDue: { $max: ["$daysPastDue", 0] } } },
|
|
964
|
+
{ $addFields: { bucketLabel: { $switch: {
|
|
965
|
+
branches: bucketBranches,
|
|
966
|
+
default: bucketLabels[bucketLabels.length - 1]
|
|
967
|
+
} } } },
|
|
968
|
+
{ $addFields: { netAmount: params.type === "receivable" ? { $subtract: ["$journalItems.debit", "$journalItems.credit"] } : { $subtract: ["$journalItems.credit", "$journalItems.debit"] } } },
|
|
969
|
+
{ $group: {
|
|
970
|
+
_id: {
|
|
971
|
+
...groupId,
|
|
972
|
+
bucket: "$bucketLabel"
|
|
973
|
+
},
|
|
974
|
+
amount: { $sum: "$netAmount" }
|
|
975
|
+
} }
|
|
976
|
+
];
|
|
977
|
+
const results = await JournalEntryModel.aggregate(pipeline);
|
|
978
|
+
const rowKey = (accountId, contactId) => contactField ? `${String(accountId)}::${String(contactId ?? "")}` : String(accountId);
|
|
979
|
+
const rowMap = /* @__PURE__ */ new Map();
|
|
980
|
+
for (const r of results) {
|
|
981
|
+
const key = rowKey(r._id.account, r._id.contact);
|
|
982
|
+
if (!rowMap.has(key)) {
|
|
983
|
+
const acc = accountLookup.get(String(r._id.account));
|
|
984
|
+
rowMap.set(key, {
|
|
985
|
+
accountId: r._id.account,
|
|
986
|
+
accountName: acc?.name ?? "",
|
|
987
|
+
accountCode: acc?.accountNumber ?? "",
|
|
988
|
+
...contactField ? { contactId: r._id.contact } : {},
|
|
989
|
+
total: 0,
|
|
990
|
+
buckets: Object.fromEntries(bucketLabels.map((l) => [l, 0]))
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
const row = rowMap.get(key);
|
|
994
|
+
if (row.buckets[r._id.bucket] !== void 0) row.buckets[r._id.bucket] += r.amount;
|
|
995
|
+
row.total += r.amount;
|
|
996
|
+
}
|
|
997
|
+
const rows = Array.from(rowMap.values()).sort((a, b) => a.accountCode.localeCompare(b.accountCode, void 0, { numeric: true }));
|
|
998
|
+
const totals = Object.fromEntries(bucketLabels.map((l) => [l, 0]));
|
|
999
|
+
let grandTotal = 0;
|
|
1000
|
+
for (const row of rows) {
|
|
1001
|
+
for (const label of bucketLabels) totals[label] += row.buckets[label];
|
|
1002
|
+
grandTotal += row.total;
|
|
1003
|
+
}
|
|
1004
|
+
return {
|
|
1005
|
+
metadata: {
|
|
1006
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1007
|
+
asOfDate: asOfDate.toISOString().split("T")[0],
|
|
1008
|
+
type: params.type
|
|
1009
|
+
},
|
|
1010
|
+
bucketLabels,
|
|
1011
|
+
rows,
|
|
1012
|
+
totals,
|
|
1013
|
+
grandTotal
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
//#endregion
|
|
1017
|
+
//#region src/reports/dimension-breakdown.ts
|
|
1018
|
+
async function generateDimensionBreakdown(opts, params) {
|
|
1019
|
+
const { AccountModel, JournalEntryModel, country, orgField } = opts;
|
|
1020
|
+
requireOrgScope(orgField, params.organizationId);
|
|
1021
|
+
const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
|
|
1022
|
+
const itemFilters = buildItemFilters(params.filters);
|
|
1023
|
+
const accountQuery = { active: true };
|
|
1024
|
+
if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
|
|
1025
|
+
const allAccounts = await AccountModel.find(accountQuery).lean();
|
|
1026
|
+
const accountIds = allAccounts.filter((a) => {
|
|
1027
|
+
const at = country.getAccountType(a.accountTypeCode);
|
|
1028
|
+
if (!at || at.isGroup || at.isTotal) return false;
|
|
1029
|
+
if (params.accountCategory && at.category !== params.accountCategory) return false;
|
|
1030
|
+
return true;
|
|
1031
|
+
}).map((a) => a._id);
|
|
1032
|
+
if (accountIds.length === 0) return {
|
|
1033
|
+
metadata: {
|
|
1034
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1035
|
+
dimension: params.dimension,
|
|
1036
|
+
periodStart: startDate.toISOString().split("T")[0],
|
|
1037
|
+
periodEnd: endDate.toISOString().split("T")[0]
|
|
1038
|
+
},
|
|
1039
|
+
rows: [],
|
|
1040
|
+
grandTotal: 0
|
|
1041
|
+
};
|
|
1042
|
+
const accountMap = new Map(allAccounts.map((a) => [String(a._id), a]));
|
|
1043
|
+
const dimensionPath = `journalItems.${params.dimension}`;
|
|
1044
|
+
const baseMatch = {
|
|
1045
|
+
state: "posted",
|
|
1046
|
+
date: {
|
|
1047
|
+
$gte: startDate,
|
|
1048
|
+
$lte: endDate
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
|
|
1052
|
+
const pipeline = [
|
|
1053
|
+
{ $match: baseMatch },
|
|
1054
|
+
{ $unwind: "$journalItems" },
|
|
1055
|
+
{ $match: {
|
|
1056
|
+
"journalItems.account": { $in: accountIds },
|
|
1057
|
+
...itemFilters
|
|
1058
|
+
} },
|
|
1059
|
+
{ $group: {
|
|
1060
|
+
_id: {
|
|
1061
|
+
dimension: `$${dimensionPath}`,
|
|
1062
|
+
account: "$journalItems.account"
|
|
1063
|
+
},
|
|
1064
|
+
d: { $sum: "$journalItems.debit" },
|
|
1065
|
+
c: { $sum: "$journalItems.credit" }
|
|
1066
|
+
} }
|
|
1067
|
+
];
|
|
1068
|
+
const results = await JournalEntryModel.aggregate(pipeline);
|
|
1069
|
+
const dimensionMap = /* @__PURE__ */ new Map();
|
|
1070
|
+
for (const r of results) {
|
|
1071
|
+
const dimKey = r._id.dimension == null ? "__null__" : String(r._id.dimension);
|
|
1072
|
+
const accKey = String(r._id.account);
|
|
1073
|
+
if (!dimensionMap.has(dimKey)) dimensionMap.set(dimKey, /* @__PURE__ */ new Map());
|
|
1074
|
+
dimensionMap.get(dimKey).set(accKey, {
|
|
1075
|
+
d: r.d,
|
|
1076
|
+
c: r.c
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
const rows = [];
|
|
1080
|
+
const sortedDimKeys = [...dimensionMap.keys()].sort((a, b) => {
|
|
1081
|
+
if (a === "__null__") return 1;
|
|
1082
|
+
if (b === "__null__") return -1;
|
|
1083
|
+
return a.localeCompare(b);
|
|
1084
|
+
});
|
|
1085
|
+
for (const dimKey of sortedDimKeys) {
|
|
1086
|
+
const accountBalances = dimensionMap.get(dimKey);
|
|
1087
|
+
const dimensionValue = dimKey === "__null__" ? null : results.find((r) => String(r._id.dimension) === dimKey)?._id.dimension ?? null;
|
|
1088
|
+
const accounts = [];
|
|
1089
|
+
let total = 0;
|
|
1090
|
+
for (const [accId, bal] of accountBalances) {
|
|
1091
|
+
const acc = accountMap.get(accId);
|
|
1092
|
+
if (!acc) continue;
|
|
1093
|
+
const at = country.getAccountType(acc.accountTypeCode);
|
|
1094
|
+
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;
|
|
1095
|
+
accounts.push({
|
|
1096
|
+
id: acc._id,
|
|
1097
|
+
name: acc.name ?? at?.name ?? "",
|
|
1098
|
+
code: acc.accountNumber ?? at?.code ?? "",
|
|
1099
|
+
balance
|
|
1100
|
+
});
|
|
1101
|
+
total += balance;
|
|
1102
|
+
}
|
|
1103
|
+
accounts.sort((a, b) => a.code.localeCompare(b.code, void 0, { numeric: true }));
|
|
1104
|
+
rows.push({
|
|
1105
|
+
dimensionValue,
|
|
1106
|
+
accounts,
|
|
1107
|
+
total
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
const grandTotal = rows.reduce((s, r) => s + r.total, 0);
|
|
1111
|
+
return {
|
|
1112
|
+
metadata: {
|
|
1113
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1114
|
+
dimension: params.dimension,
|
|
1115
|
+
periodStart: startDate.toISOString().split("T")[0],
|
|
1116
|
+
periodEnd: endDate.toISOString().split("T")[0]
|
|
1117
|
+
},
|
|
1118
|
+
rows,
|
|
1119
|
+
grandTotal
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
//#endregion
|
|
1123
|
+
//#region src/reports/budget-vs-actual.ts
|
|
1124
|
+
/**
|
|
1125
|
+
* Budget vs Actual Report
|
|
1126
|
+
*
|
|
1127
|
+
* Compares budgeted amounts against actual journal entry balances
|
|
1128
|
+
* for a given period. All monetary values are integer cents.
|
|
1129
|
+
*/
|
|
1130
|
+
async function generateBudgetVsActual(opts, params) {
|
|
1131
|
+
const { AccountModel, JournalEntryModel, BudgetModel, country, orgField } = opts;
|
|
1132
|
+
requireOrgScope(orgField, params.organizationId);
|
|
1133
|
+
const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
|
|
1134
|
+
const budgetQuery = {
|
|
1135
|
+
periodStart: { $lte: endDate },
|
|
1136
|
+
periodEnd: { $gte: startDate }
|
|
1137
|
+
};
|
|
1138
|
+
if (orgField && params.organizationId) budgetQuery[orgField] = params.organizationId;
|
|
1139
|
+
if (params.accountIds && params.accountIds.length > 0) budgetQuery.account = { $in: params.accountIds };
|
|
1140
|
+
const budgets = await BudgetModel.find(budgetQuery).lean();
|
|
1141
|
+
if (budgets.length === 0) return {
|
|
1142
|
+
metadata: {
|
|
1143
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1144
|
+
periodStart: startDate.toISOString(),
|
|
1145
|
+
periodEnd: endDate.toISOString()
|
|
1146
|
+
},
|
|
1147
|
+
rows: [],
|
|
1148
|
+
summary: {
|
|
1149
|
+
totalBudget: 0,
|
|
1150
|
+
totalActual: 0,
|
|
1151
|
+
totalVariance: 0
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
1154
|
+
const budgetByAccount = /* @__PURE__ */ new Map();
|
|
1155
|
+
for (const b of budgets) {
|
|
1156
|
+
const key = String(b.account);
|
|
1157
|
+
budgetByAccount.set(key, (budgetByAccount.get(key) ?? 0) + b.amount);
|
|
1158
|
+
}
|
|
1159
|
+
const accountIds = [...budgetByAccount.keys()];
|
|
1160
|
+
const accountQuery = { _id: { $in: accountIds } };
|
|
1161
|
+
if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
|
|
1162
|
+
const accounts = await AccountModel.find(accountQuery).lean();
|
|
1163
|
+
const accountMap = new Map(accounts.map((a) => [String(a._id), a]));
|
|
1164
|
+
const baseMatch = {
|
|
1165
|
+
state: "posted",
|
|
1166
|
+
date: {
|
|
1167
|
+
$gte: startDate,
|
|
1168
|
+
$lte: endDate
|
|
1169
|
+
}
|
|
1170
|
+
};
|
|
1171
|
+
if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
|
|
1172
|
+
const pipeline = [
|
|
1173
|
+
{ $match: baseMatch },
|
|
1174
|
+
{ $unwind: "$journalItems" },
|
|
1175
|
+
{ $match: { "journalItems.account": { $in: accountIds.map((id) => new mongoose.Types.ObjectId(id)) } } },
|
|
1176
|
+
{ $group: {
|
|
1177
|
+
_id: "$journalItems.account",
|
|
1178
|
+
totalDebit: { $sum: "$journalItems.debit" },
|
|
1179
|
+
totalCredit: { $sum: "$journalItems.credit" }
|
|
1180
|
+
} }
|
|
1181
|
+
];
|
|
1182
|
+
const actuals = await JournalEntryModel.aggregate(pipeline);
|
|
1183
|
+
const actualByAccount = /* @__PURE__ */ new Map();
|
|
1184
|
+
for (const a of actuals) actualByAccount.set(String(a._id), {
|
|
1185
|
+
debit: a.totalDebit,
|
|
1186
|
+
credit: a.totalCredit
|
|
1187
|
+
});
|
|
1188
|
+
const rows = [];
|
|
1189
|
+
for (const [accountId, budgetAmount] of budgetByAccount) {
|
|
1190
|
+
const acc = accountMap.get(accountId);
|
|
1191
|
+
if (!acc) continue;
|
|
1192
|
+
const at = country.getAccountType(acc.accountTypeCode);
|
|
1193
|
+
if (!at || at.isGroup) continue;
|
|
1194
|
+
const actual = actualByAccount.get(accountId) ?? {
|
|
1195
|
+
debit: 0,
|
|
1196
|
+
credit: 0
|
|
1197
|
+
};
|
|
1198
|
+
const mainType = extractMainType(at.category);
|
|
1199
|
+
let actualAmount;
|
|
1200
|
+
if (mainType === "Income") actualAmount = actual.credit - actual.debit;
|
|
1201
|
+
else if (mainType === "Expense") actualAmount = actual.debit - actual.credit;
|
|
1202
|
+
else if (mainType === "Asset") actualAmount = actual.debit - actual.credit;
|
|
1203
|
+
else actualAmount = actual.credit - actual.debit;
|
|
1204
|
+
const variance = actualAmount - budgetAmount;
|
|
1205
|
+
const variancePercent = budgetAmount !== 0 ? Math.round(variance / budgetAmount * 1e4) / 100 : 0;
|
|
1206
|
+
rows.push({
|
|
1207
|
+
accountId: acc._id,
|
|
1208
|
+
accountName: acc.name,
|
|
1209
|
+
accountCode: acc.accountNumber,
|
|
1210
|
+
category: at.category,
|
|
1211
|
+
budgetAmount,
|
|
1212
|
+
actualAmount,
|
|
1213
|
+
variance,
|
|
1214
|
+
variancePercent
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
rows.sort((a, b) => a.accountCode.localeCompare(b.accountCode));
|
|
1218
|
+
const summary = {
|
|
1219
|
+
totalBudget: rows.reduce((s, r) => s + r.budgetAmount, 0),
|
|
1220
|
+
totalActual: rows.reduce((s, r) => s + r.actualAmount, 0),
|
|
1221
|
+
totalVariance: rows.reduce((s, r) => s + r.variance, 0)
|
|
1222
|
+
};
|
|
1223
|
+
return {
|
|
1224
|
+
metadata: {
|
|
1225
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1226
|
+
periodStart: startDate.toISOString(),
|
|
1227
|
+
periodEnd: endDate.toISOString()
|
|
1228
|
+
},
|
|
1229
|
+
rows,
|
|
1230
|
+
summary
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
//#endregion
|
|
1234
|
+
//#region src/utils/revaluation.ts
|
|
1235
|
+
/**
|
|
1236
|
+
* Compute revaluation results for a set of accounts at new exchange rates.
|
|
1237
|
+
*
|
|
1238
|
+
* For each account, finds the matching rate by currency, computes the
|
|
1239
|
+
* revalued base amount, and determines the gain/loss.
|
|
1240
|
+
* Accounts with zero gain/loss are excluded from the results.
|
|
1241
|
+
*
|
|
1242
|
+
* @param accounts - Foreign-currency account balances at historical rates
|
|
1243
|
+
* @param rates - New exchange rates to revalue against
|
|
1244
|
+
* @param baseCurrency - The functional/base currency code (accounts in this currency are skipped)
|
|
1245
|
+
*/
|
|
1246
|
+
function computeRevaluation(accounts, rates, baseCurrency) {
|
|
1247
|
+
const rateMap = new Map(rates.map((r) => [r.currency, r.rate]));
|
|
1248
|
+
const results = [];
|
|
1249
|
+
for (const acct of accounts) {
|
|
1250
|
+
if (acct.currency === baseCurrency) continue;
|
|
1251
|
+
const rate = rateMap.get(acct.currency);
|
|
1252
|
+
if (rate === void 0) continue;
|
|
1253
|
+
const revaluedBase = Math.round(acct.foreignBalance * rate);
|
|
1254
|
+
const gainLoss = revaluedBase - acct.baseBalance;
|
|
1255
|
+
if (gainLoss === 0) continue;
|
|
1256
|
+
results.push({
|
|
1257
|
+
accountId: acct.accountId,
|
|
1258
|
+
accountName: acct.accountName,
|
|
1259
|
+
accountCode: acct.accountCode,
|
|
1260
|
+
currency: acct.currency,
|
|
1261
|
+
foreignBalance: acct.foreignBalance,
|
|
1262
|
+
historicalBase: acct.baseBalance,
|
|
1263
|
+
revaluedBase,
|
|
1264
|
+
gainLoss
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
return results;
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Build a balanced revaluation journal entry from revaluation results.
|
|
1271
|
+
*
|
|
1272
|
+
* For each result with a non-zero gain/loss:
|
|
1273
|
+
* - Gain (positive gainLoss): Debit the account, Credit the unrealized gain/loss account
|
|
1274
|
+
* - Loss (negative gainLoss): Credit the account, Debit the unrealized gain/loss account
|
|
1275
|
+
*
|
|
1276
|
+
* @param results - Revaluation results from computeRevaluation
|
|
1277
|
+
* @param unrealizedGainLossAccountId - The account to book the offsetting entry against
|
|
1278
|
+
* @param date - Date for the revaluation entry
|
|
1279
|
+
*/
|
|
1280
|
+
function buildRevaluationEntry(results, unrealizedGainLossAccountId, date) {
|
|
1281
|
+
const journalItems = [];
|
|
1282
|
+
let totalDebit = 0;
|
|
1283
|
+
let totalCredit = 0;
|
|
1284
|
+
for (const r of results) {
|
|
1285
|
+
if (r.gainLoss === 0) continue;
|
|
1286
|
+
const absAmount = Math.abs(r.gainLoss);
|
|
1287
|
+
if (r.gainLoss > 0) {
|
|
1288
|
+
journalItems.push({
|
|
1289
|
+
account: r.accountId,
|
|
1290
|
+
debit: absAmount,
|
|
1291
|
+
credit: 0,
|
|
1292
|
+
originalDebit: 0,
|
|
1293
|
+
originalCredit: 0,
|
|
1294
|
+
label: `FX revaluation ${r.currency} — gain`
|
|
1295
|
+
});
|
|
1296
|
+
journalItems.push({
|
|
1297
|
+
account: unrealizedGainLossAccountId,
|
|
1298
|
+
debit: 0,
|
|
1299
|
+
credit: absAmount,
|
|
1300
|
+
originalDebit: 0,
|
|
1301
|
+
originalCredit: 0,
|
|
1302
|
+
label: `FX revaluation ${r.currency} — gain`
|
|
1303
|
+
});
|
|
1304
|
+
} else {
|
|
1305
|
+
journalItems.push({
|
|
1306
|
+
account: r.accountId,
|
|
1307
|
+
debit: 0,
|
|
1308
|
+
credit: absAmount,
|
|
1309
|
+
originalDebit: 0,
|
|
1310
|
+
originalCredit: 0,
|
|
1311
|
+
label: `FX revaluation ${r.currency} — loss`
|
|
1312
|
+
});
|
|
1313
|
+
journalItems.push({
|
|
1314
|
+
account: unrealizedGainLossAccountId,
|
|
1315
|
+
debit: absAmount,
|
|
1316
|
+
credit: 0,
|
|
1317
|
+
originalDebit: 0,
|
|
1318
|
+
originalCredit: 0,
|
|
1319
|
+
label: `FX revaluation ${r.currency} — loss`
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
totalDebit += absAmount;
|
|
1323
|
+
totalCredit += absAmount;
|
|
1324
|
+
}
|
|
1325
|
+
const dateStr = date.toISOString().split("T")[0];
|
|
1326
|
+
return {
|
|
1327
|
+
journalItems,
|
|
1328
|
+
totalDebit,
|
|
1329
|
+
totalCredit,
|
|
1330
|
+
label: `Foreign exchange revaluation — ${dateStr}`
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
//#endregion
|
|
1334
|
+
//#region src/reports/revaluation.ts
|
|
1335
|
+
/**
|
|
1336
|
+
* Generate a foreign exchange revaluation report.
|
|
1337
|
+
*
|
|
1338
|
+
* 1. Finds all accounts with a `currency` field (foreign-currency accounts)
|
|
1339
|
+
* 2. Filters to balance sheet accounts only (not P&L)
|
|
1340
|
+
* 3. Aggregates foreign-currency and base-currency balances from posted entries
|
|
1341
|
+
* 4. Computes gain/loss at the new rates
|
|
1342
|
+
* 5. Optionally creates and saves a balanced journal entry
|
|
1343
|
+
*/
|
|
1344
|
+
async function generateRevaluation(opts, params) {
|
|
1345
|
+
const { AccountModel, JournalEntryModel, country, orgField, baseCurrency } = opts;
|
|
1346
|
+
requireOrgScope(orgField, params.organizationId);
|
|
1347
|
+
const accountQuery = {
|
|
1348
|
+
active: true,
|
|
1349
|
+
currency: {
|
|
1350
|
+
$exists: true,
|
|
1351
|
+
$ne: null
|
|
1352
|
+
}
|
|
1353
|
+
};
|
|
1354
|
+
if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
|
|
1355
|
+
const bsAccounts = (await AccountModel.find(accountQuery).lean()).filter((a) => {
|
|
1356
|
+
const at = country.getAccountType(a.accountTypeCode);
|
|
1357
|
+
return at && !at.isGroup && at.category.startsWith("Balance Sheet");
|
|
1358
|
+
});
|
|
1359
|
+
if (bsAccounts.length === 0) return {
|
|
1360
|
+
metadata: {
|
|
1361
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1362
|
+
asOfDate: params.asOfDate.toISOString().split("T")[0],
|
|
1363
|
+
baseCurrency
|
|
1364
|
+
},
|
|
1365
|
+
results: [],
|
|
1366
|
+
totalGainLoss: 0
|
|
1367
|
+
};
|
|
1368
|
+
const bsAccountIds = bsAccounts.map((a) => a._id);
|
|
1369
|
+
const baseMatch = {
|
|
1370
|
+
state: "posted",
|
|
1371
|
+
date: { $lte: params.asOfDate }
|
|
1372
|
+
};
|
|
1373
|
+
if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
|
|
1374
|
+
const balanceResults = await JournalEntryModel.aggregate([
|
|
1375
|
+
{ $match: baseMatch },
|
|
1376
|
+
{ $unwind: "$journalItems" },
|
|
1377
|
+
{ $match: { "journalItems.account": { $in: bsAccountIds } } },
|
|
1378
|
+
{ $group: {
|
|
1379
|
+
_id: "$journalItems.account",
|
|
1380
|
+
debit: { $sum: "$journalItems.debit" },
|
|
1381
|
+
credit: { $sum: "$journalItems.credit" },
|
|
1382
|
+
originalDebit: { $sum: { $ifNull: ["$journalItems.originalDebit", 0] } },
|
|
1383
|
+
originalCredit: { $sum: { $ifNull: ["$journalItems.originalCredit", 0] } }
|
|
1384
|
+
} }
|
|
1385
|
+
]);
|
|
1386
|
+
const accountMap = new Map(bsAccounts.map((a) => [String(a._id), a]));
|
|
1387
|
+
const accountBalances = [];
|
|
1388
|
+
for (const r of balanceResults) {
|
|
1389
|
+
const acct = accountMap.get(String(r._id));
|
|
1390
|
+
if (!acct) continue;
|
|
1391
|
+
const at = country.getAccountType(acct.accountTypeCode);
|
|
1392
|
+
if (!at) continue;
|
|
1393
|
+
accountBalances.push({
|
|
1394
|
+
accountId: r._id,
|
|
1395
|
+
accountName: acct.name ?? at.name,
|
|
1396
|
+
accountCode: acct.accountNumber ?? at.code,
|
|
1397
|
+
currency: acct.currency,
|
|
1398
|
+
foreignBalance: r.originalDebit - r.originalCredit,
|
|
1399
|
+
baseBalance: r.debit - r.credit,
|
|
1400
|
+
category: at.category
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
const results = computeRevaluation(accountBalances, params.rates, baseCurrency);
|
|
1404
|
+
const totalGainLoss = results.reduce((sum, r) => sum + r.gainLoss, 0);
|
|
1405
|
+
let entryId;
|
|
1406
|
+
if (params.generateEntry && results.length > 0) {
|
|
1407
|
+
const entryData = buildRevaluationEntry(results, params.unrealizedGainLossAccountId, params.asOfDate);
|
|
1408
|
+
const doc = {
|
|
1409
|
+
journalType: "GENERAL",
|
|
1410
|
+
state: "posted",
|
|
1411
|
+
date: params.asOfDate,
|
|
1412
|
+
label: entryData.label,
|
|
1413
|
+
journalItems: entryData.journalItems,
|
|
1414
|
+
totalDebit: entryData.totalDebit,
|
|
1415
|
+
totalCredit: entryData.totalCredit
|
|
1416
|
+
};
|
|
1417
|
+
if (orgField && params.organizationId) doc[orgField] = params.organizationId;
|
|
1418
|
+
entryId = (await JournalEntryModel.create(doc))._id;
|
|
1419
|
+
}
|
|
1420
|
+
return {
|
|
1421
|
+
metadata: {
|
|
1422
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1423
|
+
asOfDate: params.asOfDate.toISOString().split("T")[0],
|
|
1424
|
+
baseCurrency
|
|
1425
|
+
},
|
|
1426
|
+
results,
|
|
1427
|
+
totalGainLoss,
|
|
1428
|
+
...entryId !== void 0 ? { entryId } : {}
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
//#endregion
|
|
1432
|
+
//#region src/utils/logger.ts
|
|
1433
|
+
/** Default console-based implementation */
|
|
1434
|
+
const defaultLogger = {
|
|
1435
|
+
warn: (msg, meta) => console.warn(`[accounting] ${msg}`, meta ?? ""),
|
|
1436
|
+
error: (msg, meta) => console.error(`[accounting] ${msg}`, meta ?? ""),
|
|
1437
|
+
info: (msg, meta) => console.info(`[accounting] ${msg}`, meta ?? "")
|
|
1438
|
+
};
|
|
1439
|
+
//#endregion
|
|
1440
|
+
//#region src/utils/session.ts
|
|
1441
|
+
/**
|
|
1442
|
+
* Acquire a session: uses external if provided, otherwise creates an internal one.
|
|
1443
|
+
* Returns { session, ownSession } so callers can commit/abort/end appropriately.
|
|
1444
|
+
*
|
|
1445
|
+
* When transactions are unavailable (no replica set / standalone), returns
|
|
1446
|
+
* session=null and the function runs without transactional safety.
|
|
1447
|
+
*/
|
|
1448
|
+
async function acquireSession(db, externalSession, logger = defaultLogger) {
|
|
1449
|
+
if (externalSession) return {
|
|
1450
|
+
session: externalSession,
|
|
1451
|
+
ownSession: false
|
|
1452
|
+
};
|
|
1453
|
+
try {
|
|
1454
|
+
const session = await db.startSession();
|
|
1455
|
+
try {
|
|
1456
|
+
const conn = db;
|
|
1457
|
+
if ((conn.getClient?.() ?? conn.client)?.topology?.description?.type === "Single") {
|
|
1458
|
+
session.endSession();
|
|
1459
|
+
logger.warn("Transactions unavailable (standalone MongoDB). Operation is not atomic.");
|
|
1460
|
+
return {
|
|
1461
|
+
session: null,
|
|
1462
|
+
ownSession: false
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
} catch {}
|
|
1466
|
+
try {
|
|
1467
|
+
session.startTransaction();
|
|
1468
|
+
return {
|
|
1469
|
+
session,
|
|
1470
|
+
ownSession: true
|
|
1471
|
+
};
|
|
1472
|
+
} catch (err) {
|
|
1473
|
+
session.endSession();
|
|
1474
|
+
logger.warn("Transactions unavailable (no replica set). Operation is not atomic.", { error: err.message });
|
|
1475
|
+
return {
|
|
1476
|
+
session: null,
|
|
1477
|
+
ownSession: false
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
} catch {
|
|
1481
|
+
return {
|
|
1482
|
+
session: null,
|
|
1483
|
+
ownSession: false
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* Finalize an owned session: commit or abort, then always end.
|
|
1489
|
+
*/
|
|
1490
|
+
async function finalizeSession(session, ownSession, success) {
|
|
1491
|
+
if (!ownSession || !session) return;
|
|
1492
|
+
try {
|
|
1493
|
+
if (success && session.inTransaction()) await session.commitTransaction();
|
|
1494
|
+
else if (!success && session.inTransaction()) try {
|
|
1495
|
+
await session.abortTransaction();
|
|
1496
|
+
} catch {}
|
|
1497
|
+
} finally {
|
|
1498
|
+
session.endSession();
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
789
1501
|
//#endregion
|
|
790
1502
|
//#region src/reports/fiscal-close.ts
|
|
791
1503
|
async function closeFiscalPeriod(opts, params) {
|
|
792
|
-
const { AccountModel, JournalEntryModel, FiscalPeriodModel, country, orgField,
|
|
1504
|
+
const { AccountModel, JournalEntryModel, FiscalPeriodModel, country, orgField, retainedEarningsAccountCode = country.retainedEarningsAccountCode ?? "3600", logger = defaultLogger } = opts;
|
|
793
1505
|
const { periodId, organizationId, closedBy } = params;
|
|
794
1506
|
requireOrgScope(orgField, organizationId);
|
|
795
1507
|
const { session, ownSession } = await acquireSession(AccountModel.db, params.session, logger);
|
|
@@ -811,7 +1523,7 @@ async function closeFiscalPeriod(opts, params) {
|
|
|
811
1523
|
for (const acc of allAccounts) {
|
|
812
1524
|
const at = country.getAccountType(acc.accountTypeCode);
|
|
813
1525
|
if (!at) continue;
|
|
814
|
-
if (acc.accountTypeCode ===
|
|
1526
|
+
if (acc.accountTypeCode === retainedEarningsAccountCode) retainedEarningsId = acc._id;
|
|
815
1527
|
if (at.isGroup || at.isTotal) continue;
|
|
816
1528
|
if (at.category.startsWith("Income Statement")) isAccounts.push({
|
|
817
1529
|
id: acc._id,
|
|
@@ -819,7 +1531,7 @@ async function closeFiscalPeriod(opts, params) {
|
|
|
819
1531
|
isIncome: at.category === "Income Statement-Income"
|
|
820
1532
|
});
|
|
821
1533
|
}
|
|
822
|
-
if (!retainedEarningsId) throw Errors.fiscal(`Retained earnings account (code: ${
|
|
1534
|
+
if (!retainedEarningsId) throw Errors.fiscal(`Retained earnings account (code: ${retainedEarningsAccountCode}) not found. Create this account before closing the fiscal period.`);
|
|
823
1535
|
const baseMatch = {
|
|
824
1536
|
state: "posted",
|
|
825
1537
|
date: {
|
|
@@ -940,7 +1652,5 @@ async function reopenFiscalPeriod(opts, params) {
|
|
|
940
1652
|
await finalizeSession(session, ownSession, success);
|
|
941
1653
|
}
|
|
942
1654
|
}
|
|
943
|
-
|
|
944
1655
|
//#endregion
|
|
945
|
-
export {
|
|
946
|
-
//# sourceMappingURL=fiscal-close-DuXDgVvb.mjs.map
|
|
1656
|
+
export { getDateRange as C, buildItemFilters as S, buildAccountTypeMap as _, defaultLogger as a, isVirtualTaxAccount as b, computeRevaluation as c, DEFAULT_BUCKETS as d, generateAgedBalance as f, generateBalanceSheet as g, generateIncomeStatement as h, finalizeSession as i, generateBudgetVsActual as l, generateGeneralLedger as m, reopenFiscalPeriod as n, generateRevaluation as o, generateCashFlow as p, acquireSession as r, buildRevaluationEntry as s, closeFiscalPeriod as t, generateDimensionBreakdown as u, calculateTotal as v, getFiscalYearStart as w, generateTrialBalance as x, computeEndingBalance as y };
|