@classytic/ledger 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +227 -189
- package/dist/constants/index.d.mts +1 -1
- package/dist/constants/index.mjs +2 -3
- package/dist/country/index.d.mts +1 -1
- package/dist/{journals-BfwnCFam.mjs → currencies-CsuBGfgs.mjs} +80 -1
- package/dist/{date-lock.plugin-DL6pe24p.mjs → date-lock.plugin-B2Jy0ukX.mjs} +61 -10
- package/dist/errors-BmRjW38t.mjs +33 -0
- package/dist/exports/index.d.mts +1 -1
- package/dist/exports/index.mjs +1 -1
- package/dist/{fiscal-close-B2_7WMTe.mjs → fiscal-close-Dk3yRT9i.mjs} +14 -4
- package/dist/{index-CxZqRaOU.d.mts → index-GmfEFxVn.d.mts} +1 -1
- package/dist/index.d.mts +530 -338
- package/dist/index.mjs +1824 -175
- package/dist/{journals-DTipb_rz.d.mts → journals-C50E9mpo.d.mts} +1 -1
- package/dist/plugins/index.d.mts +1 -1
- package/dist/plugins/index.mjs +1 -1
- package/dist/reports/index.d.mts +1 -1
- package/dist/reports/index.mjs +1 -1
- package/dist/{trial-balance-DcQ0xj_4.d.mts → trial-balance-BZ7yOOFD.d.mts} +16 -4
- package/package.json +1 -11
- package/dist/currencies-W8kQAkm0.mjs +0 -80
- package/dist/engine-scgOvxHJ.d.mts +0 -130
- package/dist/errors-B_dyYZc_.mjs +0 -26
- package/dist/journal-entry.schema-JqrfbvB4.d.mts +0 -103
- package/dist/logger-UbTdBb1x.d.mts +0 -14
- package/dist/reconciliation.repository-D-D_ITL-.d.mts +0 -135
- package/dist/reconciliation.repository-fPwFKvrk.mjs +0 -542
- package/dist/reconciliation.schema-BA1lPv4t.mjs +0 -666
- package/dist/repositories/index.d.mts +0 -2
- package/dist/repositories/index.mjs +0 -2
- package/dist/schemas/index.d.mts +0 -71
- package/dist/schemas/index.mjs +0 -2
- package/dist/tenant-guard-r17Se3Bb.mjs +0 -13
- /package/dist/{categories-DWogBUgQ.mjs → categories-BkKdv16V.mjs} +0 -0
- /package/dist/{core-8Xfnpn6g.d.mts → core-BkGjuVZj.d.mts} +0 -0
- /package/dist/{exports-DoGQQtMQ.mjs → exports-BP-0Ni5W.mjs} +0 -0
- /package/dist/{idempotency.plugin-zU-GKJ0-.d.mts → idempotency.plugin-CK7LHnBn.d.mts} +0 -0
- /package/dist/{index-J-XIbXH-.d.mts → index-D1ZjgVxn.d.mts} +0 -0
package/dist/index.mjs
CHANGED
|
@@ -1,16 +1,676 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
import { a as getJournalType, c as registerJournalType, i as getCustomJournalTypes, n as JOURNAL_TYPES, o as getJournalTypeCodes, s as isValidJournalType, t as JOURNAL_CODES } from "./journals-BfwnCFam.mjs";
|
|
1
|
+
import { a as JOURNAL_CODES, c as getCustomJournalTypes, d as isValidJournalType, f as registerJournalType, i as isValidCurrency, l as getJournalType, n as getCurrency, o as JOURNAL_TYPES, r as getMinorUnit, s as _freezeJournalTypes, t as CURRENCIES, u as getJournalTypeCodes } from "./currencies-CsuBGfgs.mjs";
|
|
3
2
|
import { Money, add, allocate, format, formatPlain, fromDecimal, multiply, parseCents, percentage, splitTaxExclusive, splitTaxInclusive, subtract, toDecimal } from "./money.mjs";
|
|
4
|
-
import { n as Errors, t as AccountingError } from "./errors-
|
|
5
|
-
import { i as
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { n as wireJournalEntryMethods, r as wireAccountMethods, t as wireReconciliationMethods } from "./reconciliation.repository-fPwFKvrk.mjs";
|
|
9
|
-
import { i as isValidCurrency, n as getCurrency, r as getMinorUnit, t as CURRENCIES } from "./currencies-W8kQAkm0.mjs";
|
|
3
|
+
import { n as Errors, t as AccountingError } from "./errors-BmRjW38t.mjs";
|
|
4
|
+
import { C as DEFAULT_BUCKETS, S as isVirtualTaxAccount, T as requireOrgScope, _ as getDateRange, a as defaultLogger, b as calculateTotal, c as buildRevaluationEntry, d as generateGeneralLedger, f as generateDimensionBreakdown, g as buildItemFilters, h as generateBalanceSheet, i as finalizeSession, l as computeRevaluation, m as generateBudgetVsActual, n as reopenFiscalPeriod, o as generateTrialBalance, p as generateCashFlow, r as acquireSession, s as generateRevaluation, t as closeFiscalPeriod, u as generateIncomeStatement, v as getFiscalYearStart, w as generateAgedBalance, x as computeEndingBalance, y as buildAccountTypeMap } from "./fiscal-close-Dk3yRT9i.mjs";
|
|
5
|
+
import { c as getNormalBalance, d as isValidCategory, l as isBalanceSheet, n as CATEGORY_KEYS, t as CATEGORIES, u as isIncomeStatement } from "./categories-BkKdv16V.mjs";
|
|
6
|
+
import { i as doubleEntryPlugin, n as idempotencyPlugin, r as fiscalLockPlugin, t as dateLockPlugin } from "./date-lock.plugin-B2Jy0ukX.mjs";
|
|
10
7
|
import { defineCountryPack } from "./country/index.mjs";
|
|
11
|
-
import { a as exportToCsv, i as quickbooksFieldMap, r as universalFieldMap, t as flattenJournalEntries } from "./exports-
|
|
12
|
-
import { Schema } from "mongoose";
|
|
8
|
+
import { a as exportToCsv, i as quickbooksFieldMap, r as universalFieldMap, t as flattenJournalEntries } from "./exports-BP-0Ni5W.mjs";
|
|
9
|
+
import mongoose, { Schema } from "mongoose";
|
|
13
10
|
import { Repository } from "@classytic/mongokit";
|
|
11
|
+
//#region src/schemas/currency-field.ts
|
|
12
|
+
/**
|
|
13
|
+
* Build the Mongoose currency field definition.
|
|
14
|
+
* Returns `null` if multi-currency is not enabled.
|
|
15
|
+
*/
|
|
16
|
+
function buildCurrencyField(config) {
|
|
17
|
+
if (!config.multiCurrency?.enabled) return null;
|
|
18
|
+
const allowed = config.multiCurrency.currencies;
|
|
19
|
+
return {
|
|
20
|
+
type: String,
|
|
21
|
+
default: null,
|
|
22
|
+
...allowed?.length ? { enum: [
|
|
23
|
+
null,
|
|
24
|
+
config.currency,
|
|
25
|
+
...allowed
|
|
26
|
+
] } : {}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/schemas/account.schema.ts
|
|
31
|
+
/**
|
|
32
|
+
* Account Schema Factory
|
|
33
|
+
*
|
|
34
|
+
* Creates a Mongoose schema for Chart of Accounts that is:
|
|
35
|
+
* - Multi-tenant aware (adds org field + compound indexes when configured)
|
|
36
|
+
* - Validates accountTypeCode against the country pack
|
|
37
|
+
* - Supports accountNumber (unique per org) and name (user-facing display)
|
|
38
|
+
* - Lean: no cached balances — always computed from journal entries
|
|
39
|
+
*/
|
|
40
|
+
function createAccountSchema(config, options = {}) {
|
|
41
|
+
const { multiTenant, country } = config;
|
|
42
|
+
const { indexes = true, extraFields = {}, extraIndexes = [] } = options;
|
|
43
|
+
const fields = {
|
|
44
|
+
accountTypeCode: {
|
|
45
|
+
type: String,
|
|
46
|
+
required: true,
|
|
47
|
+
validate: {
|
|
48
|
+
validator: (code) => country.isValidAccountType(code),
|
|
49
|
+
message: (props) => `"${props.value}" is not a valid account type code for ${country.name}.`
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
accountNumber: {
|
|
53
|
+
type: String,
|
|
54
|
+
required: true,
|
|
55
|
+
trim: true
|
|
56
|
+
},
|
|
57
|
+
name: {
|
|
58
|
+
type: String,
|
|
59
|
+
required: true,
|
|
60
|
+
trim: true
|
|
61
|
+
},
|
|
62
|
+
active: {
|
|
63
|
+
type: Boolean,
|
|
64
|
+
default: true
|
|
65
|
+
},
|
|
66
|
+
isCashAccount: {
|
|
67
|
+
type: Boolean,
|
|
68
|
+
default: false
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const currencyField = buildCurrencyField(config);
|
|
72
|
+
if (currencyField) fields.currency = currencyField;
|
|
73
|
+
Object.assign(fields, extraFields);
|
|
74
|
+
if (multiTenant) fields[multiTenant.orgField] = {
|
|
75
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
76
|
+
ref: multiTenant.orgRef,
|
|
77
|
+
required: true
|
|
78
|
+
};
|
|
79
|
+
const schema = new mongoose.Schema(fields, { timestamps: true });
|
|
80
|
+
schema.pre("validate", function() {
|
|
81
|
+
if (!this.accountNumber && this.accountTypeCode) this.accountNumber = this.accountTypeCode;
|
|
82
|
+
if (!this.name && this.accountTypeCode) this.name = country.getAccountType(this.accountTypeCode)?.name ?? this.accountTypeCode;
|
|
83
|
+
});
|
|
84
|
+
if (indexes) if (multiTenant) {
|
|
85
|
+
const org = multiTenant.orgField;
|
|
86
|
+
schema.index({
|
|
87
|
+
[org]: 1,
|
|
88
|
+
active: 1
|
|
89
|
+
});
|
|
90
|
+
schema.index({
|
|
91
|
+
[org]: 1,
|
|
92
|
+
accountNumber: 1
|
|
93
|
+
}, { unique: true });
|
|
94
|
+
schema.index({
|
|
95
|
+
[org]: 1,
|
|
96
|
+
accountTypeCode: 1
|
|
97
|
+
});
|
|
98
|
+
} else {
|
|
99
|
+
schema.index({ active: 1 });
|
|
100
|
+
schema.index({ accountNumber: 1 }, { unique: true });
|
|
101
|
+
schema.index({ accountTypeCode: 1 });
|
|
102
|
+
}
|
|
103
|
+
for (const idx of extraIndexes) schema.index(idx.fields, idx.options);
|
|
104
|
+
return schema;
|
|
105
|
+
}
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region src/schemas/budget.schema.ts
|
|
108
|
+
/**
|
|
109
|
+
* Budget Schema Factory
|
|
110
|
+
*
|
|
111
|
+
* Creates a Mongoose schema for budget records.
|
|
112
|
+
* Each record represents a budgeted amount for an account over a specific period.
|
|
113
|
+
* All monetary amounts are in integer cents.
|
|
114
|
+
*/
|
|
115
|
+
function createBudgetSchema(config, options = {}) {
|
|
116
|
+
const { multiTenant } = config;
|
|
117
|
+
const { indexes = true, extraFields = {}, extraIndexes = [] } = options;
|
|
118
|
+
const fields = {
|
|
119
|
+
account: {
|
|
120
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
121
|
+
ref: "Account",
|
|
122
|
+
required: true
|
|
123
|
+
},
|
|
124
|
+
periodStart: {
|
|
125
|
+
type: Date,
|
|
126
|
+
required: true
|
|
127
|
+
},
|
|
128
|
+
periodEnd: {
|
|
129
|
+
type: Date,
|
|
130
|
+
required: true
|
|
131
|
+
},
|
|
132
|
+
amount: {
|
|
133
|
+
type: Number,
|
|
134
|
+
required: true,
|
|
135
|
+
validate: {
|
|
136
|
+
validator: (v) => Number.isInteger(v),
|
|
137
|
+
message: "amount must be an integer (cents)."
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
label: {
|
|
141
|
+
type: String,
|
|
142
|
+
default: null
|
|
143
|
+
},
|
|
144
|
+
...extraFields
|
|
145
|
+
};
|
|
146
|
+
if (multiTenant) fields[multiTenant.orgField] = {
|
|
147
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
148
|
+
ref: multiTenant.orgRef,
|
|
149
|
+
required: true
|
|
150
|
+
};
|
|
151
|
+
const schema = new mongoose.Schema(fields, { timestamps: true });
|
|
152
|
+
schema.pre("validate", function() {
|
|
153
|
+
const doc = this;
|
|
154
|
+
if (doc.periodStart && doc.periodEnd && doc.periodEnd <= doc.periodStart) doc.invalidate("periodEnd", "periodEnd must be after periodStart.", doc.periodEnd, "periodEnd");
|
|
155
|
+
});
|
|
156
|
+
if (indexes) if (multiTenant) {
|
|
157
|
+
const org = multiTenant.orgField;
|
|
158
|
+
schema.index({
|
|
159
|
+
[org]: 1,
|
|
160
|
+
account: 1,
|
|
161
|
+
periodStart: 1,
|
|
162
|
+
periodEnd: 1
|
|
163
|
+
}, { unique: true });
|
|
164
|
+
schema.index({
|
|
165
|
+
[org]: 1,
|
|
166
|
+
periodStart: 1,
|
|
167
|
+
periodEnd: 1
|
|
168
|
+
});
|
|
169
|
+
} else {
|
|
170
|
+
schema.index({
|
|
171
|
+
account: 1,
|
|
172
|
+
periodStart: 1,
|
|
173
|
+
periodEnd: 1
|
|
174
|
+
}, { unique: true });
|
|
175
|
+
schema.index({
|
|
176
|
+
periodStart: 1,
|
|
177
|
+
periodEnd: 1
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
for (const idx of extraIndexes) schema.index(idx.fields, idx.options);
|
|
181
|
+
return schema;
|
|
182
|
+
}
|
|
183
|
+
//#endregion
|
|
184
|
+
//#region src/schemas/fiscal-period.schema.ts
|
|
185
|
+
/**
|
|
186
|
+
* Fiscal Period Schema Factory
|
|
187
|
+
*
|
|
188
|
+
* Creates a Mongoose schema for tracking fiscal periods (months, quarters, years).
|
|
189
|
+
* Supports closing periods to lock entries.
|
|
190
|
+
*/
|
|
191
|
+
function createFiscalPeriodSchema(config, options = {}) {
|
|
192
|
+
const { multiTenant } = config;
|
|
193
|
+
const { indexes = true, extraFields = {}, extraIndexes = [] } = options;
|
|
194
|
+
const fields = {
|
|
195
|
+
name: {
|
|
196
|
+
type: String,
|
|
197
|
+
required: true
|
|
198
|
+
},
|
|
199
|
+
startDate: {
|
|
200
|
+
type: Date,
|
|
201
|
+
required: true
|
|
202
|
+
},
|
|
203
|
+
endDate: {
|
|
204
|
+
type: Date,
|
|
205
|
+
required: true
|
|
206
|
+
},
|
|
207
|
+
closed: {
|
|
208
|
+
type: Boolean,
|
|
209
|
+
default: false
|
|
210
|
+
},
|
|
211
|
+
closedAt: {
|
|
212
|
+
type: Date,
|
|
213
|
+
default: null
|
|
214
|
+
},
|
|
215
|
+
closedBy: {
|
|
216
|
+
type: String,
|
|
217
|
+
default: null
|
|
218
|
+
},
|
|
219
|
+
closingEntryId: {
|
|
220
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
221
|
+
default: null
|
|
222
|
+
},
|
|
223
|
+
reopenedAt: {
|
|
224
|
+
type: Date,
|
|
225
|
+
default: null
|
|
226
|
+
},
|
|
227
|
+
reopenedBy: {
|
|
228
|
+
type: String,
|
|
229
|
+
default: null
|
|
230
|
+
},
|
|
231
|
+
...extraFields
|
|
232
|
+
};
|
|
233
|
+
if (multiTenant) fields[multiTenant.orgField] = {
|
|
234
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
235
|
+
ref: multiTenant.orgRef,
|
|
236
|
+
required: true
|
|
237
|
+
};
|
|
238
|
+
const schema = new mongoose.Schema(fields, { timestamps: true });
|
|
239
|
+
if (indexes) if (multiTenant) {
|
|
240
|
+
const org = multiTenant.orgField;
|
|
241
|
+
schema.index({
|
|
242
|
+
[org]: 1,
|
|
243
|
+
startDate: 1,
|
|
244
|
+
endDate: 1
|
|
245
|
+
}, { unique: true });
|
|
246
|
+
schema.index({
|
|
247
|
+
[org]: 1,
|
|
248
|
+
closed: 1
|
|
249
|
+
});
|
|
250
|
+
} else {
|
|
251
|
+
schema.index({
|
|
252
|
+
startDate: 1,
|
|
253
|
+
endDate: 1
|
|
254
|
+
}, { unique: true });
|
|
255
|
+
schema.index({ closed: 1 });
|
|
256
|
+
}
|
|
257
|
+
for (const idx of extraIndexes) schema.index(idx.fields, idx.options);
|
|
258
|
+
schema.pre("validate", async function() {
|
|
259
|
+
const doc = this;
|
|
260
|
+
if (!doc.startDate || !doc.endDate) return;
|
|
261
|
+
const overlapQuery = {
|
|
262
|
+
_id: { $ne: doc._id },
|
|
263
|
+
startDate: { $lt: doc.endDate },
|
|
264
|
+
endDate: { $gt: doc.startDate }
|
|
265
|
+
};
|
|
266
|
+
if (multiTenant) overlapQuery[multiTenant.orgField] = doc[multiTenant.orgField];
|
|
267
|
+
const overlap = await doc.collection.findOne(overlapQuery);
|
|
268
|
+
if (overlap) {
|
|
269
|
+
const msg = `Fiscal period overlaps with existing period "${overlap.name}" (${new Date(overlap.startDate).toISOString().split("T")[0]} – ${new Date(overlap.endDate).toISOString().split("T")[0]}).`;
|
|
270
|
+
doc.invalidate("startDate", msg, doc.startDate, "overlap");
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
return schema;
|
|
274
|
+
}
|
|
275
|
+
//#endregion
|
|
276
|
+
//#region src/schemas/journal-entry.schema.ts
|
|
277
|
+
/**
|
|
278
|
+
* Journal Entry Schema Factory
|
|
279
|
+
*
|
|
280
|
+
* Creates a Mongoose schema for double-entry journal entries.
|
|
281
|
+
* - Multi-tenant aware
|
|
282
|
+
* - Embedded journal items with account refs
|
|
283
|
+
* - State machine: draft → posted, draft → archived
|
|
284
|
+
* - Auto-generated reference numbers
|
|
285
|
+
* - Double-entry validation on post
|
|
286
|
+
* - Optimized indexes for high-load reporting
|
|
287
|
+
*/
|
|
288
|
+
function createJournalEntrySchema(config, accountModelName, options = {}) {
|
|
289
|
+
const { multiTenant } = config;
|
|
290
|
+
const { indexes = true, autoReference = true, textSearch = true, extraFields = {}, extraIndexes = [], extraItemFields = {} } = options;
|
|
291
|
+
const TaxDetailSchema = new mongoose.Schema({
|
|
292
|
+
taxCode: { type: String },
|
|
293
|
+
taxName: { type: String }
|
|
294
|
+
}, { _id: false });
|
|
295
|
+
const amountValidator = {
|
|
296
|
+
validator: (v) => Number.isInteger(v) && v >= 0,
|
|
297
|
+
message: "{PATH} must be a non-negative integer (cents), got {VALUE}"
|
|
298
|
+
};
|
|
299
|
+
const currencyItemFields = {};
|
|
300
|
+
const currencyField = buildCurrencyField(config);
|
|
301
|
+
if (currencyField) {
|
|
302
|
+
currencyItemFields.currency = currencyField;
|
|
303
|
+
currencyItemFields.exchangeRate = {
|
|
304
|
+
type: Number,
|
|
305
|
+
default: null,
|
|
306
|
+
validate: {
|
|
307
|
+
validator: (v) => v === null || v > 0,
|
|
308
|
+
message: "exchangeRate must be greater than zero when set, got {VALUE}"
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
currencyItemFields.originalDebit = {
|
|
312
|
+
type: Number,
|
|
313
|
+
default: null,
|
|
314
|
+
min: 0,
|
|
315
|
+
validate: amountValidator
|
|
316
|
+
};
|
|
317
|
+
currencyItemFields.originalCredit = {
|
|
318
|
+
type: Number,
|
|
319
|
+
default: null,
|
|
320
|
+
min: 0,
|
|
321
|
+
validate: amountValidator
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
const JournalItemSchema = new mongoose.Schema({
|
|
325
|
+
account: {
|
|
326
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
327
|
+
ref: accountModelName,
|
|
328
|
+
required: true
|
|
329
|
+
},
|
|
330
|
+
label: { type: String },
|
|
331
|
+
date: { type: Date },
|
|
332
|
+
debit: {
|
|
333
|
+
type: Number,
|
|
334
|
+
default: 0,
|
|
335
|
+
min: 0,
|
|
336
|
+
validate: amountValidator
|
|
337
|
+
},
|
|
338
|
+
credit: {
|
|
339
|
+
type: Number,
|
|
340
|
+
default: 0,
|
|
341
|
+
min: 0,
|
|
342
|
+
validate: amountValidator
|
|
343
|
+
},
|
|
344
|
+
taxDetails: {
|
|
345
|
+
type: [TaxDetailSchema],
|
|
346
|
+
default: []
|
|
347
|
+
},
|
|
348
|
+
...currencyItemFields,
|
|
349
|
+
...extraItemFields
|
|
350
|
+
}, { _id: false });
|
|
351
|
+
_freezeJournalTypes();
|
|
352
|
+
const fields = {
|
|
353
|
+
journalType: {
|
|
354
|
+
type: String,
|
|
355
|
+
enum: getJournalTypeCodes(),
|
|
356
|
+
default: JOURNAL_CODES.MISC,
|
|
357
|
+
required: true
|
|
358
|
+
},
|
|
359
|
+
referenceNumber: { type: String },
|
|
360
|
+
label: { type: String },
|
|
361
|
+
date: {
|
|
362
|
+
type: Date,
|
|
363
|
+
default: Date.now,
|
|
364
|
+
required: function() {
|
|
365
|
+
return this.state !== "draft";
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
journalItems: {
|
|
369
|
+
type: [JournalItemSchema],
|
|
370
|
+
default: []
|
|
371
|
+
},
|
|
372
|
+
totalDebit: {
|
|
373
|
+
type: Number,
|
|
374
|
+
required: true,
|
|
375
|
+
min: 0,
|
|
376
|
+
validate: {
|
|
377
|
+
validator: Number.isInteger,
|
|
378
|
+
message: "totalDebit must be an integer (cents)"
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
totalCredit: {
|
|
382
|
+
type: Number,
|
|
383
|
+
required: true,
|
|
384
|
+
min: 0,
|
|
385
|
+
validate: {
|
|
386
|
+
validator: Number.isInteger,
|
|
387
|
+
message: "totalCredit must be an integer (cents)"
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
state: {
|
|
391
|
+
type: String,
|
|
392
|
+
enum: [
|
|
393
|
+
"draft",
|
|
394
|
+
"posted",
|
|
395
|
+
"archived"
|
|
396
|
+
],
|
|
397
|
+
default: "draft",
|
|
398
|
+
required: true
|
|
399
|
+
},
|
|
400
|
+
stateChangedAt: {
|
|
401
|
+
type: Date,
|
|
402
|
+
default: Date.now
|
|
403
|
+
},
|
|
404
|
+
reversed: {
|
|
405
|
+
type: Boolean,
|
|
406
|
+
default: false
|
|
407
|
+
},
|
|
408
|
+
reversedBy: {
|
|
409
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
410
|
+
ref: "JournalEntry",
|
|
411
|
+
default: null
|
|
412
|
+
},
|
|
413
|
+
reversalOf: {
|
|
414
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
415
|
+
ref: "JournalEntry",
|
|
416
|
+
default: null
|
|
417
|
+
},
|
|
418
|
+
...extraFields
|
|
419
|
+
};
|
|
420
|
+
if (config.audit?.trackActor) {
|
|
421
|
+
fields.createdBy = {
|
|
422
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
423
|
+
default: null
|
|
424
|
+
};
|
|
425
|
+
fields.postedBy = {
|
|
426
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
427
|
+
default: null
|
|
428
|
+
};
|
|
429
|
+
fields.reversedByUser = {
|
|
430
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
431
|
+
default: null
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
if (config.strictness?.requireApproval || config.audit?.trackActor) {
|
|
435
|
+
fields.approvedBy = {
|
|
436
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
437
|
+
default: null
|
|
438
|
+
};
|
|
439
|
+
fields.approvedAt = {
|
|
440
|
+
type: Date,
|
|
441
|
+
default: null
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
if (config.idempotency) fields.idempotencyKey = {
|
|
445
|
+
type: String,
|
|
446
|
+
default: null
|
|
447
|
+
};
|
|
448
|
+
if (multiTenant) fields[multiTenant.orgField] = {
|
|
449
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
450
|
+
ref: multiTenant.orgRef,
|
|
451
|
+
required: true
|
|
452
|
+
};
|
|
453
|
+
const schema = new mongoose.Schema(fields, { timestamps: true });
|
|
454
|
+
schema.pre("validate", function() {
|
|
455
|
+
for (const item of this.journalItems) if (!item.date) item.date = this.date;
|
|
456
|
+
for (let i = 0; i < this.journalItems.length; i++) {
|
|
457
|
+
const d = this.journalItems[i].debit ?? 0;
|
|
458
|
+
const c = this.journalItems[i].credit ?? 0;
|
|
459
|
+
if (d > 0 && c > 0) throw new Error(`Journal item at index ${i}: cannot have both debit (${d}) and credit (${c}) greater than zero`);
|
|
460
|
+
if (this.state === "posted" && d === 0 && c === 0) throw new Error(`Journal item at index ${i}: posted entries cannot have zero-value lines (both debit and credit are 0)`);
|
|
461
|
+
}
|
|
462
|
+
const totalDebit = this.journalItems.reduce((s, item) => s + (item.debit ?? 0), 0);
|
|
463
|
+
const totalCredit = this.journalItems.reduce((s, item) => s + (item.credit ?? 0), 0);
|
|
464
|
+
if (this.state === "posted") {
|
|
465
|
+
if (this.journalItems.length < 2) throw new Error("Posted entries must have at least 2 journal items");
|
|
466
|
+
if (totalDebit !== totalCredit) throw new Error("Total debit must equal total credit for posted entries");
|
|
467
|
+
}
|
|
468
|
+
this.totalDebit = totalDebit;
|
|
469
|
+
this.totalCredit = totalCredit;
|
|
470
|
+
});
|
|
471
|
+
if (autoReference) {
|
|
472
|
+
const generateReferenceNumber = async (doc, Model, session) => {
|
|
473
|
+
const jt = doc.journalType || "MISC";
|
|
474
|
+
const d = new Date(doc.date);
|
|
475
|
+
const prefix = `${jt}/${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/`;
|
|
476
|
+
const matchFilter = { referenceNumber: { $regex: `^${prefix.replace(/\//g, "\\/")}` } };
|
|
477
|
+
if (multiTenant) matchFilter[multiTenant.orgField] = doc[multiTenant.orgField];
|
|
478
|
+
const pipeline = [
|
|
479
|
+
{ $match: matchFilter },
|
|
480
|
+
{ $addFields: { _refSeq: { $toInt: { $arrayElemAt: [{ $split: ["$referenceNumber", "/"] }, -1] } } } },
|
|
481
|
+
{ $sort: { _refSeq: -1 } },
|
|
482
|
+
{ $limit: 1 },
|
|
483
|
+
{ $project: { _refSeq: 1 } }
|
|
484
|
+
];
|
|
485
|
+
const results = await Model.aggregate(pipeline).session(session);
|
|
486
|
+
let seq = 1;
|
|
487
|
+
if (results.length > 0 && typeof results[0]._refSeq === "number") seq = results[0]._refSeq + 1;
|
|
488
|
+
return `${prefix}${String(seq).padStart(4, "0")}`;
|
|
489
|
+
};
|
|
490
|
+
schema.pre("save", async function() {
|
|
491
|
+
if (this.isModified("journalType")) this.referenceNumber = void 0;
|
|
492
|
+
if (!this.referenceNumber) {
|
|
493
|
+
const session = this.$session?.() ?? null;
|
|
494
|
+
const Model = this.constructor;
|
|
495
|
+
this.referenceNumber = await generateReferenceNumber(this, Model, session);
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
const MAX_REF_RETRIES = 3;
|
|
499
|
+
schema.post("save", async (error, doc, next) => {
|
|
500
|
+
const mongoError = error;
|
|
501
|
+
if (mongoError.code === 11e3 && mongoError.keyPattern?.referenceNumber) {
|
|
502
|
+
const entry = doc;
|
|
503
|
+
const retryCount = entry.__refRetries ?? 0;
|
|
504
|
+
if (retryCount >= MAX_REF_RETRIES) {
|
|
505
|
+
next(/* @__PURE__ */ new Error(`Failed to generate unique reference number after ${MAX_REF_RETRIES} retries. Too many concurrent inserts for this period.`));
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
entry.__refRetries = retryCount + 1;
|
|
509
|
+
const session = entry.$session?.() ?? null;
|
|
510
|
+
const Model = entry.constructor;
|
|
511
|
+
entry.referenceNumber = await generateReferenceNumber(entry, Model, session);
|
|
512
|
+
try {
|
|
513
|
+
await entry.save({ session });
|
|
514
|
+
next();
|
|
515
|
+
} catch (retryError) {
|
|
516
|
+
next(retryError);
|
|
517
|
+
}
|
|
518
|
+
} else next(error);
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
if (indexes) {
|
|
522
|
+
const org = multiTenant?.orgField;
|
|
523
|
+
const refPartial = { partialFilterExpression: { referenceNumber: {
|
|
524
|
+
$exists: true,
|
|
525
|
+
$type: "string"
|
|
526
|
+
} } };
|
|
527
|
+
if (org) {
|
|
528
|
+
schema.index({
|
|
529
|
+
[org]: 1,
|
|
530
|
+
referenceNumber: 1
|
|
531
|
+
}, {
|
|
532
|
+
unique: true,
|
|
533
|
+
...refPartial
|
|
534
|
+
});
|
|
535
|
+
schema.index({
|
|
536
|
+
[org]: 1,
|
|
537
|
+
state: 1,
|
|
538
|
+
date: 1
|
|
539
|
+
});
|
|
540
|
+
schema.index({
|
|
541
|
+
[org]: 1,
|
|
542
|
+
date: -1
|
|
543
|
+
});
|
|
544
|
+
schema.index({
|
|
545
|
+
[org]: 1,
|
|
546
|
+
journalType: 1
|
|
547
|
+
});
|
|
548
|
+
schema.index({
|
|
549
|
+
"journalItems.account": 1,
|
|
550
|
+
state: 1
|
|
551
|
+
});
|
|
552
|
+
schema.index({
|
|
553
|
+
[org]: 1,
|
|
554
|
+
"journalItems.account": 1,
|
|
555
|
+
date: 1,
|
|
556
|
+
state: 1
|
|
557
|
+
});
|
|
558
|
+
} else {
|
|
559
|
+
schema.index({ referenceNumber: 1 }, {
|
|
560
|
+
unique: true,
|
|
561
|
+
...refPartial
|
|
562
|
+
});
|
|
563
|
+
schema.index({
|
|
564
|
+
state: 1,
|
|
565
|
+
date: 1
|
|
566
|
+
});
|
|
567
|
+
schema.index({ date: -1 });
|
|
568
|
+
schema.index({ journalType: 1 });
|
|
569
|
+
schema.index({
|
|
570
|
+
"journalItems.account": 1,
|
|
571
|
+
state: 1
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
schema.index({ reversed: 1 });
|
|
575
|
+
if (config.idempotency) {
|
|
576
|
+
const idempotencyIdx = {};
|
|
577
|
+
if (org) idempotencyIdx[org] = 1;
|
|
578
|
+
idempotencyIdx.idempotencyKey = 1;
|
|
579
|
+
schema.index(idempotencyIdx, {
|
|
580
|
+
unique: true,
|
|
581
|
+
partialFilterExpression: { idempotencyKey: {
|
|
582
|
+
$exists: true,
|
|
583
|
+
$ne: null
|
|
584
|
+
} }
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (textSearch) schema.index({
|
|
589
|
+
referenceNumber: "text",
|
|
590
|
+
label: "text"
|
|
591
|
+
}, {
|
|
592
|
+
weights: {
|
|
593
|
+
referenceNumber: 10,
|
|
594
|
+
label: 5
|
|
595
|
+
},
|
|
596
|
+
name: "journal_text_idx"
|
|
597
|
+
});
|
|
598
|
+
for (const idx of extraIndexes) schema.index(idx.fields, idx.options);
|
|
599
|
+
return schema;
|
|
600
|
+
}
|
|
601
|
+
//#endregion
|
|
602
|
+
//#region src/schemas/reconciliation.schema.ts
|
|
603
|
+
/**
|
|
604
|
+
* Reconciliation Schema Factory
|
|
605
|
+
*
|
|
606
|
+
* Creates a Mongoose schema for reconciliation records that link matched
|
|
607
|
+
* debit/credit journal items. Used to track which journal entries have been
|
|
608
|
+
* reconciled against each other for a given account.
|
|
609
|
+
*/
|
|
610
|
+
function createReconciliationSchema(config, accountModelName, journalEntryModelName, options = {}) {
|
|
611
|
+
const { multiTenant } = config;
|
|
612
|
+
const { indexes = true, extraFields = {}, extraIndexes = [] } = options;
|
|
613
|
+
const fields = {
|
|
614
|
+
account: {
|
|
615
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
616
|
+
ref: accountModelName,
|
|
617
|
+
required: true
|
|
618
|
+
},
|
|
619
|
+
journalEntryIds: {
|
|
620
|
+
type: [{
|
|
621
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
622
|
+
ref: journalEntryModelName
|
|
623
|
+
}],
|
|
624
|
+
required: true,
|
|
625
|
+
validate: {
|
|
626
|
+
validator: (v) => Array.isArray(v) && v.length > 0,
|
|
627
|
+
message: "journalEntryIds must contain at least one entry."
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
debitTotal: {
|
|
631
|
+
type: Number,
|
|
632
|
+
required: true
|
|
633
|
+
},
|
|
634
|
+
creditTotal: {
|
|
635
|
+
type: Number,
|
|
636
|
+
required: true
|
|
637
|
+
},
|
|
638
|
+
difference: {
|
|
639
|
+
type: Number,
|
|
640
|
+
default: 0
|
|
641
|
+
},
|
|
642
|
+
note: { type: String },
|
|
643
|
+
reconciledBy: { type: String },
|
|
644
|
+
reconciledAt: {
|
|
645
|
+
type: Date,
|
|
646
|
+
default: Date.now
|
|
647
|
+
},
|
|
648
|
+
...extraFields
|
|
649
|
+
};
|
|
650
|
+
if (multiTenant) fields[multiTenant.orgField] = {
|
|
651
|
+
type: mongoose.Schema.Types.ObjectId,
|
|
652
|
+
ref: multiTenant.orgRef,
|
|
653
|
+
required: true
|
|
654
|
+
};
|
|
655
|
+
const schema = new mongoose.Schema(fields, { timestamps: true });
|
|
656
|
+
if (indexes) {
|
|
657
|
+
if (multiTenant) {
|
|
658
|
+
const org = multiTenant.orgField;
|
|
659
|
+
schema.index({
|
|
660
|
+
[org]: 1,
|
|
661
|
+
account: 1,
|
|
662
|
+
reconciledAt: 1
|
|
663
|
+
});
|
|
664
|
+
} else schema.index({
|
|
665
|
+
account: 1,
|
|
666
|
+
reconciledAt: 1
|
|
667
|
+
});
|
|
668
|
+
schema.index({ journalEntryIds: 1 });
|
|
669
|
+
}
|
|
670
|
+
for (const idx of extraIndexes) schema.index(idx.fields, idx.options);
|
|
671
|
+
return schema;
|
|
672
|
+
}
|
|
673
|
+
//#endregion
|
|
14
674
|
//#region src/models/factory.ts
|
|
15
675
|
function resolveModelNames(overrides) {
|
|
16
676
|
return {
|
|
@@ -48,6 +708,545 @@ function createModels(connection, config) {
|
|
|
48
708
|
};
|
|
49
709
|
}
|
|
50
710
|
//#endregion
|
|
711
|
+
//#region src/repositories/account.repository.ts
|
|
712
|
+
/**
|
|
713
|
+
* Wire seedAccounts, bulkCreate and posting-account validation
|
|
714
|
+
* onto an existing mongokit Repository.
|
|
715
|
+
*
|
|
716
|
+
* @param repository - A mongokit Repository instance (already created)
|
|
717
|
+
* @param AccountModel - The Mongoose model for accounts
|
|
718
|
+
* @param country - The CountryPack for account type lookups
|
|
719
|
+
* @param orgField - The multi-tenant field name (e.g. 'business')
|
|
720
|
+
*/
|
|
721
|
+
function wireAccountMethods(repository, AccountModel, country, orgField) {
|
|
722
|
+
repository.on("before:create", (ctx) => {
|
|
723
|
+
const code = ctx.data?.accountTypeCode;
|
|
724
|
+
if (code && !country.isPostingAccount(code)) throw Errors.validation(`Cannot create account with type "${code}" — it is a structural group or calculated total, not a posting account.`);
|
|
725
|
+
});
|
|
726
|
+
/**
|
|
727
|
+
* Seed standard posting accounts for an organization.
|
|
728
|
+
*/
|
|
729
|
+
repository.seedAccounts = async (orgId, options = {}) => {
|
|
730
|
+
requireOrgScope(orgField, orgId);
|
|
731
|
+
const postingTypes = country.getPostingAccountTypes();
|
|
732
|
+
const filter = {};
|
|
733
|
+
if (orgField && orgId != null) filter[orgField] = orgId;
|
|
734
|
+
const existing = await AccountModel.find(filter).select("accountNumber").lean();
|
|
735
|
+
const existingNumbers = new Set(existing.map((a) => a.accountNumber));
|
|
736
|
+
const toCreate = postingTypes.filter((at) => !existingNumbers.has(at.code)).map((at) => {
|
|
737
|
+
const doc = {
|
|
738
|
+
accountTypeCode: at.code,
|
|
739
|
+
accountNumber: at.code,
|
|
740
|
+
name: at.name
|
|
741
|
+
};
|
|
742
|
+
if (orgField && orgId != null) doc[orgField] = orgId;
|
|
743
|
+
return doc;
|
|
744
|
+
});
|
|
745
|
+
if (toCreate.length === 0) return {
|
|
746
|
+
created: 0,
|
|
747
|
+
skipped: existingNumbers.size
|
|
748
|
+
};
|
|
749
|
+
try {
|
|
750
|
+
return {
|
|
751
|
+
created: (await AccountModel.insertMany(toCreate, {
|
|
752
|
+
session: options.session ?? void 0,
|
|
753
|
+
ordered: false
|
|
754
|
+
})).length,
|
|
755
|
+
skipped: existingNumbers.size
|
|
756
|
+
};
|
|
757
|
+
} catch (err) {
|
|
758
|
+
const bulkError = err;
|
|
759
|
+
if (bulkError.code === 11e3 || bulkError.writeErrors) {
|
|
760
|
+
const insertedDocs = bulkError.insertedDocs ?? [];
|
|
761
|
+
return {
|
|
762
|
+
created: insertedDocs.length,
|
|
763
|
+
skipped: existingNumbers.size + (toCreate.length - insertedDocs.length)
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
throw err;
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
/**
|
|
770
|
+
* Bulk create accounts with validation and skip-if-exists logic.
|
|
771
|
+
*
|
|
772
|
+
* Uses a single batch query to check existing accounts (instead of N+1),
|
|
773
|
+
* and ordered: false on insertMany to handle concurrent race conditions
|
|
774
|
+
* gracefully (duplicate key errors on individual docs don't abort the batch).
|
|
775
|
+
*/
|
|
776
|
+
repository.bulkCreate = async (accounts, orgId) => {
|
|
777
|
+
requireOrgScope(orgField, orgId);
|
|
778
|
+
const results = {
|
|
779
|
+
created: [],
|
|
780
|
+
skipped: [],
|
|
781
|
+
errors: []
|
|
782
|
+
};
|
|
783
|
+
const validAccounts = [];
|
|
784
|
+
for (let i = 0; i < accounts.length; i++) {
|
|
785
|
+
const { accountTypeCode, accountNumber, name, active = true, isCashAccount = false } = accounts[i];
|
|
786
|
+
if (!accountTypeCode) {
|
|
787
|
+
results.errors.push({
|
|
788
|
+
index: i,
|
|
789
|
+
reason: "accountTypeCode is required"
|
|
790
|
+
});
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
const at = country.getAccountType(accountTypeCode);
|
|
794
|
+
if (!at) {
|
|
795
|
+
results.errors.push({
|
|
796
|
+
index: i,
|
|
797
|
+
accountTypeCode,
|
|
798
|
+
reason: "Invalid account type code"
|
|
799
|
+
});
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
if (!country.isPostingAccount(accountTypeCode)) {
|
|
803
|
+
results.errors.push({
|
|
804
|
+
index: i,
|
|
805
|
+
accountTypeCode,
|
|
806
|
+
reason: `Not a posting account (${at.isGroup ? "group" : "total"})`
|
|
807
|
+
});
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
const resolvedNumber = accountNumber ?? accountTypeCode;
|
|
811
|
+
const resolvedName = name ?? at.name ?? accountTypeCode;
|
|
812
|
+
validAccounts.push({
|
|
813
|
+
index: i,
|
|
814
|
+
accountTypeCode,
|
|
815
|
+
accountNumber: resolvedNumber,
|
|
816
|
+
name: resolvedName,
|
|
817
|
+
active: Boolean(active),
|
|
818
|
+
isCashAccount: Boolean(isCashAccount)
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
if (validAccounts.length === 0) return {
|
|
822
|
+
summary: {
|
|
823
|
+
total: accounts.length,
|
|
824
|
+
created: 0,
|
|
825
|
+
skipped: results.skipped.length,
|
|
826
|
+
errors: results.errors.length
|
|
827
|
+
},
|
|
828
|
+
...results
|
|
829
|
+
};
|
|
830
|
+
const existsFilter = { accountNumber: { $in: validAccounts.map((a) => a.accountNumber) } };
|
|
831
|
+
if (orgField && orgId != null) existsFilter[orgField] = orgId;
|
|
832
|
+
const existingDocs = await AccountModel.find(existsFilter).select("accountNumber").lean();
|
|
833
|
+
const existingNumbers = new Set(existingDocs.map((d) => d.accountNumber));
|
|
834
|
+
const toCreate = [];
|
|
835
|
+
for (const item of validAccounts) if (existingNumbers.has(item.accountNumber)) results.skipped.push({
|
|
836
|
+
index: item.index,
|
|
837
|
+
accountTypeCode: item.accountTypeCode,
|
|
838
|
+
reason: "Already exists"
|
|
839
|
+
});
|
|
840
|
+
else toCreate.push(item);
|
|
841
|
+
if (toCreate.length > 0) {
|
|
842
|
+
const docs = toCreate.map((item) => {
|
|
843
|
+
const doc = {
|
|
844
|
+
accountTypeCode: item.accountTypeCode,
|
|
845
|
+
accountNumber: item.accountNumber,
|
|
846
|
+
name: item.name,
|
|
847
|
+
active: item.active,
|
|
848
|
+
isCashAccount: item.isCashAccount
|
|
849
|
+
};
|
|
850
|
+
if (orgField && orgId != null) doc[orgField] = orgId;
|
|
851
|
+
return doc;
|
|
852
|
+
});
|
|
853
|
+
try {
|
|
854
|
+
const inserted = await AccountModel.insertMany(docs, { ordered: false });
|
|
855
|
+
results.created = toCreate.map((item, idx) => ({
|
|
856
|
+
accountTypeCode: item.accountTypeCode,
|
|
857
|
+
active: item.active,
|
|
858
|
+
isCashAccount: item.isCashAccount,
|
|
859
|
+
_id: inserted[idx]._id
|
|
860
|
+
}));
|
|
861
|
+
} catch (err) {
|
|
862
|
+
const bulkError = err;
|
|
863
|
+
if (bulkError.code === 11e3 || bulkError.writeErrors) {
|
|
864
|
+
const insertedDocs = bulkError.insertedDocs ?? [];
|
|
865
|
+
const insertedNumbers = new Set(insertedDocs.map((d) => d.accountNumber));
|
|
866
|
+
for (const item of toCreate) if (insertedNumbers.has(item.accountNumber)) {
|
|
867
|
+
const iDoc = insertedDocs.find((d) => d.accountNumber === item.accountNumber);
|
|
868
|
+
results.created.push({
|
|
869
|
+
accountTypeCode: item.accountTypeCode,
|
|
870
|
+
active: item.active,
|
|
871
|
+
isCashAccount: item.isCashAccount,
|
|
872
|
+
_id: iDoc?._id
|
|
873
|
+
});
|
|
874
|
+
} else results.skipped.push({
|
|
875
|
+
index: item.index,
|
|
876
|
+
accountTypeCode: item.accountTypeCode,
|
|
877
|
+
reason: "Already exists (concurrent insert)"
|
|
878
|
+
});
|
|
879
|
+
} else throw err;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return {
|
|
883
|
+
summary: {
|
|
884
|
+
total: accounts.length,
|
|
885
|
+
created: results.created.length,
|
|
886
|
+
skipped: results.skipped.length,
|
|
887
|
+
errors: results.errors.length
|
|
888
|
+
},
|
|
889
|
+
...results
|
|
890
|
+
};
|
|
891
|
+
};
|
|
892
|
+
if (typeof repository.registerMethod === "function") for (const name of ["seedAccounts", "bulkCreate"]) {
|
|
893
|
+
const fn = repository[name];
|
|
894
|
+
try {
|
|
895
|
+
delete repository[name];
|
|
896
|
+
repository.registerMethod(name, fn);
|
|
897
|
+
} catch {
|
|
898
|
+
repository[name] = fn;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
return repository;
|
|
902
|
+
}
|
|
903
|
+
//#endregion
|
|
904
|
+
//#region src/repositories/journal-entry.repository.ts
|
|
905
|
+
/** Keys that are either handled explicitly or must not be copied */
|
|
906
|
+
const ITEM_CORE_KEYS = new Set([
|
|
907
|
+
"account",
|
|
908
|
+
"debit",
|
|
909
|
+
"credit",
|
|
910
|
+
"label",
|
|
911
|
+
"date",
|
|
912
|
+
"taxDetails",
|
|
913
|
+
"_id",
|
|
914
|
+
"id"
|
|
915
|
+
]);
|
|
916
|
+
/**
|
|
917
|
+
* Wire post/reverse onto an existing mongokit Repository.
|
|
918
|
+
*
|
|
919
|
+
* All reads use `repository.getByQuery()` so registered plugins
|
|
920
|
+
* (multi-tenant, audit, cache) fire on every operation.
|
|
921
|
+
*
|
|
922
|
+
* @param repository - A mongokit Repository instance (already created)
|
|
923
|
+
* @param _JournalEntryModel - (Deprecated) The Mongoose model — no longer used internally; kept for API compat
|
|
924
|
+
* @param orgField - The multi-tenant field name (e.g. 'business')
|
|
925
|
+
* @param strictness - Strictness rules (immutable, requireActor, requireApproval)
|
|
926
|
+
*/
|
|
927
|
+
function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, strictness) {
|
|
928
|
+
const getByQuery = repository.getByQuery.bind(repository);
|
|
929
|
+
const create = repository.create.bind(repository);
|
|
930
|
+
const withTransaction = repository.withTransaction.bind(repository);
|
|
931
|
+
/** Build a tenant-scoped query for a single entry by ID (injection-safe) */
|
|
932
|
+
function buildQuery(id, orgId) {
|
|
933
|
+
validateScalarId(id, "entry ID");
|
|
934
|
+
if (orgId != null) validateScalarId(orgId, "organization ID");
|
|
935
|
+
const query = { _id: id };
|
|
936
|
+
if (orgField && orgId != null) query[orgField] = orgId;
|
|
937
|
+
return query;
|
|
938
|
+
}
|
|
939
|
+
/** Reject operator-injected objects like { $ne: null } but allow ObjectIds */
|
|
940
|
+
function validateScalarId(value, label) {
|
|
941
|
+
if (value == null || typeof value !== "object") return;
|
|
942
|
+
const obj = value;
|
|
943
|
+
if (typeof obj.toHexString === "function" || obj._bsontype === "ObjectId") return;
|
|
944
|
+
if (Object.keys(obj).some((k) => k.startsWith("$"))) throw Errors.validation(`Invalid ${label} — MongoDB operators are not allowed.`);
|
|
945
|
+
}
|
|
946
|
+
/** Fetch an entry via the repository (fires all hooks) */
|
|
947
|
+
async function findEntry(query, options) {
|
|
948
|
+
const opts = { lean: false };
|
|
949
|
+
if (options.populate) opts.populate = options.populate;
|
|
950
|
+
if (options.session) opts.session = options.session;
|
|
951
|
+
return await getByQuery(query, opts);
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Post an entry (draft → posted).
|
|
955
|
+
* Validates items, balance, and accounts before changing state.
|
|
956
|
+
*/
|
|
957
|
+
repository.post = async (id, orgId, options = {}) => {
|
|
958
|
+
if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for post operations.");
|
|
959
|
+
requireOrgScope(orgField, orgId);
|
|
960
|
+
const entry = await findEntry(buildQuery(id, orgId), {
|
|
961
|
+
session: options.session,
|
|
962
|
+
populate: "journalItems.account"
|
|
963
|
+
});
|
|
964
|
+
if (!entry) throw Errors.notFound("Entry not found");
|
|
965
|
+
if (entry.idempotencyKey && entry.state === "posted") return entry;
|
|
966
|
+
if (entry.state !== "draft") throw Errors.validation("Only draft entries can be posted");
|
|
967
|
+
if (strictness?.requireApproval) {
|
|
968
|
+
if (!entry.approvedBy || !entry.approvedAt) throw Errors.validation("Entry must be approved before posting. Both approvedBy and approvedAt are required.");
|
|
969
|
+
}
|
|
970
|
+
if (!entry.journalItems || entry.journalItems.length < 2) throw Errors.validation("Journal entry must have at least 2 items to post");
|
|
971
|
+
const missing = entry.journalItems.filter((i) => !i.account || i.account === "");
|
|
972
|
+
if (missing.length > 0) throw Errors.validation(`${missing.length} item(s) missing an account`);
|
|
973
|
+
const nullAccounts = entry.journalItems.filter((i) => {
|
|
974
|
+
const acct = i.account;
|
|
975
|
+
if (!acct) return true;
|
|
976
|
+
if (typeof acct === "string") return true;
|
|
977
|
+
if (typeof acct === "object" && !acct._id) return true;
|
|
978
|
+
return false;
|
|
979
|
+
});
|
|
980
|
+
if (nullAccounts.length > 0) throw Errors.validation(`${nullAccounts.length} item(s) reference accounts that do not exist. Ensure all accounts are created before posting.`);
|
|
981
|
+
if (orgField && orgId != null) {
|
|
982
|
+
const crossTenant = entry.journalItems.filter((i) => {
|
|
983
|
+
const acct = i.account;
|
|
984
|
+
if (!acct || typeof acct !== "object") return false;
|
|
985
|
+
return String(acct[orgField]) !== String(orgId);
|
|
986
|
+
});
|
|
987
|
+
if (crossTenant.length > 0) throw Errors.validation(`${crossTenant.length} item(s) reference accounts from another organization`);
|
|
988
|
+
}
|
|
989
|
+
const zeroed = entry.journalItems.filter((i) => (i.debit || 0) === 0 && (i.credit || 0) === 0);
|
|
990
|
+
if (zeroed.length > 0) throw Errors.validation(`${zeroed.length} item(s) have both debit and credit as zero`);
|
|
991
|
+
const bothSet = entry.journalItems.filter((i) => (i.debit || 0) > 0 && (i.credit || 0) > 0);
|
|
992
|
+
if (bothSet.length > 0) throw Errors.validation(`${bothSet.length} item(s) have both debit and credit set — each line must be debit OR credit, not both`);
|
|
993
|
+
const totalDebit = entry.journalItems.reduce((s, i) => s + (i.debit || 0), 0);
|
|
994
|
+
const totalCredit = entry.journalItems.reduce((s, i) => s + (i.credit || 0), 0);
|
|
995
|
+
if (totalDebit !== totalCredit) throw Errors.validation(`Entry is not balanced. Debit: ${totalDebit}, Credit: ${totalCredit}`);
|
|
996
|
+
entry.state = "posted";
|
|
997
|
+
entry.stateChangedAt = /* @__PURE__ */ new Date();
|
|
998
|
+
if (options.actorId) entry.postedBy = options.actorId;
|
|
999
|
+
await entry.save({ session: options.session });
|
|
1000
|
+
return entry;
|
|
1001
|
+
};
|
|
1002
|
+
/**
|
|
1003
|
+
* Unpost an entry (posted → draft).
|
|
1004
|
+
* Resets state to draft so the entry can be edited and re-posted.
|
|
1005
|
+
* Also clears the reversed flag if set, allowing full re-editing.
|
|
1006
|
+
*/
|
|
1007
|
+
repository.unpost = async (id, orgId, options = {}) => {
|
|
1008
|
+
if (strictness?.immutable) throw Errors.immutable("Unpost is disabled in strict mode. Use reverse() to correct posted entries.");
|
|
1009
|
+
if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for unpost operations.");
|
|
1010
|
+
requireOrgScope(orgField, orgId);
|
|
1011
|
+
const entry = await findEntry(buildQuery(id, orgId), { session: options.session });
|
|
1012
|
+
if (!entry) throw Errors.notFound("Entry not found");
|
|
1013
|
+
if (entry.state !== "posted") throw Errors.validation("Only posted entries can be unposted");
|
|
1014
|
+
if (entry.reversed) throw Errors.validation("Cannot unpost a reversed entry. The reversal entry is still posted and linked to this entry. Reverse the reversal entry first, or create a new correcting entry instead.");
|
|
1015
|
+
entry.state = "draft";
|
|
1016
|
+
entry.stateChangedAt = /* @__PURE__ */ new Date();
|
|
1017
|
+
await entry.save({ session: options.session });
|
|
1018
|
+
return entry;
|
|
1019
|
+
};
|
|
1020
|
+
/**
|
|
1021
|
+
* Archive a draft entry (draft → archived).
|
|
1022
|
+
* Used to discard unneeded drafts without deleting them, preserving audit trail.
|
|
1023
|
+
* Only draft entries can be archived. Posted entries must be reversed instead.
|
|
1024
|
+
*/
|
|
1025
|
+
repository.archive = async (id, orgId, options = {}) => {
|
|
1026
|
+
if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for archive operations.");
|
|
1027
|
+
requireOrgScope(orgField, orgId);
|
|
1028
|
+
const entry = await findEntry(buildQuery(id, orgId), { session: options.session });
|
|
1029
|
+
if (!entry) throw Errors.notFound("Entry not found");
|
|
1030
|
+
if (entry.state !== "draft") throw Errors.validation("Only draft entries can be archived");
|
|
1031
|
+
entry.state = "archived";
|
|
1032
|
+
entry.stateChangedAt = /* @__PURE__ */ new Date();
|
|
1033
|
+
await entry.save({ session: options.session });
|
|
1034
|
+
return entry;
|
|
1035
|
+
};
|
|
1036
|
+
/**
|
|
1037
|
+
* Duplicate an entry as a new draft.
|
|
1038
|
+
* Copies journal items, journal type, and label. Assigns today's date.
|
|
1039
|
+
*/
|
|
1040
|
+
repository.duplicate = async (id, orgId, options = {}) => {
|
|
1041
|
+
requireOrgScope(orgField, orgId);
|
|
1042
|
+
const entry = await findEntry(buildQuery(id, orgId), { session: options.session });
|
|
1043
|
+
if (!entry) throw Errors.notFound("Entry not found");
|
|
1044
|
+
const duplicateData = {
|
|
1045
|
+
journalType: entry.journalType,
|
|
1046
|
+
state: "draft",
|
|
1047
|
+
date: /* @__PURE__ */ new Date(),
|
|
1048
|
+
label: entry.label ? `Copy of ${entry.label}` : "Duplicated entry",
|
|
1049
|
+
journalItems: entry.journalItems.map((item) => {
|
|
1050
|
+
const accountId = typeof item.account === "object" && item.account !== null ? item.account._id : item.account;
|
|
1051
|
+
const extra = {};
|
|
1052
|
+
for (const key of Object.keys(item)) if (!ITEM_CORE_KEYS.has(key)) extra[key] = item[key];
|
|
1053
|
+
return {
|
|
1054
|
+
...extra,
|
|
1055
|
+
account: accountId,
|
|
1056
|
+
debit: item.debit ?? 0,
|
|
1057
|
+
credit: item.credit ?? 0,
|
|
1058
|
+
label: item.label,
|
|
1059
|
+
date: /* @__PURE__ */ new Date(),
|
|
1060
|
+
taxDetails: item.taxDetails ?? []
|
|
1061
|
+
};
|
|
1062
|
+
})
|
|
1063
|
+
};
|
|
1064
|
+
if (orgField && entry[orgField] != null) duplicateData[orgField] = entry[orgField];
|
|
1065
|
+
return await create(duplicateData, options.session ? { session: options.session } : {});
|
|
1066
|
+
};
|
|
1067
|
+
/**
|
|
1068
|
+
* Reverse a posted entry by creating a mirror entry with flipped debits/credits.
|
|
1069
|
+
* Marks the original as reversed and links both entries bidirectionally.
|
|
1070
|
+
*
|
|
1071
|
+
* Uses repository.withTransaction() for automatic retry on transient failures.
|
|
1072
|
+
* Pass an external session to join a caller-managed transaction instead.
|
|
1073
|
+
*
|
|
1074
|
+
* Routes the reversal through repository.create() so all plugins (fiscal-lock,
|
|
1075
|
+
* double-entry) enforce policy on the reversal entry.
|
|
1076
|
+
*/
|
|
1077
|
+
repository.reverse = async (id, orgId, options = {}) => {
|
|
1078
|
+
if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for reverse operations.");
|
|
1079
|
+
requireOrgScope(orgField, orgId);
|
|
1080
|
+
const query = buildQuery(id, orgId);
|
|
1081
|
+
const doReverse = async (session) => {
|
|
1082
|
+
const entry = await findEntry(query, {
|
|
1083
|
+
session,
|
|
1084
|
+
populate: "journalItems.account"
|
|
1085
|
+
});
|
|
1086
|
+
if (!entry) throw Errors.notFound("Entry not found");
|
|
1087
|
+
if (entry.state !== "posted") throw Errors.validation("Only posted entries can be reversed");
|
|
1088
|
+
if (entry.reversed) throw Errors.validation("Entry has already been reversed");
|
|
1089
|
+
const reversalItems = entry.journalItems.map((item) => {
|
|
1090
|
+
const accountId = typeof item.account === "object" && item.account !== null ? item.account._id : item.account;
|
|
1091
|
+
const extra = {};
|
|
1092
|
+
for (const key of Object.keys(item)) if (!ITEM_CORE_KEYS.has(key)) extra[key] = item[key];
|
|
1093
|
+
return {
|
|
1094
|
+
...extra,
|
|
1095
|
+
account: accountId,
|
|
1096
|
+
debit: item.credit ?? 0,
|
|
1097
|
+
credit: item.debit ?? 0,
|
|
1098
|
+
label: item.label ? `Reversal: ${item.label}` : void 0,
|
|
1099
|
+
date: item.date,
|
|
1100
|
+
taxDetails: item.taxDetails ?? []
|
|
1101
|
+
};
|
|
1102
|
+
});
|
|
1103
|
+
const totalDebit = reversalItems.reduce((s, i) => s + i.debit, 0);
|
|
1104
|
+
const totalCredit = reversalItems.reduce((s, i) => s + i.credit, 0);
|
|
1105
|
+
const reversalData = {
|
|
1106
|
+
journalType: entry.journalType ?? "MISC",
|
|
1107
|
+
state: "posted",
|
|
1108
|
+
date: options.reversalDate ?? /* @__PURE__ */ new Date(),
|
|
1109
|
+
label: `Reversal of ${entry.referenceNumber ?? entry._id}`,
|
|
1110
|
+
journalItems: reversalItems,
|
|
1111
|
+
totalDebit,
|
|
1112
|
+
totalCredit,
|
|
1113
|
+
reversalOf: entry._id,
|
|
1114
|
+
stateChangedAt: /* @__PURE__ */ new Date()
|
|
1115
|
+
};
|
|
1116
|
+
if (orgField && entry[orgField] != null) reversalData[orgField] = entry[orgField];
|
|
1117
|
+
if (options.actorId) reversalData.postedBy = options.actorId;
|
|
1118
|
+
const reversalEntry = await create(reversalData, session ? { session } : {});
|
|
1119
|
+
entry.reversed = true;
|
|
1120
|
+
entry.reversedBy = reversalEntry._id;
|
|
1121
|
+
if (options.actorId) entry.reversedByUser = options.actorId;
|
|
1122
|
+
await entry.save({ session });
|
|
1123
|
+
return {
|
|
1124
|
+
original: entry,
|
|
1125
|
+
reversal: reversalEntry
|
|
1126
|
+
};
|
|
1127
|
+
};
|
|
1128
|
+
if (options.session) return await doReverse(options.session);
|
|
1129
|
+
if (withTransaction) return await withTransaction((session) => doReverse(session), { allowFallback: true });
|
|
1130
|
+
return await doReverse();
|
|
1131
|
+
};
|
|
1132
|
+
const methodNames = [
|
|
1133
|
+
"post",
|
|
1134
|
+
"unpost",
|
|
1135
|
+
"archive",
|
|
1136
|
+
"duplicate",
|
|
1137
|
+
"reverse"
|
|
1138
|
+
];
|
|
1139
|
+
if (typeof repository.registerMethod === "function") for (const name of methodNames) {
|
|
1140
|
+
const fn = repository[name];
|
|
1141
|
+
try {
|
|
1142
|
+
delete repository[name];
|
|
1143
|
+
repository.registerMethod(name, fn);
|
|
1144
|
+
} catch {
|
|
1145
|
+
repository[name] = fn;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
return repository;
|
|
1149
|
+
}
|
|
1150
|
+
//#endregion
|
|
1151
|
+
//#region src/repositories/reconciliation.repository.ts
|
|
1152
|
+
/**
|
|
1153
|
+
* Wire reconciliation methods onto an existing mongokit Repository.
|
|
1154
|
+
*
|
|
1155
|
+
* - reconcile() uses repository.create() so hooks (multi-tenant, audit) fire
|
|
1156
|
+
* - unreconcile() uses repository.delete() so hooks fire
|
|
1157
|
+
* - Cross-repo reads (JournalEntryModel) use direct Model access (acceptable)
|
|
1158
|
+
*/
|
|
1159
|
+
function wireReconciliationMethods(repository, _ReconciliationModel, JournalEntryModel, orgField) {
|
|
1160
|
+
const create = repository.create.bind(repository);
|
|
1161
|
+
const deleteById = repository.delete.bind(repository);
|
|
1162
|
+
/**
|
|
1163
|
+
* Create a reconciliation record linking matched journal entries.
|
|
1164
|
+
* Validates that all entries exist, are posted, and belong to the same account/org.
|
|
1165
|
+
*/
|
|
1166
|
+
repository.reconcile = async (input) => {
|
|
1167
|
+
const { account, journalEntryIds, note, reconciledBy, organizationId } = input;
|
|
1168
|
+
requireOrgScope(orgField, organizationId);
|
|
1169
|
+
if (!journalEntryIds || journalEntryIds.length === 0) throw Errors.validation("journalEntryIds must contain at least one entry.");
|
|
1170
|
+
const query = { _id: { $in: journalEntryIds } };
|
|
1171
|
+
if (orgField && organizationId != null) query[orgField] = organizationId;
|
|
1172
|
+
const entries = await JournalEntryModel.find(query).lean();
|
|
1173
|
+
if (entries.length !== journalEntryIds.length) throw Errors.notFound(`Expected ${journalEntryIds.length} entries but found ${entries.length}. Some entries do not exist or belong to a different organization.`);
|
|
1174
|
+
const notPosted = entries.filter((e) => e.state !== "posted");
|
|
1175
|
+
if (notPosted.length > 0) throw Errors.validation(`${notPosted.length} entry(ies) are not posted. Only posted entries can be reconciled.`);
|
|
1176
|
+
const accountStr = String(account);
|
|
1177
|
+
for (const entry of entries) if (!entry.journalItems.some((item) => String(item.account) === accountStr)) throw Errors.validation(`Entry ${entry._id} does not contain any items for account ${account}.`);
|
|
1178
|
+
let debitTotal = 0;
|
|
1179
|
+
let creditTotal = 0;
|
|
1180
|
+
for (const entry of entries) for (const item of entry.journalItems) if (String(item.account) === accountStr) {
|
|
1181
|
+
debitTotal += item.debit ?? 0;
|
|
1182
|
+
creditTotal += item.credit ?? 0;
|
|
1183
|
+
}
|
|
1184
|
+
const reconciliationData = {
|
|
1185
|
+
account,
|
|
1186
|
+
journalEntryIds,
|
|
1187
|
+
debitTotal,
|
|
1188
|
+
creditTotal,
|
|
1189
|
+
difference: debitTotal - creditTotal,
|
|
1190
|
+
note,
|
|
1191
|
+
reconciledBy,
|
|
1192
|
+
reconciledAt: /* @__PURE__ */ new Date()
|
|
1193
|
+
};
|
|
1194
|
+
if (orgField && organizationId != null) reconciliationData[orgField] = organizationId;
|
|
1195
|
+
return await create(reconciliationData);
|
|
1196
|
+
};
|
|
1197
|
+
/**
|
|
1198
|
+
* Remove a reconciliation record via repository.delete().
|
|
1199
|
+
*/
|
|
1200
|
+
repository.unreconcile = async (input) => {
|
|
1201
|
+
const { reconciliationId, organizationId } = input;
|
|
1202
|
+
requireOrgScope(orgField, organizationId);
|
|
1203
|
+
if (orgField && organizationId != null) {
|
|
1204
|
+
if (!await repository._executeQuery(async (Model) => Model.findOne({
|
|
1205
|
+
_id: reconciliationId,
|
|
1206
|
+
[orgField]: organizationId
|
|
1207
|
+
}).select("_id").lean())) throw Errors.notFound("Reconciliation record not found.");
|
|
1208
|
+
}
|
|
1209
|
+
const result = await deleteById(String(reconciliationId));
|
|
1210
|
+
if (!result.success) throw Errors.notFound("Reconciliation record not found.");
|
|
1211
|
+
return result;
|
|
1212
|
+
};
|
|
1213
|
+
/**
|
|
1214
|
+
* Find journal entries for an account that are NOT in any reconciliation record.
|
|
1215
|
+
* Uses repository.getAll() for reconciliation lookups (hooks fire),
|
|
1216
|
+
* and direct JournalEntryModel for cross-repo reads (acceptable).
|
|
1217
|
+
*/
|
|
1218
|
+
repository.getUnreconciled = async (input) => {
|
|
1219
|
+
const { accountId, organizationId, limit = 100, skip = 0 } = input;
|
|
1220
|
+
requireOrgScope(orgField, organizationId);
|
|
1221
|
+
const reconFilter = { account: accountId };
|
|
1222
|
+
if (orgField && organizationId != null) reconFilter[orgField] = organizationId;
|
|
1223
|
+
const reconciliations = await repository._executeQuery(async (Model) => Model.find(reconFilter).select("journalEntryIds").lean());
|
|
1224
|
+
const reconciledIds = /* @__PURE__ */ new Set();
|
|
1225
|
+
for (const rec of reconciliations) for (const id of rec.journalEntryIds) reconciledIds.add(String(id));
|
|
1226
|
+
const entryFilter = {
|
|
1227
|
+
state: "posted",
|
|
1228
|
+
"journalItems.account": accountId
|
|
1229
|
+
};
|
|
1230
|
+
if (orgField && organizationId != null) entryFilter[orgField] = organizationId;
|
|
1231
|
+
if (reconciledIds.size > 0) entryFilter._id = { $nin: Array.from(reconciledIds) };
|
|
1232
|
+
return await JournalEntryModel.find(entryFilter).sort({ date: -1 }).skip(skip).limit(limit).lean();
|
|
1233
|
+
};
|
|
1234
|
+
if (typeof repository.registerMethod === "function") for (const name of [
|
|
1235
|
+
"reconcile",
|
|
1236
|
+
"unreconcile",
|
|
1237
|
+
"getUnreconciled"
|
|
1238
|
+
]) {
|
|
1239
|
+
const fn = repository[name];
|
|
1240
|
+
try {
|
|
1241
|
+
delete repository[name];
|
|
1242
|
+
repository.registerMethod(name, fn);
|
|
1243
|
+
} catch {
|
|
1244
|
+
repository[name] = fn;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
return repository;
|
|
1248
|
+
}
|
|
1249
|
+
//#endregion
|
|
51
1250
|
//#region src/repositories/factory.ts
|
|
52
1251
|
/**
|
|
53
1252
|
* Repositories Factory — wires fully-configured repositories.
|
|
@@ -67,11 +1266,16 @@ function createModels(connection, config) {
|
|
|
67
1266
|
* - `budgets` — plain CRUD
|
|
68
1267
|
* - `reconciliations` — has reconcile(), unreconcile(), getUnreconciled()
|
|
69
1268
|
*/
|
|
70
|
-
function createRepositories(models, config, plugins = {}) {
|
|
1269
|
+
function createRepositories(models, config, plugins = {}, pagination = {}) {
|
|
71
1270
|
const orgField = config.multiTenant?.orgField;
|
|
72
1271
|
const strictness = config.strictness;
|
|
73
1272
|
const country = config.country;
|
|
74
|
-
const
|
|
1273
|
+
const accountPagination = pagination.account ?? {};
|
|
1274
|
+
const jePagination = pagination.journalEntry ?? {};
|
|
1275
|
+
const fpPagination = pagination.fiscalPeriod ?? {};
|
|
1276
|
+
const budgetPagination = pagination.budget ?? {};
|
|
1277
|
+
const reconPagination = pagination.reconciliation ?? {};
|
|
1278
|
+
const accounts = wireAccountMethods(new Repository(models.Account, plugins.account ?? [], accountPagination), models.Account, country, orgField);
|
|
75
1279
|
const jePlugins = [
|
|
76
1280
|
...plugins.journalEntry ?? [],
|
|
77
1281
|
doubleEntryPlugin({
|
|
@@ -91,10 +1295,566 @@ function createRepositories(models, config, plugins = {}) {
|
|
|
91
1295
|
}));
|
|
92
1296
|
return {
|
|
93
1297
|
accounts,
|
|
94
|
-
journalEntries: wireJournalEntryMethods(new Repository(models.JournalEntry, jePlugins), models.JournalEntry, orgField, strictness),
|
|
95
|
-
fiscalPeriods: new Repository(models.FiscalPeriod, plugins.fiscalPeriod ?? []),
|
|
96
|
-
budgets: new Repository(models.Budget, plugins.budget ?? []),
|
|
97
|
-
reconciliations: wireReconciliationMethods(new Repository(models.Reconciliation, plugins.reconciliation ?? []), models.Reconciliation, models.JournalEntry, orgField)
|
|
1298
|
+
journalEntries: wireJournalEntryMethods(new Repository(models.JournalEntry, jePlugins, jePagination), models.JournalEntry, orgField, strictness),
|
|
1299
|
+
fiscalPeriods: new Repository(models.FiscalPeriod, plugins.fiscalPeriod ?? [], fpPagination),
|
|
1300
|
+
budgets: new Repository(models.Budget, plugins.budget ?? [], budgetPagination),
|
|
1301
|
+
reconciliations: wireReconciliationMethods(new Repository(models.Reconciliation, plugins.reconciliation ?? [], reconPagination), models.Reconciliation, models.JournalEntry, orgField)
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
//#endregion
|
|
1305
|
+
//#region src/semantic/introspect.ts
|
|
1306
|
+
const REPORT_CATALOG = Object.freeze([
|
|
1307
|
+
{
|
|
1308
|
+
name: "trialBalance",
|
|
1309
|
+
title: "Trial Balance",
|
|
1310
|
+
description: "Debits and credits per account for a period, with opening, current, and ending balances.",
|
|
1311
|
+
params: [
|
|
1312
|
+
{
|
|
1313
|
+
name: "dateOption",
|
|
1314
|
+
required: true,
|
|
1315
|
+
description: "'month' | 'quarter' | 'year' | 'custom'"
|
|
1316
|
+
},
|
|
1317
|
+
{
|
|
1318
|
+
name: "dateValue",
|
|
1319
|
+
required: true,
|
|
1320
|
+
description: "Period value (format depends on dateOption)"
|
|
1321
|
+
},
|
|
1322
|
+
{
|
|
1323
|
+
name: "organizationId",
|
|
1324
|
+
required: false,
|
|
1325
|
+
description: "Multi-tenant scoping"
|
|
1326
|
+
},
|
|
1327
|
+
{
|
|
1328
|
+
name: "accountId",
|
|
1329
|
+
required: false,
|
|
1330
|
+
description: "Filter to a single account"
|
|
1331
|
+
},
|
|
1332
|
+
{
|
|
1333
|
+
name: "filters",
|
|
1334
|
+
required: false,
|
|
1335
|
+
description: "Additional dimension filters"
|
|
1336
|
+
}
|
|
1337
|
+
]
|
|
1338
|
+
},
|
|
1339
|
+
{
|
|
1340
|
+
name: "balanceSheet",
|
|
1341
|
+
title: "Balance Sheet",
|
|
1342
|
+
description: "Assets, liabilities, and equity at a point in time. Includes computed retained earnings.",
|
|
1343
|
+
params: [
|
|
1344
|
+
{
|
|
1345
|
+
name: "dateOption",
|
|
1346
|
+
required: true,
|
|
1347
|
+
description: "'month' | 'quarter' | 'year' | 'custom'"
|
|
1348
|
+
},
|
|
1349
|
+
{
|
|
1350
|
+
name: "dateValue",
|
|
1351
|
+
required: true,
|
|
1352
|
+
description: "Period value"
|
|
1353
|
+
},
|
|
1354
|
+
{
|
|
1355
|
+
name: "organizationId",
|
|
1356
|
+
required: false,
|
|
1357
|
+
description: "Multi-tenant scoping"
|
|
1358
|
+
},
|
|
1359
|
+
{
|
|
1360
|
+
name: "businessName",
|
|
1361
|
+
required: false,
|
|
1362
|
+
description: "Header label"
|
|
1363
|
+
},
|
|
1364
|
+
{
|
|
1365
|
+
name: "filters",
|
|
1366
|
+
required: false,
|
|
1367
|
+
description: "Additional dimension filters"
|
|
1368
|
+
}
|
|
1369
|
+
]
|
|
1370
|
+
},
|
|
1371
|
+
{
|
|
1372
|
+
name: "incomeStatement",
|
|
1373
|
+
title: "Income Statement",
|
|
1374
|
+
description: "Revenue, COGS, gross profit, operating expenses, and net income for a period.",
|
|
1375
|
+
params: [
|
|
1376
|
+
{
|
|
1377
|
+
name: "dateOption",
|
|
1378
|
+
required: true,
|
|
1379
|
+
description: "'month' | 'quarter' | 'year' | 'custom'"
|
|
1380
|
+
},
|
|
1381
|
+
{
|
|
1382
|
+
name: "dateValue",
|
|
1383
|
+
required: true,
|
|
1384
|
+
description: "Period value"
|
|
1385
|
+
},
|
|
1386
|
+
{
|
|
1387
|
+
name: "organizationId",
|
|
1388
|
+
required: false,
|
|
1389
|
+
description: "Multi-tenant scoping"
|
|
1390
|
+
},
|
|
1391
|
+
{
|
|
1392
|
+
name: "businessName",
|
|
1393
|
+
required: false,
|
|
1394
|
+
description: "Header label"
|
|
1395
|
+
},
|
|
1396
|
+
{
|
|
1397
|
+
name: "filters",
|
|
1398
|
+
required: false,
|
|
1399
|
+
description: "Additional dimension filters"
|
|
1400
|
+
}
|
|
1401
|
+
]
|
|
1402
|
+
},
|
|
1403
|
+
{
|
|
1404
|
+
name: "generalLedger",
|
|
1405
|
+
title: "General Ledger",
|
|
1406
|
+
description: "Per-account transaction detail with running balances. Use accountId to scope.",
|
|
1407
|
+
params: [
|
|
1408
|
+
{
|
|
1409
|
+
name: "dateOption",
|
|
1410
|
+
required: true,
|
|
1411
|
+
description: "'month' | 'quarter' | 'year' | 'custom'"
|
|
1412
|
+
},
|
|
1413
|
+
{
|
|
1414
|
+
name: "dateValue",
|
|
1415
|
+
required: true,
|
|
1416
|
+
description: "Period value"
|
|
1417
|
+
},
|
|
1418
|
+
{
|
|
1419
|
+
name: "organizationId",
|
|
1420
|
+
required: false,
|
|
1421
|
+
description: "Multi-tenant scoping"
|
|
1422
|
+
},
|
|
1423
|
+
{
|
|
1424
|
+
name: "accountId",
|
|
1425
|
+
required: false,
|
|
1426
|
+
description: "Scope to a single account"
|
|
1427
|
+
}
|
|
1428
|
+
]
|
|
1429
|
+
},
|
|
1430
|
+
{
|
|
1431
|
+
name: "cashFlow",
|
|
1432
|
+
title: "Cash Flow Statement",
|
|
1433
|
+
description: "Cash movement by Operating / Investing / Financing sections.",
|
|
1434
|
+
params: [
|
|
1435
|
+
{
|
|
1436
|
+
name: "dateOption",
|
|
1437
|
+
required: true,
|
|
1438
|
+
description: "'month' | 'quarter' | 'year' | 'custom'"
|
|
1439
|
+
},
|
|
1440
|
+
{
|
|
1441
|
+
name: "dateValue",
|
|
1442
|
+
required: true,
|
|
1443
|
+
description: "Period value"
|
|
1444
|
+
},
|
|
1445
|
+
{
|
|
1446
|
+
name: "organizationId",
|
|
1447
|
+
required: false,
|
|
1448
|
+
description: "Multi-tenant scoping"
|
|
1449
|
+
}
|
|
1450
|
+
]
|
|
1451
|
+
},
|
|
1452
|
+
{
|
|
1453
|
+
name: "agedBalance",
|
|
1454
|
+
title: "Aged Receivables / Payables",
|
|
1455
|
+
description: "Outstanding AR or AP bucketed by age (current, 30, 60, 90+).",
|
|
1456
|
+
params: [
|
|
1457
|
+
{
|
|
1458
|
+
name: "type",
|
|
1459
|
+
required: true,
|
|
1460
|
+
description: "'receivable' | 'payable'"
|
|
1461
|
+
},
|
|
1462
|
+
{
|
|
1463
|
+
name: "asOfDate",
|
|
1464
|
+
required: false,
|
|
1465
|
+
description: "Defaults to now"
|
|
1466
|
+
},
|
|
1467
|
+
{
|
|
1468
|
+
name: "organizationId",
|
|
1469
|
+
required: false,
|
|
1470
|
+
description: "Multi-tenant scoping"
|
|
1471
|
+
},
|
|
1472
|
+
{
|
|
1473
|
+
name: "buckets",
|
|
1474
|
+
required: false,
|
|
1475
|
+
description: "Custom bucket definitions"
|
|
1476
|
+
}
|
|
1477
|
+
]
|
|
1478
|
+
},
|
|
1479
|
+
{
|
|
1480
|
+
name: "dimensionBreakdown",
|
|
1481
|
+
title: "Dimension Breakdown",
|
|
1482
|
+
description: "Expense/revenue by a custom dimension (department, project, cost center).",
|
|
1483
|
+
params: [
|
|
1484
|
+
{
|
|
1485
|
+
name: "dimension",
|
|
1486
|
+
required: true,
|
|
1487
|
+
description: "Field name to group by (e.g. departmentId)"
|
|
1488
|
+
},
|
|
1489
|
+
{
|
|
1490
|
+
name: "dateOption",
|
|
1491
|
+
required: true,
|
|
1492
|
+
description: "'month' | 'quarter' | 'year' | 'custom'"
|
|
1493
|
+
},
|
|
1494
|
+
{
|
|
1495
|
+
name: "dateValue",
|
|
1496
|
+
required: true,
|
|
1497
|
+
description: "Period value"
|
|
1498
|
+
},
|
|
1499
|
+
{
|
|
1500
|
+
name: "organizationId",
|
|
1501
|
+
required: false,
|
|
1502
|
+
description: "Multi-tenant scoping"
|
|
1503
|
+
},
|
|
1504
|
+
{
|
|
1505
|
+
name: "accountCategory",
|
|
1506
|
+
required: false,
|
|
1507
|
+
description: "Filter by statement category"
|
|
1508
|
+
}
|
|
1509
|
+
]
|
|
1510
|
+
},
|
|
1511
|
+
{
|
|
1512
|
+
name: "budgetVsActual",
|
|
1513
|
+
title: "Budget vs Actual",
|
|
1514
|
+
description: "Compare budgeted amounts to actual posted entries for a period.",
|
|
1515
|
+
params: [
|
|
1516
|
+
{
|
|
1517
|
+
name: "dateOption",
|
|
1518
|
+
required: true,
|
|
1519
|
+
description: "'month' | 'quarter' | 'year' | 'custom'"
|
|
1520
|
+
},
|
|
1521
|
+
{
|
|
1522
|
+
name: "dateValue",
|
|
1523
|
+
required: true,
|
|
1524
|
+
description: "Period value"
|
|
1525
|
+
},
|
|
1526
|
+
{
|
|
1527
|
+
name: "organizationId",
|
|
1528
|
+
required: false,
|
|
1529
|
+
description: "Multi-tenant scoping"
|
|
1530
|
+
}
|
|
1531
|
+
]
|
|
1532
|
+
},
|
|
1533
|
+
{
|
|
1534
|
+
name: "revaluation",
|
|
1535
|
+
title: "Foreign Exchange Revaluation",
|
|
1536
|
+
description: "Unrealized FX gain/loss on foreign-currency accounts at a date.",
|
|
1537
|
+
params: [
|
|
1538
|
+
{
|
|
1539
|
+
name: "asOfDate",
|
|
1540
|
+
required: true,
|
|
1541
|
+
description: "Revaluation date"
|
|
1542
|
+
},
|
|
1543
|
+
{
|
|
1544
|
+
name: "rates",
|
|
1545
|
+
required: true,
|
|
1546
|
+
description: "Array of { currency, rate } at asOfDate"
|
|
1547
|
+
},
|
|
1548
|
+
{
|
|
1549
|
+
name: "unrealizedGainLossAccountId",
|
|
1550
|
+
required: true,
|
|
1551
|
+
description: "Account for FX P&L"
|
|
1552
|
+
},
|
|
1553
|
+
{
|
|
1554
|
+
name: "generateEntry",
|
|
1555
|
+
required: false,
|
|
1556
|
+
description: "Auto-create adjustment entry"
|
|
1557
|
+
},
|
|
1558
|
+
{
|
|
1559
|
+
name: "organizationId",
|
|
1560
|
+
required: false,
|
|
1561
|
+
description: "Multi-tenant scoping"
|
|
1562
|
+
}
|
|
1563
|
+
]
|
|
1564
|
+
}
|
|
1565
|
+
]);
|
|
1566
|
+
function buildIntrospectAPI({ models, country, config }) {
|
|
1567
|
+
const AccountModel = models.Account;
|
|
1568
|
+
const FiscalPeriodModel = models.FiscalPeriod;
|
|
1569
|
+
const orgField = config.multiTenant?.orgField;
|
|
1570
|
+
const normalBalanceFor = (category) => {
|
|
1571
|
+
if (category.endsWith("Asset") || category.endsWith("Expense")) return "debit";
|
|
1572
|
+
return "credit";
|
|
1573
|
+
};
|
|
1574
|
+
const accounts = async (organizationId, session = null) => {
|
|
1575
|
+
const filter = {};
|
|
1576
|
+
if (orgField && organizationId != null) filter[orgField] = organizationId;
|
|
1577
|
+
return (await AccountModel.find(filter).session(session).lean()).map((doc) => {
|
|
1578
|
+
const code = String(doc.accountTypeCode ?? "");
|
|
1579
|
+
const at = country.getAccountType(code);
|
|
1580
|
+
const category = at?.category ?? "Unknown";
|
|
1581
|
+
return {
|
|
1582
|
+
id: String(doc._id),
|
|
1583
|
+
code,
|
|
1584
|
+
name: at?.name ?? code,
|
|
1585
|
+
category,
|
|
1586
|
+
normalBalance: normalBalanceFor(category),
|
|
1587
|
+
parentCode: at?.parentCode ?? null,
|
|
1588
|
+
isPosting: country.isPostingAccount(code),
|
|
1589
|
+
active: doc.active !== false,
|
|
1590
|
+
...orgField && doc[orgField] != null ? { organizationId: String(doc[orgField]) } : {}
|
|
1591
|
+
};
|
|
1592
|
+
});
|
|
1593
|
+
};
|
|
1594
|
+
const journalTypes = () => {
|
|
1595
|
+
const builtIn = Object.values(JOURNAL_TYPES);
|
|
1596
|
+
const custom = getCustomJournalTypes();
|
|
1597
|
+
return Object.freeze([...builtIn, ...custom]);
|
|
1598
|
+
};
|
|
1599
|
+
const reports = () => REPORT_CATALOG;
|
|
1600
|
+
const taxCodes = (region) => {
|
|
1601
|
+
if (region) return Object.freeze(country.getTaxCodesForRegion(region));
|
|
1602
|
+
return Object.freeze(Object.values(country.taxCodes));
|
|
1603
|
+
};
|
|
1604
|
+
const fiscalPeriods = async (organizationId, session = null) => {
|
|
1605
|
+
const filter = {};
|
|
1606
|
+
if (orgField && organizationId != null) filter[orgField] = organizationId;
|
|
1607
|
+
return (await FiscalPeriodModel.find(filter).sort({ startDate: 1 }).session(session).lean()).map((doc) => ({
|
|
1608
|
+
id: String(doc._id),
|
|
1609
|
+
name: String(doc.name ?? ""),
|
|
1610
|
+
startDate: doc.startDate,
|
|
1611
|
+
endDate: doc.endDate,
|
|
1612
|
+
closed: Boolean(doc.closed),
|
|
1613
|
+
...doc.closedAt ? { closedAt: doc.closedAt } : {},
|
|
1614
|
+
...orgField && doc[orgField] != null ? { organizationId: String(doc[orgField]) } : {}
|
|
1615
|
+
}));
|
|
1616
|
+
};
|
|
1617
|
+
const catalog = async (organizationId) => ({
|
|
1618
|
+
accounts: await accounts(organizationId),
|
|
1619
|
+
journalTypes: journalTypes(),
|
|
1620
|
+
reports: reports(),
|
|
1621
|
+
taxCodes: taxCodes(),
|
|
1622
|
+
fiscalPeriods: await fiscalPeriods(organizationId)
|
|
1623
|
+
});
|
|
1624
|
+
return {
|
|
1625
|
+
accounts,
|
|
1626
|
+
journalTypes,
|
|
1627
|
+
reports,
|
|
1628
|
+
taxCodes,
|
|
1629
|
+
fiscalPeriods,
|
|
1630
|
+
catalog
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
//#endregion
|
|
1634
|
+
//#region src/semantic/record.ts
|
|
1635
|
+
function buildRecordAPI({ models, repositories, country, config }) {
|
|
1636
|
+
const AccountModel = models.Account;
|
|
1637
|
+
const orgField = config.multiTenant?.orgField;
|
|
1638
|
+
const lookupTaxRate = (taxCode) => {
|
|
1639
|
+
const tc = country.taxCodes[taxCode];
|
|
1640
|
+
if (!tc) throw Errors.notFound(`Tax code '${taxCode}' not found in country pack.`, [{
|
|
1641
|
+
path: "tax.code",
|
|
1642
|
+
issue: "unknown tax code",
|
|
1643
|
+
value: taxCode
|
|
1644
|
+
}]);
|
|
1645
|
+
return tc.rate;
|
|
1646
|
+
};
|
|
1647
|
+
const resolveAccounts = async (organizationId, codes, path, session) => {
|
|
1648
|
+
const unique = Array.from(new Set(codes));
|
|
1649
|
+
const filter = { accountTypeCode: { $in: unique } };
|
|
1650
|
+
if (orgField && organizationId != null) filter[orgField] = organizationId;
|
|
1651
|
+
const docs = await AccountModel.find(filter).select(`_id accountTypeCode`).session(session ?? null).lean();
|
|
1652
|
+
const map = /* @__PURE__ */ new Map();
|
|
1653
|
+
for (const d of docs) if (!map.has(d.accountTypeCode)) map.set(d.accountTypeCode, d._id);
|
|
1654
|
+
const missing = unique.filter((c) => !map.has(c));
|
|
1655
|
+
if (missing.length > 0) throw Errors.notFound(`Account(s) not found in ${orgField && organizationId ? "org" : "default"} chart of accounts: ${missing.join(", ")}. Seed them first via engine.repositories.accounts.seedAccounts().`, missing.map((code) => ({
|
|
1656
|
+
path,
|
|
1657
|
+
issue: "account type code not found in chart of accounts",
|
|
1658
|
+
value: code
|
|
1659
|
+
})));
|
|
1660
|
+
return map;
|
|
1661
|
+
};
|
|
1662
|
+
const validateAmount = (amount, path = "amount") => {
|
|
1663
|
+
if (!Number.isInteger(amount)) throw Errors.validation(`Amount must be an integer (cents), got ${amount}.`, [{
|
|
1664
|
+
path,
|
|
1665
|
+
issue: "must be an integer",
|
|
1666
|
+
value: amount
|
|
1667
|
+
}]);
|
|
1668
|
+
if (amount <= 0) throw Errors.validation(`Amount must be positive, got ${amount}.`, [{
|
|
1669
|
+
path,
|
|
1670
|
+
issue: "must be positive",
|
|
1671
|
+
value: amount
|
|
1672
|
+
}]);
|
|
1673
|
+
};
|
|
1674
|
+
const postEntry = async (organizationId, payload, options) => {
|
|
1675
|
+
if (orgField && organizationId != null) payload[orgField] = organizationId;
|
|
1676
|
+
const actorId = options?.actorId ?? (options?.user ? options.user._id?.toString() ?? options.user.id?.toString() : void 0);
|
|
1677
|
+
if (actorId) {
|
|
1678
|
+
payload.createdBy = actorId;
|
|
1679
|
+
payload.postedBy = actorId;
|
|
1680
|
+
}
|
|
1681
|
+
if (options?.idempotencyKey) payload.idempotencyKey = options.idempotencyKey;
|
|
1682
|
+
payload.state = "posted";
|
|
1683
|
+
const ctx = { session: options?.session ?? void 0 };
|
|
1684
|
+
if (options?.user) ctx.user = options.user;
|
|
1685
|
+
if (orgField && organizationId != null) ctx.organizationId = organizationId;
|
|
1686
|
+
if (options) {
|
|
1687
|
+
for (const key of Object.keys(options)) if (key !== "session" && key !== "user" && key !== "actorId" && key !== "idempotencyKey") ctx[key] = options[key];
|
|
1688
|
+
}
|
|
1689
|
+
return repositories.journalEntries.create(payload, ctx);
|
|
1690
|
+
};
|
|
1691
|
+
const buildItem = (account, debit, credit, label, dimensions) => ({
|
|
1692
|
+
account,
|
|
1693
|
+
debit,
|
|
1694
|
+
credit,
|
|
1695
|
+
...label ? { label } : {},
|
|
1696
|
+
...dimensions ?? {}
|
|
1697
|
+
});
|
|
1698
|
+
const sale = async (organizationId, input, options) => {
|
|
1699
|
+
validateAmount(input.amount, "amount");
|
|
1700
|
+
let baseAmount = input.amount;
|
|
1701
|
+
let taxAmount = 0;
|
|
1702
|
+
if (input.tax) {
|
|
1703
|
+
const rate = lookupTaxRate(input.tax.code);
|
|
1704
|
+
if (input.tax.inclusive) {
|
|
1705
|
+
const split = splitTaxInclusive(input.amount, rate);
|
|
1706
|
+
baseAmount = split.base;
|
|
1707
|
+
taxAmount = split.tax;
|
|
1708
|
+
} else {
|
|
1709
|
+
const split = splitTaxExclusive(input.amount, rate);
|
|
1710
|
+
baseAmount = split.base;
|
|
1711
|
+
taxAmount = split.tax;
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
const totalCharge = baseAmount + taxAmount;
|
|
1715
|
+
const codes = [input.receivableAccount, input.revenueAccount];
|
|
1716
|
+
if (input.tax) codes.push(input.tax.account);
|
|
1717
|
+
const acctMap = await resolveAccounts(organizationId, codes, "receivableAccount", options?.session ?? null);
|
|
1718
|
+
const items = [buildItem(acctMap.get(input.receivableAccount), totalCharge, 0, input.label, input.dimensions), buildItem(acctMap.get(input.revenueAccount), 0, baseAmount, input.label, input.dimensions)];
|
|
1719
|
+
if (input.tax) items.push(buildItem(acctMap.get(input.tax.account), 0, taxAmount, `${input.label ?? "Sale"} — ${input.tax.code}`, input.dimensions));
|
|
1720
|
+
return postEntry(organizationId, {
|
|
1721
|
+
journalType: input.journalType ?? "SALES",
|
|
1722
|
+
date: input.date,
|
|
1723
|
+
label: input.label,
|
|
1724
|
+
referenceNumber: input.reference,
|
|
1725
|
+
journalItems: items
|
|
1726
|
+
}, options);
|
|
1727
|
+
};
|
|
1728
|
+
const expense = async (organizationId, input, options) => {
|
|
1729
|
+
validateAmount(input.amount, "amount");
|
|
1730
|
+
let baseAmount = input.amount;
|
|
1731
|
+
let taxAmount = 0;
|
|
1732
|
+
if (input.tax) {
|
|
1733
|
+
const rate = lookupTaxRate(input.tax.code);
|
|
1734
|
+
if (input.tax.inclusive) {
|
|
1735
|
+
const split = splitTaxInclusive(input.amount, rate);
|
|
1736
|
+
baseAmount = split.base;
|
|
1737
|
+
taxAmount = split.tax;
|
|
1738
|
+
} else {
|
|
1739
|
+
const split = splitTaxExclusive(input.amount, rate);
|
|
1740
|
+
baseAmount = split.base;
|
|
1741
|
+
taxAmount = split.tax;
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
const totalPaid = baseAmount + taxAmount;
|
|
1745
|
+
const codes = [input.expenseAccount, input.paidFromAccount];
|
|
1746
|
+
if (input.tax) codes.push(input.tax.account);
|
|
1747
|
+
const acctMap = await resolveAccounts(organizationId, codes, "expenseAccount", options?.session ?? null);
|
|
1748
|
+
const items = [buildItem(acctMap.get(input.expenseAccount), baseAmount, 0, input.label, input.dimensions)];
|
|
1749
|
+
if (input.tax) items.push(buildItem(acctMap.get(input.tax.account), taxAmount, 0, `${input.label ?? "Expense"} — ${input.tax.code} ITC`, input.dimensions));
|
|
1750
|
+
items.push(buildItem(acctMap.get(input.paidFromAccount), 0, totalPaid, input.label, input.dimensions));
|
|
1751
|
+
return postEntry(organizationId, {
|
|
1752
|
+
journalType: input.journalType ?? "PURCHASES",
|
|
1753
|
+
date: input.date,
|
|
1754
|
+
label: input.label,
|
|
1755
|
+
referenceNumber: input.reference,
|
|
1756
|
+
journalItems: items
|
|
1757
|
+
}, options);
|
|
1758
|
+
};
|
|
1759
|
+
const transfer = async (organizationId, input, options) => {
|
|
1760
|
+
validateAmount(input.amount, "amount");
|
|
1761
|
+
if (input.fromAccount === input.toAccount) throw Errors.validation("Transfer source and destination accounts must be different.", [{
|
|
1762
|
+
path: "fromAccount",
|
|
1763
|
+
issue: "must differ from toAccount",
|
|
1764
|
+
value: {
|
|
1765
|
+
from: input.fromAccount,
|
|
1766
|
+
to: input.toAccount
|
|
1767
|
+
}
|
|
1768
|
+
}]);
|
|
1769
|
+
const acctMap = await resolveAccounts(organizationId, [input.fromAccount, input.toAccount], "fromAccount", options?.session ?? null);
|
|
1770
|
+
const items = [buildItem(acctMap.get(input.toAccount), input.amount, 0, input.label, input.dimensions), buildItem(acctMap.get(input.fromAccount), 0, input.amount, input.label, input.dimensions)];
|
|
1771
|
+
return postEntry(organizationId, {
|
|
1772
|
+
journalType: input.journalType ?? "GENERAL",
|
|
1773
|
+
date: input.date,
|
|
1774
|
+
label: input.label,
|
|
1775
|
+
referenceNumber: input.reference,
|
|
1776
|
+
journalItems: items
|
|
1777
|
+
}, options);
|
|
1778
|
+
};
|
|
1779
|
+
const payment = async (organizationId, input, options) => {
|
|
1780
|
+
validateAmount(input.amount, "amount");
|
|
1781
|
+
const acctMap = await resolveAccounts(organizationId, [input.fromReceivableAccount, input.toCashAccount], "fromReceivableAccount", options?.session ?? null);
|
|
1782
|
+
const items = [buildItem(acctMap.get(input.toCashAccount), input.amount, 0, input.label, input.dimensions), buildItem(acctMap.get(input.fromReceivableAccount), 0, input.amount, input.label, input.dimensions)];
|
|
1783
|
+
return postEntry(organizationId, {
|
|
1784
|
+
journalType: input.journalType ?? "CASH_RECEIPTS",
|
|
1785
|
+
date: input.date,
|
|
1786
|
+
label: input.label,
|
|
1787
|
+
referenceNumber: input.reference,
|
|
1788
|
+
journalItems: items
|
|
1789
|
+
}, options);
|
|
1790
|
+
};
|
|
1791
|
+
const adjustment = async (organizationId, input, options) => {
|
|
1792
|
+
if (!input.lines || input.lines.length < 2) throw Errors.validation("Adjustment requires at least 2 lines.", [{
|
|
1793
|
+
path: "lines",
|
|
1794
|
+
issue: "must contain at least 2 entries",
|
|
1795
|
+
value: input.lines?.length
|
|
1796
|
+
}]);
|
|
1797
|
+
const lineErrors = [];
|
|
1798
|
+
let totalDebit = 0;
|
|
1799
|
+
let totalCredit = 0;
|
|
1800
|
+
input.lines.forEach((line, idx) => {
|
|
1801
|
+
const d = line.debit ?? 0;
|
|
1802
|
+
const c = line.credit ?? 0;
|
|
1803
|
+
if (!Number.isInteger(d) || d < 0) lineErrors.push({
|
|
1804
|
+
path: `lines.${idx}.debit`,
|
|
1805
|
+
issue: "must be a non-negative integer",
|
|
1806
|
+
value: d
|
|
1807
|
+
});
|
|
1808
|
+
if (!Number.isInteger(c) || c < 0) lineErrors.push({
|
|
1809
|
+
path: `lines.${idx}.credit`,
|
|
1810
|
+
issue: "must be a non-negative integer",
|
|
1811
|
+
value: c
|
|
1812
|
+
});
|
|
1813
|
+
if (d > 0 && c > 0) lineErrors.push({
|
|
1814
|
+
path: `lines.${idx}`,
|
|
1815
|
+
issue: "line cannot have both debit and credit",
|
|
1816
|
+
value: {
|
|
1817
|
+
debit: d,
|
|
1818
|
+
credit: c
|
|
1819
|
+
}
|
|
1820
|
+
});
|
|
1821
|
+
if (d === 0 && c === 0) lineErrors.push({
|
|
1822
|
+
path: `lines.${idx}`,
|
|
1823
|
+
issue: "line must have a non-zero debit or credit",
|
|
1824
|
+
value: {
|
|
1825
|
+
debit: 0,
|
|
1826
|
+
credit: 0
|
|
1827
|
+
}
|
|
1828
|
+
});
|
|
1829
|
+
totalDebit += d;
|
|
1830
|
+
totalCredit += c;
|
|
1831
|
+
});
|
|
1832
|
+
if (lineErrors.length > 0) throw Errors.validation(`Invalid adjustment lines: ${lineErrors.length} issue(s).`, lineErrors);
|
|
1833
|
+
if (totalDebit !== totalCredit) throw Errors.validation(`Adjustment not balanced: debits (${totalDebit}) ≠ credits (${totalCredit}).`, [{
|
|
1834
|
+
path: "lines",
|
|
1835
|
+
issue: "debits must equal credits",
|
|
1836
|
+
value: {
|
|
1837
|
+
totalDebit,
|
|
1838
|
+
totalCredit,
|
|
1839
|
+
difference: totalDebit - totalCredit
|
|
1840
|
+
}
|
|
1841
|
+
}]);
|
|
1842
|
+
const acctMap = await resolveAccounts(organizationId, input.lines.map((l) => l.account), "lines", options?.session ?? null);
|
|
1843
|
+
const items = input.lines.map((line) => buildItem(acctMap.get(line.account), line.debit ?? 0, line.credit ?? 0, line.label ?? input.label, input.dimensions));
|
|
1844
|
+
return postEntry(organizationId, {
|
|
1845
|
+
journalType: input.journalType ?? "GENERAL",
|
|
1846
|
+
date: input.date,
|
|
1847
|
+
label: input.label,
|
|
1848
|
+
referenceNumber: input.reference,
|
|
1849
|
+
journalItems: items
|
|
1850
|
+
}, options);
|
|
1851
|
+
};
|
|
1852
|
+
return {
|
|
1853
|
+
sale,
|
|
1854
|
+
expense,
|
|
1855
|
+
transfer,
|
|
1856
|
+
payment,
|
|
1857
|
+
adjustment
|
|
98
1858
|
};
|
|
99
1859
|
}
|
|
100
1860
|
//#endregion
|
|
@@ -104,77 +1864,58 @@ var AccountingEngine = class {
|
|
|
104
1864
|
country;
|
|
105
1865
|
currency;
|
|
106
1866
|
money = Money;
|
|
107
|
-
|
|
108
|
-
|
|
1867
|
+
models;
|
|
1868
|
+
repositories;
|
|
1869
|
+
record;
|
|
1870
|
+
introspect;
|
|
109
1871
|
_reports;
|
|
110
|
-
constructor(config
|
|
1872
|
+
constructor(config) {
|
|
1873
|
+
if (!config.mongoose) throw new Error("createAccountingEngine: `mongoose` connection is required. Pass `mongoose: mongoose.connection` in config.");
|
|
111
1874
|
this.config = config;
|
|
112
1875
|
this.country = config.country;
|
|
113
1876
|
this.currency = config.currency;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
/**
|
|
129
|
-
* Auto-wired repositories with plugins + domain methods (post, reverse, etc.).
|
|
130
|
-
* Requires `mongoose` in config.
|
|
131
|
-
*
|
|
132
|
-
* @throws if `mongoose` was not provided in config
|
|
133
|
-
*/
|
|
134
|
-
get repositories() {
|
|
135
|
-
if (!this._repositories) throw new Error("engine.repositories requires a Mongoose connection. Pass `mongoose: connection` in createAccountingEngine config.");
|
|
136
|
-
return this._repositories;
|
|
1877
|
+
this.models = createModels(config.mongoose, config);
|
|
1878
|
+
this.repositories = createRepositories(this.models, config, config.plugins ?? {}, config.pagination ?? {});
|
|
1879
|
+
this.record = buildRecordAPI({
|
|
1880
|
+
models: this.models,
|
|
1881
|
+
repositories: this.repositories,
|
|
1882
|
+
country: this.country,
|
|
1883
|
+
config: this.config
|
|
1884
|
+
});
|
|
1885
|
+
this.introspect = buildIntrospectAPI({
|
|
1886
|
+
models: this.models,
|
|
1887
|
+
country: this.country,
|
|
1888
|
+
config: this.config
|
|
1889
|
+
});
|
|
137
1890
|
}
|
|
138
1891
|
/**
|
|
139
|
-
* Pre-built reports bound to
|
|
140
|
-
*
|
|
1892
|
+
* Pre-built reports bound to the engine's owned models.
|
|
1893
|
+
* Lazy-initialized on first access.
|
|
141
1894
|
*/
|
|
142
1895
|
get reports() {
|
|
143
|
-
if (!this._reports)
|
|
144
|
-
if (!this._models) throw new Error("engine.reports requires a Mongoose connection. Pass `mongoose: connection` in createAccountingEngine config, or use engine.createReports({ Account, JournalEntry }) for manual models.");
|
|
145
|
-
this._reports = this._buildReports({
|
|
146
|
-
Account: this._models.Account,
|
|
147
|
-
JournalEntry: this._models.JournalEntry,
|
|
148
|
-
Budget: this._models.Budget
|
|
149
|
-
});
|
|
150
|
-
}
|
|
1896
|
+
if (!this._reports) this._reports = this._buildReports();
|
|
151
1897
|
return this._reports;
|
|
152
1898
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
createJournalEntrySchema(accountModelName, options) {
|
|
157
|
-
return createJournalEntrySchema(this.config, accountModelName, options);
|
|
158
|
-
}
|
|
159
|
-
createFiscalPeriodSchema(options) {
|
|
160
|
-
return createFiscalPeriodSchema(this.config, options);
|
|
1899
|
+
/** Get all posting account types (accounts you can post transactions to) */
|
|
1900
|
+
getPostingAccountTypes() {
|
|
1901
|
+
return this.country.getPostingAccountTypes();
|
|
161
1902
|
}
|
|
162
|
-
|
|
163
|
-
|
|
1903
|
+
/** Validate an account type code */
|
|
1904
|
+
isValidAccountType(code) {
|
|
1905
|
+
return this.country.isValidAccountType(code);
|
|
164
1906
|
}
|
|
165
|
-
|
|
166
|
-
|
|
1907
|
+
/** Get account type definition by code */
|
|
1908
|
+
getAccountType(code) {
|
|
1909
|
+
return this.country.getAccountType(code);
|
|
167
1910
|
}
|
|
168
|
-
/**
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
* owns the models (via `mongoose` in config).
|
|
172
|
-
*/
|
|
173
|
-
createReports(models) {
|
|
174
|
-
return this._buildReports(models);
|
|
1911
|
+
/** Get tax codes for a region */
|
|
1912
|
+
getTaxCodesForRegion(region) {
|
|
1913
|
+
return this.country.getTaxCodesForRegion(region);
|
|
175
1914
|
}
|
|
176
|
-
_buildReports(
|
|
177
|
-
const
|
|
1915
|
+
_buildReports() {
|
|
1916
|
+
const AccountModel = this.models.Account;
|
|
1917
|
+
const JournalEntryModel = this.models.JournalEntry;
|
|
1918
|
+
const BudgetModel = this.models.Budget;
|
|
178
1919
|
const { country, config } = this;
|
|
179
1920
|
const orgField = config.multiTenant?.orgField;
|
|
180
1921
|
const fiscalYearStartMonth = config.fiscalYearStartMonth ?? 1;
|
|
@@ -230,16 +1971,13 @@ var AccountingEngine = class {
|
|
|
230
1971
|
country,
|
|
231
1972
|
orgField
|
|
232
1973
|
}, params),
|
|
233
|
-
budgetVsActual: (params) => {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
orgField
|
|
241
|
-
}, params);
|
|
242
|
-
},
|
|
1974
|
+
budgetVsActual: (params) => generateBudgetVsActual({
|
|
1975
|
+
AccountModel,
|
|
1976
|
+
JournalEntryModel,
|
|
1977
|
+
BudgetModel,
|
|
1978
|
+
country,
|
|
1979
|
+
orgField
|
|
1980
|
+
}, params),
|
|
243
1981
|
revaluation: (params) => generateRevaluation({
|
|
244
1982
|
AccountModel,
|
|
245
1983
|
JournalEntryModel,
|
|
@@ -249,98 +1987,9 @@ var AccountingEngine = class {
|
|
|
249
1987
|
}, params)
|
|
250
1988
|
};
|
|
251
1989
|
}
|
|
252
|
-
/** Get all posting account types (accounts you can post transactions to) */
|
|
253
|
-
getPostingAccountTypes() {
|
|
254
|
-
return this.country.getPostingAccountTypes();
|
|
255
|
-
}
|
|
256
|
-
/** Validate an account type code */
|
|
257
|
-
isValidAccountType(code) {
|
|
258
|
-
return this.country.isValidAccountType(code);
|
|
259
|
-
}
|
|
260
|
-
/** Get account type definition by code */
|
|
261
|
-
getAccountType(code) {
|
|
262
|
-
return this.country.getAccountType(code);
|
|
263
|
-
}
|
|
264
|
-
/** Get tax codes for a region */
|
|
265
|
-
getTaxCodesForRegion(region) {
|
|
266
|
-
return this.country.getTaxCodesForRegion(region);
|
|
267
|
-
}
|
|
268
|
-
/**
|
|
269
|
-
* Create a fully-configured journal entry repository with secure plugin wiring.
|
|
270
|
-
* This is the **recommended** way to set up journal entry repositories.
|
|
271
|
-
*
|
|
272
|
-
* Includes:
|
|
273
|
-
* - Double-entry plugin with account existence + tenant integrity validation
|
|
274
|
-
* - Fiscal lock plugin (when FiscalPeriodModel is provided)
|
|
275
|
-
* - post(), unpost(), reverse(), and duplicate() domain methods
|
|
276
|
-
*
|
|
277
|
-
* @param createRepository - The `createRepository` function from @classytic/mongokit
|
|
278
|
-
* @param models.JournalEntryModel - Mongoose model for journal entries
|
|
279
|
-
* @param models.AccountModel - Mongoose model for accounts (required for secure posted-create validation)
|
|
280
|
-
* @param models.FiscalPeriodModel - Mongoose model for fiscal periods (optional, enables fiscal lock)
|
|
281
|
-
* @param additionalPlugins - Extra plugins to include (e.g. timestampPlugin)
|
|
282
|
-
* @returns A wired repository with post(), unpost(), reverse(), duplicate(), and all plugins configured
|
|
283
|
-
*/
|
|
284
|
-
createJournalEntryRepository(createRepository, models, additionalPlugins = []) {
|
|
285
|
-
const orgField = this.config.multiTenant?.orgField;
|
|
286
|
-
const { JournalEntryModel, AccountModel, FiscalPeriodModel } = models;
|
|
287
|
-
const jeModel = JournalEntryModel;
|
|
288
|
-
const plugins = [...additionalPlugins, doubleEntryPlugin({
|
|
289
|
-
JournalEntryModel: jeModel,
|
|
290
|
-
AccountModel,
|
|
291
|
-
orgField
|
|
292
|
-
})];
|
|
293
|
-
if (FiscalPeriodModel) plugins.push(fiscalLockPlugin({
|
|
294
|
-
FiscalPeriodModel,
|
|
295
|
-
JournalEntryModel: jeModel,
|
|
296
|
-
orgField
|
|
297
|
-
}));
|
|
298
|
-
if (this.config.idempotency) plugins.push(idempotencyPlugin({
|
|
299
|
-
JournalEntryModel: jeModel,
|
|
300
|
-
orgField
|
|
301
|
-
}));
|
|
302
|
-
return wireJournalEntryMethods(createRepository(JournalEntryModel, plugins), JournalEntryModel, orgField, this.config.strictness);
|
|
303
|
-
}
|
|
304
|
-
/**
|
|
305
|
-
* Wire post/reverse domain methods onto a mongokit Repository
|
|
306
|
-
* for journal entries. The repository must already be created via
|
|
307
|
-
* `createRepository(Model, plugins)` from @classytic/mongokit.
|
|
308
|
-
*
|
|
309
|
-
* **Note:** Prefer `createJournalEntryRepository()` which guarantees
|
|
310
|
-
* secure plugin wiring. This method only adds domain methods and does
|
|
311
|
-
* not validate plugin configuration.
|
|
312
|
-
*
|
|
313
|
-
* @param repository - An existing mongokit Repository instance
|
|
314
|
-
* @param JournalEntryModel - The Mongoose model for journal entries
|
|
315
|
-
* @returns The same repository, now with `.post()` and `.reverse()`
|
|
316
|
-
*/
|
|
317
|
-
wireJournalEntryRepository(repository, JournalEntryModel) {
|
|
318
|
-
const orgField = this.config.multiTenant?.orgField;
|
|
319
|
-
return wireJournalEntryMethods(repository, JournalEntryModel, orgField, this.config.strictness);
|
|
320
|
-
}
|
|
321
|
-
/**
|
|
322
|
-
* Wire seedAccounts/bulkCreate and posting-account validation onto a
|
|
323
|
-
* mongokit Repository for accounts. The repository must already be
|
|
324
|
-
* created via `createRepository(Model, plugins)` from @classytic/mongokit.
|
|
325
|
-
*
|
|
326
|
-
* @param repository - An existing mongokit Repository instance
|
|
327
|
-
* @param AccountModel - The Mongoose model for accounts
|
|
328
|
-
* @returns The same repository, now with `.seedAccounts()` and `.bulkCreate()`
|
|
329
|
-
*/
|
|
330
|
-
wireAccountRepository(repository, AccountModel) {
|
|
331
|
-
const orgField = this.config.multiTenant?.orgField;
|
|
332
|
-
return wireAccountMethods(repository, AccountModel, this.country, orgField);
|
|
333
|
-
}
|
|
334
|
-
/**
|
|
335
|
-
* Wire reconcile/unreconcile/getUnreconciled methods onto a mongokit Repository.
|
|
336
|
-
*/
|
|
337
|
-
wireReconciliationRepository(repository, ReconciliationModel, JournalEntryModel) {
|
|
338
|
-
const orgField = this.config.multiTenant?.orgField;
|
|
339
|
-
return wireReconciliationMethods(repository, ReconciliationModel, JournalEntryModel, orgField);
|
|
340
|
-
}
|
|
341
1990
|
};
|
|
342
|
-
function createAccountingEngine(config
|
|
343
|
-
return new AccountingEngine(config
|
|
1991
|
+
function createAccountingEngine(config) {
|
|
1992
|
+
return new AccountingEngine(config);
|
|
344
1993
|
}
|
|
345
1994
|
//#endregion
|
|
346
1995
|
//#region src/utils/dimensions.ts
|
|
@@ -402,4 +2051,4 @@ function buildDimensionIndexes(dimensions, orgField) {
|
|
|
402
2051
|
});
|
|
403
2052
|
}
|
|
404
2053
|
//#endregion
|
|
405
|
-
export { AccountingEngine, AccountingError, CATEGORIES, CATEGORY_KEYS, CURRENCIES, DEFAULT_BUCKETS, Errors, JOURNAL_CODES, JOURNAL_TYPES, Money, acquireSession, add, allocate, buildAccountTypeMap, buildDimensionFields, buildDimensionIndexes, buildItemFilters, buildRevaluationEntry, calculateTotal, closeFiscalPeriod, computeEndingBalance, computeRevaluation,
|
|
2054
|
+
export { AccountingEngine, AccountingError, CATEGORIES, CATEGORY_KEYS, CURRENCIES, DEFAULT_BUCKETS, Errors, JOURNAL_CODES, JOURNAL_TYPES, Money, acquireSession, add, allocate, buildAccountTypeMap, buildDimensionFields, buildDimensionIndexes, buildItemFilters, buildRevaluationEntry, calculateTotal, closeFiscalPeriod, computeEndingBalance, computeRevaluation, createAccountingEngine, createModels, createRepositories, dateLockPlugin, defaultLogger, defineCountryPack, doubleEntryPlugin, exportToCsv, finalizeSession, fiscalLockPlugin, flattenJournalEntries, format, formatPlain, fromDecimal, generateAgedBalance, generateBalanceSheet, generateBudgetVsActual, generateCashFlow, generateDimensionBreakdown, generateGeneralLedger, generateIncomeStatement, generateRevaluation, generateTrialBalance, getCurrency, getCustomJournalTypes, getDateRange, getFiscalYearStart, getJournalType, getJournalTypeCodes, getMinorUnit, getNormalBalance, idempotencyPlugin, isBalanceSheet, isIncomeStatement, isValidCategory, isValidCurrency, isValidJournalType, isVirtualTaxAccount, multiply, parseCents, percentage, quickbooksFieldMap, registerJournalType, reopenFiscalPeriod, resolveModelNames, splitTaxExclusive, splitTaxInclusive, subtract, toDecimal, universalFieldMap };
|