@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.
Files changed (41) hide show
  1. package/README.md +221 -115
  2. package/dist/bridges/index.d.mts +2 -0
  3. package/dist/bridges/index.mjs +1 -0
  4. package/dist/constants/index.d.mts +1 -1
  5. package/dist/constants/index.mjs +2 -2
  6. package/dist/country/index.d.mts +1 -1
  7. package/dist/errors-BI5k4iak.mjs +121 -0
  8. package/dist/events/index.d.mts +2 -0
  9. package/dist/events/index.mjs +2 -0
  10. package/dist/exports/index.d.mts +1 -1
  11. package/dist/exports/index.mjs +1 -1
  12. package/dist/{fx-realization.plugin-CfYy1tB6.mjs → fx-realization.plugin-Bxlb8cIx.mjs} +45 -2
  13. package/dist/{index-BX8miYdu.d.mts → index-08IpHhrU.d.mts} +12 -1
  14. package/dist/{index-Bl0_ak5w.d.mts → index-Db0n_6Z8.d.mts} +1 -1
  15. package/dist/index-dqkjgpII.d.mts +104 -0
  16. package/dist/index.d.mts +344 -65
  17. package/dist/index.mjs +539 -110
  18. package/dist/{journals-C50E9mpo.d.mts → journals-DUpWwFt1.d.mts} +1 -1
  19. package/dist/opening-balance-1cixYh6Y.mjs +60 -0
  20. package/dist/outbox-store-DQbL-KYT.mjs +132 -0
  21. package/dist/outbox-store-UYC4eZpI.d.mts +249 -0
  22. package/dist/{partner-ledger-D9H5hegI.mjs → partner-ledger-BoebloHk.mjs} +2 -2
  23. package/dist/plugins/index.d.mts +1 -1
  24. package/dist/plugins/index.mjs +1 -1
  25. package/dist/reports/index.d.mts +1 -1
  26. package/dist/reports/index.mjs +1 -1
  27. package/dist/sync/index.d.mts +313 -0
  28. package/dist/sync/index.mjs +527 -0
  29. package/dist/sync-JvchM3FO.d.mts +152 -0
  30. package/dist/{trial-balance-DTc8kzTD.d.mts → trial-balance-DyNm5bFu.d.mts} +2 -2
  31. package/docs/country-packs.md +71 -47
  32. package/docs/engine.md +3 -2
  33. package/docs/subledger-integration.md +29 -8
  34. package/docs/sync.md +330 -0
  35. package/package.json +36 -14
  36. package/dist/errors-CSDQPNyt.mjs +0 -33
  37. /package/dist/{categories-BkKdv16V.mjs → categories-FJlrvzcl.mjs} +0 -0
  38. /package/dist/{core-BkGjuVZj.d.mts → core-DwjkrRkJ.d.mts} +0 -0
  39. /package/dist/{currencies-CsuBGfgs.mjs → currencies-Jo5oaM_4.mjs} +0 -0
  40. /package/dist/{exports-BP-0Ni5W.mjs → exports-C30yRapf.mjs} +0 -0
  41. /package/dist/{index-D1ZjgVxn.d.mts → index-J-XIbXH-.d.mts} +0 -0
package/dist/index.mjs CHANGED
@@ -1,13 +1,42 @@
1
- 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-CsuBGfgs.mjs";
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 Errors, t as AccountingError } from "./errors-CSDQPNyt.mjs";
4
- 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-D9H5hegI.mjs";
5
- import { c as getNormalBalance, d as isValidCategory, l as isBalanceSheet, n as CATEGORY_KEYS, t as CATEGORIES, u as isIncomeStatement } from "./categories-BkKdv16V.mjs";
6
- 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-CfYy1tB6.mjs";
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-BP-0Ni5W.mjs";
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
- import { Repository } from "@classytic/mongokit";
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, { timestamps: true });
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
- const generateReferenceNumber = async (doc, Model, session) => {
602
- const jt = doc.journalType || "MISC";
603
- const d = new Date(doc.date);
604
- const prefix = `${jt}/${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/`;
605
- const matchFilter = { referenceNumber: { $regex: `^${prefix.replace(/\//g, "\\/")}` } };
606
- if (multiTenant) matchFilter[multiTenant.orgField] = doc[multiTenant.orgField];
607
- const pipeline = [
608
- { $match: matchFilter },
609
- { $addFields: { _refSeq: { $toInt: { $arrayElemAt: [{ $split: ["$referenceNumber", "/"] }, -1] } } } },
610
- { $sort: { _refSeq: -1 } },
611
- { $limit: 1 },
612
- { $project: { _refSeq: 1 } }
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
- const MAX_REF_RETRIES = 3;
628
- schema.post("save", async (error, doc, next) => {
629
- const mongoError = error;
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
- $exists: true,
717
- $ne: null
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, AccountModel, country, orgField) {
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 AccountModel.find(filter).select("accountNumber").lean();
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
- return {
956
- created: (await AccountModel.insertMany(toCreate, {
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
- return {
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 AccountModel.find(existsFilter).select("accountNumber").lean();
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 AccountModel.insertMany(docs, { ordered: false });
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 create = repository.create.bind(repository);
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
- return await update(entry._id, patch, updateOptions) ?? entry;
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
- return await update(entry._id, {
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
- return await update(entry._id, {
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
- return await create(duplicateData, options.session ? { session: options.session } : {});
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: await update(entry._id, markPatch, markOptions) ?? entry,
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
- const emit = repoInstance._emitHook;
1616
- if (emit) await emit.call(repoInstance, "after:match", {
1617
- reconciliation: record,
1618
- items: itemSnapshots,
1619
- sharedCurrency,
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 bulkOps = (existing.items ?? []).map((it) => ({ updateOne: {
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
- * Consumer never constructs Repository, never calls wireXxx methods.
1710
- * Just uses `engine.repositories.accounts.seedAccounts(orgId)`.
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), models.Account, country, orgField);
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
- const journalEntries = wireJournalEntryMethods(new Repository(models.JournalEntry, jePlugins, jePagination), models.JournalEntry, orgField, strictness);
1749
- const fiscalPeriods = new Repository(models.FiscalPeriod, plugins.fiscalPeriod ?? [], fpPagination);
1750
- const budgets = new Repository(models.Budget, plugins.budget ?? [], budgetPagination);
1751
- const reconciliations = wireReconciliationMethods(new Repository(models.Reconciliation, plugins.reconciliation ?? [], reconPagination), models.Reconciliation, models.JournalEntry, orgField);
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 };