@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.
- package/README.md +82 -20
- package/dist/constants/index.d.mts +2 -2
- package/dist/constants/index.mjs +3 -3
- package/dist/{date-lock.plugin-eYAJ9h_u.mjs → date-lock.plugin-DL6pe24p.mjs} +2 -2
- package/dist/{engine-Cn-9yerQ.d.mts → engine-scgOvxHJ.d.mts} +30 -2
- package/dist/exports/index.d.mts +1 -1
- package/dist/exports/index.mjs +1 -1
- package/dist/{exports-I5Xkq-9_.mjs → exports-DoGQQtMQ.mjs} +96 -75
- package/dist/{fiscal-close-B6LhQ10f.mjs → fiscal-close-B2_7WMTe.mjs} +748 -751
- package/dist/{index-BPukb3L8.d.mts → index-J-XIbXH-.d.mts} +7 -7
- package/dist/index.d.mts +239 -87
- package/dist/index.mjs +149 -12
- package/dist/{fiscal-period.schema-BMnlI9H5.d.mts → journal-entry.schema-JqrfbvB4.d.mts} +12 -12
- package/dist/{journals-oH-FK3g8.mjs → journals-BfwnCFam.mjs} +27 -4
- package/dist/{currencies-4WAbFRlw.d.mts → journals-DTipb_rz.d.mts} +16 -7
- package/dist/money.mjs +2 -2
- package/dist/plugins/index.d.mts +1 -1
- package/dist/plugins/index.mjs +1 -1
- package/dist/{reconciliation.repository-CW4-8q90.d.mts → reconciliation.repository-D-D_ITL-.d.mts} +14 -14
- package/dist/{account.repository-BpkSd6q3.mjs → reconciliation.repository-fPwFKvrk.mjs} +255 -255
- package/dist/{reconciliation.schema-BuetvZTd.mjs → reconciliation.schema-BA1lPv4t.mjs} +174 -173
- package/dist/reports/index.d.mts +1 -1
- package/dist/reports/index.mjs +1 -1
- package/dist/repositories/index.d.mts +1 -1
- package/dist/repositories/index.mjs +1 -1
- package/dist/schemas/index.d.mts +6 -6
- package/dist/schemas/index.mjs +1 -1
- package/dist/{tenant-guard-Fm6AID_6.mjs → tenant-guard-r17Se3Bb.mjs} +1 -1
- package/dist/{revaluation-D9x0NE8w.d.mts → trial-balance-DcQ0xj_4.d.mts} +124 -124
- package/docs/schemas.md +2 -2
- package/package.json +14 -6
- /package/dist/{categories-CclX7Q94.mjs → categories-DWogBUgQ.mjs} +0 -0
- /package/dist/{errors-B7yC-Jfw.mjs → errors-B_dyYZc_.mjs} +0 -0
- /package/dist/{idempotency.plugin-B_CNsInz.d.mts → idempotency.plugin-zU-GKJ0-.d.mts} +0 -0
- /package/dist/{logger-CbHWZl7v.d.mts → logger-UbTdBb1x.d.mts} +0 -0
|
@@ -1,7 +1,183 @@
|
|
|
1
|
-
import { n as Errors } from "./errors-
|
|
2
|
-
import { t as requireOrgScope } from "./tenant-guard-
|
|
3
|
-
import { i as extractMainType } from "./categories-
|
|
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/
|
|
126
|
-
async function
|
|
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 {
|
|
130
|
-
const fiscalYearStart = getFiscalYearStart(
|
|
305
|
+
const { endDate } = getDateRange(params.dateOption, params.dateValue);
|
|
306
|
+
const fiscalYearStart = getFiscalYearStart(endDate, fiscalYearStartMonth);
|
|
131
307
|
const itemFilters = buildItemFilters(params.filters);
|
|
132
|
-
const
|
|
133
|
-
if (orgField && params.organizationId)
|
|
134
|
-
const allAccounts = await AccountModel.find(
|
|
135
|
-
const bsIds =
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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/
|
|
507
|
-
async function
|
|
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
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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:
|
|
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
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
-
|
|
1008
|
-
|
|
705
|
+
periodStart: startDate.toISOString().split("T")[0],
|
|
706
|
+
periodEnd: endDate.toISOString().split("T")[0],
|
|
707
|
+
displayPeriod: periodDisplay
|
|
1009
708
|
},
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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)
|
|
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/
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
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
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
totalVariance: 0
|
|
972
|
+
accounts: glAccounts,
|
|
973
|
+
period: {
|
|
974
|
+
startDate,
|
|
975
|
+
endDate
|
|
1152
976
|
}
|
|
1153
977
|
};
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
const
|
|
1161
|
-
|
|
1162
|
-
const
|
|
1163
|
-
|
|
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
|
|
1001
|
+
const results = await JournalEntryModel.aggregate([
|
|
1173
1002
|
{ $match: baseMatch },
|
|
1174
1003
|
{ $unwind: "$journalItems" },
|
|
1175
|
-
{ $match: {
|
|
1004
|
+
{ $match: {
|
|
1005
|
+
"journalItems.account": { $in: isIds },
|
|
1006
|
+
...itemFilters
|
|
1007
|
+
} },
|
|
1176
1008
|
{ $group: {
|
|
1177
1009
|
_id: "$journalItems.account",
|
|
1178
|
-
|
|
1179
|
-
|
|
1010
|
+
d: { $sum: "$journalItems.debit" },
|
|
1011
|
+
c: { $sum: "$journalItems.credit" }
|
|
1180
1012
|
} }
|
|
1181
|
-
];
|
|
1182
|
-
const
|
|
1183
|
-
const
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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
|
|
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
|
-
|
|
1200
|
-
if (
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
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
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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
|
-
|
|
1230
|
-
|
|
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 {
|
|
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 };
|