@classytic/ledger 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +221 -115
  2. package/dist/bridges/index.d.mts +2 -0
  3. package/dist/bridges/index.mjs +1 -0
  4. package/dist/constants/index.d.mts +1 -1
  5. package/dist/constants/index.mjs +2 -2
  6. package/dist/country/index.d.mts +1 -1
  7. package/dist/errors-BI5k4iak.mjs +121 -0
  8. package/dist/events/index.d.mts +2 -0
  9. package/dist/events/index.mjs +2 -0
  10. package/dist/exports/index.d.mts +1 -1
  11. package/dist/exports/index.mjs +1 -1
  12. package/dist/{fx-realization.plugin-CfYy1tB6.mjs → fx-realization.plugin-Bxlb8cIx.mjs} +45 -2
  13. package/dist/{index-BX8miYdu.d.mts → index-08IpHhrU.d.mts} +12 -1
  14. package/dist/{index-Bl0_ak5w.d.mts → index-Db0n_6Z8.d.mts} +1 -1
  15. package/dist/index-dqkjgpII.d.mts +104 -0
  16. package/dist/index.d.mts +344 -65
  17. package/dist/index.mjs +539 -110
  18. package/dist/{journals-C50E9mpo.d.mts → journals-DUpWwFt1.d.mts} +1 -1
  19. package/dist/opening-balance-1cixYh6Y.mjs +60 -0
  20. package/dist/outbox-store-DQbL-KYT.mjs +132 -0
  21. package/dist/outbox-store-UYC4eZpI.d.mts +249 -0
  22. package/dist/{partner-ledger-D9H5hegI.mjs → partner-ledger-BoebloHk.mjs} +2 -2
  23. package/dist/plugins/index.d.mts +1 -1
  24. package/dist/plugins/index.mjs +1 -1
  25. package/dist/reports/index.d.mts +1 -1
  26. package/dist/reports/index.mjs +1 -1
  27. package/dist/sync/index.d.mts +313 -0
  28. package/dist/sync/index.mjs +527 -0
  29. package/dist/sync-JvchM3FO.d.mts +152 -0
  30. package/dist/{trial-balance-DTc8kzTD.d.mts → trial-balance-DyNm5bFu.d.mts} +2 -2
  31. package/docs/country-packs.md +71 -47
  32. package/docs/engine.md +3 -2
  33. package/docs/subledger-integration.md +29 -8
  34. package/docs/sync.md +330 -0
  35. package/package.json +36 -14
  36. package/dist/errors-CSDQPNyt.mjs +0 -33
  37. /package/dist/{categories-BkKdv16V.mjs → categories-FJlrvzcl.mjs} +0 -0
  38. /package/dist/{core-BkGjuVZj.d.mts → core-DwjkrRkJ.d.mts} +0 -0
  39. /package/dist/{currencies-CsuBGfgs.mjs → currencies-Jo5oaM_4.mjs} +0 -0
  40. /package/dist/{exports-BP-0Ni5W.mjs → exports-C30yRapf.mjs} +0 -0
  41. /package/dist/{index-D1ZjgVxn.d.mts → index-J-XIbXH-.d.mts} +0 -0
package/README.md CHANGED
@@ -2,14 +2,14 @@
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).
5
+ > **0.7.0 (BREAKING)** — `@classytic/ledger` is now a **pure double-entry accounting engine**. Tax computation, return templates, repartition, and exigibility have been removed from the core and country-pack contracts and now live in dedicated tax packages (`@classytic/bd-tax` is the existing reference; `@classytic/ca-tax` will follow). Country packs `@classytic/ledger-bd@0.2.0` and `@classytic/ledger-ca@0.2.0` ship the chart of accounts + journal templates only they re-export the raw tax data tables as named constants for tax engines to lift. The 0.6.x A/P + A/R primitives (item-level matching, partner ledger, credit limit, FX realization, journal resource, open-item queries) are unchanged. See [CHANGELOG.md](CHANGELOG.md).
6
6
 
7
7
  ## Install
8
8
 
9
9
  ```bash
10
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)
11
+ npm install @classytic/ledger-ca # Canada (GIFI chart of accounts)
12
+ npm install @classytic/ledger-bd # Bangladesh (BFRS chart of accounts)
13
13
  ```
14
14
 
15
15
  ## Quick Start
@@ -30,10 +30,9 @@ await engine.repositories.accounts.seedAccounts(orgId);
30
30
 
31
31
  await engine.record.sale(orgId, {
32
32
  date: new Date("2025-04-01"),
33
- amount: 10000, // $100.00 in cents (tax-exclusive)
33
+ amount: 11300, // $113.00 in cents (caller pre-computes any tax)
34
34
  receivableAccount: "1200", // AR
35
35
  revenueAccount: "4010", // Service Revenue
36
- tax: { code: "HST", account: "2300" },
37
36
  label: "INV-001",
38
37
  });
39
38
 
@@ -48,32 +47,29 @@ The engine owns the models. After `createAccountingEngine` you have:
48
47
 
49
48
  | Property | What it gives you |
50
49
  | --- | --- |
51
- | `engine.models.{Account,JournalEntry,FiscalPeriod,Budget,Reconciliation}` | Mongoose models |
50
+ | `engine.models.{Account,JournalEntry,FiscalPeriod,Budget,Reconciliation,Journal}` | Mongoose models |
52
51
  | `engine.repositories.accounts` | `seedAccounts()`, `bulkCreate()` + plugins |
53
52
  | `engine.repositories.journalEntries` | `post()`, `unpost()`, `reverse()`, `duplicate()` + double-entry, fiscal-lock, idempotency |
53
+ | `engine.repositories.journals` | First-class posting channels — `seedDefaults()`, `nextSequenceNumber()` |
54
+ | `engine.repositories.reconciliations` | Item-level matching — `match()`, `unmatch()`, `getOpenItems()` |
54
55
  | `engine.repositories.{fiscalPeriods,budgets}` | Plain CRUD |
55
- | `engine.repositories.reconciliations` | `reconcile()`, `unreconcile()`, `getUnreconciled()` |
56
56
  | `engine.record.*` | Domain verbs (`sale`, `expense`, `transfer`, `payment`, `adjustment`) |
57
- | `engine.introspect.*` | Runtime catalog of accounts, tax codes, reports |
58
- | `engine.reports.*` | All 10 reports, bound to owned models |
57
+ | `engine.introspect.*` | Runtime catalog of accounts, journal types, reports, fiscal periods |
58
+ | `engine.reports.*` | All 12 reports, bound to owned models |
59
59
 
60
60
  ## Semantic Record API
61
61
 
62
- Record business operations as domain verbs. The engine resolves account codes, splits tax, and produces a balanced journal entry — you never touch debits/credits.
62
+ Record business operations as domain verbs. The engine resolves account codes and produces a balanced journal entry — you never touch debits/credits.
63
63
 
64
64
  ```ts
65
- // Cash sale with 13% HST
66
65
  await engine.record.sale(orgId, {
67
66
  date, amount: 10000,
68
67
  receivableAccount: "1001", revenueAccount: "4010",
69
- tax: { code: "HST", account: "2300" },
70
68
  });
71
69
 
72
- // Expense with recoverable input tax credit
73
70
  await engine.record.expense(orgId, {
74
71
  date, amount: 3000,
75
72
  expenseAccount: "6010", paidFromAccount: "2001",
76
- tax: { code: "HST_ITC", account: "2400" },
77
73
  });
78
74
 
79
75
  await engine.record.transfer(orgId, { date, amount: 5000, fromAccount: "1001", toAccount: "1002" });
@@ -93,16 +89,97 @@ await engine.record.adjustment(orgId, {
93
89
  });
94
90
  ```
95
91
 
92
+ > **Tax lines:** the semantic verbs are tax-agnostic in 0.7+. Compute VAT/GST/HST via your tax engine of choice (`@classytic/bd-tax`, the forthcoming `@classytic/ca-tax`, or your own) and either pre-add the tax to `amount` and post the tax line via `record.adjustment`, or post the full entry directly via `engine.repositories.journalEntries.create()`.
93
+
96
94
  All verbs accept `options.user`, `options.session`, `options.idempotencyKey`, plus any custom field — they all flow into mongokit's `RepositoryContext` so audit/observability plugins (and your hooks) pick them up automatically.
97
95
 
96
+ ## Accounts Payable & Receivable
97
+
98
+ The 0.6.x A/P + A/R primitives are the foundation for any ERP workflow on top of the ledger.
99
+
100
+ ```ts
101
+ // Tag every journal item with a partnerId via extraItemFields (one-time setup)
102
+ const engine = createAccountingEngine({
103
+ // ...
104
+ schemaOptions: {
105
+ journalEntry: {
106
+ extraItemFields: {
107
+ partnerId: { type: String, index: true },
108
+ },
109
+ },
110
+ },
111
+ });
112
+
113
+ // Post a credit sale on 30-day terms
114
+ const invoice = await engine.repositories.journalEntries.create({
115
+ state: "posted",
116
+ date: new Date("2026-01-15"),
117
+ journalItems: [
118
+ { account: arId, debit: 100_000, partnerId: "wholesale-1", maturityDate: new Date("2026-02-14") },
119
+ { account: revenueId, credit: 100_000 },
120
+ ],
121
+ });
122
+
123
+ // Customer pays $400 of the $1000 invoice
124
+ const payment = await engine.repositories.journalEntries.create({
125
+ state: "posted",
126
+ date: new Date("2026-01-25"),
127
+ journalItems: [
128
+ { account: cashId, debit: 40_000 },
129
+ { account: arId, credit: 40_000, partnerId: "wholesale-1" },
130
+ ],
131
+ });
132
+
133
+ // Match the AR sides — partial settlement
134
+ await engine.repositories.reconciliations.match({
135
+ account: arId,
136
+ items: [
137
+ { entry: invoice._id, itemIndex: 0 },
138
+ { entry: payment._id, itemIndex: 1 },
139
+ ],
140
+ });
141
+
142
+ // Open items for this partner (subsidiary ledger)
143
+ await engine.repositories.reconciliations.getOpenItems({
144
+ accountId: arId,
145
+ filter: { partnerId: "wholesale-1" },
146
+ });
147
+
148
+ // Customer statement with running balance + aged buckets
149
+ import { generatePartnerLedger } from "@classytic/ledger";
150
+ await generatePartnerLedger(
151
+ { AccountModel: engine.models.Account, JournalEntryModel: engine.models.JournalEntry },
152
+ {
153
+ controlAccountId: arId,
154
+ partnerId: "wholesale-1",
155
+ startDate: new Date("2026-01-01"),
156
+ endDate: new Date("2026-03-31"),
157
+ },
158
+ );
159
+
160
+ // Cross-partner aged A/R buckets
161
+ import { generateAgedBalance } from "@classytic/ledger";
162
+ await generateAgedBalance(
163
+ { AccountModel, JournalEntryModel, country: canadaPack },
164
+ { type: "receivable", contactField: "journalItems.partnerId" },
165
+ );
166
+
167
+ // Enforce per-customer credit limits
168
+ import { creditLimitPlugin } from "@classytic/ledger/plugins";
169
+ creditLimitPlugin({
170
+ arControlAccountId: arId,
171
+ JournalEntryModel: engine.models.JournalEntry,
172
+ getCreditLimit: async (partnerId) => Customer.findById(partnerId).then(c => c?.creditLimit ?? null),
173
+ }).apply(engine.repositories.journalEntries);
174
+ ```
175
+
98
176
  ## Introspection
99
177
 
100
178
  ```ts
101
179
  const catalog = await engine.introspect.catalog(orgId);
102
- // { accounts, journalTypes, reports, taxCodes, fiscalPeriods }
180
+ // { accounts, journalTypes, reports, fiscalPeriods }
103
181
 
104
182
  engine.introspect.accounts(orgId);
105
- engine.introspect.taxCodes("ON");
106
183
  engine.introspect.reports(); // sync — static catalog
107
184
  ```
108
185
 
@@ -113,124 +190,74 @@ try {
113
190
  await engine.record.sale(orgId, { ... });
114
191
  } catch (err) {
115
192
  if (err instanceof AccountingError) {
116
- err.status // 400 | 403 | 404 | 409
117
- err.code // 'VALIDATION_ERROR' | 'NOT_FOUND' | ...
193
+ err.status // 400 | 402 | 403 | 404 | 409
194
+ err.code // 'VALIDATION_ERROR' | 'NOT_FOUND' | 'CREDIT_LIMIT_EXCEEDED' | 'PERIOD_LOCKED_FISCAL' | ...
118
195
  err.fields // [{ path, issue, value }, ...]
119
196
  err.toJSON();
120
197
  }
121
198
  }
122
199
  ```
123
200
 
124
- Field errors come straight from plugins (double-entry, fiscal-lock) and the semantic layer.
125
-
126
201
  ## Audit, Observability & Framework Integration
127
202
 
128
- The ledger is **framework-agnostic** and operates at the model layer Express, Fastify, Nest, Hono, and Arc all work the same way. Three places you can hook in, composable:
129
-
130
- 1. **Any mongokit plugin** via `config.plugins` — see the `@classytic/mongokit` docs for `auditTrailPlugin`, `observabilityPlugin`, and others.
131
- ```ts
132
- const engine = createAccountingEngine({
133
- mongoose: mongoose.connection, country: canadaPack, currency: "CAD",
134
- plugins: {
135
- journalEntry: [/* your mongokit plugins */],
136
- account: [/* your mongokit plugins */],
137
- },
138
- });
139
- ```
140
- 2. **Runtime listeners** — no plugin needed:
141
- ```ts
142
- engine.repositories.journalEntries.on("after:create", ({ context, result }) => {
143
- auditLog.write({ userId: context.user?._id, orgId: context.organizationId, entryId: result._id });
144
- });
145
- ```
146
- 3. **Your framework's own audit (HTTP-level)** — e.g. `@classytic/arc`'s `auditPlugin` records resource CRUD with request context. Use it alone, or combine with a model-layer plugin.
147
-
148
- | Layer | What it sees | What it misses |
149
- | --- | --- | --- |
150
- | HTTP middleware (Arc / Express / Nest) | Request → user, IP, route, payload | Background jobs, CLI scripts, anything bypassing HTTP |
151
- | Model-layer mongokit plugin | Every collection write, regardless of caller | HTTP context unless the caller forwards it |
152
-
153
- For accounting compliance most teams want **both** — HTTP audit for traffic, model audit on `journalEntry` for an immutable trail. Forward request context on the call so both layers see it:
203
+ Every operation flows through mongokit's `RepositoryContext`. Custom plugins can hook `before:create` / `after:create` / `before:update` / `after:update` / `after:match` to add audit trails, metrics, webhooks, or business rules none of it is hardcoded into the core.
154
204
 
155
- ```ts
156
- // Express
157
- app.post("/sales", async (req, res) => {
158
- await engine.record.sale(req.body.orgId, req.body, {
159
- user: req.user, ip: req.ip, userAgent: req.headers["user-agent"],
160
- });
161
- });
162
- ```
205
+ The `_ledgerInternal` context flag (`'post' | 'unpost' | 'archive' | 'reverseMark' | 'fxRealize'`) tells plugins which engine operation is in flight, so guards (locks, credit limit, immutability) can exempt legitimate engine writes without affecting consumer code.
163
206
 
164
207
  ## Reports
165
208
 
166
- ```ts
167
- await engine.reports.trialBalance({ organizationId, dateOption: "year", dateValue: 2025 });
168
- await engine.reports.balanceSheet({ organizationId, dateOption: "year", dateValue: 2025 });
169
- await engine.reports.incomeStatement({ organizationId, dateOption: "quarter", dateValue: { year: 2025, quarter: 2 } });
170
- await engine.reports.generalLedger({ organizationId, dateOption: "month", dateValue: { year: 2025, month: 4 } });
171
- await engine.reports.cashFlow({ organizationId, dateOption: "year", dateValue: 2025 });
172
- await engine.reports.agedBalance({ organizationId, type: "receivable", asOfDate: new Date() });
173
- await engine.reports.budgetVsActual({ organizationId, dateOption: "year", dateValue: 2025 });
174
- await engine.reports.dimensionBreakdown({ organizationId, dimension: "departmentId", dateOption: "year", dateValue: 2025 });
175
- await engine.reports.revaluation({ organizationId, asOfDate: new Date(), rates: [{ currency: "USD", rate: 1.40 }], unrealizedGainLossAccountId });
176
- ```
177
-
178
- All values are integer cents. Use `Money.toDecimal()` at your API boundary.
209
+ 12 typed reports, all multi-tenant scoped, all returning structured JSON ready for any UI:
179
210
 
180
- The 10 reports:
181
-
182
- - **Trial Balance** (3-column: opening + period + ending)
183
- - **Balance Sheet** (with computed retained earnings, multi-year aware)
184
- - **Income Statement** (revenue, COGS, gross profit, operating expenses, net income)
185
- - **General Ledger** (per-account with running balances)
186
- - **Cash Flow** (operating / investing / financing)
187
- - **Aged Receivable / Payable** (configurable buckets)
188
- - **Budget vs Actual** (variance analysis)
189
- - **Dimension Breakdown** (by department, project, cost center)
190
- - **FX Revaluation** (unrealized gain/loss)
191
- - **Fiscal Year Close / Reopen** (automatic closing entries)
211
+ | Report | Purpose |
212
+ | --- | --- |
213
+ | `trialBalance` | Debits/credits per account with running balances |
214
+ | `balanceSheet` | Assets, liabilities, equity at a date with computed retained earnings |
215
+ | `incomeStatement` | Revenue, COGS, expenses, net income for a period |
216
+ | `generalLedger` | Per-account transaction detail with running balance |
217
+ | `cashFlow` | Operating / investing / financing breakdown |
218
+ | `agedBalance` | A/R or A/P bucketed by age, optionally per partner |
219
+ | `partnerLedger` | Supplier/customer statement with opening + running balance + aged buckets |
220
+ | `dimensionBreakdown` | Group by department/project/cost center |
221
+ | `budgetVsActual` | Variance vs budget per account/period |
222
+ | `revaluation` | Foreign-currency unrealized FX gain/loss at a date |
223
+ | `closeFiscalPeriod` / `reopenFiscalPeriod` | Year-end close pipeline |
192
224
 
193
225
  ## Engine Configuration
194
226
 
195
227
  ```ts
196
- createAccountingEngine({
197
- mongoose: mongoose.connection, // required
198
- country: canadaPack, // required
199
- currency: "CAD", // required — base/functional currency
200
- multiTenant: { orgField, orgRef }, // optional
228
+ const engine = createAccountingEngine({
229
+ mongoose: mongoose.connection,
230
+ country: canadaPack,
231
+ currency: "CAD",
232
+ multiTenant: { orgField: "organizationId", orgRef: "Organization" },
201
233
  multiCurrency: { enabled: true, currencies: ["USD", "EUR"] },
202
- fiscalYearStartMonth: 1, // 1=Jan (default), 4=Apr, 7=Jul
203
- retainedEarningsAccountCode: "3600", // overrides country pack
204
- modelNames: { account: "GLAccount", ... }, // custom collection names
205
- schemaOptions: { // extra fields/indexes per model
234
+ fiscalYearStartMonth: 1,
235
+ idempotency: true,
236
+ strictness: { immutable: true, requireActor: true },
237
+ schemaOptions: {
206
238
  journalEntry: {
207
- extraFields: { aiJob: { status: String, generatedAt: Date } },
208
- extraIndexes: [{ fields: { "aiJob.status": 1 }, options: { sparse: true } }],
239
+ extraItemFields: {
240
+ partnerId: { type: String, index: true },
241
+ departmentId: { type: mongoose.Schema.Types.ObjectId },
242
+ },
209
243
  },
210
244
  },
211
- strictness: {
212
- immutable: true, // disable unpost — corrections only via reverse
213
- requireActor: true, // actorId required on post/reverse
214
- requireApproval: true, // entries must be approved before posting
215
- },
216
- plugins: { journalEntry: [...], account: [...] }, // any mongokit plugins
217
- pagination: { account: { maxLimit: 5000 } }, // optional caps; no default cap
218
245
  });
219
246
  ```
220
247
 
221
- `pagination` has **no default cap** — large enterprise charts of accounts can be tens of thousands of rows. Pass `{ maxLimit: N }` per repository if you want to bound list queries.
222
-
223
248
  ## Built-in Plugins
224
249
 
225
250
  | Plugin | Purpose |
226
251
  | --- | --- |
227
- | `doubleEntryPlugin` | Validates debits = credits, account existence, tenant integrity |
228
- | `fiscalLockPlugin` | Prevents posting to closed fiscal periods |
229
- | `dateLockPlugin` | Blocks entries before a configurable lock date |
230
- | `taxHookPlugin` | Auto-generates tax lines via a `TaxLineGenerator` |
231
- | `idempotencyPlugin` | Prevents duplicate entries by key |
252
+ | `doubleEntryPlugin` | Validates debits = credits, account existence, tenant integrity, posted-entry immutability |
253
+ | `fiscalLockPlugin` | Prevents posting into closed fiscal periods (auto-wired) |
254
+ | `dailyLockPlugin` | Per-branch `lastClosedDate` watermark for daily POS close |
255
+ | `createLockPlugin` | Generic lock factory compose your own scopes (bank recon, payroll, tax filings) |
256
+ | `idempotencyPlugin` | Prevents duplicate entries by key (auto-wired when `idempotency: true`) |
257
+ | `creditLimitPlugin` | Per-partner A/R credit limit enforcement |
258
+ | `fxRealizationPlugin` | Books realized FX gain/loss when matched items have different exchange rates |
232
259
 
233
- `doubleEntryPlugin`, `fiscalLockPlugin` and `idempotencyPlugin` are wired automatically by the engine. The others are opt-in via the second `createAccountingEngine` argument.
260
+ `doubleEntryPlugin`, `fiscalLockPlugin`, and `idempotencyPlugin` (when enabled) are wired automatically by the engine. The others are opt-in via `.apply(engine.repositories.journalEntries)` or `.apply(engine.repositories.reconciliations)`.
234
261
 
235
262
  ## Custom Journal Types
236
263
 
@@ -247,25 +274,99 @@ Reference numbers use the type prefix (`POS_SALES/2025/03/0001`). The registry f
247
274
 
248
275
  ## Country Packs
249
276
 
277
+ A country pack ships the **chart of accounts** + accounting conventions for a jurisdiction. Tax (VAT/GST/HST/income-tax) lives in separate tax packages — see "Tax" below.
278
+
250
279
  ```ts
251
280
  import { defineCountryPack } from "@classytic/ledger";
252
281
 
253
282
  export const myPack = defineCountryPack({
254
- code: "US", name: "United States", defaultCurrency: "USD",
283
+ code: "US",
284
+ name: "United States",
285
+ defaultCurrency: "USD",
255
286
  retainedEarningsAccountCode: "3200",
256
287
  accountTypes: [/* chart of accounts */],
257
- taxCodes: {/* tax codes */},
258
- taxCodesByRegion: {}, regions: [],
288
+ journalTemplates: [
289
+ { code: "SALES", name: "Sales", journalType: "SALES", kind: "sale", sequencePrefix: "INV" },
290
+ // ...
291
+ ],
292
+ });
293
+ ```
294
+
295
+ Available: `@classytic/ledger-ca` (Canada GIFI), `@classytic/ledger-bd` (Bangladesh BFRS).
296
+
297
+ ## Tax
298
+
299
+ `@classytic/ledger@0.7+` is intentionally tax-agnostic. The same separation Odoo (`account/` vs `l10n_*`), QuickBooks (Ledger vs TaxService), and Xero (accounting vs Xero Tax) all use.
300
+
301
+ For tax computation, return generation, and repartition:
302
+
303
+ - **`@classytic/bd-tax`** — Bangladesh income tax + VAT compute, IT-11GA forms, Mushak 9.1 returns, deduction optimizer, depreciation
304
+ - **`@classytic/ca-tax`** *(planned)* — Canadian GST/HST/PST/QST compute, CRA GST34 form, ITC tracking
305
+ - **Or your own** — tax engines just call `engine.repositories.journalEntries.create()` with the tax line items they want posted
306
+
307
+ The country packs (`ledger-bd`, `ledger-ca`) still re-export their raw tax data tables (`TAX_CODES`, `TAX_CODES_BY_REGION`, `mushakReturnTemplate`, `craReturnTemplate`) as named exports so tax packages can lift them — they're just no longer wired into the `CountryPack` contract.
308
+
309
+ ## Invoice Engine Integration
310
+
311
+ Wire `@classytic/invoice` to the ledger with one call — no manual journal wiring needed:
312
+
313
+ ```ts
314
+ import { createAccountingEngine } from "@classytic/ledger";
315
+ import { createLedgerBridge } from "@classytic/ledger/sync";
316
+ import { createInvoiceEngine } from "@classytic/invoice";
317
+ import { canadaPack } from "@classytic/ledger-ca";
318
+
319
+ const accounting = createAccountingEngine({
320
+ mongoose: mongoose.connection,
321
+ country: canadaPack,
322
+ currency: "CAD",
323
+ multiTenant: { orgField: "organizationId", orgRef: "Organization" },
324
+ idempotency: true,
325
+ });
326
+
327
+ const invoicing = createInvoiceEngine({
328
+ mongoose: mongoose.connection,
329
+ ledger: createLedgerBridge(accounting, {
330
+ accounts: {
331
+ receivable: "1200", // Accounts Receivable
332
+ payable: "2000", // Accounts Payable
333
+ revenue: "4000", // Revenue
334
+ expense: "5000", // Expenses
335
+ taxPayable: "2100", // Tax Payable
336
+ taxReceivable: "1150", // Tax Receivable
337
+ cash: "1000", // Cash / Bank
338
+ },
339
+ }),
340
+ });
341
+ ```
342
+
343
+ The bridge handles all 5 move types (`out_invoice`, `in_invoice`, `out_refund`, `in_refund`, `receipt`), payment recording, and reversal. See [docs/sync.md](docs/sync.md) for the full mapping table and configuration options.
344
+
345
+ For custom subledgers (inventory, payroll, etc.) that don't use `@classytic/invoice`, see [docs/subledger-integration.md](docs/subledger-integration.md) for the manual `PostingContract` pattern.
346
+
347
+ ## URL-Driven Queries
348
+
349
+ Parse URL query parameters directly into paginated repository queries via mongokit's `QueryParser`:
350
+
351
+ ```ts
352
+ const parser = engine.createQueryParser("journalEntry");
353
+ const parsed = parser.parse(req.query);
354
+ // ?state=posted&date[gte]=2025-01-01&sort=-date&limit=25
355
+
356
+ const result = await engine.repositories.journalEntries.getAll({
357
+ ...parsed,
358
+ filters: { ...parsed.filters, organizationId },
259
359
  });
260
360
  ```
261
361
 
262
- Available: `@classytic/ledger-ca` (Canada), `@classytic/ledger-bd` (Bangladesh).
362
+ Available for all 6 models: `account`, `journalEntry`, `fiscalPeriod`, `budget`, `reconciliation`, `journal`.
263
363
 
264
364
  ## Subpath Exports
265
365
 
266
366
  | Path | Contents |
267
367
  | --- | --- |
268
368
  | `@classytic/ledger` | Engine, Money, plugins, reports, types |
369
+ | `@classytic/ledger/sync` | `createLedgerBridge`, `wireImport`, `wireExport`, bank/invoice/JE mappers |
269
370
  | `@classytic/ledger/money` | `Money` class |
270
371
  | `@classytic/ledger/reports` | Standalone report generators |
271
372
  | `@classytic/ledger/plugins` | All plugins |
@@ -276,9 +377,10 @@ Available: `@classytic/ledger-ca` (Canada), `@classytic/ledger-bd` (Bangladesh).
276
377
  ## Testing
277
378
 
278
379
  ```bash
279
- npm test # 1273 tests, 67 files
280
- npx vitest run tests/e2e/ # full-year scenarios
281
- npx vitest run tests/scenarios/ # integration scenarios
380
+ npm test # 1327 tests, 77 files
381
+ npm run smoke # full pipeline against published dist/
382
+ npx vitest run tests/e2e/ # end-to-end scenarios
383
+ npx vitest run tests/scenarios/ # multi-step business scenarios
282
384
  npx vitest run tests/hardening/ # edge cases & invariants
283
385
  ```
284
386
 
@@ -286,11 +388,15 @@ Coverage includes:
286
388
 
287
389
  - Canadian small-business full-year lifecycle (open → post → close → reopen)
288
390
  - Multi-year fiscal cycles with retained-earnings rollover
289
- - Multi-currency trading with FX revaluation
391
+ - Multi-currency trading with realized + unrealized FX
290
392
  - Multi-tenant report isolation (org A cannot see org B)
291
- - All 10 reports with month / quarter / year / custom date ranges
393
+ - All 12 reports with month / quarter / year / custom date ranges
292
394
  - Reversal and correction workflows
293
395
  - Custom journal type registry → schema → posting pipeline
396
+ - Item-level matching: 1-to-1, 1-to-many, partial settlement, unmatch
397
+ - Per-partner credit limit enforcement + reversal exemption
398
+ - FX realization plugin auto-booking gain/loss on cross-rate match
399
+ - Full ERP A/P + A/R cycle (bill receipt → match → supplier statement → aged balance)
294
400
  - Double-entry conservation across all entries
295
401
  - Money arithmetic hardening (overflow, penny-leak, float traps)
296
402
  - O-Level / A-Level / university textbook accounting problems
@@ -300,7 +406,7 @@ Coverage includes:
300
406
  - Node.js >= 22
301
407
  - MongoDB (replica set recommended for transactions)
302
408
  - Mongoose >= 9.4.1
303
- - @classytic/mongokit >= 3.5.3
409
+ - @classytic/mongokit >= 3.5.6
304
410
 
305
411
  ## License
306
412
 
@@ -0,0 +1,2 @@
1
+ import { a as EntryReversedNotification, c as PeriodLockedNotification, i as SourceRef, l as ReconciliationMismatchNotification, n as SourceBridge, o as NotificationBridge, r as SourceBridgeContext, s as NotificationBridgeContext, t as LedgerBridges } from "../index-dqkjgpII.mjs";
2
+ export { EntryReversedNotification, LedgerBridges, NotificationBridge, NotificationBridgeContext, PeriodLockedNotification, ReconciliationMismatchNotification, SourceBridge, SourceBridgeContext, SourceRef };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,2 +1,2 @@
1
- import { S as isValidCategory, _ as getCategoryMainType, a as getJournalTypeCodes, b as isBalanceSheet, c as CURRENCIES, d as isValidCurrency, f as CATEGORIES, g as extractStatementType, h as extractMainType, i as getJournalType, l as getCurrency, m as categoryKey, n as JOURNAL_TYPES, o as isValidJournalType, p as CATEGORY_KEYS, r as getCustomJournalTypes, s as registerJournalType, t as JOURNAL_CODES, u as getMinorUnit, v as getCategoryStatementType, x as isIncomeStatement, y as getNormalBalance } from "../journals-C50E9mpo.mjs";
1
+ import { S as isValidCategory, _ as getCategoryMainType, a as getJournalTypeCodes, b as isBalanceSheet, c as CURRENCIES, d as isValidCurrency, f as CATEGORIES, g as extractStatementType, h as extractMainType, i as getJournalType, l as getCurrency, m as categoryKey, n as JOURNAL_TYPES, o as isValidJournalType, p as CATEGORY_KEYS, r as getCustomJournalTypes, s as registerJournalType, t as JOURNAL_CODES, u as getMinorUnit, v as getCategoryStatementType, x as isIncomeStatement, y as getNormalBalance } from "../journals-DUpWwFt1.mjs";
2
2
  export { CATEGORIES, CATEGORY_KEYS, CURRENCIES, JOURNAL_CODES, JOURNAL_TYPES, categoryKey, extractMainType, extractStatementType, getCategoryMainType, getCategoryStatementType, getCurrency, getCustomJournalTypes, getJournalType, getJournalTypeCodes, getMinorUnit, getNormalBalance, isBalanceSheet, isIncomeStatement, isValidCategory, isValidCurrency, isValidJournalType, registerJournalType };
@@ -1,3 +1,3 @@
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, t as CURRENCIES, u as getJournalTypeCodes } from "../currencies-CsuBGfgs.mjs";
2
- import { a as extractStatementType, c as getNormalBalance, d as isValidCategory, i as extractMainType, l as isBalanceSheet, n as CATEGORY_KEYS, o as getCategoryMainType, r as categoryKey, s as getCategoryStatementType, t as CATEGORIES, u as isIncomeStatement } from "../categories-BkKdv16V.mjs";
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, t as CURRENCIES, u as getJournalTypeCodes } from "../currencies-Jo5oaM_4.mjs";
2
+ import { a as extractStatementType, c as getNormalBalance, d as isValidCategory, i as extractMainType, l as isBalanceSheet, n as CATEGORY_KEYS, o as getCategoryMainType, r as categoryKey, s as getCategoryStatementType, t as CATEGORIES, u as isIncomeStatement } from "../categories-FJlrvzcl.mjs";
3
3
  export { CATEGORIES, CATEGORY_KEYS, CURRENCIES, JOURNAL_CODES, JOURNAL_TYPES, categoryKey, extractMainType, extractStatementType, getCategoryMainType, getCategoryStatementType, getCurrency, getCustomJournalTypes, getJournalType, getJournalTypeCodes, getMinorUnit, getNormalBalance, isBalanceSheet, isIncomeStatement, isValidCategory, isValidCurrency, isValidJournalType, registerJournalType };
@@ -1,2 +1,2 @@
1
- import { i as defineCountryPack, n as CountryPackInput, r as JournalTemplate, t as CountryPack } from "../index-BX8miYdu.mjs";
1
+ import { i as defineCountryPack, n as CountryPackInput, r as JournalTemplate, t as CountryPack } from "../index-08IpHhrU.mjs";
2
2
  export { CountryPack, CountryPackInput, JournalTemplate, defineCountryPack };
@@ -0,0 +1,121 @@
1
+ //#region src/utils/errors.ts
2
+ var AccountingError = class extends Error {
3
+ status;
4
+ code;
5
+ fields;
6
+ constructor(message, status = 400, code = "ACCOUNTING_ERROR", fields) {
7
+ super(message);
8
+ this.name = "AccountingError";
9
+ this.status = status;
10
+ this.code = code;
11
+ if (fields && fields.length > 0) this.fields = Object.freeze([...fields]);
12
+ }
13
+ /** Serialize to a plain object for API responses and logs. */
14
+ toJSON() {
15
+ return {
16
+ name: this.name,
17
+ message: this.message,
18
+ status: this.status,
19
+ code: this.code,
20
+ ...this.fields ? { fields: this.fields } : {}
21
+ };
22
+ }
23
+ };
24
+ const Errors = {
25
+ validation: (msg, fields) => new AccountingError(msg, 400, "VALIDATION_ERROR", fields),
26
+ notFound: (msg, fields) => new AccountingError(msg, 404, "NOT_FOUND", fields),
27
+ conflict: (msg, fields) => new AccountingError(msg, 409, "CONFLICT", fields),
28
+ immutable: (msg, _fields) => new AccountingError(msg, 403, "IMMUTABLE_ENTRY"),
29
+ locked: (scope, msg, fields) => new AccountingError(msg, 409, `PERIOD_LOCKED_${scope.toUpperCase()}`, fields)
30
+ };
31
+ /**
32
+ * Thrown when an idempotent create was attempted with a key that already
33
+ * resolved to a different winner (not the common "same winner, same payload"
34
+ * replay — that returns the existing doc without throwing).
35
+ *
36
+ * Typically surfaces when the host supplies an `idempotencyKey` that collides
37
+ * with a logically-different prior write.
38
+ */
39
+ var IdempotencyConflictError = class extends AccountingError {
40
+ idempotencyKey;
41
+ existingId;
42
+ constructor(idempotencyKey, existingId, message) {
43
+ super(message ?? `Idempotency key "${idempotencyKey}" already resolved to entry ${String(existingId)}.`, 409, "IDEMPOTENCY_CONFLICT");
44
+ this.name = "IdempotencyConflictError";
45
+ this.idempotencyKey = idempotencyKey;
46
+ this.existingId = existingId;
47
+ }
48
+ };
49
+ /**
50
+ * Thrown when the unique `referenceNumber` index fires. With the new atomic
51
+ * counter this should be effectively impossible — if it ever throws, it
52
+ * indicates either a pre-atomic-counter doc that was hand-inserted OR a bug
53
+ * in the counter partitioning.
54
+ */
55
+ var DuplicateReferenceError = class extends AccountingError {
56
+ referenceNumber;
57
+ constructor(referenceNumber, message) {
58
+ super(message ?? `Duplicate reference number "${referenceNumber}".`, 409, "DUPLICATE_REFERENCE_NUMBER");
59
+ this.name = "DuplicateReferenceError";
60
+ this.referenceNumber = referenceNumber;
61
+ }
62
+ };
63
+ /**
64
+ * Thrown when an optimistic-concurrency FSM transition fails because another
65
+ * writer advanced the state or version between our read and our write.
66
+ *
67
+ * Callers should re-fetch the doc and decide whether to retry or surface.
68
+ */
69
+ var ConcurrencyError = class extends AccountingError {
70
+ resource;
71
+ resourceId;
72
+ constructor(resource, resourceId, message) {
73
+ super(message ?? `${resource} ${String(resourceId)} was modified by another writer — retry after re-fetch.`, 409, "CONCURRENCY_CONFLICT");
74
+ this.name = "ConcurrencyError";
75
+ this.resource = resource;
76
+ this.resourceId = resourceId;
77
+ }
78
+ };
79
+ /**
80
+ * Thrown when a mutation targets an entry that is protected by immutability —
81
+ * either `strictness.immutable` or the double-entry plugin's posted-entry
82
+ * guard. Factory `Errors.immutable(msg)` returns this subclass so callers
83
+ * can `instanceof`-match without sniffing the `code` field.
84
+ */
85
+ var ImmutableViolationError = class extends AccountingError {
86
+ entryId;
87
+ constructor(entryId, message, fields) {
88
+ super(message ?? `Entry ${String(entryId)} is posted and immutable. Use reverse() to correct it.`, 403, "IMMUTABLE_ENTRY", fields);
89
+ this.name = "ImmutableViolationError";
90
+ this.entryId = entryId;
91
+ }
92
+ };
93
+ Errors.immutable = (msg, fields) => new ImmutableViolationError(null, msg, fields);
94
+ /**
95
+ * Detect a Mongo duplicate-key error (11000) and return the index name the
96
+ * conflict hit on, so callers can switch on which unique key fired.
97
+ *
98
+ * Handles both driver-style and mongoose-style error shapes. Safe to call
99
+ * with any `unknown` — returns `null` when the error is not a dup-key.
100
+ */
101
+ function classifyDuplicateKey(err) {
102
+ if (!err || typeof err !== "object") return null;
103
+ const e = err;
104
+ const code = typeof e.code === "number" ? e.code : typeof e.code === "string" ? Number(e.code) : void 0;
105
+ const isDriverDup = code === 11e3 || e.name === "MongoServerError" && code === 11e3 || Array.isArray(e.writeErrors) && e.writeErrors.some((w) => w?.code === 11e3);
106
+ const wrappedMsgMatch = typeof e.message === "string" ? e.message.match(/^Duplicate value for ([a-zA-Z_.0-9,\s]+?)(?:\s*\(|$)/) : null;
107
+ const isMongokitDup = e.status === 409 && !!wrappedMsgMatch;
108
+ if (!isDriverDup && !isMongokitDup) return null;
109
+ let keyPattern = e.keyPattern;
110
+ if (!keyPattern && Array.isArray(e.writeErrors)) keyPattern = e.writeErrors.find((w) => w?.code === 11e3)?.keyPattern;
111
+ if (!keyPattern && wrappedMsgMatch) {
112
+ const fields = wrappedMsgMatch[1].split(",").map((f) => f.trim()).filter(Boolean);
113
+ keyPattern = Object.fromEntries(fields.map((f) => [f, 1]));
114
+ }
115
+ return {
116
+ indexName: keyPattern ? Object.keys(keyPattern).join("_") : typeof e.message === "string" && e.message.match(/index: ([^\s]+)/)?.[1] ? e.message.match(/index: ([^\s]+)/)[1] : "unknown",
117
+ keyPattern
118
+ };
119
+ }
120
+ //#endregion
121
+ export { IdempotencyConflictError as a, Errors as i, ConcurrencyError as n, ImmutableViolationError as o, DuplicateReferenceError as r, classifyDuplicateKey as s, AccountingError as t };
@@ -0,0 +1,2 @@
1
+ import { C as EntryReversedPayload, D as ReconciliationUnmatchedPayload, E as ReconciliationMatchedPayload, O as LEDGER_EVENTS, S as EntryPostedPayload, T as JournalSeededPayload, _ as AccountBulkCreatedPayload, a as OutboxOwnershipError, b as EntryCreatedPayload, c as InProcessLedgerBus, d as createEvent, f as DomainEvent, g as PublishManyResult, h as EventTransport, i as OutboxFailOptions, k as LedgerEventName, l as InProcessLedgerBusOptions, m as EventLogger, n as OutboxClaimOptions, o as OutboxStore, p as EventHandler, r as OutboxErrorInfo, s as OutboxWriteOptions, t as OutboxAcknowledgeOptions, u as EventContext, v as AccountSeededPayload, w as EntryUnpostedPayload, x as EntryDuplicatedPayload, y as EntryArchivedPayload } from "../outbox-store-UYC4eZpI.mjs";
2
+ export { type AccountBulkCreatedPayload, type AccountSeededPayload, type DomainEvent, type EntryArchivedPayload, type EntryCreatedPayload, type EntryDuplicatedPayload, type EntryPostedPayload, type EntryReversedPayload, type EntryUnpostedPayload, type EventContext, type EventHandler, type EventLogger, type EventTransport, InProcessLedgerBus, type InProcessLedgerBusOptions, type JournalSeededPayload, LEDGER_EVENTS, type LedgerEventName, type OutboxAcknowledgeOptions, type OutboxClaimOptions, type OutboxErrorInfo, type OutboxFailOptions, OutboxOwnershipError, type OutboxStore, type OutboxWriteOptions, type PublishManyResult, type ReconciliationMatchedPayload, type ReconciliationUnmatchedPayload, createEvent };
@@ -0,0 +1,2 @@
1
+ import { i as LEDGER_EVENTS, n as InProcessLedgerBus, r as createEvent, t as OutboxOwnershipError } from "../outbox-store-DQbL-KYT.mjs";
2
+ export { InProcessLedgerBus, LEDGER_EVENTS, OutboxOwnershipError, createEvent };
@@ -1,2 +1,2 @@
1
- import { _ as PopulatedJournalEntry, a as exportToCsv, c as getHeaders, d as serializeCsv, f as CsvOptions, g as PopulatedAccount, h as FlatJournalRow, i as quickbooksFieldMap, l as buildCsv, m as ExportFieldMap, n as flattenJournalEntry, o as extractAllRows, p as ExportField, r as universalFieldMap, s as extractRow, t as flattenJournalEntries, u as escapeCell, v as PopulatedJournalItem } from "../index-D1ZjgVxn.mjs";
1
+ import { _ as PopulatedJournalEntry, a as exportToCsv, c as getHeaders, d as serializeCsv, f as CsvOptions, g as PopulatedAccount, h as FlatJournalRow, i as quickbooksFieldMap, l as buildCsv, m as ExportFieldMap, n as flattenJournalEntry, o as extractAllRows, p as ExportField, r as universalFieldMap, s as extractRow, t as flattenJournalEntries, u as escapeCell, v as PopulatedJournalItem } from "../index-J-XIbXH-.mjs";
2
2
  export { CsvOptions, ExportField, ExportFieldMap, FlatJournalRow, PopulatedAccount, PopulatedJournalEntry, PopulatedJournalItem, buildCsv, escapeCell, exportToCsv, extractAllRows, extractRow, flattenJournalEntries, flattenJournalEntry, getHeaders, quickbooksFieldMap, serializeCsv, universalFieldMap };