@classytic/ledger 0.2.0 → 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-kDKwDt0I.mjs → account.repository-BpkSd6q3.mjs} +189 -38
- package/dist/categories-CclX7Q94.mjs +0 -2
- 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-4WAbFRlw.d.mts +1 -2
- package/dist/currencies-W8kQAkm0.mjs +0 -2
- package/dist/{idempotency.plugin-v9NQ_ta-.mjs → date-lock.plugin-eYAJ9h_u.mjs} +49 -9
- package/dist/{engine-BzBMpWuy.d.mts → engine-Cn-9yerQ.d.mts} +11 -7
- package/dist/errors-B7yC-Jfw.mjs +0 -2
- package/dist/exports-I5Xkq-9_.mjs +0 -2
- package/dist/{fiscal-close-L631E3De.mjs → fiscal-close-B6LhQ10f.mjs} +737 -20
- package/dist/fiscal-period.schema-BMnlI9H5.d.mts +103 -0
- package/dist/{idempotency.plugin-CPxPt4vX.d.mts → idempotency.plugin-B_CNsInz.d.mts} +19 -17
- package/dist/index-BPukb3L8.d.mts +1 -2
- package/dist/{index-ZnSiqHYV.d.mts → index-CxZqRaOU.d.mts} +20 -6
- package/dist/index.d.mts +248 -26
- package/dist/index.mjs +119 -21
- package/dist/journals-oH-FK3g8.mjs +0 -2
- package/dist/{logger-UbTdBb1x.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 -2
- package/dist/reconciliation.repository-CW4-8q90.d.mts +135 -0
- package/dist/{fiscal-period.schema-BQ5wsAq3.mjs → reconciliation.schema-BuetvZTd.mjs} +168 -24
- 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/revaluation-D9x0NE8w.d.mts +530 -0
- package/dist/schemas/index.d.mts +71 -2
- package/dist/schemas/index.mjs +2 -2
- package/dist/tenant-guard-Fm6AID_6.mjs +13 -0
- package/docs/reports.md +1 -1
- package/package.json +2 -2
- 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,13 @@
|
|
|
1
1
|
import { n as Errors } from "./errors-B7yC-Jfw.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { t as requireOrgScope } from "./tenant-guard-Fm6AID_6.mjs";
|
|
3
3
|
import { i as extractMainType } from "./categories-CclX7Q94.mjs";
|
|
4
|
+
import mongoose from "mongoose";
|
|
4
5
|
//#region src/utils/date-range.ts
|
|
5
6
|
/**
|
|
6
7
|
* Compute start/end dates from a date option + value.
|
|
7
8
|
*
|
|
9
|
+
* @throws {Error} If value is null/undefined/invalid for the given option
|
|
10
|
+
*
|
|
8
11
|
* Examples:
|
|
9
12
|
* getDateRange('month', '2025-03') → Mar 1 – Mar 31
|
|
10
13
|
* getDateRange('quarter', { quarter: 2, year: 2025 }) → Apr 1 – Jun 30
|
|
@@ -12,6 +15,7 @@ import { i as extractMainType } from "./categories-CclX7Q94.mjs";
|
|
|
12
15
|
* getDateRange('custom', { startDate, endDate })
|
|
13
16
|
*/
|
|
14
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}"`);
|
|
15
19
|
switch (option) {
|
|
16
20
|
case "month": {
|
|
17
21
|
let year;
|
|
@@ -22,16 +26,21 @@ function getDateRange(option, value) {
|
|
|
22
26
|
month = parseInt(match[2], 10) - 1;
|
|
23
27
|
} else {
|
|
24
28
|
const date = new Date(value);
|
|
29
|
+
if (isNaN(date.getTime())) throw new Error(`Invalid month value: ${String(value)}`);
|
|
25
30
|
year = date.getFullYear();
|
|
26
31
|
month = date.getMonth();
|
|
27
32
|
}
|
|
33
|
+
if (year < 1900 || year > 9999) throw new Error(`Year ${year} is out of valid range (1900–9999)`);
|
|
28
34
|
return {
|
|
29
35
|
startDate: new Date(year, month, 1),
|
|
30
36
|
endDate: new Date(year, month + 1, 0, 23, 59, 59, 999)
|
|
31
37
|
};
|
|
32
38
|
}
|
|
33
39
|
case "quarter": {
|
|
40
|
+
if (typeof value !== "object" || value === null) throw new Error("Quarter dateValue must be an object with { quarter, year }");
|
|
34
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.`);
|
|
35
44
|
const startMonth = (quarter - 1) * 3;
|
|
36
45
|
return {
|
|
37
46
|
startDate: new Date(year, startMonth, 1),
|
|
@@ -40,17 +49,23 @@ function getDateRange(option, value) {
|
|
|
40
49
|
}
|
|
41
50
|
case "year": {
|
|
42
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.`);
|
|
43
53
|
return {
|
|
44
54
|
startDate: new Date(year, 0, 1),
|
|
45
55
|
endDate: new Date(year, 11, 31, 23, 59, 59, 999)
|
|
46
56
|
};
|
|
47
57
|
}
|
|
48
58
|
case "custom": {
|
|
49
|
-
|
|
50
|
-
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");
|
|
51
66
|
if (end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0 && end.getMilliseconds() === 0) end.setHours(23, 59, 59, 999);
|
|
52
67
|
return {
|
|
53
|
-
startDate:
|
|
68
|
+
startDate: start,
|
|
54
69
|
endDate: end
|
|
55
70
|
};
|
|
56
71
|
}
|
|
@@ -199,7 +214,31 @@ async function generateTrialBalance(opts, params) {
|
|
|
199
214
|
}
|
|
200
215
|
});
|
|
201
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
|
+
})}`;
|
|
202
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
|
+
},
|
|
203
242
|
rows,
|
|
204
243
|
period: {
|
|
205
244
|
startDate,
|
|
@@ -254,7 +293,7 @@ function buildAccountTypeMap(accountTypes) {
|
|
|
254
293
|
//#endregion
|
|
255
294
|
//#region src/reports/balance-sheet.ts
|
|
256
295
|
async function generateBalanceSheet(opts, params) {
|
|
257
|
-
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;
|
|
258
297
|
requireOrgScope(orgField, params.organizationId);
|
|
259
298
|
const { endDate } = getDateRange(params.dateOption, params.dateValue);
|
|
260
299
|
const fiscalYearStart = getFiscalYearStart(endDate, fiscalYearStartMonth);
|
|
@@ -266,13 +305,15 @@ async function generateBalanceSheet(opts, params) {
|
|
|
266
305
|
const at = country.getAccountType(a.accountTypeCode);
|
|
267
306
|
return at && !at.isGroup && at.category.startsWith("Balance Sheet");
|
|
268
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));
|
|
269
310
|
const isIds = allAccounts.filter((a) => {
|
|
270
311
|
const at = country.getAccountType(a.accountTypeCode);
|
|
271
312
|
return at && !at.isGroup && !at.isTotal && at.category.startsWith("Income Statement");
|
|
272
313
|
}).map((a) => a._id);
|
|
273
314
|
const baseMatch = { state: "posted" };
|
|
274
315
|
if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
|
|
275
|
-
const [bsResults, netIncomeResults, priorRetainedResults] = await Promise.all([
|
|
316
|
+
const [bsResults, netIncomeResults, priorRetainedResults, reAccountResults] = await Promise.all([
|
|
276
317
|
JournalEntryModel.aggregate([
|
|
277
318
|
{ $match: {
|
|
278
319
|
...baseMatch,
|
|
@@ -323,10 +364,27 @@ async function generateBalanceSheet(opts, params) {
|
|
|
323
364
|
d: { $sum: "$journalItems.debit" },
|
|
324
365
|
c: { $sum: "$journalItems.credit" }
|
|
325
366
|
} }
|
|
326
|
-
])
|
|
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([])]
|
|
327
384
|
]);
|
|
328
385
|
const netIncome = netIncomeResults.length > 0 ? netIncomeResults[0].c - netIncomeResults[0].d : 0;
|
|
329
|
-
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;
|
|
330
388
|
const accountMap = new Map(allAccounts.map((a) => [String(a._id), a]));
|
|
331
389
|
const accountTypeMap = buildAccountTypeMap(country.accountTypes);
|
|
332
390
|
const balanceMap = /* @__PURE__ */ new Map();
|
|
@@ -354,6 +412,7 @@ async function generateBalanceSheet(opts, params) {
|
|
|
354
412
|
for (const r of bsResults) {
|
|
355
413
|
const acc = accountMap.get(String(r._id));
|
|
356
414
|
if (!acc) continue;
|
|
415
|
+
if (reAccountIdSet.has(String(r._id))) continue;
|
|
357
416
|
const at = country.getAccountType(acc.accountTypeCode);
|
|
358
417
|
if (!at) continue;
|
|
359
418
|
const mainType = extractMainType(at.category) ?? "Asset";
|
|
@@ -382,7 +441,7 @@ async function generateBalanceSheet(opts, params) {
|
|
|
382
441
|
accounts: [{
|
|
383
442
|
id: "prior-retained",
|
|
384
443
|
name: "Previous Years Retained Earnings",
|
|
385
|
-
code:
|
|
444
|
+
code: retainedEarningsDisplayCode ?? retainedEarningsAccountCode ?? "",
|
|
386
445
|
balance: priorRetained
|
|
387
446
|
}, {
|
|
388
447
|
id: "current-year",
|
|
@@ -397,13 +456,24 @@ async function generateBalanceSheet(opts, params) {
|
|
|
397
456
|
groupsMap.Equity[reGroup.name].accounts.push(...reGroup.accounts);
|
|
398
457
|
groupsMap.Equity[reGroup.name].total += reGroup.total;
|
|
399
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
|
+
});
|
|
400
470
|
const pruneGroups = (groups) => Object.values(groups).map((g) => ({
|
|
401
471
|
...g,
|
|
402
472
|
accounts: g.accounts.filter((a) => a.balance !== 0 || a.isTotal || a.isCalculated)
|
|
403
473
|
})).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);
|
|
474
|
+
assets.groups = sortGroupsByCode(pruneGroups(groupsMap.Asset));
|
|
475
|
+
liabilities.groups = sortGroupsByCode(pruneGroups(groupsMap.Liability));
|
|
476
|
+
equity.groups = sortGroupsByCode(Object.values(groupsMap.Equity));
|
|
407
477
|
assets.total = assets.groups.reduce((s, g) => s + g.total, 0);
|
|
408
478
|
liabilities.total = liabilities.groups.reduce((s, g) => s + g.total, 0);
|
|
409
479
|
equity.total = equity.groups.reduce((s, g) => s + g.total, 0);
|
|
@@ -503,16 +573,26 @@ async function generateIncomeStatement(opts, params) {
|
|
|
503
573
|
});
|
|
504
574
|
groups[groupName].total += netAmount;
|
|
505
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
|
+
};
|
|
506
586
|
const labels = country.reportLabels ?? {};
|
|
507
587
|
const revenue = {
|
|
508
588
|
name: labels.revenue ?? "Revenue",
|
|
509
589
|
total: Object.values(revenueGroups).reduce((s, g) => s + g.total, 0),
|
|
510
|
-
groups:
|
|
590
|
+
groups: sortGroups(revenueGroups)
|
|
511
591
|
};
|
|
512
592
|
const expenses = {
|
|
513
593
|
name: labels.expenses ?? "Expenses",
|
|
514
594
|
total: Object.values(expenseGroups).reduce((s, g) => s + g.total, 0),
|
|
515
|
-
groups:
|
|
595
|
+
groups: sortGroups(expenseGroups)
|
|
516
596
|
};
|
|
517
597
|
const cogsCode = country.cogsGroupCode;
|
|
518
598
|
const isCogs = (name) => cogsCode ? name === cogsCode : name === "Cost of Sales" || name === "Cost of Goods Sold";
|
|
@@ -576,6 +656,11 @@ async function generateGeneralLedger(opts, params) {
|
|
|
576
656
|
endDate
|
|
577
657
|
}
|
|
578
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
|
+
});
|
|
579
664
|
const bsAccountIds = [];
|
|
580
665
|
const isAccountIds = [];
|
|
581
666
|
const allAccountIds = [];
|
|
@@ -674,7 +759,26 @@ async function generateGeneralLedger(opts, params) {
|
|
|
674
759
|
closingBalance: runningBalance
|
|
675
760
|
});
|
|
676
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
|
+
})}`;
|
|
677
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
|
+
},
|
|
678
782
|
accounts: glAccounts,
|
|
679
783
|
period: {
|
|
680
784
|
startDate,
|
|
@@ -756,6 +860,7 @@ async function generateCashFlow(opts, params) {
|
|
|
756
860
|
});
|
|
757
861
|
flows[meta.cfCategory].total += amount;
|
|
758
862
|
}
|
|
863
|
+
for (const section of Object.values(flows)) section.accounts.sort((a, b) => a.code.localeCompare(b.code, void 0, { numeric: true }));
|
|
759
864
|
const netCashFlow = flows.Operating.total + flows.Investing.total + flows.Financing.total;
|
|
760
865
|
const periodDisplay = `${startDate.toLocaleDateString("en-US", {
|
|
761
866
|
month: "short",
|
|
@@ -780,9 +885,623 @@ async function generateCashFlow(opts, params) {
|
|
|
780
885
|
};
|
|
781
886
|
}
|
|
782
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
|
+
}
|
|
1501
|
+
//#endregion
|
|
783
1502
|
//#region src/reports/fiscal-close.ts
|
|
784
1503
|
async function closeFiscalPeriod(opts, params) {
|
|
785
|
-
const { AccountModel, JournalEntryModel, FiscalPeriodModel, country, orgField,
|
|
1504
|
+
const { AccountModel, JournalEntryModel, FiscalPeriodModel, country, orgField, retainedEarningsAccountCode = country.retainedEarningsAccountCode ?? "3600", logger = defaultLogger } = opts;
|
|
786
1505
|
const { periodId, organizationId, closedBy } = params;
|
|
787
1506
|
requireOrgScope(orgField, organizationId);
|
|
788
1507
|
const { session, ownSession } = await acquireSession(AccountModel.db, params.session, logger);
|
|
@@ -804,7 +1523,7 @@ async function closeFiscalPeriod(opts, params) {
|
|
|
804
1523
|
for (const acc of allAccounts) {
|
|
805
1524
|
const at = country.getAccountType(acc.accountTypeCode);
|
|
806
1525
|
if (!at) continue;
|
|
807
|
-
if (acc.accountTypeCode ===
|
|
1526
|
+
if (acc.accountTypeCode === retainedEarningsAccountCode) retainedEarningsId = acc._id;
|
|
808
1527
|
if (at.isGroup || at.isTotal) continue;
|
|
809
1528
|
if (at.category.startsWith("Income Statement")) isAccounts.push({
|
|
810
1529
|
id: acc._id,
|
|
@@ -812,7 +1531,7 @@ async function closeFiscalPeriod(opts, params) {
|
|
|
812
1531
|
isIncome: at.category === "Income Statement-Income"
|
|
813
1532
|
});
|
|
814
1533
|
}
|
|
815
|
-
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.`);
|
|
816
1535
|
const baseMatch = {
|
|
817
1536
|
state: "posted",
|
|
818
1537
|
date: {
|
|
@@ -934,6 +1653,4 @@ async function reopenFiscalPeriod(opts, params) {
|
|
|
934
1653
|
}
|
|
935
1654
|
}
|
|
936
1655
|
//#endregion
|
|
937
|
-
export {
|
|
938
|
-
|
|
939
|
-
//# sourceMappingURL=fiscal-close-L631E3De.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 };
|