@classytic/ledger 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +161 -64
  2. package/dist/{categories-CclX7Q94.mjs → categories-FJlrvzcl.mjs} +0 -2
  3. package/dist/constants/index.d.mts +2 -2
  4. package/dist/constants/index.mjs +3 -3
  5. package/dist/core-8Xfnpn6g.d.mts +1 -2
  6. package/dist/country/index.d.mts +1 -1
  7. package/dist/country/index.mjs +0 -2
  8. package/dist/currencies-W8kQAkm0.mjs +0 -2
  9. package/dist/{idempotency.plugin-v9NQ_ta-.mjs → date-lock.plugin-C8kqPBjh.mjs} +51 -11
  10. package/dist/{engine-BzBMpWuy.d.mts → engine-DF-MtsEr.d.mts} +10 -6
  11. package/dist/{errors-B7yC-Jfw.mjs → errors-BoGUSUYL.mjs} +0 -2
  12. package/dist/exports/index.d.mts +1 -1
  13. package/dist/exports/index.mjs +1 -1
  14. package/dist/{exports-I5Xkq-9_.mjs → exports-DoGQQtMQ.mjs} +96 -77
  15. package/dist/{fiscal-close-L631E3De.mjs → fiscal-close-DmPV82e4.mjs} +1000 -286
  16. package/dist/{idempotency.plugin-CPxPt4vX.d.mts → idempotency.plugin-zU-GKJ0-.d.mts} +19 -17
  17. package/dist/{index-ZnSiqHYV.d.mts → index-CxZqRaOU.d.mts} +20 -6
  18. package/dist/{index-BPukb3L8.d.mts → index-J-XIbXH-.d.mts} +7 -8
  19. package/dist/index.d.mts +280 -58
  20. package/dist/index.mjs +123 -25
  21. package/dist/journal-entry.schema-B1CzLwC3.d.mts +103 -0
  22. package/dist/{journals-oH-FK3g8.mjs → journals-BcMn71Cq.mjs} +27 -6
  23. package/dist/{currencies-4WAbFRlw.d.mts → journals-DTipb_rz.d.mts} +16 -8
  24. package/dist/logger-UbTdBb1x.d.mts +1 -2
  25. package/dist/money.d.mts +1 -2
  26. package/dist/money.mjs +5 -5
  27. package/dist/plugins/index.d.mts +38 -2
  28. package/dist/plugins/index.mjs +57 -2
  29. package/dist/reconciliation.repository-DEybU_Ok.d.mts +135 -0
  30. package/dist/{account.repository-kDKwDt0I.mjs → reconciliation.repository-DgJEDVS-.mjs} +361 -210
  31. package/dist/{fiscal-period.schema-BQ5wsAq3.mjs → reconciliation.schema-KScbsXbY.mjs} +235 -90
  32. package/dist/reports/index.d.mts +2 -2
  33. package/dist/reports/index.mjs +2 -2
  34. package/dist/repositories/index.d.mts +2 -2
  35. package/dist/repositories/index.mjs +2 -2
  36. package/dist/schemas/index.d.mts +71 -2
  37. package/dist/schemas/index.mjs +2 -2
  38. package/dist/tenant-guard-CAxXoWuS.mjs +13 -0
  39. package/dist/trial-balance-DcQ0xj_4.d.mts +530 -0
  40. package/docs/reports.md +1 -1
  41. package/package.json +14 -6
  42. package/dist/account.repository-C7gwFLfM.d.mts +0 -29
  43. package/dist/account.repository-C7gwFLfM.d.mts.map +0 -1
  44. package/dist/account.repository-kDKwDt0I.mjs.map +0 -1
  45. package/dist/categories-CclX7Q94.mjs.map +0 -1
  46. package/dist/core-8Xfnpn6g.d.mts.map +0 -1
  47. package/dist/country/index.mjs.map +0 -1
  48. package/dist/currencies-4WAbFRlw.d.mts.map +0 -1
  49. package/dist/currencies-W8kQAkm0.mjs.map +0 -1
  50. package/dist/engine-BzBMpWuy.d.mts.map +0 -1
  51. package/dist/errors-B7yC-Jfw.mjs.map +0 -1
  52. package/dist/exports-I5Xkq-9_.mjs.map +0 -1
  53. package/dist/fiscal-close-L631E3De.mjs.map +0 -1
  54. package/dist/fiscal-close-dNlzB37y.d.mts +0 -270
  55. package/dist/fiscal-close-dNlzB37y.d.mts.map +0 -1
  56. package/dist/fiscal-period.schema-BQ5wsAq3.mjs.map +0 -1
  57. package/dist/fiscal-period.schema-BRdKAjrr.d.mts +0 -38
  58. package/dist/fiscal-period.schema-BRdKAjrr.d.mts.map +0 -1
  59. package/dist/idempotency.plugin-CPxPt4vX.d.mts.map +0 -1
  60. package/dist/idempotency.plugin-v9NQ_ta-.mjs.map +0 -1
  61. package/dist/index-BPukb3L8.d.mts.map +0 -1
  62. package/dist/index-ZnSiqHYV.d.mts.map +0 -1
  63. package/dist/index.d.mts.map +0 -1
  64. package/dist/index.mjs.map +0 -1
  65. package/dist/journals-oH-FK3g8.mjs.map +0 -1
  66. package/dist/logger-UbTdBb1x.d.mts.map +0 -1
  67. package/dist/money.d.mts.map +0 -1
  68. package/dist/money.mjs.map +0 -1
  69. package/dist/session-Ba8E3Ufa.mjs +0 -84
  70. package/dist/session-Ba8E3Ufa.mjs.map +0 -1
@@ -1,10 +1,189 @@
1
- import { n as Errors } from "./errors-B7yC-Jfw.mjs";
2
- import { i as requireOrgScope, n as finalizeSession, r as defaultLogger, t as acquireSession } from "./session-Ba8E3Ufa.mjs";
3
- import { i as extractMainType } from "./categories-CclX7Q94.mjs";
1
+ import { n as Errors } from "./errors-BoGUSUYL.mjs";
2
+ import { t as requireOrgScope } from "./tenant-guard-CAxXoWuS.mjs";
3
+ import { i as extractMainType } from "./categories-FJlrvzcl.mjs";
4
+ import mongoose from "mongoose";
5
+ //#region src/reports/aged-balance.ts
6
+ const DEFAULT_BUCKETS = [
7
+ {
8
+ label: "Current",
9
+ minDays: 0,
10
+ maxDays: 31
11
+ },
12
+ {
13
+ label: "31-60",
14
+ minDays: 31,
15
+ maxDays: 61
16
+ },
17
+ {
18
+ label: "61-90",
19
+ minDays: 61,
20
+ maxDays: 91
21
+ },
22
+ {
23
+ label: "90+",
24
+ minDays: 91,
25
+ maxDays: Infinity
26
+ }
27
+ ];
28
+ async function generateAgedBalance(opts, params) {
29
+ const { AccountModel, JournalEntryModel, country, orgField } = opts;
30
+ requireOrgScope(orgField, params.organizationId);
31
+ const asOfDate = params.asOfDate ?? /* @__PURE__ */ new Date();
32
+ const buckets = params.buckets ?? DEFAULT_BUCKETS;
33
+ const bucketLabels = buckets.map((b) => b.label);
34
+ const dueDateField = params.dueDateField ?? "journalItems.dueDate";
35
+ const contactField = params.contactField;
36
+ const accountQuery = { active: true };
37
+ if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
38
+ let targetAccountIds;
39
+ if (params.accountIds && params.accountIds.length > 0) targetAccountIds = params.accountIds;
40
+ else {
41
+ const allAccounts = await AccountModel.find(accountQuery).lean();
42
+ const categoryPrefix = params.type === "receivable" ? "Balance Sheet-Asset" : "Balance Sheet-Liability";
43
+ targetAccountIds = allAccounts.filter((a) => {
44
+ const at = country.getAccountType(a.accountTypeCode);
45
+ return at && !at.isGroup && at.category.startsWith(categoryPrefix);
46
+ }).map((a) => a._id);
47
+ }
48
+ if (targetAccountIds.length === 0) return {
49
+ metadata: {
50
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
51
+ asOfDate: asOfDate.toISOString().split("T")[0],
52
+ type: params.type
53
+ },
54
+ bucketLabels,
55
+ rows: [],
56
+ totals: Object.fromEntries(bucketLabels.map((l) => [l, 0])),
57
+ grandTotal: 0
58
+ };
59
+ const allAccounts = await AccountModel.find(accountQuery).lean();
60
+ const accountLookup = new Map(allAccounts.map((a) => [String(a._id), a]));
61
+ const baseMatch = {
62
+ state: "posted",
63
+ date: { $lte: asOfDate }
64
+ };
65
+ if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
66
+ asOfDate.getTime();
67
+ const groupId = { account: "$journalItems.account" };
68
+ if (contactField) groupId.contact = `$${contactField}`;
69
+ const bucketBranches = buckets.map((b) => {
70
+ return {
71
+ case: b.maxDays === Infinity ? { $gte: ["$daysPastDue", b.minDays] } : { $and: [{ $gte: ["$daysPastDue", b.minDays] }, { $lt: ["$daysPastDue", b.maxDays] }] },
72
+ then: b.label
73
+ };
74
+ });
75
+ const pipeline = [
76
+ { $match: baseMatch },
77
+ { $unwind: "$journalItems" },
78
+ { $match: { "journalItems.account": { $in: targetAccountIds } } },
79
+ { $addFields: { daysPastDue: { $floor: { $divide: [{ $subtract: [asOfDate, { $ifNull: [`$${dueDateField}`, asOfDate] }] }, 1e3 * 60 * 60 * 24] } } } },
80
+ { $addFields: { daysPastDue: { $max: ["$daysPastDue", 0] } } },
81
+ { $addFields: { bucketLabel: { $switch: {
82
+ branches: bucketBranches,
83
+ default: bucketLabels[bucketLabels.length - 1]
84
+ } } } },
85
+ { $addFields: { netAmount: params.type === "receivable" ? { $subtract: ["$journalItems.debit", "$journalItems.credit"] } : { $subtract: ["$journalItems.credit", "$journalItems.debit"] } } },
86
+ { $group: {
87
+ _id: {
88
+ ...groupId,
89
+ bucket: "$bucketLabel"
90
+ },
91
+ amount: { $sum: "$netAmount" }
92
+ } }
93
+ ];
94
+ const results = await JournalEntryModel.aggregate(pipeline);
95
+ const rowKey = (accountId, contactId) => contactField ? `${String(accountId)}::${String(contactId ?? "")}` : String(accountId);
96
+ const rowMap = /* @__PURE__ */ new Map();
97
+ for (const r of results) {
98
+ const key = rowKey(r._id.account, r._id.contact);
99
+ if (!rowMap.has(key)) {
100
+ const acc = accountLookup.get(String(r._id.account));
101
+ rowMap.set(key, {
102
+ accountId: r._id.account,
103
+ accountName: acc?.name ?? "",
104
+ accountCode: acc?.accountNumber ?? "",
105
+ ...contactField ? { contactId: r._id.contact } : {},
106
+ total: 0,
107
+ buckets: Object.fromEntries(bucketLabels.map((l) => [l, 0]))
108
+ });
109
+ }
110
+ const row = rowMap.get(key);
111
+ if (row.buckets[r._id.bucket] !== void 0) row.buckets[r._id.bucket] += r.amount;
112
+ row.total += r.amount;
113
+ }
114
+ const rows = Array.from(rowMap.values()).sort((a, b) => a.accountCode.localeCompare(b.accountCode, void 0, { numeric: true }));
115
+ const totals = Object.fromEntries(bucketLabels.map((l) => [l, 0]));
116
+ let grandTotal = 0;
117
+ for (const row of rows) {
118
+ for (const label of bucketLabels) totals[label] += row.buckets[label];
119
+ grandTotal += row.total;
120
+ }
121
+ return {
122
+ metadata: {
123
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
124
+ asOfDate: asOfDate.toISOString().split("T")[0],
125
+ type: params.type
126
+ },
127
+ bucketLabels,
128
+ rows,
129
+ totals,
130
+ grandTotal
131
+ };
132
+ }
133
+ //#endregion
134
+ //#region src/utils/account-helpers.ts
135
+ /**
136
+ * Account Helper Utilities
137
+ */
138
+ /**
139
+ * Check if an account type is a virtual tax sub-account.
140
+ * Returns true if the account's parent has `isVirtualTotal: true`.
141
+ * Works for any country pack — no code format assumptions.
142
+ */
143
+ function isVirtualTaxAccount(accountType, accountMap) {
144
+ if (!accountType.parentCode) return false;
145
+ return accountMap.get(accountType.parentCode)?.isVirtualTotal === true;
146
+ }
147
+ /**
148
+ * Calculate a total from sub-accounts using the totalAccountTypes formula.
149
+ * @param formula - Array of { account, operation } instructions
150
+ * @param balanceMap - Map of account code → balance
151
+ */
152
+ function calculateTotal(formula, balanceMap) {
153
+ let total = 0;
154
+ for (const item of formula) {
155
+ const balance = balanceMap.get(item.account) ?? 0;
156
+ total += item.operation === "+" ? balance : -balance;
157
+ }
158
+ return total;
159
+ }
160
+ /**
161
+ * Compute the ending balance for an account given its debits and credits.
162
+ * Uses the account's main type to determine normal balance direction.
163
+ *
164
+ * Assets & Expenses: debit - credit
165
+ * Liabilities, Equity & Income: credit - debit
166
+ */
167
+ function computeEndingBalance(category, totalDebit, totalCredit) {
168
+ const mainType = extractMainType(category);
169
+ if (mainType === "Asset" || mainType === "Expense") return totalDebit - totalCredit;
170
+ return totalCredit - totalDebit;
171
+ }
172
+ /**
173
+ * Build a lookup map from an array of account types.
174
+ */
175
+ function buildAccountTypeMap(accountTypes) {
176
+ const map = /* @__PURE__ */ new Map();
177
+ for (const at of accountTypes) map.set(at.code, at);
178
+ return map;
179
+ }
180
+ //#endregion
4
181
  //#region src/utils/date-range.ts
5
182
  /**
6
183
  * Compute start/end dates from a date option + value.
7
184
  *
185
+ * @throws {Error} If value is null/undefined/invalid for the given option
186
+ *
8
187
  * Examples:
9
188
  * getDateRange('month', '2025-03') → Mar 1 – Mar 31
10
189
  * getDateRange('quarter', { quarter: 2, year: 2025 }) → Apr 1 – Jun 30
@@ -12,6 +191,7 @@ import { i as extractMainType } from "./categories-CclX7Q94.mjs";
12
191
  * getDateRange('custom', { startDate, endDate })
13
192
  */
14
193
  function getDateRange(option, value) {
194
+ if (value == null && (option === "month" || option === "quarter" || option === "year" || option === "custom")) throw new Error(`dateValue is required for dateOption "${option}"`);
15
195
  switch (option) {
16
196
  case "month": {
17
197
  let year;
@@ -22,16 +202,21 @@ function getDateRange(option, value) {
22
202
  month = parseInt(match[2], 10) - 1;
23
203
  } else {
24
204
  const date = new Date(value);
205
+ if (Number.isNaN(date.getTime())) throw new Error(`Invalid month value: ${String(value)}`);
25
206
  year = date.getFullYear();
26
207
  month = date.getMonth();
27
208
  }
209
+ if (year < 1900 || year > 9999) throw new Error(`Year ${year} is out of valid range (1900–9999)`);
28
210
  return {
29
211
  startDate: new Date(year, month, 1),
30
212
  endDate: new Date(year, month + 1, 0, 23, 59, 59, 999)
31
213
  };
32
214
  }
33
215
  case "quarter": {
216
+ if (typeof value !== "object" || value === null) throw new Error("Quarter dateValue must be an object with { quarter, year }");
34
217
  const { quarter, year } = value;
218
+ if (!Number.isInteger(quarter) || quarter < 1 || quarter > 4) throw new Error(`Invalid quarter: ${quarter}. Must be 1–4.`);
219
+ if (!Number.isInteger(year) || year < 1900 || year > 9999) throw new Error(`Invalid year: ${year}. Must be 1900–9999.`);
35
220
  const startMonth = (quarter - 1) * 3;
36
221
  return {
37
222
  startDate: new Date(year, startMonth, 1),
@@ -40,17 +225,23 @@ function getDateRange(option, value) {
40
225
  }
41
226
  case "year": {
42
227
  const year = typeof value === "number" ? value : parseInt(String(value), 10);
228
+ if (Number.isNaN(year) || year < 1900 || year > 9999) throw new Error(`Invalid year: ${String(value)}. Must be a number between 1900–9999.`);
43
229
  return {
44
230
  startDate: new Date(year, 0, 1),
45
231
  endDate: new Date(year, 11, 31, 23, 59, 59, 999)
46
232
  };
47
233
  }
48
234
  case "custom": {
49
- const { startDate, endDate } = value;
50
- const end = new Date(endDate);
235
+ if (typeof value !== "object" || value === null) throw new Error("Custom dateValue must be an object with { startDate, endDate }");
236
+ const { startDate: rawStart, endDate: rawEnd } = value;
237
+ if (!rawStart || !rawEnd) throw new Error("Custom date range requires both startDate and endDate");
238
+ const start = new Date(rawStart);
239
+ const end = new Date(rawEnd);
240
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) throw new Error("Custom date range contains invalid dates");
241
+ if (start > end) throw new Error("startDate must be before endDate");
51
242
  if (end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0 && end.getMilliseconds() === 0) end.setHours(23, 59, 59, 999);
52
243
  return {
53
- startDate: new Date(startDate),
244
+ startDate: start,
54
245
  endDate: end
55
246
  };
56
247
  }
@@ -107,154 +298,9 @@ function buildItemFilters(filters) {
107
298
  return result;
108
299
  }
109
300
  //#endregion
110
- //#region src/reports/trial-balance.ts
111
- async function generateTrialBalance(opts, params) {
112
- const { AccountModel, JournalEntryModel, country, orgField, fiscalYearStartMonth = 1 } = opts;
113
- requireOrgScope(orgField, params.organizationId);
114
- const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
115
- const fiscalYearStart = getFiscalYearStart(startDate, fiscalYearStartMonth);
116
- const itemFilters = buildItemFilters(params.filters);
117
- const accountQuery = { active: true };
118
- if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
119
- const allAccounts = await AccountModel.find(accountQuery).lean();
120
- const bsIds = [];
121
- const isIds = [];
122
- for (const acc of allAccounts) {
123
- const at = country.getAccountType(acc.accountTypeCode);
124
- if (!at || at.isGroup) continue;
125
- if (at.category.startsWith("Balance Sheet")) bsIds.push(acc._id);
126
- else if (at.category.startsWith("Income Statement")) isIds.push(acc._id);
127
- }
128
- const baseMatch = { state: "posted" };
129
- if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
130
- const accountFilter = params.accountId ? { "journalItems.account": params.accountId } : {};
131
- const buildPipeline = (ids, dateFrom, dateTo) => [
132
- { $match: {
133
- ...baseMatch,
134
- date: {
135
- $gte: dateFrom,
136
- $lt: dateTo
137
- }
138
- } },
139
- { $unwind: "$journalItems" },
140
- { $match: {
141
- "journalItems.account": { $in: ids },
142
- ...accountFilter,
143
- ...itemFilters
144
- } },
145
- { $group: {
146
- _id: "$journalItems.account",
147
- d: { $sum: "$journalItems.debit" },
148
- c: { $sum: "$journalItems.credit" }
149
- } }
150
- ];
151
- const [bsInitial, isInitial, current] = await Promise.all([
152
- bsIds.length ? JournalEntryModel.aggregate(buildPipeline(bsIds, /* @__PURE__ */ new Date(0), startDate)) : [],
153
- isIds.length ? JournalEntryModel.aggregate(buildPipeline(isIds, fiscalYearStart, startDate)) : [],
154
- JournalEntryModel.aggregate(buildPipeline([...bsIds, ...isIds], startDate, new Date(endDate.getTime() + 1)))
155
- ]);
156
- const map = /* @__PURE__ */ new Map();
157
- for (const r of [...bsInitial, ...isInitial]) {
158
- const key = String(r._id);
159
- map.set(key, {
160
- iD: r.d,
161
- iC: r.c,
162
- cD: 0,
163
- cC: 0
164
- });
165
- }
166
- for (const r of current) {
167
- const key = String(r._id);
168
- const existing = map.get(key) ?? {
169
- iD: 0,
170
- iC: 0,
171
- cD: 0,
172
- cC: 0
173
- };
174
- existing.cD = r.d;
175
- existing.cC = r.c;
176
- map.set(key, existing);
177
- }
178
- const accountLookup = new Map(allAccounts.map((a) => [String(a._id), a]));
179
- const rows = [];
180
- for (const [id, bal] of map) {
181
- const acc = accountLookup.get(id);
182
- const net = bal.iD + bal.cD - (bal.iC + bal.cC);
183
- rows.push({
184
- account: acc ?? id,
185
- initial: {
186
- debit: bal.iD,
187
- credit: bal.iC
188
- },
189
- current: {
190
- debit: bal.cD,
191
- credit: bal.cC
192
- },
193
- ending: net >= 0 ? {
194
- debit: net,
195
- credit: 0
196
- } : {
197
- debit: 0,
198
- credit: Math.abs(net)
199
- }
200
- });
201
- }
202
- return {
203
- rows,
204
- period: {
205
- startDate,
206
- endDate
207
- }
208
- };
209
- }
210
- //#endregion
211
- //#region src/utils/account-helpers.ts
212
- /**
213
- * Check if an account type is a virtual tax sub-account.
214
- * Returns true if the account's parent has `isVirtualTotal: true`.
215
- * Works for any country pack — no code format assumptions.
216
- */
217
- function isVirtualTaxAccount(accountType, accountMap) {
218
- if (!accountType.parentCode) return false;
219
- return accountMap.get(accountType.parentCode)?.isVirtualTotal === true;
220
- }
221
- /**
222
- * Calculate a total from sub-accounts using the totalAccountTypes formula.
223
- * @param formula - Array of { account, operation } instructions
224
- * @param balanceMap - Map of account code → balance
225
- */
226
- function calculateTotal(formula, balanceMap) {
227
- let total = 0;
228
- for (const item of formula) {
229
- const balance = balanceMap.get(item.account) ?? 0;
230
- total += item.operation === "+" ? balance : -balance;
231
- }
232
- return total;
233
- }
234
- /**
235
- * Compute the ending balance for an account given its debits and credits.
236
- * Uses the account's main type to determine normal balance direction.
237
- *
238
- * Assets & Expenses: debit - credit
239
- * Liabilities, Equity & Income: credit - debit
240
- */
241
- function computeEndingBalance(category, totalDebit, totalCredit) {
242
- const mainType = extractMainType(category);
243
- if (mainType === "Asset" || mainType === "Expense") return totalDebit - totalCredit;
244
- return totalCredit - totalDebit;
245
- }
246
- /**
247
- * Build a lookup map from an array of account types.
248
- */
249
- function buildAccountTypeMap(accountTypes) {
250
- const map = /* @__PURE__ */ new Map();
251
- for (const at of accountTypes) map.set(at.code, at);
252
- return map;
253
- }
254
- //#endregion
255
301
  //#region src/reports/balance-sheet.ts
256
302
  async function generateBalanceSheet(opts, params) {
257
- const { AccountModel, JournalEntryModel, country, orgField, fiscalYearStartMonth = 1, retainedEarningsCode = country.retainedEarningsCode ?? "3660", currentYearEarningsCode = country.currentYearEarningsCode ?? "3680" } = opts;
303
+ const { AccountModel, JournalEntryModel, country, orgField, fiscalYearStartMonth = 1, retainedEarningsAccountCode = country.retainedEarningsAccountCode, retainedEarningsDisplayCode = country.retainedEarningsDisplayCode ?? retainedEarningsAccountCode, currentYearEarningsCode = country.currentYearEarningsCode ?? "3680" } = opts;
258
304
  requireOrgScope(orgField, params.organizationId);
259
305
  const { endDate } = getDateRange(params.dateOption, params.dateValue);
260
306
  const fiscalYearStart = getFiscalYearStart(endDate, fiscalYearStartMonth);
@@ -266,13 +312,15 @@ async function generateBalanceSheet(opts, params) {
266
312
  const at = country.getAccountType(a.accountTypeCode);
267
313
  return at && !at.isGroup && at.category.startsWith("Balance Sheet");
268
314
  }).map((a) => a._id);
315
+ const reAccountIds = retainedEarningsAccountCode ? allAccounts.filter((a) => a.accountTypeCode === retainedEarningsAccountCode).map((a) => a._id) : [];
316
+ const reAccountIdSet = new Set(reAccountIds.map(String));
269
317
  const isIds = allAccounts.filter((a) => {
270
318
  const at = country.getAccountType(a.accountTypeCode);
271
319
  return at && !at.isGroup && !at.isTotal && at.category.startsWith("Income Statement");
272
320
  }).map((a) => a._id);
273
321
  const baseMatch = { state: "posted" };
274
322
  if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
275
- const [bsResults, netIncomeResults, priorRetainedResults] = await Promise.all([
323
+ const [bsResults, netIncomeResults, priorRetainedResults, reAccountResults] = await Promise.all([
276
324
  JournalEntryModel.aggregate([
277
325
  { $match: {
278
326
  ...baseMatch,
@@ -323,10 +371,27 @@ async function generateBalanceSheet(opts, params) {
323
371
  d: { $sum: "$journalItems.debit" },
324
372
  c: { $sum: "$journalItems.credit" }
325
373
  } }
326
- ])
374
+ ]),
375
+ ...reAccountIds.length > 0 ? [JournalEntryModel.aggregate([
376
+ { $match: {
377
+ ...baseMatch,
378
+ date: { $lte: endDate }
379
+ } },
380
+ { $unwind: "$journalItems" },
381
+ { $match: {
382
+ "journalItems.account": { $in: reAccountIds },
383
+ ...itemFilters
384
+ } },
385
+ { $group: {
386
+ _id: null,
387
+ d: { $sum: "$journalItems.debit" },
388
+ c: { $sum: "$journalItems.credit" }
389
+ } }
390
+ ])] : [Promise.resolve([])]
327
391
  ]);
328
392
  const netIncome = netIncomeResults.length > 0 ? netIncomeResults[0].c - netIncomeResults[0].d : 0;
329
- const priorRetained = priorRetainedResults.length > 0 ? priorRetainedResults[0].c - priorRetainedResults[0].d : 0;
393
+ const priorUnclosedPL = priorRetainedResults.length > 0 ? priorRetainedResults[0].c - priorRetainedResults[0].d : 0;
394
+ const priorRetained = (reAccountResults.length > 0 ? reAccountResults[0].c - reAccountResults[0].d : 0) + priorUnclosedPL;
330
395
  const accountMap = new Map(allAccounts.map((a) => [String(a._id), a]));
331
396
  const accountTypeMap = buildAccountTypeMap(country.accountTypes);
332
397
  const balanceMap = /* @__PURE__ */ new Map();
@@ -354,6 +419,7 @@ async function generateBalanceSheet(opts, params) {
354
419
  for (const r of bsResults) {
355
420
  const acc = accountMap.get(String(r._id));
356
421
  if (!acc) continue;
422
+ if (reAccountIdSet.has(String(r._id))) continue;
357
423
  const at = country.getAccountType(acc.accountTypeCode);
358
424
  if (!at) continue;
359
425
  const mainType = extractMainType(at.category) ?? "Asset";
@@ -382,7 +448,7 @@ async function generateBalanceSheet(opts, params) {
382
448
  accounts: [{
383
449
  id: "prior-retained",
384
450
  name: "Previous Years Retained Earnings",
385
- code: retainedEarningsCode,
451
+ code: retainedEarningsDisplayCode ?? retainedEarningsAccountCode ?? "",
386
452
  balance: priorRetained
387
453
  }, {
388
454
  id: "current-year",
@@ -397,13 +463,24 @@ async function generateBalanceSheet(opts, params) {
397
463
  groupsMap.Equity[reGroup.name].accounts.push(...reGroup.accounts);
398
464
  groupsMap.Equity[reGroup.name].total += reGroup.total;
399
465
  }
466
+ const sortAccountsInGroups = (groups) => {
467
+ for (const g of Object.values(groups)) g.accounts.sort((a, b) => (a.code ?? "").localeCompare(b.code ?? "", void 0, { numeric: true }));
468
+ };
469
+ sortAccountsInGroups(groupsMap.Asset);
470
+ sortAccountsInGroups(groupsMap.Liability);
471
+ sortAccountsInGroups(groupsMap.Equity);
472
+ const sortGroupsByCode = (groups) => groups.sort((a, b) => {
473
+ const codeA = a.accounts[0]?.code ?? "";
474
+ const codeB = b.accounts[0]?.code ?? "";
475
+ return codeA.localeCompare(codeB, void 0, { numeric: true });
476
+ });
400
477
  const pruneGroups = (groups) => Object.values(groups).map((g) => ({
401
478
  ...g,
402
479
  accounts: g.accounts.filter((a) => a.balance !== 0 || a.isTotal || a.isCalculated)
403
480
  })).filter((g) => g.accounts.length > 0 || g.total !== 0);
404
- assets.groups = pruneGroups(groupsMap.Asset);
405
- liabilities.groups = pruneGroups(groupsMap.Liability);
406
- equity.groups = Object.values(groupsMap.Equity);
481
+ assets.groups = sortGroupsByCode(pruneGroups(groupsMap.Asset));
482
+ liabilities.groups = sortGroupsByCode(pruneGroups(groupsMap.Liability));
483
+ equity.groups = sortGroupsByCode(Object.values(groupsMap.Equity));
407
484
  assets.total = assets.groups.reduce((s, g) => s + g.total, 0);
408
485
  liabilities.total = liabilities.groups.reduce((s, g) => s + g.total, 0);
409
486
  equity.total = equity.groups.reduce((s, g) => s + g.total, 0);
@@ -433,19 +510,41 @@ async function generateBalanceSheet(opts, params) {
433
510
  };
434
511
  }
435
512
  //#endregion
436
- //#region src/reports/income-statement.ts
437
- async function generateIncomeStatement(opts, params) {
438
- const { AccountModel, JournalEntryModel, country, orgField } = opts;
513
+ //#region src/reports/budget-vs-actual.ts
514
+ async function generateBudgetVsActual(opts, params) {
515
+ const { AccountModel, JournalEntryModel, BudgetModel, country, orgField } = opts;
439
516
  requireOrgScope(orgField, params.organizationId);
440
517
  const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
441
- const itemFilters = buildItemFilters(params.filters);
442
- const q = { active: true };
443
- if (orgField && params.organizationId) q[orgField] = params.organizationId;
444
- const allAccounts = await AccountModel.find(q).lean();
445
- const isIds = allAccounts.filter((a) => {
446
- const at = country.getAccountType(a.accountTypeCode);
447
- return at && !at.isGroup && !at.isTotal && at.category.startsWith("Income Statement");
448
- }).map((a) => a._id);
518
+ const budgetQuery = {
519
+ periodStart: { $lte: endDate },
520
+ periodEnd: { $gte: startDate }
521
+ };
522
+ if (orgField && params.organizationId) budgetQuery[orgField] = params.organizationId;
523
+ if (params.accountIds && params.accountIds.length > 0) budgetQuery.account = { $in: params.accountIds };
524
+ const budgets = await BudgetModel.find(budgetQuery).lean();
525
+ if (budgets.length === 0) return {
526
+ metadata: {
527
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
528
+ periodStart: startDate.toISOString(),
529
+ periodEnd: endDate.toISOString()
530
+ },
531
+ rows: [],
532
+ summary: {
533
+ totalBudget: 0,
534
+ totalActual: 0,
535
+ totalVariance: 0
536
+ }
537
+ };
538
+ const budgetByAccount = /* @__PURE__ */ new Map();
539
+ for (const b of budgets) {
540
+ const key = String(b.account);
541
+ budgetByAccount.set(key, (budgetByAccount.get(key) ?? 0) + b.amount);
542
+ }
543
+ const accountIds = [...budgetByAccount.keys()];
544
+ const accountQuery = { _id: { $in: accountIds } };
545
+ if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
546
+ const accounts = await AccountModel.find(accountQuery).lean();
547
+ const accountMap = new Map(accounts.map((a) => [String(a._id), a]));
449
548
  const baseMatch = {
450
549
  state: "posted",
451
550
  date: {
@@ -454,11 +553,105 @@ async function generateIncomeStatement(opts, params) {
454
553
  }
455
554
  };
456
555
  if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
457
- const results = await JournalEntryModel.aggregate([
556
+ const pipeline = [
557
+ { $match: baseMatch },
558
+ { $unwind: "$journalItems" },
559
+ { $match: { "journalItems.account": { $in: accountIds.map((id) => new mongoose.Types.ObjectId(id)) } } },
560
+ { $group: {
561
+ _id: "$journalItems.account",
562
+ totalDebit: { $sum: "$journalItems.debit" },
563
+ totalCredit: { $sum: "$journalItems.credit" }
564
+ } }
565
+ ];
566
+ const actuals = await JournalEntryModel.aggregate(pipeline);
567
+ const actualByAccount = /* @__PURE__ */ new Map();
568
+ for (const a of actuals) actualByAccount.set(String(a._id), {
569
+ debit: a.totalDebit,
570
+ credit: a.totalCredit
571
+ });
572
+ const rows = [];
573
+ for (const [accountId, budgetAmount] of budgetByAccount) {
574
+ const acc = accountMap.get(accountId);
575
+ if (!acc) continue;
576
+ const at = country.getAccountType(acc.accountTypeCode);
577
+ if (!at || at.isGroup) continue;
578
+ const actual = actualByAccount.get(accountId) ?? {
579
+ debit: 0,
580
+ credit: 0
581
+ };
582
+ const mainType = extractMainType(at.category);
583
+ let actualAmount;
584
+ if (mainType === "Income") actualAmount = actual.credit - actual.debit;
585
+ else if (mainType === "Expense") actualAmount = actual.debit - actual.credit;
586
+ else if (mainType === "Asset") actualAmount = actual.debit - actual.credit;
587
+ else actualAmount = actual.credit - actual.debit;
588
+ const variance = actualAmount - budgetAmount;
589
+ const variancePercent = budgetAmount !== 0 ? Math.round(variance / budgetAmount * 1e4) / 100 : 0;
590
+ rows.push({
591
+ accountId: acc._id,
592
+ accountName: acc.name,
593
+ accountCode: acc.accountNumber,
594
+ category: at.category,
595
+ budgetAmount,
596
+ actualAmount,
597
+ variance,
598
+ variancePercent
599
+ });
600
+ }
601
+ rows.sort((a, b) => a.accountCode.localeCompare(b.accountCode));
602
+ const summary = {
603
+ totalBudget: rows.reduce((s, r) => s + r.budgetAmount, 0),
604
+ totalActual: rows.reduce((s, r) => s + r.actualAmount, 0),
605
+ totalVariance: rows.reduce((s, r) => s + r.variance, 0)
606
+ };
607
+ return {
608
+ metadata: {
609
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
610
+ periodStart: startDate.toISOString(),
611
+ periodEnd: endDate.toISOString()
612
+ },
613
+ rows,
614
+ summary
615
+ };
616
+ }
617
+ //#endregion
618
+ //#region src/reports/cash-flow.ts
619
+ async function generateCashFlow(opts, params) {
620
+ const { AccountModel, JournalEntryModel, country, orgField } = opts;
621
+ requireOrgScope(orgField, params.organizationId);
622
+ const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
623
+ const itemFilters = buildItemFilters(params.filters);
624
+ const q = { active: true };
625
+ if (orgField && params.organizationId) q[orgField] = params.organizationId;
626
+ const allAccounts = await AccountModel.find(q).lean();
627
+ const accountCfMap = /* @__PURE__ */ new Map();
628
+ const accountMap = new Map(allAccounts.map((a) => [String(a._id), a]));
629
+ const cfAccountIds = [];
630
+ for (const acc of allAccounts) {
631
+ const at = country.getAccountType(acc.accountTypeCode);
632
+ if (!at || at.isGroup || at.isTotal) continue;
633
+ const cf = at.cashFlowCategory;
634
+ if (!cf) continue;
635
+ const normalized = cf.charAt(0).toUpperCase() + cf.slice(1);
636
+ accountCfMap.set(String(acc._id), {
637
+ category: at.category,
638
+ cfCategory: normalized
639
+ });
640
+ cfAccountIds.push(acc._id);
641
+ }
642
+ const baseMatch = {
643
+ state: "posted",
644
+ date: {
645
+ $gte: startDate,
646
+ $lte: endDate
647
+ }
648
+ };
649
+ if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
650
+ const results = cfAccountIds.length > 0 ? await JournalEntryModel.aggregate([
458
651
  { $match: baseMatch },
459
652
  { $unwind: "$journalItems" },
460
653
  { $match: {
461
- "journalItems.account": { $in: isIds },
654
+ "journalItems.account": { $in: cfAccountIds },
462
655
  ...itemFilters
463
656
  } },
464
657
  { $group: {
@@ -466,65 +659,38 @@ async function generateIncomeStatement(opts, params) {
466
659
  d: { $sum: "$journalItems.debit" },
467
660
  c: { $sum: "$journalItems.credit" }
468
661
  } }
469
- ]);
470
- const accountMap = new Map(allAccounts.map((a) => [String(a._id), a]));
471
- const revenueGroups = {};
472
- const expenseGroups = {};
473
- const resolveGroupName = (at) => {
474
- const visited = /* @__PURE__ */ new Set();
475
- let current = at.parentCode ? country.getAccountType(at.parentCode) : void 0;
476
- while (current && !visited.has(current.code)) {
477
- if (current.isGroup) return current.name;
478
- visited.add(current.code);
479
- current = current.parentCode ? country.getAccountType(current.parentCode) : void 0;
662
+ ]) : [];
663
+ const flows = {
664
+ Operating: {
665
+ total: 0,
666
+ accounts: []
667
+ },
668
+ Investing: {
669
+ total: 0,
670
+ accounts: []
671
+ },
672
+ Financing: {
673
+ total: 0,
674
+ accounts: []
480
675
  }
481
- return at.name;
482
676
  };
483
677
  for (const r of results) {
484
- const acc = accountMap.get(String(r._id));
485
- if (!acc) continue;
486
- const at = country.getAccountType(acc.accountTypeCode);
487
- if (!at) continue;
488
- const mainType = extractMainType(at.category);
489
- const netAmount = mainType === "Income" ? r.c - r.d : r.d - r.c;
490
- if (netAmount === 0) continue;
491
- const groupName = resolveGroupName(at);
492
- const groups = mainType === "Income" ? revenueGroups : expenseGroups;
493
- if (!(groupName in groups)) groups[groupName] = {
494
- name: groupName,
495
- total: 0,
496
- accounts: []
497
- };
498
- groups[groupName].accounts.push({
499
- id: acc._id,
500
- name: acc.name ?? at.name,
501
- code: acc.accountNumber ?? at.code,
502
- balance: netAmount
678
+ const accIdStr = String(r._id);
679
+ const meta = accountCfMap.get(accIdStr);
680
+ if (!meta) continue;
681
+ const amount = computeEndingBalance(meta.category, r.d, r.c);
682
+ const acc = accountMap.get(accIdStr);
683
+ const at = country.getAccountType(acc?.accountTypeCode);
684
+ flows[meta.cfCategory].accounts.push({
685
+ name: acc?.name ?? at?.name ?? "",
686
+ code: acc?.accountNumber ?? at?.code ?? "",
687
+ amount
503
688
  });
504
- groups[groupName].total += netAmount;
689
+ flows[meta.cfCategory].total += amount;
505
690
  }
506
- const labels = country.reportLabels ?? {};
507
- const revenue = {
508
- name: labels.revenue ?? "Revenue",
509
- total: Object.values(revenueGroups).reduce((s, g) => s + g.total, 0),
510
- groups: Object.values(revenueGroups)
511
- };
512
- const expenses = {
513
- name: labels.expenses ?? "Expenses",
514
- total: Object.values(expenseGroups).reduce((s, g) => s + g.total, 0),
515
- groups: Object.values(expenseGroups)
516
- };
517
- const cogsCode = country.cogsGroupCode;
518
- const isCogs = (name) => cogsCode ? name === cogsCode : name === "Cost of Sales" || name === "Cost of Goods Sold";
519
- const costOfSales = expenses.groups.find((g) => isCogs(g.name))?.total ?? 0;
520
- const grossProfit = revenue.total - costOfSales;
521
- const operatingIncome = grossProfit - expenses.groups.filter((g) => !isCogs(g.name)).reduce((s, g) => s + g.total, 0);
522
- const netIncome = revenue.total - expenses.total;
523
- const periodDisplay = params.dateOption === "year" ? `For the year ended ${endDate.toLocaleDateString("en-US", {
524
- year: "numeric",
525
- month: "long",
526
- day: "numeric"
527
- })}` : `${startDate.toLocaleDateString("en-US", {
691
+ for (const section of Object.values(flows)) section.accounts.sort((a, b) => a.code.localeCompare(b.code, void 0, { numeric: true }));
692
+ const netCashFlow = flows.Operating.total + flows.Investing.total + flows.Financing.total;
693
+ const periodDisplay = `${startDate.toLocaleDateString("en-US", {
528
694
  month: "short",
529
695
  day: "numeric"
530
696
  })} – ${endDate.toLocaleDateString("en-US", {
@@ -540,12 +706,116 @@ async function generateIncomeStatement(opts, params) {
540
706
  periodEnd: endDate.toISOString().split("T")[0],
541
707
  displayPeriod: periodDisplay
542
708
  },
543
- revenue,
544
- costOfSales,
545
- grossProfit,
546
- expenses,
547
- operatingIncome,
548
- netIncome
709
+ operating: flows.Operating,
710
+ investing: flows.Investing,
711
+ financing: flows.Financing,
712
+ netCashFlow
713
+ };
714
+ }
715
+ //#endregion
716
+ //#region src/reports/dimension-breakdown.ts
717
+ async function generateDimensionBreakdown(opts, params) {
718
+ const { AccountModel, JournalEntryModel, country, orgField } = opts;
719
+ requireOrgScope(orgField, params.organizationId);
720
+ const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
721
+ const itemFilters = buildItemFilters(params.filters);
722
+ const accountQuery = { active: true };
723
+ if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
724
+ const allAccounts = await AccountModel.find(accountQuery).lean();
725
+ const accountIds = allAccounts.filter((a) => {
726
+ const at = country.getAccountType(a.accountTypeCode);
727
+ if (!at || at.isGroup || at.isTotal) return false;
728
+ if (params.accountCategory && at.category !== params.accountCategory) return false;
729
+ return true;
730
+ }).map((a) => a._id);
731
+ if (accountIds.length === 0) return {
732
+ metadata: {
733
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
734
+ dimension: params.dimension,
735
+ periodStart: startDate.toISOString().split("T")[0],
736
+ periodEnd: endDate.toISOString().split("T")[0]
737
+ },
738
+ rows: [],
739
+ grandTotal: 0
740
+ };
741
+ const accountMap = new Map(allAccounts.map((a) => [String(a._id), a]));
742
+ const dimensionPath = `journalItems.${params.dimension}`;
743
+ const baseMatch = {
744
+ state: "posted",
745
+ date: {
746
+ $gte: startDate,
747
+ $lte: endDate
748
+ }
749
+ };
750
+ if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
751
+ const pipeline = [
752
+ { $match: baseMatch },
753
+ { $unwind: "$journalItems" },
754
+ { $match: {
755
+ "journalItems.account": { $in: accountIds },
756
+ ...itemFilters
757
+ } },
758
+ { $group: {
759
+ _id: {
760
+ dimension: `$${dimensionPath}`,
761
+ account: "$journalItems.account"
762
+ },
763
+ d: { $sum: "$journalItems.debit" },
764
+ c: { $sum: "$journalItems.credit" }
765
+ } }
766
+ ];
767
+ const results = await JournalEntryModel.aggregate(pipeline);
768
+ const dimensionMap = /* @__PURE__ */ new Map();
769
+ for (const r of results) {
770
+ const dimKey = r._id.dimension == null ? "__null__" : String(r._id.dimension);
771
+ const accKey = String(r._id.account);
772
+ if (!dimensionMap.has(dimKey)) dimensionMap.set(dimKey, /* @__PURE__ */ new Map());
773
+ dimensionMap.get(dimKey)?.set(accKey, {
774
+ d: r.d,
775
+ c: r.c
776
+ });
777
+ }
778
+ const rows = [];
779
+ const sortedDimKeys = [...dimensionMap.keys()].sort((a, b) => {
780
+ if (a === "__null__") return 1;
781
+ if (b === "__null__") return -1;
782
+ return a.localeCompare(b);
783
+ });
784
+ for (const dimKey of sortedDimKeys) {
785
+ const accountBalances = dimensionMap.get(dimKey);
786
+ const dimensionValue = dimKey === "__null__" ? null : results.find((r) => String(r._id.dimension) === dimKey)?._id.dimension ?? null;
787
+ const accounts = [];
788
+ let total = 0;
789
+ for (const [accId, bal] of accountBalances) {
790
+ const acc = accountMap.get(accId);
791
+ if (!acc) continue;
792
+ const at = country.getAccountType(acc.accountTypeCode);
793
+ const balance = at && (at.category === "Income Statement-Income" || at.category === "Balance Sheet-Liability" || at.category === "Balance Sheet-Equity") ? bal.c - bal.d : bal.d - bal.c;
794
+ accounts.push({
795
+ id: acc._id,
796
+ name: acc.name ?? at?.name ?? "",
797
+ code: acc.accountNumber ?? at?.code ?? "",
798
+ balance
799
+ });
800
+ total += balance;
801
+ }
802
+ accounts.sort((a, b) => a.code.localeCompare(b.code, void 0, { numeric: true }));
803
+ rows.push({
804
+ dimensionValue,
805
+ accounts,
806
+ total
807
+ });
808
+ }
809
+ const grandTotal = rows.reduce((s, r) => s + r.total, 0);
810
+ return {
811
+ metadata: {
812
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
813
+ dimension: params.dimension,
814
+ periodStart: startDate.toISOString().split("T")[0],
815
+ periodEnd: endDate.toISOString().split("T")[0]
816
+ },
817
+ rows,
818
+ grandTotal
549
819
  };
550
820
  }
551
821
  //#endregion
@@ -576,6 +846,11 @@ async function generateGeneralLedger(opts, params) {
576
846
  endDate
577
847
  }
578
848
  };
849
+ filtered.sort((a, b) => {
850
+ const codeA = a.acc.accountNumber ?? a.at.code;
851
+ const codeB = b.acc.accountNumber ?? b.at.code;
852
+ return codeA.localeCompare(codeB, void 0, { numeric: true });
853
+ });
579
854
  const bsAccountIds = [];
580
855
  const isAccountIds = [];
581
856
  const allAccountIds = [];
@@ -674,7 +949,26 @@ async function generateGeneralLedger(opts, params) {
674
949
  closingBalance: runningBalance
675
950
  });
676
951
  }
952
+ const periodDisplay = params.dateOption === "year" ? `For the year ended ${endDate.toLocaleDateString("en-US", {
953
+ year: "numeric",
954
+ month: "long",
955
+ day: "numeric"
956
+ })}` : `${startDate.toLocaleDateString("en-US", {
957
+ month: "short",
958
+ day: "numeric"
959
+ })} – ${endDate.toLocaleDateString("en-US", {
960
+ year: "numeric",
961
+ month: "short",
962
+ day: "numeric"
963
+ })}`;
677
964
  return {
965
+ metadata: {
966
+ businessName: params.businessName,
967
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
968
+ periodStart: startDate.toISOString().split("T")[0],
969
+ periodEnd: endDate.toISOString().split("T")[0],
970
+ displayPeriod: periodDisplay
971
+ },
678
972
  accounts: glAccounts,
679
973
  period: {
680
974
  startDate,
@@ -683,8 +977,8 @@ async function generateGeneralLedger(opts, params) {
683
977
  };
684
978
  }
685
979
  //#endregion
686
- //#region src/reports/cash-flow.ts
687
- async function generateCashFlow(opts, params) {
980
+ //#region src/reports/income-statement.ts
981
+ async function generateIncomeStatement(opts, params) {
688
982
  const { AccountModel, JournalEntryModel, country, orgField } = opts;
689
983
  requireOrgScope(orgField, params.organizationId);
690
984
  const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
@@ -692,22 +986,11 @@ async function generateCashFlow(opts, params) {
692
986
  const q = { active: true };
693
987
  if (orgField && params.organizationId) q[orgField] = params.organizationId;
694
988
  const allAccounts = await AccountModel.find(q).lean();
695
- const accountCfMap = /* @__PURE__ */ new Map();
696
- const accountMap = new Map(allAccounts.map((a) => [String(a._id), a]));
697
- const cfAccountIds = [];
698
- for (const acc of allAccounts) {
699
- const at = country.getAccountType(acc.accountTypeCode);
700
- if (!at || at.isGroup || at.isTotal) continue;
701
- const cf = at.cashFlowCategory;
702
- if (!cf) continue;
703
- const normalized = cf.charAt(0).toUpperCase() + cf.slice(1);
704
- accountCfMap.set(String(acc._id), {
705
- category: at.category,
706
- cfCategory: normalized
707
- });
708
- cfAccountIds.push(acc._id);
709
- }
710
- const baseMatch = {
989
+ const isIds = allAccounts.filter((a) => {
990
+ const at = country.getAccountType(a.accountTypeCode);
991
+ return at && !at.isGroup && !at.isTotal && at.category.startsWith("Income Statement");
992
+ }).map((a) => a._id);
993
+ const baseMatch = {
711
994
  state: "posted",
712
995
  date: {
713
996
  $gte: startDate,
@@ -715,11 +998,11 @@ async function generateCashFlow(opts, params) {
715
998
  }
716
999
  };
717
1000
  if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
718
- const results = cfAccountIds.length > 0 ? await JournalEntryModel.aggregate([
1001
+ const results = await JournalEntryModel.aggregate([
719
1002
  { $match: baseMatch },
720
1003
  { $unwind: "$journalItems" },
721
1004
  { $match: {
722
- "journalItems.account": { $in: cfAccountIds },
1005
+ "journalItems.account": { $in: isIds },
723
1006
  ...itemFilters
724
1007
  } },
725
1008
  { $group: {
@@ -727,37 +1010,399 @@ async function generateCashFlow(opts, params) {
727
1010
  d: { $sum: "$journalItems.debit" },
728
1011
  c: { $sum: "$journalItems.credit" }
729
1012
  } }
730
- ]) : [];
731
- const flows = {
732
- Operating: {
733
- total: 0,
734
- accounts: []
735
- },
736
- Investing: {
1013
+ ]);
1014
+ const accountMap = new Map(allAccounts.map((a) => [String(a._id), a]));
1015
+ const revenueGroups = {};
1016
+ const expenseGroups = {};
1017
+ const resolveGroupName = (at) => {
1018
+ const visited = /* @__PURE__ */ new Set();
1019
+ let current = at.parentCode ? country.getAccountType(at.parentCode) : void 0;
1020
+ while (current && !visited.has(current.code)) {
1021
+ if (current.isGroup) return current.name;
1022
+ visited.add(current.code);
1023
+ current = current.parentCode ? country.getAccountType(current.parentCode) : void 0;
1024
+ }
1025
+ return at.name;
1026
+ };
1027
+ for (const r of results) {
1028
+ const acc = accountMap.get(String(r._id));
1029
+ if (!acc) continue;
1030
+ const at = country.getAccountType(acc.accountTypeCode);
1031
+ if (!at) continue;
1032
+ const mainType = extractMainType(at.category);
1033
+ const netAmount = mainType === "Income" ? r.c - r.d : r.d - r.c;
1034
+ if (netAmount === 0) continue;
1035
+ const groupName = resolveGroupName(at);
1036
+ const groups = mainType === "Income" ? revenueGroups : expenseGroups;
1037
+ if (!(groupName in groups)) groups[groupName] = {
1038
+ name: groupName,
737
1039
  total: 0,
738
1040
  accounts: []
1041
+ };
1042
+ groups[groupName].accounts.push({
1043
+ id: acc._id,
1044
+ name: acc.name ?? at.name,
1045
+ code: acc.accountNumber ?? at.code,
1046
+ balance: netAmount
1047
+ });
1048
+ groups[groupName].total += netAmount;
1049
+ }
1050
+ const sortGroups = (groups) => {
1051
+ const sorted = Object.values(groups);
1052
+ for (const g of sorted) g.accounts.sort((a, b) => (a.code ?? "").localeCompare(b.code ?? "", void 0, { numeric: true }));
1053
+ sorted.sort((a, b) => {
1054
+ const codeA = a.accounts[0]?.code ?? "";
1055
+ const codeB = b.accounts[0]?.code ?? "";
1056
+ return codeA.localeCompare(codeB, void 0, { numeric: true });
1057
+ });
1058
+ return sorted;
1059
+ };
1060
+ const labels = country.reportLabels ?? {};
1061
+ const revenue = {
1062
+ name: labels.revenue ?? "Revenue",
1063
+ total: Object.values(revenueGroups).reduce((s, g) => s + g.total, 0),
1064
+ groups: sortGroups(revenueGroups)
1065
+ };
1066
+ const expenses = {
1067
+ name: labels.expenses ?? "Expenses",
1068
+ total: Object.values(expenseGroups).reduce((s, g) => s + g.total, 0),
1069
+ groups: sortGroups(expenseGroups)
1070
+ };
1071
+ const cogsCode = country.cogsGroupCode;
1072
+ const isCogs = (name) => cogsCode ? name === cogsCode : name === "Cost of Sales" || name === "Cost of Goods Sold";
1073
+ const costOfSales = expenses.groups.find((g) => isCogs(g.name))?.total ?? 0;
1074
+ const grossProfit = revenue.total - costOfSales;
1075
+ const operatingIncome = grossProfit - expenses.groups.filter((g) => !isCogs(g.name)).reduce((s, g) => s + g.total, 0);
1076
+ const netIncome = revenue.total - expenses.total;
1077
+ const periodDisplay = params.dateOption === "year" ? `For the year ended ${endDate.toLocaleDateString("en-US", {
1078
+ year: "numeric",
1079
+ month: "long",
1080
+ day: "numeric"
1081
+ })}` : `${startDate.toLocaleDateString("en-US", {
1082
+ month: "short",
1083
+ day: "numeric"
1084
+ })} – ${endDate.toLocaleDateString("en-US", {
1085
+ year: "numeric",
1086
+ month: "short",
1087
+ day: "numeric"
1088
+ })}`;
1089
+ return {
1090
+ metadata: {
1091
+ businessName: params.businessName,
1092
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1093
+ periodStart: startDate.toISOString().split("T")[0],
1094
+ periodEnd: endDate.toISOString().split("T")[0],
1095
+ displayPeriod: periodDisplay
739
1096
  },
740
- Financing: {
741
- total: 0,
742
- accounts: []
743
- }
1097
+ revenue,
1098
+ costOfSales,
1099
+ grossProfit,
1100
+ expenses,
1101
+ operatingIncome,
1102
+ netIncome
744
1103
  };
1104
+ }
1105
+ //#endregion
1106
+ //#region src/utils/revaluation.ts
1107
+ /**
1108
+ * Compute revaluation results for a set of accounts at new exchange rates.
1109
+ *
1110
+ * For each account, finds the matching rate by currency, computes the
1111
+ * revalued base amount, and determines the gain/loss.
1112
+ * Accounts with zero gain/loss are excluded from the results.
1113
+ *
1114
+ * @param accounts - Foreign-currency account balances at historical rates
1115
+ * @param rates - New exchange rates to revalue against
1116
+ * @param baseCurrency - The functional/base currency code (accounts in this currency are skipped)
1117
+ */
1118
+ function computeRevaluation(accounts, rates, baseCurrency) {
1119
+ const rateMap = new Map(rates.map((r) => [r.currency, r.rate]));
1120
+ const results = [];
1121
+ for (const acct of accounts) {
1122
+ if (acct.currency === baseCurrency) continue;
1123
+ const rate = rateMap.get(acct.currency);
1124
+ if (rate === void 0) continue;
1125
+ const revaluedBase = Math.round(acct.foreignBalance * rate);
1126
+ const gainLoss = revaluedBase - acct.baseBalance;
1127
+ if (gainLoss === 0) continue;
1128
+ results.push({
1129
+ accountId: acct.accountId,
1130
+ accountName: acct.accountName,
1131
+ accountCode: acct.accountCode,
1132
+ currency: acct.currency,
1133
+ foreignBalance: acct.foreignBalance,
1134
+ historicalBase: acct.baseBalance,
1135
+ revaluedBase,
1136
+ gainLoss
1137
+ });
1138
+ }
1139
+ return results;
1140
+ }
1141
+ /**
1142
+ * Build a balanced revaluation journal entry from revaluation results.
1143
+ *
1144
+ * For each result with a non-zero gain/loss:
1145
+ * - Gain (positive gainLoss): Debit the account, Credit the unrealized gain/loss account
1146
+ * - Loss (negative gainLoss): Credit the account, Debit the unrealized gain/loss account
1147
+ *
1148
+ * @param results - Revaluation results from computeRevaluation
1149
+ * @param unrealizedGainLossAccountId - The account to book the offsetting entry against
1150
+ * @param date - Date for the revaluation entry
1151
+ */
1152
+ function buildRevaluationEntry(results, unrealizedGainLossAccountId, date) {
1153
+ const journalItems = [];
1154
+ let totalDebit = 0;
1155
+ let totalCredit = 0;
745
1156
  for (const r of results) {
746
- const accIdStr = String(r._id);
747
- const meta = accountCfMap.get(accIdStr);
748
- if (!meta) continue;
749
- const amount = computeEndingBalance(meta.category, r.d, r.c);
750
- const acc = accountMap.get(accIdStr);
751
- const at = country.getAccountType(acc?.accountTypeCode);
752
- flows[meta.cfCategory].accounts.push({
753
- name: acc?.name ?? at?.name ?? "",
754
- code: acc?.accountNumber ?? at?.code ?? "",
755
- amount
1157
+ if (r.gainLoss === 0) continue;
1158
+ const absAmount = Math.abs(r.gainLoss);
1159
+ if (r.gainLoss > 0) {
1160
+ journalItems.push({
1161
+ account: r.accountId,
1162
+ debit: absAmount,
1163
+ credit: 0,
1164
+ originalDebit: 0,
1165
+ originalCredit: 0,
1166
+ label: `FX revaluation ${r.currency} — gain`
1167
+ });
1168
+ journalItems.push({
1169
+ account: unrealizedGainLossAccountId,
1170
+ debit: 0,
1171
+ credit: absAmount,
1172
+ originalDebit: 0,
1173
+ originalCredit: 0,
1174
+ label: `FX revaluation ${r.currency} — gain`
1175
+ });
1176
+ } else {
1177
+ journalItems.push({
1178
+ account: r.accountId,
1179
+ debit: 0,
1180
+ credit: absAmount,
1181
+ originalDebit: 0,
1182
+ originalCredit: 0,
1183
+ label: `FX revaluation ${r.currency} — loss`
1184
+ });
1185
+ journalItems.push({
1186
+ account: unrealizedGainLossAccountId,
1187
+ debit: absAmount,
1188
+ credit: 0,
1189
+ originalDebit: 0,
1190
+ originalCredit: 0,
1191
+ label: `FX revaluation ${r.currency} — loss`
1192
+ });
1193
+ }
1194
+ totalDebit += absAmount;
1195
+ totalCredit += absAmount;
1196
+ }
1197
+ const dateStr = date.toISOString().split("T")[0];
1198
+ return {
1199
+ journalItems,
1200
+ totalDebit,
1201
+ totalCredit,
1202
+ label: `Foreign exchange revaluation — ${dateStr}`
1203
+ };
1204
+ }
1205
+ //#endregion
1206
+ //#region src/reports/revaluation.ts
1207
+ /**
1208
+ * Generate a foreign exchange revaluation report.
1209
+ *
1210
+ * 1. Finds all accounts with a `currency` field (foreign-currency accounts)
1211
+ * 2. Filters to balance sheet accounts only (not P&L)
1212
+ * 3. Aggregates foreign-currency and base-currency balances from posted entries
1213
+ * 4. Computes gain/loss at the new rates
1214
+ * 5. Optionally creates and saves a balanced journal entry
1215
+ */
1216
+ async function generateRevaluation(opts, params) {
1217
+ const { AccountModel, JournalEntryModel, country, orgField, baseCurrency } = opts;
1218
+ requireOrgScope(orgField, params.organizationId);
1219
+ const accountQuery = {
1220
+ active: true,
1221
+ currency: {
1222
+ $exists: true,
1223
+ $ne: null
1224
+ }
1225
+ };
1226
+ if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
1227
+ const bsAccounts = (await AccountModel.find(accountQuery).lean()).filter((a) => {
1228
+ const at = country.getAccountType(a.accountTypeCode);
1229
+ return at && !at.isGroup && at.category.startsWith("Balance Sheet");
1230
+ });
1231
+ if (bsAccounts.length === 0) return {
1232
+ metadata: {
1233
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1234
+ asOfDate: params.asOfDate.toISOString().split("T")[0],
1235
+ baseCurrency
1236
+ },
1237
+ results: [],
1238
+ totalGainLoss: 0
1239
+ };
1240
+ const bsAccountIds = bsAccounts.map((a) => a._id);
1241
+ const baseMatch = {
1242
+ state: "posted",
1243
+ date: { $lte: params.asOfDate }
1244
+ };
1245
+ if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
1246
+ const balanceResults = await JournalEntryModel.aggregate([
1247
+ { $match: baseMatch },
1248
+ { $unwind: "$journalItems" },
1249
+ { $match: { "journalItems.account": { $in: bsAccountIds } } },
1250
+ { $group: {
1251
+ _id: "$journalItems.account",
1252
+ debit: { $sum: "$journalItems.debit" },
1253
+ credit: { $sum: "$journalItems.credit" },
1254
+ originalDebit: { $sum: { $ifNull: ["$journalItems.originalDebit", 0] } },
1255
+ originalCredit: { $sum: { $ifNull: ["$journalItems.originalCredit", 0] } }
1256
+ } }
1257
+ ]);
1258
+ const accountMap = new Map(bsAccounts.map((a) => [String(a._id), a]));
1259
+ const accountBalances = [];
1260
+ for (const r of balanceResults) {
1261
+ const acct = accountMap.get(String(r._id));
1262
+ if (!acct) continue;
1263
+ const at = country.getAccountType(acct.accountTypeCode);
1264
+ if (!at) continue;
1265
+ accountBalances.push({
1266
+ accountId: r._id,
1267
+ accountName: acct.name ?? at.name,
1268
+ accountCode: acct.accountNumber ?? at.code,
1269
+ currency: acct.currency,
1270
+ foreignBalance: r.originalDebit - r.originalCredit,
1271
+ baseBalance: r.debit - r.credit,
1272
+ category: at.category
756
1273
  });
757
- flows[meta.cfCategory].total += amount;
758
1274
  }
759
- const netCashFlow = flows.Operating.total + flows.Investing.total + flows.Financing.total;
760
- const periodDisplay = `${startDate.toLocaleDateString("en-US", {
1275
+ const results = computeRevaluation(accountBalances, params.rates, baseCurrency);
1276
+ const totalGainLoss = results.reduce((sum, r) => sum + r.gainLoss, 0);
1277
+ let entryId;
1278
+ if (params.generateEntry && results.length > 0) {
1279
+ const entryData = buildRevaluationEntry(results, params.unrealizedGainLossAccountId, params.asOfDate);
1280
+ const doc = {
1281
+ journalType: "GENERAL",
1282
+ state: "posted",
1283
+ date: params.asOfDate,
1284
+ label: entryData.label,
1285
+ journalItems: entryData.journalItems,
1286
+ totalDebit: entryData.totalDebit,
1287
+ totalCredit: entryData.totalCredit
1288
+ };
1289
+ if (orgField && params.organizationId) doc[orgField] = params.organizationId;
1290
+ entryId = (await JournalEntryModel.create(doc))._id;
1291
+ }
1292
+ return {
1293
+ metadata: {
1294
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1295
+ asOfDate: params.asOfDate.toISOString().split("T")[0],
1296
+ baseCurrency
1297
+ },
1298
+ results,
1299
+ totalGainLoss,
1300
+ ...entryId !== void 0 ? { entryId } : {}
1301
+ };
1302
+ }
1303
+ //#endregion
1304
+ //#region src/reports/trial-balance.ts
1305
+ async function generateTrialBalance(opts, params) {
1306
+ const { AccountModel, JournalEntryModel, country, orgField, fiscalYearStartMonth = 1 } = opts;
1307
+ requireOrgScope(orgField, params.organizationId);
1308
+ const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
1309
+ const fiscalYearStart = getFiscalYearStart(startDate, fiscalYearStartMonth);
1310
+ const itemFilters = buildItemFilters(params.filters);
1311
+ const accountQuery = { active: true };
1312
+ if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
1313
+ const allAccounts = await AccountModel.find(accountQuery).lean();
1314
+ const bsIds = [];
1315
+ const isIds = [];
1316
+ for (const acc of allAccounts) {
1317
+ const at = country.getAccountType(acc.accountTypeCode);
1318
+ if (!at || at.isGroup) continue;
1319
+ if (at.category.startsWith("Balance Sheet")) bsIds.push(acc._id);
1320
+ else if (at.category.startsWith("Income Statement")) isIds.push(acc._id);
1321
+ }
1322
+ const baseMatch = { state: "posted" };
1323
+ if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
1324
+ const accountFilter = params.accountId ? { "journalItems.account": params.accountId } : {};
1325
+ const buildPipeline = (ids, dateFrom, dateTo) => [
1326
+ { $match: {
1327
+ ...baseMatch,
1328
+ date: {
1329
+ $gte: dateFrom,
1330
+ $lt: dateTo
1331
+ }
1332
+ } },
1333
+ { $unwind: "$journalItems" },
1334
+ { $match: {
1335
+ "journalItems.account": { $in: ids },
1336
+ ...accountFilter,
1337
+ ...itemFilters
1338
+ } },
1339
+ { $group: {
1340
+ _id: "$journalItems.account",
1341
+ d: { $sum: "$journalItems.debit" },
1342
+ c: { $sum: "$journalItems.credit" }
1343
+ } }
1344
+ ];
1345
+ const [bsInitial, isInitial, current] = await Promise.all([
1346
+ bsIds.length ? JournalEntryModel.aggregate(buildPipeline(bsIds, /* @__PURE__ */ new Date(0), startDate)) : [],
1347
+ isIds.length ? JournalEntryModel.aggregate(buildPipeline(isIds, fiscalYearStart, startDate)) : [],
1348
+ JournalEntryModel.aggregate(buildPipeline([...bsIds, ...isIds], startDate, new Date(endDate.getTime() + 1)))
1349
+ ]);
1350
+ const map = /* @__PURE__ */ new Map();
1351
+ for (const r of [...bsInitial, ...isInitial]) {
1352
+ const key = String(r._id);
1353
+ map.set(key, {
1354
+ iD: r.d,
1355
+ iC: r.c,
1356
+ cD: 0,
1357
+ cC: 0
1358
+ });
1359
+ }
1360
+ for (const r of current) {
1361
+ const key = String(r._id);
1362
+ const existing = map.get(key) ?? {
1363
+ iD: 0,
1364
+ iC: 0,
1365
+ cD: 0,
1366
+ cC: 0
1367
+ };
1368
+ existing.cD = r.d;
1369
+ existing.cC = r.c;
1370
+ map.set(key, existing);
1371
+ }
1372
+ const accountLookup = new Map(allAccounts.map((a) => [String(a._id), a]));
1373
+ const rows = [];
1374
+ for (const [id, bal] of map) {
1375
+ const acc = accountLookup.get(id);
1376
+ const net = bal.iD + bal.cD - (bal.iC + bal.cC);
1377
+ rows.push({
1378
+ account: acc ?? id,
1379
+ initial: {
1380
+ debit: bal.iD,
1381
+ credit: bal.iC
1382
+ },
1383
+ current: {
1384
+ debit: bal.cD,
1385
+ credit: bal.cC
1386
+ },
1387
+ ending: net >= 0 ? {
1388
+ debit: net,
1389
+ credit: 0
1390
+ } : {
1391
+ debit: 0,
1392
+ credit: Math.abs(net)
1393
+ }
1394
+ });
1395
+ }
1396
+ rows.sort((a, b) => {
1397
+ const codeA = a.account?.accountNumber ?? a.account?.accountTypeCode ?? "";
1398
+ const codeB = b.account?.accountNumber ?? b.account?.accountTypeCode ?? "";
1399
+ return codeA.localeCompare(codeB, void 0, { numeric: true });
1400
+ });
1401
+ const periodDisplay = params.dateOption === "year" ? `For the year ended ${endDate.toLocaleDateString("en-US", {
1402
+ year: "numeric",
1403
+ month: "long",
1404
+ day: "numeric"
1405
+ })}` : `${startDate.toLocaleDateString("en-US", {
761
1406
  month: "short",
762
1407
  day: "numeric"
763
1408
  })} – ${endDate.toLocaleDateString("en-US", {
@@ -773,16 +1418,87 @@ async function generateCashFlow(opts, params) {
773
1418
  periodEnd: endDate.toISOString().split("T")[0],
774
1419
  displayPeriod: periodDisplay
775
1420
  },
776
- operating: flows.Operating,
777
- investing: flows.Investing,
778
- financing: flows.Financing,
779
- netCashFlow
1421
+ rows,
1422
+ period: {
1423
+ startDate,
1424
+ endDate
1425
+ }
780
1426
  };
781
1427
  }
782
1428
  //#endregion
1429
+ //#region src/utils/logger.ts
1430
+ /** Default console-based implementation */
1431
+ const defaultLogger = {
1432
+ warn: (msg, meta) => console.warn(`[accounting] ${msg}`, meta ?? ""),
1433
+ error: (msg, meta) => console.error(`[accounting] ${msg}`, meta ?? ""),
1434
+ info: (msg, meta) => console.info(`[accounting] ${msg}`, meta ?? "")
1435
+ };
1436
+ //#endregion
1437
+ //#region src/utils/session.ts
1438
+ /**
1439
+ * Acquire a session: uses external if provided, otherwise creates an internal one.
1440
+ * Returns { session, ownSession } so callers can commit/abort/end appropriately.
1441
+ *
1442
+ * When transactions are unavailable (no replica set / standalone), returns
1443
+ * session=null and the function runs without transactional safety.
1444
+ */
1445
+ async function acquireSession(db, externalSession, logger = defaultLogger) {
1446
+ if (externalSession) return {
1447
+ session: externalSession,
1448
+ ownSession: false
1449
+ };
1450
+ try {
1451
+ const session = await db.startSession();
1452
+ try {
1453
+ const conn = db;
1454
+ if ((conn.getClient?.() ?? conn.client)?.topology?.description?.type === "Single") {
1455
+ session.endSession();
1456
+ logger.warn("Transactions unavailable (standalone MongoDB). Operation is not atomic.");
1457
+ return {
1458
+ session: null,
1459
+ ownSession: false
1460
+ };
1461
+ }
1462
+ } catch {}
1463
+ try {
1464
+ session.startTransaction();
1465
+ return {
1466
+ session,
1467
+ ownSession: true
1468
+ };
1469
+ } catch (err) {
1470
+ session.endSession();
1471
+ logger.warn("Transactions unavailable (no replica set). Operation is not atomic.", { error: err.message });
1472
+ return {
1473
+ session: null,
1474
+ ownSession: false
1475
+ };
1476
+ }
1477
+ } catch {
1478
+ return {
1479
+ session: null,
1480
+ ownSession: false
1481
+ };
1482
+ }
1483
+ }
1484
+ /**
1485
+ * Finalize an owned session: commit or abort, then always end.
1486
+ */
1487
+ async function finalizeSession(session, ownSession, success) {
1488
+ if (!ownSession || !session) return;
1489
+ try {
1490
+ if (success && session.inTransaction()) await session.commitTransaction();
1491
+ else if (!success && session.inTransaction()) try {
1492
+ await session.abortTransaction();
1493
+ } catch {}
1494
+ } finally {
1495
+ session.endSession();
1496
+ }
1497
+ }
1498
+ //#endregion
783
1499
  //#region src/reports/fiscal-close.ts
784
1500
  async function closeFiscalPeriod(opts, params) {
785
- const { AccountModel, JournalEntryModel, FiscalPeriodModel, country, orgField, retainedEarningsCode = country.retainedEarningsCode ?? "3660", logger = defaultLogger } = opts;
1501
+ const { AccountModel, JournalEntryModel, FiscalPeriodModel, country, orgField, retainedEarningsAccountCode = country.retainedEarningsAccountCode ?? "3600", logger = defaultLogger } = opts;
786
1502
  const { periodId, organizationId, closedBy } = params;
787
1503
  requireOrgScope(orgField, organizationId);
788
1504
  const { session, ownSession } = await acquireSession(AccountModel.db, params.session, logger);
@@ -804,7 +1520,7 @@ async function closeFiscalPeriod(opts, params) {
804
1520
  for (const acc of allAccounts) {
805
1521
  const at = country.getAccountType(acc.accountTypeCode);
806
1522
  if (!at) continue;
807
- if (acc.accountTypeCode === retainedEarningsCode) retainedEarningsId = acc._id;
1523
+ if (acc.accountTypeCode === retainedEarningsAccountCode) retainedEarningsId = acc._id;
808
1524
  if (at.isGroup || at.isTotal) continue;
809
1525
  if (at.category.startsWith("Income Statement")) isAccounts.push({
810
1526
  id: acc._id,
@@ -812,7 +1528,7 @@ async function closeFiscalPeriod(opts, params) {
812
1528
  isIncome: at.category === "Income Statement-Income"
813
1529
  });
814
1530
  }
815
- if (!retainedEarningsId) throw Errors.fiscal(`Retained earnings account (code: ${retainedEarningsCode}) not found. Create this account before closing the fiscal period.`);
1531
+ if (!retainedEarningsId) throw Errors.fiscal(`Retained earnings account (code: ${retainedEarningsAccountCode}) not found. Create this account before closing the fiscal period.`);
816
1532
  const baseMatch = {
817
1533
  state: "posted",
818
1534
  date: {
@@ -934,6 +1650,4 @@ async function reopenFiscalPeriod(opts, params) {
934
1650
  }
935
1651
  }
936
1652
  //#endregion
937
- export { generateIncomeStatement as a, calculateTotal as c, generateTrialBalance as d, buildItemFilters as f, generateGeneralLedger as i, computeEndingBalance as l, getFiscalYearStart as m, reopenFiscalPeriod as n, generateBalanceSheet as o, getDateRange as p, generateCashFlow as r, buildAccountTypeMap as s, closeFiscalPeriod as t, isVirtualTaxAccount as u };
938
-
939
- //# sourceMappingURL=fiscal-close-L631E3De.mjs.map
1653
+ export { DEFAULT_BUCKETS as C, isVirtualTaxAccount as S, getDateRange as _, defaultLogger as a, calculateTotal as b, buildRevaluationEntry as c, generateGeneralLedger as d, generateDimensionBreakdown as f, buildItemFilters as g, generateBalanceSheet as h, finalizeSession as i, computeRevaluation as l, generateBudgetVsActual as m, reopenFiscalPeriod as n, generateTrialBalance as o, generateCashFlow as p, acquireSession as r, generateRevaluation as s, closeFiscalPeriod as t, generateIncomeStatement as u, getFiscalYearStart as v, generateAgedBalance as w, computeEndingBalance as x, buildAccountTypeMap as y };