@classytic/ledger 0.5.1 → 0.7.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/country/index.mjs +0 -3
- package/dist/{errors-BmRjW38t.mjs → errors-CSDQPNyt.mjs} +1 -1
- package/dist/fx-realization.plugin-CfYy1tB6.mjs +423 -0
- package/dist/{index-GmfEFxVn.d.mts → index-BX8miYdu.d.mts} +41 -41
- package/dist/index-Bl0_ak5w.d.mts +464 -0
- package/dist/index.d.mts +31 -138
- package/dist/index.mjs +513 -166
- package/dist/{fiscal-close-Dk3yRT9i.mjs → partner-ledger-D9H5hegI.mjs} +143 -6
- package/dist/plugins/index.d.mts +2 -38
- package/dist/plugins/index.mjs +2 -57
- package/dist/reports/index.d.mts +2 -2
- package/dist/reports/index.mjs +2 -2
- package/dist/{trial-balance-BZ7yOOFD.d.mts → trial-balance-DTc8kzTD.d.mts} +75 -33
- 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/country/index.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export { CountryPack, CountryPackInput,
|
|
1
|
+
import { i as defineCountryPack, n as CountryPackInput, r as JournalTemplate, t as CountryPack } from "../index-BX8miYdu.mjs";
|
|
2
|
+
export { CountryPack, CountryPackInput, JournalTemplate, defineCountryPack };
|
package/dist/country/index.mjs
CHANGED
|
@@ -15,9 +15,6 @@ function defineCountryPack(input) {
|
|
|
15
15
|
const at = accountMap.get(code);
|
|
16
16
|
return at !== void 0 && !at.isTotal && !at.isGroup;
|
|
17
17
|
},
|
|
18
|
-
getTaxCodesForRegion: (region) => {
|
|
19
|
-
return (input.taxCodesByRegion[region] ?? []).map((c) => input.taxCodes[c]).filter(Boolean);
|
|
20
|
-
},
|
|
21
18
|
flattenAccountTypes: () => input.accountTypes
|
|
22
19
|
};
|
|
23
20
|
}
|
|
@@ -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,423 @@
|
|
|
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
|
+
function dailyLockPlugin(options) {
|
|
279
|
+
return createLockPlugin({
|
|
280
|
+
scope: "daily",
|
|
281
|
+
JournalEntryModel: options.JournalEntryModel,
|
|
282
|
+
orgField: options.orgField,
|
|
283
|
+
resolve: watermarkResolver({
|
|
284
|
+
scope: "daily",
|
|
285
|
+
getWatermark: options.getLastClosedDate,
|
|
286
|
+
formatLabel: (watermark) => `day closed through ${watermark.toISOString().split("T")[0]}`
|
|
287
|
+
})
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
//#endregion
|
|
291
|
+
//#region src/plugins/credit-limit.plugin.ts
|
|
292
|
+
function creditLimitPlugin(options) {
|
|
293
|
+
const { arControlAccountId, getCreditLimit, partnerField = "partnerId", JournalEntryModel, orgField, toleranceCents = 0 } = options;
|
|
294
|
+
const arControlStr = String(arControlAccountId);
|
|
295
|
+
return {
|
|
296
|
+
name: "accounting:credit-limit",
|
|
297
|
+
apply(repo) {
|
|
298
|
+
repo.on("before:create", async (context) => {
|
|
299
|
+
const data = context.data;
|
|
300
|
+
if (!data) return;
|
|
301
|
+
if (context._ledgerInternal) return;
|
|
302
|
+
if (data.state !== "posted") return;
|
|
303
|
+
const items = data.journalItems ?? [];
|
|
304
|
+
if (items.length === 0) return;
|
|
305
|
+
const session = context.session ?? null;
|
|
306
|
+
const newExposureByPartner = /* @__PURE__ */ new Map();
|
|
307
|
+
for (const item of items) {
|
|
308
|
+
if (String(item.account) !== arControlStr) continue;
|
|
309
|
+
const delta = (item.debit ?? 0) - (item.credit ?? 0);
|
|
310
|
+
if (delta <= 0) continue;
|
|
311
|
+
const partner = item[partnerField];
|
|
312
|
+
if (partner == null) throw Errors.validation(`creditLimitPlugin: A/R item missing required "${partnerField}" — every credit-sale line must carry a partner reference.`);
|
|
313
|
+
const key = String(partner);
|
|
314
|
+
newExposureByPartner.set(key, (newExposureByPartner.get(key) ?? 0) + delta);
|
|
315
|
+
}
|
|
316
|
+
if (newExposureByPartner.size === 0) return;
|
|
317
|
+
for (const [partnerKey, newDelta] of newExposureByPartner) {
|
|
318
|
+
const limit = await getCreditLimit(partnerKey, session);
|
|
319
|
+
if (limit == null) continue;
|
|
320
|
+
const orgFilter = {};
|
|
321
|
+
if (orgField && context[orgField] != null) orgFilter[orgField] = context[orgField];
|
|
322
|
+
else if (orgField && data[orgField] != null) orgFilter[orgField] = data[orgField];
|
|
323
|
+
const pipeline = [
|
|
324
|
+
{ $match: {
|
|
325
|
+
state: "posted",
|
|
326
|
+
...orgFilter
|
|
327
|
+
} },
|
|
328
|
+
{ $unwind: "$journalItems" },
|
|
329
|
+
{ $match: {
|
|
330
|
+
"journalItems.account": arControlAccountId,
|
|
331
|
+
[`journalItems.${partnerField}`]: partnerKey,
|
|
332
|
+
$or: [{ "journalItems.matchingNumber": null }, { "journalItems.matchingNumber": { $exists: false } }]
|
|
333
|
+
} },
|
|
334
|
+
{ $group: {
|
|
335
|
+
_id: null,
|
|
336
|
+
outstanding: { $sum: { $subtract: [{ $ifNull: ["$journalItems.debit", 0] }, { $ifNull: ["$journalItems.credit", 0] }] } }
|
|
337
|
+
} }
|
|
338
|
+
];
|
|
339
|
+
const currentOutstanding = (await JournalEntryModel.aggregate(pipeline).session(session))[0]?.outstanding ?? 0;
|
|
340
|
+
const projected = currentOutstanding + newDelta;
|
|
341
|
+
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", [
|
|
342
|
+
{
|
|
343
|
+
path: partnerField,
|
|
344
|
+
issue: "over credit limit",
|
|
345
|
+
value: partnerKey
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
path: "limit",
|
|
349
|
+
issue: "partner credit limit in cents",
|
|
350
|
+
value: limit
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
path: "currentOutstanding",
|
|
354
|
+
issue: "existing open A/R in cents",
|
|
355
|
+
value: currentOutstanding
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
path: "newExposure",
|
|
359
|
+
issue: "new debit being posted in cents",
|
|
360
|
+
value: newDelta
|
|
361
|
+
}
|
|
362
|
+
]);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
//#endregion
|
|
369
|
+
//#region src/plugins/fx-realization.plugin.ts
|
|
370
|
+
function fxRealizationPlugin(options) {
|
|
371
|
+
const { journalEntries, realizedGainAccount, realizedLossAccount, baseCurrency, orgField } = options;
|
|
372
|
+
return {
|
|
373
|
+
name: "accounting:fx-realization",
|
|
374
|
+
apply(repo) {
|
|
375
|
+
repo.on("after:match", async (ctx) => {
|
|
376
|
+
const { reconciliation, items, sharedCurrency, session } = ctx;
|
|
377
|
+
if (!sharedCurrency || sharedCurrency === baseCurrency) return;
|
|
378
|
+
const rates = items.map((i) => i.exchangeRate).filter((r) => typeof r === "number" && r > 0);
|
|
379
|
+
if (rates.length < 2) return;
|
|
380
|
+
if (rates.every((r) => r === rates[0])) return;
|
|
381
|
+
const baseNet = items.reduce((sum, it) => sum + it.debit - it.credit, 0);
|
|
382
|
+
if (baseNet === 0) return;
|
|
383
|
+
const gain = baseNet > 0;
|
|
384
|
+
const absAmount = Math.abs(baseNet);
|
|
385
|
+
const accountId = reconciliation.account;
|
|
386
|
+
const matchingNumber = reconciliation.matchingNumber;
|
|
387
|
+
const orgId = orgField ? reconciliation[orgField] : void 0;
|
|
388
|
+
const fxItems = gain ? [{
|
|
389
|
+
account: realizedGainAccount,
|
|
390
|
+
debit: 0,
|
|
391
|
+
credit: absAmount
|
|
392
|
+
}, {
|
|
393
|
+
account: accountId,
|
|
394
|
+
debit: absAmount,
|
|
395
|
+
credit: 0
|
|
396
|
+
}] : [{
|
|
397
|
+
account: realizedLossAccount,
|
|
398
|
+
debit: absAmount,
|
|
399
|
+
credit: 0
|
|
400
|
+
}, {
|
|
401
|
+
account: accountId,
|
|
402
|
+
debit: 0,
|
|
403
|
+
credit: absAmount
|
|
404
|
+
}];
|
|
405
|
+
const entryData = {
|
|
406
|
+
journalType: "MISC",
|
|
407
|
+
state: "posted",
|
|
408
|
+
date: /* @__PURE__ */ new Date(),
|
|
409
|
+
label: `FX realization for ${matchingNumber}`,
|
|
410
|
+
journalItems: fxItems
|
|
411
|
+
};
|
|
412
|
+
if (orgField && orgId != null) entryData[orgField] = orgId;
|
|
413
|
+
const created = await journalEntries.create(entryData, {
|
|
414
|
+
session,
|
|
415
|
+
_ledgerInternal: "fxRealize"
|
|
416
|
+
});
|
|
417
|
+
await repo.Model.updateOne({ matchingNumber }, { $set: { fxRealizationEntry: created._id } }, { session: session ?? void 0 });
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
//#endregion
|
|
423
|
+
export { watermarkResolver as a, idempotencyPlugin as c, fiscalLockPlugin as i, doubleEntryPlugin as l, creditLimitPlugin as n, periodResolver as o, dailyLockPlugin as r, createLockPlugin as s, fxRealizationPlugin as t };
|
|
@@ -1,33 +1,40 @@
|
|
|
1
1
|
import { t as AccountType } from "./core-BkGjuVZj.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/country/index.d.ts
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Declarative template that tells the engine which journals to seed for a
|
|
6
|
+
* new organization. Consumers call
|
|
7
|
+
* `engine.repositories.journals.seedDefaults(orgId)` which reads these from
|
|
8
|
+
* the country pack and creates one Journal document per template.
|
|
9
|
+
*
|
|
10
|
+
* Journals are *optional* — if a consumer never seeds journals, the legacy
|
|
11
|
+
* `journalType` enum on a journal entry still works. Consumers opting in
|
|
12
|
+
* get per-journal sequence prefixes, restricted payment methods, bank
|
|
13
|
+
* statement sources, etc.
|
|
14
|
+
*/
|
|
15
|
+
interface JournalTemplate {
|
|
16
|
+
/** Short stable identifier — e.g. `'SALES'`, `'PURCHASE'`, `'BANK'`. */
|
|
5
17
|
readonly code: string;
|
|
18
|
+
/** Display name. */
|
|
6
19
|
readonly name: string;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
readonly
|
|
12
|
-
|
|
13
|
-
readonly
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
readonly
|
|
22
|
-
|
|
23
|
-
readonly
|
|
24
|
-
readonly
|
|
25
|
-
}
|
|
26
|
-
interface TaxReportTemplate {
|
|
27
|
-
readonly name: string;
|
|
28
|
-
readonly lines: Readonly<Record<string | number, TaxReportLine>>;
|
|
29
|
-
calculate(inputData: Record<string | number, number>, manualData?: Record<string | number, number>): Record<string | number, number>;
|
|
30
|
-
summarize(calculated: Record<string | number, number>): Record<string, unknown>;
|
|
20
|
+
/**
|
|
21
|
+
* One of the registered `JOURNAL_TYPES` codes — connects this journal to
|
|
22
|
+
* the engine's reference-number generator and posting-contract system.
|
|
23
|
+
*/
|
|
24
|
+
readonly journalType: string;
|
|
25
|
+
/** Reference-number prefix — defaults to `code` when omitted. */
|
|
26
|
+
readonly sequencePrefix?: string;
|
|
27
|
+
/** First sequence number — defaults to `1`. */
|
|
28
|
+
readonly sequenceStartNum?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Logical source — pure ledgering (`'general'`), sale-side docs (`'sale'`),
|
|
31
|
+
* purchase-side docs (`'purchase'`), cash/bank movement (`'bank'`, `'cash'`).
|
|
32
|
+
* Drives default locks (sale-lock, purchase-lock) when they're wired.
|
|
33
|
+
*/
|
|
34
|
+
readonly kind?: 'general' | 'sale' | 'purchase' | 'bank' | 'cash' | string;
|
|
35
|
+
/** Optional default debit/credit account roles for quick data entry. */
|
|
36
|
+
readonly defaultDebitAccountRole?: string;
|
|
37
|
+
readonly defaultCreditAccountRole?: string;
|
|
31
38
|
}
|
|
32
39
|
interface CountryPack {
|
|
33
40
|
/** ISO 3166-1 alpha-2 code (e.g., 'CA', 'US', 'GB') */
|
|
@@ -38,17 +45,15 @@ interface CountryPack {
|
|
|
38
45
|
readonly defaultCurrency: string;
|
|
39
46
|
/**
|
|
40
47
|
* Full chart of accounts template — flat array of account type definitions.
|
|
41
|
-
* Includes both
|
|
48
|
+
* Includes both posting accounts and grouping / total rows.
|
|
42
49
|
*/
|
|
43
50
|
readonly accountTypes: readonly AccountType[];
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
readonly
|
|
50
|
-
/** Tax report template (e.g., CRA GST/HST return) */
|
|
51
|
-
readonly taxReport?: TaxReportTemplate;
|
|
51
|
+
/**
|
|
52
|
+
* Optional journal templates seeded per organization. When a consumer
|
|
53
|
+
* calls `engine.repositories.journals.seedDefaults(orgId)`, the engine
|
|
54
|
+
* creates one Journal document per template. See `JournalTemplate`.
|
|
55
|
+
*/
|
|
56
|
+
readonly journalTemplates?: readonly JournalTemplate[];
|
|
52
57
|
/**
|
|
53
58
|
* The retained earnings account code — the account that holds accumulated
|
|
54
59
|
* retained earnings (e.g. '3600' CA, '3310' BD).
|
|
@@ -85,8 +90,6 @@ interface CountryPack {
|
|
|
85
90
|
isValidAccountType(code: string): boolean;
|
|
86
91
|
/** Check if an account type can receive postings */
|
|
87
92
|
isPostingAccount(code: string): boolean;
|
|
88
|
-
/** Get tax codes for a specific region */
|
|
89
|
-
getTaxCodesForRegion(region: string): TaxCode[];
|
|
90
93
|
/** Flatten hierarchical accounts (if needed) */
|
|
91
94
|
flattenAccountTypes(): readonly AccountType[];
|
|
92
95
|
}
|
|
@@ -95,10 +98,7 @@ interface CountryPackInput {
|
|
|
95
98
|
name: string;
|
|
96
99
|
defaultCurrency: string;
|
|
97
100
|
accountTypes: readonly AccountType[];
|
|
98
|
-
|
|
99
|
-
taxCodesByRegion: TaxCodesByRegion;
|
|
100
|
-
regions: readonly string[];
|
|
101
|
-
taxReport?: TaxReportTemplate;
|
|
101
|
+
journalTemplates?: readonly JournalTemplate[];
|
|
102
102
|
retainedEarningsAccountCode?: string;
|
|
103
103
|
retainedEarningsDisplayCode?: string;
|
|
104
104
|
currentYearEarningsCode?: string;
|
|
@@ -116,4 +116,4 @@ interface CountryPackInput {
|
|
|
116
116
|
*/
|
|
117
117
|
declare function defineCountryPack(input: CountryPackInput): CountryPack;
|
|
118
118
|
//#endregion
|
|
119
|
-
export {
|
|
119
|
+
export { defineCountryPack as i, CountryPackInput as n, JournalTemplate as r, CountryPack as t };
|