@classytic/ledger 0.5.0 → 0.5.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.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Embeddable double-entry accounting engine for MongoDB. Integer-cents arithmetic, plugin-based, country-agnostic, multi-tenant at every layer. Framework-agnostic — works with Express, Fastify, Nest, Arc, or any plain Mongoose app.
4
4
 
5
+ > **0.5.1** — Critical plugin-pipeline fixes. `post()`, `unpost()`, `archive()`, and the `reverse()` mark-as-reversed step now route through `repository.update()` so `before:update` / `after:update` hooks fire on every state transition (period locks, audit, observability are no longer silently bypassed). `reverse()` and `duplicate()` propagate every consumer-defined top-level field (`departmentId`, `projectId`, `sourceRef`, `branchTag`, `organizationId`, …). New typed `_ledgerInternal` flag on `RepositoryContext` lets plugin authors observe internal transitions without casts. See [CHANGELOG.md](CHANGELOG.md).
6
+
5
7
  ## Install
6
8
 
7
9
  ```bash
@@ -100,7 +100,8 @@ function doubleEntryPlugin(options = {}) {
100
100
  const validateUpdate = async (context) => {
101
101
  const data = context.data;
102
102
  if (!data) return;
103
- if (JournalEntryModel) {
103
+ const internalOp = context._ledgerInternal;
104
+ if (JournalEntryModel && !internalOp) {
104
105
  const id = context.id;
105
106
  if (id) {
106
107
  if ((await JournalEntryModel.findById(id).select("state").session(context.session ?? null).lean())?.state === "posted") {
@@ -15,6 +15,44 @@ declare function dateLockPlugin(options: DateLockPluginOptions): {
15
15
  apply(repo: RepositoryInstance): void;
16
16
  };
17
17
  //#endregion
18
+ //#region src/types/mongokit-augmentation.d.ts
19
+ /**
20
+ * Module augmentation for `@classytic/mongokit`.
21
+ *
22
+ * Ledger's state-transition methods (post, unpost, archive, reverseMark) tag
23
+ * their `repository.update()` call with a `_ledgerInternal` flag so the
24
+ * double-entry immutability guard can distinguish legitimate transitions from
25
+ * arbitrary edits. This file types that flag onto both `RepositoryContext`
26
+ * (what plugins observe) and `SessionOptions` (what callers pass) so consumers
27
+ * and plugin authors get full IntelliSense without casts.
28
+ *
29
+ * This file is side-effect only — importing it anywhere in the package is
30
+ * enough to activate the augmentation. `src/types/index.ts` re-exports it.
31
+ */
32
+ type LedgerInternalOp = 'post' | 'unpost' | 'archive' | 'reverseMark';
33
+ declare module '@classytic/mongokit' {
34
+ interface RepositoryContext {
35
+ /**
36
+ * Set by ledger's repository methods (post, unpost, archive, reverseMark)
37
+ * to signal a legitimate internal state transition. Plugins observing
38
+ * `before:update` can read this to distinguish from arbitrary edits.
39
+ *
40
+ * External `repository.update()` callers cannot spoof this flag because
41
+ * it is only set by ledger's own repo methods, never surfaced in the
42
+ * public API.
43
+ */
44
+ _ledgerInternal?: LedgerInternalOp;
45
+ }
46
+ interface SessionOptions {
47
+ /**
48
+ * Ledger-internal flag — see `RepositoryContext._ledgerInternal`.
49
+ * Typed here so ledger's repo methods can pass it without casts.
50
+ * Consumers should never set this directly.
51
+ */
52
+ _ledgerInternal?: LedgerInternalOp;
53
+ }
54
+ }
55
+ //#endregion
18
56
  //#region src/plugins/double-entry.plugin.d.ts
19
57
  interface DoubleEntryPluginOptions {
20
58
  /** Only enforce on posted entries (default: true) */
package/dist/index.d.mts CHANGED
@@ -4,7 +4,7 @@ import { a as TaxReportLine, i as TaxCodesByRegion, n as CountryPackInput, o as
4
4
  import { _ as PopulatedJournalEntry, a as exportToCsv, h as FlatJournalRow, i as quickbooksFieldMap, m as ExportFieldMap, p as ExportField, r as universalFieldMap, t as flattenJournalEntries } from "./index-D1ZjgVxn.mjs";
5
5
  import { Money, abs, add, allocate, equals, format, formatPlain, fromDecimal, isNegative, isPositive, isValid, isZero, max, min, multiply, negate, parseCents, percentage, round, splitTaxExclusive, splitTaxInclusive, subtract, toDecimal } from "./money.mjs";
6
6
  import { $ as AgedBucketConfig, A as BudgetVsActualReport, B as IncomeStatementReport, C as DimensionBreakdownReport, D as generateCashFlow, F as BalanceSheetReport, G as TaxReport, H as ReportAccount, I as CashFlowReport, J as TrialBalanceRow, K as TaxReturnSummary, L as CashFlowSection, M as generateBudgetVsActual, O as BudgetVsActualOptions, P as generateBalanceSheet, Q as AgedBalanceRow, R as GeneralLedgerAccount, S as DimensionBreakdownParams, T as generateDimensionBreakdown, U as ReportCategory, V as LedgerEntry, W as ReportGroup, X as AgedBalanceParams, Y as AgedBalanceOptions, Z as AgedBalanceReport, a as RevaluationReport, b as reopenFiscalPeriod, c as RevaluationRate, d as computeRevaluation, et as DEFAULT_BUCKETS, h as generateGeneralLedger, i as RevaluationParams, j as BudgetVsActualRow, k as BudgetVsActualParams, l as RevaluationResult, n as generateTrialBalance, nt as Logger, o as generateRevaluation, p as generateIncomeStatement, q as TrialBalanceReport, r as RevaluationOptions, rt as defaultLogger, s as AccountForeignBalance, tt as generateAgedBalance, u as buildRevaluationEntry, w as DimensionBreakdownRow, x as DimensionBreakdownOptions, y as closeFiscalPeriod, z as GeneralLedgerReport } from "./trial-balance-BZ7yOOFD.mjs";
7
- import { c as dateLockPlugin, i as fiscalLockPlugin, n as idempotencyPlugin, o as doubleEntryPlugin } from "./idempotency.plugin-CK7LHnBn.mjs";
7
+ import { c as dateLockPlugin, i as fiscalLockPlugin, n as idempotencyPlugin, o as doubleEntryPlugin } from "./idempotency.plugin-WcQLZU9n.mjs";
8
8
  import { ClientSession, Connection, Model } from "mongoose";
9
9
  import { PaginationConfig, PluginType, Repository } from "@classytic/mongokit";
10
10
 
package/dist/index.mjs CHANGED
@@ -3,7 +3,7 @@ import { Money, add, allocate, format, formatPlain, fromDecimal, multiply, parse
3
3
  import { n as Errors, t as AccountingError } from "./errors-BmRjW38t.mjs";
4
4
  import { C as DEFAULT_BUCKETS, S as isVirtualTaxAccount, T as requireOrgScope, _ as getDateRange, a as defaultLogger, b as calculateTotal, c as buildRevaluationEntry, d as generateGeneralLedger, f as generateDimensionBreakdown, g as buildItemFilters, h as generateBalanceSheet, i as finalizeSession, l as computeRevaluation, m as generateBudgetVsActual, n as reopenFiscalPeriod, o as generateTrialBalance, p as generateCashFlow, r as acquireSession, s as generateRevaluation, t as closeFiscalPeriod, u as generateIncomeStatement, v as getFiscalYearStart, w as generateAgedBalance, x as computeEndingBalance, y as buildAccountTypeMap } from "./fiscal-close-Dk3yRT9i.mjs";
5
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 { i as doubleEntryPlugin, n as idempotencyPlugin, r as fiscalLockPlugin, t as dateLockPlugin } from "./date-lock.plugin-B2Jy0ukX.mjs";
6
+ import { i as doubleEntryPlugin, n as idempotencyPlugin, r as fiscalLockPlugin, t as dateLockPlugin } from "./date-lock.plugin-B6WyvqNG.mjs";
7
7
  import { defineCountryPack } from "./country/index.mjs";
8
8
  import { a as exportToCsv, i as quickbooksFieldMap, r as universalFieldMap, t as flattenJournalEntries } from "./exports-BP-0Ni5W.mjs";
9
9
  import mongoose, { Schema } from "mongoose";
@@ -927,7 +927,43 @@ const ITEM_CORE_KEYS = new Set([
927
927
  function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, strictness) {
928
928
  const getByQuery = repository.getByQuery.bind(repository);
929
929
  const create = repository.create.bind(repository);
930
+ const update = repository.update.bind(repository);
930
931
  const withTransaction = repository.withTransaction.bind(repository);
932
+ const RESERVED_TOPLEVEL = new Set([
933
+ "_id",
934
+ "__v",
935
+ "id",
936
+ "journalType",
937
+ "state",
938
+ "date",
939
+ "label",
940
+ "journalItems",
941
+ "totalDebit",
942
+ "totalCredit",
943
+ "reversalOf",
944
+ "reversedBy",
945
+ "reversedByUser",
946
+ "reversed",
947
+ "stateChangedAt",
948
+ "createdAt",
949
+ "updatedAt",
950
+ "referenceNumber",
951
+ "idempotencyKey",
952
+ "postedBy",
953
+ "approvedBy",
954
+ "approvedAt"
955
+ ]);
956
+ /** Copy non-reserved top-level fields from `source` onto `target`. */
957
+ function copyExtraTopLevel(source, target) {
958
+ const obj = typeof source.toObject === "function" ? source.toObject() : source;
959
+ for (const key of Object.keys(obj)) {
960
+ if (RESERVED_TOPLEVEL.has(key)) continue;
961
+ if (key in target) continue;
962
+ const value = obj[key];
963
+ if (value === void 0 || value === null) continue;
964
+ target[key] = value;
965
+ }
966
+ }
931
967
  /** Build a tenant-scoped query for a single entry by ID (injection-safe) */
932
968
  function buildQuery(id, orgId) {
933
969
  validateScalarId(id, "entry ID");
@@ -993,11 +1029,16 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
993
1029
  const totalDebit = entry.journalItems.reduce((s, i) => s + (i.debit || 0), 0);
994
1030
  const totalCredit = entry.journalItems.reduce((s, i) => s + (i.credit || 0), 0);
995
1031
  if (totalDebit !== totalCredit) throw Errors.validation(`Entry is not balanced. Debit: ${totalDebit}, Credit: ${totalCredit}`);
996
- entry.state = "posted";
997
- entry.stateChangedAt = /* @__PURE__ */ new Date();
998
- if (options.actorId) entry.postedBy = options.actorId;
999
- await entry.save({ session: options.session });
1000
- return entry;
1032
+ const patch = {
1033
+ state: "posted",
1034
+ stateChangedAt: /* @__PURE__ */ new Date()
1035
+ };
1036
+ if (options.actorId) patch.postedBy = options.actorId;
1037
+ const updateOptions = {
1038
+ _ledgerInternal: "post",
1039
+ ...options.session ? { session: options.session } : {}
1040
+ };
1041
+ return await update(entry._id, patch, updateOptions) ?? entry;
1001
1042
  };
1002
1043
  /**
1003
1044
  * Unpost an entry (posted → draft).
@@ -1012,10 +1053,14 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
1012
1053
  if (!entry) throw Errors.notFound("Entry not found");
1013
1054
  if (entry.state !== "posted") throw Errors.validation("Only posted entries can be unposted");
1014
1055
  if (entry.reversed) throw Errors.validation("Cannot unpost a reversed entry. The reversal entry is still posted and linked to this entry. Reverse the reversal entry first, or create a new correcting entry instead.");
1015
- entry.state = "draft";
1016
- entry.stateChangedAt = /* @__PURE__ */ new Date();
1017
- await entry.save({ session: options.session });
1018
- return entry;
1056
+ const updateOptions = {
1057
+ _ledgerInternal: "unpost",
1058
+ ...options.session ? { session: options.session } : {}
1059
+ };
1060
+ return await update(entry._id, {
1061
+ state: "draft",
1062
+ stateChangedAt: /* @__PURE__ */ new Date()
1063
+ }, updateOptions) ?? entry;
1019
1064
  };
1020
1065
  /**
1021
1066
  * Archive a draft entry (draft → archived).
@@ -1028,10 +1073,14 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
1028
1073
  const entry = await findEntry(buildQuery(id, orgId), { session: options.session });
1029
1074
  if (!entry) throw Errors.notFound("Entry not found");
1030
1075
  if (entry.state !== "draft") throw Errors.validation("Only draft entries can be archived");
1031
- entry.state = "archived";
1032
- entry.stateChangedAt = /* @__PURE__ */ new Date();
1033
- await entry.save({ session: options.session });
1034
- return entry;
1076
+ const updateOptions = {
1077
+ _ledgerInternal: "archive",
1078
+ ...options.session ? { session: options.session } : {}
1079
+ };
1080
+ return await update(entry._id, {
1081
+ state: "archived",
1082
+ stateChangedAt: /* @__PURE__ */ new Date()
1083
+ }, updateOptions) ?? entry;
1035
1084
  };
1036
1085
  /**
1037
1086
  * Duplicate an entry as a new draft.
@@ -1061,7 +1110,7 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
1061
1110
  };
1062
1111
  })
1063
1112
  };
1064
- if (orgField && entry[orgField] != null) duplicateData[orgField] = entry[orgField];
1113
+ copyExtraTopLevel(entry, duplicateData);
1065
1114
  return await create(duplicateData, options.session ? { session: options.session } : {});
1066
1115
  };
1067
1116
  /**
@@ -1113,15 +1162,20 @@ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, stric
1113
1162
  reversalOf: entry._id,
1114
1163
  stateChangedAt: /* @__PURE__ */ new Date()
1115
1164
  };
1116
- if (orgField && entry[orgField] != null) reversalData[orgField] = entry[orgField];
1165
+ copyExtraTopLevel(entry, reversalData);
1117
1166
  if (options.actorId) reversalData.postedBy = options.actorId;
1118
1167
  const reversalEntry = await create(reversalData, session ? { session } : {});
1119
- entry.reversed = true;
1120
- entry.reversedBy = reversalEntry._id;
1121
- if (options.actorId) entry.reversedByUser = options.actorId;
1122
- await entry.save({ session });
1168
+ const markPatch = {
1169
+ reversed: true,
1170
+ reversedBy: reversalEntry._id
1171
+ };
1172
+ if (options.actorId) markPatch.reversedByUser = options.actorId;
1173
+ const markOptions = {
1174
+ _ledgerInternal: "reverseMark",
1175
+ ...session ? { session } : {}
1176
+ };
1123
1177
  return {
1124
- original: entry,
1178
+ original: await update(entry._id, markPatch, markOptions) ?? entry,
1125
1179
  reversal: reversalEntry
1126
1180
  };
1127
1181
  };
@@ -1,4 +1,4 @@
1
- import { a as DoubleEntryPluginOptions, c as dateLockPlugin, i as fiscalLockPlugin, n as idempotencyPlugin, o as doubleEntryPlugin, r as FiscalLockPluginOptions, s as DateLockPluginOptions, t as IdempotencyPluginOptions } from "../idempotency.plugin-CK7LHnBn.mjs";
1
+ import { a as DoubleEntryPluginOptions, c as dateLockPlugin, i as fiscalLockPlugin, n as idempotencyPlugin, o as doubleEntryPlugin, r as FiscalLockPluginOptions, s as DateLockPluginOptions, t as IdempotencyPluginOptions } from "../idempotency.plugin-WcQLZU9n.mjs";
2
2
  import { RepositoryInstance } from "@classytic/mongokit";
3
3
 
4
4
  //#region src/utils/tax-hooks.d.ts
@@ -1,4 +1,4 @@
1
- import { i as doubleEntryPlugin, n as idempotencyPlugin, r as fiscalLockPlugin, t as dateLockPlugin } from "../date-lock.plugin-B2Jy0ukX.mjs";
1
+ import { i as doubleEntryPlugin, n as idempotencyPlugin, r as fiscalLockPlugin, t as dateLockPlugin } from "../date-lock.plugin-B6WyvqNG.mjs";
2
2
  //#region src/utils/tax-hooks.ts
3
3
  /**
4
4
  * Apply a tax hook to journal items.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/ledger",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Production-grade double-entry accounting engine for MongoDB — schemas, reports, tax, multi-tenant",
5
5
  "type": "module",
6
6
  "sideEffects": false,