@classytic/ledger 0.10.3 → 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 +115 -19
  13. package/dist/index.mjs +340 -156
  14. package/dist/{journals-DUpWwFt1.d.mts → journals-CTrAuzdk.d.mts} +1 -1
  15. package/dist/{partner-ledger-BIkmQsAc.mjs → partner-ledger-B0eym6Ss.mjs} +868 -212
  16. package/dist/plugins/index.d.mts +1 -1
  17. package/dist/plugins/index.mjs +1 -1
  18. package/dist/reports/index.d.mts +1 -1
  19. package/dist/reports/index.mjs +1 -1
  20. package/dist/{trial-balance-DCG5lOoC.d.mts → trial-balance-UXV2PN6x.d.mts} +215 -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);
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;
685
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,33 @@ 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
+ }]
1238
+ },
1239
+ investing: {
1240
+ totals: { ...zeros },
1241
+ lines: []
718
1242
  },
719
- operating: flows.Operating,
720
- investing: flows.Investing,
721
- financing: flows.Financing,
722
- netCashFlow
1243
+ financing: {
1244
+ totals: { ...zeros },
1245
+ lines: []
1246
+ },
1247
+ fxEffect: { ...zeros },
1248
+ netCashFlow: { ...zeros },
1249
+ cashReconciliation: recon
723
1250
  };
724
1251
  }
725
1252
  //#endregion
@@ -1075,6 +1602,7 @@ async function generateIncomeStatement(opts, params) {
1075
1602
  requireOrgScope(orgField, params.organizationId);
1076
1603
  const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
1077
1604
  const itemFilters = buildItemFilters(params.filters);
1605
+ const periods = buildPeriodColumns(startDate, endDate, params.comparative ?? null);
1078
1606
  const q = { active: true };
1079
1607
  if (orgField && params.organizationId) q[orgField] = params.organizationId;
1080
1608
  const allAccounts = await AccountModel.find(q).lean();
@@ -1082,30 +1610,9 @@ async function generateIncomeStatement(opts, params) {
1082
1610
  const at = country.getAccountType(a.accountTypeCode);
1083
1611
  return at && !at.isGroup && !at.isTotal && at.category.startsWith("Income Statement");
1084
1612
  }).map((a) => a._id);
1085
- const baseMatch = {
1086
- state: "posted",
1087
- date: {
1088
- $gte: startDate,
1089
- $lte: endDate
1090
- }
1091
- };
1613
+ const baseMatch = { state: "posted" };
1092
1614
  if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
1093
- const results = await JournalEntryModel.aggregate([
1094
- { $match: baseMatch },
1095
- { $unwind: "$journalItems" },
1096
- { $match: {
1097
- "journalItems.account": { $in: isIds },
1098
- ...itemFilters
1099
- } },
1100
- { $group: {
1101
- _id: "$journalItems.account",
1102
- d: { $sum: "$journalItems.debit" },
1103
- c: { $sum: "$journalItems.credit" }
1104
- } }
1105
- ]);
1106
1615
  const accountMap = new Map(allAccounts.map((a) => [String(a._id), a]));
1107
- const revenueGroups = {};
1108
- const expenseGroups = {};
1109
1616
  const resolveGroupName = (at) => {
1110
1617
  const visited = /* @__PURE__ */ new Set();
1111
1618
  let current = at.parentCode ? country.getAccountType(at.parentCode) : void 0;
@@ -1116,56 +1623,157 @@ async function generateIncomeStatement(opts, params) {
1116
1623
  }
1117
1624
  return at.name;
1118
1625
  };
1119
- for (const r of results) {
1120
- const acc = accountMap.get(String(r._id));
1121
- if (!acc) continue;
1122
- const at = country.getAccountType(acc.accountTypeCode);
1123
- if (!at) continue;
1124
- const mainType = extractMainType(at.category);
1125
- const netAmount = mainType === "Income" ? r.c - r.d : r.d - r.c;
1126
- if (netAmount === 0) continue;
1127
- const groupName = resolveGroupName(at);
1128
- const groups = mainType === "Income" ? revenueGroups : expenseGroups;
1129
- if (!(groupName in groups)) groups[groupName] = {
1130
- name: groupName,
1131
- total: 0,
1132
- accounts: []
1133
- };
1134
- groups[groupName].accounts.push({
1135
- id: acc._id,
1136
- name: acc.name ?? at.name,
1137
- code: acc.accountNumber ?? at.code,
1138
- balance: netAmount
1139
- });
1140
- 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);
1141
1650
  }
1142
- const sortGroups = (groups) => {
1143
- const sorted = Object.values(groups);
1144
- for (const g of sorted) g.accounts.sort((a, b) => (a.code ?? "").localeCompare(b.code ?? "", void 0, { numeric: true }));
1145
- sorted.sort((a, b) => {
1146
- const codeA = a.accounts[0]?.code ?? "";
1147
- const codeB = b.accounts[0]?.code ?? "";
1148
- return codeA.localeCompare(codeB, void 0, { numeric: true });
1149
- });
1150
- 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
+ })))
1151
1721
  };
1152
- const labels = country.reportLabels ?? {};
1153
- const revenue = {
1154
- name: labels.revenue ?? "Revenue",
1155
- total: Object.values(revenueGroups).reduce((s, g) => s + g.total, 0),
1156
- 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
+ })))
1157
1735
  };
1158
- const expenses = {
1159
- name: labels.expenses ?? "Expenses",
1160
- total: Object.values(expenseGroups).reduce((s, g) => s + g.total, 0),
1161
- 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
+ ]
1162
1776
  };
1163
- const cogsCode = country.cogsGroupCode;
1164
- const isCogs = (name) => cogsCode ? name === cogsCode : name === "Cost of Sales" || name === "Cost of Goods Sold";
1165
- const costOfSales = expenses.groups.find((g) => isCogs(g.name))?.total ?? 0;
1166
- const grossProfit = revenue.total - costOfSales;
1167
- const operatingIncome = grossProfit - expenses.groups.filter((g) => !isCogs(g.name)).reduce((s, g) => s + g.total, 0);
1168
- const netIncome = revenue.total - expenses.total;
1169
1777
  const periodDisplay = params.dateOption === "year" ? `For the year ended ${endDate.toLocaleDateString("en-US", {
1170
1778
  year: "numeric",
1171
1779
  month: "long",
@@ -1178,20 +1786,28 @@ async function generateIncomeStatement(opts, params) {
1178
1786
  month: "short",
1179
1787
  day: "numeric"
1180
1788
  })}`;
1789
+ const labels = country.reportLabels ?? {};
1181
1790
  return {
1182
1791
  metadata: {
1183
1792
  businessName: params.businessName,
1184
1793
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1185
- periodStart: startDate.toISOString().split("T")[0],
1186
- periodEnd: endDate.toISOString().split("T")[0],
1187
- 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
+ }
1188
1802
  },
1189
- revenue,
1190
- costOfSales,
1191
- grossProfit,
1192
- expenses,
1193
- operatingIncome,
1194
- netIncome
1803
+ periods: periods.map((p) => p.column),
1804
+ revenueSection,
1805
+ expensesSection,
1806
+ summarySection,
1807
+ costOfSalesByPeriod,
1808
+ grossProfitByPeriod,
1809
+ operatingIncomeByPeriod,
1810
+ netIncomeByPeriod
1195
1811
  };
1196
1812
  }
1197
1813
  //#endregion
@@ -1398,7 +2014,7 @@ async function generateTrialBalance(opts, params) {
1398
2014
  const { AccountModel, JournalEntryModel, country, orgField, fiscalYearStartMonth = 1 } = opts;
1399
2015
  requireOrgScope(orgField, params.organizationId);
1400
2016
  const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
1401
- const fiscalYearStart = getFiscalYearStart(startDate, fiscalYearStartMonth);
2017
+ const periods = buildPeriodColumns(startDate, endDate, params.comparative ?? null);
1402
2018
  const itemFilters = buildItemFilters(params.filters);
1403
2019
  const accountQuery = { active: true };
1404
2020
  if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
@@ -1414,6 +2030,7 @@ async function generateTrialBalance(opts, params) {
1414
2030
  const baseMatch = { state: "posted" };
1415
2031
  if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
1416
2032
  const accountFilter = params.accountId ? { "journalItems.account": params.accountId } : {};
2033
+ const accountLookup = new Map(allAccounts.map((a) => [String(a._id), a]));
1417
2034
  const buildPipeline = (ids, dateFrom, dateTo) => [
1418
2035
  { $match: {
1419
2036
  ...baseMatch,
@@ -1434,58 +2051,95 @@ async function generateTrialBalance(opts, params) {
1434
2051
  c: { $sum: "$journalItems.credit" }
1435
2052
  } }
1436
2053
  ];
1437
- const [bsInitial, isInitial, current] = await Promise.all([
1438
- bsIds.length ? JournalEntryModel.aggregate(buildPipeline(bsIds, /* @__PURE__ */ new Date(0), startDate)) : [],
1439
- isIds.length ? JournalEntryModel.aggregate(buildPipeline(isIds, fiscalYearStart, startDate)) : [],
1440
- JournalEntryModel.aggregate(buildPipeline([...bsIds, ...isIds], startDate, new Date(endDate.getTime() + 1)))
1441
- ]);
1442
- const map = /* @__PURE__ */ new Map();
1443
- for (const r of [...bsInitial, ...isInitial]) {
1444
- const key = String(r._id);
1445
- map.set(key, {
1446
- iD: r.d,
1447
- iC: r.c,
1448
- cD: 0,
1449
- cC: 0
1450
- });
1451
- }
1452
- for (const r of current) {
1453
- const key = String(r._id);
1454
- const existing = map.get(key) ?? {
1455
- iD: 0,
1456
- iC: 0,
1457
- cD: 0,
1458
- cC: 0
1459
- };
1460
- existing.cD = r.d;
1461
- existing.cC = r.c;
1462
- map.set(key, existing);
1463
- }
1464
- const accountLookup = new Map(allAccounts.map((a) => [String(a._id), a]));
1465
- const rows = [];
1466
- for (const [id, bal] of map) {
1467
- const acc = accountLookup.get(id);
1468
- const net = bal.iD + bal.cD - (bal.iC + bal.cC);
1469
- rows.push({
1470
- account: acc ?? id,
1471
- initial: {
1472
- debit: bal.iD,
1473
- credit: bal.iC
1474
- },
1475
- current: {
1476
- debit: bal.cD,
1477
- credit: bal.cC
1478
- },
1479
- ending: net >= 0 ? {
1480
- debit: net,
1481
- credit: 0
1482
- } : {
1483
- debit: 0,
1484
- credit: Math.abs(net)
1485
- }
1486
- });
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
+ }
1487
2141
  }
1488
- rows.sort((a, b) => {
2142
+ const columnarRows = [...columnarRowsByAccount.values()].sort((a, b) => {
1489
2143
  const codeA = a.account?.accountNumber ?? a.account?.accountTypeCode ?? "";
1490
2144
  const codeB = b.account?.accountNumber ?? b.account?.accountTypeCode ?? "";
1491
2145
  return codeA.localeCompare(codeB, void 0, { numeric: true });
@@ -1506,15 +2160,17 @@ async function generateTrialBalance(opts, params) {
1506
2160
  metadata: {
1507
2161
  businessName: params.businessName,
1508
2162
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1509
- periodStart: startDate.toISOString().split("T")[0],
1510
- periodEnd: endDate.toISOString().split("T")[0],
1511
- displayPeriod: periodDisplay
2163
+ periodStart: isoDate(startDate),
2164
+ periodEnd: isoDate(endDate),
2165
+ displayPeriod: periodDisplay,
2166
+ comparative: params.comparative ?? null
1512
2167
  },
1513
- rows,
1514
2168
  period: {
1515
2169
  startDate,
1516
2170
  endDate
1517
- }
2171
+ },
2172
+ periods: periods.map((p) => p.column),
2173
+ columnarRows
1518
2174
  };
1519
2175
  }
1520
2176
  //#endregion
@@ -1680,12 +2336,12 @@ async function closeFiscalPeriod(opts, params) {
1680
2336
  closingEntryId = closingEntry._id;
1681
2337
  }
1682
2338
  const closedAt = /* @__PURE__ */ new Date();
1683
- await FiscalPeriodModel.findOneAndUpdate(periodQuery, {
2339
+ await FiscalPeriodModel.findOneAndUpdate(periodQuery, { $set: {
1684
2340
  closed: true,
1685
2341
  closedAt,
1686
2342
  closedBy: closedBy ?? null,
1687
2343
  closingEntryId
1688
- }, queryOpts);
2344
+ } }, queryOpts);
1689
2345
  const result = {
1690
2346
  periodId,
1691
2347
  netIncome,
@@ -1722,14 +2378,14 @@ async function reopenFiscalPeriod(opts, params) {
1722
2378
  const closingEntryId = period.closingEntryId ?? null;
1723
2379
  if (closingEntryId) await JournalEntryModel.findByIdAndDelete(closingEntryId, queryOpts);
1724
2380
  const reopenedAt = /* @__PURE__ */ new Date();
1725
- await FiscalPeriodModel.findOneAndUpdate(periodQuery, {
2381
+ await FiscalPeriodModel.findOneAndUpdate(periodQuery, { $set: {
1726
2382
  closed: false,
1727
2383
  closedAt: null,
1728
2384
  closedBy: null,
1729
2385
  closingEntryId: null,
1730
2386
  reopenedAt,
1731
2387
  reopenedBy: reopenedBy ?? null
1732
- }, queryOpts);
2388
+ } }, queryOpts);
1733
2389
  const result = {
1734
2390
  periodId,
1735
2391
  deletedEntryId: closingEntryId,