@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.
Files changed (35) hide show
  1. package/README.md +82 -20
  2. package/dist/constants/index.d.mts +2 -2
  3. package/dist/constants/index.mjs +3 -3
  4. package/dist/{date-lock.plugin-eYAJ9h_u.mjs → date-lock.plugin-DL6pe24p.mjs} +2 -2
  5. package/dist/{engine-Cn-9yerQ.d.mts → engine-scgOvxHJ.d.mts} +30 -2
  6. package/dist/exports/index.d.mts +1 -1
  7. package/dist/exports/index.mjs +1 -1
  8. package/dist/{exports-I5Xkq-9_.mjs → exports-DoGQQtMQ.mjs} +96 -75
  9. package/dist/{fiscal-close-B6LhQ10f.mjs → fiscal-close-B2_7WMTe.mjs} +748 -751
  10. package/dist/{index-BPukb3L8.d.mts → index-J-XIbXH-.d.mts} +7 -7
  11. package/dist/index.d.mts +239 -87
  12. package/dist/index.mjs +149 -12
  13. package/dist/{fiscal-period.schema-BMnlI9H5.d.mts → journal-entry.schema-JqrfbvB4.d.mts} +12 -12
  14. package/dist/{journals-oH-FK3g8.mjs → journals-BfwnCFam.mjs} +27 -4
  15. package/dist/{currencies-4WAbFRlw.d.mts → journals-DTipb_rz.d.mts} +16 -7
  16. package/dist/money.mjs +2 -2
  17. package/dist/plugins/index.d.mts +1 -1
  18. package/dist/plugins/index.mjs +1 -1
  19. package/dist/{reconciliation.repository-CW4-8q90.d.mts → reconciliation.repository-D-D_ITL-.d.mts} +14 -14
  20. package/dist/{account.repository-BpkSd6q3.mjs → reconciliation.repository-fPwFKvrk.mjs} +255 -255
  21. package/dist/{reconciliation.schema-BuetvZTd.mjs → reconciliation.schema-BA1lPv4t.mjs} +174 -173
  22. package/dist/reports/index.d.mts +1 -1
  23. package/dist/reports/index.mjs +1 -1
  24. package/dist/repositories/index.d.mts +1 -1
  25. package/dist/repositories/index.mjs +1 -1
  26. package/dist/schemas/index.d.mts +6 -6
  27. package/dist/schemas/index.mjs +1 -1
  28. package/dist/{tenant-guard-Fm6AID_6.mjs → tenant-guard-r17Se3Bb.mjs} +1 -1
  29. package/dist/{revaluation-D9x0NE8w.d.mts → trial-balance-DcQ0xj_4.d.mts} +124 -124
  30. package/docs/schemas.md +2 -2
  31. package/package.json +14 -6
  32. /package/dist/{categories-CclX7Q94.mjs → categories-DWogBUgQ.mjs} +0 -0
  33. /package/dist/{errors-B7yC-Jfw.mjs → errors-B_dyYZc_.mjs} +0 -0
  34. /package/dist/{idempotency.plugin-B_CNsInz.d.mts → idempotency.plugin-zU-GKJ0-.d.mts} +0 -0
  35. /package/dist/{logger-CbHWZl7v.d.mts → logger-UbTdBb1x.d.mts} +0 -0
@@ -1,93 +1,187 @@
1
- import { n as Errors } from "./errors-B7yC-Jfw.mjs";
2
- import { t as requireOrgScope } from "./tenant-guard-Fm6AID_6.mjs";
3
- //#region src/repositories/reconciliation.repository.ts
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 reconciliation methods onto an existing mongokit Repository.
5
+ * Wire seedAccounts, bulkCreate and posting-account validation
6
+ * onto an existing mongokit Repository.
6
7
  *
7
- * - reconcile() uses repository.create() so hooks (multi-tenant, audit) fire
8
- * - unreconcile() uses repository.delete() so hooks fire
9
- * - Cross-repo reads (JournalEntryModel) use direct Model access (acceptable)
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 wireReconciliationMethods(repository, _ReconciliationModel, JournalEntryModel, orgField) {
12
- const create = repository.create.bind(repository);
13
- const deleteById = repository.delete.bind(repository);
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
- * Create a reconciliation record linking matched journal entries.
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.reconcile = async function(input) {
19
- const { account, journalEntryIds, note, reconciledBy, organizationId } = input;
20
- requireOrgScope(orgField, organizationId);
21
- if (!journalEntryIds || journalEntryIds.length === 0) throw Errors.validation("journalEntryIds must contain at least one entry.");
22
- const query = { _id: { $in: journalEntryIds } };
23
- if (orgField && organizationId != null) query[orgField] = organizationId;
24
- const entries = await JournalEntryModel.find(query).lean();
25
- 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.`);
26
- const notPosted = entries.filter((e) => e.state !== "posted");
27
- if (notPosted.length > 0) throw Errors.validation(`${notPosted.length} entry(ies) are not posted. Only posted entries can be reconciled.`);
28
- const accountStr = String(account);
29
- 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}.`);
30
- let debitTotal = 0;
31
- let creditTotal = 0;
32
- for (const entry of entries) for (const item of entry.journalItems) if (String(item.account) === accountStr) {
33
- debitTotal += item.debit ?? 0;
34
- creditTotal += item.credit ?? 0;
35
- }
36
- const reconciliationData = {
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
- if (orgField && organizationId != null) reconciliationData[orgField] = organizationId;
47
- return await create(reconciliationData);
48
- };
49
- /**
50
- * Remove a reconciliation record via repository.delete().
51
- */
52
- repository.unreconcile = async function(input) {
53
- const { reconciliationId, organizationId } = input;
54
- requireOrgScope(orgField, organizationId);
55
- if (orgField && organizationId != null) {
56
- if (!await repository._executeQuery(async (Model) => Model.findOne({
57
- _id: reconciliationId,
58
- [orgField]: organizationId
59
- }).select("_id").lean())) throw Errors.notFound("Reconciliation record not found.");
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
- * Find journal entries for an account that are NOT in any reconciliation record.
67
- * Uses repository.getAll() for reconciliation lookups (hooks fire),
68
- * and direct JournalEntryModel for cross-repo reads (acceptable).
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.getUnreconciled = async function(input) {
71
- const { accountId, organizationId, limit = 100, skip = 0 } = input;
72
- requireOrgScope(orgField, organizationId);
73
- const reconFilter = { account: accountId };
74
- if (orgField && organizationId != null) reconFilter[orgField] = organizationId;
75
- const reconciliations = await repository._executeQuery(async (Model) => Model.find(reconFilter).select("journalEntryIds").lean());
76
- const reconciledIds = /* @__PURE__ */ new Set();
77
- for (const rec of reconciliations) for (const id of rec.journalEntryIds) reconciledIds.add(String(id));
78
- const entryFilter = {
79
- state: "posted",
80
- "journalItems.account": accountId
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 function(id, orgId, options = {}) {
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 function(id, orgId, options = {}) {
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 function(id, orgId, options = {}) {
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 function(id, orgId, options = {}) {
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 function(id, orgId, options = {}) {
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["_id"];
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/account.repository.ts
443
+ //#region src/repositories/reconciliation.repository.ts
350
444
  /**
351
- * Wire seedAccounts, bulkCreate and posting-account validation
352
- * onto an existing mongokit Repository.
445
+ * Wire reconciliation methods onto an existing mongokit Repository.
353
446
  *
354
- * @param repository - A mongokit Repository instance (already created)
355
- * @param AccountModel - The Mongoose model for accounts
356
- * @param country - The CountryPack for account type lookups
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 wireAccountMethods(repository, AccountModel, country, orgField) {
360
- repository.on("before:create", (ctx) => {
361
- const code = ctx.data?.accountTypeCode;
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
- * Seed standard posting accounts for an organization.
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.seedAccounts = async function(orgId, options = {}) {
368
- requireOrgScope(orgField, orgId);
369
- const postingTypes = country.getPostingAccountTypes();
370
- const filter = {};
371
- if (orgField && orgId != null) filter[orgField] = orgId;
372
- const existing = await AccountModel.find(filter).select("accountNumber").lean();
373
- const existingNumbers = new Set(existing.map((a) => a.accountNumber));
374
- const toCreate = postingTypes.filter((at) => !existingNumbers.has(at.code)).map((at) => {
375
- const doc = {
376
- accountTypeCode: at.code,
377
- accountNumber: at.code,
378
- name: at.name
379
- };
380
- if (orgField && orgId != null) doc[orgField] = orgId;
381
- return doc;
382
- });
383
- if (toCreate.length === 0) return {
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
- * Bulk create accounts with validation and skip-if-exists logic.
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.bulkCreate = async function(accounts, orgId) {
415
- requireOrgScope(orgField, orgId);
416
- const results = {
417
- created: [],
418
- skipped: [],
419
- errors: []
420
- };
421
- const validAccounts = [];
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
- return {
521
- summary: {
522
- total: accounts.length,
523
- created: results.created.length,
524
- skipped: results.skipped.length,
525
- errors: results.errors.length
526
- },
527
- ...results
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 ["seedAccounts", "bulkCreate"]) {
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, wireReconciliationMethods as r, wireAccountMethods as t };
542
+ export { wireJournalEntryMethods as n, wireAccountMethods as r, wireReconciliationMethods as t };