@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.
- package/dist/country/index.d.mts +2 -2
- package/dist/{errors-BmRjW38t.mjs → errors-CSDQPNyt.mjs} +1 -1
- package/dist/fx-realization.plugin-CgQFDGv2.mjs +459 -0
- package/dist/index-BthGypsI.d.mts +228 -0
- package/dist/index.d.mts +50 -105
- package/dist/index.mjs +570 -104
- package/dist/{fiscal-close-Dk3yRT9i.mjs → partner-ledger-D9H5hegI.mjs} +143 -6
- package/dist/plugins/index.d.mts +2 -24
- package/dist/plugins/index.mjs +2 -2
- package/dist/reports/index.d.mts +2 -2
- package/dist/reports/index.mjs +2 -2
- package/dist/tax-hooks-BnVenul5.d.mts +513 -0
- package/dist/{trial-balance-BZ7yOOFD.d.mts → trial-balance-s92GEvRR.d.mts} +75 -2
- package/package.json +6 -5
- package/dist/date-lock.plugin-B6WyvqNG.mjs +0 -254
- package/dist/idempotency.plugin-WcQLZU9n.d.mts +0 -98
- package/dist/index-GmfEFxVn.d.mts +0 -119
package/dist/country/index.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as TaxReportLine, i as
|
|
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
|
-
|
|
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 };
|