@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.
Files changed (38) hide show
  1. package/README.md +227 -189
  2. package/dist/constants/index.d.mts +1 -1
  3. package/dist/constants/index.mjs +2 -3
  4. package/dist/country/index.d.mts +1 -1
  5. package/dist/{journals-BfwnCFam.mjs → currencies-CsuBGfgs.mjs} +80 -1
  6. package/dist/{date-lock.plugin-DL6pe24p.mjs → date-lock.plugin-B2Jy0ukX.mjs} +61 -10
  7. package/dist/errors-BmRjW38t.mjs +33 -0
  8. package/dist/exports/index.d.mts +1 -1
  9. package/dist/exports/index.mjs +1 -1
  10. package/dist/{fiscal-close-B2_7WMTe.mjs → fiscal-close-Dk3yRT9i.mjs} +14 -4
  11. package/dist/{index-CxZqRaOU.d.mts → index-GmfEFxVn.d.mts} +1 -1
  12. package/dist/index.d.mts +525 -344
  13. package/dist/index.mjs +1814 -170
  14. package/dist/{journals-DTipb_rz.d.mts → journals-C50E9mpo.d.mts} +1 -1
  15. package/dist/plugins/index.d.mts +1 -1
  16. package/dist/plugins/index.mjs +1 -1
  17. package/dist/reports/index.d.mts +1 -1
  18. package/dist/reports/index.mjs +1 -1
  19. package/dist/{trial-balance-DcQ0xj_4.d.mts → trial-balance-BZ7yOOFD.d.mts} +16 -4
  20. package/package.json +1 -11
  21. package/dist/currencies-W8kQAkm0.mjs +0 -80
  22. package/dist/engine-scgOvxHJ.d.mts +0 -130
  23. package/dist/errors-B_dyYZc_.mjs +0 -26
  24. package/dist/journal-entry.schema-JqrfbvB4.d.mts +0 -103
  25. package/dist/logger-UbTdBb1x.d.mts +0 -14
  26. package/dist/reconciliation.repository-D-D_ITL-.d.mts +0 -135
  27. package/dist/reconciliation.repository-fPwFKvrk.mjs +0 -542
  28. package/dist/reconciliation.schema-BA1lPv4t.mjs +0 -666
  29. package/dist/repositories/index.d.mts +0 -2
  30. package/dist/repositories/index.mjs +0 -2
  31. package/dist/schemas/index.d.mts +0 -71
  32. package/dist/schemas/index.mjs +0 -2
  33. package/dist/tenant-guard-r17Se3Bb.mjs +0 -13
  34. /package/dist/{categories-DWogBUgQ.mjs → categories-BkKdv16V.mjs} +0 -0
  35. /package/dist/{core-8Xfnpn6g.d.mts → core-BkGjuVZj.d.mts} +0 -0
  36. /package/dist/{exports-DoGQQtMQ.mjs → exports-BP-0Ni5W.mjs} +0 -0
  37. /package/dist/{idempotency.plugin-zU-GKJ0-.d.mts → idempotency.plugin-CK7LHnBn.d.mts} +0 -0
  38. /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 createAccountSchema, i as createBudgetSchema, n as createJournalEntrySchema, r as createFiscalPeriodSchema, t as createReconciliationSchema } from "./reconciliation.schema-BA1lPv4t.mjs";
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-B_dyYZc_.mjs";
5
- import { i as doubleEntryPlugin, n as idempotencyPlugin, r as fiscalLockPlugin, t as dateLockPlugin } from "./date-lock.plugin-DL6pe24p.mjs";
6
- import { C as DEFAULT_BUCKETS, S as isVirtualTaxAccount, _ 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-B2_7WMTe.mjs";
7
- import { c as getNormalBalance, d as isValidCategory, l as isBalanceSheet, n as CATEGORY_KEYS, t as CATEGORIES, u as isIncomeStatement } from "./categories-DWogBUgQ.mjs";
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-DoGQQtMQ.mjs";
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.
@@ -71,7 +1270,7 @@ 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 accountPagination = pagination.account ?? { maxLimit: 1e3 };
1273
+ const accountPagination = pagination.account ?? {};
75
1274
  const jePagination = pagination.journalEntry ?? {};
76
1275
  const fpPagination = pagination.fiscalPeriod ?? {};
77
1276
  const budgetPagination = pagination.budget ?? {};
@@ -103,83 +1302,620 @@ function createRepositories(models, config, plugins = {}, pagination = {}) {
103
1302
  };
104
1303
  }
105
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
1858
+ };
1859
+ }
1860
+ //#endregion
106
1861
  //#region src/engine.ts
107
1862
  var AccountingEngine = class {
108
1863
  config;
109
1864
  country;
110
1865
  currency;
111
1866
  money = Money;
112
- _models;
113
- _repositories;
1867
+ models;
1868
+ repositories;
1869
+ record;
1870
+ introspect;
114
1871
  _reports;
115
- constructor(config, plugins = {}, pagination = {}) {
1872
+ constructor(config) {
1873
+ if (!config.mongoose) throw new Error("createAccountingEngine: `mongoose` connection is required. Pass `mongoose: mongoose.connection` in config.");
116
1874
  this.config = config;
117
1875
  this.country = config.country;
118
1876
  this.currency = config.currency;
119
- if (config.mongoose) {
120
- this._models = createModels(config.mongoose, config);
121
- this._repositories = createRepositories(this._models, config, plugins, pagination);
122
- }
123
- }
124
- /**
125
- * Auto-created Mongoose models. Requires `mongoose` in config.
126
- *
127
- * @throws if `mongoose` was not provided in config
128
- */
129
- get models() {
130
- if (!this._models) throw new Error("engine.models requires a Mongoose connection. Pass `mongoose: connection` in createAccountingEngine config.");
131
- return this._models;
132
- }
133
- /**
134
- * Auto-wired repositories with plugins + domain methods (post, reverse, etc.).
135
- * Requires `mongoose` in config.
136
- *
137
- * @throws if `mongoose` was not provided in config
138
- */
139
- get repositories() {
140
- if (!this._repositories) throw new Error("engine.repositories requires a Mongoose connection. Pass `mongoose: connection` in createAccountingEngine config.");
141
- 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
+ });
142
1890
  }
143
1891
  /**
144
- * Pre-built reports bound to auto-created models. Requires `mongoose` in config.
145
- * For custom models, use `engine.createReports({ Account, JournalEntry, Budget })`.
1892
+ * Pre-built reports bound to the engine's owned models.
1893
+ * Lazy-initialized on first access.
146
1894
  */
147
1895
  get reports() {
148
- if (!this._reports) {
149
- 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.");
150
- this._reports = this._buildReports({
151
- Account: this._models.Account,
152
- JournalEntry: this._models.JournalEntry,
153
- Budget: this._models.Budget
154
- });
155
- }
1896
+ if (!this._reports) this._reports = this._buildReports();
156
1897
  return this._reports;
157
1898
  }
158
- createAccountSchema(options) {
159
- return createAccountSchema(this.config, options);
160
- }
161
- createJournalEntrySchema(accountModelName, options) {
162
- return createJournalEntrySchema(this.config, accountModelName, options);
163
- }
164
- createFiscalPeriodSchema(options) {
165
- return createFiscalPeriodSchema(this.config, options);
1899
+ /** Get all posting account types (accounts you can post transactions to) */
1900
+ getPostingAccountTypes() {
1901
+ return this.country.getPostingAccountTypes();
166
1902
  }
167
- createBudgetSchema(options) {
168
- return createBudgetSchema(this.config, options);
1903
+ /** Validate an account type code */
1904
+ isValidAccountType(code) {
1905
+ return this.country.isValidAccountType(code);
169
1906
  }
170
- createReconciliationSchema(accountModelName, journalEntryModelName, options) {
171
- return createReconciliationSchema(this.config, accountModelName, journalEntryModelName, options);
1907
+ /** Get account type definition by code */
1908
+ getAccountType(code) {
1909
+ return this.country.getAccountType(code);
172
1910
  }
173
- /**
174
- * Build a reports object bound to the given models. Use this when you
175
- * need custom/external models. Prefer `engine.reports` when the engine
176
- * owns the models (via `mongoose` in config).
177
- */
178
- createReports(models) {
179
- return this._buildReports(models);
1911
+ /** Get tax codes for a region */
1912
+ getTaxCodesForRegion(region) {
1913
+ return this.country.getTaxCodesForRegion(region);
180
1914
  }
181
- _buildReports(models) {
182
- const { Account: AccountModel, JournalEntry: JournalEntryModel, Budget: BudgetModel } = models;
1915
+ _buildReports() {
1916
+ const AccountModel = this.models.Account;
1917
+ const JournalEntryModel = this.models.JournalEntry;
1918
+ const BudgetModel = this.models.Budget;
183
1919
  const { country, config } = this;
184
1920
  const orgField = config.multiTenant?.orgField;
185
1921
  const fiscalYearStartMonth = config.fiscalYearStartMonth ?? 1;
@@ -235,16 +1971,13 @@ var AccountingEngine = class {
235
1971
  country,
236
1972
  orgField
237
1973
  }, params),
238
- budgetVsActual: (params) => {
239
- if (!BudgetModel) throw new Error("Budget model required — pass Budget to createReports()");
240
- return generateBudgetVsActual({
241
- AccountModel,
242
- JournalEntryModel,
243
- BudgetModel,
244
- country,
245
- orgField
246
- }, params);
247
- },
1974
+ budgetVsActual: (params) => generateBudgetVsActual({
1975
+ AccountModel,
1976
+ JournalEntryModel,
1977
+ BudgetModel,
1978
+ country,
1979
+ orgField
1980
+ }, params),
248
1981
  revaluation: (params) => generateRevaluation({
249
1982
  AccountModel,
250
1983
  JournalEntryModel,
@@ -254,98 +1987,9 @@ var AccountingEngine = class {
254
1987
  }, params)
255
1988
  };
256
1989
  }
257
- /** Get all posting account types (accounts you can post transactions to) */
258
- getPostingAccountTypes() {
259
- return this.country.getPostingAccountTypes();
260
- }
261
- /** Validate an account type code */
262
- isValidAccountType(code) {
263
- return this.country.isValidAccountType(code);
264
- }
265
- /** Get account type definition by code */
266
- getAccountType(code) {
267
- return this.country.getAccountType(code);
268
- }
269
- /** Get tax codes for a region */
270
- getTaxCodesForRegion(region) {
271
- return this.country.getTaxCodesForRegion(region);
272
- }
273
- /**
274
- * Create a fully-configured journal entry repository with secure plugin wiring.
275
- * This is the **recommended** way to set up journal entry repositories.
276
- *
277
- * Includes:
278
- * - Double-entry plugin with account existence + tenant integrity validation
279
- * - Fiscal lock plugin (when FiscalPeriodModel is provided)
280
- * - post(), unpost(), reverse(), and duplicate() domain methods
281
- *
282
- * @param createRepository - The `createRepository` function from @classytic/mongokit
283
- * @param models.JournalEntryModel - Mongoose model for journal entries
284
- * @param models.AccountModel - Mongoose model for accounts (required for secure posted-create validation)
285
- * @param models.FiscalPeriodModel - Mongoose model for fiscal periods (optional, enables fiscal lock)
286
- * @param additionalPlugins - Extra plugins to include (e.g. timestampPlugin)
287
- * @returns A wired repository with post(), unpost(), reverse(), duplicate(), and all plugins configured
288
- */
289
- createJournalEntryRepository(createRepository, models, additionalPlugins = []) {
290
- const orgField = this.config.multiTenant?.orgField;
291
- const { JournalEntryModel, AccountModel, FiscalPeriodModel } = models;
292
- const jeModel = JournalEntryModel;
293
- const plugins = [...additionalPlugins, doubleEntryPlugin({
294
- JournalEntryModel: jeModel,
295
- AccountModel,
296
- orgField
297
- })];
298
- if (FiscalPeriodModel) plugins.push(fiscalLockPlugin({
299
- FiscalPeriodModel,
300
- JournalEntryModel: jeModel,
301
- orgField
302
- }));
303
- if (this.config.idempotency) plugins.push(idempotencyPlugin({
304
- JournalEntryModel: jeModel,
305
- orgField
306
- }));
307
- return wireJournalEntryMethods(createRepository(JournalEntryModel, plugins), JournalEntryModel, orgField, this.config.strictness);
308
- }
309
- /**
310
- * Wire post/reverse domain methods onto a mongokit Repository
311
- * for journal entries. The repository must already be created via
312
- * `createRepository(Model, plugins)` from @classytic/mongokit.
313
- *
314
- * **Note:** Prefer `createJournalEntryRepository()` which guarantees
315
- * secure plugin wiring. This method only adds domain methods and does
316
- * not validate plugin configuration.
317
- *
318
- * @param repository - An existing mongokit Repository instance
319
- * @param JournalEntryModel - The Mongoose model for journal entries
320
- * @returns The same repository, now with `.post()` and `.reverse()`
321
- */
322
- wireJournalEntryRepository(repository, JournalEntryModel) {
323
- const orgField = this.config.multiTenant?.orgField;
324
- return wireJournalEntryMethods(repository, JournalEntryModel, orgField, this.config.strictness);
325
- }
326
- /**
327
- * Wire seedAccounts/bulkCreate and posting-account validation onto a
328
- * mongokit Repository for accounts. The repository must already be
329
- * created via `createRepository(Model, plugins)` from @classytic/mongokit.
330
- *
331
- * @param repository - An existing mongokit Repository instance
332
- * @param AccountModel - The Mongoose model for accounts
333
- * @returns The same repository, now with `.seedAccounts()` and `.bulkCreate()`
334
- */
335
- wireAccountRepository(repository, AccountModel) {
336
- const orgField = this.config.multiTenant?.orgField;
337
- return wireAccountMethods(repository, AccountModel, this.country, orgField);
338
- }
339
- /**
340
- * Wire reconcile/unreconcile/getUnreconciled methods onto a mongokit Repository.
341
- */
342
- wireReconciliationRepository(repository, ReconciliationModel, JournalEntryModel) {
343
- const orgField = this.config.multiTenant?.orgField;
344
- return wireReconciliationMethods(repository, ReconciliationModel, JournalEntryModel, orgField);
345
- }
346
1990
  };
347
- function createAccountingEngine(config, plugins = {}, pagination = {}) {
348
- return new AccountingEngine(config, plugins, pagination);
1991
+ function createAccountingEngine(config) {
1992
+ return new AccountingEngine(config);
349
1993
  }
350
1994
  //#endregion
351
1995
  //#region src/utils/dimensions.ts
@@ -407,4 +2051,4 @@ function buildDimensionIndexes(dimensions, orgField) {
407
2051
  });
408
2052
  }
409
2053
  //#endregion
410
- 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, createAccountSchema, createAccountingEngine, createFiscalPeriodSchema, createJournalEntrySchema, 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, wireAccountMethods, wireJournalEntryMethods, wireReconciliationMethods };
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 };