@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.
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/account.repository-1C2sZvB2.d.mts +29 -0
- package/dist/account.repository-1C2sZvB2.d.mts.map +1 -0
- package/dist/account.repository-Crf5DGO4.mjs +393 -0
- package/dist/account.repository-Crf5DGO4.mjs.map +1 -0
- package/dist/categories-BNJBd4ze.mjs +70 -0
- package/dist/categories-BNJBd4ze.mjs.map +1 -0
- package/dist/constants/index.d.mts +2 -0
- package/dist/constants/index.mjs +5 -0
- package/dist/core-Cx0baosR.d.mts +104 -0
- package/dist/core-Cx0baosR.d.mts.map +1 -0
- package/dist/country/index.d.mts +105 -0
- package/dist/country/index.d.mts.map +1 -0
- package/dist/country/index.mjs +27 -0
- package/dist/country/index.mjs.map +1 -0
- package/dist/currencies-BBk3NwXn.mjs +82 -0
- package/dist/currencies-BBk3NwXn.mjs.map +1 -0
- package/dist/currencies-Bkn3FNkC.d.mts +38 -0
- package/dist/currencies-Bkn3FNkC.d.mts.map +1 -0
- package/dist/engine-Cd73EOT6.d.mts +72 -0
- package/dist/engine-Cd73EOT6.d.mts.map +1 -0
- package/dist/errors-CeqRahE-.mjs +28 -0
- package/dist/errors-CeqRahE-.mjs.map +1 -0
- package/dist/exports/index.d.mts +2 -0
- package/dist/exports/index.mjs +3 -0
- package/dist/fiscal-close-CNOwv_ud.mjs +934 -0
- package/dist/fiscal-close-CNOwv_ud.mjs.map +1 -0
- package/dist/fiscal-close-CzUzpnMg.d.mts +270 -0
- package/dist/fiscal-close-CzUzpnMg.d.mts.map +1 -0
- package/dist/fiscal-period.schema-CbALaaKl.mjs +477 -0
- package/dist/fiscal-period.schema-CbALaaKl.mjs.map +1 -0
- package/dist/fiscal-period.schema-DI2scngu.d.mts +38 -0
- package/dist/fiscal-period.schema-DI2scngu.d.mts.map +1 -0
- package/dist/idempotency.plugin-BESs9YPD.d.mts +58 -0
- package/dist/idempotency.plugin-BESs9YPD.d.mts.map +1 -0
- package/dist/idempotency.plugin-C6r8RI8d.mjs +165 -0
- package/dist/idempotency.plugin-C6r8RI8d.mjs.map +1 -0
- package/dist/index.d.mts +308 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +171 -0
- package/dist/index.mjs.map +1 -0
- package/dist/journals-CI3Wb4EF.mjs +92 -0
- package/dist/journals-CI3Wb4EF.mjs.map +1 -0
- package/dist/logger-Cv6VVc4r.d.mts +15 -0
- package/dist/logger-Cv6VVc4r.d.mts.map +1 -0
- package/dist/money.d.mts +129 -0
- package/dist/money.d.mts.map +1 -0
- package/dist/money.mjs +197 -0
- package/dist/money.mjs.map +1 -0
- package/dist/plugins/index.d.mts +2 -0
- package/dist/plugins/index.mjs +3 -0
- package/dist/reports/index.d.mts +2 -0
- package/dist/reports/index.mjs +3 -0
- package/dist/repositories/index.d.mts +2 -0
- package/dist/repositories/index.mjs +3 -0
- package/dist/schemas/index.d.mts +2 -0
- package/dist/schemas/index.mjs +3 -0
- package/dist/session-Dh0s6zG4.mjs +87 -0
- package/dist/session-Dh0s6zG4.mjs.map +1 -0
- package/dist/universal-CMfrZ2hG.mjs +257 -0
- package/dist/universal-CMfrZ2hG.mjs.map +1 -0
- package/dist/universal-x33ZJODp.d.mts +137 -0
- package/dist/universal-x33ZJODp.d.mts.map +1 -0
- package/docs/country-packs.md +117 -0
- package/docs/engine.md +147 -0
- package/docs/exports.md +81 -0
- package/docs/money.md +81 -0
- package/docs/plugins.md +136 -0
- package/docs/reports.md +154 -0
- package/docs/repositories.md +239 -0
- package/docs/schemas.md +146 -0
- package/docs/subledger-integration.md +287 -0
- 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
|