@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.
Files changed (70) hide show
  1. package/README.md +161 -64
  2. package/dist/{account.repository-Crf5DGO4.mjs → account.repository-BpkSd6q3.mjs} +190 -41
  3. package/dist/{categories-BNJBd4ze.mjs → categories-CclX7Q94.mjs} +0 -2
  4. package/dist/constants/index.d.mts +1 -1
  5. package/dist/constants/index.mjs +4 -5
  6. package/dist/{core-Cx0baosR.d.mts → core-8Xfnpn6g.d.mts} +1 -2
  7. package/dist/country/index.d.mts +2 -105
  8. package/dist/country/index.mjs +0 -2
  9. package/dist/{currencies-Bkn3FNkC.d.mts → currencies-4WAbFRlw.d.mts} +2 -3
  10. package/dist/{currencies-BBk3NwXn.mjs → currencies-W8kQAkm0.mjs} +0 -2
  11. package/dist/{idempotency.plugin-C6r8RI8d.mjs → date-lock.plugin-eYAJ9h_u.mjs} +50 -13
  12. package/dist/{engine-Cd73EOT6.d.mts → engine-Cn-9yerQ.d.mts} +38 -8
  13. package/dist/{errors-CeqRahE-.mjs → errors-B7yC-Jfw.mjs} +0 -2
  14. package/dist/exports/index.d.mts +2 -2
  15. package/dist/exports/index.mjs +2 -3
  16. package/dist/{universal-CMfrZ2hG.mjs → exports-I5Xkq-9_.mjs} +0 -7
  17. package/dist/{fiscal-close-DuXDgVvb.mjs → fiscal-close-B6LhQ10f.mjs} +742 -32
  18. package/dist/fiscal-period.schema-BMnlI9H5.d.mts +103 -0
  19. package/dist/{idempotency.plugin-BESs9YPD.d.mts → idempotency.plugin-B_CNsInz.d.mts} +19 -17
  20. package/dist/{universal-x33ZJODp.d.mts → index-BPukb3L8.d.mts} +1 -2
  21. package/dist/index-CxZqRaOU.d.mts +119 -0
  22. package/dist/index.d.mts +251 -29
  23. package/dist/index.mjs +124 -27
  24. package/dist/{journals-CI3Wb4EF.mjs → journals-oH-FK3g8.mjs} +0 -2
  25. package/dist/{logger-Cv6VVc4r.d.mts → logger-CbHWZl7v.d.mts} +1 -2
  26. package/dist/money.d.mts +1 -2
  27. package/dist/money.mjs +3 -3
  28. package/dist/plugins/index.d.mts +38 -2
  29. package/dist/plugins/index.mjs +57 -3
  30. package/dist/reconciliation.repository-CW4-8q90.d.mts +135 -0
  31. package/dist/{fiscal-period.schema-CbALaaKl.mjs → reconciliation.schema-BuetvZTd.mjs} +218 -30
  32. package/dist/reports/index.d.mts +2 -2
  33. package/dist/reports/index.mjs +2 -3
  34. package/dist/repositories/index.d.mts +2 -2
  35. package/dist/repositories/index.mjs +2 -3
  36. package/dist/revaluation-D9x0NE8w.d.mts +530 -0
  37. package/dist/schemas/index.d.mts +71 -2
  38. package/dist/schemas/index.mjs +2 -3
  39. package/dist/tenant-guard-Fm6AID_6.mjs +13 -0
  40. package/docs/reports.md +1 -1
  41. package/package.json +3 -3
  42. package/dist/account.repository-1C2sZvB2.d.mts +0 -29
  43. package/dist/account.repository-1C2sZvB2.d.mts.map +0 -1
  44. package/dist/account.repository-Crf5DGO4.mjs.map +0 -1
  45. package/dist/categories-BNJBd4ze.mjs.map +0 -1
  46. package/dist/core-Cx0baosR.d.mts.map +0 -1
  47. package/dist/country/index.d.mts.map +0 -1
  48. package/dist/country/index.mjs.map +0 -1
  49. package/dist/currencies-BBk3NwXn.mjs.map +0 -1
  50. package/dist/currencies-Bkn3FNkC.d.mts.map +0 -1
  51. package/dist/engine-Cd73EOT6.d.mts.map +0 -1
  52. package/dist/errors-CeqRahE-.mjs.map +0 -1
  53. package/dist/fiscal-close-CzUzpnMg.d.mts +0 -270
  54. package/dist/fiscal-close-CzUzpnMg.d.mts.map +0 -1
  55. package/dist/fiscal-close-DuXDgVvb.mjs.map +0 -1
  56. package/dist/fiscal-period.schema-CbALaaKl.mjs.map +0 -1
  57. package/dist/fiscal-period.schema-DI2scngu.d.mts +0 -38
  58. package/dist/fiscal-period.schema-DI2scngu.d.mts.map +0 -1
  59. package/dist/idempotency.plugin-BESs9YPD.d.mts.map +0 -1
  60. package/dist/idempotency.plugin-C6r8RI8d.mjs.map +0 -1
  61. package/dist/index.d.mts.map +0 -1
  62. package/dist/index.mjs.map +0 -1
  63. package/dist/journals-CI3Wb4EF.mjs.map +0 -1
  64. package/dist/logger-Cv6VVc4r.d.mts.map +0 -1
  65. package/dist/money.d.mts.map +0 -1
  66. package/dist/money.mjs.map +0 -1
  67. package/dist/session-Dh0s6zG4.mjs +0 -87
  68. package/dist/session-Dh0s6zG4.mjs.map +0 -1
  69. package/dist/universal-CMfrZ2hG.mjs.map +0 -1
  70. package/dist/universal-x33ZJODp.d.mts.map +0 -1
@@ -1,11 +1,13 @@
1
- import { n as Errors } from "./errors-CeqRahE-.mjs";
2
- import { i as requireOrgScope, n as finalizeSession, r as defaultLogger, t as acquireSession } from "./session-Dh0s6zG4.mjs";
3
- import { i as extractMainType } from "./categories-BNJBd4ze.mjs";
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
- const { startDate, endDate } = value;
51
- const end = new Date(endDate);
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: new Date(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, retainedEarningsCode = country.retainedEarningsCode ?? "3660", currentYearEarningsCode = country.currentYearEarningsCode ?? "3680" } = opts;
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 priorRetained = priorRetainedResults.length > 0 ? priorRetainedResults[0].c - priorRetainedResults[0].d : 0;
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: retainedEarningsCode,
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: Object.values(revenueGroups)
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: Object.values(expenseGroups)
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, retainedEarningsCode = country.retainedEarningsCode ?? "3660", logger = defaultLogger } = opts;
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 === retainedEarningsCode) retainedEarningsId = acc._id;
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: ${retainedEarningsCode}) not found. Create this account before closing the fiscal period.`);
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 { generateIncomeStatement as a, calculateTotal as c, generateTrialBalance as d, buildItemFilters as f, generateGeneralLedger as i, computeEndingBalance as l, getFiscalYearStart as m, reopenFiscalPeriod as n, generateBalanceSheet as o, getDateRange as p, generateCashFlow as r, buildAccountTypeMap as s, closeFiscalPeriod as t, isVirtualTaxAccount as u };
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 };