@classytic/ledger 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -20
- package/dist/constants/index.d.mts +2 -2
- package/dist/constants/index.mjs +3 -3
- package/dist/{date-lock.plugin-eYAJ9h_u.mjs → date-lock.plugin-DL6pe24p.mjs} +2 -2
- package/dist/{engine-Cn-9yerQ.d.mts → engine-scgOvxHJ.d.mts} +30 -2
- package/dist/exports/index.d.mts +1 -1
- package/dist/exports/index.mjs +1 -1
- package/dist/{exports-I5Xkq-9_.mjs → exports-DoGQQtMQ.mjs} +96 -75
- package/dist/{fiscal-close-B6LhQ10f.mjs → fiscal-close-B2_7WMTe.mjs} +748 -751
- package/dist/{index-BPukb3L8.d.mts → index-J-XIbXH-.d.mts} +7 -7
- package/dist/index.d.mts +239 -87
- package/dist/index.mjs +149 -12
- package/dist/{fiscal-period.schema-BMnlI9H5.d.mts → journal-entry.schema-JqrfbvB4.d.mts} +12 -12
- package/dist/{journals-oH-FK3g8.mjs → journals-BfwnCFam.mjs} +27 -4
- package/dist/{currencies-4WAbFRlw.d.mts → journals-DTipb_rz.d.mts} +16 -7
- package/dist/money.mjs +2 -2
- package/dist/plugins/index.d.mts +1 -1
- package/dist/plugins/index.mjs +1 -1
- package/dist/{reconciliation.repository-CW4-8q90.d.mts → reconciliation.repository-D-D_ITL-.d.mts} +14 -14
- package/dist/{account.repository-BpkSd6q3.mjs → reconciliation.repository-fPwFKvrk.mjs} +255 -255
- package/dist/{reconciliation.schema-BuetvZTd.mjs → reconciliation.schema-BA1lPv4t.mjs} +174 -173
- package/dist/reports/index.d.mts +1 -1
- package/dist/reports/index.mjs +1 -1
- package/dist/repositories/index.d.mts +1 -1
- package/dist/repositories/index.mjs +1 -1
- package/dist/schemas/index.d.mts +6 -6
- package/dist/schemas/index.mjs +1 -1
- package/dist/{tenant-guard-Fm6AID_6.mjs → tenant-guard-r17Se3Bb.mjs} +1 -1
- package/dist/{revaluation-D9x0NE8w.d.mts → trial-balance-DcQ0xj_4.d.mts} +124 -124
- package/docs/schemas.md +2 -2
- package/package.json +14 -6
- /package/dist/{categories-CclX7Q94.mjs → categories-DWogBUgQ.mjs} +0 -0
- /package/dist/{errors-B7yC-Jfw.mjs → errors-B_dyYZc_.mjs} +0 -0
- /package/dist/{idempotency.plugin-B_CNsInz.d.mts → idempotency.plugin-zU-GKJ0-.d.mts} +0 -0
- /package/dist/{logger-CbHWZl7v.d.mts → logger-UbTdBb1x.d.mts} +0 -0
|
@@ -1,93 +1,187 @@
|
|
|
1
|
-
import { n as Errors } from "./errors-
|
|
2
|
-
import { t as requireOrgScope } from "./tenant-guard-
|
|
3
|
-
//#region src/repositories/
|
|
1
|
+
import { n as Errors } from "./errors-B_dyYZc_.mjs";
|
|
2
|
+
import { t as requireOrgScope } from "./tenant-guard-r17Se3Bb.mjs";
|
|
3
|
+
//#region src/repositories/account.repository.ts
|
|
4
4
|
/**
|
|
5
|
-
* Wire
|
|
5
|
+
* Wire seedAccounts, bulkCreate and posting-account validation
|
|
6
|
+
* onto an existing mongokit Repository.
|
|
6
7
|
*
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
8
|
+
* @param repository - A mongokit Repository instance (already created)
|
|
9
|
+
* @param AccountModel - The Mongoose model for accounts
|
|
10
|
+
* @param country - The CountryPack for account type lookups
|
|
11
|
+
* @param orgField - The multi-tenant field name (e.g. 'business')
|
|
10
12
|
*/
|
|
11
|
-
function
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
function wireAccountMethods(repository, AccountModel, country, orgField) {
|
|
14
|
+
repository.on("before:create", (ctx) => {
|
|
15
|
+
const code = ctx.data?.accountTypeCode;
|
|
16
|
+
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.`);
|
|
17
|
+
});
|
|
14
18
|
/**
|
|
15
|
-
*
|
|
16
|
-
* Validates that all entries exist, are posted, and belong to the same account/org.
|
|
19
|
+
* Seed standard posting accounts for an organization.
|
|
17
20
|
*/
|
|
18
|
-
repository.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
account,
|
|
38
|
-
journalEntryIds,
|
|
39
|
-
debitTotal,
|
|
40
|
-
creditTotal,
|
|
41
|
-
difference: debitTotal - creditTotal,
|
|
42
|
-
note,
|
|
43
|
-
reconciledBy,
|
|
44
|
-
reconciledAt: /* @__PURE__ */ new Date()
|
|
21
|
+
repository.seedAccounts = async (orgId, options = {}) => {
|
|
22
|
+
requireOrgScope(orgField, orgId);
|
|
23
|
+
const postingTypes = country.getPostingAccountTypes();
|
|
24
|
+
const filter = {};
|
|
25
|
+
if (orgField && orgId != null) filter[orgField] = orgId;
|
|
26
|
+
const existing = await AccountModel.find(filter).select("accountNumber").lean();
|
|
27
|
+
const existingNumbers = new Set(existing.map((a) => a.accountNumber));
|
|
28
|
+
const toCreate = postingTypes.filter((at) => !existingNumbers.has(at.code)).map((at) => {
|
|
29
|
+
const doc = {
|
|
30
|
+
accountTypeCode: at.code,
|
|
31
|
+
accountNumber: at.code,
|
|
32
|
+
name: at.name
|
|
33
|
+
};
|
|
34
|
+
if (orgField && orgId != null) doc[orgField] = orgId;
|
|
35
|
+
return doc;
|
|
36
|
+
});
|
|
37
|
+
if (toCreate.length === 0) return {
|
|
38
|
+
created: 0,
|
|
39
|
+
skipped: existingNumbers.size
|
|
45
40
|
};
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
41
|
+
try {
|
|
42
|
+
return {
|
|
43
|
+
created: (await AccountModel.insertMany(toCreate, {
|
|
44
|
+
session: options.session ?? void 0,
|
|
45
|
+
ordered: false
|
|
46
|
+
})).length,
|
|
47
|
+
skipped: existingNumbers.size
|
|
48
|
+
};
|
|
49
|
+
} catch (err) {
|
|
50
|
+
const bulkError = err;
|
|
51
|
+
if (bulkError.code === 11e3 || bulkError.writeErrors) {
|
|
52
|
+
const insertedDocs = bulkError.insertedDocs ?? [];
|
|
53
|
+
return {
|
|
54
|
+
created: insertedDocs.length,
|
|
55
|
+
skipped: existingNumbers.size + (toCreate.length - insertedDocs.length)
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
throw err;
|
|
60
59
|
}
|
|
61
|
-
const result = await deleteById(String(reconciliationId));
|
|
62
|
-
if (!result) throw Errors.notFound("Reconciliation record not found.");
|
|
63
|
-
return result;
|
|
64
60
|
};
|
|
65
61
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
62
|
+
* Bulk create accounts with validation and skip-if-exists logic.
|
|
63
|
+
*
|
|
64
|
+
* Uses a single batch query to check existing accounts (instead of N+1),
|
|
65
|
+
* and ordered: false on insertMany to handle concurrent race conditions
|
|
66
|
+
* gracefully (duplicate key errors on individual docs don't abort the batch).
|
|
69
67
|
*/
|
|
70
|
-
repository.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
68
|
+
repository.bulkCreate = async (accounts, orgId) => {
|
|
69
|
+
requireOrgScope(orgField, orgId);
|
|
70
|
+
const results = {
|
|
71
|
+
created: [],
|
|
72
|
+
skipped: [],
|
|
73
|
+
errors: []
|
|
74
|
+
};
|
|
75
|
+
const validAccounts = [];
|
|
76
|
+
for (let i = 0; i < accounts.length; i++) {
|
|
77
|
+
const { accountTypeCode, accountNumber, name, active = true, isCashAccount = false } = accounts[i];
|
|
78
|
+
if (!accountTypeCode) {
|
|
79
|
+
results.errors.push({
|
|
80
|
+
index: i,
|
|
81
|
+
reason: "accountTypeCode is required"
|
|
82
|
+
});
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const at = country.getAccountType(accountTypeCode);
|
|
86
|
+
if (!at) {
|
|
87
|
+
results.errors.push({
|
|
88
|
+
index: i,
|
|
89
|
+
accountTypeCode,
|
|
90
|
+
reason: "Invalid account type code"
|
|
91
|
+
});
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (!country.isPostingAccount(accountTypeCode)) {
|
|
95
|
+
results.errors.push({
|
|
96
|
+
index: i,
|
|
97
|
+
accountTypeCode,
|
|
98
|
+
reason: `Not a posting account (${at.isGroup ? "group" : "total"})`
|
|
99
|
+
});
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const resolvedNumber = accountNumber ?? accountTypeCode;
|
|
103
|
+
const resolvedName = name ?? at.name ?? accountTypeCode;
|
|
104
|
+
validAccounts.push({
|
|
105
|
+
index: i,
|
|
106
|
+
accountTypeCode,
|
|
107
|
+
accountNumber: resolvedNumber,
|
|
108
|
+
name: resolvedName,
|
|
109
|
+
active: Boolean(active),
|
|
110
|
+
isCashAccount: Boolean(isCashAccount)
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (validAccounts.length === 0) return {
|
|
114
|
+
summary: {
|
|
115
|
+
total: accounts.length,
|
|
116
|
+
created: 0,
|
|
117
|
+
skipped: results.skipped.length,
|
|
118
|
+
errors: results.errors.length
|
|
119
|
+
},
|
|
120
|
+
...results
|
|
121
|
+
};
|
|
122
|
+
const existsFilter = { accountNumber: { $in: validAccounts.map((a) => a.accountNumber) } };
|
|
123
|
+
if (orgField && orgId != null) existsFilter[orgField] = orgId;
|
|
124
|
+
const existingDocs = await AccountModel.find(existsFilter).select("accountNumber").lean();
|
|
125
|
+
const existingNumbers = new Set(existingDocs.map((d) => d.accountNumber));
|
|
126
|
+
const toCreate = [];
|
|
127
|
+
for (const item of validAccounts) if (existingNumbers.has(item.accountNumber)) results.skipped.push({
|
|
128
|
+
index: item.index,
|
|
129
|
+
accountTypeCode: item.accountTypeCode,
|
|
130
|
+
reason: "Already exists"
|
|
131
|
+
});
|
|
132
|
+
else toCreate.push(item);
|
|
133
|
+
if (toCreate.length > 0) {
|
|
134
|
+
const docs = toCreate.map((item) => {
|
|
135
|
+
const doc = {
|
|
136
|
+
accountTypeCode: item.accountTypeCode,
|
|
137
|
+
accountNumber: item.accountNumber,
|
|
138
|
+
name: item.name,
|
|
139
|
+
active: item.active,
|
|
140
|
+
isCashAccount: item.isCashAccount
|
|
141
|
+
};
|
|
142
|
+
if (orgField && orgId != null) doc[orgField] = orgId;
|
|
143
|
+
return doc;
|
|
144
|
+
});
|
|
145
|
+
try {
|
|
146
|
+
const inserted = await AccountModel.insertMany(docs, { ordered: false });
|
|
147
|
+
results.created = toCreate.map((item, idx) => ({
|
|
148
|
+
accountTypeCode: item.accountTypeCode,
|
|
149
|
+
active: item.active,
|
|
150
|
+
isCashAccount: item.isCashAccount,
|
|
151
|
+
_id: inserted[idx]._id
|
|
152
|
+
}));
|
|
153
|
+
} catch (err) {
|
|
154
|
+
const bulkError = err;
|
|
155
|
+
if (bulkError.code === 11e3 || bulkError.writeErrors) {
|
|
156
|
+
const insertedDocs = bulkError.insertedDocs ?? [];
|
|
157
|
+
const insertedNumbers = new Set(insertedDocs.map((d) => d.accountNumber));
|
|
158
|
+
for (const item of toCreate) if (insertedNumbers.has(item.accountNumber)) {
|
|
159
|
+
const iDoc = insertedDocs.find((d) => d.accountNumber === item.accountNumber);
|
|
160
|
+
results.created.push({
|
|
161
|
+
accountTypeCode: item.accountTypeCode,
|
|
162
|
+
active: item.active,
|
|
163
|
+
isCashAccount: item.isCashAccount,
|
|
164
|
+
_id: iDoc?._id
|
|
165
|
+
});
|
|
166
|
+
} else results.skipped.push({
|
|
167
|
+
index: item.index,
|
|
168
|
+
accountTypeCode: item.accountTypeCode,
|
|
169
|
+
reason: "Already exists (concurrent insert)"
|
|
170
|
+
});
|
|
171
|
+
} else throw err;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
summary: {
|
|
176
|
+
total: accounts.length,
|
|
177
|
+
created: results.created.length,
|
|
178
|
+
skipped: results.skipped.length,
|
|
179
|
+
errors: results.errors.length
|
|
180
|
+
},
|
|
181
|
+
...results
|
|
81
182
|
};
|
|
82
|
-
if (orgField && organizationId != null) entryFilter[orgField] = organizationId;
|
|
83
|
-
if (reconciledIds.size > 0) entryFilter._id = { $nin: Array.from(reconciledIds) };
|
|
84
|
-
return await JournalEntryModel.find(entryFilter).sort({ date: -1 }).skip(skip).limit(limit).lean();
|
|
85
183
|
};
|
|
86
|
-
if (typeof repository.registerMethod === "function") for (const name of [
|
|
87
|
-
"reconcile",
|
|
88
|
-
"unreconcile",
|
|
89
|
-
"getUnreconciled"
|
|
90
|
-
]) {
|
|
184
|
+
if (typeof repository.registerMethod === "function") for (const name of ["seedAccounts", "bulkCreate"]) {
|
|
91
185
|
const fn = repository[name];
|
|
92
186
|
try {
|
|
93
187
|
delete repository[name];
|
|
@@ -152,7 +246,7 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
|
|
|
152
246
|
* Post an entry (draft → posted).
|
|
153
247
|
* Validates items, balance, and accounts before changing state.
|
|
154
248
|
*/
|
|
155
|
-
repository.post = async
|
|
249
|
+
repository.post = async (id, orgId, options = {}) => {
|
|
156
250
|
if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for post operations.");
|
|
157
251
|
requireOrgScope(orgField, orgId);
|
|
158
252
|
const entry = await findEntry(buildQuery(id, orgId), {
|
|
@@ -202,7 +296,7 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
|
|
|
202
296
|
* Resets state to draft so the entry can be edited and re-posted.
|
|
203
297
|
* Also clears the reversed flag if set, allowing full re-editing.
|
|
204
298
|
*/
|
|
205
|
-
repository.unpost = async
|
|
299
|
+
repository.unpost = async (id, orgId, options = {}) => {
|
|
206
300
|
if (strictness?.immutable) throw Errors.immutable("Unpost is disabled in strict mode. Use reverse() to correct posted entries.");
|
|
207
301
|
if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for unpost operations.");
|
|
208
302
|
requireOrgScope(orgField, orgId);
|
|
@@ -220,7 +314,7 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
|
|
|
220
314
|
* Used to discard unneeded drafts without deleting them, preserving audit trail.
|
|
221
315
|
* Only draft entries can be archived. Posted entries must be reversed instead.
|
|
222
316
|
*/
|
|
223
|
-
repository.archive = async
|
|
317
|
+
repository.archive = async (id, orgId, options = {}) => {
|
|
224
318
|
if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for archive operations.");
|
|
225
319
|
requireOrgScope(orgField, orgId);
|
|
226
320
|
const entry = await findEntry(buildQuery(id, orgId), { session: options.session });
|
|
@@ -235,7 +329,7 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
|
|
|
235
329
|
* Duplicate an entry as a new draft.
|
|
236
330
|
* Copies journal items, journal type, and label. Assigns today's date.
|
|
237
331
|
*/
|
|
238
|
-
repository.duplicate = async
|
|
332
|
+
repository.duplicate = async (id, orgId, options = {}) => {
|
|
239
333
|
requireOrgScope(orgField, orgId);
|
|
240
334
|
const entry = await findEntry(buildQuery(id, orgId), { session: options.session });
|
|
241
335
|
if (!entry) throw Errors.notFound("Entry not found");
|
|
@@ -272,7 +366,7 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
|
|
|
272
366
|
* Routes the reversal through repository.create() so all plugins (fiscal-lock,
|
|
273
367
|
* double-entry) enforce policy on the reversal entry.
|
|
274
368
|
*/
|
|
275
|
-
repository.reverse = async
|
|
369
|
+
repository.reverse = async (id, orgId, options = {}) => {
|
|
276
370
|
if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for reverse operations.");
|
|
277
371
|
requireOrgScope(orgField, orgId);
|
|
278
372
|
const query = buildQuery(id, orgId);
|
|
@@ -315,7 +409,7 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
|
|
|
315
409
|
if (options.actorId) reversalData.postedBy = options.actorId;
|
|
316
410
|
const reversalEntry = await create(reversalData, session ? { session } : {});
|
|
317
411
|
entry.reversed = true;
|
|
318
|
-
entry.reversedBy = reversalEntry
|
|
412
|
+
entry.reversedBy = reversalEntry._id;
|
|
319
413
|
if (options.actorId) entry.reversedByUser = options.actorId;
|
|
320
414
|
await entry.save({ session });
|
|
321
415
|
return {
|
|
@@ -346,188 +440,94 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
|
|
|
346
440
|
return repository;
|
|
347
441
|
}
|
|
348
442
|
//#endregion
|
|
349
|
-
//#region src/repositories/
|
|
443
|
+
//#region src/repositories/reconciliation.repository.ts
|
|
350
444
|
/**
|
|
351
|
-
* Wire
|
|
352
|
-
* onto an existing mongokit Repository.
|
|
445
|
+
* Wire reconciliation methods onto an existing mongokit Repository.
|
|
353
446
|
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
* @param orgField - The multi-tenant field name (e.g. 'business')
|
|
447
|
+
* - reconcile() uses repository.create() so hooks (multi-tenant, audit) fire
|
|
448
|
+
* - unreconcile() uses repository.delete() so hooks fire
|
|
449
|
+
* - Cross-repo reads (JournalEntryModel) use direct Model access (acceptable)
|
|
358
450
|
*/
|
|
359
|
-
function
|
|
360
|
-
repository.
|
|
361
|
-
|
|
362
|
-
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.`);
|
|
363
|
-
});
|
|
451
|
+
function wireReconciliationMethods(repository, _ReconciliationModel, JournalEntryModel, orgField) {
|
|
452
|
+
const create = repository.create.bind(repository);
|
|
453
|
+
const deleteById = repository.delete.bind(repository);
|
|
364
454
|
/**
|
|
365
|
-
*
|
|
455
|
+
* Create a reconciliation record linking matched journal entries.
|
|
456
|
+
* Validates that all entries exist, are posted, and belong to the same account/org.
|
|
366
457
|
*/
|
|
367
|
-
repository.
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
created: 0,
|
|
385
|
-
skipped: existingNumbers.size
|
|
386
|
-
};
|
|
387
|
-
try {
|
|
388
|
-
return {
|
|
389
|
-
created: (await AccountModel.insertMany(toCreate, {
|
|
390
|
-
session: options.session ?? void 0,
|
|
391
|
-
ordered: false
|
|
392
|
-
})).length,
|
|
393
|
-
skipped: existingNumbers.size
|
|
394
|
-
};
|
|
395
|
-
} catch (err) {
|
|
396
|
-
const bulkError = err;
|
|
397
|
-
if (bulkError.code === 11e3 || bulkError.writeErrors) {
|
|
398
|
-
const insertedDocs = bulkError.insertedDocs ?? [];
|
|
399
|
-
return {
|
|
400
|
-
created: insertedDocs.length,
|
|
401
|
-
skipped: existingNumbers.size + (toCreate.length - insertedDocs.length)
|
|
402
|
-
};
|
|
403
|
-
}
|
|
404
|
-
throw err;
|
|
458
|
+
repository.reconcile = async (input) => {
|
|
459
|
+
const { account, journalEntryIds, note, reconciledBy, organizationId } = input;
|
|
460
|
+
requireOrgScope(orgField, organizationId);
|
|
461
|
+
if (!journalEntryIds || journalEntryIds.length === 0) throw Errors.validation("journalEntryIds must contain at least one entry.");
|
|
462
|
+
const query = { _id: { $in: journalEntryIds } };
|
|
463
|
+
if (orgField && organizationId != null) query[orgField] = organizationId;
|
|
464
|
+
const entries = await JournalEntryModel.find(query).lean();
|
|
465
|
+
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.`);
|
|
466
|
+
const notPosted = entries.filter((e) => e.state !== "posted");
|
|
467
|
+
if (notPosted.length > 0) throw Errors.validation(`${notPosted.length} entry(ies) are not posted. Only posted entries can be reconciled.`);
|
|
468
|
+
const accountStr = String(account);
|
|
469
|
+
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}.`);
|
|
470
|
+
let debitTotal = 0;
|
|
471
|
+
let creditTotal = 0;
|
|
472
|
+
for (const entry of entries) for (const item of entry.journalItems) if (String(item.account) === accountStr) {
|
|
473
|
+
debitTotal += item.debit ?? 0;
|
|
474
|
+
creditTotal += item.credit ?? 0;
|
|
405
475
|
}
|
|
476
|
+
const reconciliationData = {
|
|
477
|
+
account,
|
|
478
|
+
journalEntryIds,
|
|
479
|
+
debitTotal,
|
|
480
|
+
creditTotal,
|
|
481
|
+
difference: debitTotal - creditTotal,
|
|
482
|
+
note,
|
|
483
|
+
reconciledBy,
|
|
484
|
+
reconciledAt: /* @__PURE__ */ new Date()
|
|
485
|
+
};
|
|
486
|
+
if (orgField && organizationId != null) reconciliationData[orgField] = organizationId;
|
|
487
|
+
return await create(reconciliationData);
|
|
406
488
|
};
|
|
407
489
|
/**
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
* Uses a single batch query to check existing accounts (instead of N+1),
|
|
411
|
-
* and ordered: false on insertMany to handle concurrent race conditions
|
|
412
|
-
* gracefully (duplicate key errors on individual docs don't abort the batch).
|
|
490
|
+
* Remove a reconciliation record via repository.delete().
|
|
413
491
|
*/
|
|
414
|
-
repository.
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
for (let i = 0; i < accounts.length; i++) {
|
|
423
|
-
const { accountTypeCode, accountNumber, name, active = true, isCashAccount = false } = accounts[i];
|
|
424
|
-
if (!accountTypeCode) {
|
|
425
|
-
results.errors.push({
|
|
426
|
-
index: i,
|
|
427
|
-
reason: "accountTypeCode is required"
|
|
428
|
-
});
|
|
429
|
-
continue;
|
|
430
|
-
}
|
|
431
|
-
const at = country.getAccountType(accountTypeCode);
|
|
432
|
-
if (!at) {
|
|
433
|
-
results.errors.push({
|
|
434
|
-
index: i,
|
|
435
|
-
accountTypeCode,
|
|
436
|
-
reason: "Invalid account type code"
|
|
437
|
-
});
|
|
438
|
-
continue;
|
|
439
|
-
}
|
|
440
|
-
if (!country.isPostingAccount(accountTypeCode)) {
|
|
441
|
-
results.errors.push({
|
|
442
|
-
index: i,
|
|
443
|
-
accountTypeCode,
|
|
444
|
-
reason: `Not a posting account (${at.isGroup ? "group" : "total"})`
|
|
445
|
-
});
|
|
446
|
-
continue;
|
|
447
|
-
}
|
|
448
|
-
const resolvedNumber = accountNumber ?? accountTypeCode;
|
|
449
|
-
const resolvedName = name ?? at.name ?? accountTypeCode;
|
|
450
|
-
validAccounts.push({
|
|
451
|
-
index: i,
|
|
452
|
-
accountTypeCode,
|
|
453
|
-
accountNumber: resolvedNumber,
|
|
454
|
-
name: resolvedName,
|
|
455
|
-
active: Boolean(active),
|
|
456
|
-
isCashAccount: Boolean(isCashAccount)
|
|
457
|
-
});
|
|
458
|
-
}
|
|
459
|
-
if (validAccounts.length === 0) return {
|
|
460
|
-
summary: {
|
|
461
|
-
total: accounts.length,
|
|
462
|
-
created: 0,
|
|
463
|
-
skipped: results.skipped.length,
|
|
464
|
-
errors: results.errors.length
|
|
465
|
-
},
|
|
466
|
-
...results
|
|
467
|
-
};
|
|
468
|
-
const existsFilter = { accountNumber: { $in: validAccounts.map((a) => a.accountNumber) } };
|
|
469
|
-
if (orgField && orgId != null) existsFilter[orgField] = orgId;
|
|
470
|
-
const existingDocs = await AccountModel.find(existsFilter).select("accountNumber").lean();
|
|
471
|
-
const existingNumbers = new Set(existingDocs.map((d) => d.accountNumber));
|
|
472
|
-
const toCreate = [];
|
|
473
|
-
for (const item of validAccounts) if (existingNumbers.has(item.accountNumber)) results.skipped.push({
|
|
474
|
-
index: item.index,
|
|
475
|
-
accountTypeCode: item.accountTypeCode,
|
|
476
|
-
reason: "Already exists"
|
|
477
|
-
});
|
|
478
|
-
else toCreate.push(item);
|
|
479
|
-
if (toCreate.length > 0) {
|
|
480
|
-
const docs = toCreate.map((item) => {
|
|
481
|
-
const doc = {
|
|
482
|
-
accountTypeCode: item.accountTypeCode,
|
|
483
|
-
accountNumber: item.accountNumber,
|
|
484
|
-
name: item.name,
|
|
485
|
-
active: item.active,
|
|
486
|
-
isCashAccount: item.isCashAccount
|
|
487
|
-
};
|
|
488
|
-
if (orgField && orgId != null) doc[orgField] = orgId;
|
|
489
|
-
return doc;
|
|
490
|
-
});
|
|
491
|
-
try {
|
|
492
|
-
const inserted = await AccountModel.insertMany(docs, { ordered: false });
|
|
493
|
-
results.created = toCreate.map((item, idx) => ({
|
|
494
|
-
accountTypeCode: item.accountTypeCode,
|
|
495
|
-
active: item.active,
|
|
496
|
-
isCashAccount: item.isCashAccount,
|
|
497
|
-
_id: inserted[idx]._id
|
|
498
|
-
}));
|
|
499
|
-
} catch (err) {
|
|
500
|
-
const bulkError = err;
|
|
501
|
-
if (bulkError.code === 11e3 || bulkError.writeErrors) {
|
|
502
|
-
const insertedDocs = bulkError.insertedDocs ?? [];
|
|
503
|
-
const insertedNumbers = new Set(insertedDocs.map((d) => d.accountNumber));
|
|
504
|
-
for (const item of toCreate) if (insertedNumbers.has(item.accountNumber)) {
|
|
505
|
-
const iDoc = insertedDocs.find((d) => d.accountNumber === item.accountNumber);
|
|
506
|
-
results.created.push({
|
|
507
|
-
accountTypeCode: item.accountTypeCode,
|
|
508
|
-
active: item.active,
|
|
509
|
-
isCashAccount: item.isCashAccount,
|
|
510
|
-
_id: iDoc?._id
|
|
511
|
-
});
|
|
512
|
-
} else results.skipped.push({
|
|
513
|
-
index: item.index,
|
|
514
|
-
accountTypeCode: item.accountTypeCode,
|
|
515
|
-
reason: "Already exists (concurrent insert)"
|
|
516
|
-
});
|
|
517
|
-
} else throw err;
|
|
518
|
-
}
|
|
492
|
+
repository.unreconcile = async (input) => {
|
|
493
|
+
const { reconciliationId, organizationId } = input;
|
|
494
|
+
requireOrgScope(orgField, organizationId);
|
|
495
|
+
if (orgField && organizationId != null) {
|
|
496
|
+
if (!await repository._executeQuery(async (Model) => Model.findOne({
|
|
497
|
+
_id: reconciliationId,
|
|
498
|
+
[orgField]: organizationId
|
|
499
|
+
}).select("_id").lean())) throw Errors.notFound("Reconciliation record not found.");
|
|
519
500
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
501
|
+
const result = await deleteById(String(reconciliationId));
|
|
502
|
+
if (!result.success) throw Errors.notFound("Reconciliation record not found.");
|
|
503
|
+
return result;
|
|
504
|
+
};
|
|
505
|
+
/**
|
|
506
|
+
* Find journal entries for an account that are NOT in any reconciliation record.
|
|
507
|
+
* Uses repository.getAll() for reconciliation lookups (hooks fire),
|
|
508
|
+
* and direct JournalEntryModel for cross-repo reads (acceptable).
|
|
509
|
+
*/
|
|
510
|
+
repository.getUnreconciled = async (input) => {
|
|
511
|
+
const { accountId, organizationId, limit = 100, skip = 0 } = input;
|
|
512
|
+
requireOrgScope(orgField, organizationId);
|
|
513
|
+
const reconFilter = { account: accountId };
|
|
514
|
+
if (orgField && organizationId != null) reconFilter[orgField] = organizationId;
|
|
515
|
+
const reconciliations = await repository._executeQuery(async (Model) => Model.find(reconFilter).select("journalEntryIds").lean());
|
|
516
|
+
const reconciledIds = /* @__PURE__ */ new Set();
|
|
517
|
+
for (const rec of reconciliations) for (const id of rec.journalEntryIds) reconciledIds.add(String(id));
|
|
518
|
+
const entryFilter = {
|
|
519
|
+
state: "posted",
|
|
520
|
+
"journalItems.account": accountId
|
|
528
521
|
};
|
|
522
|
+
if (orgField && organizationId != null) entryFilter[orgField] = organizationId;
|
|
523
|
+
if (reconciledIds.size > 0) entryFilter._id = { $nin: Array.from(reconciledIds) };
|
|
524
|
+
return await JournalEntryModel.find(entryFilter).sort({ date: -1 }).skip(skip).limit(limit).lean();
|
|
529
525
|
};
|
|
530
|
-
if (typeof repository.registerMethod === "function") for (const name of [
|
|
526
|
+
if (typeof repository.registerMethod === "function") for (const name of [
|
|
527
|
+
"reconcile",
|
|
528
|
+
"unreconcile",
|
|
529
|
+
"getUnreconciled"
|
|
530
|
+
]) {
|
|
531
531
|
const fn = repository[name];
|
|
532
532
|
try {
|
|
533
533
|
delete repository[name];
|
|
@@ -539,4 +539,4 @@ function wireAccountMethods(repository, AccountModel, country, orgField) {
|
|
|
539
539
|
return repository;
|
|
540
540
|
}
|
|
541
541
|
//#endregion
|
|
542
|
-
export { wireJournalEntryMethods as n,
|
|
542
|
+
export { wireJournalEntryMethods as n, wireAccountMethods as r, wireReconciliationMethods as t };
|