@classytic/ledger 0.7.0 → 0.9.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 +221 -115
- package/dist/bridges/index.d.mts +2 -0
- package/dist/bridges/index.mjs +1 -0
- package/dist/constants/index.d.mts +1 -1
- package/dist/constants/index.mjs +2 -2
- package/dist/country/index.d.mts +1 -1
- package/dist/errors-BI5k4iak.mjs +121 -0
- package/dist/events/index.d.mts +2 -0
- package/dist/events/index.mjs +2 -0
- package/dist/exports/index.d.mts +1 -1
- package/dist/exports/index.mjs +1 -1
- package/dist/{fx-realization.plugin-CfYy1tB6.mjs → fx-realization.plugin-Bxlb8cIx.mjs} +45 -2
- package/dist/{index-BX8miYdu.d.mts → index-08IpHhrU.d.mts} +12 -1
- package/dist/{index-Bl0_ak5w.d.mts → index-Db0n_6Z8.d.mts} +1 -1
- package/dist/index-dqkjgpII.d.mts +104 -0
- package/dist/index.d.mts +344 -65
- package/dist/index.mjs +539 -110
- package/dist/{journals-C50E9mpo.d.mts → journals-DUpWwFt1.d.mts} +1 -1
- package/dist/opening-balance-1cixYh6Y.mjs +60 -0
- package/dist/outbox-store-DQbL-KYT.mjs +132 -0
- package/dist/outbox-store-UYC4eZpI.d.mts +249 -0
- package/dist/{partner-ledger-D9H5hegI.mjs → partner-ledger-BoebloHk.mjs} +2 -2
- 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/sync/index.d.mts +313 -0
- package/dist/sync/index.mjs +527 -0
- package/dist/sync-JvchM3FO.d.mts +152 -0
- package/dist/{trial-balance-DTc8kzTD.d.mts → trial-balance-DyNm5bFu.d.mts} +2 -2
- package/docs/country-packs.md +71 -47
- package/docs/engine.md +3 -2
- package/docs/subledger-integration.md +29 -8
- package/docs/sync.md +330 -0
- package/package.json +36 -14
- package/dist/errors-CSDQPNyt.mjs +0 -33
- /package/dist/{categories-BkKdv16V.mjs → categories-FJlrvzcl.mjs} +0 -0
- /package/dist/{core-BkGjuVZj.d.mts → core-DwjkrRkJ.d.mts} +0 -0
- /package/dist/{currencies-CsuBGfgs.mjs → currencies-Jo5oaM_4.mjs} +0 -0
- /package/dist/{exports-BP-0Ni5W.mjs → exports-C30yRapf.mjs} +0 -0
- /package/dist/{index-D1ZjgVxn.d.mts → index-J-XIbXH-.d.mts} +0 -0
package/dist/index.mjs
CHANGED
|
@@ -1,13 +1,42 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { i as LEDGER_EVENTS, n as InProcessLedgerBus, r as createEvent, t as OutboxOwnershipError } from "./outbox-store-DQbL-KYT.mjs";
|
|
2
|
+
import { a as IdempotencyConflictError, i as Errors, n as ConcurrencyError, o as ImmutableViolationError, r as DuplicateReferenceError, s as classifyDuplicateKey, t as AccountingError } from "./errors-BI5k4iak.mjs";
|
|
3
|
+
import { a as JOURNAL_CODES, c as getCustomJournalTypes, d as isValidJournalType, f as registerJournalType, i as isValidCurrency, l as getJournalType, n as getCurrency, o as JOURNAL_TYPES, r as getMinorUnit, s as _freezeJournalTypes, t as CURRENCIES, u as getJournalTypeCodes } from "./currencies-Jo5oaM_4.mjs";
|
|
2
4
|
import { Money, add, allocate, format, formatPlain, fromDecimal, multiply, parseCents, percentage, splitTaxExclusive, splitTaxInclusive, subtract, toDecimal } from "./money.mjs";
|
|
3
|
-
import { n as
|
|
4
|
-
import {
|
|
5
|
-
import { c as
|
|
6
|
-
import {
|
|
5
|
+
import { C as isVirtualTaxAccount, E as requireOrgScope, S as computeEndingBalance, T as generateAgedBalance, _ as buildItemFilters, a as finalizeSession, b as buildAccountTypeMap, c as generateRevaluation, d as generateIncomeStatement, f as generateGeneralLedger, g as generateBalanceSheet, h as generateBudgetVsActual, i as acquireSession, l as buildRevaluationEntry, m as generateCashFlow, n as closeFiscalPeriod, o as defaultLogger, p as generateDimensionBreakdown, r as reopenFiscalPeriod, s as generateTrialBalance, t as generatePartnerLedger, u as computeRevaluation, v as getDateRange, w as DEFAULT_BUCKETS, x as calculateTotal, y as getFiscalYearStart } from "./partner-ledger-BoebloHk.mjs";
|
|
6
|
+
import { c as getNormalBalance, d as isValidCategory, l as isBalanceSheet, n as CATEGORY_KEYS, t as CATEGORIES, u as isIncomeStatement } from "./categories-FJlrvzcl.mjs";
|
|
7
|
+
import { a as watermarkResolver, c as idempotencyPlugin, i as fiscalLockPlugin, l as doubleEntryPlugin, n as creditLimitPlugin, o as periodResolver, r as dailyLockPlugin, s as createLockPlugin, t as fxRealizationPlugin } from "./fx-realization.plugin-Bxlb8cIx.mjs";
|
|
8
|
+
import { t as buildOpeningBalanceEntry } from "./opening-balance-1cixYh6Y.mjs";
|
|
7
9
|
import { defineCountryPack } from "./country/index.mjs";
|
|
8
|
-
import { a as exportToCsv, i as quickbooksFieldMap, r as universalFieldMap, t as flattenJournalEntries } from "./exports-
|
|
10
|
+
import { a as exportToCsv, i as quickbooksFieldMap, r as universalFieldMap, t as flattenJournalEntries } from "./exports-C30yRapf.mjs";
|
|
11
|
+
import { QueryParser, Repository, getNextSequence, multiTenantPlugin } from "@classytic/mongokit";
|
|
9
12
|
import mongoose, { Schema } from "mongoose";
|
|
10
|
-
|
|
13
|
+
//#region src/plugins/immutable-guard.plugin.ts
|
|
14
|
+
/**
|
|
15
|
+
* Returns a mongokit plugin function. Install only when
|
|
16
|
+
* `config.strictness.immutable === true`.
|
|
17
|
+
*/
|
|
18
|
+
function immutableGuardPlugin(options) {
|
|
19
|
+
const { JournalEntryModel, orgField } = options;
|
|
20
|
+
return (repo) => {
|
|
21
|
+
repo.on("before:update", async (ctx) => {
|
|
22
|
+
if (ctx._ledgerInternal) return;
|
|
23
|
+
const id = ctx.id;
|
|
24
|
+
if (!id) return;
|
|
25
|
+
const query = { _id: id };
|
|
26
|
+
if (orgField && ctx.query && orgField in ctx.query) query[orgField] = ctx.query[orgField];
|
|
27
|
+
if ((await JournalEntryModel.findOne(query).select({ state: 1 }).lean())?.state === "posted") throw new ImmutableViolationError(id);
|
|
28
|
+
});
|
|
29
|
+
repo.on("before:delete", async (ctx) => {
|
|
30
|
+
if (ctx._ledgerInternal) return;
|
|
31
|
+
const id = ctx.id;
|
|
32
|
+
if (!id) return;
|
|
33
|
+
const query = { _id: id };
|
|
34
|
+
if (orgField && ctx.query && orgField in ctx.query) query[orgField] = ctx.query[orgField];
|
|
35
|
+
if ((await JournalEntryModel.findOne(query).select({ state: 1 }).lean())?.state === "posted") throw new ImmutableViolationError(id);
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
11
40
|
//#region src/schemas/currency-field.ts
|
|
12
41
|
/**
|
|
13
42
|
* Build the Mongoose currency field definition.
|
|
@@ -579,7 +608,10 @@ function createJournalEntrySchema(config, accountModelName, options = {}) {
|
|
|
579
608
|
ref: multiTenant.orgRef,
|
|
580
609
|
required: true
|
|
581
610
|
};
|
|
582
|
-
const schema = new mongoose.Schema(fields, {
|
|
611
|
+
const schema = new mongoose.Schema(fields, {
|
|
612
|
+
timestamps: true,
|
|
613
|
+
optimisticConcurrency: true
|
|
614
|
+
});
|
|
583
615
|
schema.pre("validate", function() {
|
|
584
616
|
for (const item of this.journalItems) if (!item.date) item.date = this.date;
|
|
585
617
|
for (let i = 0; i < this.journalItems.length; i++) {
|
|
@@ -597,56 +629,25 @@ function createJournalEntrySchema(config, accountModelName, options = {}) {
|
|
|
597
629
|
this.totalDebit = totalDebit;
|
|
598
630
|
this.totalCredit = totalCredit;
|
|
599
631
|
});
|
|
600
|
-
if (autoReference) {
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
const
|
|
604
|
-
const
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
const
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
const results = await Model.aggregate(pipeline).session(session);
|
|
615
|
-
let seq = 1;
|
|
616
|
-
if (results.length > 0 && typeof results[0]._refSeq === "number") seq = results[0]._refSeq + 1;
|
|
617
|
-
return `${prefix}${String(seq).padStart(4, "0")}`;
|
|
618
|
-
};
|
|
619
|
-
schema.pre("save", async function() {
|
|
620
|
-
if (this.isModified("journalType")) this.referenceNumber = void 0;
|
|
621
|
-
if (!this.referenceNumber) {
|
|
622
|
-
const session = this.$session?.() ?? null;
|
|
623
|
-
const Model = this.constructor;
|
|
624
|
-
this.referenceNumber = await generateReferenceNumber(this, Model, session);
|
|
632
|
+
if (autoReference) schema.pre("save", async function() {
|
|
633
|
+
if (this.isModified("journalType")) this.referenceNumber = void 0;
|
|
634
|
+
if (!this.referenceNumber) {
|
|
635
|
+
const session = this.$session?.() ?? null;
|
|
636
|
+
const connection = this.constructor.db;
|
|
637
|
+
const journalType = this.journalType || "MISC";
|
|
638
|
+
const date = this.date ? new Date(this.date) : /* @__PURE__ */ new Date();
|
|
639
|
+
const year = date.getFullYear();
|
|
640
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
641
|
+
let orgScope = "global";
|
|
642
|
+
if (multiTenant) {
|
|
643
|
+
const raw = this.get(multiTenant.orgField);
|
|
644
|
+
if (raw != null) orgScope = typeof raw.toHexString === "function" ? raw.toHexString() : String(raw);
|
|
645
|
+
else orgScope = "unscoped";
|
|
625
646
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
if (mongoError.code === 11e3 && mongoError.keyPattern?.referenceNumber) {
|
|
631
|
-
const entry = doc;
|
|
632
|
-
const retryCount = entry.__refRetries ?? 0;
|
|
633
|
-
if (retryCount >= MAX_REF_RETRIES) {
|
|
634
|
-
next(/* @__PURE__ */ new Error(`Failed to generate unique reference number after ${MAX_REF_RETRIES} retries. Too many concurrent inserts for this period.`));
|
|
635
|
-
return;
|
|
636
|
-
}
|
|
637
|
-
entry.__refRetries = retryCount + 1;
|
|
638
|
-
const session = entry.$session?.() ?? null;
|
|
639
|
-
const Model = entry.constructor;
|
|
640
|
-
entry.referenceNumber = await generateReferenceNumber(entry, Model, session);
|
|
641
|
-
try {
|
|
642
|
-
await entry.save({ session });
|
|
643
|
-
next();
|
|
644
|
-
} catch (retryError) {
|
|
645
|
-
next(retryError);
|
|
646
|
-
}
|
|
647
|
-
} else next(error);
|
|
648
|
-
});
|
|
649
|
-
}
|
|
647
|
+
const seq = await getNextSequence(`ledger:${orgScope}:${journalType}:${year}-${month}`, 1, connection, session ?? void 0);
|
|
648
|
+
this.referenceNumber = `${journalType}/${year}/${month}/${String(seq).padStart(4, "0")}`;
|
|
649
|
+
}
|
|
650
|
+
});
|
|
650
651
|
if (indexes) {
|
|
651
652
|
const org = multiTenant?.orgField;
|
|
652
653
|
const refPartial = { partialFilterExpression: { referenceNumber: {
|
|
@@ -712,10 +713,13 @@ function createJournalEntrySchema(config, accountModelName, options = {}) {
|
|
|
712
713
|
idempotencyIdx.idempotencyKey = 1;
|
|
713
714
|
schema.index(idempotencyIdx, {
|
|
714
715
|
unique: true,
|
|
715
|
-
partialFilterExpression: { idempotencyKey: {
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
716
|
+
partialFilterExpression: { idempotencyKey: { $type: "string" } }
|
|
717
|
+
});
|
|
718
|
+
const ttlSeconds = typeof config.idempotencyTtlSeconds === "number" && config.idempotencyTtlSeconds > 0 ? config.idempotencyTtlSeconds : 86400;
|
|
719
|
+
schema.index({ createdAt: 1 }, {
|
|
720
|
+
name: "idempotency_ttl_idx",
|
|
721
|
+
expireAfterSeconds: ttlSeconds,
|
|
722
|
+
partialFilterExpression: { idempotencyKey: { $type: "string" } }
|
|
719
723
|
});
|
|
720
724
|
}
|
|
721
725
|
}
|
|
@@ -914,16 +918,26 @@ function createModels(connection, config) {
|
|
|
914
918
|
}
|
|
915
919
|
//#endregion
|
|
916
920
|
//#region src/repositories/account.repository.ts
|
|
921
|
+
async function safePublish$3(events, outboxStore, type, payload, ctx) {
|
|
922
|
+
const event = createEvent(type, payload, ctx);
|
|
923
|
+
if (outboxStore) try {
|
|
924
|
+
await outboxStore.save(event, { session: ctx?.session ?? void 0 });
|
|
925
|
+
} catch {}
|
|
926
|
+
if (events) try {
|
|
927
|
+
await events.publish(event);
|
|
928
|
+
} catch {}
|
|
929
|
+
}
|
|
917
930
|
/**
|
|
918
931
|
* Wire seedAccounts, bulkCreate and posting-account validation
|
|
919
932
|
* onto an existing mongokit Repository.
|
|
920
933
|
*
|
|
921
934
|
* @param repository - A mongokit Repository instance (already created)
|
|
922
|
-
* @param AccountModel - The Mongoose model for accounts
|
|
923
935
|
* @param country - The CountryPack for account type lookups
|
|
924
936
|
* @param orgField - The multi-tenant field name (e.g. 'business')
|
|
925
937
|
*/
|
|
926
|
-
function wireAccountMethods(repository,
|
|
938
|
+
function wireAccountMethods(repository, country, orgField, integrations = {}) {
|
|
939
|
+
const events = integrations.events;
|
|
940
|
+
const outboxStore = integrations.outboxStore;
|
|
927
941
|
repository.on("before:create", (ctx) => {
|
|
928
942
|
const code = ctx.data?.accountTypeCode;
|
|
929
943
|
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.`);
|
|
@@ -936,7 +950,10 @@ function wireAccountMethods(repository, AccountModel, country, orgField) {
|
|
|
936
950
|
const postingTypes = country.getPostingAccountTypes();
|
|
937
951
|
const filter = {};
|
|
938
952
|
if (orgField && orgId != null) filter[orgField] = orgId;
|
|
939
|
-
const existing = await
|
|
953
|
+
const existing = await repository.findAll(filter, {
|
|
954
|
+
select: { accountNumber: 1 },
|
|
955
|
+
lean: true
|
|
956
|
+
});
|
|
940
957
|
const existingNumbers = new Set(existing.map((a) => a.accountNumber));
|
|
941
958
|
const toCreate = postingTypes.filter((at) => !existingNumbers.has(at.code)).map((at) => {
|
|
942
959
|
const doc = {
|
|
@@ -951,9 +968,10 @@ function wireAccountMethods(repository, AccountModel, country, orgField) {
|
|
|
951
968
|
created: 0,
|
|
952
969
|
skipped: existingNumbers.size
|
|
953
970
|
};
|
|
971
|
+
let result;
|
|
954
972
|
try {
|
|
955
|
-
|
|
956
|
-
created: (await
|
|
973
|
+
result = {
|
|
974
|
+
created: (await repository.createMany(toCreate, {
|
|
957
975
|
session: options.session ?? void 0,
|
|
958
976
|
ordered: false
|
|
959
977
|
})).length,
|
|
@@ -963,13 +981,21 @@ function wireAccountMethods(repository, AccountModel, country, orgField) {
|
|
|
963
981
|
const bulkError = err;
|
|
964
982
|
if (bulkError.code === 11e3 || bulkError.writeErrors) {
|
|
965
983
|
const insertedDocs = bulkError.insertedDocs ?? [];
|
|
966
|
-
|
|
984
|
+
result = {
|
|
967
985
|
created: insertedDocs.length,
|
|
968
986
|
skipped: existingNumbers.size + (toCreate.length - insertedDocs.length)
|
|
969
987
|
};
|
|
970
|
-
}
|
|
971
|
-
throw err;
|
|
988
|
+
} else throw err;
|
|
972
989
|
}
|
|
990
|
+
await safePublish$3(events, outboxStore, LEDGER_EVENTS.ACCOUNT_SEEDED, {
|
|
991
|
+
created: result.created,
|
|
992
|
+
skipped: result.skipped,
|
|
993
|
+
organizationId: orgId
|
|
994
|
+
}, {
|
|
995
|
+
organizationId: orgId,
|
|
996
|
+
session: options.session ?? null
|
|
997
|
+
});
|
|
998
|
+
return result;
|
|
973
999
|
};
|
|
974
1000
|
/**
|
|
975
1001
|
* Bulk create accounts with validation and skip-if-exists logic.
|
|
@@ -1034,7 +1060,10 @@ function wireAccountMethods(repository, AccountModel, country, orgField) {
|
|
|
1034
1060
|
};
|
|
1035
1061
|
const existsFilter = { accountNumber: { $in: validAccounts.map((a) => a.accountNumber) } };
|
|
1036
1062
|
if (orgField && orgId != null) existsFilter[orgField] = orgId;
|
|
1037
|
-
const existingDocs = await
|
|
1063
|
+
const existingDocs = await repository.findAll(existsFilter, {
|
|
1064
|
+
select: { accountNumber: 1 },
|
|
1065
|
+
lean: true
|
|
1066
|
+
});
|
|
1038
1067
|
const existingNumbers = new Set(existingDocs.map((d) => d.accountNumber));
|
|
1039
1068
|
const toCreate = [];
|
|
1040
1069
|
for (const item of validAccounts) if (existingNumbers.has(item.accountNumber)) results.skipped.push({
|
|
@@ -1056,7 +1085,7 @@ function wireAccountMethods(repository, AccountModel, country, orgField) {
|
|
|
1056
1085
|
return doc;
|
|
1057
1086
|
});
|
|
1058
1087
|
try {
|
|
1059
|
-
const inserted = await
|
|
1088
|
+
const inserted = await repository.createMany(docs, { ordered: false });
|
|
1060
1089
|
results.created = toCreate.map((item, idx) => ({
|
|
1061
1090
|
accountTypeCode: item.accountTypeCode,
|
|
1062
1091
|
active: item.active,
|
|
@@ -1084,13 +1113,20 @@ function wireAccountMethods(repository, AccountModel, country, orgField) {
|
|
|
1084
1113
|
} else throw err;
|
|
1085
1114
|
}
|
|
1086
1115
|
}
|
|
1116
|
+
const summary = {
|
|
1117
|
+
total: accounts.length,
|
|
1118
|
+
created: results.created.length,
|
|
1119
|
+
skipped: results.skipped.length,
|
|
1120
|
+
errors: results.errors.length
|
|
1121
|
+
};
|
|
1122
|
+
await safePublish$3(events, outboxStore, LEDGER_EVENTS.ACCOUNT_BULK_CREATED, {
|
|
1123
|
+
created: summary.created,
|
|
1124
|
+
skipped: summary.skipped,
|
|
1125
|
+
errors: summary.errors,
|
|
1126
|
+
organizationId: orgId
|
|
1127
|
+
}, { organizationId: orgId });
|
|
1087
1128
|
return {
|
|
1088
|
-
summary
|
|
1089
|
-
total: accounts.length,
|
|
1090
|
-
created: results.created.length,
|
|
1091
|
-
skipped: results.skipped.length,
|
|
1092
|
-
errors: results.errors.length
|
|
1093
|
-
},
|
|
1129
|
+
summary,
|
|
1094
1130
|
...results
|
|
1095
1131
|
};
|
|
1096
1132
|
};
|
|
@@ -1107,6 +1143,15 @@ function wireAccountMethods(repository, AccountModel, country, orgField) {
|
|
|
1107
1143
|
}
|
|
1108
1144
|
//#endregion
|
|
1109
1145
|
//#region src/repositories/journal.repository.ts
|
|
1146
|
+
async function safePublish$2(events, outboxStore, type, payload, ctx) {
|
|
1147
|
+
const event = createEvent(type, payload, ctx);
|
|
1148
|
+
if (outboxStore) try {
|
|
1149
|
+
await outboxStore.save(event);
|
|
1150
|
+
} catch {}
|
|
1151
|
+
if (events) try {
|
|
1152
|
+
await events.publish(event);
|
|
1153
|
+
} catch {}
|
|
1154
|
+
}
|
|
1110
1155
|
/**
|
|
1111
1156
|
* Lean default set used when a country pack doesn't provide
|
|
1112
1157
|
* `journalTemplates`. Covers the Stripe/QuickBooks/Xero baseline.
|
|
@@ -1148,9 +1193,11 @@ const DEFAULT_TEMPLATES = [
|
|
|
1148
1193
|
sequencePrefix: "JE"
|
|
1149
1194
|
}
|
|
1150
1195
|
];
|
|
1151
|
-
function wireJournalMethods(repository, country, orgField) {
|
|
1196
|
+
function wireJournalMethods(repository, country, orgField, integrations = {}) {
|
|
1152
1197
|
const create = repository.create.bind(repository);
|
|
1153
1198
|
const exists = repository.exists.bind(repository);
|
|
1199
|
+
const events = integrations.events;
|
|
1200
|
+
const outboxStore = integrations.outboxStore;
|
|
1154
1201
|
repository.seedDefaults = async (orgId) => {
|
|
1155
1202
|
requireOrgScope(orgField, orgId);
|
|
1156
1203
|
const templates = country.journalTemplates ?? DEFAULT_TEMPLATES;
|
|
@@ -1176,6 +1223,11 @@ function wireJournalMethods(repository, country, orgField) {
|
|
|
1176
1223
|
await create(data);
|
|
1177
1224
|
created += 1;
|
|
1178
1225
|
}
|
|
1226
|
+
await safePublish$2(events, outboxStore, LEDGER_EVENTS.JOURNAL_SEEDED, {
|
|
1227
|
+
created,
|
|
1228
|
+
skipped,
|
|
1229
|
+
organizationId: orgId
|
|
1230
|
+
}, { organizationId: orgId });
|
|
1179
1231
|
return {
|
|
1180
1232
|
created,
|
|
1181
1233
|
skipped
|
|
@@ -1205,6 +1257,25 @@ function wireJournalMethods(repository, country, orgField) {
|
|
|
1205
1257
|
}
|
|
1206
1258
|
//#endregion
|
|
1207
1259
|
//#region src/repositories/journal-entry.repository.ts
|
|
1260
|
+
/**
|
|
1261
|
+
* Publish a domain event. When an outbox store is provided, first persist
|
|
1262
|
+
* the event inside the caller's session (so outbox + ledger write commit
|
|
1263
|
+
* atomically), then fire-and-forget publish to the transport. Without an
|
|
1264
|
+
* outbox, publish-only, still fire-and-forget — transport errors never
|
|
1265
|
+
* propagate into ledger mutations.
|
|
1266
|
+
*
|
|
1267
|
+
* Tracks PACKAGE_RULES §16 (host-composed transactional outbox) and §14
|
|
1268
|
+
* (domain verbs publish via injected transport).
|
|
1269
|
+
*/
|
|
1270
|
+
async function safePublish$1(events, outboxStore, type, payload, ctx, meta) {
|
|
1271
|
+
const event = createEvent(type, payload, ctx, meta);
|
|
1272
|
+
if (outboxStore) try {
|
|
1273
|
+
await outboxStore.save(event, { session: ctx?.session ?? void 0 });
|
|
1274
|
+
} catch {}
|
|
1275
|
+
if (events) try {
|
|
1276
|
+
await events.publish(event);
|
|
1277
|
+
} catch {}
|
|
1278
|
+
}
|
|
1208
1279
|
/** Keys that are either handled explicitly or must not be copied */
|
|
1209
1280
|
const ITEM_CORE_KEYS = new Set([
|
|
1210
1281
|
"account",
|
|
@@ -1227,11 +1298,58 @@ const ITEM_CORE_KEYS = new Set([
|
|
|
1227
1298
|
* @param orgField - The multi-tenant field name (e.g. 'business')
|
|
1228
1299
|
* @param strictness - Strictness rules (immutable, requireActor, requireApproval)
|
|
1229
1300
|
*/
|
|
1230
|
-
function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, strictness) {
|
|
1301
|
+
function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, strictness, integrations = {}) {
|
|
1302
|
+
const events = integrations.events;
|
|
1303
|
+
const outboxStore = integrations.outboxStore;
|
|
1231
1304
|
const getByQuery = repository.getByQuery.bind(repository);
|
|
1232
|
-
const
|
|
1305
|
+
const baseCreate = repository.create.bind(repository);
|
|
1233
1306
|
const update = repository.update.bind(repository);
|
|
1234
1307
|
const withTransaction = repository.withTransaction.bind(repository);
|
|
1308
|
+
const raceSafeCreate = async (data, options) => {
|
|
1309
|
+
const input = data;
|
|
1310
|
+
const idempotencyKey = typeof input.idempotencyKey === "string" && input.idempotencyKey.length > 0 ? input.idempotencyKey : void 0;
|
|
1311
|
+
const orgValue = orgField ? input[orgField] : void 0;
|
|
1312
|
+
if (idempotencyKey) {
|
|
1313
|
+
const prequery = { idempotencyKey };
|
|
1314
|
+
if (orgField && orgValue != null) prequery[orgField] = orgValue;
|
|
1315
|
+
const existing = await getByQuery(prequery, {
|
|
1316
|
+
lean: false,
|
|
1317
|
+
throwOnNotFound: false,
|
|
1318
|
+
...options?.session ? { session: options.session } : {}
|
|
1319
|
+
});
|
|
1320
|
+
if (existing) return existing;
|
|
1321
|
+
}
|
|
1322
|
+
try {
|
|
1323
|
+
return await baseCreate(data, options);
|
|
1324
|
+
} catch (err) {
|
|
1325
|
+
if (err instanceof IdempotencyConflictError && err.existingId) {
|
|
1326
|
+
const winner = await getByQuery({ _id: err.existingId }, {
|
|
1327
|
+
lean: false,
|
|
1328
|
+
throwOnNotFound: false,
|
|
1329
|
+
...options?.session ? { session: options.session } : {}
|
|
1330
|
+
});
|
|
1331
|
+
if (winner) return winner;
|
|
1332
|
+
throw err;
|
|
1333
|
+
}
|
|
1334
|
+
const dup = classifyDuplicateKey(err);
|
|
1335
|
+
if (!dup) throw err;
|
|
1336
|
+
if (dup.keyPattern?.referenceNumber) throw new DuplicateReferenceError(String(input.referenceNumber ?? ""));
|
|
1337
|
+
if (dup.keyPattern?.idempotencyKey && idempotencyKey) {
|
|
1338
|
+
const winnerQuery = { idempotencyKey };
|
|
1339
|
+
if (orgField && orgValue != null) winnerQuery[orgField] = orgValue;
|
|
1340
|
+
const winner = await getByQuery(winnerQuery, {
|
|
1341
|
+
lean: false,
|
|
1342
|
+
throwOnNotFound: false,
|
|
1343
|
+
...options?.session ? { session: options.session } : {}
|
|
1344
|
+
});
|
|
1345
|
+
if (winner) return winner;
|
|
1346
|
+
throw new IdempotencyConflictError(idempotencyKey, null);
|
|
1347
|
+
}
|
|
1348
|
+
throw Errors.conflict(`Journal entry write violated unique index ${dup.indexName}.`);
|
|
1349
|
+
}
|
|
1350
|
+
};
|
|
1351
|
+
repository.create = raceSafeCreate.bind(repository);
|
|
1352
|
+
const create = raceSafeCreate;
|
|
1235
1353
|
const RESERVED_TOPLEVEL = new Set([
|
|
1236
1354
|
"_id",
|
|
1237
1355
|
"__v",
|
|
@@ -1341,7 +1459,23 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
|
|
|
1341
1459
|
_ledgerInternal: "post",
|
|
1342
1460
|
...options.session ? { session: options.session } : {}
|
|
1343
1461
|
};
|
|
1344
|
-
|
|
1462
|
+
const final = await update(entry._id, patch, updateOptions) ?? entry;
|
|
1463
|
+
await safePublish$1(events, outboxStore, LEDGER_EVENTS.ENTRY_POSTED, {
|
|
1464
|
+
entryId: final._id,
|
|
1465
|
+
referenceNumber: final.referenceNumber,
|
|
1466
|
+
postedBy: options.actorId,
|
|
1467
|
+
totalDebit,
|
|
1468
|
+
totalCredit,
|
|
1469
|
+
organizationId: orgId
|
|
1470
|
+
}, {
|
|
1471
|
+
actorId: options.actorId,
|
|
1472
|
+
organizationId: orgId,
|
|
1473
|
+
session: options.session ?? null
|
|
1474
|
+
}, {
|
|
1475
|
+
resource: "journal-entry",
|
|
1476
|
+
resourceId: String(final._id)
|
|
1477
|
+
});
|
|
1478
|
+
return final;
|
|
1345
1479
|
};
|
|
1346
1480
|
/**
|
|
1347
1481
|
* Unpost an entry (posted → draft).
|
|
@@ -1360,10 +1494,23 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
|
|
|
1360
1494
|
_ledgerInternal: "unpost",
|
|
1361
1495
|
...options.session ? { session: options.session } : {}
|
|
1362
1496
|
};
|
|
1363
|
-
|
|
1497
|
+
const final = await update(entry._id, {
|
|
1364
1498
|
state: "draft",
|
|
1365
1499
|
stateChangedAt: /* @__PURE__ */ new Date()
|
|
1366
1500
|
}, updateOptions) ?? entry;
|
|
1501
|
+
await safePublish$1(events, outboxStore, LEDGER_EVENTS.ENTRY_UNPOSTED, {
|
|
1502
|
+
entryId: final._id,
|
|
1503
|
+
unpostedBy: options.actorId,
|
|
1504
|
+
organizationId: orgId
|
|
1505
|
+
}, {
|
|
1506
|
+
actorId: options.actorId,
|
|
1507
|
+
organizationId: orgId,
|
|
1508
|
+
session: options.session ?? null
|
|
1509
|
+
}, {
|
|
1510
|
+
resource: "journal-entry",
|
|
1511
|
+
resourceId: String(final._id)
|
|
1512
|
+
});
|
|
1513
|
+
return final;
|
|
1367
1514
|
};
|
|
1368
1515
|
/**
|
|
1369
1516
|
* Archive a draft entry (draft → archived).
|
|
@@ -1380,10 +1527,23 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
|
|
|
1380
1527
|
_ledgerInternal: "archive",
|
|
1381
1528
|
...options.session ? { session: options.session } : {}
|
|
1382
1529
|
};
|
|
1383
|
-
|
|
1530
|
+
const final = await update(entry._id, {
|
|
1384
1531
|
state: "archived",
|
|
1385
1532
|
stateChangedAt: /* @__PURE__ */ new Date()
|
|
1386
1533
|
}, updateOptions) ?? entry;
|
|
1534
|
+
await safePublish$1(events, outboxStore, LEDGER_EVENTS.ENTRY_ARCHIVED, {
|
|
1535
|
+
entryId: final._id,
|
|
1536
|
+
archivedBy: options.actorId,
|
|
1537
|
+
organizationId: orgId
|
|
1538
|
+
}, {
|
|
1539
|
+
actorId: options.actorId,
|
|
1540
|
+
organizationId: orgId,
|
|
1541
|
+
session: options.session ?? null
|
|
1542
|
+
}, {
|
|
1543
|
+
resource: "journal-entry",
|
|
1544
|
+
resourceId: String(final._id)
|
|
1545
|
+
});
|
|
1546
|
+
return final;
|
|
1387
1547
|
};
|
|
1388
1548
|
/**
|
|
1389
1549
|
* Duplicate an entry as a new draft.
|
|
@@ -1414,7 +1574,21 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
|
|
|
1414
1574
|
})
|
|
1415
1575
|
};
|
|
1416
1576
|
copyExtraTopLevel(entry, duplicateData);
|
|
1417
|
-
|
|
1577
|
+
const duplicated = await create(duplicateData, options.session ? { session: options.session } : {});
|
|
1578
|
+
const dup = duplicated;
|
|
1579
|
+
await safePublish$1(events, outboxStore, LEDGER_EVENTS.ENTRY_DUPLICATED, {
|
|
1580
|
+
sourceEntryId: entry._id,
|
|
1581
|
+
duplicateEntryId: dup._id,
|
|
1582
|
+
organizationId: orgId
|
|
1583
|
+
}, {
|
|
1584
|
+
actorId: void 0,
|
|
1585
|
+
organizationId: orgId,
|
|
1586
|
+
session: options.session ?? null
|
|
1587
|
+
}, {
|
|
1588
|
+
resource: "journal-entry",
|
|
1589
|
+
resourceId: String(dup._id)
|
|
1590
|
+
});
|
|
1591
|
+
return duplicated;
|
|
1418
1592
|
};
|
|
1419
1593
|
/**
|
|
1420
1594
|
* Reverse a posted entry by creating a mirror entry with flipped debits/credits.
|
|
@@ -1477,8 +1651,23 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
|
|
|
1477
1651
|
_ledgerInternal: "reverseMark",
|
|
1478
1652
|
...session ? { session } : {}
|
|
1479
1653
|
};
|
|
1654
|
+
const original = await update(entry._id, markPatch, markOptions) ?? entry;
|
|
1655
|
+
await safePublish$1(events, outboxStore, LEDGER_EVENTS.ENTRY_REVERSED, {
|
|
1656
|
+
originalEntryId: original._id,
|
|
1657
|
+
reversalEntryId: reversalEntry._id,
|
|
1658
|
+
reversalDate: reversalData.date ?? /* @__PURE__ */ new Date(),
|
|
1659
|
+
reversedBy: options.actorId,
|
|
1660
|
+
organizationId: orgId
|
|
1661
|
+
}, {
|
|
1662
|
+
actorId: options.actorId,
|
|
1663
|
+
organizationId: orgId,
|
|
1664
|
+
session: session ?? null
|
|
1665
|
+
}, {
|
|
1666
|
+
resource: "journal-entry",
|
|
1667
|
+
resourceId: String(original._id)
|
|
1668
|
+
});
|
|
1480
1669
|
return {
|
|
1481
|
-
original
|
|
1670
|
+
original,
|
|
1482
1671
|
reversal: reversalEntry
|
|
1483
1672
|
};
|
|
1484
1673
|
};
|
|
@@ -1506,6 +1695,15 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
|
|
|
1506
1695
|
}
|
|
1507
1696
|
//#endregion
|
|
1508
1697
|
//#region src/repositories/reconciliation.repository.ts
|
|
1698
|
+
async function safePublish(events, outboxStore, type, payload, ctx) {
|
|
1699
|
+
const event = createEvent(type, payload, ctx);
|
|
1700
|
+
if (outboxStore) try {
|
|
1701
|
+
await outboxStore.save(event, { session: ctx?.session ?? void 0 });
|
|
1702
|
+
} catch {}
|
|
1703
|
+
if (events) try {
|
|
1704
|
+
await events.publish(event);
|
|
1705
|
+
} catch {}
|
|
1706
|
+
}
|
|
1509
1707
|
/**
|
|
1510
1708
|
* Default matching-number generator — atomic counter stored in the
|
|
1511
1709
|
* reconciliation collection. Uses a dedicated sentinel document keyed
|
|
@@ -1537,10 +1735,14 @@ async function nextMatchingNumber(ReconciliationModel, orgField, orgId, session)
|
|
|
1537
1735
|
}).lean())?.seq ?? 1;
|
|
1538
1736
|
return `RECN-${String(seq).padStart(6, "0")}`;
|
|
1539
1737
|
}
|
|
1540
|
-
function wireReconciliationMethods(repository, ReconciliationModel, JournalEntryModel, orgField) {
|
|
1738
|
+
function wireReconciliationMethods(repository, ReconciliationModel, JournalEntryModel, orgField, integrations = {}) {
|
|
1541
1739
|
const create = repository.create.bind(repository);
|
|
1542
1740
|
const deleteById = repository.delete.bind(repository);
|
|
1543
1741
|
const repoInstance = repository;
|
|
1742
|
+
const events = integrations.events;
|
|
1743
|
+
const outboxStore = integrations.outboxStore;
|
|
1744
|
+
const notification = integrations.bridges?.notification;
|
|
1745
|
+
const emitHook = repoInstance.emitAsync.bind(repoInstance);
|
|
1544
1746
|
repository.match = async (input) => {
|
|
1545
1747
|
const { account, items, note, reconciledBy, organizationId, session = null } = input;
|
|
1546
1748
|
let { matchingNumber } = input;
|
|
@@ -1585,6 +1787,17 @@ function wireReconciliationMethods(repository, ReconciliationModel, JournalEntry
|
|
|
1585
1787
|
const isFullReconcile = difference === 0;
|
|
1586
1788
|
const sharedCurrency = currencies.size === 1 ? Array.from(currencies)[0] : null;
|
|
1587
1789
|
if (!matchingNumber) matchingNumber = await nextMatchingNumber(ReconciliationModel, orgField, organizationId, session);
|
|
1790
|
+
const hookCtx = {
|
|
1791
|
+
input,
|
|
1792
|
+
items: itemSnapshots,
|
|
1793
|
+
sharedCurrency,
|
|
1794
|
+
matchingNumber,
|
|
1795
|
+
debitTotal,
|
|
1796
|
+
creditTotal,
|
|
1797
|
+
isFullReconcile,
|
|
1798
|
+
session
|
|
1799
|
+
};
|
|
1800
|
+
await emitHook("before:match", hookCtx);
|
|
1588
1801
|
const bulkOps = itemSnapshots.map((snap) => ({ updateOne: {
|
|
1589
1802
|
filter: { _id: snap.entry },
|
|
1590
1803
|
update: { $set: { [`journalItems.${snap.itemIndex}.matchingNumber`]: matchingNumber } }
|
|
@@ -1612,13 +1825,33 @@ function wireReconciliationMethods(repository, ReconciliationModel, JournalEntry
|
|
|
1612
1825
|
};
|
|
1613
1826
|
if (orgField && organizationId != null) reconciliationData[orgField] = organizationId;
|
|
1614
1827
|
const record = await create(reconciliationData);
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
reconciliation: record
|
|
1618
|
-
|
|
1619
|
-
|
|
1828
|
+
await emitHook("after:match", {
|
|
1829
|
+
...hookCtx,
|
|
1830
|
+
reconciliation: record
|
|
1831
|
+
});
|
|
1832
|
+
await safePublish(events, outboxStore, LEDGER_EVENTS.RECONCILIATION_MATCHED, {
|
|
1833
|
+
matchingNumber,
|
|
1834
|
+
account,
|
|
1835
|
+
itemCount: itemSnapshots.length,
|
|
1836
|
+
debitTotal,
|
|
1837
|
+
creditTotal,
|
|
1838
|
+
isFullReconcile,
|
|
1839
|
+
currency: sharedCurrency,
|
|
1840
|
+
organizationId
|
|
1841
|
+
}, {
|
|
1842
|
+
organizationId,
|
|
1620
1843
|
session
|
|
1621
1844
|
});
|
|
1845
|
+
if (!isFullReconcile && notification?.onReconciliationMismatch) try {
|
|
1846
|
+
await notification.onReconciliationMismatch({
|
|
1847
|
+
matchingNumber,
|
|
1848
|
+
account,
|
|
1849
|
+
debitTotal,
|
|
1850
|
+
creditTotal,
|
|
1851
|
+
difference,
|
|
1852
|
+
currency: sharedCurrency
|
|
1853
|
+
}, { organizationId });
|
|
1854
|
+
} catch {}
|
|
1622
1855
|
return record;
|
|
1623
1856
|
};
|
|
1624
1857
|
repository.unmatch = async (input) => {
|
|
@@ -1628,13 +1861,31 @@ function wireReconciliationMethods(repository, ReconciliationModel, JournalEntry
|
|
|
1628
1861
|
if (orgField && organizationId != null) query[orgField] = organizationId;
|
|
1629
1862
|
const existing = await ReconciliationModel.findOne(query).session(session).lean();
|
|
1630
1863
|
if (!existing) throw Errors.notFound(`Reconciliation ${matchingNumber} not found`);
|
|
1631
|
-
const
|
|
1864
|
+
const items = existing.items ?? [];
|
|
1865
|
+
const unmatchCtx = {
|
|
1866
|
+
matchingNumber,
|
|
1867
|
+
reconciliation: existing,
|
|
1868
|
+
items,
|
|
1869
|
+
organizationId,
|
|
1870
|
+
session
|
|
1871
|
+
};
|
|
1872
|
+
await emitHook("before:unmatch", unmatchCtx);
|
|
1873
|
+
const bulkOps = items.map((it) => ({ updateOne: {
|
|
1632
1874
|
filter: { _id: it.entry },
|
|
1633
1875
|
update: { $set: { [`journalItems.${it.itemIndex}.matchingNumber`]: null } }
|
|
1634
1876
|
} }));
|
|
1635
1877
|
if (bulkOps.length > 0) await JournalEntryModel.bulkWrite(bulkOps, { session: session ?? void 0 });
|
|
1636
1878
|
const result = await deleteById(String(existing._id));
|
|
1637
1879
|
if (!result.success) throw Errors.notFound("Failed to delete reconciliation record");
|
|
1880
|
+
await emitHook("after:unmatch", unmatchCtx);
|
|
1881
|
+
await safePublish(events, outboxStore, LEDGER_EVENTS.RECONCILIATION_UNMATCHED, {
|
|
1882
|
+
matchingNumber,
|
|
1883
|
+
itemCount: items.length,
|
|
1884
|
+
organizationId
|
|
1885
|
+
}, {
|
|
1886
|
+
organizationId,
|
|
1887
|
+
session
|
|
1888
|
+
});
|
|
1638
1889
|
return result;
|
|
1639
1890
|
};
|
|
1640
1891
|
repository.getOpenItems = async (params) => {
|
|
@@ -1706,29 +1957,39 @@ function wireReconciliationMethods(repository, ReconciliationModel, JournalEntry
|
|
|
1706
1957
|
* Matches the flow/promo pattern: engine owns the repositories with all
|
|
1707
1958
|
* plugins (double-entry, fiscal-lock, idempotency) pre-wired.
|
|
1708
1959
|
*
|
|
1709
|
-
*
|
|
1710
|
-
*
|
|
1960
|
+
* 0.9.0 additions:
|
|
1961
|
+
* - Optional `multiTenantPlugin` adoption (config.multiTenant.plugin)
|
|
1962
|
+
* - Optional `EventTransport` threaded to every wireXxxMethods call
|
|
1963
|
+
* - Optional `LedgerBridges` threaded through so domain verbs can resolve
|
|
1964
|
+
* external sources or send notifications without importing siblings.
|
|
1711
1965
|
*/
|
|
1712
1966
|
/**
|
|
1713
1967
|
* Build all ledger repositories with plugins + domain methods pre-wired.
|
|
1714
|
-
*
|
|
1715
|
-
* - `accounts` — has seedAccounts(), bulkCreate()
|
|
1716
|
-
* - `journalEntries` — has post(), unpost(), reverse(), duplicate() + double-entry + fiscal-lock (+ idempotency if enabled)
|
|
1717
|
-
* - `fiscalPeriods` — plain CRUD
|
|
1718
|
-
* - `budgets` — plain CRUD
|
|
1719
|
-
* - `reconciliations` — has reconcile(), unreconcile(), getUnreconciled()
|
|
1720
1968
|
*/
|
|
1721
|
-
function createRepositories(models, config, plugins = {}, pagination = {}) {
|
|
1969
|
+
function createRepositories(models, config, plugins = {}, pagination = {}, integrations = {}) {
|
|
1722
1970
|
const orgField = config.multiTenant?.orgField;
|
|
1723
1971
|
const strictness = config.strictness;
|
|
1724
1972
|
const country = config.country;
|
|
1973
|
+
const { events, bridges, outboxStore } = integrations;
|
|
1974
|
+
const tenantPlugins = [];
|
|
1975
|
+
if (orgField && config.multiTenant?.plugin) tenantPlugins.push(multiTenantPlugin({
|
|
1976
|
+
tenantField: orgField,
|
|
1977
|
+
contextKey: "organizationId",
|
|
1978
|
+
required: config.multiTenant.required ?? false,
|
|
1979
|
+
fieldType: config.tenantFieldType ?? "string"
|
|
1980
|
+
}));
|
|
1725
1981
|
const accountPagination = pagination.account ?? {};
|
|
1726
1982
|
const jePagination = pagination.journalEntry ?? {};
|
|
1727
1983
|
const fpPagination = pagination.fiscalPeriod ?? {};
|
|
1728
1984
|
const budgetPagination = pagination.budget ?? {};
|
|
1729
1985
|
const reconPagination = pagination.reconciliation ?? {};
|
|
1730
|
-
const accounts = wireAccountMethods(new Repository(models.Account, plugins.account ?? [], accountPagination),
|
|
1986
|
+
const accounts = wireAccountMethods(new Repository(models.Account, [...tenantPlugins, ...plugins.account ?? []], accountPagination), country, orgField, {
|
|
1987
|
+
events,
|
|
1988
|
+
bridges,
|
|
1989
|
+
outboxStore
|
|
1990
|
+
});
|
|
1731
1991
|
const jePlugins = [
|
|
1992
|
+
...tenantPlugins,
|
|
1732
1993
|
...plugins.journalEntry ?? [],
|
|
1733
1994
|
doubleEntryPlugin({
|
|
1734
1995
|
JournalEntryModel: models.JournalEntry,
|
|
@@ -1745,10 +2006,22 @@ function createRepositories(models, config, plugins = {}, pagination = {}) {
|
|
|
1745
2006
|
JournalEntryModel: models.JournalEntry,
|
|
1746
2007
|
orgField
|
|
1747
2008
|
}));
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
2009
|
+
if (strictness?.immutable) jePlugins.push(immutableGuardPlugin({
|
|
2010
|
+
JournalEntryModel: models.JournalEntry,
|
|
2011
|
+
orgField
|
|
2012
|
+
}));
|
|
2013
|
+
const journalEntries = wireJournalEntryMethods(new Repository(models.JournalEntry, jePlugins, jePagination), models.JournalEntry, orgField, strictness, {
|
|
2014
|
+
events,
|
|
2015
|
+
bridges,
|
|
2016
|
+
outboxStore
|
|
2017
|
+
});
|
|
2018
|
+
const fiscalPeriods = new Repository(models.FiscalPeriod, [...tenantPlugins, ...plugins.fiscalPeriod ?? []], fpPagination);
|
|
2019
|
+
const budgets = new Repository(models.Budget, [...tenantPlugins, ...plugins.budget ?? []], budgetPagination);
|
|
2020
|
+
const reconciliations = wireReconciliationMethods(new Repository(models.Reconciliation, [...tenantPlugins, ...plugins.reconciliation ?? []], reconPagination), models.Reconciliation, models.JournalEntry, orgField, {
|
|
2021
|
+
events,
|
|
2022
|
+
bridges,
|
|
2023
|
+
outboxStore
|
|
2024
|
+
});
|
|
1752
2025
|
const journalPagination = pagination.journal ?? {};
|
|
1753
2026
|
return {
|
|
1754
2027
|
accounts,
|
|
@@ -1756,7 +2029,11 @@ function createRepositories(models, config, plugins = {}, pagination = {}) {
|
|
|
1756
2029
|
fiscalPeriods,
|
|
1757
2030
|
budgets,
|
|
1758
2031
|
reconciliations,
|
|
1759
|
-
journals: wireJournalMethods(new Repository(models.Journal, plugins.journal ?? [], journalPagination), country, orgField
|
|
2032
|
+
journals: wireJournalMethods(new Repository(models.Journal, [...tenantPlugins, ...plugins.journal ?? []], journalPagination), country, orgField, {
|
|
2033
|
+
events,
|
|
2034
|
+
bridges,
|
|
2035
|
+
outboxStore
|
|
2036
|
+
})
|
|
1760
2037
|
};
|
|
1761
2038
|
}
|
|
1762
2039
|
//#endregion
|
|
@@ -2255,16 +2532,80 @@ function buildRecordAPI({ models, repositories, config }) {
|
|
|
2255
2532
|
journalItems: items
|
|
2256
2533
|
}, options);
|
|
2257
2534
|
};
|
|
2535
|
+
const openingBalance = async (organizationId, input, options) => {
|
|
2536
|
+
if (!input.balances || input.balances.length === 0) throw Errors.validation("Opening balance requires at least one account balance.", [{
|
|
2537
|
+
path: "balances",
|
|
2538
|
+
issue: "must contain at least 1 entry",
|
|
2539
|
+
value: 0
|
|
2540
|
+
}]);
|
|
2541
|
+
const equityCode = input.equityAccount ?? config.country?.retainedEarningsAccountCode;
|
|
2542
|
+
if (!equityCode) throw Errors.validation("Equity contra account code is required. Pass equityAccount or configure retainedEarningsAccountCode in the country pack.", [{
|
|
2543
|
+
path: "equityAccount",
|
|
2544
|
+
issue: "required",
|
|
2545
|
+
value: void 0
|
|
2546
|
+
}]);
|
|
2547
|
+
const result = buildOpeningBalanceEntry({
|
|
2548
|
+
cutoverDate: input.cutoverDate,
|
|
2549
|
+
balances: input.balances.map((b) => ({
|
|
2550
|
+
accountCode: b.account,
|
|
2551
|
+
balance: b.balance
|
|
2552
|
+
})),
|
|
2553
|
+
equityAccountCode: equityCode,
|
|
2554
|
+
label: input.label
|
|
2555
|
+
});
|
|
2556
|
+
const acctMap = await resolveAccounts(organizationId, result.entry.journalItems.map((item) => item.account), "balances", options?.session ?? null);
|
|
2557
|
+
const items = result.entry.journalItems.map((item) => buildItem(acctMap.get(item.account), item.debit, item.credit, item.label));
|
|
2558
|
+
return postEntry(organizationId, {
|
|
2559
|
+
journalType: result.entry.journalType ?? "GENERAL",
|
|
2560
|
+
date: result.entry.date,
|
|
2561
|
+
label: result.entry.label,
|
|
2562
|
+
journalItems: items,
|
|
2563
|
+
...result.entry.extra
|
|
2564
|
+
}, options);
|
|
2565
|
+
};
|
|
2258
2566
|
return {
|
|
2259
2567
|
sale,
|
|
2260
2568
|
expense,
|
|
2261
2569
|
transfer,
|
|
2262
2570
|
payment,
|
|
2263
|
-
adjustment
|
|
2571
|
+
adjustment,
|
|
2572
|
+
openingBalance
|
|
2264
2573
|
};
|
|
2265
2574
|
}
|
|
2266
2575
|
//#endregion
|
|
2267
2576
|
//#region src/engine.ts
|
|
2577
|
+
/**
|
|
2578
|
+
* AccountingEngine — The main entry point for @classytic/ledger.
|
|
2579
|
+
*
|
|
2580
|
+
* The engine owns all models, repositories, and reports. Matches the
|
|
2581
|
+
* @classytic/flow and @classytic/promo pattern: pass a mongoose connection
|
|
2582
|
+
* in config, and everything is auto-wired.
|
|
2583
|
+
*
|
|
2584
|
+
* @example
|
|
2585
|
+
* ```typescript
|
|
2586
|
+
* import mongoose from 'mongoose';
|
|
2587
|
+
* import { createAccountingEngine } from '@classytic/ledger';
|
|
2588
|
+
* import { canadaPack } from '@classytic/ledger-ca';
|
|
2589
|
+
*
|
|
2590
|
+
* const engine = createAccountingEngine({
|
|
2591
|
+
* mongoose: mongoose.connection,
|
|
2592
|
+
* country: canadaPack,
|
|
2593
|
+
* currency: 'CAD',
|
|
2594
|
+
* multiTenant: { orgField: 'organizationId', orgRef: 'Organization' },
|
|
2595
|
+
* });
|
|
2596
|
+
*
|
|
2597
|
+
* // Models — auto-created Mongoose models
|
|
2598
|
+
* engine.models.Account
|
|
2599
|
+
* engine.models.JournalEntry
|
|
2600
|
+
*
|
|
2601
|
+
* // Repositories — plugins + domain methods pre-wired
|
|
2602
|
+
* await engine.repositories.accounts.seedAccounts(orgId);
|
|
2603
|
+
* await engine.repositories.journalEntries.post(entryId, orgId);
|
|
2604
|
+
*
|
|
2605
|
+
* // Reports — bound to owned models
|
|
2606
|
+
* const bs = await engine.reports.balanceSheet({ organizationId: orgId, dateOption: 'year', dateValue: 2025 });
|
|
2607
|
+
* ```
|
|
2608
|
+
*/
|
|
2268
2609
|
var AccountingEngine = class {
|
|
2269
2610
|
config;
|
|
2270
2611
|
country;
|
|
@@ -2274,14 +2615,48 @@ var AccountingEngine = class {
|
|
|
2274
2615
|
repositories;
|
|
2275
2616
|
record;
|
|
2276
2617
|
introspect;
|
|
2618
|
+
/**
|
|
2619
|
+
* Event transport — structurally matches `@classytic/arc`'s `EventTransport`.
|
|
2620
|
+
* When the host does not inject one, the engine instantiates
|
|
2621
|
+
* `InProcessLedgerBus` (suitable for single-instance deployments only).
|
|
2622
|
+
* Subscribe with glob patterns: `ledger:entry.*`, `ledger:reconciliation.*`, `*`.
|
|
2623
|
+
*/
|
|
2624
|
+
events;
|
|
2625
|
+
/**
|
|
2626
|
+
* Host-provided bridges. Empty object when none supplied. Callers should
|
|
2627
|
+
* optional-chain every method (`engine.bridges.source?.resolve?.(...)`).
|
|
2628
|
+
*/
|
|
2629
|
+
bridges;
|
|
2630
|
+
/**
|
|
2631
|
+
* Host-provided outbox store for durable event delivery (0.9.0). When
|
|
2632
|
+
* present, every domain event is persisted to the outbox in the same
|
|
2633
|
+
* mongoose session as the ledger write before the transport publish.
|
|
2634
|
+
* Undefined when the host opts out of durable delivery.
|
|
2635
|
+
*/
|
|
2636
|
+
outboxStore;
|
|
2277
2637
|
_reports;
|
|
2278
2638
|
constructor(config) {
|
|
2279
2639
|
if (!config.mongoose) throw new Error("createAccountingEngine: `mongoose` connection is required. Pass `mongoose: mongoose.connection` in config.");
|
|
2280
2640
|
this.config = config;
|
|
2281
2641
|
this.country = config.country;
|
|
2282
2642
|
this.currency = config.currency;
|
|
2643
|
+
this.events = config.eventTransport ?? new InProcessLedgerBus();
|
|
2644
|
+
this.bridges = config.bridges ?? {};
|
|
2645
|
+
this.outboxStore = config.outboxStore;
|
|
2283
2646
|
this.models = createModels(config.mongoose, config);
|
|
2284
|
-
this.repositories = createRepositories(this.models, config, config.plugins ?? {}, config.pagination ?? {}
|
|
2647
|
+
this.repositories = createRepositories(this.models, config, config.plugins ?? {}, config.pagination ?? {}, {
|
|
2648
|
+
events: this.events,
|
|
2649
|
+
bridges: this.bridges,
|
|
2650
|
+
outboxStore: this.outboxStore
|
|
2651
|
+
});
|
|
2652
|
+
if (config.syncIndexes) Promise.all([
|
|
2653
|
+
this.models.Account.syncIndexes().catch(() => void 0),
|
|
2654
|
+
this.models.JournalEntry.syncIndexes().catch(() => void 0),
|
|
2655
|
+
this.models.FiscalPeriod.syncIndexes().catch(() => void 0),
|
|
2656
|
+
this.models.Budget.syncIndexes().catch(() => void 0),
|
|
2657
|
+
this.models.Reconciliation.syncIndexes().catch(() => void 0),
|
|
2658
|
+
this.models.Journal.syncIndexes().catch(() => void 0)
|
|
2659
|
+
]);
|
|
2285
2660
|
this.record = buildRecordAPI({
|
|
2286
2661
|
models: this.models,
|
|
2287
2662
|
repositories: this.repositories,
|
|
@@ -2313,6 +2688,60 @@ var AccountingEngine = class {
|
|
|
2313
2688
|
getAccountType(code) {
|
|
2314
2689
|
return this.country.getAccountType(code);
|
|
2315
2690
|
}
|
|
2691
|
+
/**
|
|
2692
|
+
* Create a pre-configured QueryParser for URL-driven queries against
|
|
2693
|
+
* ledger repositories. Returns a mongokit QueryParser with the correct
|
|
2694
|
+
* schema and pagination limits for the specified model.
|
|
2695
|
+
*
|
|
2696
|
+
* @param model - Which ledger model to parse queries for
|
|
2697
|
+
* @param overrides - Additional QueryParserOptions to merge
|
|
2698
|
+
*
|
|
2699
|
+
* @example
|
|
2700
|
+
* ```typescript
|
|
2701
|
+
* const parser = engine.createQueryParser('journalEntry');
|
|
2702
|
+
* const parsed = parser.parse(req.query);
|
|
2703
|
+
* const result = await engine.repositories.journalEntries.getAll({
|
|
2704
|
+
* ...parsed,
|
|
2705
|
+
* filters: { ...parsed.filters, organizationId },
|
|
2706
|
+
* });
|
|
2707
|
+
* ```
|
|
2708
|
+
*/
|
|
2709
|
+
createQueryParser(model, overrides) {
|
|
2710
|
+
const paginationConfig = this.config.pagination ?? {};
|
|
2711
|
+
const entry = {
|
|
2712
|
+
account: {
|
|
2713
|
+
model: this.models.Account,
|
|
2714
|
+
pagination: paginationConfig.account
|
|
2715
|
+
},
|
|
2716
|
+
journalEntry: {
|
|
2717
|
+
model: this.models.JournalEntry,
|
|
2718
|
+
pagination: paginationConfig.journalEntry
|
|
2719
|
+
},
|
|
2720
|
+
fiscalPeriod: {
|
|
2721
|
+
model: this.models.FiscalPeriod,
|
|
2722
|
+
pagination: paginationConfig.fiscalPeriod
|
|
2723
|
+
},
|
|
2724
|
+
budget: {
|
|
2725
|
+
model: this.models.Budget,
|
|
2726
|
+
pagination: paginationConfig.budget
|
|
2727
|
+
},
|
|
2728
|
+
reconciliation: {
|
|
2729
|
+
model: this.models.Reconciliation,
|
|
2730
|
+
pagination: paginationConfig.reconciliation
|
|
2731
|
+
},
|
|
2732
|
+
journal: {
|
|
2733
|
+
model: this.models.Journal,
|
|
2734
|
+
pagination: paginationConfig.journal
|
|
2735
|
+
}
|
|
2736
|
+
}[model];
|
|
2737
|
+
if (!entry) throw new Error(`createQueryParser: unknown model "${model}"`);
|
|
2738
|
+
return new QueryParser({
|
|
2739
|
+
schema: entry.model.schema,
|
|
2740
|
+
maxLimit: entry.pagination?.maxLimit ?? 100,
|
|
2741
|
+
searchMode: "regex",
|
|
2742
|
+
...overrides
|
|
2743
|
+
});
|
|
2744
|
+
}
|
|
2316
2745
|
_buildReports() {
|
|
2317
2746
|
const AccountModel = this.models.Account;
|
|
2318
2747
|
const JournalEntryModel = this.models.JournalEntry;
|
|
@@ -2452,4 +2881,4 @@ function buildDimensionIndexes(dimensions, orgField) {
|
|
|
2452
2881
|
});
|
|
2453
2882
|
}
|
|
2454
2883
|
//#endregion
|
|
2455
|
-
export { AccountingEngine, AccountingError, CATEGORIES, CATEGORY_KEYS, CURRENCIES, DEFAULT_BUCKETS, Errors, JOURNAL_CODES, JOURNAL_TYPES, Money, acquireSession, add, allocate, buildAccountTypeMap, buildDimensionFields, buildDimensionIndexes, buildItemFilters, buildRevaluationEntry, calculateTotal, closeFiscalPeriod, computeEndingBalance, computeRevaluation, createAccountingEngine, createLockPlugin, createModels, createRepositories, creditLimitPlugin, dailyLockPlugin, defaultLogger, defineCountryPack, doubleEntryPlugin, exportToCsv, finalizeSession, fiscalLockPlugin, flattenJournalEntries, format, formatPlain, fromDecimal, fxRealizationPlugin, generateAgedBalance, generateBalanceSheet, generateBudgetVsActual, generateCashFlow, generateDimensionBreakdown, generateGeneralLedger, generateIncomeStatement, generatePartnerLedger, generateRevaluation, generateTrialBalance, getCurrency, getCustomJournalTypes, getDateRange, getFiscalYearStart, getJournalType, getJournalTypeCodes, getMinorUnit, getNormalBalance, idempotencyPlugin, isBalanceSheet, isIncomeStatement, isValidCategory, isValidCurrency, isValidJournalType, isVirtualTaxAccount, multiply, parseCents, percentage, periodResolver, quickbooksFieldMap, registerJournalType, reopenFiscalPeriod, resolveModelNames, splitTaxExclusive, splitTaxInclusive, subtract, toDecimal, universalFieldMap, watermarkResolver };
|
|
2884
|
+
export { AccountingEngine, AccountingError, CATEGORIES, CATEGORY_KEYS, CURRENCIES, ConcurrencyError, DEFAULT_BUCKETS, DuplicateReferenceError, Errors, IdempotencyConflictError, ImmutableViolationError, InProcessLedgerBus, JOURNAL_CODES, JOURNAL_TYPES, LEDGER_EVENTS, Money, OutboxOwnershipError, acquireSession, add, allocate, buildAccountTypeMap, buildDimensionFields, buildDimensionIndexes, buildItemFilters, buildRevaluationEntry, calculateTotal, classifyDuplicateKey, closeFiscalPeriod, computeEndingBalance, computeRevaluation, createAccountingEngine, createEvent, createLockPlugin, createModels, createRepositories, creditLimitPlugin, dailyLockPlugin, defaultLogger, defineCountryPack, doubleEntryPlugin, exportToCsv, finalizeSession, fiscalLockPlugin, flattenJournalEntries, format, formatPlain, fromDecimal, fxRealizationPlugin, generateAgedBalance, generateBalanceSheet, generateBudgetVsActual, generateCashFlow, generateDimensionBreakdown, generateGeneralLedger, generateIncomeStatement, generatePartnerLedger, generateRevaluation, generateTrialBalance, getCurrency, getCustomJournalTypes, getDateRange, getFiscalYearStart, getJournalType, getJournalTypeCodes, getMinorUnit, getNormalBalance, idempotencyPlugin, immutableGuardPlugin, isBalanceSheet, isIncomeStatement, isValidCategory, isValidCurrency, isValidJournalType, isVirtualTaxAccount, multiply, parseCents, percentage, periodResolver, quickbooksFieldMap, registerJournalType, reopenFiscalPeriod, resolveModelNames, splitTaxExclusive, splitTaxInclusive, subtract, toDecimal, universalFieldMap, watermarkResolver };
|