@classytic/ledger 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +227 -189
- package/dist/constants/index.d.mts +1 -1
- package/dist/constants/index.mjs +2 -3
- package/dist/country/index.d.mts +1 -1
- package/dist/{journals-BfwnCFam.mjs → currencies-CsuBGfgs.mjs} +80 -1
- package/dist/{date-lock.plugin-DL6pe24p.mjs → date-lock.plugin-B2Jy0ukX.mjs} +61 -10
- package/dist/errors-BmRjW38t.mjs +33 -0
- package/dist/exports/index.d.mts +1 -1
- package/dist/exports/index.mjs +1 -1
- package/dist/{fiscal-close-B2_7WMTe.mjs → fiscal-close-Dk3yRT9i.mjs} +14 -4
- package/dist/{index-CxZqRaOU.d.mts → index-GmfEFxVn.d.mts} +1 -1
- package/dist/index.d.mts +530 -338
- package/dist/index.mjs +1824 -175
- package/dist/{journals-DTipb_rz.d.mts → journals-C50E9mpo.d.mts} +1 -1
- package/dist/plugins/index.d.mts +1 -1
- package/dist/plugins/index.mjs +1 -1
- package/dist/reports/index.d.mts +1 -1
- package/dist/reports/index.mjs +1 -1
- package/dist/{trial-balance-DcQ0xj_4.d.mts → trial-balance-BZ7yOOFD.d.mts} +16 -4
- package/package.json +1 -11
- package/dist/currencies-W8kQAkm0.mjs +0 -80
- package/dist/engine-scgOvxHJ.d.mts +0 -130
- package/dist/errors-B_dyYZc_.mjs +0 -26
- package/dist/journal-entry.schema-JqrfbvB4.d.mts +0 -103
- package/dist/logger-UbTdBb1x.d.mts +0 -14
- package/dist/reconciliation.repository-D-D_ITL-.d.mts +0 -135
- package/dist/reconciliation.repository-fPwFKvrk.mjs +0 -542
- package/dist/reconciliation.schema-BA1lPv4t.mjs +0 -666
- package/dist/repositories/index.d.mts +0 -2
- package/dist/repositories/index.mjs +0 -2
- package/dist/schemas/index.d.mts +0 -71
- package/dist/schemas/index.mjs +0 -2
- package/dist/tenant-guard-r17Se3Bb.mjs +0 -13
- /package/dist/{categories-DWogBUgQ.mjs → categories-BkKdv16V.mjs} +0 -0
- /package/dist/{core-8Xfnpn6g.d.mts → core-BkGjuVZj.d.mts} +0 -0
- /package/dist/{exports-DoGQQtMQ.mjs → exports-BP-0Ni5W.mjs} +0 -0
- /package/dist/{idempotency.plugin-zU-GKJ0-.d.mts → idempotency.plugin-CK7LHnBn.d.mts} +0 -0
- /package/dist/{index-J-XIbXH-.d.mts → index-D1ZjgVxn.d.mts} +0 -0
|
@@ -1,542 +0,0 @@
|
|
|
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
|
-
/**
|
|
5
|
-
* Wire seedAccounts, bulkCreate and posting-account validation
|
|
6
|
-
* onto an existing mongokit Repository.
|
|
7
|
-
*
|
|
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')
|
|
12
|
-
*/
|
|
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
|
-
});
|
|
18
|
-
/**
|
|
19
|
-
* Seed standard posting accounts for an organization.
|
|
20
|
-
*/
|
|
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
|
|
40
|
-
};
|
|
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;
|
|
59
|
-
}
|
|
60
|
-
};
|
|
61
|
-
/**
|
|
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).
|
|
67
|
-
*/
|
|
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
|
|
182
|
-
};
|
|
183
|
-
};
|
|
184
|
-
if (typeof repository.registerMethod === "function") for (const name of ["seedAccounts", "bulkCreate"]) {
|
|
185
|
-
const fn = repository[name];
|
|
186
|
-
try {
|
|
187
|
-
delete repository[name];
|
|
188
|
-
repository.registerMethod(name, fn);
|
|
189
|
-
} catch {
|
|
190
|
-
repository[name] = fn;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
return repository;
|
|
194
|
-
}
|
|
195
|
-
//#endregion
|
|
196
|
-
//#region src/repositories/journal-entry.repository.ts
|
|
197
|
-
/** Keys that are either handled explicitly or must not be copied */
|
|
198
|
-
const ITEM_CORE_KEYS = new Set([
|
|
199
|
-
"account",
|
|
200
|
-
"debit",
|
|
201
|
-
"credit",
|
|
202
|
-
"label",
|
|
203
|
-
"date",
|
|
204
|
-
"taxDetails",
|
|
205
|
-
"_id",
|
|
206
|
-
"id"
|
|
207
|
-
]);
|
|
208
|
-
/**
|
|
209
|
-
* Wire post/reverse onto an existing mongokit Repository.
|
|
210
|
-
*
|
|
211
|
-
* All reads use `repository.getByQuery()` so registered plugins
|
|
212
|
-
* (multi-tenant, audit, cache) fire on every operation.
|
|
213
|
-
*
|
|
214
|
-
* @param repository - A mongokit Repository instance (already created)
|
|
215
|
-
* @param _JournalEntryModel - (Deprecated) The Mongoose model — no longer used internally; kept for API compat
|
|
216
|
-
* @param orgField - The multi-tenant field name (e.g. 'business')
|
|
217
|
-
* @param strictness - Strictness rules (immutable, requireActor, requireApproval)
|
|
218
|
-
*/
|
|
219
|
-
function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, strictness) {
|
|
220
|
-
const getByQuery = repository.getByQuery.bind(repository);
|
|
221
|
-
const create = repository.create.bind(repository);
|
|
222
|
-
const withTransaction = repository.withTransaction.bind(repository);
|
|
223
|
-
/** Build a tenant-scoped query for a single entry by ID (injection-safe) */
|
|
224
|
-
function buildQuery(id, orgId) {
|
|
225
|
-
validateScalarId(id, "entry ID");
|
|
226
|
-
if (orgId != null) validateScalarId(orgId, "organization ID");
|
|
227
|
-
const query = { _id: id };
|
|
228
|
-
if (orgField && orgId != null) query[orgField] = orgId;
|
|
229
|
-
return query;
|
|
230
|
-
}
|
|
231
|
-
/** Reject operator-injected objects like { $ne: null } but allow ObjectIds */
|
|
232
|
-
function validateScalarId(value, label) {
|
|
233
|
-
if (value == null || typeof value !== "object") return;
|
|
234
|
-
const obj = value;
|
|
235
|
-
if (typeof obj.toHexString === "function" || obj._bsontype === "ObjectId") return;
|
|
236
|
-
if (Object.keys(obj).some((k) => k.startsWith("$"))) throw Errors.validation(`Invalid ${label} — MongoDB operators are not allowed.`);
|
|
237
|
-
}
|
|
238
|
-
/** Fetch an entry via the repository (fires all hooks) */
|
|
239
|
-
async function findEntry(query, options) {
|
|
240
|
-
const opts = { lean: false };
|
|
241
|
-
if (options.populate) opts.populate = options.populate;
|
|
242
|
-
if (options.session) opts.session = options.session;
|
|
243
|
-
return await getByQuery(query, opts);
|
|
244
|
-
}
|
|
245
|
-
/**
|
|
246
|
-
* Post an entry (draft → posted).
|
|
247
|
-
* Validates items, balance, and accounts before changing state.
|
|
248
|
-
*/
|
|
249
|
-
repository.post = async (id, orgId, options = {}) => {
|
|
250
|
-
if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for post operations.");
|
|
251
|
-
requireOrgScope(orgField, orgId);
|
|
252
|
-
const entry = await findEntry(buildQuery(id, orgId), {
|
|
253
|
-
session: options.session,
|
|
254
|
-
populate: "journalItems.account"
|
|
255
|
-
});
|
|
256
|
-
if (!entry) throw Errors.notFound("Entry not found");
|
|
257
|
-
if (entry.idempotencyKey && entry.state === "posted") return entry;
|
|
258
|
-
if (entry.state !== "draft") throw Errors.validation("Only draft entries can be posted");
|
|
259
|
-
if (strictness?.requireApproval) {
|
|
260
|
-
if (!entry.approvedBy || !entry.approvedAt) throw Errors.validation("Entry must be approved before posting. Both approvedBy and approvedAt are required.");
|
|
261
|
-
}
|
|
262
|
-
if (!entry.journalItems || entry.journalItems.length < 2) throw Errors.validation("Journal entry must have at least 2 items to post");
|
|
263
|
-
const missing = entry.journalItems.filter((i) => !i.account || i.account === "");
|
|
264
|
-
if (missing.length > 0) throw Errors.validation(`${missing.length} item(s) missing an account`);
|
|
265
|
-
const nullAccounts = entry.journalItems.filter((i) => {
|
|
266
|
-
const acct = i.account;
|
|
267
|
-
if (!acct) return true;
|
|
268
|
-
if (typeof acct === "string") return true;
|
|
269
|
-
if (typeof acct === "object" && !acct._id) return true;
|
|
270
|
-
return false;
|
|
271
|
-
});
|
|
272
|
-
if (nullAccounts.length > 0) throw Errors.validation(`${nullAccounts.length} item(s) reference accounts that do not exist. Ensure all accounts are created before posting.`);
|
|
273
|
-
if (orgField && orgId != null) {
|
|
274
|
-
const crossTenant = entry.journalItems.filter((i) => {
|
|
275
|
-
const acct = i.account;
|
|
276
|
-
if (!acct || typeof acct !== "object") return false;
|
|
277
|
-
return String(acct[orgField]) !== String(orgId);
|
|
278
|
-
});
|
|
279
|
-
if (crossTenant.length > 0) throw Errors.validation(`${crossTenant.length} item(s) reference accounts from another organization`);
|
|
280
|
-
}
|
|
281
|
-
const zeroed = entry.journalItems.filter((i) => (i.debit || 0) === 0 && (i.credit || 0) === 0);
|
|
282
|
-
if (zeroed.length > 0) throw Errors.validation(`${zeroed.length} item(s) have both debit and credit as zero`);
|
|
283
|
-
const bothSet = entry.journalItems.filter((i) => (i.debit || 0) > 0 && (i.credit || 0) > 0);
|
|
284
|
-
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`);
|
|
285
|
-
const totalDebit = entry.journalItems.reduce((s, i) => s + (i.debit || 0), 0);
|
|
286
|
-
const totalCredit = entry.journalItems.reduce((s, i) => s + (i.credit || 0), 0);
|
|
287
|
-
if (totalDebit !== totalCredit) throw Errors.validation(`Entry is not balanced. Debit: ${totalDebit}, Credit: ${totalCredit}`);
|
|
288
|
-
entry.state = "posted";
|
|
289
|
-
entry.stateChangedAt = /* @__PURE__ */ new Date();
|
|
290
|
-
if (options.actorId) entry.postedBy = options.actorId;
|
|
291
|
-
await entry.save({ session: options.session });
|
|
292
|
-
return entry;
|
|
293
|
-
};
|
|
294
|
-
/**
|
|
295
|
-
* Unpost an entry (posted → draft).
|
|
296
|
-
* Resets state to draft so the entry can be edited and re-posted.
|
|
297
|
-
* Also clears the reversed flag if set, allowing full re-editing.
|
|
298
|
-
*/
|
|
299
|
-
repository.unpost = async (id, orgId, options = {}) => {
|
|
300
|
-
if (strictness?.immutable) throw Errors.immutable("Unpost is disabled in strict mode. Use reverse() to correct posted entries.");
|
|
301
|
-
if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for unpost operations.");
|
|
302
|
-
requireOrgScope(orgField, orgId);
|
|
303
|
-
const entry = await findEntry(buildQuery(id, orgId), { session: options.session });
|
|
304
|
-
if (!entry) throw Errors.notFound("Entry not found");
|
|
305
|
-
if (entry.state !== "posted") throw Errors.validation("Only posted entries can be unposted");
|
|
306
|
-
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.");
|
|
307
|
-
entry.state = "draft";
|
|
308
|
-
entry.stateChangedAt = /* @__PURE__ */ new Date();
|
|
309
|
-
await entry.save({ session: options.session });
|
|
310
|
-
return entry;
|
|
311
|
-
};
|
|
312
|
-
/**
|
|
313
|
-
* Archive a draft entry (draft → archived).
|
|
314
|
-
* Used to discard unneeded drafts without deleting them, preserving audit trail.
|
|
315
|
-
* Only draft entries can be archived. Posted entries must be reversed instead.
|
|
316
|
-
*/
|
|
317
|
-
repository.archive = async (id, orgId, options = {}) => {
|
|
318
|
-
if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for archive operations.");
|
|
319
|
-
requireOrgScope(orgField, orgId);
|
|
320
|
-
const entry = await findEntry(buildQuery(id, orgId), { session: options.session });
|
|
321
|
-
if (!entry) throw Errors.notFound("Entry not found");
|
|
322
|
-
if (entry.state !== "draft") throw Errors.validation("Only draft entries can be archived");
|
|
323
|
-
entry.state = "archived";
|
|
324
|
-
entry.stateChangedAt = /* @__PURE__ */ new Date();
|
|
325
|
-
await entry.save({ session: options.session });
|
|
326
|
-
return entry;
|
|
327
|
-
};
|
|
328
|
-
/**
|
|
329
|
-
* Duplicate an entry as a new draft.
|
|
330
|
-
* Copies journal items, journal type, and label. Assigns today's date.
|
|
331
|
-
*/
|
|
332
|
-
repository.duplicate = async (id, orgId, options = {}) => {
|
|
333
|
-
requireOrgScope(orgField, orgId);
|
|
334
|
-
const entry = await findEntry(buildQuery(id, orgId), { session: options.session });
|
|
335
|
-
if (!entry) throw Errors.notFound("Entry not found");
|
|
336
|
-
const duplicateData = {
|
|
337
|
-
journalType: entry.journalType,
|
|
338
|
-
state: "draft",
|
|
339
|
-
date: /* @__PURE__ */ new Date(),
|
|
340
|
-
label: entry.label ? `Copy of ${entry.label}` : "Duplicated entry",
|
|
341
|
-
journalItems: entry.journalItems.map((item) => {
|
|
342
|
-
const accountId = typeof item.account === "object" && item.account !== null ? item.account._id : item.account;
|
|
343
|
-
const extra = {};
|
|
344
|
-
for (const key of Object.keys(item)) if (!ITEM_CORE_KEYS.has(key)) extra[key] = item[key];
|
|
345
|
-
return {
|
|
346
|
-
...extra,
|
|
347
|
-
account: accountId,
|
|
348
|
-
debit: item.debit ?? 0,
|
|
349
|
-
credit: item.credit ?? 0,
|
|
350
|
-
label: item.label,
|
|
351
|
-
date: /* @__PURE__ */ new Date(),
|
|
352
|
-
taxDetails: item.taxDetails ?? []
|
|
353
|
-
};
|
|
354
|
-
})
|
|
355
|
-
};
|
|
356
|
-
if (orgField && entry[orgField] != null) duplicateData[orgField] = entry[orgField];
|
|
357
|
-
return await create(duplicateData, options.session ? { session: options.session } : {});
|
|
358
|
-
};
|
|
359
|
-
/**
|
|
360
|
-
* Reverse a posted entry by creating a mirror entry with flipped debits/credits.
|
|
361
|
-
* Marks the original as reversed and links both entries bidirectionally.
|
|
362
|
-
*
|
|
363
|
-
* Uses repository.withTransaction() for automatic retry on transient failures.
|
|
364
|
-
* Pass an external session to join a caller-managed transaction instead.
|
|
365
|
-
*
|
|
366
|
-
* Routes the reversal through repository.create() so all plugins (fiscal-lock,
|
|
367
|
-
* double-entry) enforce policy on the reversal entry.
|
|
368
|
-
*/
|
|
369
|
-
repository.reverse = async (id, orgId, options = {}) => {
|
|
370
|
-
if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for reverse operations.");
|
|
371
|
-
requireOrgScope(orgField, orgId);
|
|
372
|
-
const query = buildQuery(id, orgId);
|
|
373
|
-
const doReverse = async (session) => {
|
|
374
|
-
const entry = await findEntry(query, {
|
|
375
|
-
session,
|
|
376
|
-
populate: "journalItems.account"
|
|
377
|
-
});
|
|
378
|
-
if (!entry) throw Errors.notFound("Entry not found");
|
|
379
|
-
if (entry.state !== "posted") throw Errors.validation("Only posted entries can be reversed");
|
|
380
|
-
if (entry.reversed) throw Errors.validation("Entry has already been reversed");
|
|
381
|
-
const reversalItems = entry.journalItems.map((item) => {
|
|
382
|
-
const accountId = typeof item.account === "object" && item.account !== null ? item.account._id : item.account;
|
|
383
|
-
const extra = {};
|
|
384
|
-
for (const key of Object.keys(item)) if (!ITEM_CORE_KEYS.has(key)) extra[key] = item[key];
|
|
385
|
-
return {
|
|
386
|
-
...extra,
|
|
387
|
-
account: accountId,
|
|
388
|
-
debit: item.credit ?? 0,
|
|
389
|
-
credit: item.debit ?? 0,
|
|
390
|
-
label: item.label ? `Reversal: ${item.label}` : void 0,
|
|
391
|
-
date: item.date,
|
|
392
|
-
taxDetails: item.taxDetails ?? []
|
|
393
|
-
};
|
|
394
|
-
});
|
|
395
|
-
const totalDebit = reversalItems.reduce((s, i) => s + i.debit, 0);
|
|
396
|
-
const totalCredit = reversalItems.reduce((s, i) => s + i.credit, 0);
|
|
397
|
-
const reversalData = {
|
|
398
|
-
journalType: entry.journalType ?? "MISC",
|
|
399
|
-
state: "posted",
|
|
400
|
-
date: options.reversalDate ?? /* @__PURE__ */ new Date(),
|
|
401
|
-
label: `Reversal of ${entry.referenceNumber ?? entry._id}`,
|
|
402
|
-
journalItems: reversalItems,
|
|
403
|
-
totalDebit,
|
|
404
|
-
totalCredit,
|
|
405
|
-
reversalOf: entry._id,
|
|
406
|
-
stateChangedAt: /* @__PURE__ */ new Date()
|
|
407
|
-
};
|
|
408
|
-
if (orgField && entry[orgField] != null) reversalData[orgField] = entry[orgField];
|
|
409
|
-
if (options.actorId) reversalData.postedBy = options.actorId;
|
|
410
|
-
const reversalEntry = await create(reversalData, session ? { session } : {});
|
|
411
|
-
entry.reversed = true;
|
|
412
|
-
entry.reversedBy = reversalEntry._id;
|
|
413
|
-
if (options.actorId) entry.reversedByUser = options.actorId;
|
|
414
|
-
await entry.save({ session });
|
|
415
|
-
return {
|
|
416
|
-
original: entry,
|
|
417
|
-
reversal: reversalEntry
|
|
418
|
-
};
|
|
419
|
-
};
|
|
420
|
-
if (options.session) return await doReverse(options.session);
|
|
421
|
-
if (withTransaction) return await withTransaction((session) => doReverse(session), { allowFallback: true });
|
|
422
|
-
return await doReverse();
|
|
423
|
-
};
|
|
424
|
-
const methodNames = [
|
|
425
|
-
"post",
|
|
426
|
-
"unpost",
|
|
427
|
-
"archive",
|
|
428
|
-
"duplicate",
|
|
429
|
-
"reverse"
|
|
430
|
-
];
|
|
431
|
-
if (typeof repository.registerMethod === "function") for (const name of methodNames) {
|
|
432
|
-
const fn = repository[name];
|
|
433
|
-
try {
|
|
434
|
-
delete repository[name];
|
|
435
|
-
repository.registerMethod(name, fn);
|
|
436
|
-
} catch {
|
|
437
|
-
repository[name] = fn;
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
return repository;
|
|
441
|
-
}
|
|
442
|
-
//#endregion
|
|
443
|
-
//#region src/repositories/reconciliation.repository.ts
|
|
444
|
-
/**
|
|
445
|
-
* Wire reconciliation methods onto an existing mongokit Repository.
|
|
446
|
-
*
|
|
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)
|
|
450
|
-
*/
|
|
451
|
-
function wireReconciliationMethods(repository, _ReconciliationModel, JournalEntryModel, orgField) {
|
|
452
|
-
const create = repository.create.bind(repository);
|
|
453
|
-
const deleteById = repository.delete.bind(repository);
|
|
454
|
-
/**
|
|
455
|
-
* Create a reconciliation record linking matched journal entries.
|
|
456
|
-
* Validates that all entries exist, are posted, and belong to the same account/org.
|
|
457
|
-
*/
|
|
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;
|
|
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);
|
|
488
|
-
};
|
|
489
|
-
/**
|
|
490
|
-
* Remove a reconciliation record via repository.delete().
|
|
491
|
-
*/
|
|
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.");
|
|
500
|
-
}
|
|
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
|
|
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();
|
|
525
|
-
};
|
|
526
|
-
if (typeof repository.registerMethod === "function") for (const name of [
|
|
527
|
-
"reconcile",
|
|
528
|
-
"unreconcile",
|
|
529
|
-
"getUnreconciled"
|
|
530
|
-
]) {
|
|
531
|
-
const fn = repository[name];
|
|
532
|
-
try {
|
|
533
|
-
delete repository[name];
|
|
534
|
-
repository.registerMethod(name, fn);
|
|
535
|
-
} catch {
|
|
536
|
-
repository[name] = fn;
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
return repository;
|
|
540
|
-
}
|
|
541
|
-
//#endregion
|
|
542
|
-
export { wireJournalEntryMethods as n, wireAccountMethods as r, wireReconciliationMethods as t };
|