@classytic/ledger 0.10.2 → 0.11.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 (31) hide show
  1. package/dist/bridges/index.d.mts +1 -1
  2. package/dist/constants/index.d.mts +1 -1
  3. package/dist/constants/index.mjs +2 -2
  4. package/dist/{core-DwjkrRkJ.d.mts → core-B7uVjqGS.d.mts} +25 -0
  5. package/dist/country/index.d.mts +1 -1
  6. package/dist/events/index.d.mts +1 -1
  7. package/dist/exports/index.d.mts +1 -1
  8. package/dist/exports/index.mjs +1 -1
  9. package/dist/{fx-realization.plugin-Dzqzi3u0.mjs → fx-realization.plugin-DY3pPxIi.mjs} +70 -1
  10. package/dist/{index-ClLwzNRF.d.mts → index-BFPFihTF.d.mts} +8 -0
  11. package/dist/{index-08IpHhrU.d.mts → index-Dd7HknPP.d.mts} +1 -1
  12. package/dist/index.d.mts +120 -24
  13. package/dist/index.mjs +375 -165
  14. package/dist/{journals-DUpWwFt1.d.mts → journals-CTrAuzdk.d.mts} +1 -1
  15. package/dist/{partner-ledger-CR0geilx.mjs → partner-ledger-B0eym6Ss.mjs} +951 -213
  16. package/dist/plugins/index.d.mts +1 -1
  17. package/dist/plugins/index.mjs +1 -1
  18. package/dist/reports/index.d.mts +2 -2
  19. package/dist/reports/index.mjs +2 -2
  20. package/dist/{trial-balance-DyNm5bFu.d.mts → trial-balance-UXV2PN6x.d.mts} +280 -75
  21. package/package.json +8 -20
  22. package/dist/opening-balance-1cixYh6Y.mjs +0 -60
  23. package/dist/sync/index.d.mts +0 -324
  24. package/dist/sync/index.mjs +0 -530
  25. package/dist/sync-JvchM3FO.d.mts +0 -152
  26. /package/dist/{categories-FJlrvzcl.mjs → categories-CclX7Q94.mjs} +0 -0
  27. /package/dist/{currencies-Jo5oaM_4.mjs → currencies-OuPHPyS2.mjs} +0 -0
  28. /package/dist/{exports-C30yRapf.mjs → exports-B3whucXe.mjs} +0 -0
  29. /package/dist/{index-Bl0gP9lD.d.mts → index-DygMrab0.d.mts} +0 -0
  30. /package/dist/{index-J-XIbXH-.d.mts → index-pRW5cZhF.d.mts} +0 -0
  31. /package/dist/{outbox-store-BcCiHMPw.d.mts → outbox-store-CPLeocPg.d.mts} +0 -0
@@ -1,6 +1,123 @@
1
1
  import { i as Errors } from "./errors-vXd932rB.mjs";
2
- import { i as extractMainType } from "./categories-FJlrvzcl.mjs";
2
+ import { i as extractMainType } from "./categories-CclX7Q94.mjs";
3
3
  import mongoose from "mongoose";
4
+ //#region src/utils/period-columns.ts
5
+ const MONTH_LABELS = [
6
+ "Jan",
7
+ "Feb",
8
+ "Mar",
9
+ "Apr",
10
+ "May",
11
+ "Jun",
12
+ "Jul",
13
+ "Aug",
14
+ "Sep",
15
+ "Oct",
16
+ "Nov",
17
+ "Dec"
18
+ ];
19
+ function isoDate(d) {
20
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
21
+ }
22
+ function endOfMonth(year, month) {
23
+ return new Date(year, month + 1, 0, 23, 59, 59, 999);
24
+ }
25
+ function maxDate(a, b) {
26
+ return a > b ? a : b;
27
+ }
28
+ function minDate(a, b) {
29
+ return a < b ? a : b;
30
+ }
31
+ function pushClampedPeriod(periods, column, rawStart, rawEnd, outerStart, outerEnd) {
32
+ const start = maxDate(rawStart, outerStart);
33
+ const end = minDate(rawEnd, outerEnd);
34
+ if (start > end) return;
35
+ periods.push({
36
+ column: {
37
+ ...column,
38
+ startDate: isoDate(start),
39
+ endDate: isoDate(end)
40
+ },
41
+ start,
42
+ end
43
+ });
44
+ }
45
+ function buildPeriodColumns(outerStart, outerEnd, comparative) {
46
+ if (!comparative) return [{
47
+ column: {
48
+ key: "total",
49
+ label: `${outerStart.toLocaleDateString("en-US", {
50
+ month: "short",
51
+ day: "numeric"
52
+ })} - ${outerEnd.toLocaleDateString("en-US", {
53
+ year: "numeric",
54
+ month: "short",
55
+ day: "numeric"
56
+ })}`,
57
+ startDate: isoDate(outerStart),
58
+ endDate: isoDate(outerEnd)
59
+ },
60
+ start: outerStart,
61
+ end: outerEnd
62
+ }];
63
+ const periods = [];
64
+ const startYear = outerStart.getFullYear();
65
+ const startMonth = outerStart.getMonth();
66
+ const endYear = outerEnd.getFullYear();
67
+ const endMonth = outerEnd.getMonth();
68
+ if (comparative === "monthly") {
69
+ let y = startYear;
70
+ let m = startMonth;
71
+ while (y < endYear || y === endYear && m <= endMonth) {
72
+ pushClampedPeriod(periods, {
73
+ key: `${y}-${String(m + 1).padStart(2, "0")}`,
74
+ label: `${MONTH_LABELS[m]} ${y}`
75
+ }, new Date(y, m, 1), endOfMonth(y, m), outerStart, outerEnd);
76
+ m += 1;
77
+ if (m === 12) {
78
+ m = 0;
79
+ y += 1;
80
+ }
81
+ }
82
+ } else {
83
+ let y = startYear;
84
+ let q = Math.floor(startMonth / 3);
85
+ const endQ = Math.floor(endMonth / 3);
86
+ while (y < endYear || y === endYear && q <= endQ) {
87
+ pushClampedPeriod(periods, {
88
+ key: `${y}-Q${q + 1}`,
89
+ label: `Q${q + 1} ${y}`
90
+ }, new Date(y, q * 3, 1), endOfMonth(y, q * 3 + 2), outerStart, outerEnd);
91
+ q += 1;
92
+ if (q === 4) {
93
+ q = 0;
94
+ y += 1;
95
+ }
96
+ }
97
+ }
98
+ periods.push({
99
+ column: {
100
+ key: "total",
101
+ label: `Total ${endYear === startYear ? endYear : `${startYear}-${endYear}`}`,
102
+ startDate: isoDate(outerStart),
103
+ endDate: isoDate(outerEnd),
104
+ isTotal: true
105
+ },
106
+ start: outerStart,
107
+ end: outerEnd
108
+ });
109
+ return periods;
110
+ }
111
+ function buildAgeBucketColumns(buckets, asOfDate) {
112
+ return buckets.map((bucket) => ({
113
+ key: bucket.label,
114
+ label: bucket.label,
115
+ startDate: "",
116
+ endDate: "",
117
+ isAgeBucket: true
118
+ }));
119
+ }
120
+ //#endregion
4
121
  //#region src/utils/tenant-guard.ts
5
122
  /**
6
123
  * Multi-tenant scope guard.
@@ -40,6 +157,7 @@ async function generateAgedBalance(opts, params) {
40
157
  requireOrgScope(orgField, params.organizationId);
41
158
  const asOfDate = params.asOfDate ?? /* @__PURE__ */ new Date();
42
159
  const buckets = params.buckets ?? DEFAULT_BUCKETS;
160
+ const periods = buildAgeBucketColumns(buckets, asOfDate);
43
161
  const bucketLabels = buckets.map((b) => b.label);
44
162
  const dueDateField = params.dueDateField ?? "journalItems.dueDate";
45
163
  const contactField = params.contactField;
@@ -61,7 +179,7 @@ async function generateAgedBalance(opts, params) {
61
179
  asOfDate: asOfDate.toISOString().split("T")[0],
62
180
  type: params.type
63
181
  },
64
- bucketLabels,
182
+ periods,
65
183
  rows: [],
66
184
  totals: Object.fromEntries(bucketLabels.map((l) => [l, 0])),
67
185
  grandTotal: 0
@@ -114,18 +232,19 @@ async function generateAgedBalance(opts, params) {
114
232
  accountCode: acc?.accountNumber ?? "",
115
233
  ...contactField ? { contactId: r._id.contact } : {},
116
234
  total: 0,
117
- buckets: Object.fromEntries(bucketLabels.map((l) => [l, 0]))
235
+ amounts: Object.fromEntries(bucketLabels.map((l) => [l, 0]))
118
236
  });
119
237
  }
120
238
  const row = rowMap.get(key);
121
- if (row.buckets[r._id.bucket] !== void 0) row.buckets[r._id.bucket] += r.amount;
239
+ if (!row) continue;
240
+ if (row.amounts[r._id.bucket] !== void 0) row.amounts[r._id.bucket] += r.amount;
122
241
  row.total += r.amount;
123
242
  }
124
243
  const rows = Array.from(rowMap.values()).sort((a, b) => a.accountCode.localeCompare(b.accountCode, void 0, { numeric: true }));
125
244
  const totals = Object.fromEntries(bucketLabels.map((l) => [l, 0]));
126
245
  let grandTotal = 0;
127
246
  for (const row of rows) {
128
- for (const label of bucketLabels) totals[label] += row.buckets[label];
247
+ for (const label of bucketLabels) totals[label] += row.amounts[label];
129
248
  grandTotal += row.total;
130
249
  }
131
250
  return {
@@ -134,7 +253,7 @@ async function generateAgedBalance(opts, params) {
134
253
  asOfDate: asOfDate.toISOString().split("T")[0],
135
254
  type: params.type
136
255
  },
137
- bucketLabels,
256
+ periods,
138
257
  rows,
139
258
  totals,
140
259
  grandTotal
@@ -312,7 +431,8 @@ function buildItemFilters(filters) {
312
431
  async function generateBalanceSheet(opts, params) {
313
432
  const { AccountModel, JournalEntryModel, country, orgField, fiscalYearStartMonth = 1, retainedEarningsAccountCode = country.retainedEarningsAccountCode, retainedEarningsDisplayCode = country.retainedEarningsDisplayCode ?? retainedEarningsAccountCode, currentYearEarningsCode = country.currentYearEarningsCode ?? "3680" } = opts;
314
433
  requireOrgScope(orgField, params.organizationId);
315
- const { endDate } = getDateRange(params.dateOption, params.dateValue);
434
+ const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
435
+ const periods = buildPeriodColumns(startDate, endDate, params.comparative ?? null);
316
436
  const fiscalYearStart = getFiscalYearStart(endDate, fiscalYearStartMonth);
317
437
  const itemFilters = buildItemFilters(params.filters);
318
438
  const q = { active: true };
@@ -495,17 +615,7 @@ async function generateBalanceSheet(opts, params) {
495
615
  liabilities.total = liabilities.groups.reduce((s, g) => s + g.total, 0);
496
616
  equity.total = equity.groups.reduce((s, g) => s + g.total, 0);
497
617
  const liabilitiesAndEquity = liabilities.total + equity.total;
498
- return {
499
- metadata: {
500
- businessName: params.businessName,
501
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
502
- asOfDate: endDate.toISOString().split("T")[0],
503
- displayDate: `As of ${endDate.toLocaleDateString("en-US", {
504
- year: "numeric",
505
- month: "long",
506
- day: "numeric"
507
- })}`
508
- },
618
+ const currentSnapshot = {
509
619
  assets,
510
620
  liabilities,
511
621
  equity,
@@ -518,6 +628,126 @@ async function generateBalanceSheet(opts, params) {
518
628
  isBalanced: assets.total === liabilitiesAndEquity
519
629
  }
520
630
  };
631
+ const summaryByPeriod = {
632
+ totalAssets: {},
633
+ totalLiabilities: {},
634
+ totalEquity: {},
635
+ liabilitiesAndEquity: {},
636
+ difference: {},
637
+ isBalanced: {}
638
+ };
639
+ const snapshotSections = /* @__PURE__ */ new Map();
640
+ for (const period of periods) {
641
+ if (period.column.key === "total") {
642
+ summaryByPeriod.totalAssets[period.column.key] = currentSnapshot.summary.totalAssets;
643
+ summaryByPeriod.totalLiabilities[period.column.key] = currentSnapshot.summary.totalLiabilities;
644
+ summaryByPeriod.totalEquity[period.column.key] = currentSnapshot.summary.totalEquity;
645
+ summaryByPeriod.liabilitiesAndEquity[period.column.key] = currentSnapshot.summary.liabilitiesAndEquity;
646
+ summaryByPeriod.difference[period.column.key] = currentSnapshot.summary.difference;
647
+ summaryByPeriod.isBalanced[period.column.key] = currentSnapshot.summary.isBalanced;
648
+ snapshotSections.set(period.column.key, {
649
+ assetsSection: buildBalanceSheetSectionFromCategory("assets", period.column.key, assets),
650
+ liabilitiesSection: buildBalanceSheetSectionFromCategory("liabilities", period.column.key, liabilities),
651
+ equitySection: buildBalanceSheetSectionFromCategory("equity", period.column.key, equity)
652
+ });
653
+ continue;
654
+ }
655
+ const snapshot = await generateBalanceSheet(opts, {
656
+ ...params,
657
+ dateOption: "custom",
658
+ dateValue: {
659
+ startDate: period.start,
660
+ endDate: period.end
661
+ },
662
+ comparative: null
663
+ });
664
+ summaryByPeriod.totalAssets[period.column.key] = snapshot.summaryByPeriod.totalAssets.total ?? 0;
665
+ summaryByPeriod.totalLiabilities[period.column.key] = snapshot.summaryByPeriod.totalLiabilities.total ?? 0;
666
+ summaryByPeriod.totalEquity[period.column.key] = snapshot.summaryByPeriod.totalEquity.total ?? 0;
667
+ summaryByPeriod.liabilitiesAndEquity[period.column.key] = snapshot.summaryByPeriod.liabilitiesAndEquity.total ?? 0;
668
+ summaryByPeriod.difference[period.column.key] = snapshot.summaryByPeriod.difference.total ?? 0;
669
+ summaryByPeriod.isBalanced[period.column.key] = snapshot.summaryByPeriod.isBalanced.total ?? false;
670
+ snapshotSections.set(period.column.key, {
671
+ assetsSection: snapshot.assetsSection,
672
+ liabilitiesSection: snapshot.liabilitiesSection,
673
+ equitySection: snapshot.equitySection
674
+ });
675
+ }
676
+ return {
677
+ metadata: {
678
+ businessName: params.businessName,
679
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
680
+ asOfDate: isoDate(endDate),
681
+ displayDate: `As of ${endDate.toLocaleDateString("en-US", {
682
+ year: "numeric",
683
+ month: "long",
684
+ day: "numeric"
685
+ })}`,
686
+ comparative: params.comparative ?? null,
687
+ labels: {
688
+ assets: labels.assets,
689
+ liabilities: labels.liabilities,
690
+ equity: labels.equity
691
+ }
692
+ },
693
+ periods: periods.map((p) => p.column),
694
+ summaryByPeriod,
695
+ assetsSection: mergeBalanceSheetSections("assetsSection", periods, snapshotSections),
696
+ liabilitiesSection: mergeBalanceSheetSections("liabilitiesSection", periods, snapshotSections),
697
+ equitySection: mergeBalanceSheetSections("equitySection", periods, snapshotSections)
698
+ };
699
+ }
700
+ function buildBalanceSheetSectionFromCategory(section, periodKey, category) {
701
+ const totals = { [periodKey]: category.total };
702
+ const lines = [];
703
+ for (const group of category.groups) for (const account of group.accounts) {
704
+ const accountId = String(account.id);
705
+ lines.push({
706
+ label: account.name,
707
+ code: account.code,
708
+ amounts: { [periodKey]: account.balance },
709
+ source: account.isCalculated ? {
710
+ kind: "calculated",
711
+ accountId,
712
+ group: group.name,
713
+ section: "equity"
714
+ } : {
715
+ kind: "account",
716
+ accountId,
717
+ group: group.name,
718
+ section
719
+ }
720
+ });
721
+ }
722
+ return {
723
+ totals,
724
+ lines: lines.sort((a, b) => a.code.localeCompare(b.code, void 0, { numeric: true }))
725
+ };
726
+ }
727
+ function mergeBalanceSheetSections(key, periods, snapshots) {
728
+ const totals = Object.fromEntries(periods.map((p) => [p.column.key, 0]));
729
+ const lines = /* @__PURE__ */ new Map();
730
+ for (const period of periods) {
731
+ const snapshot = snapshots.get(period.column.key);
732
+ if (!snapshot) continue;
733
+ const section = snapshot[key];
734
+ totals[period.column.key] = section.totals.total ?? section.totals[period.column.key] ?? 0;
735
+ for (const sourceLine of section.lines) {
736
+ const lineKey = "accountId" in sourceLine.source ? String(sourceLine.source.accountId) : `${sourceLine.code}:${sourceLine.label}`;
737
+ const line = lines.get(lineKey) ?? {
738
+ label: sourceLine.label,
739
+ code: sourceLine.code,
740
+ amounts: Object.fromEntries(periods.map((p) => [p.column.key, 0])),
741
+ source: sourceLine.source
742
+ };
743
+ line.amounts[period.column.key] = sourceLine.amounts.total ?? sourceLine.amounts[period.column.key] ?? 0;
744
+ lines.set(lineKey, line);
745
+ }
746
+ }
747
+ return {
748
+ totals,
749
+ lines: [...lines.values()].sort((a, b) => a.code.localeCompare(b.code, void 0, { numeric: true }))
750
+ };
521
751
  }
522
752
  //#endregion
523
753
  //#region src/reports/budget-vs-actual.ts
@@ -626,29 +856,86 @@ async function generateBudgetVsActual(opts, params) {
626
856
  }
627
857
  //#endregion
628
858
  //#region src/reports/cash-flow.ts
859
+ const TAG_DISPLAY_NAMES = {
860
+ depreciation: "Depreciation",
861
+ amortization: "Amortization",
862
+ impairment: "Impairment",
863
+ gain_on_disposal: "Gain on disposal",
864
+ loss_on_disposal: "Loss on disposal",
865
+ unrealized_fx: "Unrealized FX",
866
+ stock_based_compensation: "Stock-based compensation"
867
+ };
868
+ /**
869
+ * Country-pack convention: account codes 1111-1130 are cash & near-cash.
870
+ * The per-Account `isCashAccount: true` flag is the authoritative override.
871
+ */
872
+ function isCashAccount(meta) {
873
+ if (meta.isCashAccount === true) return true;
874
+ return /^11(1[0-9]|2[0-9]|30)$/.test(meta.accountTypeCode ?? "");
875
+ }
876
+ function resolveSection(meta) {
877
+ if (meta.cashflowSection) return meta.cashflowSection;
878
+ if (!meta.isBalanceSheet) return "excluded";
879
+ if (meta.isCash) return "cash";
880
+ switch (meta.cashFlowCategory) {
881
+ case "Operating": return "operating";
882
+ case "Investing": return "investing";
883
+ case "Financing": return "financing";
884
+ default: return "excluded";
885
+ }
886
+ }
887
+ function tagDisplayName(tag) {
888
+ return TAG_DISPLAY_NAMES[tag] ?? tag.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
889
+ }
629
890
  async function generateCashFlow(opts, params) {
630
891
  const { AccountModel, JournalEntryModel, country, orgField } = opts;
631
892
  requireOrgScope(orgField, params.organizationId);
632
- const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
893
+ const { startDate: outerStart, endDate: outerEnd } = getDateRange(params.dateOption, params.dateValue);
633
894
  const itemFilters = buildItemFilters(params.filters);
634
- const q = { active: true };
635
- if (orgField && params.organizationId) q[orgField] = params.organizationId;
636
- const allAccounts = await AccountModel.find(q).lean();
637
- const accountCfMap = /* @__PURE__ */ new Map();
638
- const accountMap = new Map(allAccounts.map((a) => [String(a._id), a]));
639
- const cfAccountIds = [];
640
- for (const acc of allAccounts) {
641
- const at = country.getAccountType(acc.accountTypeCode);
895
+ const periods = buildPeriodColumns(outerStart, outerEnd, params.comparative ?? null);
896
+ const accountQuery = { active: true };
897
+ if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
898
+ const accounts = await AccountModel.find(accountQuery).lean();
899
+ const metas = /* @__PURE__ */ new Map();
900
+ for (const acc of accounts) {
901
+ const at = country.getAccountType(acc.accountTypeCode ?? "");
642
902
  if (!at || at.isGroup || at.isTotal) continue;
643
- const cf = at.cashFlowCategory;
644
- if (!cf) continue;
645
- const normalized = cf.charAt(0).toUpperCase() + cf.slice(1);
646
- accountCfMap.set(String(acc._id), {
903
+ const isBalanceSheet = at.category.startsWith("Balance Sheet-");
904
+ const isCash = isCashAccount(acc);
905
+ const meta = {
906
+ ...acc,
647
907
  category: at.category,
648
- cfCategory: normalized
649
- });
650
- cfAccountIds.push(acc._id);
908
+ cashFlowCategory: at.cashFlowCategory ?? null,
909
+ nonCashAdjustmentTag: at.nonCashAdjustmentTag ?? null,
910
+ isBalanceSheet,
911
+ isCash,
912
+ section: "excluded"
913
+ };
914
+ meta.section = resolveSection(meta);
915
+ metas.set(String(acc._id), meta);
651
916
  }
917
+ if (metas.size === 0) return emptyReport(periods, params);
918
+ const columnComputations = /* @__PURE__ */ new Map();
919
+ for (const period of periods) columnComputations.set(period.column.key, await computeColumn({
920
+ JournalEntryModel,
921
+ metas,
922
+ startDate: period.start,
923
+ endDate: period.end,
924
+ orgField,
925
+ organizationId: params.organizationId,
926
+ itemFilters
927
+ }));
928
+ return assembleReport({
929
+ periods: periods.map((p) => p.column),
930
+ columnComputations,
931
+ metas,
932
+ outerStart,
933
+ outerEnd,
934
+ params
935
+ });
936
+ }
937
+ async function computeColumn(args) {
938
+ const { JournalEntryModel, metas, startDate, endDate, orgField, organizationId, itemFilters } = args;
652
939
  const baseMatch = {
653
940
  state: "posted",
654
941
  date: {
@@ -656,12 +943,13 @@ async function generateCashFlow(opts, params) {
656
943
  $lte: endDate
657
944
  }
658
945
  };
659
- if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
660
- const results = cfAccountIds.length > 0 ? await JournalEntryModel.aggregate([
946
+ if (orgField && organizationId) baseMatch[orgField] = organizationId;
947
+ const accountIds = [...metas.values()].map((m) => m._id);
948
+ const periodRows = await JournalEntryModel.aggregate([
661
949
  { $match: baseMatch },
662
950
  { $unwind: "$journalItems" },
663
951
  { $match: {
664
- "journalItems.account": { $in: cfAccountIds },
952
+ "journalItems.account": { $in: accountIds },
665
953
  ...itemFilters
666
954
  } },
667
955
  { $group: {
@@ -669,41 +957,261 @@ async function generateCashFlow(opts, params) {
669
957
  d: { $sum: "$journalItems.debit" },
670
958
  c: { $sum: "$journalItems.credit" }
671
959
  } }
672
- ]) : [];
673
- const flows = {
674
- Operating: {
675
- total: 0,
676
- accounts: []
677
- },
678
- Investing: {
679
- total: 0,
680
- accounts: []
681
- },
682
- Financing: {
683
- total: 0,
684
- accounts: []
960
+ ]);
961
+ const movements = /* @__PURE__ */ new Map();
962
+ for (const row of periodRows) movements.set(String(row._id), {
963
+ d: row.d,
964
+ c: row.c
965
+ });
966
+ let netIncome = 0;
967
+ for (const meta of metas.values()) {
968
+ if (meta.isBalanceSheet) continue;
969
+ const m = movements.get(String(meta._id));
970
+ if (!m) continue;
971
+ if (meta.category === "Income Statement-Income") netIncome += m.c - m.d;
972
+ else if (meta.category === "Income Statement-Expense") netIncome -= m.d - m.c;
973
+ }
974
+ const nonCashByTag = /* @__PURE__ */ new Map();
975
+ for (const meta of metas.values()) {
976
+ if (!meta.nonCashAdjustmentTag) continue;
977
+ const m = movements.get(String(meta._id));
978
+ if (!m) continue;
979
+ const addback = meta.category === "Income Statement-Expense" ? m.d - m.c : -(m.c - m.d);
980
+ const tag = meta.nonCashAdjustmentTag;
981
+ const bucket = nonCashByTag.get(tag) ?? {
982
+ amount: 0,
983
+ labels: []
984
+ };
985
+ bucket.amount += addback;
986
+ if (!bucket.labels.includes(meta.name ?? meta.accountTypeCode ?? "")) bucket.labels.push(meta.name ?? meta.accountTypeCode ?? "");
987
+ nonCashByTag.set(tag, bucket);
988
+ }
989
+ const bsMovements = /* @__PURE__ */ new Map();
990
+ for (const meta of metas.values()) {
991
+ if (!meta.isBalanceSheet) continue;
992
+ if (meta.section === "cash" || meta.section === "excluded") continue;
993
+ const m = movements.get(String(meta._id));
994
+ if (!m || m.d === 0 && m.c === 0) continue;
995
+ bsMovements.set(String(meta._id), {
996
+ section: meta.section,
997
+ cashEffect: -(m.d - m.c),
998
+ meta
999
+ });
1000
+ }
1001
+ const { openingCash, closingCash } = await computeCashBalances({
1002
+ JournalEntryModel,
1003
+ cashIds: [...metas.values()].filter((m) => m.isCash).map((m) => m._id),
1004
+ startDate,
1005
+ endDate,
1006
+ orgField,
1007
+ organizationId
1008
+ });
1009
+ return {
1010
+ netIncome,
1011
+ nonCashByTag,
1012
+ bsMovements,
1013
+ openingCash,
1014
+ closingCash,
1015
+ cashDelta: closingCash - openingCash
1016
+ };
1017
+ }
1018
+ function assembleReport(args) {
1019
+ const { periods, columnComputations, metas, outerStart, outerEnd, params } = args;
1020
+ const tagsSeen = /* @__PURE__ */ new Set();
1021
+ const tagLabels = /* @__PURE__ */ new Map();
1022
+ const bsAccountIds = /* @__PURE__ */ new Set();
1023
+ for (const comp of columnComputations.values()) {
1024
+ for (const tag of comp.nonCashByTag.keys()) tagsSeen.add(tag);
1025
+ for (const [tag, b] of comp.nonCashByTag.entries()) {
1026
+ const label = b.labels.length === 1 ? b.labels[0] : `${tagDisplayName(tag)} (${b.labels.length} accounts)`;
1027
+ tagLabels.set(tag, label);
685
1028
  }
1029
+ for (const id of comp.bsMovements.keys()) bsAccountIds.add(id);
1030
+ }
1031
+ const sortedTags = [...tagsSeen].sort((a, b) => tagDisplayName(a).localeCompare(tagDisplayName(b)));
1032
+ const sortedBs = [...bsAccountIds].map((id) => ({
1033
+ id,
1034
+ meta: metas.get(id)
1035
+ })).filter((x) => !!x.meta).sort((a, b) => {
1036
+ const codeA = a.meta.accountNumber ?? a.meta.accountTypeCode ?? "";
1037
+ const codeB = b.meta.accountNumber ?? b.meta.accountTypeCode ?? "";
1038
+ return codeA.localeCompare(codeB, void 0, { numeric: true });
1039
+ });
1040
+ const netIncomeAmounts = {};
1041
+ const nonCashAmounts = {};
1042
+ const bsAmounts = {};
1043
+ const fxByCol = {};
1044
+ const operatingTotals = {};
1045
+ const investingTotals = {};
1046
+ const financingTotals = {};
1047
+ const netCashByCol = {};
1048
+ const reconByCol = {};
1049
+ for (const tag of sortedTags) nonCashAmounts[tag] = {};
1050
+ for (const x of sortedBs) bsAmounts[x.id] = {};
1051
+ for (const col of periods) {
1052
+ const comp = columnComputations.get(col.key);
1053
+ if (!comp) continue;
1054
+ netIncomeAmounts[col.key] = comp.netIncome;
1055
+ fxByCol[col.key] = 0;
1056
+ let opTotal = comp.netIncome;
1057
+ let invTotal = 0;
1058
+ let finTotal = 0;
1059
+ for (const tag of sortedTags) {
1060
+ const amt = comp.nonCashByTag.get(tag)?.amount ?? 0;
1061
+ nonCashAmounts[tag][col.key] = amt;
1062
+ opTotal += amt;
1063
+ }
1064
+ for (const x of sortedBs) {
1065
+ const mv = comp.bsMovements.get(x.id);
1066
+ const amt = mv?.cashEffect ?? 0;
1067
+ bsAmounts[x.id][col.key] = amt;
1068
+ if (mv) {
1069
+ if (mv.section === "operating") opTotal += amt;
1070
+ else if (mv.section === "investing") invTotal += amt;
1071
+ else if (mv.section === "financing") finTotal += amt;
1072
+ }
1073
+ }
1074
+ operatingTotals[col.key] = opTotal;
1075
+ investingTotals[col.key] = invTotal;
1076
+ financingTotals[col.key] = finTotal;
1077
+ netCashByCol[col.key] = opTotal + invTotal + finTotal + fxByCol[col.key];
1078
+ const calculated = comp.openingCash + netCashByCol[col.key];
1079
+ reconByCol[col.key] = {
1080
+ openingCash: comp.openingCash,
1081
+ closingCash: comp.closingCash,
1082
+ calculated,
1083
+ tieOutOk: Math.abs(comp.closingCash - calculated) <= 1
1084
+ };
1085
+ }
1086
+ const operating = {
1087
+ totals: operatingTotals,
1088
+ lines: [
1089
+ {
1090
+ label: "Net Income",
1091
+ code: "",
1092
+ amounts: { ...netIncomeAmounts },
1093
+ source: { kind: "netIncome" }
1094
+ },
1095
+ ...sortedTags.map((tag) => ({
1096
+ label: tagLabels.get(tag) ?? tagDisplayName(tag),
1097
+ code: "",
1098
+ amounts: { ...nonCashAmounts[tag] },
1099
+ source: {
1100
+ kind: "nonCashAdjustment",
1101
+ tag
1102
+ }
1103
+ })),
1104
+ ...sortedBs.filter((x) => x.meta.section === "operating").map((x) => ({
1105
+ label: x.meta.name ?? x.meta.accountTypeCode ?? "",
1106
+ code: x.meta.accountNumber ?? x.meta.accountTypeCode ?? "",
1107
+ amounts: { ...bsAmounts[x.id] },
1108
+ source: {
1109
+ kind: "workingCapital",
1110
+ accountId: x.id
1111
+ }
1112
+ }))
1113
+ ]
686
1114
  };
687
- for (const r of results) {
688
- const accIdStr = String(r._id);
689
- const meta = accountCfMap.get(accIdStr);
690
- if (!meta) continue;
691
- const amount = computeEndingBalance(meta.category, r.d, r.c);
692
- const acc = accountMap.get(accIdStr);
693
- const at = country.getAccountType(acc?.accountTypeCode);
694
- flows[meta.cfCategory].accounts.push({
695
- name: acc?.name ?? at?.name ?? "",
696
- code: acc?.accountNumber ?? at?.code ?? "",
697
- amount
698
- });
699
- flows[meta.cfCategory].total += amount;
1115
+ const investing = {
1116
+ totals: investingTotals,
1117
+ lines: sortedBs.filter((x) => x.meta.section === "investing").map((x) => ({
1118
+ label: x.meta.name ?? x.meta.accountTypeCode ?? "",
1119
+ code: x.meta.accountNumber ?? x.meta.accountTypeCode ?? "",
1120
+ amounts: { ...bsAmounts[x.id] },
1121
+ source: {
1122
+ kind: "directMovement",
1123
+ accountId: x.id
1124
+ }
1125
+ }))
1126
+ };
1127
+ const financing = {
1128
+ totals: financingTotals,
1129
+ lines: sortedBs.filter((x) => x.meta.section === "financing").map((x) => ({
1130
+ label: x.meta.name ?? x.meta.accountTypeCode ?? "",
1131
+ code: x.meta.accountNumber ?? x.meta.accountTypeCode ?? "",
1132
+ amounts: { ...bsAmounts[x.id] },
1133
+ source: {
1134
+ kind: "directMovement",
1135
+ accountId: x.id
1136
+ }
1137
+ }))
1138
+ };
1139
+ const periodDisplay = `${outerStart.toLocaleDateString("en-US", {
1140
+ month: "short",
1141
+ day: "numeric"
1142
+ })} – ${outerEnd.toLocaleDateString("en-US", {
1143
+ year: "numeric",
1144
+ month: "short",
1145
+ day: "numeric"
1146
+ })}`;
1147
+ return {
1148
+ metadata: {
1149
+ businessName: params.businessName,
1150
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1151
+ periodStart: isoDate(outerStart),
1152
+ periodEnd: isoDate(outerEnd),
1153
+ displayPeriod: periodDisplay,
1154
+ ...params.currency ? { currency: params.currency } : {},
1155
+ comparative: params.comparative ?? null
1156
+ },
1157
+ periods,
1158
+ operating,
1159
+ investing,
1160
+ financing,
1161
+ fxEffect: fxByCol,
1162
+ netCashFlow: netCashByCol,
1163
+ cashReconciliation: reconByCol
1164
+ };
1165
+ }
1166
+ async function computeCashBalances(args) {
1167
+ const { JournalEntryModel, cashIds, startDate, endDate, orgField, organizationId } = args;
1168
+ if (cashIds.length === 0) return {
1169
+ openingCash: 0,
1170
+ closingCash: 0
1171
+ };
1172
+ const match = { state: "posted" };
1173
+ if (orgField && organizationId) match[orgField] = organizationId;
1174
+ const rows = await JournalEntryModel.aggregate([
1175
+ { $match: match },
1176
+ { $unwind: "$journalItems" },
1177
+ { $match: { "journalItems.account": { $in: cashIds } } },
1178
+ { $group: {
1179
+ _id: null,
1180
+ opening: { $sum: { $cond: [
1181
+ { $lt: ["$date", startDate] },
1182
+ { $subtract: ["$journalItems.debit", "$journalItems.credit"] },
1183
+ 0
1184
+ ] } },
1185
+ closing: { $sum: { $cond: [
1186
+ { $lte: ["$date", endDate] },
1187
+ { $subtract: ["$journalItems.debit", "$journalItems.credit"] },
1188
+ 0
1189
+ ] } }
1190
+ } }
1191
+ ]);
1192
+ return {
1193
+ openingCash: rows[0]?.opening ?? 0,
1194
+ closingCash: rows[0]?.closing ?? 0
1195
+ };
1196
+ }
1197
+ function emptyReport(periods, params) {
1198
+ const zeros = {};
1199
+ const recon = {};
1200
+ for (const p of periods) {
1201
+ zeros[p.column.key] = 0;
1202
+ recon[p.column.key] = {
1203
+ openingCash: 0,
1204
+ closingCash: 0,
1205
+ calculated: 0,
1206
+ tieOutOk: true
1207
+ };
700
1208
  }
701
- for (const section of Object.values(flows)) section.accounts.sort((a, b) => a.code.localeCompare(b.code, void 0, { numeric: true }));
702
- const netCashFlow = flows.Operating.total + flows.Investing.total + flows.Financing.total;
703
- const periodDisplay = `${startDate.toLocaleDateString("en-US", {
1209
+ const outerStart = periods[0].start;
1210
+ const outerEnd = periods[periods.length - 1].end;
1211
+ const periodDisplay = `${outerStart.toLocaleDateString("en-US", {
704
1212
  month: "short",
705
1213
  day: "numeric"
706
- })} – ${endDate.toLocaleDateString("en-US", {
1214
+ })} – ${outerEnd.toLocaleDateString("en-US", {
707
1215
  year: "numeric",
708
1216
  month: "short",
709
1217
  day: "numeric"
@@ -712,14 +1220,115 @@ async function generateCashFlow(opts, params) {
712
1220
  metadata: {
713
1221
  businessName: params.businessName,
714
1222
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
715
- periodStart: startDate.toISOString().split("T")[0],
716
- periodEnd: endDate.toISOString().split("T")[0],
717
- displayPeriod: periodDisplay
1223
+ periodStart: isoDate(outerStart),
1224
+ periodEnd: isoDate(outerEnd),
1225
+ displayPeriod: periodDisplay,
1226
+ ...params.currency ? { currency: params.currency } : {},
1227
+ comparative: params.comparative ?? null
1228
+ },
1229
+ periods: periods.map((p) => p.column),
1230
+ operating: {
1231
+ totals: { ...zeros },
1232
+ lines: [{
1233
+ label: "Net Income",
1234
+ code: "",
1235
+ amounts: { ...zeros },
1236
+ source: { kind: "netIncome" }
1237
+ }]
718
1238
  },
719
- operating: flows.Operating,
720
- investing: flows.Investing,
721
- financing: flows.Financing,
722
- netCashFlow
1239
+ investing: {
1240
+ totals: { ...zeros },
1241
+ lines: []
1242
+ },
1243
+ financing: {
1244
+ totals: { ...zeros },
1245
+ lines: []
1246
+ },
1247
+ fxEffect: { ...zeros },
1248
+ netCashFlow: { ...zeros },
1249
+ cashReconciliation: recon
1250
+ };
1251
+ }
1252
+ //#endregion
1253
+ //#region src/reports/daybook.ts
1254
+ const DEFAULT_LIMIT = 5e3;
1255
+ const MAX_LIMIT = 5e4;
1256
+ async function generateDaybook(opts, params) {
1257
+ const { JournalEntryModel, orgField } = opts;
1258
+ const { startDate, endDate, state = "posted", accountId, journalType, partnerId, partnerField = "partnerId" } = params;
1259
+ requireOrgScope(orgField, params.organizationId);
1260
+ const limit = Math.min(params.limit ?? DEFAULT_LIMIT, MAX_LIMIT);
1261
+ const baseMatch = { date: {
1262
+ $gte: startDate,
1263
+ $lte: endDate
1264
+ } };
1265
+ if (state !== "all") baseMatch.state = state;
1266
+ if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
1267
+ if (journalType) baseMatch.journalType = journalType;
1268
+ const itemMatch = {};
1269
+ if (accountId) itemMatch["journalItems.account"] = accountId;
1270
+ if (partnerId !== void 0 && partnerId !== null) itemMatch[`journalItems.${partnerField}`] = partnerId;
1271
+ const pipeline = [
1272
+ { $match: baseMatch },
1273
+ { $addFields: { journalItems: { $map: {
1274
+ input: { $range: [0, { $size: "$journalItems" }] },
1275
+ as: "idx",
1276
+ in: { $mergeObjects: [{ $arrayElemAt: ["$journalItems", "$$idx"] }, { _itemIndex: "$$idx" }] }
1277
+ } } } },
1278
+ { $unwind: "$journalItems" },
1279
+ ...Object.keys(itemMatch).length > 0 ? [{ $match: itemMatch }] : [],
1280
+ { $project: {
1281
+ _id: 0,
1282
+ entryId: "$_id",
1283
+ itemIndex: "$journalItems._itemIndex",
1284
+ date: { $ifNull: ["$journalItems.date", "$date"] },
1285
+ referenceNumber: "$referenceNumber",
1286
+ journalType: "$journalType",
1287
+ entryLabel: "$label",
1288
+ itemLabel: "$journalItems.label",
1289
+ state: "$state",
1290
+ accountId: "$journalItems.account",
1291
+ debit: { $ifNull: ["$journalItems.debit", 0] },
1292
+ credit: { $ifNull: ["$journalItems.credit", 0] },
1293
+ partnerId: `$journalItems.${partnerField}`,
1294
+ matchingNumber: "$journalItems.matchingNumber"
1295
+ } },
1296
+ { $sort: {
1297
+ date: 1,
1298
+ entryId: 1,
1299
+ itemIndex: 1
1300
+ } },
1301
+ { $limit: limit + 1 }
1302
+ ];
1303
+ const rows = await JournalEntryModel.aggregate(pipeline);
1304
+ const truncated = rows.length > limit;
1305
+ const lines = truncated ? rows.slice(0, limit) : rows;
1306
+ let totalDebit = 0;
1307
+ let totalCredit = 0;
1308
+ for (const r of lines) {
1309
+ totalDebit += r.debit ?? 0;
1310
+ totalCredit += r.credit ?? 0;
1311
+ }
1312
+ return {
1313
+ metadata: {
1314
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1315
+ period: {
1316
+ startDate: startDate.toISOString().split("T")[0],
1317
+ endDate: endDate.toISOString().split("T")[0]
1318
+ },
1319
+ state,
1320
+ filters: {
1321
+ ...accountId ? { accountId } : {},
1322
+ ...journalType ? { journalType } : {},
1323
+ ...partnerId !== void 0 && partnerId !== null ? { partnerId } : {}
1324
+ },
1325
+ truncated,
1326
+ rowCount: lines.length
1327
+ },
1328
+ lines,
1329
+ totalDebit,
1330
+ totalCredit,
1331
+ netDelta: totalDebit - totalCredit
723
1332
  };
724
1333
  }
725
1334
  //#endregion
@@ -993,6 +1602,7 @@ async function generateIncomeStatement(opts, params) {
993
1602
  requireOrgScope(orgField, params.organizationId);
994
1603
  const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
995
1604
  const itemFilters = buildItemFilters(params.filters);
1605
+ const periods = buildPeriodColumns(startDate, endDate, params.comparative ?? null);
996
1606
  const q = { active: true };
997
1607
  if (orgField && params.organizationId) q[orgField] = params.organizationId;
998
1608
  const allAccounts = await AccountModel.find(q).lean();
@@ -1000,30 +1610,9 @@ async function generateIncomeStatement(opts, params) {
1000
1610
  const at = country.getAccountType(a.accountTypeCode);
1001
1611
  return at && !at.isGroup && !at.isTotal && at.category.startsWith("Income Statement");
1002
1612
  }).map((a) => a._id);
1003
- const baseMatch = {
1004
- state: "posted",
1005
- date: {
1006
- $gte: startDate,
1007
- $lte: endDate
1008
- }
1009
- };
1613
+ const baseMatch = { state: "posted" };
1010
1614
  if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
1011
- const results = await JournalEntryModel.aggregate([
1012
- { $match: baseMatch },
1013
- { $unwind: "$journalItems" },
1014
- { $match: {
1015
- "journalItems.account": { $in: isIds },
1016
- ...itemFilters
1017
- } },
1018
- { $group: {
1019
- _id: "$journalItems.account",
1020
- d: { $sum: "$journalItems.debit" },
1021
- c: { $sum: "$journalItems.credit" }
1022
- } }
1023
- ]);
1024
1615
  const accountMap = new Map(allAccounts.map((a) => [String(a._id), a]));
1025
- const revenueGroups = {};
1026
- const expenseGroups = {};
1027
1616
  const resolveGroupName = (at) => {
1028
1617
  const visited = /* @__PURE__ */ new Set();
1029
1618
  let current = at.parentCode ? country.getAccountType(at.parentCode) : void 0;
@@ -1034,56 +1623,157 @@ async function generateIncomeStatement(opts, params) {
1034
1623
  }
1035
1624
  return at.name;
1036
1625
  };
1037
- for (const r of results) {
1038
- const acc = accountMap.get(String(r._id));
1039
- if (!acc) continue;
1040
- const at = country.getAccountType(acc.accountTypeCode);
1041
- if (!at) continue;
1042
- const mainType = extractMainType(at.category);
1043
- const netAmount = mainType === "Income" ? r.c - r.d : r.d - r.c;
1044
- if (netAmount === 0) continue;
1045
- const groupName = resolveGroupName(at);
1046
- const groups = mainType === "Income" ? revenueGroups : expenseGroups;
1047
- if (!(groupName in groups)) groups[groupName] = {
1048
- name: groupName,
1049
- total: 0,
1050
- accounts: []
1051
- };
1052
- groups[groupName].accounts.push({
1053
- id: acc._id,
1054
- name: acc.name ?? at.name,
1055
- code: acc.accountNumber ?? at.code,
1056
- balance: netAmount
1057
- });
1058
- groups[groupName].total += netAmount;
1626
+ const cogsCode = country.cogsGroupCode;
1627
+ const isCogs = (name) => cogsCode ? name === cogsCode : name === "Cost of Sales" || name === "Cost of Goods Sold";
1628
+ const periodResults = /* @__PURE__ */ new Map();
1629
+ for (const period of periods) {
1630
+ const results = await JournalEntryModel.aggregate([
1631
+ { $match: {
1632
+ ...baseMatch,
1633
+ date: {
1634
+ $gte: period.start,
1635
+ $lte: period.end
1636
+ }
1637
+ } },
1638
+ { $unwind: "$journalItems" },
1639
+ { $match: {
1640
+ "journalItems.account": { $in: isIds },
1641
+ ...itemFilters
1642
+ } },
1643
+ { $group: {
1644
+ _id: "$journalItems.account",
1645
+ d: { $sum: "$journalItems.debit" },
1646
+ c: { $sum: "$journalItems.credit" }
1647
+ } }
1648
+ ]);
1649
+ periodResults.set(period.column.key, results);
1059
1650
  }
1060
- const sortGroups = (groups) => {
1061
- const sorted = Object.values(groups);
1062
- for (const g of sorted) g.accounts.sort((a, b) => (a.code ?? "").localeCompare(b.code ?? "", void 0, { numeric: true }));
1063
- sorted.sort((a, b) => {
1064
- const codeA = a.accounts[0]?.code ?? "";
1065
- const codeB = b.accounts[0]?.code ?? "";
1066
- return codeA.localeCompare(codeB, void 0, { numeric: true });
1067
- });
1068
- return sorted;
1651
+ const revenueTotals = {};
1652
+ const expenseTotals = {};
1653
+ const costOfSalesByPeriod = {};
1654
+ const grossProfitByPeriod = {};
1655
+ const operatingIncomeByPeriod = {};
1656
+ const netIncomeByPeriod = {};
1657
+ const revenueLines = /* @__PURE__ */ new Map();
1658
+ const expenseLines = /* @__PURE__ */ new Map();
1659
+ for (const period of periods) {
1660
+ const revenuePeriodGroups = {};
1661
+ const expensePeriodGroups = {};
1662
+ for (const r of periodResults.get(period.column.key) ?? []) {
1663
+ const acc = accountMap.get(String(r._id));
1664
+ if (!acc) continue;
1665
+ const at = country.getAccountType(acc.accountTypeCode);
1666
+ if (!at) continue;
1667
+ const mainType = extractMainType(at.category);
1668
+ const netAmount = mainType === "Income" ? r.c - r.d : r.d - r.c;
1669
+ if (netAmount === 0) continue;
1670
+ const groupName = resolveGroupName(at);
1671
+ const targetGroups = mainType === "Income" ? revenuePeriodGroups : expensePeriodGroups;
1672
+ if (!(groupName in targetGroups)) targetGroups[groupName] = {
1673
+ name: groupName,
1674
+ total: 0,
1675
+ accounts: []
1676
+ };
1677
+ targetGroups[groupName].accounts.push({
1678
+ id: acc._id,
1679
+ name: acc.name ?? at.name,
1680
+ code: acc.accountNumber ?? at.code,
1681
+ balance: netAmount
1682
+ });
1683
+ targetGroups[groupName].total += netAmount;
1684
+ const lineMap = mainType === "Income" ? revenueLines : expenseLines;
1685
+ const key = String(acc._id);
1686
+ const line = lineMap.get(key) ?? {
1687
+ label: acc.name ?? at.name,
1688
+ code: acc.accountNumber ?? at.code,
1689
+ group: groupName,
1690
+ amounts: Object.fromEntries(periods.map((p) => [p.column.key, 0]))
1691
+ };
1692
+ line.amounts[period.column.key] = netAmount;
1693
+ lineMap.set(key, line);
1694
+ }
1695
+ const periodRevenue = Object.values(revenuePeriodGroups).reduce((s, g) => s + g.total, 0);
1696
+ const periodExpenses = Object.values(expensePeriodGroups).reduce((s, g) => s + g.total, 0);
1697
+ const periodCostOfSales = Object.values(expensePeriodGroups).filter((g) => isCogs(g.name)).reduce((s, g) => s + g.total, 0);
1698
+ const periodGrossProfit = periodRevenue - periodCostOfSales;
1699
+ const periodOperatingExpenses = Object.values(expensePeriodGroups).filter((g) => !isCogs(g.name)).reduce((s, g) => s + g.total, 0);
1700
+ revenueTotals[period.column.key] = periodRevenue;
1701
+ expenseTotals[period.column.key] = periodExpenses;
1702
+ costOfSalesByPeriod[period.column.key] = periodCostOfSales;
1703
+ grossProfitByPeriod[period.column.key] = periodGrossProfit;
1704
+ operatingIncomeByPeriod[period.column.key] = periodGrossProfit - periodOperatingExpenses;
1705
+ netIncomeByPeriod[period.column.key] = periodRevenue - periodExpenses;
1706
+ }
1707
+ const sortLines = (lines) => lines.sort((a, b) => a.code.localeCompare(b.code, void 0, { numeric: true }));
1708
+ const revenueSection = {
1709
+ totals: revenueTotals,
1710
+ lines: sortLines([...revenueLines.entries()].map(([accountId, line]) => ({
1711
+ label: line.label,
1712
+ code: line.code,
1713
+ amounts: line.amounts,
1714
+ source: {
1715
+ kind: "account",
1716
+ accountId,
1717
+ group: line.group,
1718
+ statementType: "revenue"
1719
+ }
1720
+ })))
1069
1721
  };
1070
- const labels = country.reportLabels ?? {};
1071
- const revenue = {
1072
- name: labels.revenue ?? "Revenue",
1073
- total: Object.values(revenueGroups).reduce((s, g) => s + g.total, 0),
1074
- groups: sortGroups(revenueGroups)
1722
+ const expensesSection = {
1723
+ totals: expenseTotals,
1724
+ lines: sortLines([...expenseLines.entries()].map(([accountId, line]) => ({
1725
+ label: line.label,
1726
+ code: line.code,
1727
+ amounts: line.amounts,
1728
+ source: {
1729
+ kind: "account",
1730
+ accountId,
1731
+ group: line.group,
1732
+ statementType: "expense"
1733
+ }
1734
+ })))
1075
1735
  };
1076
- const expenses = {
1077
- name: labels.expenses ?? "Expenses",
1078
- total: Object.values(expenseGroups).reduce((s, g) => s + g.total, 0),
1079
- groups: sortGroups(expenseGroups)
1736
+ const summarySection = {
1737
+ totals: netIncomeByPeriod,
1738
+ lines: [
1739
+ {
1740
+ label: "Cost of Sales",
1741
+ code: "",
1742
+ amounts: costOfSalesByPeriod,
1743
+ source: {
1744
+ kind: "aggregate",
1745
+ name: "costOfSales"
1746
+ }
1747
+ },
1748
+ {
1749
+ label: "Gross Profit",
1750
+ code: "",
1751
+ amounts: grossProfitByPeriod,
1752
+ source: {
1753
+ kind: "aggregate",
1754
+ name: "grossProfit"
1755
+ }
1756
+ },
1757
+ {
1758
+ label: "Operating Income",
1759
+ code: "",
1760
+ amounts: operatingIncomeByPeriod,
1761
+ source: {
1762
+ kind: "aggregate",
1763
+ name: "operatingIncome"
1764
+ }
1765
+ },
1766
+ {
1767
+ label: "Net Income",
1768
+ code: "",
1769
+ amounts: netIncomeByPeriod,
1770
+ source: {
1771
+ kind: "aggregate",
1772
+ name: "netIncome"
1773
+ }
1774
+ }
1775
+ ]
1080
1776
  };
1081
- const cogsCode = country.cogsGroupCode;
1082
- const isCogs = (name) => cogsCode ? name === cogsCode : name === "Cost of Sales" || name === "Cost of Goods Sold";
1083
- const costOfSales = expenses.groups.find((g) => isCogs(g.name))?.total ?? 0;
1084
- const grossProfit = revenue.total - costOfSales;
1085
- const operatingIncome = grossProfit - expenses.groups.filter((g) => !isCogs(g.name)).reduce((s, g) => s + g.total, 0);
1086
- const netIncome = revenue.total - expenses.total;
1087
1777
  const periodDisplay = params.dateOption === "year" ? `For the year ended ${endDate.toLocaleDateString("en-US", {
1088
1778
  year: "numeric",
1089
1779
  month: "long",
@@ -1096,20 +1786,28 @@ async function generateIncomeStatement(opts, params) {
1096
1786
  month: "short",
1097
1787
  day: "numeric"
1098
1788
  })}`;
1789
+ const labels = country.reportLabels ?? {};
1099
1790
  return {
1100
1791
  metadata: {
1101
1792
  businessName: params.businessName,
1102
1793
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1103
- periodStart: startDate.toISOString().split("T")[0],
1104
- periodEnd: endDate.toISOString().split("T")[0],
1105
- displayPeriod: periodDisplay
1794
+ periodStart: isoDate(startDate),
1795
+ periodEnd: isoDate(endDate),
1796
+ displayPeriod: periodDisplay,
1797
+ comparative: params.comparative ?? null,
1798
+ labels: {
1799
+ revenue: labels.revenue,
1800
+ expenses: labels.expenses
1801
+ }
1106
1802
  },
1107
- revenue,
1108
- costOfSales,
1109
- grossProfit,
1110
- expenses,
1111
- operatingIncome,
1112
- netIncome
1803
+ periods: periods.map((p) => p.column),
1804
+ revenueSection,
1805
+ expensesSection,
1806
+ summarySection,
1807
+ costOfSalesByPeriod,
1808
+ grossProfitByPeriod,
1809
+ operatingIncomeByPeriod,
1810
+ netIncomeByPeriod
1113
1811
  };
1114
1812
  }
1115
1813
  //#endregion
@@ -1316,7 +2014,7 @@ async function generateTrialBalance(opts, params) {
1316
2014
  const { AccountModel, JournalEntryModel, country, orgField, fiscalYearStartMonth = 1 } = opts;
1317
2015
  requireOrgScope(orgField, params.organizationId);
1318
2016
  const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
1319
- const fiscalYearStart = getFiscalYearStart(startDate, fiscalYearStartMonth);
2017
+ const periods = buildPeriodColumns(startDate, endDate, params.comparative ?? null);
1320
2018
  const itemFilters = buildItemFilters(params.filters);
1321
2019
  const accountQuery = { active: true };
1322
2020
  if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
@@ -1332,6 +2030,7 @@ async function generateTrialBalance(opts, params) {
1332
2030
  const baseMatch = { state: "posted" };
1333
2031
  if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
1334
2032
  const accountFilter = params.accountId ? { "journalItems.account": params.accountId } : {};
2033
+ const accountLookup = new Map(allAccounts.map((a) => [String(a._id), a]));
1335
2034
  const buildPipeline = (ids, dateFrom, dateTo) => [
1336
2035
  { $match: {
1337
2036
  ...baseMatch,
@@ -1352,58 +2051,95 @@ async function generateTrialBalance(opts, params) {
1352
2051
  c: { $sum: "$journalItems.credit" }
1353
2052
  } }
1354
2053
  ];
1355
- const [bsInitial, isInitial, current] = await Promise.all([
1356
- bsIds.length ? JournalEntryModel.aggregate(buildPipeline(bsIds, /* @__PURE__ */ new Date(0), startDate)) : [],
1357
- isIds.length ? JournalEntryModel.aggregate(buildPipeline(isIds, fiscalYearStart, startDate)) : [],
1358
- JournalEntryModel.aggregate(buildPipeline([...bsIds, ...isIds], startDate, new Date(endDate.getTime() + 1)))
1359
- ]);
1360
- const map = /* @__PURE__ */ new Map();
1361
- for (const r of [...bsInitial, ...isInitial]) {
1362
- const key = String(r._id);
1363
- map.set(key, {
1364
- iD: r.d,
1365
- iC: r.c,
1366
- cD: 0,
1367
- cC: 0
1368
- });
1369
- }
1370
- for (const r of current) {
1371
- const key = String(r._id);
1372
- const existing = map.get(key) ?? {
1373
- iD: 0,
1374
- iC: 0,
1375
- cD: 0,
1376
- cC: 0
1377
- };
1378
- existing.cD = r.d;
1379
- existing.cC = r.c;
1380
- map.set(key, existing);
1381
- }
1382
- const accountLookup = new Map(allAccounts.map((a) => [String(a._id), a]));
1383
- const rows = [];
1384
- for (const [id, bal] of map) {
1385
- const acc = accountLookup.get(id);
1386
- const net = bal.iD + bal.cD - (bal.iC + bal.cC);
1387
- rows.push({
1388
- account: acc ?? id,
1389
- initial: {
1390
- debit: bal.iD,
1391
- credit: bal.iC
1392
- },
1393
- current: {
1394
- debit: bal.cD,
1395
- credit: bal.cC
1396
- },
1397
- ending: net >= 0 ? {
1398
- debit: net,
1399
- credit: 0
1400
- } : {
1401
- debit: 0,
1402
- credit: Math.abs(net)
1403
- }
1404
- });
2054
+ const sortRows = (rows) => rows.sort((a, b) => {
2055
+ const codeA = a.account?.accountNumber ?? a.account?.accountTypeCode ?? "";
2056
+ const codeB = b.account?.accountNumber ?? b.account?.accountTypeCode ?? "";
2057
+ return codeA.localeCompare(codeB, void 0, { numeric: true });
2058
+ });
2059
+ const computeRowsForRange = async (rangeStart, rangeEnd) => {
2060
+ const fiscalYearStart = getFiscalYearStart(rangeStart, fiscalYearStartMonth);
2061
+ const [bsInitial, isInitial, current] = await Promise.all([
2062
+ bsIds.length ? JournalEntryModel.aggregate(buildPipeline(bsIds, /* @__PURE__ */ new Date(0), rangeStart)) : [],
2063
+ isIds.length ? JournalEntryModel.aggregate(buildPipeline(isIds, fiscalYearStart, rangeStart)) : [],
2064
+ JournalEntryModel.aggregate(buildPipeline([...bsIds, ...isIds], rangeStart, new Date(rangeEnd.getTime() + 1)))
2065
+ ]);
2066
+ const map = /* @__PURE__ */ new Map();
2067
+ for (const r of [...bsInitial, ...isInitial]) {
2068
+ const key = String(r._id);
2069
+ map.set(key, {
2070
+ iD: r.d,
2071
+ iC: r.c,
2072
+ cD: 0,
2073
+ cC: 0
2074
+ });
2075
+ }
2076
+ for (const r of current) {
2077
+ const key = String(r._id);
2078
+ const existing = map.get(key) ?? {
2079
+ iD: 0,
2080
+ iC: 0,
2081
+ cD: 0,
2082
+ cC: 0
2083
+ };
2084
+ existing.cD = r.d;
2085
+ existing.cC = r.c;
2086
+ map.set(key, existing);
2087
+ }
2088
+ const rows = [];
2089
+ for (const [id, bal] of map) {
2090
+ const acc = accountLookup.get(id);
2091
+ const net = bal.iD + bal.cD - (bal.iC + bal.cC);
2092
+ rows.push({
2093
+ account: acc ?? id,
2094
+ initial: {
2095
+ debit: bal.iD,
2096
+ credit: bal.iC
2097
+ },
2098
+ current: {
2099
+ debit: bal.cD,
2100
+ credit: bal.cC
2101
+ },
2102
+ ending: net >= 0 ? {
2103
+ debit: net,
2104
+ credit: 0
2105
+ } : {
2106
+ debit: 0,
2107
+ credit: Math.abs(net)
2108
+ }
2109
+ });
2110
+ }
2111
+ return sortRows(rows);
2112
+ };
2113
+ const columnarRowsByAccount = /* @__PURE__ */ new Map();
2114
+ for (const period of periods) {
2115
+ const periodRows = await computeRowsForRange(period.start, period.end);
2116
+ for (const row of periodRows) {
2117
+ const id = String(row.account?._id ?? row.account);
2118
+ const columnar = columnarRowsByAccount.get(id) ?? {
2119
+ account: row.account,
2120
+ initial: {
2121
+ debit: Object.fromEntries(periods.map((p) => [p.column.key, 0])),
2122
+ credit: Object.fromEntries(periods.map((p) => [p.column.key, 0]))
2123
+ },
2124
+ current: {
2125
+ debit: Object.fromEntries(periods.map((p) => [p.column.key, 0])),
2126
+ credit: Object.fromEntries(periods.map((p) => [p.column.key, 0]))
2127
+ },
2128
+ ending: {
2129
+ debit: Object.fromEntries(periods.map((p) => [p.column.key, 0])),
2130
+ credit: Object.fromEntries(periods.map((p) => [p.column.key, 0]))
2131
+ }
2132
+ };
2133
+ columnar.initial.debit[period.column.key] = row.initial.debit;
2134
+ columnar.initial.credit[period.column.key] = row.initial.credit;
2135
+ columnar.current.debit[period.column.key] = row.current.debit;
2136
+ columnar.current.credit[period.column.key] = row.current.credit;
2137
+ columnar.ending.debit[period.column.key] = row.ending.debit;
2138
+ columnar.ending.credit[period.column.key] = row.ending.credit;
2139
+ columnarRowsByAccount.set(id, columnar);
2140
+ }
1405
2141
  }
1406
- rows.sort((a, b) => {
2142
+ const columnarRows = [...columnarRowsByAccount.values()].sort((a, b) => {
1407
2143
  const codeA = a.account?.accountNumber ?? a.account?.accountTypeCode ?? "";
1408
2144
  const codeB = b.account?.accountNumber ?? b.account?.accountTypeCode ?? "";
1409
2145
  return codeA.localeCompare(codeB, void 0, { numeric: true });
@@ -1424,15 +2160,17 @@ async function generateTrialBalance(opts, params) {
1424
2160
  metadata: {
1425
2161
  businessName: params.businessName,
1426
2162
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1427
- periodStart: startDate.toISOString().split("T")[0],
1428
- periodEnd: endDate.toISOString().split("T")[0],
1429
- displayPeriod: periodDisplay
2163
+ periodStart: isoDate(startDate),
2164
+ periodEnd: isoDate(endDate),
2165
+ displayPeriod: periodDisplay,
2166
+ comparative: params.comparative ?? null
1430
2167
  },
1431
- rows,
1432
2168
  period: {
1433
2169
  startDate,
1434
2170
  endDate
1435
- }
2171
+ },
2172
+ periods: periods.map((p) => p.column),
2173
+ columnarRows
1436
2174
  };
1437
2175
  }
1438
2176
  //#endregion
@@ -1598,12 +2336,12 @@ async function closeFiscalPeriod(opts, params) {
1598
2336
  closingEntryId = closingEntry._id;
1599
2337
  }
1600
2338
  const closedAt = /* @__PURE__ */ new Date();
1601
- await FiscalPeriodModel.findOneAndUpdate(periodQuery, {
2339
+ await FiscalPeriodModel.findOneAndUpdate(periodQuery, { $set: {
1602
2340
  closed: true,
1603
2341
  closedAt,
1604
2342
  closedBy: closedBy ?? null,
1605
2343
  closingEntryId
1606
- }, queryOpts);
2344
+ } }, queryOpts);
1607
2345
  const result = {
1608
2346
  periodId,
1609
2347
  netIncome,
@@ -1640,14 +2378,14 @@ async function reopenFiscalPeriod(opts, params) {
1640
2378
  const closingEntryId = period.closingEntryId ?? null;
1641
2379
  if (closingEntryId) await JournalEntryModel.findByIdAndDelete(closingEntryId, queryOpts);
1642
2380
  const reopenedAt = /* @__PURE__ */ new Date();
1643
- await FiscalPeriodModel.findOneAndUpdate(periodQuery, {
2381
+ await FiscalPeriodModel.findOneAndUpdate(periodQuery, { $set: {
1644
2382
  closed: false,
1645
2383
  closedAt: null,
1646
2384
  closedBy: null,
1647
2385
  closingEntryId: null,
1648
2386
  reopenedAt,
1649
2387
  reopenedBy: reopenedBy ?? null
1650
- }, queryOpts);
2388
+ } }, queryOpts);
1651
2389
  const result = {
1652
2390
  periodId,
1653
2391
  deletedEntryId: closingEntryId,
@@ -1797,4 +2535,4 @@ async function generatePartnerLedger(opts, params) {
1797
2535
  };
1798
2536
  }
1799
2537
  //#endregion
1800
- export { isVirtualTaxAccount as C, requireOrgScope as E, computeEndingBalance as S, generateAgedBalance as T, buildItemFilters as _, finalizeSession as a, buildAccountTypeMap as b, generateRevaluation as c, generateIncomeStatement as d, generateGeneralLedger as f, generateBalanceSheet as g, generateBudgetVsActual as h, acquireSession as i, buildRevaluationEntry as l, generateCashFlow as m, closeFiscalPeriod as n, defaultLogger as o, generateDimensionBreakdown as p, reopenFiscalPeriod as r, generateTrialBalance as s, generatePartnerLedger as t, computeRevaluation as u, getDateRange as v, DEFAULT_BUCKETS as w, calculateTotal as x, getFiscalYearStart as y };
2538
+ export { computeEndingBalance as C, requireOrgScope as D, generateAgedBalance as E, calculateTotal as S, DEFAULT_BUCKETS as T, generateBalanceSheet as _, finalizeSession as a, getFiscalYearStart as b, generateRevaluation as c, generateIncomeStatement as d, generateGeneralLedger as f, generateBudgetVsActual as g, generateCashFlow as h, acquireSession as i, buildRevaluationEntry as l, generateDaybook as m, closeFiscalPeriod as n, defaultLogger as o, generateDimensionBreakdown as p, reopenFiscalPeriod as r, generateTrialBalance as s, generatePartnerLedger as t, computeRevaluation as u, buildItemFilters as v, isVirtualTaxAccount as w, buildAccountTypeMap as x, getDateRange as y };