@classytic/ledger 0.8.0 → 0.9.1

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 (37) hide show
  1. package/README.md +94 -348
  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-DDVK-oYO.mjs → fx-realization.plugin-Bxlb8cIx.mjs} +2 -2
  13. package/dist/{index-RNZsX0Yo.d.mts → index-08IpHhrU.d.mts} +1 -1
  14. package/dist/{index-BSsvrf3m.d.mts → index-ClLwzNRF.d.mts} +3 -3
  15. package/dist/index-Dih0lM65.d.mts +169 -0
  16. package/dist/index.d.mts +233 -65
  17. package/dist/index.mjs +400 -99
  18. package/dist/{journals-Dd4A9TN3.d.mts → journals-DUpWwFt1.d.mts} +1 -1
  19. package/dist/outbox-store-DQbL-KYT.mjs +132 -0
  20. package/dist/outbox-store-UYC4eZpI.d.mts +249 -0
  21. package/dist/{partner-ledger-D9H5hegI.mjs → partner-ledger-BoebloHk.mjs} +2 -2
  22. package/dist/plugins/index.d.mts +1 -1
  23. package/dist/plugins/index.mjs +1 -1
  24. package/dist/reports/index.d.mts +1 -1
  25. package/dist/reports/index.mjs +1 -1
  26. package/dist/sync/index.d.mts +1 -1
  27. package/dist/sync/index.mjs +1 -1
  28. package/dist/{sync-CnuVf441.d.mts → sync-JvchM3FO.d.mts} +1 -1
  29. package/dist/{trial-balance-DTj-c21f.d.mts → trial-balance-DyNm5bFu.d.mts} +2 -2
  30. package/package.json +27 -14
  31. package/dist/errors-CSDQPNyt.mjs +0 -33
  32. /package/dist/{categories-BkKdv16V.mjs → categories-FJlrvzcl.mjs} +0 -0
  33. /package/dist/{core-MpgjCqK0.d.mts → core-DwjkrRkJ.d.mts} +0 -0
  34. /package/dist/{currencies-CsuBGfgs.mjs → currencies-Jo5oaM_4.mjs} +0 -0
  35. /package/dist/{exports-B3whucXe.mjs → exports-C30yRapf.mjs} +0 -0
  36. /package/dist/{index-bCEeSzdO.d.mts → index-J-XIbXH-.d.mts} +0 -0
  37. /package/dist/{opening-balance-DPXmAIzN.mjs → opening-balance-1cixYh6Y.mjs} +0 -0
package/dist/index.mjs CHANGED
@@ -1,14 +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-DDVK-oYO.mjs";
7
- import { t as buildOpeningBalanceEntry } from "./opening-balance-DPXmAIzN.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";
8
9
  import { defineCountryPack } from "./country/index.mjs";
9
- import { a as exportToCsv, i as quickbooksFieldMap, r as universalFieldMap, t as flattenJournalEntries } from "./exports-B3whucXe.mjs";
10
- import { QueryParser, Repository } from "@classytic/mongokit";
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";
11
12
  import mongoose, { Schema } from "mongoose";
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
12
40
  //#region src/schemas/currency-field.ts
13
41
  /**
14
42
  * Build the Mongoose currency field definition.
@@ -580,7 +608,10 @@ function createJournalEntrySchema(config, accountModelName, options = {}) {
580
608
  ref: multiTenant.orgRef,
581
609
  required: true
582
610
  };
583
- const schema = new mongoose.Schema(fields, { timestamps: true });
611
+ const schema = new mongoose.Schema(fields, {
612
+ timestamps: true,
613
+ optimisticConcurrency: true
614
+ });
584
615
  schema.pre("validate", function() {
585
616
  for (const item of this.journalItems) if (!item.date) item.date = this.date;
586
617
  for (let i = 0; i < this.journalItems.length; i++) {
@@ -598,56 +629,25 @@ function createJournalEntrySchema(config, accountModelName, options = {}) {
598
629
  this.totalDebit = totalDebit;
599
630
  this.totalCredit = totalCredit;
600
631
  });
601
- if (autoReference) {
602
- const generateReferenceNumber = async (doc, Model, session) => {
603
- const jt = doc.journalType || "MISC";
604
- const d = new Date(doc.date);
605
- const prefix = `${jt}/${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/`;
606
- const matchFilter = { referenceNumber: { $regex: `^${prefix.replace(/\//g, "\\/")}` } };
607
- if (multiTenant) matchFilter[multiTenant.orgField] = doc[multiTenant.orgField];
608
- const pipeline = [
609
- { $match: matchFilter },
610
- { $addFields: { _refSeq: { $toInt: { $arrayElemAt: [{ $split: ["$referenceNumber", "/"] }, -1] } } } },
611
- { $sort: { _refSeq: -1 } },
612
- { $limit: 1 },
613
- { $project: { _refSeq: 1 } }
614
- ];
615
- const results = await Model.aggregate(pipeline).session(session);
616
- let seq = 1;
617
- if (results.length > 0 && typeof results[0]._refSeq === "number") seq = results[0]._refSeq + 1;
618
- return `${prefix}${String(seq).padStart(4, "0")}`;
619
- };
620
- schema.pre("save", async function() {
621
- if (this.isModified("journalType")) this.referenceNumber = void 0;
622
- if (!this.referenceNumber) {
623
- const session = this.$session?.() ?? null;
624
- const Model = this.constructor;
625
- 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";
626
646
  }
627
- });
628
- const MAX_REF_RETRIES = 3;
629
- schema.post("save", async (error, doc, next) => {
630
- const mongoError = error;
631
- if (mongoError.code === 11e3 && mongoError.keyPattern?.referenceNumber) {
632
- const entry = doc;
633
- const retryCount = entry.__refRetries ?? 0;
634
- if (retryCount >= MAX_REF_RETRIES) {
635
- next(/* @__PURE__ */ new Error(`Failed to generate unique reference number after ${MAX_REF_RETRIES} retries. Too many concurrent inserts for this period.`));
636
- return;
637
- }
638
- entry.__refRetries = retryCount + 1;
639
- const session = entry.$session?.() ?? null;
640
- const Model = entry.constructor;
641
- entry.referenceNumber = await generateReferenceNumber(entry, Model, session);
642
- try {
643
- await entry.save({ session });
644
- next();
645
- } catch (retryError) {
646
- next(retryError);
647
- }
648
- } else next(error);
649
- });
650
- }
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
+ });
651
651
  if (indexes) {
652
652
  const org = multiTenant?.orgField;
653
653
  const refPartial = { partialFilterExpression: { referenceNumber: {
@@ -713,10 +713,13 @@ function createJournalEntrySchema(config, accountModelName, options = {}) {
713
713
  idempotencyIdx.idempotencyKey = 1;
714
714
  schema.index(idempotencyIdx, {
715
715
  unique: true,
716
- partialFilterExpression: { idempotencyKey: {
717
- $exists: true,
718
- $ne: null
719
- } }
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" } }
720
723
  });
721
724
  }
722
725
  }
@@ -915,6 +918,15 @@ function createModels(connection, config) {
915
918
  }
916
919
  //#endregion
917
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
+ }
918
930
  /**
919
931
  * Wire seedAccounts, bulkCreate and posting-account validation
920
932
  * onto an existing mongokit Repository.
@@ -923,7 +935,9 @@ function createModels(connection, config) {
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, 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.`);
@@ -954,8 +968,9 @@ function wireAccountMethods(repository, country, orgField) {
954
968
  created: 0,
955
969
  skipped: existingNumbers.size
956
970
  };
971
+ let result;
957
972
  try {
958
- return {
973
+ result = {
959
974
  created: (await repository.createMany(toCreate, {
960
975
  session: options.session ?? void 0,
961
976
  ordered: false
@@ -966,13 +981,21 @@ function wireAccountMethods(repository, country, orgField) {
966
981
  const bulkError = err;
967
982
  if (bulkError.code === 11e3 || bulkError.writeErrors) {
968
983
  const insertedDocs = bulkError.insertedDocs ?? [];
969
- return {
984
+ result = {
970
985
  created: insertedDocs.length,
971
986
  skipped: existingNumbers.size + (toCreate.length - insertedDocs.length)
972
987
  };
973
- }
974
- throw err;
988
+ } else throw err;
975
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;
976
999
  };
977
1000
  /**
978
1001
  * Bulk create accounts with validation and skip-if-exists logic.
@@ -1090,13 +1113,20 @@ function wireAccountMethods(repository, country, orgField) {
1090
1113
  } else throw err;
1091
1114
  }
1092
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 });
1093
1128
  return {
1094
- summary: {
1095
- total: accounts.length,
1096
- created: results.created.length,
1097
- skipped: results.skipped.length,
1098
- errors: results.errors.length
1099
- },
1129
+ summary,
1100
1130
  ...results
1101
1131
  };
1102
1132
  };
@@ -1113,6 +1143,15 @@ function wireAccountMethods(repository, country, orgField) {
1113
1143
  }
1114
1144
  //#endregion
1115
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
+ }
1116
1155
  /**
1117
1156
  * Lean default set used when a country pack doesn't provide
1118
1157
  * `journalTemplates`. Covers the Stripe/QuickBooks/Xero baseline.
@@ -1154,9 +1193,11 @@ const DEFAULT_TEMPLATES = [
1154
1193
  sequencePrefix: "JE"
1155
1194
  }
1156
1195
  ];
1157
- function wireJournalMethods(repository, country, orgField) {
1196
+ function wireJournalMethods(repository, country, orgField, integrations = {}) {
1158
1197
  const create = repository.create.bind(repository);
1159
1198
  const exists = repository.exists.bind(repository);
1199
+ const events = integrations.events;
1200
+ const outboxStore = integrations.outboxStore;
1160
1201
  repository.seedDefaults = async (orgId) => {
1161
1202
  requireOrgScope(orgField, orgId);
1162
1203
  const templates = country.journalTemplates ?? DEFAULT_TEMPLATES;
@@ -1182,6 +1223,11 @@ function wireJournalMethods(repository, country, orgField) {
1182
1223
  await create(data);
1183
1224
  created += 1;
1184
1225
  }
1226
+ await safePublish$2(events, outboxStore, LEDGER_EVENTS.JOURNAL_SEEDED, {
1227
+ created,
1228
+ skipped,
1229
+ organizationId: orgId
1230
+ }, { organizationId: orgId });
1185
1231
  return {
1186
1232
  created,
1187
1233
  skipped
@@ -1211,6 +1257,25 @@ function wireJournalMethods(repository, country, orgField) {
1211
1257
  }
1212
1258
  //#endregion
1213
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
+ }
1214
1279
  /** Keys that are either handled explicitly or must not be copied */
1215
1280
  const ITEM_CORE_KEYS = new Set([
1216
1281
  "account",
@@ -1233,11 +1298,58 @@ const ITEM_CORE_KEYS = new Set([
1233
1298
  * @param orgField - The multi-tenant field name (e.g. 'business')
1234
1299
  * @param strictness - Strictness rules (immutable, requireActor, requireApproval)
1235
1300
  */
1236
- function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, strictness) {
1301
+ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, strictness, integrations = {}) {
1302
+ const events = integrations.events;
1303
+ const outboxStore = integrations.outboxStore;
1237
1304
  const getByQuery = repository.getByQuery.bind(repository);
1238
- const create = repository.create.bind(repository);
1305
+ const baseCreate = repository.create.bind(repository);
1239
1306
  const update = repository.update.bind(repository);
1240
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;
1241
1353
  const RESERVED_TOPLEVEL = new Set([
1242
1354
  "_id",
1243
1355
  "__v",
@@ -1347,7 +1459,23 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
1347
1459
  _ledgerInternal: "post",
1348
1460
  ...options.session ? { session: options.session } : {}
1349
1461
  };
1350
- 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;
1351
1479
  };
1352
1480
  /**
1353
1481
  * Unpost an entry (posted → draft).
@@ -1366,10 +1494,23 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
1366
1494
  _ledgerInternal: "unpost",
1367
1495
  ...options.session ? { session: options.session } : {}
1368
1496
  };
1369
- return await update(entry._id, {
1497
+ const final = await update(entry._id, {
1370
1498
  state: "draft",
1371
1499
  stateChangedAt: /* @__PURE__ */ new Date()
1372
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;
1373
1514
  };
1374
1515
  /**
1375
1516
  * Archive a draft entry (draft → archived).
@@ -1386,10 +1527,23 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
1386
1527
  _ledgerInternal: "archive",
1387
1528
  ...options.session ? { session: options.session } : {}
1388
1529
  };
1389
- return await update(entry._id, {
1530
+ const final = await update(entry._id, {
1390
1531
  state: "archived",
1391
1532
  stateChangedAt: /* @__PURE__ */ new Date()
1392
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;
1393
1547
  };
1394
1548
  /**
1395
1549
  * Duplicate an entry as a new draft.
@@ -1420,7 +1574,21 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
1420
1574
  })
1421
1575
  };
1422
1576
  copyExtraTopLevel(entry, duplicateData);
1423
- 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;
1424
1592
  };
1425
1593
  /**
1426
1594
  * Reverse a posted entry by creating a mirror entry with flipped debits/credits.
@@ -1483,8 +1651,23 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
1483
1651
  _ledgerInternal: "reverseMark",
1484
1652
  ...session ? { session } : {}
1485
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
+ });
1486
1669
  return {
1487
- original: await update(entry._id, markPatch, markOptions) ?? entry,
1670
+ original,
1488
1671
  reversal: reversalEntry
1489
1672
  };
1490
1673
  };
@@ -1512,6 +1695,15 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
1512
1695
  }
1513
1696
  //#endregion
1514
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
+ }
1515
1707
  /**
1516
1708
  * Default matching-number generator — atomic counter stored in the
1517
1709
  * reconciliation collection. Uses a dedicated sentinel document keyed
@@ -1543,10 +1735,13 @@ async function nextMatchingNumber(ReconciliationModel, orgField, orgId, session)
1543
1735
  }).lean())?.seq ?? 1;
1544
1736
  return `RECN-${String(seq).padStart(6, "0")}`;
1545
1737
  }
1546
- function wireReconciliationMethods(repository, ReconciliationModel, JournalEntryModel, orgField) {
1738
+ function wireReconciliationMethods(repository, ReconciliationModel, JournalEntryModel, orgField, integrations = {}) {
1547
1739
  const create = repository.create.bind(repository);
1548
1740
  const deleteById = repository.delete.bind(repository);
1549
1741
  const repoInstance = repository;
1742
+ const events = integrations.events;
1743
+ const outboxStore = integrations.outboxStore;
1744
+ const notification = integrations.bridges?.notification;
1550
1745
  const emitHook = repoInstance.emitAsync.bind(repoInstance);
1551
1746
  repository.match = async (input) => {
1552
1747
  const { account, items, note, reconciledBy, organizationId, session = null } = input;
@@ -1634,6 +1829,29 @@ function wireReconciliationMethods(repository, ReconciliationModel, JournalEntry
1634
1829
  ...hookCtx,
1635
1830
  reconciliation: record
1636
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,
1843
+ session
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 {}
1637
1855
  return record;
1638
1856
  };
1639
1857
  repository.unmatch = async (input) => {
@@ -1660,6 +1878,14 @@ function wireReconciliationMethods(repository, ReconciliationModel, JournalEntry
1660
1878
  const result = await deleteById(String(existing._id));
1661
1879
  if (!result.success) throw Errors.notFound("Failed to delete reconciliation record");
1662
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
+ });
1663
1889
  return result;
1664
1890
  };
1665
1891
  repository.getOpenItems = async (params) => {
@@ -1731,29 +1957,39 @@ function wireReconciliationMethods(repository, ReconciliationModel, JournalEntry
1731
1957
  * Matches the flow/promo pattern: engine owns the repositories with all
1732
1958
  * plugins (double-entry, fiscal-lock, idempotency) pre-wired.
1733
1959
  *
1734
- * Consumer never constructs Repository, never calls wireXxx methods.
1735
- * 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.
1736
1965
  */
1737
1966
  /**
1738
1967
  * Build all ledger repositories with plugins + domain methods pre-wired.
1739
- *
1740
- * - `accounts` — has seedAccounts(), bulkCreate()
1741
- * - `journalEntries` — has post(), unpost(), reverse(), duplicate() + double-entry + fiscal-lock (+ idempotency if enabled)
1742
- * - `fiscalPeriods` — plain CRUD
1743
- * - `budgets` — plain CRUD
1744
- * - `reconciliations` — has reconcile(), unreconcile(), getUnreconciled()
1745
1968
  */
1746
- function createRepositories(models, config, plugins = {}, pagination = {}) {
1969
+ function createRepositories(models, config, plugins = {}, pagination = {}, integrations = {}) {
1747
1970
  const orgField = config.multiTenant?.orgField;
1748
1971
  const strictness = config.strictness;
1749
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
+ }));
1750
1981
  const accountPagination = pagination.account ?? {};
1751
1982
  const jePagination = pagination.journalEntry ?? {};
1752
1983
  const fpPagination = pagination.fiscalPeriod ?? {};
1753
1984
  const budgetPagination = pagination.budget ?? {};
1754
1985
  const reconPagination = pagination.reconciliation ?? {};
1755
- const accounts = wireAccountMethods(new Repository(models.Account, plugins.account ?? [], accountPagination), country, orgField);
1986
+ const accounts = wireAccountMethods(new Repository(models.Account, [...tenantPlugins, ...plugins.account ?? []], accountPagination), country, orgField, {
1987
+ events,
1988
+ bridges,
1989
+ outboxStore
1990
+ });
1756
1991
  const jePlugins = [
1992
+ ...tenantPlugins,
1757
1993
  ...plugins.journalEntry ?? [],
1758
1994
  doubleEntryPlugin({
1759
1995
  JournalEntryModel: models.JournalEntry,
@@ -1770,10 +2006,22 @@ function createRepositories(models, config, plugins = {}, pagination = {}) {
1770
2006
  JournalEntryModel: models.JournalEntry,
1771
2007
  orgField
1772
2008
  }));
1773
- const journalEntries = wireJournalEntryMethods(new Repository(models.JournalEntry, jePlugins, jePagination), models.JournalEntry, orgField, strictness);
1774
- const fiscalPeriods = new Repository(models.FiscalPeriod, plugins.fiscalPeriod ?? [], fpPagination);
1775
- const budgets = new Repository(models.Budget, plugins.budget ?? [], budgetPagination);
1776
- 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
+ });
1777
2025
  const journalPagination = pagination.journal ?? {};
1778
2026
  return {
1779
2027
  accounts,
@@ -1781,7 +2029,11 @@ function createRepositories(models, config, plugins = {}, pagination = {}) {
1781
2029
  fiscalPeriods,
1782
2030
  budgets,
1783
2031
  reconciliations,
1784
- 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
+ })
1785
2037
  };
1786
2038
  }
1787
2039
  //#endregion
@@ -2363,14 +2615,48 @@ var AccountingEngine = class {
2363
2615
  repositories;
2364
2616
  record;
2365
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;
2366
2637
  _reports;
2367
2638
  constructor(config) {
2368
2639
  if (!config.mongoose) throw new Error("createAccountingEngine: `mongoose` connection is required. Pass `mongoose: mongoose.connection` in config.");
2369
2640
  this.config = config;
2370
2641
  this.country = config.country;
2371
2642
  this.currency = config.currency;
2643
+ this.events = config.eventTransport ?? new InProcessLedgerBus();
2644
+ this.bridges = config.bridges ?? {};
2645
+ this.outboxStore = config.outboxStore;
2372
2646
  this.models = createModels(config.mongoose, config);
2373
- 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
+ ]);
2374
2660
  this.record = buildRecordAPI({
2375
2661
  models: this.models,
2376
2662
  repositories: this.repositories,
@@ -2383,6 +2669,21 @@ var AccountingEngine = class {
2383
2669
  });
2384
2670
  }
2385
2671
  /**
2672
+ * Explicitly sync indexes on all managed models.
2673
+ * Call this in deploy-time scripts — NOT on every boot.
2674
+ * See PACKAGE_RULES section 32.
2675
+ */
2676
+ async syncIndexes() {
2677
+ await Promise.all([
2678
+ this.models.Account.syncIndexes(),
2679
+ this.models.JournalEntry.syncIndexes(),
2680
+ this.models.FiscalPeriod.syncIndexes(),
2681
+ this.models.Budget.syncIndexes(),
2682
+ this.models.Reconciliation.syncIndexes(),
2683
+ this.models.Journal.syncIndexes()
2684
+ ]);
2685
+ }
2686
+ /**
2386
2687
  * Pre-built reports bound to the engine's owned models.
2387
2688
  * Lazy-initialized on first access.
2388
2689
  */
@@ -2595,4 +2896,4 @@ function buildDimensionIndexes(dimensions, orgField) {
2595
2896
  });
2596
2897
  }
2597
2898
  //#endregion
2598
- 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 };
2899
+ 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 };