@classytic/ledger 0.4.2 → 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 +525 -344
- package/dist/index.mjs +1814 -170
- 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
|
@@ -1,666 +0,0 @@
|
|
|
1
|
-
import { o as getJournalTypeCodes, r as _freezeJournalTypes, t as JOURNAL_CODES } from "./journals-BfwnCFam.mjs";
|
|
2
|
-
import mongoose from "mongoose";
|
|
3
|
-
//#region src/schemas/currency-field.ts
|
|
4
|
-
/**
|
|
5
|
-
* Build the Mongoose currency field definition.
|
|
6
|
-
* Returns `null` if multi-currency is not enabled.
|
|
7
|
-
*/
|
|
8
|
-
function buildCurrencyField(config) {
|
|
9
|
-
if (!config.multiCurrency?.enabled) return null;
|
|
10
|
-
const allowed = config.multiCurrency.currencies;
|
|
11
|
-
return {
|
|
12
|
-
type: String,
|
|
13
|
-
default: null,
|
|
14
|
-
...allowed?.length ? { enum: [
|
|
15
|
-
null,
|
|
16
|
-
config.currency,
|
|
17
|
-
...allowed
|
|
18
|
-
] } : {}
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
//#endregion
|
|
22
|
-
//#region src/schemas/account.schema.ts
|
|
23
|
-
/**
|
|
24
|
-
* Account Schema Factory
|
|
25
|
-
*
|
|
26
|
-
* Creates a Mongoose schema for Chart of Accounts that is:
|
|
27
|
-
* - Multi-tenant aware (adds org field + compound indexes when configured)
|
|
28
|
-
* - Validates accountTypeCode against the country pack
|
|
29
|
-
* - Supports accountNumber (unique per org) and name (user-facing display)
|
|
30
|
-
* - Lean: no cached balances — always computed from journal entries
|
|
31
|
-
*/
|
|
32
|
-
function createAccountSchema(config, options = {}) {
|
|
33
|
-
const { multiTenant, country } = config;
|
|
34
|
-
const { indexes = true, extraFields = {}, extraIndexes = [] } = options;
|
|
35
|
-
const fields = {
|
|
36
|
-
accountTypeCode: {
|
|
37
|
-
type: String,
|
|
38
|
-
required: true,
|
|
39
|
-
validate: {
|
|
40
|
-
validator: (code) => country.isValidAccountType(code),
|
|
41
|
-
message: (props) => `"${props.value}" is not a valid account type code for ${country.name}.`
|
|
42
|
-
}
|
|
43
|
-
},
|
|
44
|
-
accountNumber: {
|
|
45
|
-
type: String,
|
|
46
|
-
required: true,
|
|
47
|
-
trim: true
|
|
48
|
-
},
|
|
49
|
-
name: {
|
|
50
|
-
type: String,
|
|
51
|
-
required: true,
|
|
52
|
-
trim: true
|
|
53
|
-
},
|
|
54
|
-
active: {
|
|
55
|
-
type: Boolean,
|
|
56
|
-
default: true
|
|
57
|
-
},
|
|
58
|
-
isCashAccount: {
|
|
59
|
-
type: Boolean,
|
|
60
|
-
default: false
|
|
61
|
-
}
|
|
62
|
-
};
|
|
63
|
-
const currencyField = buildCurrencyField(config);
|
|
64
|
-
if (currencyField) fields.currency = currencyField;
|
|
65
|
-
Object.assign(fields, extraFields);
|
|
66
|
-
if (multiTenant) fields[multiTenant.orgField] = {
|
|
67
|
-
type: mongoose.Schema.Types.ObjectId,
|
|
68
|
-
ref: multiTenant.orgRef,
|
|
69
|
-
required: true
|
|
70
|
-
};
|
|
71
|
-
const schema = new mongoose.Schema(fields, { timestamps: true });
|
|
72
|
-
schema.pre("validate", function() {
|
|
73
|
-
if (!this.accountNumber && this.accountTypeCode) this.accountNumber = this.accountTypeCode;
|
|
74
|
-
if (!this.name && this.accountTypeCode) this.name = country.getAccountType(this.accountTypeCode)?.name ?? this.accountTypeCode;
|
|
75
|
-
});
|
|
76
|
-
if (indexes) if (multiTenant) {
|
|
77
|
-
const org = multiTenant.orgField;
|
|
78
|
-
schema.index({
|
|
79
|
-
[org]: 1,
|
|
80
|
-
active: 1
|
|
81
|
-
});
|
|
82
|
-
schema.index({
|
|
83
|
-
[org]: 1,
|
|
84
|
-
accountNumber: 1
|
|
85
|
-
}, { unique: true });
|
|
86
|
-
schema.index({
|
|
87
|
-
[org]: 1,
|
|
88
|
-
accountTypeCode: 1
|
|
89
|
-
});
|
|
90
|
-
} else {
|
|
91
|
-
schema.index({ active: 1 });
|
|
92
|
-
schema.index({ accountNumber: 1 }, { unique: true });
|
|
93
|
-
schema.index({ accountTypeCode: 1 });
|
|
94
|
-
}
|
|
95
|
-
for (const idx of extraIndexes) schema.index(idx.fields, idx.options);
|
|
96
|
-
return schema;
|
|
97
|
-
}
|
|
98
|
-
//#endregion
|
|
99
|
-
//#region src/schemas/budget.schema.ts
|
|
100
|
-
/**
|
|
101
|
-
* Budget Schema Factory
|
|
102
|
-
*
|
|
103
|
-
* Creates a Mongoose schema for budget records.
|
|
104
|
-
* Each record represents a budgeted amount for an account over a specific period.
|
|
105
|
-
* All monetary amounts are in integer cents.
|
|
106
|
-
*/
|
|
107
|
-
function createBudgetSchema(config, options = {}) {
|
|
108
|
-
const { multiTenant } = config;
|
|
109
|
-
const { indexes = true, extraFields = {}, extraIndexes = [] } = options;
|
|
110
|
-
const fields = {
|
|
111
|
-
account: {
|
|
112
|
-
type: mongoose.Schema.Types.ObjectId,
|
|
113
|
-
ref: "Account",
|
|
114
|
-
required: true
|
|
115
|
-
},
|
|
116
|
-
periodStart: {
|
|
117
|
-
type: Date,
|
|
118
|
-
required: true
|
|
119
|
-
},
|
|
120
|
-
periodEnd: {
|
|
121
|
-
type: Date,
|
|
122
|
-
required: true
|
|
123
|
-
},
|
|
124
|
-
amount: {
|
|
125
|
-
type: Number,
|
|
126
|
-
required: true,
|
|
127
|
-
validate: {
|
|
128
|
-
validator: (v) => Number.isInteger(v),
|
|
129
|
-
message: "amount must be an integer (cents)."
|
|
130
|
-
}
|
|
131
|
-
},
|
|
132
|
-
label: {
|
|
133
|
-
type: String,
|
|
134
|
-
default: null
|
|
135
|
-
},
|
|
136
|
-
...extraFields
|
|
137
|
-
};
|
|
138
|
-
if (multiTenant) fields[multiTenant.orgField] = {
|
|
139
|
-
type: mongoose.Schema.Types.ObjectId,
|
|
140
|
-
ref: multiTenant.orgRef,
|
|
141
|
-
required: true
|
|
142
|
-
};
|
|
143
|
-
const schema = new mongoose.Schema(fields, { timestamps: true });
|
|
144
|
-
schema.pre("validate", function() {
|
|
145
|
-
const doc = this;
|
|
146
|
-
if (doc.periodStart && doc.periodEnd && doc.periodEnd <= doc.periodStart) doc.invalidate("periodEnd", "periodEnd must be after periodStart.", doc.periodEnd, "periodEnd");
|
|
147
|
-
});
|
|
148
|
-
if (indexes) if (multiTenant) {
|
|
149
|
-
const org = multiTenant.orgField;
|
|
150
|
-
schema.index({
|
|
151
|
-
[org]: 1,
|
|
152
|
-
account: 1,
|
|
153
|
-
periodStart: 1,
|
|
154
|
-
periodEnd: 1
|
|
155
|
-
}, { unique: true });
|
|
156
|
-
schema.index({
|
|
157
|
-
[org]: 1,
|
|
158
|
-
periodStart: 1,
|
|
159
|
-
periodEnd: 1
|
|
160
|
-
});
|
|
161
|
-
} else {
|
|
162
|
-
schema.index({
|
|
163
|
-
account: 1,
|
|
164
|
-
periodStart: 1,
|
|
165
|
-
periodEnd: 1
|
|
166
|
-
}, { unique: true });
|
|
167
|
-
schema.index({
|
|
168
|
-
periodStart: 1,
|
|
169
|
-
periodEnd: 1
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
for (const idx of extraIndexes) schema.index(idx.fields, idx.options);
|
|
173
|
-
return schema;
|
|
174
|
-
}
|
|
175
|
-
//#endregion
|
|
176
|
-
//#region src/schemas/fiscal-period.schema.ts
|
|
177
|
-
/**
|
|
178
|
-
* Fiscal Period Schema Factory
|
|
179
|
-
*
|
|
180
|
-
* Creates a Mongoose schema for tracking fiscal periods (months, quarters, years).
|
|
181
|
-
* Supports closing periods to lock entries.
|
|
182
|
-
*/
|
|
183
|
-
function createFiscalPeriodSchema(config, options = {}) {
|
|
184
|
-
const { multiTenant } = config;
|
|
185
|
-
const { indexes = true, extraFields = {}, extraIndexes = [] } = options;
|
|
186
|
-
const fields = {
|
|
187
|
-
name: {
|
|
188
|
-
type: String,
|
|
189
|
-
required: true
|
|
190
|
-
},
|
|
191
|
-
startDate: {
|
|
192
|
-
type: Date,
|
|
193
|
-
required: true
|
|
194
|
-
},
|
|
195
|
-
endDate: {
|
|
196
|
-
type: Date,
|
|
197
|
-
required: true
|
|
198
|
-
},
|
|
199
|
-
closed: {
|
|
200
|
-
type: Boolean,
|
|
201
|
-
default: false
|
|
202
|
-
},
|
|
203
|
-
closedAt: {
|
|
204
|
-
type: Date,
|
|
205
|
-
default: null
|
|
206
|
-
},
|
|
207
|
-
closedBy: {
|
|
208
|
-
type: String,
|
|
209
|
-
default: null
|
|
210
|
-
},
|
|
211
|
-
closingEntryId: {
|
|
212
|
-
type: mongoose.Schema.Types.ObjectId,
|
|
213
|
-
default: null
|
|
214
|
-
},
|
|
215
|
-
reopenedAt: {
|
|
216
|
-
type: Date,
|
|
217
|
-
default: null
|
|
218
|
-
},
|
|
219
|
-
reopenedBy: {
|
|
220
|
-
type: String,
|
|
221
|
-
default: null
|
|
222
|
-
},
|
|
223
|
-
...extraFields
|
|
224
|
-
};
|
|
225
|
-
if (multiTenant) fields[multiTenant.orgField] = {
|
|
226
|
-
type: mongoose.Schema.Types.ObjectId,
|
|
227
|
-
ref: multiTenant.orgRef,
|
|
228
|
-
required: true
|
|
229
|
-
};
|
|
230
|
-
const schema = new mongoose.Schema(fields, { timestamps: true });
|
|
231
|
-
if (indexes) if (multiTenant) {
|
|
232
|
-
const org = multiTenant.orgField;
|
|
233
|
-
schema.index({
|
|
234
|
-
[org]: 1,
|
|
235
|
-
startDate: 1,
|
|
236
|
-
endDate: 1
|
|
237
|
-
}, { unique: true });
|
|
238
|
-
schema.index({
|
|
239
|
-
[org]: 1,
|
|
240
|
-
closed: 1
|
|
241
|
-
});
|
|
242
|
-
} else {
|
|
243
|
-
schema.index({
|
|
244
|
-
startDate: 1,
|
|
245
|
-
endDate: 1
|
|
246
|
-
}, { unique: true });
|
|
247
|
-
schema.index({ closed: 1 });
|
|
248
|
-
}
|
|
249
|
-
for (const idx of extraIndexes) schema.index(idx.fields, idx.options);
|
|
250
|
-
schema.pre("validate", async function() {
|
|
251
|
-
const doc = this;
|
|
252
|
-
if (!doc.startDate || !doc.endDate) return;
|
|
253
|
-
const overlapQuery = {
|
|
254
|
-
_id: { $ne: doc._id },
|
|
255
|
-
startDate: { $lt: doc.endDate },
|
|
256
|
-
endDate: { $gt: doc.startDate }
|
|
257
|
-
};
|
|
258
|
-
if (multiTenant) overlapQuery[multiTenant.orgField] = doc[multiTenant.orgField];
|
|
259
|
-
const overlap = await doc.collection.findOne(overlapQuery);
|
|
260
|
-
if (overlap) {
|
|
261
|
-
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]}).`;
|
|
262
|
-
doc.invalidate("startDate", msg, doc.startDate, "overlap");
|
|
263
|
-
}
|
|
264
|
-
});
|
|
265
|
-
return schema;
|
|
266
|
-
}
|
|
267
|
-
//#endregion
|
|
268
|
-
//#region src/schemas/journal-entry.schema.ts
|
|
269
|
-
/**
|
|
270
|
-
* Journal Entry Schema Factory
|
|
271
|
-
*
|
|
272
|
-
* Creates a Mongoose schema for double-entry journal entries.
|
|
273
|
-
* - Multi-tenant aware
|
|
274
|
-
* - Embedded journal items with account refs
|
|
275
|
-
* - State machine: draft → posted, draft → archived
|
|
276
|
-
* - Auto-generated reference numbers
|
|
277
|
-
* - Double-entry validation on post
|
|
278
|
-
* - Optimized indexes for high-load reporting
|
|
279
|
-
*/
|
|
280
|
-
function createJournalEntrySchema(config, accountModelName, options = {}) {
|
|
281
|
-
const { multiTenant } = config;
|
|
282
|
-
const { indexes = true, autoReference = true, textSearch = true, extraFields = {}, extraIndexes = [], extraItemFields = {} } = options;
|
|
283
|
-
const TaxDetailSchema = new mongoose.Schema({
|
|
284
|
-
taxCode: { type: String },
|
|
285
|
-
taxName: { type: String }
|
|
286
|
-
}, { _id: false });
|
|
287
|
-
const amountValidator = {
|
|
288
|
-
validator: (v) => Number.isInteger(v) && v >= 0,
|
|
289
|
-
message: "{PATH} must be a non-negative integer (cents), got {VALUE}"
|
|
290
|
-
};
|
|
291
|
-
const currencyItemFields = {};
|
|
292
|
-
const currencyField = buildCurrencyField(config);
|
|
293
|
-
if (currencyField) {
|
|
294
|
-
currencyItemFields.currency = currencyField;
|
|
295
|
-
currencyItemFields.exchangeRate = {
|
|
296
|
-
type: Number,
|
|
297
|
-
default: null,
|
|
298
|
-
validate: {
|
|
299
|
-
validator: (v) => v === null || v > 0,
|
|
300
|
-
message: "exchangeRate must be greater than zero when set, got {VALUE}"
|
|
301
|
-
}
|
|
302
|
-
};
|
|
303
|
-
currencyItemFields.originalDebit = {
|
|
304
|
-
type: Number,
|
|
305
|
-
default: null,
|
|
306
|
-
min: 0,
|
|
307
|
-
validate: amountValidator
|
|
308
|
-
};
|
|
309
|
-
currencyItemFields.originalCredit = {
|
|
310
|
-
type: Number,
|
|
311
|
-
default: null,
|
|
312
|
-
min: 0,
|
|
313
|
-
validate: amountValidator
|
|
314
|
-
};
|
|
315
|
-
}
|
|
316
|
-
const JournalItemSchema = new mongoose.Schema({
|
|
317
|
-
account: {
|
|
318
|
-
type: mongoose.Schema.Types.ObjectId,
|
|
319
|
-
ref: accountModelName,
|
|
320
|
-
required: true
|
|
321
|
-
},
|
|
322
|
-
label: { type: String },
|
|
323
|
-
date: { type: Date },
|
|
324
|
-
debit: {
|
|
325
|
-
type: Number,
|
|
326
|
-
default: 0,
|
|
327
|
-
min: 0,
|
|
328
|
-
validate: amountValidator
|
|
329
|
-
},
|
|
330
|
-
credit: {
|
|
331
|
-
type: Number,
|
|
332
|
-
default: 0,
|
|
333
|
-
min: 0,
|
|
334
|
-
validate: amountValidator
|
|
335
|
-
},
|
|
336
|
-
taxDetails: {
|
|
337
|
-
type: [TaxDetailSchema],
|
|
338
|
-
default: []
|
|
339
|
-
},
|
|
340
|
-
...currencyItemFields,
|
|
341
|
-
...extraItemFields
|
|
342
|
-
}, { _id: false });
|
|
343
|
-
_freezeJournalTypes();
|
|
344
|
-
const fields = {
|
|
345
|
-
journalType: {
|
|
346
|
-
type: String,
|
|
347
|
-
enum: getJournalTypeCodes(),
|
|
348
|
-
default: JOURNAL_CODES.MISC,
|
|
349
|
-
required: true
|
|
350
|
-
},
|
|
351
|
-
referenceNumber: { type: String },
|
|
352
|
-
label: { type: String },
|
|
353
|
-
date: {
|
|
354
|
-
type: Date,
|
|
355
|
-
default: Date.now,
|
|
356
|
-
required: function() {
|
|
357
|
-
return this.state !== "draft";
|
|
358
|
-
}
|
|
359
|
-
},
|
|
360
|
-
journalItems: {
|
|
361
|
-
type: [JournalItemSchema],
|
|
362
|
-
default: []
|
|
363
|
-
},
|
|
364
|
-
totalDebit: {
|
|
365
|
-
type: Number,
|
|
366
|
-
required: true,
|
|
367
|
-
min: 0,
|
|
368
|
-
validate: {
|
|
369
|
-
validator: Number.isInteger,
|
|
370
|
-
message: "totalDebit must be an integer (cents)"
|
|
371
|
-
}
|
|
372
|
-
},
|
|
373
|
-
totalCredit: {
|
|
374
|
-
type: Number,
|
|
375
|
-
required: true,
|
|
376
|
-
min: 0,
|
|
377
|
-
validate: {
|
|
378
|
-
validator: Number.isInteger,
|
|
379
|
-
message: "totalCredit must be an integer (cents)"
|
|
380
|
-
}
|
|
381
|
-
},
|
|
382
|
-
state: {
|
|
383
|
-
type: String,
|
|
384
|
-
enum: [
|
|
385
|
-
"draft",
|
|
386
|
-
"posted",
|
|
387
|
-
"archived"
|
|
388
|
-
],
|
|
389
|
-
default: "draft",
|
|
390
|
-
required: true
|
|
391
|
-
},
|
|
392
|
-
stateChangedAt: {
|
|
393
|
-
type: Date,
|
|
394
|
-
default: Date.now
|
|
395
|
-
},
|
|
396
|
-
reversed: {
|
|
397
|
-
type: Boolean,
|
|
398
|
-
default: false
|
|
399
|
-
},
|
|
400
|
-
reversedBy: {
|
|
401
|
-
type: mongoose.Schema.Types.ObjectId,
|
|
402
|
-
ref: "JournalEntry",
|
|
403
|
-
default: null
|
|
404
|
-
},
|
|
405
|
-
reversalOf: {
|
|
406
|
-
type: mongoose.Schema.Types.ObjectId,
|
|
407
|
-
ref: "JournalEntry",
|
|
408
|
-
default: null
|
|
409
|
-
},
|
|
410
|
-
...extraFields
|
|
411
|
-
};
|
|
412
|
-
if (config.audit?.trackActor) {
|
|
413
|
-
fields.createdBy = {
|
|
414
|
-
type: mongoose.Schema.Types.ObjectId,
|
|
415
|
-
default: null
|
|
416
|
-
};
|
|
417
|
-
fields.postedBy = {
|
|
418
|
-
type: mongoose.Schema.Types.ObjectId,
|
|
419
|
-
default: null
|
|
420
|
-
};
|
|
421
|
-
fields.reversedByUser = {
|
|
422
|
-
type: mongoose.Schema.Types.ObjectId,
|
|
423
|
-
default: null
|
|
424
|
-
};
|
|
425
|
-
}
|
|
426
|
-
if (config.strictness?.requireApproval || config.audit?.trackActor) {
|
|
427
|
-
fields.approvedBy = {
|
|
428
|
-
type: mongoose.Schema.Types.ObjectId,
|
|
429
|
-
default: null
|
|
430
|
-
};
|
|
431
|
-
fields.approvedAt = {
|
|
432
|
-
type: Date,
|
|
433
|
-
default: null
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
if (config.idempotency) fields.idempotencyKey = {
|
|
437
|
-
type: String,
|
|
438
|
-
default: null
|
|
439
|
-
};
|
|
440
|
-
if (multiTenant) fields[multiTenant.orgField] = {
|
|
441
|
-
type: mongoose.Schema.Types.ObjectId,
|
|
442
|
-
ref: multiTenant.orgRef,
|
|
443
|
-
required: true
|
|
444
|
-
};
|
|
445
|
-
const schema = new mongoose.Schema(fields, { timestamps: true });
|
|
446
|
-
schema.pre("validate", function() {
|
|
447
|
-
for (const item of this.journalItems) if (!item.date) item.date = this.date;
|
|
448
|
-
for (let i = 0; i < this.journalItems.length; i++) {
|
|
449
|
-
const d = this.journalItems[i].debit ?? 0;
|
|
450
|
-
const c = this.journalItems[i].credit ?? 0;
|
|
451
|
-
if (d > 0 && c > 0) throw new Error(`Journal item at index ${i}: cannot have both debit (${d}) and credit (${c}) greater than zero`);
|
|
452
|
-
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)`);
|
|
453
|
-
}
|
|
454
|
-
const totalDebit = this.journalItems.reduce((s, item) => s + (item.debit ?? 0), 0);
|
|
455
|
-
const totalCredit = this.journalItems.reduce((s, item) => s + (item.credit ?? 0), 0);
|
|
456
|
-
if (this.state === "posted") {
|
|
457
|
-
if (this.journalItems.length < 2) throw new Error("Posted entries must have at least 2 journal items");
|
|
458
|
-
if (totalDebit !== totalCredit) throw new Error("Total debit must equal total credit for posted entries");
|
|
459
|
-
}
|
|
460
|
-
this.totalDebit = totalDebit;
|
|
461
|
-
this.totalCredit = totalCredit;
|
|
462
|
-
});
|
|
463
|
-
if (autoReference) {
|
|
464
|
-
const generateReferenceNumber = async (doc, Model, session) => {
|
|
465
|
-
const jt = doc.journalType || "MISC";
|
|
466
|
-
const d = new Date(doc.date);
|
|
467
|
-
const prefix = `${jt}/${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/`;
|
|
468
|
-
const matchFilter = { referenceNumber: { $regex: `^${prefix.replace(/\//g, "\\/")}` } };
|
|
469
|
-
if (multiTenant) matchFilter[multiTenant.orgField] = doc[multiTenant.orgField];
|
|
470
|
-
const pipeline = [
|
|
471
|
-
{ $match: matchFilter },
|
|
472
|
-
{ $addFields: { _refSeq: { $toInt: { $arrayElemAt: [{ $split: ["$referenceNumber", "/"] }, -1] } } } },
|
|
473
|
-
{ $sort: { _refSeq: -1 } },
|
|
474
|
-
{ $limit: 1 },
|
|
475
|
-
{ $project: { _refSeq: 1 } }
|
|
476
|
-
];
|
|
477
|
-
const results = await Model.aggregate(pipeline).session(session);
|
|
478
|
-
let seq = 1;
|
|
479
|
-
if (results.length > 0 && typeof results[0]._refSeq === "number") seq = results[0]._refSeq + 1;
|
|
480
|
-
return `${prefix}${String(seq).padStart(4, "0")}`;
|
|
481
|
-
};
|
|
482
|
-
schema.pre("save", async function() {
|
|
483
|
-
if (this.isModified("journalType")) this.referenceNumber = void 0;
|
|
484
|
-
if (!this.referenceNumber) {
|
|
485
|
-
const session = this.$session?.() ?? null;
|
|
486
|
-
const Model = this.constructor;
|
|
487
|
-
this.referenceNumber = await generateReferenceNumber(this, Model, session);
|
|
488
|
-
}
|
|
489
|
-
});
|
|
490
|
-
const MAX_REF_RETRIES = 3;
|
|
491
|
-
schema.post("save", async (error, doc, next) => {
|
|
492
|
-
const mongoError = error;
|
|
493
|
-
if (mongoError.code === 11e3 && mongoError.keyPattern?.referenceNumber) {
|
|
494
|
-
const entry = doc;
|
|
495
|
-
const retryCount = entry.__refRetries ?? 0;
|
|
496
|
-
if (retryCount >= MAX_REF_RETRIES) {
|
|
497
|
-
next(/* @__PURE__ */ new Error(`Failed to generate unique reference number after ${MAX_REF_RETRIES} retries. Too many concurrent inserts for this period.`));
|
|
498
|
-
return;
|
|
499
|
-
}
|
|
500
|
-
entry.__refRetries = retryCount + 1;
|
|
501
|
-
const session = entry.$session?.() ?? null;
|
|
502
|
-
const Model = entry.constructor;
|
|
503
|
-
entry.referenceNumber = await generateReferenceNumber(entry, Model, session);
|
|
504
|
-
try {
|
|
505
|
-
await entry.save({ session });
|
|
506
|
-
next();
|
|
507
|
-
} catch (retryError) {
|
|
508
|
-
next(retryError);
|
|
509
|
-
}
|
|
510
|
-
} else next(error);
|
|
511
|
-
});
|
|
512
|
-
}
|
|
513
|
-
if (indexes) {
|
|
514
|
-
const org = multiTenant?.orgField;
|
|
515
|
-
const refPartial = { partialFilterExpression: { referenceNumber: {
|
|
516
|
-
$exists: true,
|
|
517
|
-
$type: "string"
|
|
518
|
-
} } };
|
|
519
|
-
if (org) {
|
|
520
|
-
schema.index({
|
|
521
|
-
[org]: 1,
|
|
522
|
-
referenceNumber: 1
|
|
523
|
-
}, {
|
|
524
|
-
unique: true,
|
|
525
|
-
...refPartial
|
|
526
|
-
});
|
|
527
|
-
schema.index({
|
|
528
|
-
[org]: 1,
|
|
529
|
-
state: 1,
|
|
530
|
-
date: 1
|
|
531
|
-
});
|
|
532
|
-
schema.index({
|
|
533
|
-
[org]: 1,
|
|
534
|
-
date: -1
|
|
535
|
-
});
|
|
536
|
-
schema.index({
|
|
537
|
-
[org]: 1,
|
|
538
|
-
journalType: 1
|
|
539
|
-
});
|
|
540
|
-
schema.index({
|
|
541
|
-
"journalItems.account": 1,
|
|
542
|
-
state: 1
|
|
543
|
-
});
|
|
544
|
-
schema.index({
|
|
545
|
-
[org]: 1,
|
|
546
|
-
"journalItems.account": 1,
|
|
547
|
-
date: 1,
|
|
548
|
-
state: 1
|
|
549
|
-
});
|
|
550
|
-
} else {
|
|
551
|
-
schema.index({ referenceNumber: 1 }, {
|
|
552
|
-
unique: true,
|
|
553
|
-
...refPartial
|
|
554
|
-
});
|
|
555
|
-
schema.index({
|
|
556
|
-
state: 1,
|
|
557
|
-
date: 1
|
|
558
|
-
});
|
|
559
|
-
schema.index({ date: -1 });
|
|
560
|
-
schema.index({ journalType: 1 });
|
|
561
|
-
schema.index({
|
|
562
|
-
"journalItems.account": 1,
|
|
563
|
-
state: 1
|
|
564
|
-
});
|
|
565
|
-
}
|
|
566
|
-
schema.index({ reversed: 1 });
|
|
567
|
-
if (config.idempotency) {
|
|
568
|
-
const idempotencyIdx = {};
|
|
569
|
-
if (org) idempotencyIdx[org] = 1;
|
|
570
|
-
idempotencyIdx.idempotencyKey = 1;
|
|
571
|
-
schema.index(idempotencyIdx, {
|
|
572
|
-
unique: true,
|
|
573
|
-
partialFilterExpression: { idempotencyKey: {
|
|
574
|
-
$exists: true,
|
|
575
|
-
$ne: null
|
|
576
|
-
} }
|
|
577
|
-
});
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
if (textSearch) schema.index({
|
|
581
|
-
referenceNumber: "text",
|
|
582
|
-
label: "text"
|
|
583
|
-
}, {
|
|
584
|
-
weights: {
|
|
585
|
-
referenceNumber: 10,
|
|
586
|
-
label: 5
|
|
587
|
-
},
|
|
588
|
-
name: "journal_text_idx"
|
|
589
|
-
});
|
|
590
|
-
for (const idx of extraIndexes) schema.index(idx.fields, idx.options);
|
|
591
|
-
return schema;
|
|
592
|
-
}
|
|
593
|
-
//#endregion
|
|
594
|
-
//#region src/schemas/reconciliation.schema.ts
|
|
595
|
-
/**
|
|
596
|
-
* Reconciliation Schema Factory
|
|
597
|
-
*
|
|
598
|
-
* Creates a Mongoose schema for reconciliation records that link matched
|
|
599
|
-
* debit/credit journal items. Used to track which journal entries have been
|
|
600
|
-
* reconciled against each other for a given account.
|
|
601
|
-
*/
|
|
602
|
-
function createReconciliationSchema(config, accountModelName, journalEntryModelName, options = {}) {
|
|
603
|
-
const { multiTenant } = config;
|
|
604
|
-
const { indexes = true, extraFields = {}, extraIndexes = [] } = options;
|
|
605
|
-
const fields = {
|
|
606
|
-
account: {
|
|
607
|
-
type: mongoose.Schema.Types.ObjectId,
|
|
608
|
-
ref: accountModelName,
|
|
609
|
-
required: true
|
|
610
|
-
},
|
|
611
|
-
journalEntryIds: {
|
|
612
|
-
type: [{
|
|
613
|
-
type: mongoose.Schema.Types.ObjectId,
|
|
614
|
-
ref: journalEntryModelName
|
|
615
|
-
}],
|
|
616
|
-
required: true,
|
|
617
|
-
validate: {
|
|
618
|
-
validator: (v) => Array.isArray(v) && v.length > 0,
|
|
619
|
-
message: "journalEntryIds must contain at least one entry."
|
|
620
|
-
}
|
|
621
|
-
},
|
|
622
|
-
debitTotal: {
|
|
623
|
-
type: Number,
|
|
624
|
-
required: true
|
|
625
|
-
},
|
|
626
|
-
creditTotal: {
|
|
627
|
-
type: Number,
|
|
628
|
-
required: true
|
|
629
|
-
},
|
|
630
|
-
difference: {
|
|
631
|
-
type: Number,
|
|
632
|
-
default: 0
|
|
633
|
-
},
|
|
634
|
-
note: { type: String },
|
|
635
|
-
reconciledBy: { type: String },
|
|
636
|
-
reconciledAt: {
|
|
637
|
-
type: Date,
|
|
638
|
-
default: Date.now
|
|
639
|
-
},
|
|
640
|
-
...extraFields
|
|
641
|
-
};
|
|
642
|
-
if (multiTenant) fields[multiTenant.orgField] = {
|
|
643
|
-
type: mongoose.Schema.Types.ObjectId,
|
|
644
|
-
ref: multiTenant.orgRef,
|
|
645
|
-
required: true
|
|
646
|
-
};
|
|
647
|
-
const schema = new mongoose.Schema(fields, { timestamps: true });
|
|
648
|
-
if (indexes) {
|
|
649
|
-
if (multiTenant) {
|
|
650
|
-
const org = multiTenant.orgField;
|
|
651
|
-
schema.index({
|
|
652
|
-
[org]: 1,
|
|
653
|
-
account: 1,
|
|
654
|
-
reconciledAt: 1
|
|
655
|
-
});
|
|
656
|
-
} else schema.index({
|
|
657
|
-
account: 1,
|
|
658
|
-
reconciledAt: 1
|
|
659
|
-
});
|
|
660
|
-
schema.index({ journalEntryIds: 1 });
|
|
661
|
-
}
|
|
662
|
-
for (const idx of extraIndexes) schema.index(idx.fields, idx.options);
|
|
663
|
-
return schema;
|
|
664
|
-
}
|
|
665
|
-
//#endregion
|
|
666
|
-
export { createAccountSchema as a, createBudgetSchema as i, createJournalEntrySchema as n, createFiscalPeriodSchema as r, createReconciliationSchema as t };
|