@classytic/ledger 0.5.1 → 0.6.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.
@@ -1,2 +1,2 @@
1
- import { a as TaxReportLine, i as TaxCodesByRegion, n as CountryPackInput, o as TaxReportTemplate, r as TaxCode, s as defineCountryPack, t as CountryPack } from "../index-GmfEFxVn.mjs";
2
- export { CountryPack, CountryPackInput, TaxCode, TaxCodesByRegion, TaxReportLine, TaxReportTemplate, defineCountryPack };
1
+ import { a as TaxCodesByRegion, c as TaxReportLine, i as TaxCode, l as TaxReportTemplate, n as CountryPackInput, o as TaxExigibility, r as JournalTemplate, s as TaxRepartitionLine, t as CountryPack, u as defineCountryPack } from "../index-BthGypsI.mjs";
2
+ export { CountryPack, CountryPackInput, JournalTemplate, TaxCode, TaxCodesByRegion, TaxExigibility, TaxRepartitionLine, TaxReportLine, TaxReportTemplate, defineCountryPack };
@@ -27,7 +27,7 @@ const Errors = {
27
27
  notFound: (msg, fields) => new AccountingError(msg, 404, "NOT_FOUND", fields),
28
28
  conflict: (msg, fields) => new AccountingError(msg, 409, "CONFLICT", fields),
29
29
  immutable: (msg, fields) => new AccountingError(msg, 403, "IMMUTABLE_ENTRY", fields),
30
- fiscal: (msg, fields) => new AccountingError(msg, 400, "FISCAL_ERROR", fields)
30
+ locked: (scope, msg, fields) => new AccountingError(msg, 409, `PERIOD_LOCKED_${scope.toUpperCase()}`, fields)
31
31
  };
32
32
  //#endregion
33
33
  export { Errors as n, AccountingError as t };
@@ -0,0 +1,459 @@
1
+ import { n as Errors, t as AccountingError } from "./errors-CSDQPNyt.mjs";
2
+ //#region src/plugins/double-entry.plugin.ts
3
+ function doubleEntryPlugin(options = {}) {
4
+ const { onlyOnPost = true, JournalEntryModel, AccountModel, orgField } = options;
5
+ function validateItems(items, data) {
6
+ const lineErrors = [];
7
+ for (let i = 0; i < items.length; i++) {
8
+ const d = items[i].debit ?? 0;
9
+ const c = items[i].credit ?? 0;
10
+ if (d > 0 && c > 0) lineErrors.push({
11
+ path: `journalItems.${i}`,
12
+ issue: "line cannot have both debit and credit greater than zero",
13
+ value: {
14
+ debit: d,
15
+ credit: c
16
+ }
17
+ });
18
+ if (d === 0 && c === 0) lineErrors.push({
19
+ path: `journalItems.${i}`,
20
+ issue: "line cannot have both debit and credit equal to zero",
21
+ value: {
22
+ debit: 0,
23
+ credit: 0
24
+ }
25
+ });
26
+ }
27
+ if (lineErrors.length > 0) throw Errors.validation(`Invalid journal line(s): ${lineErrors.map((e) => `${e.path} — ${e.issue}`).join("; ")}`, lineErrors);
28
+ const totalDebit = items.reduce((s, i) => s + (i.debit ?? 0), 0);
29
+ const totalCredit = items.reduce((s, i) => s + (i.credit ?? 0), 0);
30
+ if (totalDebit !== totalCredit) throw Errors.validation(`Double-entry violation: debits (${totalDebit}) ≠ credits (${totalCredit}). Difference: ${Math.abs(totalDebit - totalCredit)}`, [{
31
+ path: "journalItems",
32
+ issue: "debits must equal credits",
33
+ value: {
34
+ totalDebit,
35
+ totalCredit,
36
+ difference: totalDebit - totalCredit
37
+ }
38
+ }]);
39
+ data.totalDebit = totalDebit;
40
+ data.totalCredit = totalCredit;
41
+ }
42
+ return {
43
+ name: "accounting:double-entry",
44
+ apply(repo) {
45
+ const validate = async (context) => {
46
+ const data = context.data;
47
+ if (!data) return;
48
+ if (onlyOnPost && data.state !== "posted") return;
49
+ const items = data.journalItems;
50
+ if (data.state === "posted" && (!items || items.length < 2)) throw Errors.validation(`Cannot post entry: at least 2 journal items required, got ${items?.length ?? 0}.`);
51
+ if (!items || items.length === 0) return;
52
+ validateItems(items, data);
53
+ if (data.state === "posted") {
54
+ if (!AccountModel) throw new Error("doubleEntryPlugin: AccountModel is required to validate posted entries. Pass AccountModel in plugin options to enable account existence and tenant integrity checks.");
55
+ await validateAccounts(items, data, context);
56
+ }
57
+ };
58
+ /** Verify all journal item accounts exist and belong to the same org */
59
+ const validateAccounts = async (items, data, context) => {
60
+ const missingIdxs = [];
61
+ items.forEach((item, idx) => {
62
+ if (item.account == null || item.account === "") missingIdxs.push(idx);
63
+ });
64
+ if (missingIdxs.length > 0) throw Errors.validation(`Posted entry has items with missing accounts at index(es): ${missingIdxs.join(", ")}.`, missingIdxs.map((i) => ({
65
+ path: `journalItems.${i}.account`,
66
+ issue: "account is required on posted entries"
67
+ })));
68
+ const accountIds = items.map((i) => i.account);
69
+ const selectFields = orgField ? `_id ${orgField}` : "_id";
70
+ const accounts = await AccountModel?.find({ _id: { $in: accountIds } }).select(selectFields).session(context.session ?? null).lean();
71
+ const foundIds = new Set(accounts.map((a) => String(a._id)));
72
+ const missingFieldErrors = [];
73
+ items.forEach((item, idx) => {
74
+ if (!foundIds.has(String(item.account))) missingFieldErrors.push({
75
+ path: `journalItems.${idx}.account`,
76
+ issue: "account does not exist",
77
+ value: item.account
78
+ });
79
+ });
80
+ if (missingFieldErrors.length > 0) throw Errors.validation(`${missingFieldErrors.length} item(s) reference non-existent accounts.`, missingFieldErrors);
81
+ if (orgField && data[orgField] != null) {
82
+ const dataOrg = String(data[orgField]);
83
+ const accountOrgById = new Map(accounts.map((a) => [String(a._id), String(a[orgField])]));
84
+ const crossTenantFieldErrors = [];
85
+ items.forEach((item, idx) => {
86
+ const acctOrg = accountOrgById.get(String(item.account));
87
+ if (acctOrg !== void 0 && acctOrg !== dataOrg) crossTenantFieldErrors.push({
88
+ path: `journalItems.${idx}.account`,
89
+ issue: "account belongs to another organization",
90
+ value: {
91
+ account: item.account,
92
+ expectedOrg: dataOrg,
93
+ actualOrg: acctOrg
94
+ }
95
+ });
96
+ });
97
+ if (crossTenantFieldErrors.length > 0) throw Errors.validation(`${crossTenantFieldErrors.length} item(s) reference accounts from another organization.`, crossTenantFieldErrors);
98
+ }
99
+ };
100
+ const validateUpdate = async (context) => {
101
+ const data = context.data;
102
+ if (!data) return;
103
+ const internalOp = context._ledgerInternal;
104
+ if (JournalEntryModel && !internalOp) {
105
+ const id = context.id;
106
+ if (id) {
107
+ if ((await JournalEntryModel.findById(id).select("state").session(context.session ?? null).lean())?.state === "posted") {
108
+ if (data.state !== void 0 && data.state !== "posted") throw Errors.immutable("Cannot change state of a posted journal entry. Posted entries are immutable.");
109
+ const allowedKeys = new Set(["state"]);
110
+ if (Object.keys(data).some((k) => !allowedKeys.has(k))) throw Errors.immutable("Cannot modify a posted journal entry. Use reverse() to create a correcting entry instead.");
111
+ }
112
+ }
113
+ }
114
+ if (onlyOnPost && data.state !== "posted") return;
115
+ const items = data.journalItems;
116
+ if (items !== void 0) {
117
+ if (items.length < 2) throw Errors.validation(`Cannot post entry: at least 2 journal items required, got ${items.length}.`);
118
+ validateItems(items, data);
119
+ if (AccountModel) await validateAccounts(items, data, context);
120
+ return;
121
+ }
122
+ if (!JournalEntryModel) throw new Error("doubleEntryPlugin: JournalEntryModel is required to validate partial updates that set state to \"posted\". Pass JournalEntryModel in plugin options.");
123
+ const id = context.id;
124
+ if (!id) throw new Error("doubleEntryPlugin: update context is missing \"id\". Cannot validate partial post without document ID.");
125
+ const existing = await JournalEntryModel.findById(id).select("journalItems").session(context.session ?? null).lean();
126
+ if (!existing) return;
127
+ const persistedItems = existing.journalItems;
128
+ if (!persistedItems || persistedItems.length < 2) throw Errors.validation(`Cannot post entry: at least 2 journal items required, got ${persistedItems?.length ?? 0}.`);
129
+ validateItems(persistedItems, data);
130
+ if (AccountModel) await validateAccounts(persistedItems, {
131
+ ...data,
132
+ ...existing
133
+ }, context);
134
+ };
135
+ repo.on("before:create", validate);
136
+ repo.on("before:update", validateUpdate);
137
+ }
138
+ };
139
+ }
140
+ //#endregion
141
+ //#region src/plugins/idempotency.plugin.ts
142
+ function idempotencyPlugin(options) {
143
+ const { JournalEntryModel, orgField } = options;
144
+ return {
145
+ name: "accounting:idempotency",
146
+ apply(repo) {
147
+ repo.on("before:create", async (context) => {
148
+ const data = context.data;
149
+ if (!data?.idempotencyKey) return;
150
+ const query = { idempotencyKey: data.idempotencyKey };
151
+ if (orgField && data[orgField]) query[orgField] = data[orgField];
152
+ const existing = await JournalEntryModel.findOne(query).select("_id").session(context.session ?? null).lean();
153
+ if (existing) throw Errors.conflict(`Duplicate idempotency key: "${data.idempotencyKey}". Existing entry: ${existing._id}`);
154
+ });
155
+ }
156
+ };
157
+ }
158
+ //#endregion
159
+ //#region src/plugins/lock/create-lock-plugin.ts
160
+ function createLockPlugin(options) {
161
+ const { scope, resolve, accountSelector, AccountModel, JournalEntryModel, orgField } = options;
162
+ if (accountSelector && !AccountModel) throw new Error(`createLockPlugin({ scope: '${scope}' }): accountSelector requires AccountModel.`);
163
+ return {
164
+ name: `accounting:lock:${scope}`,
165
+ apply(repo) {
166
+ const run = async (context, isUpdate) => {
167
+ const data = context.data;
168
+ if (!data) return;
169
+ if (data.state !== "posted") return;
170
+ if (context._ledgerInternal === "reverseMark") return;
171
+ const session = context.session ?? null;
172
+ let entryDate;
173
+ let persistedDoc = null;
174
+ if (data.date) entryDate = new Date(data.date);
175
+ else if (!isUpdate) entryDate = /* @__PURE__ */ new Date();
176
+ else {
177
+ if (!context.id) throw new Error(`lockPlugin[${scope}]: update context is missing "id". Cannot validate lock without document ID.`);
178
+ if (!JournalEntryModel) throw new Error(`lockPlugin[${scope}]: JournalEntryModel is required to validate partial updates that set state to "posted".`);
179
+ const selectFields = orgField ? `date ${orgField} journalItems` : "date journalItems";
180
+ persistedDoc = await JournalEntryModel.findById(context.id).select(selectFields).session(session).lean();
181
+ if (persistedDoc?.date) entryDate = new Date(persistedDoc.date);
182
+ }
183
+ if (!entryDate) return;
184
+ let orgValue;
185
+ if (orgField) {
186
+ orgValue = data[orgField] ?? context[orgField];
187
+ if (!orgValue && isUpdate) {
188
+ if (persistedDoc) orgValue = persistedDoc[orgField];
189
+ else if (context.id && JournalEntryModel) {
190
+ const persisted = await JournalEntryModel.findById(context.id).select(orgField).session(session).lean();
191
+ if (persisted) orgValue = persisted[orgField];
192
+ }
193
+ }
194
+ if (!orgValue) throw new Error(`lockPlugin[${scope}]: orgField "${orgField}" is configured but could not be resolved from payload, context, or persisted document.`);
195
+ }
196
+ if (accountSelector && AccountModel) {
197
+ const accountIds = (data.journalItems ?? persistedDoc?.journalItems ?? []).map((i) => {
198
+ const a = i.account;
199
+ if (typeof a === "object" && a !== null) return a._id ?? a;
200
+ return a;
201
+ }).filter((id) => id != null);
202
+ if (accountIds.length === 0) return;
203
+ if (!(await AccountModel.find({ _id: { $in: accountIds } }).session(session).lean()).some((acc) => accountSelector(acc))) return;
204
+ }
205
+ const hit = await resolve({
206
+ entryDate,
207
+ orgValue,
208
+ session,
209
+ data,
210
+ repositoryContext: context
211
+ });
212
+ if (!hit) return;
213
+ const datePart = entryDate.toISOString().split("T")[0];
214
+ const subTypePart = hit.subType ? ` [${hit.subType}]` : "";
215
+ const refPart = hit.externalRef ? ` (ref: ${hit.externalRef})` : "";
216
+ throw Errors.locked(hit.scope, `Cannot post entry dated ${datePart}: ${hit.scope}${subTypePart} period "${hit.label}" is closed${refPart}.`);
217
+ };
218
+ repo.on("before:create", (ctx) => run(ctx, false));
219
+ repo.on("before:update", (ctx) => run(ctx, true));
220
+ }
221
+ };
222
+ }
223
+ //#endregion
224
+ //#region src/plugins/lock/period-resolver.ts
225
+ function periodResolver(options) {
226
+ const { scope, PeriodModel, startField = "startDate", endField = "endDate", closedField = "closed", closedValue = true, labelField = "name", subTypeField, externalRefField, orgField, extraQuery } = options;
227
+ return async (ctx) => {
228
+ const query = {
229
+ [startField]: { $lte: ctx.entryDate },
230
+ [endField]: { $gte: ctx.entryDate },
231
+ [closedField]: closedValue
232
+ };
233
+ if (orgField) {
234
+ if (!ctx.orgValue) throw new Error(`periodResolver[${scope}]: orgField "${orgField}" set but no orgValue was resolved.`);
235
+ query[orgField] = ctx.orgValue;
236
+ }
237
+ const extra = extraQuery?.(ctx);
238
+ if (extra) Object.assign(query, extra);
239
+ const session = ctx.session;
240
+ const doc = await PeriodModel.findOne(query).session(session).lean();
241
+ if (!doc) return null;
242
+ return {
243
+ scope,
244
+ label: String(doc[labelField] ?? "(unnamed)"),
245
+ subType: subTypeField ? doc[subTypeField] : void 0,
246
+ externalRef: externalRefField ? doc[externalRefField] : void 0
247
+ };
248
+ };
249
+ }
250
+ //#endregion
251
+ //#region src/plugins/lock/watermark-resolver.ts
252
+ function watermarkResolver(options) {
253
+ const { scope, getWatermark, formatLabel } = options;
254
+ return async (ctx) => {
255
+ const watermark = await getWatermark(ctx.orgValue, ctx.session);
256
+ if (!watermark) return null;
257
+ if (ctx.entryDate.getTime() > watermark.getTime()) return null;
258
+ return {
259
+ scope,
260
+ label: formatLabel?.(watermark, ctx) ?? `through ${watermark.toISOString().split("T")[0]}`
261
+ };
262
+ };
263
+ }
264
+ //#endregion
265
+ //#region src/plugins/lock/presets.ts
266
+ function fiscalLockPlugin(options) {
267
+ return createLockPlugin({
268
+ scope: "fiscal",
269
+ JournalEntryModel: options.JournalEntryModel,
270
+ orgField: options.orgField,
271
+ resolve: periodResolver({
272
+ scope: "fiscal",
273
+ PeriodModel: options.FiscalPeriodModel,
274
+ orgField: options.orgField
275
+ })
276
+ });
277
+ }
278
+ const defaultTaxSelector = (acc) => acc.taxMetadata != null;
279
+ function taxLockPlugin(options) {
280
+ const { TaxPeriodModel, AccountModel, JournalEntryModel, orgField } = options;
281
+ const isTaxAffecting = options.isTaxAffecting ?? defaultTaxSelector;
282
+ const deriveFilter = options.deriveFilter ?? ((data) => ({
283
+ jurisdiction: data.jurisdiction,
284
+ taxType: data.taxType
285
+ }));
286
+ return createLockPlugin({
287
+ scope: "tax",
288
+ accountSelector: isTaxAffecting,
289
+ AccountModel,
290
+ JournalEntryModel,
291
+ orgField,
292
+ resolve: periodResolver({
293
+ scope: "tax",
294
+ PeriodModel: TaxPeriodModel,
295
+ startField: "periodStart",
296
+ endField: "periodEnd",
297
+ closedField: "status",
298
+ closedValue: { $ne: "open" },
299
+ labelField: "jurisdiction",
300
+ subTypeField: "taxType",
301
+ externalRefField: "returnRef",
302
+ orgField,
303
+ extraQuery: (ctx) => {
304
+ const filter = deriveFilter(ctx.data);
305
+ if (!filter) return void 0;
306
+ const out = {};
307
+ if (filter.jurisdiction) out.jurisdiction = filter.jurisdiction;
308
+ if (filter.taxType) out.taxType = filter.taxType;
309
+ return Object.keys(out).length ? out : void 0;
310
+ }
311
+ })
312
+ });
313
+ }
314
+ function dailyLockPlugin(options) {
315
+ return createLockPlugin({
316
+ scope: "daily",
317
+ JournalEntryModel: options.JournalEntryModel,
318
+ orgField: options.orgField,
319
+ resolve: watermarkResolver({
320
+ scope: "daily",
321
+ getWatermark: options.getLastClosedDate,
322
+ formatLabel: (watermark) => `day closed through ${watermark.toISOString().split("T")[0]}`
323
+ })
324
+ });
325
+ }
326
+ //#endregion
327
+ //#region src/plugins/credit-limit.plugin.ts
328
+ function creditLimitPlugin(options) {
329
+ const { arControlAccountId, getCreditLimit, partnerField = "partnerId", JournalEntryModel, orgField, toleranceCents = 0 } = options;
330
+ const arControlStr = String(arControlAccountId);
331
+ return {
332
+ name: "accounting:credit-limit",
333
+ apply(repo) {
334
+ repo.on("before:create", async (context) => {
335
+ const data = context.data;
336
+ if (!data) return;
337
+ if (context._ledgerInternal) return;
338
+ if (data.state !== "posted") return;
339
+ const items = data.journalItems ?? [];
340
+ if (items.length === 0) return;
341
+ const session = context.session ?? null;
342
+ const newExposureByPartner = /* @__PURE__ */ new Map();
343
+ for (const item of items) {
344
+ if (String(item.account) !== arControlStr) continue;
345
+ const delta = (item.debit ?? 0) - (item.credit ?? 0);
346
+ if (delta <= 0) continue;
347
+ const partner = item[partnerField];
348
+ if (partner == null) throw Errors.validation(`creditLimitPlugin: A/R item missing required "${partnerField}" — every credit-sale line must carry a partner reference.`);
349
+ const key = String(partner);
350
+ newExposureByPartner.set(key, (newExposureByPartner.get(key) ?? 0) + delta);
351
+ }
352
+ if (newExposureByPartner.size === 0) return;
353
+ for (const [partnerKey, newDelta] of newExposureByPartner) {
354
+ const limit = await getCreditLimit(partnerKey, session);
355
+ if (limit == null) continue;
356
+ const orgFilter = {};
357
+ if (orgField && context[orgField] != null) orgFilter[orgField] = context[orgField];
358
+ else if (orgField && data[orgField] != null) orgFilter[orgField] = data[orgField];
359
+ const pipeline = [
360
+ { $match: {
361
+ state: "posted",
362
+ ...orgFilter
363
+ } },
364
+ { $unwind: "$journalItems" },
365
+ { $match: {
366
+ "journalItems.account": arControlAccountId,
367
+ [`journalItems.${partnerField}`]: partnerKey,
368
+ $or: [{ "journalItems.matchingNumber": null }, { "journalItems.matchingNumber": { $exists: false } }]
369
+ } },
370
+ { $group: {
371
+ _id: null,
372
+ outstanding: { $sum: { $subtract: [{ $ifNull: ["$journalItems.debit", 0] }, { $ifNull: ["$journalItems.credit", 0] }] } }
373
+ } }
374
+ ];
375
+ const currentOutstanding = (await JournalEntryModel.aggregate(pipeline).session(session))[0]?.outstanding ?? 0;
376
+ const projected = currentOutstanding + newDelta;
377
+ if (projected > limit + toleranceCents) throw new AccountingError(`Credit limit exceeded for partner ${partnerKey}: projected ${projected}c > limit ${limit}c (current ${currentOutstanding}c + new ${newDelta}c).`, 402, "CREDIT_LIMIT_EXCEEDED", [
378
+ {
379
+ path: partnerField,
380
+ issue: "over credit limit",
381
+ value: partnerKey
382
+ },
383
+ {
384
+ path: "limit",
385
+ issue: "partner credit limit in cents",
386
+ value: limit
387
+ },
388
+ {
389
+ path: "currentOutstanding",
390
+ issue: "existing open A/R in cents",
391
+ value: currentOutstanding
392
+ },
393
+ {
394
+ path: "newExposure",
395
+ issue: "new debit being posted in cents",
396
+ value: newDelta
397
+ }
398
+ ]);
399
+ }
400
+ });
401
+ }
402
+ };
403
+ }
404
+ //#endregion
405
+ //#region src/plugins/fx-realization.plugin.ts
406
+ function fxRealizationPlugin(options) {
407
+ const { journalEntries, realizedGainAccount, realizedLossAccount, baseCurrency, orgField } = options;
408
+ return {
409
+ name: "accounting:fx-realization",
410
+ apply(repo) {
411
+ repo.on("after:match", async (ctx) => {
412
+ const { reconciliation, items, sharedCurrency, session } = ctx;
413
+ if (!sharedCurrency || sharedCurrency === baseCurrency) return;
414
+ const rates = items.map((i) => i.exchangeRate).filter((r) => typeof r === "number" && r > 0);
415
+ if (rates.length < 2) return;
416
+ if (rates.every((r) => r === rates[0])) return;
417
+ const baseNet = items.reduce((sum, it) => sum + it.debit - it.credit, 0);
418
+ if (baseNet === 0) return;
419
+ const gain = baseNet > 0;
420
+ const absAmount = Math.abs(baseNet);
421
+ const accountId = reconciliation.account;
422
+ const matchingNumber = reconciliation.matchingNumber;
423
+ const orgId = orgField ? reconciliation[orgField] : void 0;
424
+ const fxItems = gain ? [{
425
+ account: realizedGainAccount,
426
+ debit: 0,
427
+ credit: absAmount
428
+ }, {
429
+ account: accountId,
430
+ debit: absAmount,
431
+ credit: 0
432
+ }] : [{
433
+ account: realizedLossAccount,
434
+ debit: absAmount,
435
+ credit: 0
436
+ }, {
437
+ account: accountId,
438
+ debit: 0,
439
+ credit: absAmount
440
+ }];
441
+ const entryData = {
442
+ journalType: "MISC",
443
+ state: "posted",
444
+ date: /* @__PURE__ */ new Date(),
445
+ label: `FX realization for ${matchingNumber}`,
446
+ journalItems: fxItems
447
+ };
448
+ if (orgField && orgId != null) entryData[orgField] = orgId;
449
+ const created = await journalEntries.create(entryData, {
450
+ session,
451
+ _ledgerInternal: "fxRealize"
452
+ });
453
+ await repo.Model.updateOne({ matchingNumber }, { $set: { fxRealizationEntry: created._id } }, { session: session ?? void 0 });
454
+ });
455
+ }
456
+ };
457
+ }
458
+ //#endregion
459
+ export { taxLockPlugin as a, createLockPlugin as c, fiscalLockPlugin as i, idempotencyPlugin as l, creditLimitPlugin as n, watermarkResolver as o, dailyLockPlugin as r, periodResolver as s, fxRealizationPlugin as t, doubleEntryPlugin as u };