@classytic/ledger 0.3.0 → 0.4.1

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 (35) hide show
  1. package/README.md +82 -20
  2. package/dist/constants/index.d.mts +2 -2
  3. package/dist/constants/index.mjs +3 -3
  4. package/dist/{date-lock.plugin-eYAJ9h_u.mjs → date-lock.plugin-DL6pe24p.mjs} +2 -2
  5. package/dist/{engine-Cn-9yerQ.d.mts → engine-scgOvxHJ.d.mts} +30 -2
  6. package/dist/exports/index.d.mts +1 -1
  7. package/dist/exports/index.mjs +1 -1
  8. package/dist/{exports-I5Xkq-9_.mjs → exports-DoGQQtMQ.mjs} +96 -75
  9. package/dist/{fiscal-close-B6LhQ10f.mjs → fiscal-close-B2_7WMTe.mjs} +748 -751
  10. package/dist/{index-BPukb3L8.d.mts → index-J-XIbXH-.d.mts} +7 -7
  11. package/dist/index.d.mts +239 -87
  12. package/dist/index.mjs +149 -12
  13. package/dist/{fiscal-period.schema-BMnlI9H5.d.mts → journal-entry.schema-JqrfbvB4.d.mts} +12 -12
  14. package/dist/{journals-oH-FK3g8.mjs → journals-BfwnCFam.mjs} +27 -4
  15. package/dist/{currencies-4WAbFRlw.d.mts → journals-DTipb_rz.d.mts} +16 -7
  16. package/dist/money.mjs +2 -2
  17. package/dist/plugins/index.d.mts +1 -1
  18. package/dist/plugins/index.mjs +1 -1
  19. package/dist/{reconciliation.repository-CW4-8q90.d.mts → reconciliation.repository-D-D_ITL-.d.mts} +14 -14
  20. package/dist/{account.repository-BpkSd6q3.mjs → reconciliation.repository-fPwFKvrk.mjs} +255 -255
  21. package/dist/{reconciliation.schema-BuetvZTd.mjs → reconciliation.schema-BA1lPv4t.mjs} +174 -173
  22. package/dist/reports/index.d.mts +1 -1
  23. package/dist/reports/index.mjs +1 -1
  24. package/dist/repositories/index.d.mts +1 -1
  25. package/dist/repositories/index.mjs +1 -1
  26. package/dist/schemas/index.d.mts +6 -6
  27. package/dist/schemas/index.mjs +1 -1
  28. package/dist/{tenant-guard-Fm6AID_6.mjs → tenant-guard-r17Se3Bb.mjs} +1 -1
  29. package/dist/{revaluation-D9x0NE8w.d.mts → trial-balance-DcQ0xj_4.d.mts} +124 -124
  30. package/docs/schemas.md +2 -2
  31. package/package.json +14 -6
  32. /package/dist/{categories-CclX7Q94.mjs → categories-DWogBUgQ.mjs} +0 -0
  33. /package/dist/{errors-B7yC-Jfw.mjs → errors-B_dyYZc_.mjs} +0 -0
  34. /package/dist/{idempotency.plugin-B_CNsInz.d.mts → idempotency.plugin-zU-GKJ0-.d.mts} +0 -0
  35. /package/dist/{logger-CbHWZl7v.d.mts → logger-UbTdBb1x.d.mts} +0 -0
@@ -1,7 +1,183 @@
1
- import { n as Errors } from "./errors-B7yC-Jfw.mjs";
2
- import { t as requireOrgScope } from "./tenant-guard-Fm6AID_6.mjs";
3
- import { i as extractMainType } from "./categories-CclX7Q94.mjs";
1
+ import { n as Errors } from "./errors-B_dyYZc_.mjs";
2
+ import { t as requireOrgScope } from "./tenant-guard-r17Se3Bb.mjs";
3
+ import { i as extractMainType } from "./categories-DWogBUgQ.mjs";
4
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
5
181
  //#region src/utils/date-range.ts
6
182
  /**
7
183
  * Compute start/end dates from a date option + value.
@@ -26,7 +202,7 @@ function getDateRange(option, value) {
26
202
  month = parseInt(match[2], 10) - 1;
27
203
  } else {
28
204
  const date = new Date(value);
29
- if (isNaN(date.getTime())) throw new Error(`Invalid month value: ${String(value)}`);
205
+ if (Number.isNaN(date.getTime())) throw new Error(`Invalid month value: ${String(value)}`);
30
206
  year = date.getFullYear();
31
207
  month = date.getMonth();
32
208
  }
@@ -49,7 +225,7 @@ function getDateRange(option, value) {
49
225
  }
50
226
  case "year": {
51
227
  const year = typeof value === "number" ? value : parseInt(String(value), 10);
52
- if (isNaN(year) || year < 1900 || year > 9999) throw new Error(`Invalid year: ${String(value)}. Must be a number between 1900–9999.`);
228
+ if (Number.isNaN(year) || year < 1900 || year > 9999) throw new Error(`Invalid year: ${String(value)}. Must be a number between 1900–9999.`);
53
229
  return {
54
230
  startDate: new Date(year, 0, 1),
55
231
  endDate: new Date(year, 11, 31, 23, 59, 59, 999)
@@ -61,7 +237,7 @@ function getDateRange(option, value) {
61
237
  if (!rawStart || !rawEnd) throw new Error("Custom date range requires both startDate and endDate");
62
238
  const start = new Date(rawStart);
63
239
  const end = new Date(rawEnd);
64
- if (isNaN(start.getTime()) || isNaN(end.getTime())) throw new Error("Custom date range contains invalid dates");
240
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) throw new Error("Custom date range contains invalid dates");
65
241
  if (start > end) throw new Error("startDate must be before endDate");
66
242
  if (end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0 && end.getMilliseconds() === 0) end.setHours(23, 59, 59, 999);
67
243
  return {
@@ -122,195 +298,26 @@ function buildItemFilters(filters) {
122
298
  return result;
123
299
  }
124
300
  //#endregion
125
- //#region src/reports/trial-balance.ts
126
- async function generateTrialBalance(opts, params) {
127
- const { AccountModel, JournalEntryModel, country, orgField, fiscalYearStartMonth = 1 } = opts;
301
+ //#region src/reports/balance-sheet.ts
302
+ async function generateBalanceSheet(opts, params) {
303
+ const { AccountModel, JournalEntryModel, country, orgField, fiscalYearStartMonth = 1, retainedEarningsAccountCode = country.retainedEarningsAccountCode, retainedEarningsDisplayCode = country.retainedEarningsDisplayCode ?? retainedEarningsAccountCode, currentYearEarningsCode = country.currentYearEarningsCode ?? "3680" } = opts;
128
304
  requireOrgScope(orgField, params.organizationId);
129
- const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
130
- const fiscalYearStart = getFiscalYearStart(startDate, fiscalYearStartMonth);
305
+ const { endDate } = getDateRange(params.dateOption, params.dateValue);
306
+ const fiscalYearStart = getFiscalYearStart(endDate, fiscalYearStartMonth);
131
307
  const itemFilters = buildItemFilters(params.filters);
132
- const accountQuery = { active: true };
133
- if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
134
- const allAccounts = await AccountModel.find(accountQuery).lean();
135
- const bsIds = [];
136
- const isIds = [];
137
- for (const acc of allAccounts) {
138
- const at = country.getAccountType(acc.accountTypeCode);
139
- if (!at || at.isGroup) continue;
140
- if (at.category.startsWith("Balance Sheet")) bsIds.push(acc._id);
141
- else if (at.category.startsWith("Income Statement")) isIds.push(acc._id);
142
- }
143
- const baseMatch = { state: "posted" };
144
- if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
145
- const accountFilter = params.accountId ? { "journalItems.account": params.accountId } : {};
146
- const buildPipeline = (ids, dateFrom, dateTo) => [
147
- { $match: {
148
- ...baseMatch,
149
- date: {
150
- $gte: dateFrom,
151
- $lt: dateTo
152
- }
153
- } },
154
- { $unwind: "$journalItems" },
155
- { $match: {
156
- "journalItems.account": { $in: ids },
157
- ...accountFilter,
158
- ...itemFilters
159
- } },
160
- { $group: {
161
- _id: "$journalItems.account",
162
- d: { $sum: "$journalItems.debit" },
163
- c: { $sum: "$journalItems.credit" }
164
- } }
165
- ];
166
- const [bsInitial, isInitial, current] = await Promise.all([
167
- bsIds.length ? JournalEntryModel.aggregate(buildPipeline(bsIds, /* @__PURE__ */ new Date(0), startDate)) : [],
168
- isIds.length ? JournalEntryModel.aggregate(buildPipeline(isIds, fiscalYearStart, startDate)) : [],
169
- JournalEntryModel.aggregate(buildPipeline([...bsIds, ...isIds], startDate, new Date(endDate.getTime() + 1)))
170
- ]);
171
- const map = /* @__PURE__ */ new Map();
172
- for (const r of [...bsInitial, ...isInitial]) {
173
- const key = String(r._id);
174
- map.set(key, {
175
- iD: r.d,
176
- iC: r.c,
177
- cD: 0,
178
- cC: 0
179
- });
180
- }
181
- for (const r of current) {
182
- const key = String(r._id);
183
- const existing = map.get(key) ?? {
184
- iD: 0,
185
- iC: 0,
186
- cD: 0,
187
- cC: 0
188
- };
189
- existing.cD = r.d;
190
- existing.cC = r.c;
191
- map.set(key, existing);
192
- }
193
- const accountLookup = new Map(allAccounts.map((a) => [String(a._id), a]));
194
- const rows = [];
195
- for (const [id, bal] of map) {
196
- const acc = accountLookup.get(id);
197
- const net = bal.iD + bal.cD - (bal.iC + bal.cC);
198
- rows.push({
199
- account: acc ?? id,
200
- initial: {
201
- debit: bal.iD,
202
- credit: bal.iC
203
- },
204
- current: {
205
- debit: bal.cD,
206
- credit: bal.cC
207
- },
208
- ending: net >= 0 ? {
209
- debit: net,
210
- credit: 0
211
- } : {
212
- debit: 0,
213
- credit: Math.abs(net)
214
- }
215
- });
216
- }
217
- rows.sort((a, b) => {
218
- const codeA = a.account?.accountNumber ?? a.account?.accountTypeCode ?? "";
219
- const codeB = b.account?.accountNumber ?? b.account?.accountTypeCode ?? "";
220
- return codeA.localeCompare(codeB, void 0, { numeric: true });
221
- });
222
- const periodDisplay = params.dateOption === "year" ? `For the year ended ${endDate.toLocaleDateString("en-US", {
223
- year: "numeric",
224
- month: "long",
225
- day: "numeric"
226
- })}` : `${startDate.toLocaleDateString("en-US", {
227
- month: "short",
228
- day: "numeric"
229
- })} – ${endDate.toLocaleDateString("en-US", {
230
- year: "numeric",
231
- month: "short",
232
- day: "numeric"
233
- })}`;
234
- return {
235
- metadata: {
236
- businessName: params.businessName,
237
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
238
- periodStart: startDate.toISOString().split("T")[0],
239
- periodEnd: endDate.toISOString().split("T")[0],
240
- displayPeriod: periodDisplay
241
- },
242
- rows,
243
- period: {
244
- startDate,
245
- endDate
246
- }
247
- };
248
- }
249
- //#endregion
250
- //#region src/utils/account-helpers.ts
251
- /**
252
- * Check if an account type is a virtual tax sub-account.
253
- * Returns true if the account's parent has `isVirtualTotal: true`.
254
- * Works for any country pack — no code format assumptions.
255
- */
256
- function isVirtualTaxAccount(accountType, accountMap) {
257
- if (!accountType.parentCode) return false;
258
- return accountMap.get(accountType.parentCode)?.isVirtualTotal === true;
259
- }
260
- /**
261
- * Calculate a total from sub-accounts using the totalAccountTypes formula.
262
- * @param formula - Array of { account, operation } instructions
263
- * @param balanceMap - Map of account code → balance
264
- */
265
- function calculateTotal(formula, balanceMap) {
266
- let total = 0;
267
- for (const item of formula) {
268
- const balance = balanceMap.get(item.account) ?? 0;
269
- total += item.operation === "+" ? balance : -balance;
270
- }
271
- return total;
272
- }
273
- /**
274
- * Compute the ending balance for an account given its debits and credits.
275
- * Uses the account's main type to determine normal balance direction.
276
- *
277
- * Assets & Expenses: debit - credit
278
- * Liabilities, Equity & Income: credit - debit
279
- */
280
- function computeEndingBalance(category, totalDebit, totalCredit) {
281
- const mainType = extractMainType(category);
282
- if (mainType === "Asset" || mainType === "Expense") return totalDebit - totalCredit;
283
- return totalCredit - totalDebit;
284
- }
285
- /**
286
- * Build a lookup map from an array of account types.
287
- */
288
- function buildAccountTypeMap(accountTypes) {
289
- const map = /* @__PURE__ */ new Map();
290
- for (const at of accountTypes) map.set(at.code, at);
291
- return map;
292
- }
293
- //#endregion
294
- //#region src/reports/balance-sheet.ts
295
- async function generateBalanceSheet(opts, params) {
296
- const { AccountModel, JournalEntryModel, country, orgField, fiscalYearStartMonth = 1, retainedEarningsAccountCode = country.retainedEarningsAccountCode, retainedEarningsDisplayCode = country.retainedEarningsDisplayCode ?? retainedEarningsAccountCode, currentYearEarningsCode = country.currentYearEarningsCode ?? "3680" } = opts;
297
- requireOrgScope(orgField, params.organizationId);
298
- const { endDate } = getDateRange(params.dateOption, params.dateValue);
299
- const fiscalYearStart = getFiscalYearStart(endDate, fiscalYearStartMonth);
300
- const itemFilters = buildItemFilters(params.filters);
301
- const q = { active: true };
302
- if (orgField && params.organizationId) q[orgField] = params.organizationId;
303
- const allAccounts = await AccountModel.find(q).lean();
304
- const bsIds = allAccounts.filter((a) => {
305
- const at = country.getAccountType(a.accountTypeCode);
306
- return at && !at.isGroup && at.category.startsWith("Balance Sheet");
307
- }).map((a) => a._id);
308
- const reAccountIds = retainedEarningsAccountCode ? allAccounts.filter((a) => a.accountTypeCode === retainedEarningsAccountCode).map((a) => a._id) : [];
309
- const reAccountIdSet = new Set(reAccountIds.map(String));
310
- const isIds = allAccounts.filter((a) => {
311
- const at = country.getAccountType(a.accountTypeCode);
312
- return at && !at.isGroup && !at.isTotal && at.category.startsWith("Income Statement");
313
- }).map((a) => a._id);
308
+ const q = { active: true };
309
+ if (orgField && params.organizationId) q[orgField] = params.organizationId;
310
+ const allAccounts = await AccountModel.find(q).lean();
311
+ const bsIds = allAccounts.filter((a) => {
312
+ const at = country.getAccountType(a.accountTypeCode);
313
+ return at && !at.isGroup && at.category.startsWith("Balance Sheet");
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));
317
+ const isIds = allAccounts.filter((a) => {
318
+ const at = country.getAccountType(a.accountTypeCode);
319
+ return at && !at.isGroup && !at.isTotal && at.category.startsWith("Income Statement");
320
+ }).map((a) => a._id);
314
321
  const baseMatch = { state: "posted" };
315
322
  if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
316
323
  const [bsResults, netIncomeResults, priorRetainedResults, reAccountResults] = await Promise.all([
@@ -503,8 +510,113 @@ async function generateBalanceSheet(opts, params) {
503
510
  };
504
511
  }
505
512
  //#endregion
506
- //#region src/reports/income-statement.ts
507
- async function generateIncomeStatement(opts, params) {
513
+ //#region src/reports/budget-vs-actual.ts
514
+ async function generateBudgetVsActual(opts, params) {
515
+ const { AccountModel, JournalEntryModel, BudgetModel, country, orgField } = opts;
516
+ requireOrgScope(orgField, params.organizationId);
517
+ const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
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]));
548
+ const baseMatch = {
549
+ state: "posted",
550
+ date: {
551
+ $gte: startDate,
552
+ $lte: endDate
553
+ }
554
+ };
555
+ if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
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) {
508
620
  const { AccountModel, JournalEntryModel, country, orgField } = opts;
509
621
  requireOrgScope(orgField, params.organizationId);
510
622
  const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
@@ -512,10 +624,21 @@ async function generateIncomeStatement(opts, params) {
512
624
  const q = { active: true };
513
625
  if (orgField && params.organizationId) q[orgField] = params.organizationId;
514
626
  const allAccounts = await AccountModel.find(q).lean();
515
- const isIds = allAccounts.filter((a) => {
516
- const at = country.getAccountType(a.accountTypeCode);
517
- return at && !at.isGroup && !at.isTotal && at.category.startsWith("Income Statement");
518
- }).map((a) => a._id);
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
+ }
519
642
  const baseMatch = {
520
643
  state: "posted",
521
644
  date: {
@@ -524,493 +647,69 @@ async function generateIncomeStatement(opts, params) {
524
647
  }
525
648
  };
526
649
  if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
527
- const results = await JournalEntryModel.aggregate([
650
+ const results = cfAccountIds.length > 0 ? await JournalEntryModel.aggregate([
528
651
  { $match: baseMatch },
529
652
  { $unwind: "$journalItems" },
530
653
  { $match: {
531
- "journalItems.account": { $in: isIds },
654
+ "journalItems.account": { $in: cfAccountIds },
532
655
  ...itemFilters
533
656
  } },
534
657
  { $group: {
535
658
  _id: "$journalItems.account",
536
659
  d: { $sum: "$journalItems.debit" },
537
- c: { $sum: "$journalItems.credit" }
538
- } }
539
- ]);
540
- const accountMap = new Map(allAccounts.map((a) => [String(a._id), a]));
541
- const revenueGroups = {};
542
- const expenseGroups = {};
543
- const resolveGroupName = (at) => {
544
- const visited = /* @__PURE__ */ new Set();
545
- let current = at.parentCode ? country.getAccountType(at.parentCode) : void 0;
546
- while (current && !visited.has(current.code)) {
547
- if (current.isGroup) return current.name;
548
- visited.add(current.code);
549
- current = current.parentCode ? country.getAccountType(current.parentCode) : void 0;
550
- }
551
- return at.name;
552
- };
553
- for (const r of results) {
554
- const acc = accountMap.get(String(r._id));
555
- if (!acc) continue;
556
- const at = country.getAccountType(acc.accountTypeCode);
557
- if (!at) continue;
558
- const mainType = extractMainType(at.category);
559
- const netAmount = mainType === "Income" ? r.c - r.d : r.d - r.c;
560
- if (netAmount === 0) continue;
561
- const groupName = resolveGroupName(at);
562
- const groups = mainType === "Income" ? revenueGroups : expenseGroups;
563
- if (!(groupName in groups)) groups[groupName] = {
564
- name: groupName,
565
- total: 0,
566
- accounts: []
567
- };
568
- groups[groupName].accounts.push({
569
- id: acc._id,
570
- name: acc.name ?? at.name,
571
- code: acc.accountNumber ?? at.code,
572
- balance: netAmount
573
- });
574
- groups[groupName].total += netAmount;
575
- }
576
- const sortGroups = (groups) => {
577
- const sorted = Object.values(groups);
578
- for (const g of sorted) g.accounts.sort((a, b) => (a.code ?? "").localeCompare(b.code ?? "", void 0, { numeric: true }));
579
- sorted.sort((a, b) => {
580
- const codeA = a.accounts[0]?.code ?? "";
581
- const codeB = b.accounts[0]?.code ?? "";
582
- return codeA.localeCompare(codeB, void 0, { numeric: true });
583
- });
584
- return sorted;
585
- };
586
- const labels = country.reportLabels ?? {};
587
- const revenue = {
588
- name: labels.revenue ?? "Revenue",
589
- total: Object.values(revenueGroups).reduce((s, g) => s + g.total, 0),
590
- groups: sortGroups(revenueGroups)
591
- };
592
- const expenses = {
593
- name: labels.expenses ?? "Expenses",
594
- total: Object.values(expenseGroups).reduce((s, g) => s + g.total, 0),
595
- groups: sortGroups(expenseGroups)
596
- };
597
- const cogsCode = country.cogsGroupCode;
598
- const isCogs = (name) => cogsCode ? name === cogsCode : name === "Cost of Sales" || name === "Cost of Goods Sold";
599
- const costOfSales = expenses.groups.find((g) => isCogs(g.name))?.total ?? 0;
600
- const grossProfit = revenue.total - costOfSales;
601
- const operatingIncome = grossProfit - expenses.groups.filter((g) => !isCogs(g.name)).reduce((s, g) => s + g.total, 0);
602
- const netIncome = revenue.total - expenses.total;
603
- const periodDisplay = params.dateOption === "year" ? `For the year ended ${endDate.toLocaleDateString("en-US", {
604
- year: "numeric",
605
- month: "long",
606
- day: "numeric"
607
- })}` : `${startDate.toLocaleDateString("en-US", {
608
- month: "short",
609
- day: "numeric"
610
- })} – ${endDate.toLocaleDateString("en-US", {
611
- year: "numeric",
612
- month: "short",
613
- day: "numeric"
614
- })}`;
615
- return {
616
- metadata: {
617
- businessName: params.businessName,
618
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
619
- periodStart: startDate.toISOString().split("T")[0],
620
- periodEnd: endDate.toISOString().split("T")[0],
621
- displayPeriod: periodDisplay
622
- },
623
- revenue,
624
- costOfSales,
625
- grossProfit,
626
- expenses,
627
- operatingIncome,
628
- netIncome
629
- };
630
- }
631
- //#endregion
632
- //#region src/reports/general-ledger.ts
633
- async function generateGeneralLedger(opts, params) {
634
- const { AccountModel, JournalEntryModel, country, orgField, fiscalYearStartMonth = 1 } = opts;
635
- requireOrgScope(orgField, params.organizationId);
636
- const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
637
- const fiscalYearStart = getFiscalYearStart(startDate, fiscalYearStartMonth);
638
- const itemFilters = buildItemFilters(params.filters);
639
- const acctQuery = { active: true };
640
- if (orgField && params.organizationId) acctQuery[orgField] = params.organizationId;
641
- if (params.accountId) acctQuery._id = params.accountId;
642
- const allAccounts = await AccountModel.find(acctQuery).lean();
643
- const filtered = [];
644
- for (const acc of allAccounts) {
645
- const at = country.getAccountType(acc.accountTypeCode);
646
- if (!at || at.isGroup || at.isTotal) continue;
647
- filtered.push({
648
- acc,
649
- at
650
- });
651
- }
652
- if (filtered.length === 0) return {
653
- accounts: [],
654
- period: {
655
- startDate,
656
- endDate
657
- }
658
- };
659
- filtered.sort((a, b) => {
660
- const codeA = a.acc.accountNumber ?? a.at.code;
661
- const codeB = b.acc.accountNumber ?? b.at.code;
662
- return codeA.localeCompare(codeB, void 0, { numeric: true });
663
- });
664
- const bsAccountIds = [];
665
- const isAccountIds = [];
666
- const allAccountIds = [];
667
- for (const { acc, at } of filtered) {
668
- allAccountIds.push(acc._id);
669
- if (at.category.startsWith("Balance Sheet")) bsAccountIds.push(acc._id);
670
- else isAccountIds.push(acc._id);
671
- }
672
- const orgScope = {};
673
- if (orgField && params.organizationId) orgScope[orgField] = params.organizationId;
674
- const openingBalancePipeline = (accountIds, dateFilter) => accountIds.length > 0 ? JournalEntryModel.aggregate([
675
- { $match: {
676
- state: "posted",
677
- date: dateFilter,
678
- ...orgScope
679
- } },
680
- { $unwind: "$journalItems" },
681
- { $match: {
682
- "journalItems.account": { $in: accountIds },
683
- ...itemFilters
684
- } },
685
- { $group: {
686
- _id: "$journalItems.account",
687
- d: { $sum: "$journalItems.debit" },
688
- c: { $sum: "$journalItems.credit" }
689
- } }
690
- ]) : Promise.resolve([]);
691
- const [bsOpenResults, isOpenResults, periodEntries] = await Promise.all([
692
- openingBalancePipeline(bsAccountIds, { $lt: startDate }),
693
- openingBalancePipeline(isAccountIds, {
694
- $gte: fiscalYearStart,
695
- $lt: startDate
696
- }),
697
- JournalEntryModel.find({
698
- state: "posted",
699
- date: {
700
- $gte: startDate,
701
- $lte: endDate
702
- },
703
- "journalItems.account": { $in: allAccountIds },
704
- ...orgScope,
705
- ...itemFilters
706
- }).select("date referenceNumber label journalItems").sort({ date: 1 }).lean()
707
- ]);
708
- const openBalMap = /* @__PURE__ */ new Map();
709
- for (const r of [...bsOpenResults, ...isOpenResults]) openBalMap.set(String(r._id), {
710
- d: r.d,
711
- c: r.c
712
- });
713
- const entryItemsByAccount = /* @__PURE__ */ new Map();
714
- for (const entry of periodEntries) {
715
- const items = entry.journalItems ?? [];
716
- for (const item of items) {
717
- const accId = String(item.account);
718
- const debit = item.debit ?? 0;
719
- const credit = item.credit ?? 0;
720
- let list = entryItemsByAccount.get(accId);
721
- if (!list) {
722
- list = [];
723
- entryItemsByAccount.set(accId, list);
724
- }
725
- list.push({
726
- date: entry.date,
727
- referenceNumber: entry.referenceNumber ?? "",
728
- label: entry.label ?? "",
729
- debit,
730
- credit
731
- });
732
- }
733
- }
734
- const glAccounts = [];
735
- for (const { acc, at } of filtered) {
736
- const accIdStr = String(acc._id);
737
- const openData = openBalMap.get(accIdStr);
738
- const openingBalance = openData ? computeEndingBalance(at.category, openData.d, openData.c) : 0;
739
- let runningBalance = openingBalance;
740
- const entries = [];
741
- const mainType = extractMainType(at.category);
742
- const accountItems = entryItemsByAccount.get(accIdStr) ?? [];
743
- for (const item of accountItems) {
744
- const delta = mainType === "Asset" || mainType === "Expense" ? item.debit - item.credit : item.credit - item.debit;
745
- runningBalance += delta;
746
- entries.push({
747
- date: item.date,
748
- referenceNumber: item.referenceNumber,
749
- label: item.label,
750
- debit: item.debit,
751
- credit: item.credit,
752
- runningBalance
753
- });
754
- }
755
- glAccounts.push({
756
- account: acc,
757
- openingBalance,
758
- entries,
759
- closingBalance: runningBalance
760
- });
761
- }
762
- const periodDisplay = params.dateOption === "year" ? `For the year ended ${endDate.toLocaleDateString("en-US", {
763
- year: "numeric",
764
- month: "long",
765
- day: "numeric"
766
- })}` : `${startDate.toLocaleDateString("en-US", {
767
- month: "short",
768
- day: "numeric"
769
- })} – ${endDate.toLocaleDateString("en-US", {
770
- year: "numeric",
771
- month: "short",
772
- day: "numeric"
773
- })}`;
774
- return {
775
- metadata: {
776
- businessName: params.businessName,
777
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
778
- periodStart: startDate.toISOString().split("T")[0],
779
- periodEnd: endDate.toISOString().split("T")[0],
780
- displayPeriod: periodDisplay
781
- },
782
- accounts: glAccounts,
783
- period: {
784
- startDate,
785
- endDate
786
- }
787
- };
788
- }
789
- //#endregion
790
- //#region src/reports/cash-flow.ts
791
- async function generateCashFlow(opts, params) {
792
- const { AccountModel, JournalEntryModel, country, orgField } = opts;
793
- requireOrgScope(orgField, params.organizationId);
794
- const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
795
- const itemFilters = buildItemFilters(params.filters);
796
- const q = { active: true };
797
- if (orgField && params.organizationId) q[orgField] = params.organizationId;
798
- const allAccounts = await AccountModel.find(q).lean();
799
- const accountCfMap = /* @__PURE__ */ new Map();
800
- const accountMap = new Map(allAccounts.map((a) => [String(a._id), a]));
801
- const cfAccountIds = [];
802
- for (const acc of allAccounts) {
803
- const at = country.getAccountType(acc.accountTypeCode);
804
- if (!at || at.isGroup || at.isTotal) continue;
805
- const cf = at.cashFlowCategory;
806
- if (!cf) continue;
807
- const normalized = cf.charAt(0).toUpperCase() + cf.slice(1);
808
- accountCfMap.set(String(acc._id), {
809
- category: at.category,
810
- cfCategory: normalized
811
- });
812
- cfAccountIds.push(acc._id);
813
- }
814
- const baseMatch = {
815
- state: "posted",
816
- date: {
817
- $gte: startDate,
818
- $lte: endDate
819
- }
820
- };
821
- if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
822
- const results = cfAccountIds.length > 0 ? await JournalEntryModel.aggregate([
823
- { $match: baseMatch },
824
- { $unwind: "$journalItems" },
825
- { $match: {
826
- "journalItems.account": { $in: cfAccountIds },
827
- ...itemFilters
828
- } },
829
- { $group: {
830
- _id: "$journalItems.account",
831
- d: { $sum: "$journalItems.debit" },
832
- c: { $sum: "$journalItems.credit" }
833
- } }
834
- ]) : [];
835
- const flows = {
836
- Operating: {
837
- total: 0,
838
- accounts: []
839
- },
840
- Investing: {
841
- total: 0,
842
- accounts: []
843
- },
844
- Financing: {
845
- total: 0,
846
- accounts: []
847
- }
848
- };
849
- for (const r of results) {
850
- const accIdStr = String(r._id);
851
- const meta = accountCfMap.get(accIdStr);
852
- if (!meta) continue;
853
- const amount = computeEndingBalance(meta.category, r.d, r.c);
854
- const acc = accountMap.get(accIdStr);
855
- const at = country.getAccountType(acc?.accountTypeCode);
856
- flows[meta.cfCategory].accounts.push({
857
- name: acc?.name ?? at?.name ?? "",
858
- code: acc?.accountNumber ?? at?.code ?? "",
859
- amount
860
- });
861
- flows[meta.cfCategory].total += amount;
862
- }
863
- for (const section of Object.values(flows)) section.accounts.sort((a, b) => a.code.localeCompare(b.code, void 0, { numeric: true }));
864
- const netCashFlow = flows.Operating.total + flows.Investing.total + flows.Financing.total;
865
- const periodDisplay = `${startDate.toLocaleDateString("en-US", {
866
- month: "short",
867
- day: "numeric"
868
- })} – ${endDate.toLocaleDateString("en-US", {
869
- year: "numeric",
870
- month: "short",
871
- day: "numeric"
872
- })}`;
873
- return {
874
- metadata: {
875
- businessName: params.businessName,
876
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
877
- periodStart: startDate.toISOString().split("T")[0],
878
- periodEnd: endDate.toISOString().split("T")[0],
879
- displayPeriod: periodDisplay
880
- },
881
- operating: flows.Operating,
882
- investing: flows.Investing,
883
- financing: flows.Financing,
884
- netCashFlow
885
- };
886
- }
887
- //#endregion
888
- //#region src/reports/aged-balance.ts
889
- const DEFAULT_BUCKETS = [
890
- {
891
- label: "Current",
892
- minDays: 0,
893
- maxDays: 31
894
- },
895
- {
896
- label: "31-60",
897
- minDays: 31,
898
- maxDays: 61
899
- },
900
- {
901
- label: "61-90",
902
- minDays: 61,
903
- maxDays: 91
904
- },
905
- {
906
- label: "90+",
907
- minDays: 91,
908
- maxDays: Infinity
909
- }
910
- ];
911
- async function generateAgedBalance(opts, params) {
912
- const { AccountModel, JournalEntryModel, country, orgField } = opts;
913
- requireOrgScope(orgField, params.organizationId);
914
- const asOfDate = params.asOfDate ?? /* @__PURE__ */ new Date();
915
- const buckets = params.buckets ?? DEFAULT_BUCKETS;
916
- const bucketLabels = buckets.map((b) => b.label);
917
- const dueDateField = params.dueDateField ?? "journalItems.dueDate";
918
- const contactField = params.contactField;
919
- const accountQuery = { active: true };
920
- if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
921
- let targetAccountIds;
922
- if (params.accountIds && params.accountIds.length > 0) targetAccountIds = params.accountIds;
923
- else {
924
- const allAccounts = await AccountModel.find(accountQuery).lean();
925
- const categoryPrefix = params.type === "receivable" ? "Balance Sheet-Asset" : "Balance Sheet-Liability";
926
- targetAccountIds = allAccounts.filter((a) => {
927
- const at = country.getAccountType(a.accountTypeCode);
928
- return at && !at.isGroup && at.category.startsWith(categoryPrefix);
929
- }).map((a) => a._id);
930
- }
931
- if (targetAccountIds.length === 0) return {
932
- metadata: {
933
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
934
- asOfDate: asOfDate.toISOString().split("T")[0],
935
- type: params.type
936
- },
937
- bucketLabels,
938
- rows: [],
939
- totals: Object.fromEntries(bucketLabels.map((l) => [l, 0])),
940
- grandTotal: 0
941
- };
942
- const allAccounts = await AccountModel.find(accountQuery).lean();
943
- const accountLookup = new Map(allAccounts.map((a) => [String(a._id), a]));
944
- const baseMatch = {
945
- state: "posted",
946
- date: { $lte: asOfDate }
947
- };
948
- if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
949
- asOfDate.getTime();
950
- const groupId = { account: "$journalItems.account" };
951
- if (contactField) groupId.contact = `$${contactField}`;
952
- const bucketBranches = buckets.map((b) => {
953
- return {
954
- case: b.maxDays === Infinity ? { $gte: ["$daysPastDue", b.minDays] } : { $and: [{ $gte: ["$daysPastDue", b.minDays] }, { $lt: ["$daysPastDue", b.maxDays] }] },
955
- then: b.label
956
- };
957
- });
958
- const pipeline = [
959
- { $match: baseMatch },
960
- { $unwind: "$journalItems" },
961
- { $match: { "journalItems.account": { $in: targetAccountIds } } },
962
- { $addFields: { daysPastDue: { $floor: { $divide: [{ $subtract: [asOfDate, { $ifNull: [`$${dueDateField}`, asOfDate] }] }, 1e3 * 60 * 60 * 24] } } } },
963
- { $addFields: { daysPastDue: { $max: ["$daysPastDue", 0] } } },
964
- { $addFields: { bucketLabel: { $switch: {
965
- branches: bucketBranches,
966
- default: bucketLabels[bucketLabels.length - 1]
967
- } } } },
968
- { $addFields: { netAmount: params.type === "receivable" ? { $subtract: ["$journalItems.debit", "$journalItems.credit"] } : { $subtract: ["$journalItems.credit", "$journalItems.debit"] } } },
969
- { $group: {
970
- _id: {
971
- ...groupId,
972
- bucket: "$bucketLabel"
973
- },
974
- amount: { $sum: "$netAmount" }
975
- } }
976
- ];
977
- const results = await JournalEntryModel.aggregate(pipeline);
978
- const rowKey = (accountId, contactId) => contactField ? `${String(accountId)}::${String(contactId ?? "")}` : String(accountId);
979
- const rowMap = /* @__PURE__ */ new Map();
980
- for (const r of results) {
981
- const key = rowKey(r._id.account, r._id.contact);
982
- if (!rowMap.has(key)) {
983
- const acc = accountLookup.get(String(r._id.account));
984
- rowMap.set(key, {
985
- accountId: r._id.account,
986
- accountName: acc?.name ?? "",
987
- accountCode: acc?.accountNumber ?? "",
988
- ...contactField ? { contactId: r._id.contact } : {},
989
- total: 0,
990
- buckets: Object.fromEntries(bucketLabels.map((l) => [l, 0]))
991
- });
660
+ c: { $sum: "$journalItems.credit" }
661
+ } }
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: []
992
675
  }
993
- const row = rowMap.get(key);
994
- if (row.buckets[r._id.bucket] !== void 0) row.buckets[r._id.bucket] += r.amount;
995
- row.total += r.amount;
996
- }
997
- const rows = Array.from(rowMap.values()).sort((a, b) => a.accountCode.localeCompare(b.accountCode, void 0, { numeric: true }));
998
- const totals = Object.fromEntries(bucketLabels.map((l) => [l, 0]));
999
- let grandTotal = 0;
1000
- for (const row of rows) {
1001
- for (const label of bucketLabels) totals[label] += row.buckets[label];
1002
- grandTotal += row.total;
676
+ };
677
+ for (const r of results) {
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
688
+ });
689
+ flows[meta.cfCategory].total += amount;
1003
690
  }
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", {
694
+ month: "short",
695
+ day: "numeric"
696
+ })} – ${endDate.toLocaleDateString("en-US", {
697
+ year: "numeric",
698
+ month: "short",
699
+ day: "numeric"
700
+ })}`;
1004
701
  return {
1005
702
  metadata: {
703
+ businessName: params.businessName,
1006
704
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1007
- asOfDate: asOfDate.toISOString().split("T")[0],
1008
- type: params.type
705
+ periodStart: startDate.toISOString().split("T")[0],
706
+ periodEnd: endDate.toISOString().split("T")[0],
707
+ displayPeriod: periodDisplay
1009
708
  },
1010
- bucketLabels,
1011
- rows,
1012
- totals,
1013
- grandTotal
709
+ operating: flows.Operating,
710
+ investing: flows.Investing,
711
+ financing: flows.Financing,
712
+ netCashFlow
1014
713
  };
1015
714
  }
1016
715
  //#endregion
@@ -1071,7 +770,7 @@ async function generateDimensionBreakdown(opts, params) {
1071
770
  const dimKey = r._id.dimension == null ? "__null__" : String(r._id.dimension);
1072
771
  const accKey = String(r._id.account);
1073
772
  if (!dimensionMap.has(dimKey)) dimensionMap.set(dimKey, /* @__PURE__ */ new Map());
1074
- dimensionMap.get(dimKey).set(accKey, {
773
+ dimensionMap.get(dimKey)?.set(accKey, {
1075
774
  d: r.d,
1076
775
  c: r.c
1077
776
  });
@@ -1120,47 +819,177 @@ async function generateDimensionBreakdown(opts, params) {
1120
819
  };
1121
820
  }
1122
821
  //#endregion
1123
- //#region src/reports/budget-vs-actual.ts
1124
- /**
1125
- * Budget vs Actual Report
1126
- *
1127
- * Compares budgeted amounts against actual journal entry balances
1128
- * for a given period. All monetary values are integer cents.
1129
- */
1130
- async function generateBudgetVsActual(opts, params) {
1131
- const { AccountModel, JournalEntryModel, BudgetModel, country, orgField } = opts;
822
+ //#region src/reports/general-ledger.ts
823
+ async function generateGeneralLedger(opts, params) {
824
+ const { AccountModel, JournalEntryModel, country, orgField, fiscalYearStartMonth = 1 } = opts;
1132
825
  requireOrgScope(orgField, params.organizationId);
1133
826
  const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
1134
- const budgetQuery = {
1135
- periodStart: { $lte: endDate },
1136
- periodEnd: { $gte: startDate }
827
+ const fiscalYearStart = getFiscalYearStart(startDate, fiscalYearStartMonth);
828
+ const itemFilters = buildItemFilters(params.filters);
829
+ const acctQuery = { active: true };
830
+ if (orgField && params.organizationId) acctQuery[orgField] = params.organizationId;
831
+ if (params.accountId) acctQuery._id = params.accountId;
832
+ const allAccounts = await AccountModel.find(acctQuery).lean();
833
+ const filtered = [];
834
+ for (const acc of allAccounts) {
835
+ const at = country.getAccountType(acc.accountTypeCode);
836
+ if (!at || at.isGroup || at.isTotal) continue;
837
+ filtered.push({
838
+ acc,
839
+ at
840
+ });
841
+ }
842
+ if (filtered.length === 0) return {
843
+ accounts: [],
844
+ period: {
845
+ startDate,
846
+ endDate
847
+ }
1137
848
  };
1138
- if (orgField && params.organizationId) budgetQuery[orgField] = params.organizationId;
1139
- if (params.accountIds && params.accountIds.length > 0) budgetQuery.account = { $in: params.accountIds };
1140
- const budgets = await BudgetModel.find(budgetQuery).lean();
1141
- if (budgets.length === 0) return {
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
+ });
854
+ const bsAccountIds = [];
855
+ const isAccountIds = [];
856
+ const allAccountIds = [];
857
+ for (const { acc, at } of filtered) {
858
+ allAccountIds.push(acc._id);
859
+ if (at.category.startsWith("Balance Sheet")) bsAccountIds.push(acc._id);
860
+ else isAccountIds.push(acc._id);
861
+ }
862
+ const orgScope = {};
863
+ if (orgField && params.organizationId) orgScope[orgField] = params.organizationId;
864
+ const openingBalancePipeline = (accountIds, dateFilter) => accountIds.length > 0 ? JournalEntryModel.aggregate([
865
+ { $match: {
866
+ state: "posted",
867
+ date: dateFilter,
868
+ ...orgScope
869
+ } },
870
+ { $unwind: "$journalItems" },
871
+ { $match: {
872
+ "journalItems.account": { $in: accountIds },
873
+ ...itemFilters
874
+ } },
875
+ { $group: {
876
+ _id: "$journalItems.account",
877
+ d: { $sum: "$journalItems.debit" },
878
+ c: { $sum: "$journalItems.credit" }
879
+ } }
880
+ ]) : Promise.resolve([]);
881
+ const [bsOpenResults, isOpenResults, periodEntries] = await Promise.all([
882
+ openingBalancePipeline(bsAccountIds, { $lt: startDate }),
883
+ openingBalancePipeline(isAccountIds, {
884
+ $gte: fiscalYearStart,
885
+ $lt: startDate
886
+ }),
887
+ JournalEntryModel.find({
888
+ state: "posted",
889
+ date: {
890
+ $gte: startDate,
891
+ $lte: endDate
892
+ },
893
+ "journalItems.account": { $in: allAccountIds },
894
+ ...orgScope,
895
+ ...itemFilters
896
+ }).select("date referenceNumber label journalItems").sort({ date: 1 }).lean()
897
+ ]);
898
+ const openBalMap = /* @__PURE__ */ new Map();
899
+ for (const r of [...bsOpenResults, ...isOpenResults]) openBalMap.set(String(r._id), {
900
+ d: r.d,
901
+ c: r.c
902
+ });
903
+ const entryItemsByAccount = /* @__PURE__ */ new Map();
904
+ for (const entry of periodEntries) {
905
+ const items = entry.journalItems ?? [];
906
+ for (const item of items) {
907
+ const accId = String(item.account);
908
+ const debit = item.debit ?? 0;
909
+ const credit = item.credit ?? 0;
910
+ let list = entryItemsByAccount.get(accId);
911
+ if (!list) {
912
+ list = [];
913
+ entryItemsByAccount.set(accId, list);
914
+ }
915
+ list.push({
916
+ date: entry.date,
917
+ referenceNumber: entry.referenceNumber ?? "",
918
+ label: entry.label ?? "",
919
+ debit,
920
+ credit
921
+ });
922
+ }
923
+ }
924
+ const glAccounts = [];
925
+ for (const { acc, at } of filtered) {
926
+ const accIdStr = String(acc._id);
927
+ const openData = openBalMap.get(accIdStr);
928
+ const openingBalance = openData ? computeEndingBalance(at.category, openData.d, openData.c) : 0;
929
+ let runningBalance = openingBalance;
930
+ const entries = [];
931
+ const mainType = extractMainType(at.category);
932
+ const accountItems = entryItemsByAccount.get(accIdStr) ?? [];
933
+ for (const item of accountItems) {
934
+ const delta = mainType === "Asset" || mainType === "Expense" ? item.debit - item.credit : item.credit - item.debit;
935
+ runningBalance += delta;
936
+ entries.push({
937
+ date: item.date,
938
+ referenceNumber: item.referenceNumber,
939
+ label: item.label,
940
+ debit: item.debit,
941
+ credit: item.credit,
942
+ runningBalance
943
+ });
944
+ }
945
+ glAccounts.push({
946
+ account: acc,
947
+ openingBalance,
948
+ entries,
949
+ closingBalance: runningBalance
950
+ });
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
+ })}`;
964
+ return {
1142
965
  metadata: {
966
+ businessName: params.businessName,
1143
967
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1144
- periodStart: startDate.toISOString(),
1145
- periodEnd: endDate.toISOString()
968
+ periodStart: startDate.toISOString().split("T")[0],
969
+ periodEnd: endDate.toISOString().split("T")[0],
970
+ displayPeriod: periodDisplay
1146
971
  },
1147
- rows: [],
1148
- summary: {
1149
- totalBudget: 0,
1150
- totalActual: 0,
1151
- totalVariance: 0
972
+ accounts: glAccounts,
973
+ period: {
974
+ startDate,
975
+ endDate
1152
976
  }
1153
977
  };
1154
- const budgetByAccount = /* @__PURE__ */ new Map();
1155
- for (const b of budgets) {
1156
- const key = String(b.account);
1157
- budgetByAccount.set(key, (budgetByAccount.get(key) ?? 0) + b.amount);
1158
- }
1159
- const accountIds = [...budgetByAccount.keys()];
1160
- const accountQuery = { _id: { $in: accountIds } };
1161
- if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
1162
- const accounts = await AccountModel.find(accountQuery).lean();
1163
- const accountMap = new Map(accounts.map((a) => [String(a._id), a]));
978
+ }
979
+ //#endregion
980
+ //#region src/reports/income-statement.ts
981
+ async function generateIncomeStatement(opts, params) {
982
+ const { AccountModel, JournalEntryModel, country, orgField } = opts;
983
+ requireOrgScope(orgField, params.organizationId);
984
+ const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
985
+ const itemFilters = buildItemFilters(params.filters);
986
+ const q = { active: true };
987
+ if (orgField && params.organizationId) q[orgField] = params.organizationId;
988
+ const allAccounts = await AccountModel.find(q).lean();
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);
1164
993
  const baseMatch = {
1165
994
  state: "posted",
1166
995
  date: {
@@ -1169,65 +998,108 @@ async function generateBudgetVsActual(opts, params) {
1169
998
  }
1170
999
  };
1171
1000
  if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
1172
- const pipeline = [
1001
+ const results = await JournalEntryModel.aggregate([
1173
1002
  { $match: baseMatch },
1174
1003
  { $unwind: "$journalItems" },
1175
- { $match: { "journalItems.account": { $in: accountIds.map((id) => new mongoose.Types.ObjectId(id)) } } },
1004
+ { $match: {
1005
+ "journalItems.account": { $in: isIds },
1006
+ ...itemFilters
1007
+ } },
1176
1008
  { $group: {
1177
1009
  _id: "$journalItems.account",
1178
- totalDebit: { $sum: "$journalItems.debit" },
1179
- totalCredit: { $sum: "$journalItems.credit" }
1010
+ d: { $sum: "$journalItems.debit" },
1011
+ c: { $sum: "$journalItems.credit" }
1180
1012
  } }
1181
- ];
1182
- const actuals = await JournalEntryModel.aggregate(pipeline);
1183
- const actualByAccount = /* @__PURE__ */ new Map();
1184
- for (const a of actuals) actualByAccount.set(String(a._id), {
1185
- debit: a.totalDebit,
1186
- credit: a.totalCredit
1187
- });
1188
- const rows = [];
1189
- for (const [accountId, budgetAmount] of budgetByAccount) {
1190
- const acc = accountMap.get(accountId);
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));
1191
1029
  if (!acc) continue;
1192
1030
  const at = country.getAccountType(acc.accountTypeCode);
1193
- if (!at || at.isGroup) continue;
1194
- const actual = actualByAccount.get(accountId) ?? {
1195
- debit: 0,
1196
- credit: 0
1197
- };
1031
+ if (!at) continue;
1198
1032
  const mainType = extractMainType(at.category);
1199
- let actualAmount;
1200
- if (mainType === "Income") actualAmount = actual.credit - actual.debit;
1201
- else if (mainType === "Expense") actualAmount = actual.debit - actual.credit;
1202
- else if (mainType === "Asset") actualAmount = actual.debit - actual.credit;
1203
- else actualAmount = actual.credit - actual.debit;
1204
- const variance = actualAmount - budgetAmount;
1205
- const variancePercent = budgetAmount !== 0 ? Math.round(variance / budgetAmount * 1e4) / 100 : 0;
1206
- rows.push({
1207
- accountId: acc._id,
1208
- accountName: acc.name,
1209
- accountCode: acc.accountNumber,
1210
- category: at.category,
1211
- budgetAmount,
1212
- actualAmount,
1213
- variance,
1214
- variancePercent
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,
1039
+ total: 0,
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
1215
1047
  });
1048
+ groups[groupName].total += netAmount;
1216
1049
  }
1217
- rows.sort((a, b) => a.accountCode.localeCompare(b.accountCode));
1218
- const summary = {
1219
- totalBudget: rows.reduce((s, r) => s + r.budgetAmount, 0),
1220
- totalActual: rows.reduce((s, r) => s + r.actualAmount, 0),
1221
- totalVariance: rows.reduce((s, r) => s + r.variance, 0)
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)
1222
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
+ })}`;
1223
1089
  return {
1224
1090
  metadata: {
1091
+ businessName: params.businessName,
1225
1092
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1226
- periodStart: startDate.toISOString(),
1227
- periodEnd: endDate.toISOString()
1093
+ periodStart: startDate.toISOString().split("T")[0],
1094
+ periodEnd: endDate.toISOString().split("T")[0],
1095
+ displayPeriod: periodDisplay
1228
1096
  },
1229
- rows,
1230
- summary
1097
+ revenue,
1098
+ costOfSales,
1099
+ grossProfit,
1100
+ expenses,
1101
+ operatingIncome,
1102
+ netIncome
1231
1103
  };
1232
1104
  }
1233
1105
  //#endregion
@@ -1429,6 +1301,131 @@ async function generateRevaluation(opts, params) {
1429
1301
  };
1430
1302
  }
1431
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", {
1406
+ month: "short",
1407
+ day: "numeric"
1408
+ })} – ${endDate.toLocaleDateString("en-US", {
1409
+ year: "numeric",
1410
+ month: "short",
1411
+ day: "numeric"
1412
+ })}`;
1413
+ return {
1414
+ metadata: {
1415
+ businessName: params.businessName,
1416
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1417
+ periodStart: startDate.toISOString().split("T")[0],
1418
+ periodEnd: endDate.toISOString().split("T")[0],
1419
+ displayPeriod: periodDisplay
1420
+ },
1421
+ rows,
1422
+ period: {
1423
+ startDate,
1424
+ endDate
1425
+ }
1426
+ };
1427
+ }
1428
+ //#endregion
1432
1429
  //#region src/utils/logger.ts
1433
1430
  /** Default console-based implementation */
1434
1431
  const defaultLogger = {
@@ -1653,4 +1650,4 @@ async function reopenFiscalPeriod(opts, params) {
1653
1650
  }
1654
1651
  }
1655
1652
  //#endregion
1656
- export { getDateRange as C, buildItemFilters as S, buildAccountTypeMap as _, defaultLogger as a, isVirtualTaxAccount as b, computeRevaluation as c, DEFAULT_BUCKETS as d, generateAgedBalance as f, generateBalanceSheet as g, generateIncomeStatement as h, finalizeSession as i, generateBudgetVsActual as l, generateGeneralLedger as m, reopenFiscalPeriod as n, generateRevaluation as o, generateCashFlow as p, acquireSession as r, buildRevaluationEntry as s, closeFiscalPeriod as t, generateDimensionBreakdown as u, calculateTotal as v, getFiscalYearStart as w, generateTrialBalance as x, computeEndingBalance as y };
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 };