@classytic/ledger 0.1.3

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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/dist/account.repository-1C2sZvB2.d.mts +29 -0
  4. package/dist/account.repository-1C2sZvB2.d.mts.map +1 -0
  5. package/dist/account.repository-Crf5DGO4.mjs +393 -0
  6. package/dist/account.repository-Crf5DGO4.mjs.map +1 -0
  7. package/dist/categories-BNJBd4ze.mjs +70 -0
  8. package/dist/categories-BNJBd4ze.mjs.map +1 -0
  9. package/dist/constants/index.d.mts +2 -0
  10. package/dist/constants/index.mjs +5 -0
  11. package/dist/core-Cx0baosR.d.mts +104 -0
  12. package/dist/core-Cx0baosR.d.mts.map +1 -0
  13. package/dist/country/index.d.mts +105 -0
  14. package/dist/country/index.d.mts.map +1 -0
  15. package/dist/country/index.mjs +27 -0
  16. package/dist/country/index.mjs.map +1 -0
  17. package/dist/currencies-BBk3NwXn.mjs +82 -0
  18. package/dist/currencies-BBk3NwXn.mjs.map +1 -0
  19. package/dist/currencies-Bkn3FNkC.d.mts +38 -0
  20. package/dist/currencies-Bkn3FNkC.d.mts.map +1 -0
  21. package/dist/engine-Cd73EOT6.d.mts +72 -0
  22. package/dist/engine-Cd73EOT6.d.mts.map +1 -0
  23. package/dist/errors-CeqRahE-.mjs +28 -0
  24. package/dist/errors-CeqRahE-.mjs.map +1 -0
  25. package/dist/exports/index.d.mts +2 -0
  26. package/dist/exports/index.mjs +3 -0
  27. package/dist/fiscal-close-CNOwv_ud.mjs +934 -0
  28. package/dist/fiscal-close-CNOwv_ud.mjs.map +1 -0
  29. package/dist/fiscal-close-CzUzpnMg.d.mts +270 -0
  30. package/dist/fiscal-close-CzUzpnMg.d.mts.map +1 -0
  31. package/dist/fiscal-period.schema-CbALaaKl.mjs +477 -0
  32. package/dist/fiscal-period.schema-CbALaaKl.mjs.map +1 -0
  33. package/dist/fiscal-period.schema-DI2scngu.d.mts +38 -0
  34. package/dist/fiscal-period.schema-DI2scngu.d.mts.map +1 -0
  35. package/dist/idempotency.plugin-BESs9YPD.d.mts +58 -0
  36. package/dist/idempotency.plugin-BESs9YPD.d.mts.map +1 -0
  37. package/dist/idempotency.plugin-C6r8RI8d.mjs +165 -0
  38. package/dist/idempotency.plugin-C6r8RI8d.mjs.map +1 -0
  39. package/dist/index.d.mts +308 -0
  40. package/dist/index.d.mts.map +1 -0
  41. package/dist/index.mjs +171 -0
  42. package/dist/index.mjs.map +1 -0
  43. package/dist/journals-CI3Wb4EF.mjs +92 -0
  44. package/dist/journals-CI3Wb4EF.mjs.map +1 -0
  45. package/dist/logger-Cv6VVc4r.d.mts +15 -0
  46. package/dist/logger-Cv6VVc4r.d.mts.map +1 -0
  47. package/dist/money.d.mts +129 -0
  48. package/dist/money.d.mts.map +1 -0
  49. package/dist/money.mjs +197 -0
  50. package/dist/money.mjs.map +1 -0
  51. package/dist/plugins/index.d.mts +2 -0
  52. package/dist/plugins/index.mjs +3 -0
  53. package/dist/reports/index.d.mts +2 -0
  54. package/dist/reports/index.mjs +3 -0
  55. package/dist/repositories/index.d.mts +2 -0
  56. package/dist/repositories/index.mjs +3 -0
  57. package/dist/schemas/index.d.mts +2 -0
  58. package/dist/schemas/index.mjs +3 -0
  59. package/dist/session-Dh0s6zG4.mjs +87 -0
  60. package/dist/session-Dh0s6zG4.mjs.map +1 -0
  61. package/dist/universal-CMfrZ2hG.mjs +257 -0
  62. package/dist/universal-CMfrZ2hG.mjs.map +1 -0
  63. package/dist/universal-x33ZJODp.d.mts +137 -0
  64. package/dist/universal-x33ZJODp.d.mts.map +1 -0
  65. package/docs/country-packs.md +117 -0
  66. package/docs/engine.md +147 -0
  67. package/docs/exports.md +81 -0
  68. package/docs/money.md +81 -0
  69. package/docs/plugins.md +136 -0
  70. package/docs/reports.md +154 -0
  71. package/docs/repositories.md +239 -0
  72. package/docs/schemas.md +146 -0
  73. package/docs/subledger-integration.md +287 -0
  74. package/package.json +116 -0
@@ -0,0 +1,934 @@
1
+ import { n as Errors } from "./errors-CeqRahE-.mjs";
2
+ import { i as requireOrgScope, n as finalizeSession, r as defaultLogger, t as acquireSession } from "./session-Dh0s6zG4.mjs";
3
+ import { i as extractMainType } from "./categories-BNJBd4ze.mjs";
4
+
5
+ //#region src/utils/date-range.ts
6
+ /**
7
+ * Compute start/end dates from a date option + value.
8
+ *
9
+ * Examples:
10
+ * getDateRange('month', '2025-03') → Mar 1 – Mar 31
11
+ * getDateRange('quarter', { quarter: 2, year: 2025 }) → Apr 1 – Jun 30
12
+ * getDateRange('year', 2025) → Jan 1 – Dec 31
13
+ * getDateRange('custom', { startDate, endDate })
14
+ */
15
+ function getDateRange(option, value) {
16
+ switch (option) {
17
+ case "month": {
18
+ let year;
19
+ let month;
20
+ const match = String(value).match(/^(\d{4})-(\d{1,2})$/);
21
+ if (match) {
22
+ year = parseInt(match[1], 10);
23
+ month = parseInt(match[2], 10) - 1;
24
+ } else {
25
+ const date = new Date(value);
26
+ year = date.getFullYear();
27
+ month = date.getMonth();
28
+ }
29
+ return {
30
+ startDate: new Date(year, month, 1),
31
+ endDate: new Date(year, month + 1, 0, 23, 59, 59, 999)
32
+ };
33
+ }
34
+ case "quarter": {
35
+ const { quarter, year } = value;
36
+ const startMonth = (quarter - 1) * 3;
37
+ return {
38
+ startDate: new Date(year, startMonth, 1),
39
+ endDate: new Date(year, startMonth + 3, 0, 23, 59, 59, 999)
40
+ };
41
+ }
42
+ case "year": {
43
+ const year = typeof value === "number" ? value : parseInt(String(value), 10);
44
+ return {
45
+ startDate: new Date(year, 0, 1),
46
+ endDate: new Date(year, 11, 31, 23, 59, 59, 999)
47
+ };
48
+ }
49
+ case "custom": {
50
+ const { startDate, endDate } = value;
51
+ const end = new Date(endDate);
52
+ if (end.getHours() === 0 && end.getMinutes() === 0 && end.getSeconds() === 0 && end.getMilliseconds() === 0) end.setHours(23, 59, 59, 999);
53
+ return {
54
+ startDate: new Date(startDate),
55
+ endDate: end
56
+ };
57
+ }
58
+ default: {
59
+ const now = /* @__PURE__ */ new Date();
60
+ return {
61
+ startDate: new Date(now.getFullYear(), now.getMonth(), 1),
62
+ endDate: new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
63
+ };
64
+ }
65
+ }
66
+ }
67
+ /** Get fiscal year start date for a given date and fiscal start month */
68
+ function getFiscalYearStart(date, fiscalStartMonth = 1) {
69
+ const month = fiscalStartMonth - 1;
70
+ const year = date.getMonth() < month ? date.getFullYear() - 1 : date.getFullYear();
71
+ return new Date(year, month, 1);
72
+ }
73
+
74
+ //#endregion
75
+ //#region src/utils/filter-builder.ts
76
+ /**
77
+ * Filter Builder — Sanitizes user-supplied dimension filters for aggregation pipelines.
78
+ *
79
+ * Prevents injection of dangerous MongoDB operators while allowing
80
+ * standard equality and comparison filters on custom dimension fields.
81
+ */
82
+ const BLOCKED_OPERATORS = new Set([
83
+ "$where",
84
+ "$expr",
85
+ "$function",
86
+ "$accumulator",
87
+ "$merge",
88
+ "$out",
89
+ "$unionWith"
90
+ ]);
91
+ /**
92
+ * Build a sanitized filter object from user-supplied dimension filters.
93
+ * Blocks dangerous operators ($where, $expr, $function, etc.).
94
+ *
95
+ * @param filters - Key-value filters (e.g. { 'journalItems.departmentId': 'dept-1' })
96
+ * @returns Sanitized filter object safe for $match stages
97
+ * @throws Error if a blocked operator is used
98
+ */
99
+ function buildItemFilters(filters) {
100
+ if (!filters || Object.keys(filters).length === 0) return {};
101
+ const result = {};
102
+ for (const [key, value] of Object.entries(filters)) {
103
+ if (key.startsWith("$")) throw new Error(`Filter key "${key}" is not allowed. Use field names, not operators.`);
104
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
105
+ for (const opKey of Object.keys(value)) if (BLOCKED_OPERATORS.has(opKey)) throw new Error(`Filter operator "${opKey}" is not allowed.`);
106
+ }
107
+ result[key] = value;
108
+ }
109
+ return result;
110
+ }
111
+
112
+ //#endregion
113
+ //#region src/reports/trial-balance.ts
114
+ async function generateTrialBalance(opts, params) {
115
+ const { AccountModel, JournalEntryModel, country, orgField, fiscalYearStartMonth = 1 } = opts;
116
+ requireOrgScope(orgField, params.organizationId);
117
+ const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
118
+ const fiscalYearStart = getFiscalYearStart(startDate, fiscalYearStartMonth);
119
+ const itemFilters = buildItemFilters(params.filters);
120
+ const accountQuery = { active: true };
121
+ if (orgField && params.organizationId) accountQuery[orgField] = params.organizationId;
122
+ const allAccounts = await AccountModel.find(accountQuery).lean();
123
+ const bsIds = [];
124
+ const isIds = [];
125
+ for (const acc of allAccounts) {
126
+ const at = country.getAccountType(acc.accountTypeCode);
127
+ if (!at || at.isGroup) continue;
128
+ if (at.category.startsWith("Balance Sheet")) bsIds.push(acc._id);
129
+ else if (at.category.startsWith("Income Statement")) isIds.push(acc._id);
130
+ }
131
+ const baseMatch = { state: "posted" };
132
+ if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
133
+ const accountFilter = params.accountId ? { "journalItems.account": params.accountId } : {};
134
+ const buildPipeline = (ids, dateFrom, dateTo) => [
135
+ { $match: {
136
+ ...baseMatch,
137
+ date: {
138
+ $gte: dateFrom,
139
+ $lt: dateTo
140
+ }
141
+ } },
142
+ { $unwind: "$journalItems" },
143
+ { $match: {
144
+ "journalItems.account": { $in: ids },
145
+ ...accountFilter,
146
+ ...itemFilters
147
+ } },
148
+ { $group: {
149
+ _id: "$journalItems.account",
150
+ d: { $sum: "$journalItems.debit" },
151
+ c: { $sum: "$journalItems.credit" }
152
+ } }
153
+ ];
154
+ const [bsInitial, isInitial, current] = await Promise.all([
155
+ bsIds.length ? JournalEntryModel.aggregate(buildPipeline(bsIds, /* @__PURE__ */ new Date(0), startDate)) : [],
156
+ isIds.length ? JournalEntryModel.aggregate(buildPipeline(isIds, fiscalYearStart, startDate)) : [],
157
+ JournalEntryModel.aggregate(buildPipeline([...bsIds, ...isIds], startDate, new Date(endDate.getTime() + 1)))
158
+ ]);
159
+ const map = /* @__PURE__ */ new Map();
160
+ for (const r of [...bsInitial, ...isInitial]) {
161
+ const key = String(r._id);
162
+ map.set(key, {
163
+ iD: r.d,
164
+ iC: r.c,
165
+ cD: 0,
166
+ cC: 0
167
+ });
168
+ }
169
+ for (const r of current) {
170
+ const key = String(r._id);
171
+ const existing = map.get(key) ?? {
172
+ iD: 0,
173
+ iC: 0,
174
+ cD: 0,
175
+ cC: 0
176
+ };
177
+ existing.cD = r.d;
178
+ existing.cC = r.c;
179
+ map.set(key, existing);
180
+ }
181
+ const accountLookup = new Map(allAccounts.map((a) => [String(a._id), a]));
182
+ const rows = [];
183
+ for (const [id, bal] of map) {
184
+ const acc = accountLookup.get(id);
185
+ const net = bal.iD + bal.cD - (bal.iC + bal.cC);
186
+ rows.push({
187
+ account: acc ?? id,
188
+ initial: {
189
+ debit: bal.iD,
190
+ credit: bal.iC
191
+ },
192
+ current: {
193
+ debit: bal.cD,
194
+ credit: bal.cC
195
+ },
196
+ ending: net >= 0 ? {
197
+ debit: net,
198
+ credit: 0
199
+ } : {
200
+ debit: 0,
201
+ credit: Math.abs(net)
202
+ }
203
+ });
204
+ }
205
+ return {
206
+ rows,
207
+ period: {
208
+ startDate,
209
+ endDate
210
+ }
211
+ };
212
+ }
213
+
214
+ //#endregion
215
+ //#region src/utils/account-helpers.ts
216
+ /**
217
+ * Check if an account type is a virtual tax sub-account.
218
+ * Returns true if the account's parent has `isVirtualTotal: true`.
219
+ * Works for any country pack — no code format assumptions.
220
+ */
221
+ function isVirtualTaxAccount(accountType, accountMap) {
222
+ if (!accountType.parentCode) return false;
223
+ return accountMap.get(accountType.parentCode)?.isVirtualTotal === true;
224
+ }
225
+ /**
226
+ * Calculate a total from sub-accounts using the totalAccountTypes formula.
227
+ * @param formula - Array of { account, operation } instructions
228
+ * @param balanceMap - Map of account code → balance
229
+ */
230
+ function calculateTotal(formula, balanceMap) {
231
+ let total = 0;
232
+ for (const item of formula) {
233
+ const balance = balanceMap.get(item.account) ?? 0;
234
+ total += item.operation === "+" ? balance : -balance;
235
+ }
236
+ return total;
237
+ }
238
+ /**
239
+ * Compute the ending balance for an account given its debits and credits.
240
+ * Uses the account's main type to determine normal balance direction.
241
+ *
242
+ * Assets & Expenses: debit - credit
243
+ * Liabilities, Equity & Income: credit - debit
244
+ */
245
+ function computeEndingBalance(category, totalDebit, totalCredit) {
246
+ const mainType = extractMainType(category);
247
+ if (mainType === "Asset" || mainType === "Expense") return totalDebit - totalCredit;
248
+ return totalCredit - totalDebit;
249
+ }
250
+ /**
251
+ * Build a lookup map from an array of account types.
252
+ */
253
+ function buildAccountTypeMap(accountTypes) {
254
+ const map = /* @__PURE__ */ new Map();
255
+ for (const at of accountTypes) map.set(at.code, at);
256
+ return map;
257
+ }
258
+
259
+ //#endregion
260
+ //#region src/reports/balance-sheet.ts
261
+ async function generateBalanceSheet(opts, params) {
262
+ const { AccountModel, JournalEntryModel, country, orgField, fiscalYearStartMonth = 1, retainedEarningsCode = country.retainedEarningsCode ?? "3660", currentYearEarningsCode = country.currentYearEarningsCode ?? "3680" } = opts;
263
+ requireOrgScope(orgField, params.organizationId);
264
+ const { endDate } = getDateRange(params.dateOption, params.dateValue);
265
+ const fiscalYearStart = getFiscalYearStart(endDate, fiscalYearStartMonth);
266
+ const itemFilters = buildItemFilters(params.filters);
267
+ const q = { active: true };
268
+ if (orgField && params.organizationId) q[orgField] = params.organizationId;
269
+ const allAccounts = await AccountModel.find(q).lean();
270
+ const bsIds = allAccounts.filter((a) => {
271
+ const at = country.getAccountType(a.accountTypeCode);
272
+ return at && !at.isGroup && at.category.startsWith("Balance Sheet");
273
+ }).map((a) => a._id);
274
+ const isIds = allAccounts.filter((a) => {
275
+ const at = country.getAccountType(a.accountTypeCode);
276
+ return at && !at.isGroup && !at.isTotal && at.category.startsWith("Income Statement");
277
+ }).map((a) => a._id);
278
+ const baseMatch = { state: "posted" };
279
+ if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
280
+ const [bsResults, netIncomeResults, priorRetainedResults] = await Promise.all([
281
+ JournalEntryModel.aggregate([
282
+ { $match: {
283
+ ...baseMatch,
284
+ date: { $lte: endDate }
285
+ } },
286
+ { $unwind: "$journalItems" },
287
+ { $match: {
288
+ "journalItems.account": { $in: bsIds },
289
+ ...itemFilters
290
+ } },
291
+ { $group: {
292
+ _id: "$journalItems.account",
293
+ d: { $sum: "$journalItems.debit" },
294
+ c: { $sum: "$journalItems.credit" }
295
+ } }
296
+ ]),
297
+ JournalEntryModel.aggregate([
298
+ { $match: {
299
+ ...baseMatch,
300
+ date: {
301
+ $gte: fiscalYearStart,
302
+ $lte: endDate
303
+ }
304
+ } },
305
+ { $unwind: "$journalItems" },
306
+ { $match: {
307
+ "journalItems.account": { $in: isIds },
308
+ ...itemFilters
309
+ } },
310
+ { $group: {
311
+ _id: null,
312
+ d: { $sum: "$journalItems.debit" },
313
+ c: { $sum: "$journalItems.credit" }
314
+ } }
315
+ ]),
316
+ JournalEntryModel.aggregate([
317
+ { $match: {
318
+ ...baseMatch,
319
+ date: { $lt: fiscalYearStart }
320
+ } },
321
+ { $unwind: "$journalItems" },
322
+ { $match: {
323
+ "journalItems.account": { $in: isIds },
324
+ ...itemFilters
325
+ } },
326
+ { $group: {
327
+ _id: null,
328
+ d: { $sum: "$journalItems.debit" },
329
+ c: { $sum: "$journalItems.credit" }
330
+ } }
331
+ ])
332
+ ]);
333
+ const netIncome = netIncomeResults.length > 0 ? netIncomeResults[0].c - netIncomeResults[0].d : 0;
334
+ const priorRetained = priorRetainedResults.length > 0 ? priorRetainedResults[0].c - priorRetainedResults[0].d : 0;
335
+ const accountMap = new Map(allAccounts.map((a) => [String(a._id), a]));
336
+ const accountTypeMap = buildAccountTypeMap(country.accountTypes);
337
+ const balanceMap = /* @__PURE__ */ new Map();
338
+ const labels = country.reportLabels ?? {};
339
+ const assets = {
340
+ name: labels.assets ?? "Assets",
341
+ total: 0,
342
+ groups: []
343
+ };
344
+ const liabilities = {
345
+ name: labels.liabilities ?? "Liabilities",
346
+ total: 0,
347
+ groups: []
348
+ };
349
+ const equity = {
350
+ name: labels.equity ?? "Equity",
351
+ total: 0,
352
+ groups: []
353
+ };
354
+ const groupsMap = {
355
+ Asset: {},
356
+ Liability: {},
357
+ Equity: {}
358
+ };
359
+ for (const r of bsResults) {
360
+ const acc = accountMap.get(String(r._id));
361
+ if (!acc) continue;
362
+ const at = country.getAccountType(acc.accountTypeCode);
363
+ if (!at) continue;
364
+ const mainType = extractMainType(at.category) ?? "Asset";
365
+ const balance = computeEndingBalance(at.category, r.d, r.c);
366
+ balanceMap.set(at.code, balance);
367
+ const groupName = (at.parentCode ? country.getAccountType(at.parentCode) : void 0)?.name ?? at.name;
368
+ if (!(groupName in groupsMap[mainType])) groupsMap[mainType][groupName] = {
369
+ name: groupName,
370
+ total: 0,
371
+ accounts: []
372
+ };
373
+ const group = groupsMap[mainType][groupName];
374
+ if (!isVirtualTaxAccount(at, accountTypeMap)) group.accounts.push({
375
+ id: acc._id,
376
+ name: acc.name ?? at.name,
377
+ code: acc.accountNumber ?? at.code,
378
+ balance,
379
+ isTotal: at.isTotal,
380
+ isVirtualTotal: at.isVirtualTotal
381
+ });
382
+ if (!at.isTotal) group.total += balance;
383
+ }
384
+ const reGroup = {
385
+ name: "Retained Earnings",
386
+ total: priorRetained + netIncome,
387
+ accounts: [{
388
+ id: "prior-retained",
389
+ name: "Previous Years Retained Earnings",
390
+ code: retainedEarningsCode,
391
+ balance: priorRetained
392
+ }, {
393
+ id: "current-year",
394
+ name: `Current Year Net Income (${endDate.getFullYear()})`,
395
+ code: currentYearEarningsCode,
396
+ balance: netIncome,
397
+ isCalculated: true
398
+ }]
399
+ };
400
+ if (!(reGroup.name in groupsMap.Equity)) groupsMap.Equity[reGroup.name] = reGroup;
401
+ else {
402
+ groupsMap.Equity[reGroup.name].accounts.push(...reGroup.accounts);
403
+ groupsMap.Equity[reGroup.name].total += reGroup.total;
404
+ }
405
+ assets.groups = Object.values(groupsMap.Asset);
406
+ liabilities.groups = Object.values(groupsMap.Liability);
407
+ equity.groups = Object.values(groupsMap.Equity);
408
+ assets.total = assets.groups.reduce((s, g) => s + g.total, 0);
409
+ liabilities.total = liabilities.groups.reduce((s, g) => s + g.total, 0);
410
+ equity.total = equity.groups.reduce((s, g) => s + g.total, 0);
411
+ const liabilitiesAndEquity = liabilities.total + equity.total;
412
+ return {
413
+ metadata: {
414
+ businessName: params.businessName,
415
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
416
+ asOfDate: endDate.toISOString().split("T")[0],
417
+ displayDate: `As of ${endDate.toLocaleDateString("en-US", {
418
+ year: "numeric",
419
+ month: "long",
420
+ day: "numeric"
421
+ })}`
422
+ },
423
+ assets,
424
+ liabilities,
425
+ equity,
426
+ summary: {
427
+ totalAssets: assets.total,
428
+ totalLiabilities: liabilities.total,
429
+ totalEquity: equity.total,
430
+ liabilitiesAndEquity,
431
+ difference: assets.total - liabilitiesAndEquity,
432
+ isBalanced: assets.total === liabilitiesAndEquity
433
+ }
434
+ };
435
+ }
436
+
437
+ //#endregion
438
+ //#region src/reports/income-statement.ts
439
+ async function generateIncomeStatement(opts, params) {
440
+ const { AccountModel, JournalEntryModel, country, orgField } = opts;
441
+ requireOrgScope(orgField, params.organizationId);
442
+ const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
443
+ const itemFilters = buildItemFilters(params.filters);
444
+ const q = { active: true };
445
+ if (orgField && params.organizationId) q[orgField] = params.organizationId;
446
+ const allAccounts = await AccountModel.find(q).lean();
447
+ const isIds = allAccounts.filter((a) => {
448
+ const at = country.getAccountType(a.accountTypeCode);
449
+ return at && !at.isGroup && !at.isTotal && at.category.startsWith("Income Statement");
450
+ }).map((a) => a._id);
451
+ const baseMatch = {
452
+ state: "posted",
453
+ date: {
454
+ $gte: startDate,
455
+ $lte: endDate
456
+ }
457
+ };
458
+ if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
459
+ const results = await JournalEntryModel.aggregate([
460
+ { $match: baseMatch },
461
+ { $unwind: "$journalItems" },
462
+ { $match: {
463
+ "journalItems.account": { $in: isIds },
464
+ ...itemFilters
465
+ } },
466
+ { $group: {
467
+ _id: "$journalItems.account",
468
+ d: { $sum: "$journalItems.debit" },
469
+ c: { $sum: "$journalItems.credit" }
470
+ } }
471
+ ]);
472
+ const accountMap = new Map(allAccounts.map((a) => [String(a._id), a]));
473
+ const revenueGroups = {};
474
+ const expenseGroups = {};
475
+ for (const r of results) {
476
+ const acc = accountMap.get(String(r._id));
477
+ if (!acc) continue;
478
+ const at = country.getAccountType(acc.accountTypeCode);
479
+ if (!at) continue;
480
+ const mainType = extractMainType(at.category);
481
+ const netAmount = mainType === "Income" ? r.c - r.d : r.d - r.c;
482
+ if (netAmount === 0) continue;
483
+ const groupName = (at.parentCode ? country.getAccountType(at.parentCode) : void 0)?.name ?? at.name;
484
+ const groups = mainType === "Income" ? revenueGroups : expenseGroups;
485
+ if (!(groupName in groups)) groups[groupName] = {
486
+ name: groupName,
487
+ total: 0,
488
+ accounts: []
489
+ };
490
+ groups[groupName].accounts.push({
491
+ id: acc._id,
492
+ name: acc.name ?? at.name,
493
+ code: acc.accountNumber ?? at.code,
494
+ balance: netAmount
495
+ });
496
+ groups[groupName].total += netAmount;
497
+ }
498
+ const labels = country.reportLabels ?? {};
499
+ const revenue = {
500
+ name: labels.revenue ?? "Revenue",
501
+ total: Object.values(revenueGroups).reduce((s, g) => s + g.total, 0),
502
+ groups: Object.values(revenueGroups)
503
+ };
504
+ const expenses = {
505
+ name: labels.expenses ?? "Expenses",
506
+ total: Object.values(expenseGroups).reduce((s, g) => s + g.total, 0),
507
+ groups: Object.values(expenseGroups)
508
+ };
509
+ const cogsCode = country.cogsGroupCode;
510
+ const isCogs = (name) => cogsCode ? name === cogsCode : name === "Cost of Sales" || name === "Cost of Goods Sold";
511
+ const costOfSales = expenses.groups.find((g) => isCogs(g.name))?.total ?? 0;
512
+ const grossProfit = revenue.total - costOfSales;
513
+ const operatingIncome = grossProfit - expenses.groups.filter((g) => !isCogs(g.name)).reduce((s, g) => s + g.total, 0);
514
+ const netIncome = revenue.total - expenses.total;
515
+ const periodDisplay = params.dateOption === "year" ? `For the year ended ${endDate.toLocaleDateString("en-US", {
516
+ year: "numeric",
517
+ month: "long",
518
+ day: "numeric"
519
+ })}` : `${startDate.toLocaleDateString("en-US", {
520
+ month: "short",
521
+ day: "numeric"
522
+ })} – ${endDate.toLocaleDateString("en-US", {
523
+ year: "numeric",
524
+ month: "short",
525
+ day: "numeric"
526
+ })}`;
527
+ return {
528
+ metadata: {
529
+ businessName: params.businessName,
530
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
531
+ periodStart: startDate.toISOString().split("T")[0],
532
+ periodEnd: endDate.toISOString().split("T")[0],
533
+ displayPeriod: periodDisplay
534
+ },
535
+ revenue,
536
+ costOfSales,
537
+ grossProfit,
538
+ expenses,
539
+ operatingIncome,
540
+ netIncome
541
+ };
542
+ }
543
+
544
+ //#endregion
545
+ //#region src/reports/general-ledger.ts
546
+ async function generateGeneralLedger(opts, params) {
547
+ const { AccountModel, JournalEntryModel, country, orgField, fiscalYearStartMonth = 1 } = opts;
548
+ requireOrgScope(orgField, params.organizationId);
549
+ const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
550
+ const fiscalYearStart = getFiscalYearStart(startDate, fiscalYearStartMonth);
551
+ const itemFilters = buildItemFilters(params.filters);
552
+ const acctQuery = { active: true };
553
+ if (orgField && params.organizationId) acctQuery[orgField] = params.organizationId;
554
+ if (params.accountId) acctQuery._id = params.accountId;
555
+ const allAccounts = await AccountModel.find(acctQuery).lean();
556
+ const filtered = [];
557
+ for (const acc of allAccounts) {
558
+ const at = country.getAccountType(acc.accountTypeCode);
559
+ if (!at || at.isGroup || at.isTotal) continue;
560
+ filtered.push({
561
+ acc,
562
+ at
563
+ });
564
+ }
565
+ if (filtered.length === 0) return {
566
+ accounts: [],
567
+ period: {
568
+ startDate,
569
+ endDate
570
+ }
571
+ };
572
+ const bsAccountIds = [];
573
+ const isAccountIds = [];
574
+ const allAccountIds = [];
575
+ for (const { acc, at } of filtered) {
576
+ allAccountIds.push(acc._id);
577
+ if (at.category.startsWith("Balance Sheet")) bsAccountIds.push(acc._id);
578
+ else isAccountIds.push(acc._id);
579
+ }
580
+ const orgScope = {};
581
+ if (orgField && params.organizationId) orgScope[orgField] = params.organizationId;
582
+ const openingBalancePipeline = (accountIds, dateFilter) => accountIds.length > 0 ? JournalEntryModel.aggregate([
583
+ { $match: {
584
+ state: "posted",
585
+ date: dateFilter,
586
+ ...orgScope
587
+ } },
588
+ { $unwind: "$journalItems" },
589
+ { $match: {
590
+ "journalItems.account": { $in: accountIds },
591
+ ...itemFilters
592
+ } },
593
+ { $group: {
594
+ _id: "$journalItems.account",
595
+ d: { $sum: "$journalItems.debit" },
596
+ c: { $sum: "$journalItems.credit" }
597
+ } }
598
+ ]) : Promise.resolve([]);
599
+ const [bsOpenResults, isOpenResults, periodEntries] = await Promise.all([
600
+ openingBalancePipeline(bsAccountIds, { $lt: startDate }),
601
+ openingBalancePipeline(isAccountIds, {
602
+ $gte: fiscalYearStart,
603
+ $lt: startDate
604
+ }),
605
+ JournalEntryModel.find({
606
+ state: "posted",
607
+ date: {
608
+ $gte: startDate,
609
+ $lte: endDate
610
+ },
611
+ "journalItems.account": { $in: allAccountIds },
612
+ ...orgScope,
613
+ ...itemFilters
614
+ }).select("date referenceNumber label journalItems").sort({ date: 1 }).lean()
615
+ ]);
616
+ const openBalMap = /* @__PURE__ */ new Map();
617
+ for (const r of [...bsOpenResults, ...isOpenResults]) openBalMap.set(String(r._id), {
618
+ d: r.d,
619
+ c: r.c
620
+ });
621
+ const entryItemsByAccount = /* @__PURE__ */ new Map();
622
+ for (const entry of periodEntries) {
623
+ const items = entry.journalItems ?? [];
624
+ for (const item of items) {
625
+ const accId = String(item.account);
626
+ const debit = item.debit ?? 0;
627
+ const credit = item.credit ?? 0;
628
+ let list = entryItemsByAccount.get(accId);
629
+ if (!list) {
630
+ list = [];
631
+ entryItemsByAccount.set(accId, list);
632
+ }
633
+ list.push({
634
+ date: entry.date,
635
+ referenceNumber: entry.referenceNumber ?? "",
636
+ label: entry.label ?? "",
637
+ debit,
638
+ credit
639
+ });
640
+ }
641
+ }
642
+ const glAccounts = [];
643
+ for (const { acc, at } of filtered) {
644
+ const accIdStr = String(acc._id);
645
+ const openData = openBalMap.get(accIdStr);
646
+ const openingBalance = openData ? computeEndingBalance(at.category, openData.d, openData.c) : 0;
647
+ let runningBalance = openingBalance;
648
+ const entries = [];
649
+ const mainType = extractMainType(at.category);
650
+ const accountItems = entryItemsByAccount.get(accIdStr) ?? [];
651
+ for (const item of accountItems) {
652
+ const delta = mainType === "Asset" || mainType === "Expense" ? item.debit - item.credit : item.credit - item.debit;
653
+ runningBalance += delta;
654
+ entries.push({
655
+ date: item.date,
656
+ referenceNumber: item.referenceNumber,
657
+ label: item.label,
658
+ debit: item.debit,
659
+ credit: item.credit,
660
+ runningBalance
661
+ });
662
+ }
663
+ glAccounts.push({
664
+ account: acc,
665
+ openingBalance,
666
+ entries,
667
+ closingBalance: runningBalance
668
+ });
669
+ }
670
+ return {
671
+ accounts: glAccounts,
672
+ period: {
673
+ startDate,
674
+ endDate
675
+ }
676
+ };
677
+ }
678
+
679
+ //#endregion
680
+ //#region src/reports/cash-flow.ts
681
+ async function generateCashFlow(opts, params) {
682
+ const { AccountModel, JournalEntryModel, country, orgField } = opts;
683
+ requireOrgScope(orgField, params.organizationId);
684
+ const { startDate, endDate } = getDateRange(params.dateOption, params.dateValue);
685
+ const itemFilters = buildItemFilters(params.filters);
686
+ const q = { active: true };
687
+ if (orgField && params.organizationId) q[orgField] = params.organizationId;
688
+ const allAccounts = await AccountModel.find(q).lean();
689
+ const accountCfMap = /* @__PURE__ */ new Map();
690
+ const accountMap = new Map(allAccounts.map((a) => [String(a._id), a]));
691
+ const cfAccountIds = [];
692
+ for (const acc of allAccounts) {
693
+ const at = country.getAccountType(acc.accountTypeCode);
694
+ if (!at || at.isGroup || at.isTotal) continue;
695
+ const cf = at.cashFlowCategory;
696
+ if (!cf) continue;
697
+ const normalized = cf.charAt(0).toUpperCase() + cf.slice(1);
698
+ accountCfMap.set(String(acc._id), {
699
+ category: at.category,
700
+ cfCategory: normalized
701
+ });
702
+ cfAccountIds.push(acc._id);
703
+ }
704
+ const baseMatch = {
705
+ state: "posted",
706
+ date: {
707
+ $gte: startDate,
708
+ $lte: endDate
709
+ }
710
+ };
711
+ if (orgField && params.organizationId) baseMatch[orgField] = params.organizationId;
712
+ const results = cfAccountIds.length > 0 ? await JournalEntryModel.aggregate([
713
+ { $match: baseMatch },
714
+ { $unwind: "$journalItems" },
715
+ { $match: {
716
+ "journalItems.account": { $in: cfAccountIds },
717
+ ...itemFilters
718
+ } },
719
+ { $group: {
720
+ _id: "$journalItems.account",
721
+ d: { $sum: "$journalItems.debit" },
722
+ c: { $sum: "$journalItems.credit" }
723
+ } }
724
+ ]) : [];
725
+ const flows = {
726
+ Operating: {
727
+ total: 0,
728
+ accounts: []
729
+ },
730
+ Investing: {
731
+ total: 0,
732
+ accounts: []
733
+ },
734
+ Financing: {
735
+ total: 0,
736
+ accounts: []
737
+ }
738
+ };
739
+ for (const r of results) {
740
+ const accIdStr = String(r._id);
741
+ const meta = accountCfMap.get(accIdStr);
742
+ if (!meta) continue;
743
+ const amount = computeEndingBalance(meta.category, r.d, r.c);
744
+ const acc = accountMap.get(accIdStr);
745
+ const at = country.getAccountType(acc?.accountTypeCode);
746
+ flows[meta.cfCategory].accounts.push({
747
+ name: acc?.name ?? at?.name ?? "",
748
+ code: acc?.accountNumber ?? at?.code ?? "",
749
+ amount
750
+ });
751
+ flows[meta.cfCategory].total += amount;
752
+ }
753
+ const netCashFlow = flows.Operating.total + flows.Investing.total + flows.Financing.total;
754
+ const periodDisplay = `${startDate.toLocaleDateString("en-US", {
755
+ month: "short",
756
+ day: "numeric"
757
+ })} – ${endDate.toLocaleDateString("en-US", {
758
+ year: "numeric",
759
+ month: "short",
760
+ day: "numeric"
761
+ })}`;
762
+ return {
763
+ metadata: {
764
+ businessName: params.businessName,
765
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
766
+ periodStart: startDate.toISOString().split("T")[0],
767
+ periodEnd: endDate.toISOString().split("T")[0],
768
+ displayPeriod: periodDisplay
769
+ },
770
+ operating: flows.Operating,
771
+ investing: flows.Investing,
772
+ financing: flows.Financing,
773
+ netCashFlow
774
+ };
775
+ }
776
+
777
+ //#endregion
778
+ //#region src/reports/fiscal-close.ts
779
+ async function closeFiscalPeriod(opts, params) {
780
+ const { AccountModel, JournalEntryModel, FiscalPeriodModel, country, orgField, retainedEarningsCode = country.retainedEarningsCode ?? "3660", logger = defaultLogger } = opts;
781
+ const { periodId, organizationId, closedBy } = params;
782
+ requireOrgScope(orgField, organizationId);
783
+ const { session, ownSession } = await acquireSession(AccountModel.db, params.session, logger);
784
+ let success = false;
785
+ try {
786
+ const queryOpts = session ? { session } : {};
787
+ const periodQuery = { _id: periodId };
788
+ if (orgField && organizationId) periodQuery[orgField] = organizationId;
789
+ const period = await FiscalPeriodModel.findOne(periodQuery, null, queryOpts).lean();
790
+ if (!period) throw Errors.notFound("Fiscal period not found");
791
+ if (period.closed) throw Errors.fiscal("Fiscal period is already closed");
792
+ const startDate = period.startDate;
793
+ const endDate = period.endDate;
794
+ const accountQuery = { active: true };
795
+ if (orgField && organizationId) accountQuery[orgField] = organizationId;
796
+ const allAccounts = await AccountModel.find(accountQuery, null, queryOpts).lean();
797
+ const isAccounts = [];
798
+ let retainedEarningsId = null;
799
+ for (const acc of allAccounts) {
800
+ const at = country.getAccountType(acc.accountTypeCode);
801
+ if (!at) continue;
802
+ if (acc.accountTypeCode === retainedEarningsCode) retainedEarningsId = acc._id;
803
+ if (at.isGroup || at.isTotal) continue;
804
+ if (at.category.startsWith("Income Statement")) isAccounts.push({
805
+ id: acc._id,
806
+ code: at.code,
807
+ isIncome: at.category === "Income Statement-Income"
808
+ });
809
+ }
810
+ if (!retainedEarningsId) throw Errors.fiscal(`Retained earnings account (code: ${retainedEarningsCode}) not found. Create this account before closing the fiscal period.`);
811
+ const baseMatch = {
812
+ state: "posted",
813
+ date: {
814
+ $gte: startDate,
815
+ $lte: endDate
816
+ }
817
+ };
818
+ if (orgField && organizationId) baseMatch[orgField] = organizationId;
819
+ const isIds = isAccounts.map((a) => a.id);
820
+ const balances = isIds.length > 0 ? await JournalEntryModel.aggregate([
821
+ { $match: baseMatch },
822
+ { $unwind: "$journalItems" },
823
+ { $match: { "journalItems.account": { $in: isIds } } },
824
+ { $group: {
825
+ _id: "$journalItems.account",
826
+ d: { $sum: "$journalItems.debit" },
827
+ c: { $sum: "$journalItems.credit" }
828
+ } }
829
+ ], queryOpts) : [];
830
+ const closingItems = [];
831
+ let netIncome = 0;
832
+ const balMap = new Map(balances.map((b) => [String(b._id), b]));
833
+ for (const acc of isAccounts) {
834
+ const bal = balMap.get(String(acc.id));
835
+ if (!bal) continue;
836
+ const net = bal.c - bal.d;
837
+ if (net === 0) continue;
838
+ closingItems.push({
839
+ account: acc.id,
840
+ debit: net > 0 ? net : 0,
841
+ credit: net < 0 ? Math.abs(net) : 0,
842
+ label: `Close ${acc.code}`
843
+ });
844
+ netIncome += net;
845
+ }
846
+ let closingEntryId = null;
847
+ if (closingItems.length > 0) {
848
+ closingItems.push({
849
+ account: retainedEarningsId,
850
+ debit: netIncome < 0 ? Math.abs(netIncome) : 0,
851
+ credit: netIncome > 0 ? netIncome : 0,
852
+ label: "Transfer net income to retained earnings"
853
+ });
854
+ const totalDebit = closingItems.reduce((s, i) => s + i.debit, 0);
855
+ const totalCredit = closingItems.reduce((s, i) => s + i.credit, 0);
856
+ const closingEntryData = {
857
+ journalType: "YEAR_END",
858
+ state: "posted",
859
+ date: endDate,
860
+ label: `Fiscal year closing – ${period.name ?? "Period"}`,
861
+ journalItems: closingItems,
862
+ totalDebit,
863
+ totalCredit
864
+ };
865
+ if (orgField && organizationId) closingEntryData[orgField] = organizationId;
866
+ const [closingEntry] = await JournalEntryModel.create([closingEntryData], queryOpts);
867
+ closingEntryId = closingEntry._id;
868
+ }
869
+ const closedAt = /* @__PURE__ */ new Date();
870
+ await FiscalPeriodModel.findOneAndUpdate(periodQuery, {
871
+ closed: true,
872
+ closedAt,
873
+ closedBy: closedBy ?? null,
874
+ closingEntryId
875
+ }, queryOpts);
876
+ const result = {
877
+ periodId,
878
+ netIncome,
879
+ closingEntryId,
880
+ accountsClosed: closingItems.length - (closingItems.length > 0 ? 1 : 0),
881
+ closedAt
882
+ };
883
+ success = true;
884
+ return result;
885
+ } finally {
886
+ await finalizeSession(session, ownSession, success);
887
+ }
888
+ }
889
+ async function reopenFiscalPeriod(opts, params) {
890
+ const { JournalEntryModel, FiscalPeriodModel, orgField, logger = defaultLogger } = opts;
891
+ const { periodId, organizationId, reopenedBy } = params;
892
+ requireOrgScope(orgField, organizationId);
893
+ const db = (opts.AccountModel ?? FiscalPeriodModel).db;
894
+ const { session, ownSession } = await acquireSession(db, params.session, logger);
895
+ let success = false;
896
+ try {
897
+ const queryOpts = session ? { session } : {};
898
+ const periodQuery = { _id: periodId };
899
+ if (orgField && organizationId) periodQuery[orgField] = organizationId;
900
+ const period = await FiscalPeriodModel.findOne(periodQuery, null, queryOpts).lean();
901
+ if (!period) throw Errors.notFound("Fiscal period not found");
902
+ if (!period.closed) throw Errors.fiscal("Fiscal period is not closed");
903
+ const laterQuery = {
904
+ closed: true,
905
+ startDate: { $gt: period.endDate }
906
+ };
907
+ if (orgField && organizationId) laterQuery[orgField] = organizationId;
908
+ if (await FiscalPeriodModel.findOne(laterQuery, null, queryOpts).lean()) throw Errors.fiscal("Cannot reopen: a later fiscal period is already closed. Reopen later periods first.");
909
+ const closingEntryId = period.closingEntryId ?? null;
910
+ if (closingEntryId) await JournalEntryModel.findByIdAndDelete(closingEntryId, queryOpts);
911
+ const reopenedAt = /* @__PURE__ */ new Date();
912
+ await FiscalPeriodModel.findOneAndUpdate(periodQuery, {
913
+ closed: false,
914
+ closedAt: null,
915
+ closedBy: null,
916
+ closingEntryId: null,
917
+ reopenedAt,
918
+ reopenedBy: reopenedBy ?? null
919
+ }, queryOpts);
920
+ const result = {
921
+ periodId,
922
+ deletedEntryId: closingEntryId,
923
+ reopenedAt
924
+ };
925
+ success = true;
926
+ return result;
927
+ } finally {
928
+ await finalizeSession(session, ownSession, success);
929
+ }
930
+ }
931
+
932
+ //#endregion
933
+ 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 };
934
+ //# sourceMappingURL=fiscal-close-CNOwv_ud.mjs.map