@classytic/ledger 0.2.0 → 0.3.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 (66) hide show
  1. package/README.md +161 -64
  2. package/dist/{account.repository-kDKwDt0I.mjs → account.repository-BpkSd6q3.mjs} +189 -38
  3. package/dist/categories-CclX7Q94.mjs +0 -2
  4. package/dist/core-8Xfnpn6g.d.mts +1 -2
  5. package/dist/country/index.d.mts +1 -1
  6. package/dist/country/index.mjs +0 -2
  7. package/dist/currencies-4WAbFRlw.d.mts +1 -2
  8. package/dist/currencies-W8kQAkm0.mjs +0 -2
  9. package/dist/{idempotency.plugin-v9NQ_ta-.mjs → date-lock.plugin-eYAJ9h_u.mjs} +49 -9
  10. package/dist/{engine-BzBMpWuy.d.mts → engine-Cn-9yerQ.d.mts} +11 -7
  11. package/dist/errors-B7yC-Jfw.mjs +0 -2
  12. package/dist/exports-I5Xkq-9_.mjs +0 -2
  13. package/dist/{fiscal-close-L631E3De.mjs → fiscal-close-B6LhQ10f.mjs} +737 -20
  14. package/dist/fiscal-period.schema-BMnlI9H5.d.mts +103 -0
  15. package/dist/{idempotency.plugin-CPxPt4vX.d.mts → idempotency.plugin-B_CNsInz.d.mts} +19 -17
  16. package/dist/index-BPukb3L8.d.mts +1 -2
  17. package/dist/{index-ZnSiqHYV.d.mts → index-CxZqRaOU.d.mts} +20 -6
  18. package/dist/index.d.mts +248 -26
  19. package/dist/index.mjs +119 -21
  20. package/dist/journals-oH-FK3g8.mjs +0 -2
  21. package/dist/{logger-UbTdBb1x.d.mts → logger-CbHWZl7v.d.mts} +1 -2
  22. package/dist/money.d.mts +1 -2
  23. package/dist/money.mjs +3 -3
  24. package/dist/plugins/index.d.mts +38 -2
  25. package/dist/plugins/index.mjs +57 -2
  26. package/dist/reconciliation.repository-CW4-8q90.d.mts +135 -0
  27. package/dist/{fiscal-period.schema-BQ5wsAq3.mjs → reconciliation.schema-BuetvZTd.mjs} +168 -24
  28. package/dist/reports/index.d.mts +2 -2
  29. package/dist/reports/index.mjs +2 -2
  30. package/dist/repositories/index.d.mts +2 -2
  31. package/dist/repositories/index.mjs +2 -2
  32. package/dist/revaluation-D9x0NE8w.d.mts +530 -0
  33. package/dist/schemas/index.d.mts +71 -2
  34. package/dist/schemas/index.mjs +2 -2
  35. package/dist/tenant-guard-Fm6AID_6.mjs +13 -0
  36. package/docs/reports.md +1 -1
  37. package/package.json +2 -2
  38. package/dist/account.repository-C7gwFLfM.d.mts +0 -29
  39. package/dist/account.repository-C7gwFLfM.d.mts.map +0 -1
  40. package/dist/account.repository-kDKwDt0I.mjs.map +0 -1
  41. package/dist/categories-CclX7Q94.mjs.map +0 -1
  42. package/dist/core-8Xfnpn6g.d.mts.map +0 -1
  43. package/dist/country/index.mjs.map +0 -1
  44. package/dist/currencies-4WAbFRlw.d.mts.map +0 -1
  45. package/dist/currencies-W8kQAkm0.mjs.map +0 -1
  46. package/dist/engine-BzBMpWuy.d.mts.map +0 -1
  47. package/dist/errors-B7yC-Jfw.mjs.map +0 -1
  48. package/dist/exports-I5Xkq-9_.mjs.map +0 -1
  49. package/dist/fiscal-close-L631E3De.mjs.map +0 -1
  50. package/dist/fiscal-close-dNlzB37y.d.mts +0 -270
  51. package/dist/fiscal-close-dNlzB37y.d.mts.map +0 -1
  52. package/dist/fiscal-period.schema-BQ5wsAq3.mjs.map +0 -1
  53. package/dist/fiscal-period.schema-BRdKAjrr.d.mts +0 -38
  54. package/dist/fiscal-period.schema-BRdKAjrr.d.mts.map +0 -1
  55. package/dist/idempotency.plugin-CPxPt4vX.d.mts.map +0 -1
  56. package/dist/idempotency.plugin-v9NQ_ta-.mjs.map +0 -1
  57. package/dist/index-BPukb3L8.d.mts.map +0 -1
  58. package/dist/index-ZnSiqHYV.d.mts.map +0 -1
  59. package/dist/index.d.mts.map +0 -1
  60. package/dist/index.mjs.map +0 -1
  61. package/dist/journals-oH-FK3g8.mjs.map +0 -1
  62. package/dist/logger-UbTdBb1x.d.mts.map +0 -1
  63. package/dist/money.d.mts.map +0 -1
  64. package/dist/money.mjs.map +0 -1
  65. package/dist/session-Ba8E3Ufa.mjs +0 -84
  66. package/dist/session-Ba8E3Ufa.mjs.map +0 -1
package/README.md CHANGED
@@ -1,26 +1,15 @@
1
1
  # @classytic/ledger
2
2
 
3
- Production-grade double-entry accounting engine for MongoDB. Built on [@classytic/mongokit](../mongokit). Designed for multi-tenant SaaS, AI-powered finance, and global tax compliance.
4
-
5
- ## Features
6
-
7
- - **Double-entry bookkeeping** with balance validation and posted-entry protection (optionally immutable via strictness config)
8
- - **Multi-tenant** isolation via configurable org field
9
- - **Country packs** for localized chart of accounts and tax codes
10
- - **Financial reports** — trial balance, balance sheet, income statement, general ledger, cash flow
11
- - **Fiscal period management** — close and reopen with automatic year-end entries, overlap protection
12
- - **CSV export** — QuickBooks-compatible and universal field maps
13
- - **Cents-based Money** arithmetic for precision
14
- - **Plugin system** — fiscal lock, double-entry validation, idempotency (via mongokit hooks)
15
- - **Dimension fields** — custom fields on journal items (departmentId, projectId, etc.) preserved through all workflows
16
- - **Dimension filters** — filter all reports by custom journal item fields
17
- - **Strictness controls** — configurable immutability, actor tracking, and approval requirements
18
- - **Subledger contracts** — typed interfaces for integrating billing, inventory, payroll, and other subledgers
3
+ Embeddable double-entry accounting engine for MongoDB. Integer-cents arithmetic, plugin-based, country-agnostic.
4
+
5
+ Build QuickBooks, Xero, or TaxCycle-grade apps — the engine handles the accounting, you handle the UX.
19
6
 
20
7
  ## Install
21
8
 
22
9
  ```bash
23
10
  npm install @classytic/ledger @classytic/mongokit mongoose
11
+ npm install @classytic/ledger-ca # Canada (GIFI, GST/HST, CRA)
12
+ npm install @classytic/ledger-bd # Bangladesh (BFRS, VAT/TDS, Mushak)
24
13
  ```
25
14
 
26
15
  ## Quick Start
@@ -28,80 +17,188 @@ npm install @classytic/ledger @classytic/mongokit mongoose
28
17
  ```typescript
29
18
  import { createAccountingEngine } from '@classytic/ledger';
30
19
  import { canadaPack } from '@classytic/ledger-ca';
31
- import mongoose from 'mongoose';
32
20
 
33
- // 1. Create engine
34
21
  const accounting = createAccountingEngine({
35
22
  country: canadaPack,
36
23
  currency: 'CAD',
37
- multiTenant: { orgField: 'business', orgRef: 'Business' },
38
- audit: { trackActor: true },
39
- idempotency: true,
40
- strictness: { immutable: true, requireActor: true },
24
+ multiTenant: { orgField: 'organization', orgRef: 'Organization' },
41
25
  });
42
26
 
43
- // 2. Create schemas & models
27
+ // Schemas
44
28
  const Account = mongoose.model('Account', accounting.createAccountSchema());
45
- const JournalEntry = mongoose.model('JournalEntry', accounting.createJournalEntrySchema('Account', {
46
- extraItemFields: {
47
- departmentId: { type: mongoose.Schema.Types.ObjectId, ref: 'Department' },
48
- },
49
- }));
29
+ const JournalEntry = mongoose.model('JournalEntry', accounting.createJournalEntrySchema('Account'));
50
30
  const FiscalPeriod = mongoose.model('FiscalPeriod', accounting.createFiscalPeriodSchema());
51
31
 
52
- // 3. Wire repositories (adds post, reverse, duplicate, unpost, seedAccounts, bulkCreate)
53
- import { createRepository } from '@classytic/mongokit';
32
+ // Reports
33
+ const reports = accounting.createReports({ Account, JournalEntry });
34
+ const bs = await reports.balanceSheet({ organizationId, dateOption: 'year', dateValue: 2025 });
35
+ ```
54
36
 
55
- const journalRepo = accounting.createJournalEntryRepository(
56
- createRepository,
57
- { JournalEntryModel: JournalEntry, AccountModel: Account, FiscalPeriodModel: FiscalPeriod },
58
- );
37
+ ## Core Features
38
+
39
+ **Accounting Engine**
40
+ - Double-entry validation with balance enforcement
41
+ - Integer-cents storage — zero floating-point drift
42
+ - Draft → Posted → Reversed state machine
43
+ - Configurable immutability (corrections only via reversal)
44
+ - Multi-tenant isolation at every layer
45
+ - Country packs for localized charts of accounts and tax codes
46
+
47
+ **10 Reports**
48
+ - Trial Balance (3-column: initial + period + ending)
49
+ - Balance Sheet (with computed retained earnings)
50
+ - Income Statement (revenue, COGS, gross profit, operating expenses, net income)
51
+ - General Ledger (per-account with running balances)
52
+ - Cash Flow (Operating / Investing / Financing)
53
+ - Aged Receivable / Payable (configurable buckets: current, 30, 60, 90+)
54
+ - Budget vs Actual (variance analysis)
55
+ - Dimension Breakdown (by department, project, cost center)
56
+ - Foreign Exchange Revaluation (unrealized gain/loss computation)
57
+ - Fiscal Year Close / Reopen (automatic closing entries)
58
+
59
+ **Plugins**
60
+ - `doubleEntryPlugin` — validates debits = credits, account existence, tenant integrity
61
+ - `fiscalLockPlugin` — prevents posting to closed fiscal periods
62
+ - `dateLockPlugin` — blocks entries before a configurable lock date
63
+ - `taxHookPlugin` — auto-generates tax lines via user-defined `TaxLineGenerator`
64
+ - `idempotencyPlugin` — prevents duplicate entries by key
65
+
66
+ **Utilities**
67
+ - `Money` — cents arithmetic, tax splitting, allocation with zero-sum guarantee
68
+ - `buildDimensionFields` — schema helpers for analytic dimensions
69
+ - `suggestMatches` — reconciliation matching suggestions
70
+ - `computeRevaluation` — FX gain/loss computation
71
+
72
+ ## Engine Configuration
59
73
 
60
- const accountRepo = createRepository(Account, []);
61
- accounting.wireAccountRepository(accountRepo, Account);
74
+ ```typescript
75
+ createAccountingEngine({
76
+ country: canadaPack, // required
77
+ currency: 'CAD', // required — base/functional currency
78
+ multiTenant: { orgField, orgRef }, // optional — multi-tenant scoping
79
+ multiCurrency: { enabled: true, currencies: ['USD', 'EUR'] },
80
+ fiscalYearStartMonth: 1, // 1=Jan (default), 4=Apr, 7=Jul
81
+ retainedEarningsAccountCode: '3600', // overrides country pack
82
+ audit: { trackActor: true },
83
+ idempotency: true,
84
+ strictness: {
85
+ immutable: true, // disable unpost, corrections via reverse only
86
+ requireActor: true, // actorId required on post/reverse
87
+ requireApproval: true // entries must be approved before posting
88
+ },
89
+ });
90
+ ```
62
91
 
63
- // 4. Generate reports (with optional dimension filters)
64
- const reports = accounting.createReports({ Account, JournalEntry });
65
- const bs = await reports.balanceSheet({
66
- organizationId: orgId,
67
- dateOption: 'year',
68
- dateValue: 2025,
69
- filters: { 'journalItems.departmentId': deptId },
92
+ ## Reports API
93
+
94
+ ```typescript
95
+ const reports = accounting.createReports({ Account, JournalEntry, Budget });
96
+
97
+ // All reports accept: { organizationId, dateOption, dateValue, filters? }
98
+ await reports.trialBalance({ ... });
99
+ await reports.balanceSheet({ ... });
100
+ await reports.incomeStatement({ ... });
101
+ await reports.generalLedger({ ... });
102
+ await reports.cashFlow({ ... });
103
+ await reports.agedBalance({ type: 'receivable', asOfDate: new Date() });
104
+ await reports.budgetVsActual({ ... }); // requires Budget model
105
+ await reports.dimensionBreakdown({ dimension: 'departmentId', ... });
106
+ await reports.revaluation({ rates: [{ currency: 'USD', rate: 1.40 }], ... });
107
+ ```
108
+
109
+ All report data is sorted by account code. All monetary values are integer cents — use `Money.toDecimal()` at your API boundary.
110
+
111
+ ## Schemas
112
+
113
+ ```typescript
114
+ accounting.createAccountSchema(options?)
115
+ accounting.createJournalEntrySchema(accountModelName, {
116
+ extraItemFields: { departmentId: { type: ObjectId, ref: 'Department' } },
117
+ })
118
+ accounting.createFiscalPeriodSchema(options?)
119
+ accounting.createBudgetSchema(options?)
120
+ accounting.createReconciliationSchema(accountModelName, journalEntryModelName, options?)
121
+ ```
122
+
123
+ ## Plugins
124
+
125
+ ```typescript
126
+ import { dateLockPlugin, taxHookPlugin } from '@classytic/ledger';
127
+
128
+ // Date lock — block posting before a date
129
+ dateLockPlugin({
130
+ getLockDate: async (orgId) => db.getOrgLockDate(orgId),
131
+ JournalEntryModel,
132
+ });
133
+
134
+ // Tax hook — auto-generate tax lines
135
+ taxHookPlugin({
136
+ generator: {
137
+ generateTaxLines(input) {
138
+ if (!input.taxCode) return [];
139
+ const tax = Money.percentage(input.amount, 1300); // 13%
140
+ return [{ account: hstAccountId, debit: 0, credit: tax, taxDetails: [{ taxCode: 'HST' }] }];
141
+ },
142
+ },
70
143
  });
71
144
  ```
72
145
 
73
146
  ## Subpath Exports
74
147
 
75
- | Import path | Contents |
76
- |---|---|
77
- | `@classytic/ledger` | Engine, Money, schemas, plugins, reports, repositories, constants, types |
78
- | `@classytic/ledger/money` | `Money` class (cents-based arithmetic) |
79
- | `@classytic/ledger/schemas` | `createAccountSchema`, `createJournalEntrySchema`, `createFiscalPeriodSchema` |
80
- | `@classytic/ledger/reports` | Report generators (trial balance, balance sheet, etc.) |
81
- | `@classytic/ledger/plugins` | `doubleEntryPlugin`, `fiscalLockPlugin`, `idempotencyPlugin` |
82
- | `@classytic/ledger/repositories` | `wireJournalEntryMethods`, `wireAccountMethods` |
83
- | `@classytic/ledger/exports` | CSV export: `exportToCsv`, `flattenJournalEntries`, field maps |
84
- | `@classytic/ledger/constants` | Categories, journal types, currencies |
148
+ | Path | Contents |
149
+ |------|----------|
150
+ | `@classytic/ledger` | Engine, Money, all schemas, plugins, reports, types |
151
+ | `@classytic/ledger/money` | `Money` class |
152
+ | `@classytic/ledger/schemas` | Schema factories |
153
+ | `@classytic/ledger/reports` | Report generators |
154
+ | `@classytic/ledger/plugins` | All plugins |
155
+ | `@classytic/ledger/repositories` | Repository wiring |
156
+ | `@classytic/ledger/exports` | CSV export + QuickBooks field maps |
85
157
  | `@classytic/ledger/country` | `defineCountryPack`, `CountryPack` interface |
158
+ | `@classytic/ledger/constants` | Categories, journal types, currencies |
159
+
160
+ ## Country Packs
86
161
 
87
- ## Documentation
162
+ Build your own or use an existing one:
163
+
164
+ ```typescript
165
+ import { defineCountryPack } from '@classytic/ledger';
166
+
167
+ export const myPack = defineCountryPack({
168
+ code: 'US',
169
+ name: 'United States',
170
+ defaultCurrency: 'USD',
171
+ retainedEarningsAccountCode: '3200',
172
+ accountTypes: [ /* your chart of accounts */ ],
173
+ taxCodes: { /* your tax codes */ },
174
+ taxCodesByRegion: {},
175
+ regions: [],
176
+ });
177
+ ```
178
+
179
+ Available packs: `@classytic/ledger-ca` (Canada), `@classytic/ledger-bd` (Bangladesh).
180
+
181
+ ## Testing
182
+
183
+ 949 tests covering unit, integration, and end-to-end scenarios:
184
+
185
+ ```bash
186
+ npm test # run all
187
+ npx vitest run tests/e2e/ # e2e scenarios only
188
+ ```
88
189
 
89
- - [Engine & Configuration](docs/engine.md)
90
- - [Schemas](docs/schemas.md)
91
- - [Repositories](docs/repositories.md)
92
- - [Reports](docs/reports.md)
93
- - [Plugins](docs/plugins.md)
94
- - [Exports](docs/exports.md)
95
- - [Country Packs](docs/country-packs.md)
96
- - [Money](docs/money.md)
97
- - [Subledger Integration](docs/subledger-integration.md)
190
+ E2E suites include:
191
+ - Canadian small business full-year lifecycle
192
+ - Multi-currency trading with FX revaluation
193
+ - All plugins + dimensions + budgets + fiscal close
194
+ - O-Level / A-Level / university textbook accounting problems
98
195
 
99
196
  ## Requirements
100
197
 
101
198
  - Node.js >= 22
102
199
  - MongoDB (replica set recommended for transactions)
103
200
  - Mongoose >= 9
104
- - @classytic/mongokit >= 3.3.2
201
+ - @classytic/mongokit >= 3
105
202
 
106
203
  ## License
107
204
 
@@ -1,5 +1,104 @@
1
1
  import { n as Errors } from "./errors-B7yC-Jfw.mjs";
2
- import { i as requireOrgScope, n as finalizeSession, t as acquireSession } from "./session-Ba8E3Ufa.mjs";
2
+ import { t as requireOrgScope } from "./tenant-guard-Fm6AID_6.mjs";
3
+ //#region src/repositories/reconciliation.repository.ts
4
+ /**
5
+ * Wire reconciliation methods onto an existing mongokit Repository.
6
+ *
7
+ * - reconcile() uses repository.create() so hooks (multi-tenant, audit) fire
8
+ * - unreconcile() uses repository.delete() so hooks fire
9
+ * - Cross-repo reads (JournalEntryModel) use direct Model access (acceptable)
10
+ */
11
+ function wireReconciliationMethods(repository, _ReconciliationModel, JournalEntryModel, orgField) {
12
+ const create = repository.create.bind(repository);
13
+ const deleteById = repository.delete.bind(repository);
14
+ /**
15
+ * Create a reconciliation record linking matched journal entries.
16
+ * Validates that all entries exist, are posted, and belong to the same account/org.
17
+ */
18
+ repository.reconcile = async function(input) {
19
+ const { account, journalEntryIds, note, reconciledBy, organizationId } = input;
20
+ requireOrgScope(orgField, organizationId);
21
+ if (!journalEntryIds || journalEntryIds.length === 0) throw Errors.validation("journalEntryIds must contain at least one entry.");
22
+ const query = { _id: { $in: journalEntryIds } };
23
+ if (orgField && organizationId != null) query[orgField] = organizationId;
24
+ const entries = await JournalEntryModel.find(query).lean();
25
+ if (entries.length !== journalEntryIds.length) throw Errors.notFound(`Expected ${journalEntryIds.length} entries but found ${entries.length}. Some entries do not exist or belong to a different organization.`);
26
+ const notPosted = entries.filter((e) => e.state !== "posted");
27
+ if (notPosted.length > 0) throw Errors.validation(`${notPosted.length} entry(ies) are not posted. Only posted entries can be reconciled.`);
28
+ const accountStr = String(account);
29
+ for (const entry of entries) if (!entry.journalItems.some((item) => String(item.account) === accountStr)) throw Errors.validation(`Entry ${entry._id} does not contain any items for account ${account}.`);
30
+ let debitTotal = 0;
31
+ let creditTotal = 0;
32
+ for (const entry of entries) for (const item of entry.journalItems) if (String(item.account) === accountStr) {
33
+ debitTotal += item.debit ?? 0;
34
+ creditTotal += item.credit ?? 0;
35
+ }
36
+ const reconciliationData = {
37
+ account,
38
+ journalEntryIds,
39
+ debitTotal,
40
+ creditTotal,
41
+ difference: debitTotal - creditTotal,
42
+ note,
43
+ reconciledBy,
44
+ reconciledAt: /* @__PURE__ */ new Date()
45
+ };
46
+ if (orgField && organizationId != null) reconciliationData[orgField] = organizationId;
47
+ return await create(reconciliationData);
48
+ };
49
+ /**
50
+ * Remove a reconciliation record via repository.delete().
51
+ */
52
+ repository.unreconcile = async function(input) {
53
+ const { reconciliationId, organizationId } = input;
54
+ requireOrgScope(orgField, organizationId);
55
+ if (orgField && organizationId != null) {
56
+ if (!await repository._executeQuery(async (Model) => Model.findOne({
57
+ _id: reconciliationId,
58
+ [orgField]: organizationId
59
+ }).select("_id").lean())) throw Errors.notFound("Reconciliation record not found.");
60
+ }
61
+ const result = await deleteById(String(reconciliationId));
62
+ if (!result) throw Errors.notFound("Reconciliation record not found.");
63
+ return result;
64
+ };
65
+ /**
66
+ * Find journal entries for an account that are NOT in any reconciliation record.
67
+ * Uses repository.getAll() for reconciliation lookups (hooks fire),
68
+ * and direct JournalEntryModel for cross-repo reads (acceptable).
69
+ */
70
+ repository.getUnreconciled = async function(input) {
71
+ const { accountId, organizationId, limit = 100, skip = 0 } = input;
72
+ requireOrgScope(orgField, organizationId);
73
+ const reconFilter = { account: accountId };
74
+ if (orgField && organizationId != null) reconFilter[orgField] = organizationId;
75
+ const reconciliations = await repository._executeQuery(async (Model) => Model.find(reconFilter).select("journalEntryIds").lean());
76
+ const reconciledIds = /* @__PURE__ */ new Set();
77
+ for (const rec of reconciliations) for (const id of rec.journalEntryIds) reconciledIds.add(String(id));
78
+ const entryFilter = {
79
+ state: "posted",
80
+ "journalItems.account": accountId
81
+ };
82
+ if (orgField && organizationId != null) entryFilter[orgField] = organizationId;
83
+ if (reconciledIds.size > 0) entryFilter._id = { $nin: Array.from(reconciledIds) };
84
+ return await JournalEntryModel.find(entryFilter).sort({ date: -1 }).skip(skip).limit(limit).lean();
85
+ };
86
+ if (typeof repository.registerMethod === "function") for (const name of [
87
+ "reconcile",
88
+ "unreconcile",
89
+ "getUnreconciled"
90
+ ]) {
91
+ const fn = repository[name];
92
+ try {
93
+ delete repository[name];
94
+ repository.registerMethod(name, fn);
95
+ } catch {
96
+ repository[name] = fn;
97
+ }
98
+ }
99
+ return repository;
100
+ }
101
+ //#endregion
3
102
  //#region src/repositories/journal-entry.repository.ts
4
103
  /** Keys that are either handled explicitly or must not be copied */
5
104
  const ITEM_CORE_KEYS = new Set([
@@ -15,12 +114,40 @@ const ITEM_CORE_KEYS = new Set([
15
114
  /**
16
115
  * Wire post/reverse onto an existing mongokit Repository.
17
116
  *
117
+ * All reads use `repository.getByQuery()` so registered plugins
118
+ * (multi-tenant, audit, cache) fire on every operation.
119
+ *
18
120
  * @param repository - A mongokit Repository instance (already created)
19
- * @param JournalEntryModel - The Mongoose model for journal entries
121
+ * @param _JournalEntryModel - (Deprecated) The Mongoose model — no longer used internally; kept for API compat
20
122
  * @param orgField - The multi-tenant field name (e.g. 'business')
21
123
  * @param strictness - Strictness rules (immutable, requireActor, requireApproval)
22
124
  */
23
- function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strictness) {
125
+ function wireJournalEntryMethods(repository, _JournalEntryModel, orgField, strictness) {
126
+ const getByQuery = repository.getByQuery.bind(repository);
127
+ const create = repository.create.bind(repository);
128
+ const withTransaction = repository.withTransaction.bind(repository);
129
+ /** Build a tenant-scoped query for a single entry by ID (injection-safe) */
130
+ function buildQuery(id, orgId) {
131
+ validateScalarId(id, "entry ID");
132
+ if (orgId != null) validateScalarId(orgId, "organization ID");
133
+ const query = { _id: id };
134
+ if (orgField && orgId != null) query[orgField] = orgId;
135
+ return query;
136
+ }
137
+ /** Reject operator-injected objects like { $ne: null } but allow ObjectIds */
138
+ function validateScalarId(value, label) {
139
+ if (value == null || typeof value !== "object") return;
140
+ const obj = value;
141
+ if (typeof obj.toHexString === "function" || obj._bsontype === "ObjectId") return;
142
+ if (Object.keys(obj).some((k) => k.startsWith("$"))) throw Errors.validation(`Invalid ${label} — MongoDB operators are not allowed.`);
143
+ }
144
+ /** Fetch an entry via the repository (fires all hooks) */
145
+ async function findEntry(query, options) {
146
+ const opts = { lean: false };
147
+ if (options.populate) opts.populate = options.populate;
148
+ if (options.session) opts.session = options.session;
149
+ return await getByQuery(query, opts);
150
+ }
24
151
  /**
25
152
  * Post an entry (draft → posted).
26
153
  * Validates items, balance, and accounts before changing state.
@@ -28,9 +155,10 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
28
155
  repository.post = async function(id, orgId, options = {}) {
29
156
  if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for post operations.");
30
157
  requireOrgScope(orgField, orgId);
31
- const query = { _id: id };
32
- if (orgField && orgId != null) query[orgField] = orgId;
33
- const entry = await JournalEntryModel.findOne(query).populate("journalItems.account").session(options.session || null);
158
+ const entry = await findEntry(buildQuery(id, orgId), {
159
+ session: options.session,
160
+ populate: "journalItems.account"
161
+ });
34
162
  if (!entry) throw Errors.notFound("Entry not found");
35
163
  if (entry.idempotencyKey && entry.state === "posted") return entry;
36
164
  if (entry.state !== "draft") throw Errors.validation("Only draft entries can be posted");
@@ -40,6 +168,14 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
40
168
  if (!entry.journalItems || entry.journalItems.length < 2) throw Errors.validation("Journal entry must have at least 2 items to post");
41
169
  const missing = entry.journalItems.filter((i) => !i.account || i.account === "");
42
170
  if (missing.length > 0) throw Errors.validation(`${missing.length} item(s) missing an account`);
171
+ const nullAccounts = entry.journalItems.filter((i) => {
172
+ const acct = i.account;
173
+ if (!acct) return true;
174
+ if (typeof acct === "string") return true;
175
+ if (typeof acct === "object" && !acct._id) return true;
176
+ return false;
177
+ });
178
+ if (nullAccounts.length > 0) throw Errors.validation(`${nullAccounts.length} item(s) reference accounts that do not exist. Ensure all accounts are created before posting.`);
43
179
  if (orgField && orgId != null) {
44
180
  const crossTenant = entry.journalItems.filter((i) => {
45
181
  const acct = i.account;
@@ -70,17 +206,12 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
70
206
  if (strictness?.immutable) throw Errors.immutable("Unpost is disabled in strict mode. Use reverse() to correct posted entries.");
71
207
  if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for unpost operations.");
72
208
  requireOrgScope(orgField, orgId);
73
- const query = { _id: id };
74
- if (orgField && orgId != null) query[orgField] = orgId;
75
- const entry = await JournalEntryModel.findOne(query).session(options.session || null);
209
+ const entry = await findEntry(buildQuery(id, orgId), { session: options.session });
76
210
  if (!entry) throw Errors.notFound("Entry not found");
77
211
  if (entry.state !== "posted") throw Errors.validation("Only posted entries can be unposted");
212
+ 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.");
78
213
  entry.state = "draft";
79
214
  entry.stateChangedAt = /* @__PURE__ */ new Date();
80
- if (entry.reversed) {
81
- entry.reversed = false;
82
- entry.reversedBy = void 0;
83
- }
84
215
  await entry.save({ session: options.session });
85
216
  return entry;
86
217
  };
@@ -92,9 +223,7 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
92
223
  repository.archive = async function(id, orgId, options = {}) {
93
224
  if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for archive operations.");
94
225
  requireOrgScope(orgField, orgId);
95
- const query = { _id: id };
96
- if (orgField && orgId != null) query[orgField] = orgId;
97
- const entry = await JournalEntryModel.findOne(query).session(options.session || null);
226
+ const entry = await findEntry(buildQuery(id, orgId), { session: options.session });
98
227
  if (!entry) throw Errors.notFound("Entry not found");
99
228
  if (entry.state !== "draft") throw Errors.validation("Only draft entries can be archived");
100
229
  entry.state = "archived";
@@ -108,9 +237,7 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
108
237
  */
109
238
  repository.duplicate = async function(id, orgId, options = {}) {
110
239
  requireOrgScope(orgField, orgId);
111
- const query = { _id: id };
112
- if (orgField && orgId != null) query[orgField] = orgId;
113
- const entry = await JournalEntryModel.findOne(query).session(options.session || null);
240
+ const entry = await findEntry(buildQuery(id, orgId), { session: options.session });
114
241
  if (!entry) throw Errors.notFound("Entry not found");
115
242
  const duplicateData = {
116
243
  journalType: entry.journalType,
@@ -133,15 +260,14 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
133
260
  })
134
261
  };
135
262
  if (orgField && entry[orgField] != null) duplicateData[orgField] = entry[orgField];
136
- return await repository.create(duplicateData, options.session ? { session: options.session } : {});
263
+ return await create(duplicateData, options.session ? { session: options.session } : {});
137
264
  };
138
265
  /**
139
266
  * Reverse a posted entry by creating a mirror entry with flipped debits/credits.
140
267
  * Marks the original as reversed and links both entries bidirectionally.
141
268
  *
142
- * Atomic: creates an internal transaction by default. Pass an external session
143
- * to join a caller-managed transaction instead. On standalone MongoDB (no
144
- * replica set), falls back to non-atomic execution with a warning.
269
+ * Uses repository.withTransaction() for automatic retry on transient failures.
270
+ * Pass an external session to join a caller-managed transaction instead.
145
271
  *
146
272
  * Routes the reversal through repository.create() so all plugins (fiscal-lock,
147
273
  * double-entry) enforce policy on the reversal entry.
@@ -149,12 +275,12 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
149
275
  repository.reverse = async function(id, orgId, options = {}) {
150
276
  if (strictness?.requireActor && !options.actorId) throw Errors.validation("actorId is required for reverse operations.");
151
277
  requireOrgScope(orgField, orgId);
152
- const { session, ownSession } = await acquireSession(JournalEntryModel.db, options.session);
153
- let success = false;
154
- try {
155
- const query = { _id: id };
156
- if (orgField && orgId != null) query[orgField] = orgId;
157
- const entry = await JournalEntryModel.findOne(query).populate("journalItems.account").session(session || null);
278
+ const query = buildQuery(id, orgId);
279
+ const doReverse = async (session) => {
280
+ const entry = await findEntry(query, {
281
+ session,
282
+ populate: "journalItems.account"
283
+ });
158
284
  if (!entry) throw Errors.notFound("Entry not found");
159
285
  if (entry.state !== "posted") throw Errors.validation("Only posted entries can be reversed");
160
286
  if (entry.reversed) throw Errors.validation("Entry has already been reversed");
@@ -187,20 +313,37 @@ function wireJournalEntryMethods(repository, JournalEntryModel, orgField, strict
187
313
  };
188
314
  if (orgField && entry[orgField] != null) reversalData[orgField] = entry[orgField];
189
315
  if (options.actorId) reversalData.postedBy = options.actorId;
190
- const reversalEntry = await repository.create(reversalData, session ? { session } : {});
316
+ const reversalEntry = await create(reversalData, session ? { session } : {});
191
317
  entry.reversed = true;
192
- entry.reversedBy = reversalEntry._id;
318
+ entry.reversedBy = reversalEntry["_id"];
193
319
  if (options.actorId) entry.reversedByUser = options.actorId;
194
320
  await entry.save({ session });
195
- success = true;
196
321
  return {
197
322
  original: entry,
198
323
  reversal: reversalEntry
199
324
  };
200
- } finally {
201
- await finalizeSession(session, ownSession, success);
202
- }
325
+ };
326
+ if (options.session) return await doReverse(options.session);
327
+ if (withTransaction) return await withTransaction((session) => doReverse(session), { allowFallback: true });
328
+ return await doReverse();
203
329
  };
330
+ const methodNames = [
331
+ "post",
332
+ "unpost",
333
+ "archive",
334
+ "duplicate",
335
+ "reverse"
336
+ ];
337
+ if (typeof repository.registerMethod === "function") for (const name of methodNames) {
338
+ const fn = repository[name];
339
+ try {
340
+ delete repository[name];
341
+ repository.registerMethod(name, fn);
342
+ } catch {
343
+ repository[name] = fn;
344
+ }
345
+ }
346
+ return repository;
204
347
  }
205
348
  //#endregion
206
349
  //#region src/repositories/account.repository.ts
@@ -384,8 +527,16 @@ function wireAccountMethods(repository, AccountModel, country, orgField) {
384
527
  ...results
385
528
  };
386
529
  };
530
+ if (typeof repository.registerMethod === "function") for (const name of ["seedAccounts", "bulkCreate"]) {
531
+ const fn = repository[name];
532
+ try {
533
+ delete repository[name];
534
+ repository.registerMethod(name, fn);
535
+ } catch {
536
+ repository[name] = fn;
537
+ }
538
+ }
539
+ return repository;
387
540
  }
388
541
  //#endregion
389
- export { wireJournalEntryMethods as n, wireAccountMethods as t };
390
-
391
- //# sourceMappingURL=account.repository-kDKwDt0I.mjs.map
542
+ export { wireJournalEntryMethods as n, wireReconciliationMethods as r, wireAccountMethods as t };
@@ -66,5 +66,3 @@ function extractStatementType(key) {
66
66
  }
67
67
  //#endregion
68
68
  export { extractStatementType as a, getNormalBalance as c, isValidCategory as d, extractMainType as i, isBalanceSheet as l, CATEGORY_KEYS as n, getCategoryMainType as o, categoryKey as r, getCategoryStatementType as s, CATEGORIES as t, isIncomeStatement as u };
69
-
70
- //# sourceMappingURL=categories-CclX7Q94.mjs.map
@@ -100,5 +100,4 @@ interface DateRange {
100
100
  endDate: Date;
101
101
  }
102
102
  //#endregion
103
- export { TaxMetadata as _, Cents as a, DateRange as c, JournalType as d, MainType as f, TaxDetail as g, StatementType as h, CategoryKey as i, EntryState as l, ObjectId as m, CashFlowCategory as n, Currency as o, NormalBalance as p, Category as r, DateOption as s, AccountType as t, JournalItem as u, TotalAccountOp as v };
104
- //# sourceMappingURL=core-8Xfnpn6g.d.mts.map
103
+ export { TaxMetadata as _, Cents as a, DateRange as c, JournalType as d, MainType as f, TaxDetail as g, StatementType as h, CategoryKey as i, EntryState as l, ObjectId as m, CashFlowCategory as n, Currency as o, NormalBalance as p, Category as r, DateOption as s, AccountType as t, JournalItem as u, TotalAccountOp as v };
@@ -1,2 +1,2 @@
1
- import { a as TaxReportLine, i as TaxCodesByRegion, n as CountryPackInput, o as TaxReportTemplate, r as TaxCode, s as defineCountryPack, t as CountryPack } from "../index-ZnSiqHYV.mjs";
1
+ import { a as TaxReportLine, i as TaxCodesByRegion, n as CountryPackInput, o as TaxReportTemplate, r as TaxCode, s as defineCountryPack, t as CountryPack } from "../index-CxZqRaOU.mjs";
2
2
  export { CountryPack, CountryPackInput, TaxCode, TaxCodesByRegion, TaxReportLine, TaxReportTemplate, defineCountryPack };
@@ -23,5 +23,3 @@ function defineCountryPack(input) {
23
23
  }
24
24
  //#endregion
25
25
  export { defineCountryPack };
26
-
27
- //# sourceMappingURL=index.mjs.map
@@ -34,5 +34,4 @@ declare function getCurrency(code: string): Currency | null;
34
34
  declare function isValidCurrency(code: string): boolean;
35
35
  declare function getMinorUnit(code: string): number;
36
36
  //#endregion
37
- export { getNormalBalance as _, JOURNAL_CODES as a, isValidCategory as b, getJournalTypeCodes as c, CATEGORY_KEYS as d, categoryKey as f, getCategoryStatementType as g, getCategoryMainType as h, isValidCurrency as i, isValidJournalType as l, extractStatementType as m, getCurrency as n, JOURNAL_TYPES as o, extractMainType as p, getMinorUnit as r, getJournalType as s, CURRENCIES as t, CATEGORIES as u, isBalanceSheet as v, isIncomeStatement as y };
38
- //# sourceMappingURL=currencies-4WAbFRlw.d.mts.map
37
+ export { getNormalBalance as _, JOURNAL_CODES as a, isValidCategory as b, getJournalTypeCodes as c, CATEGORY_KEYS as d, categoryKey as f, getCategoryStatementType as g, getCategoryMainType as h, isValidCurrency as i, isValidJournalType as l, extractStatementType as m, getCurrency as n, JOURNAL_TYPES as o, extractMainType as p, getMinorUnit as r, getJournalType as s, CURRENCIES as t, CATEGORIES as u, isBalanceSheet as v, isIncomeStatement as y };
@@ -78,5 +78,3 @@ function getMinorUnit(code) {
78
78
  }
79
79
  //#endregion
80
80
  export { isValidCurrency as i, getCurrency as n, getMinorUnit as r, CURRENCIES as t };
81
-
82
- //# sourceMappingURL=currencies-W8kQAkm0.mjs.map