@classytic/ledger 0.9.0 → 0.10.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.
package/README.md CHANGED
@@ -1,412 +1,158 @@
1
1
  # @classytic/ledger
2
2
 
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
-
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).
3
+ Double-entry accounting engine for MongoDB. Integer-cents arithmetic, plugin-based, country-agnostic, multi-tenant. Framework-agnostic — works with Fastify, Express, Nest, or plain Mongoose.
6
4
 
7
5
  ## Install
8
6
 
9
7
  ```bash
10
8
  npm install @classytic/ledger @classytic/mongokit mongoose
11
- npm install @classytic/ledger-ca # Canada (GIFI chart of accounts)
12
9
  npm install @classytic/ledger-bd # Bangladesh (BFRS chart of accounts)
13
10
  ```
14
11
 
15
12
  ## Quick Start
16
13
 
17
14
  ```ts
18
- import mongoose from "mongoose";
19
- import { createAccountingEngine } from "@classytic/ledger";
20
- import { canadaPack } from "@classytic/ledger-ca";
15
+ import mongoose from 'mongoose';
16
+ import { createAccountingEngine } from '@classytic/ledger';
17
+ import { bangladeshPack } from '@classytic/ledger-bd';
21
18
 
22
19
  const engine = createAccountingEngine({
23
20
  mongoose: mongoose.connection,
24
- country: canadaPack,
25
- currency: "CAD",
26
- multiTenant: { orgField: "organizationId", orgRef: "Organization" },
21
+ country: bangladeshPack,
22
+ currency: 'BDT',
23
+ multiTenant: { orgField: 'organizationId', orgRef: 'organization' },
27
24
  });
28
25
 
26
+ // Seed chart of accounts for a branch
29
27
  await engine.repositories.accounts.seedAccounts(orgId);
30
28
 
31
- await engine.record.sale(orgId, {
32
- date: new Date("2025-04-01"),
33
- amount: 11300, // $113.00 in cents (caller pre-computes any tax)
34
- receivableAccount: "1200", // AR
35
- revenueAccount: "4010", // Service Revenue
36
- label: "INV-001",
37
- });
38
-
39
- const bs = await engine.reports.balanceSheet({
40
- organizationId: orgId,
41
- dateOption: "year",
42
- dateValue: 2025,
43
- });
44
- ```
45
-
46
- The engine owns the models. After `createAccountingEngine` you have:
47
-
48
- | Property | What it gives you |
49
- | --- | --- |
50
- | `engine.models.{Account,JournalEntry,FiscalPeriod,Budget,Reconciliation,Journal}` | Mongoose models |
51
- | `engine.repositories.accounts` | `seedAccounts()`, `bulkCreate()` + plugins |
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()` |
55
- | `engine.repositories.{fiscalPeriods,budgets}` | Plain CRUD |
56
- | `engine.record.*` | Domain verbs (`sale`, `expense`, `transfer`, `payment`, `adjustment`) |
57
- | `engine.introspect.*` | Runtime catalog of accounts, journal types, reports, fiscal periods |
58
- | `engine.reports.*` | All 12 reports, bound to owned models |
59
-
60
- ## Semantic Record API
61
-
62
- Record business operations as domain verbs. The engine resolves account codes and produces a balanced journal entry — you never touch debits/credits.
63
-
64
- ```ts
65
- await engine.record.sale(orgId, {
66
- date, amount: 10000,
67
- receivableAccount: "1001", revenueAccount: "4010",
68
- });
69
-
70
- await engine.record.expense(orgId, {
71
- date, amount: 3000,
72
- expenseAccount: "6010", paidFromAccount: "2001",
73
- });
74
-
75
- await engine.record.transfer(orgId, { date, amount: 5000, fromAccount: "1001", toAccount: "1002" });
76
-
77
- await engine.record.payment(orgId, {
78
- date, amount: 11300,
79
- fromReceivableAccount: "1200", toCashAccount: "1001",
80
- });
81
-
82
- // Multi-line adjustment (depreciation, accruals, corrections)
83
- await engine.record.adjustment(orgId, {
84
- date, label: "Monthly depreciation",
85
- lines: [
86
- { account: "6030", debit: 1000 },
87
- { account: "1500", credit: 1000 },
29
+ // Post a journal entry
30
+ const entry = await engine.repositories.journalEntries.create({
31
+ journalType: 'GENERAL',
32
+ date: new Date(),
33
+ label: 'Office supplies',
34
+ journalItems: [
35
+ { account: expenseAccountId, debit: 500_00, credit: 0 },
36
+ { account: cashAccountId, debit: 0, credit: 500_00 },
88
37
  ],
89
38
  });
39
+ await engine.repositories.journalEntries.post(entry._id, orgId);
90
40
  ```
91
41
 
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()`.
42
+ ## Multi-Currency (0.9.0+)
93
43
 
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.
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.
44
+ GL stays in base currency (BDT). Foreign currency is audit metadata.
99
45
 
100
46
  ```ts
101
- // Tag every journal item with a partnerId via extraItemFields (one-time setup)
102
47
  const engine = createAccountingEngine({
103
- // ...
104
- schemaOptions: {
105
- journalEntry: {
106
- extraItemFields: {
107
- partnerId: { type: String, index: true },
108
- },
109
- },
48
+ country: bangladeshPack,
49
+ currency: 'BDT',
50
+ multiCurrency: { enabled: true, currencies: ['USD', 'EUR', 'GBP'] },
51
+ bridges: {
52
+ exchangeRate: myRateBridge, // host-injected rate source
110
53
  },
111
54
  });
112
55
 
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"),
56
+ // Post with foreign currency metadata
57
+ await engine.repositories.journalEntries.create({
58
+ journalType: 'PURCHASES',
59
+ date: new Date(),
60
+ label: 'Import from China',
117
61
  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 },
62
+ {
63
+ account: inventoryId,
64
+ debit: 120_500_00, // BDT (base currency, always)
65
+ credit: 0,
66
+ currency: 'USD',
67
+ originalDebit: 1_000_00, // USD 1,000.00
68
+ exchangeRate: 120.50,
69
+ },
70
+ { account: apId, debit: 0, credit: 120_500_00 },
139
71
  ],
140
72
  });
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
73
  ```
175
74
 
176
- ## Introspection
75
+ ## Features
177
76
 
178
- ```ts
179
- const catalog = await engine.introspect.catalog(orgId);
180
- // { accounts, journalTypes, reports, fiscalPeriods }
181
-
182
- engine.introspect.accounts(orgId);
183
- engine.introspect.reports(); // sync static catalog
184
- ```
185
-
186
- ## Structured Validation Errors
187
-
188
- ```ts
189
- try {
190
- await engine.record.sale(orgId, { ... });
191
- } catch (err) {
192
- if (err instanceof AccountingError) {
193
- err.status // 400 | 402 | 403 | 404 | 409
194
- err.code // 'VALIDATION_ERROR' | 'NOT_FOUND' | 'CREDIT_LIMIT_EXCEEDED' | 'PERIOD_LOCKED_FISCAL' | ...
195
- err.fields // [{ path, issue, value }, ...]
196
- err.toJSON();
197
- }
198
- }
199
- ```
200
-
201
- ## Audit, Observability & Framework Integration
202
-
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.
204
-
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.
77
+ | Feature | What it does |
78
+ |---------|-------------|
79
+ | **Double-entry** | `doubleEntryPlugin` validates debit = credit on every post |
80
+ | **Integer cents** | All amounts in minor units (paisa/cents). No float errors. |
81
+ | **Multi-tenant** | `organizationId` scoping via mongokit plugin |
82
+ | **Multi-currency** | Optional foreign currency fields + FX realization + revaluation |
83
+ | **Idempotency** | `idempotencyPlugin` prevents duplicate postings |
84
+ | **Period locking** | `createLockPlugin` blocks edits to closed periods |
85
+ | **Credit limits** | `creditLimitPlugin` enforces per-partner credit caps |
86
+ | **Immutable guard** | `immutableGuardPlugin` prevents posted entry edits |
87
+ | **Country packs** | Pluggable chart of accounts (BD, CA, custom) |
206
88
 
207
89
  ## Reports
208
90
 
209
- 12 typed reports, all multi-tenant scoped, all returning structured JSON ready for any UI:
210
-
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 |
224
-
225
- ## Engine Configuration
226
-
227
- ```ts
228
- const engine = createAccountingEngine({
229
- mongoose: mongoose.connection,
230
- country: canadaPack,
231
- currency: "CAD",
232
- multiTenant: { orgField: "organizationId", orgRef: "Organization" },
233
- multiCurrency: { enabled: true, currencies: ["USD", "EUR"] },
234
- fiscalYearStartMonth: 1,
235
- idempotency: true,
236
- strictness: { immutable: true, requireActor: true },
237
- schemaOptions: {
238
- journalEntry: {
239
- extraItemFields: {
240
- partnerId: { type: String, index: true },
241
- departmentId: { type: mongoose.Schema.Types.ObjectId },
242
- },
243
- },
244
- },
245
- });
246
- ```
247
-
248
- ## Built-in Plugins
249
-
250
- | Plugin | Purpose |
251
- | --- | --- |
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 |
259
-
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)`.
261
-
262
- ## Custom Journal Types
263
-
264
- The 15 built-in journal types (SALES, PURCHASES, GENERAL, PAYROLL, …) cover standard accounting. Register custom types **before** the first engine call:
265
-
266
- ```ts
267
- import { registerJournalType } from "@classytic/ledger";
268
-
269
- registerJournalType("POS_SALES", { code: "POS_SALES", name: "POS Sales Journal" });
270
- registerJournalType("ECOM_SALES", { code: "ECOM_SALES", name: "E-Commerce Sales" });
271
- ```
272
-
273
- Reference numbers use the type prefix (`POS_SALES/2025/03/0001`). The registry freezes after the first schema is created.
274
-
275
- ## Country Packs
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
-
279
91
  ```ts
280
- import { defineCountryPack } from "@classytic/ledger";
281
-
282
- export const myPack = defineCountryPack({
283
- code: "US",
284
- name: "United States",
285
- defaultCurrency: "USD",
286
- retainedEarningsAccountCode: "3200",
287
- accountTypes: [/* chart of accounts */],
288
- journalTemplates: [
289
- { code: "SALES", name: "Sales", journalType: "SALES", kind: "sale", sequencePrefix: "INV" },
290
- // ...
291
- ],
292
- });
92
+ import {
93
+ generateTrialBalance,
94
+ generateBalanceSheet,
95
+ generateIncomeStatement,
96
+ generateCashFlow,
97
+ generateGeneralLedger,
98
+ generateAgedBalance,
99
+ generateBudgetVsActual,
100
+ generatePartnerLedger,
101
+ generateRevaluation,
102
+ } from '@classytic/ledger';
293
103
  ```
294
104
 
295
- Available: `@classytic/ledger-ca` (Canada GIFI), `@classytic/ledger-bd` (Bangladesh BFRS).
105
+ All reports accept `{ startDate, endDate, organizationId }` and return typed result objects.
296
106
 
297
- ## Tax
107
+ ## Bridges
298
108
 
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:
109
+ All optional. All methods optional. Features degrade gracefully.
312
110
 
313
111
  ```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
- });
112
+ import type { ExchangeRateBridge, SourceBridge, NotificationBridge } from '@classytic/ledger';
326
113
 
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
- }),
114
+ const engine = createAccountingEngine({
115
+ // ...
116
+ bridges: {
117
+ exchangeRate: myRateBridge, // FX rate lookup
118
+ source: mySourceBridge, // resolve external doc refs
119
+ notification: myNotifBridge, // alert on reversals, period locks
120
+ },
340
121
  });
341
122
  ```
342
123
 
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`:
124
+ ## Plugins
350
125
 
351
126
  ```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 },
359
- });
127
+ import {
128
+ doubleEntryPlugin,
129
+ idempotencyPlugin,
130
+ creditLimitPlugin,
131
+ fxRealizationPlugin,
132
+ immutableGuardPlugin,
133
+ createLockPlugin,
134
+ } from '@classytic/ledger';
360
135
  ```
361
136
 
362
- Available for all 6 models: `account`, `journalEntry`, `fiscalPeriod`, `budget`, `reconciliation`, `journal`.
137
+ Plugins attach to mongokit repository hooks. They run at POLICY priority before any query.
363
138
 
364
139
  ## Subpath Exports
365
140
 
366
- | Path | Contents |
367
- | --- | --- |
368
- | `@classytic/ledger` | Engine, Money, plugins, reports, types |
369
- | `@classytic/ledger/sync` | `createLedgerBridge`, `wireImport`, `wireExport`, bank/invoice/JE mappers |
370
- | `@classytic/ledger/money` | `Money` class |
371
- | `@classytic/ledger/reports` | Standalone report generators |
372
- | `@classytic/ledger/plugins` | All plugins |
373
- | `@classytic/ledger/exports` | CSV export + QuickBooks field maps |
374
- | `@classytic/ledger/country` | `defineCountryPack`, `CountryPack` |
375
- | `@classytic/ledger/constants` | Categories, journal types, currencies |
376
-
377
- ## Testing
378
-
379
- ```bash
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
384
- npx vitest run tests/hardening/ # edge cases & invariants
141
+ ```ts
142
+ import { Money } from '@classytic/ledger/money';
143
+ import { CATEGORIES, CURRENCIES } from '@classytic/ledger/constants';
144
+ import { defineCountryPack } from '@classytic/ledger/country';
145
+ import { exportToCsv, quickbooksFieldMap } from '@classytic/ledger/exports';
385
146
  ```
386
147
 
387
- Coverage includes:
388
-
389
- - Canadian small-business full-year lifecycle (open → post → close → reopen)
390
- - Multi-year fiscal cycles with retained-earnings rollover
391
- - Multi-currency trading with realized + unrealized FX
392
- - Multi-tenant report isolation (org A cannot see org B)
393
- - All 12 reports with month / quarter / year / custom date ranges
394
- - Reversal and correction workflows
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)
400
- - Double-entry conservation across all entries
401
- - Money arithmetic hardening (overflow, penny-leak, float traps)
402
- - O-Level / A-Level / university textbook accounting problems
403
-
404
- ## Requirements
148
+ ## Architecture
405
149
 
406
- - Node.js >= 22
407
- - MongoDB (replica set recommended for transactions)
408
- - Mongoose >= 9.4.1
409
- - @classytic/mongokit >= 3.5.6
150
+ - Repositories extend `@classytic/mongokit` Repository directly
151
+ - No service layer domain verbs live on the repository
152
+ - No barrel re-exports — import from source paths
153
+ - Events: arc-compatible `DomainEvent` / `EventTransport` shapes
154
+ - Country packs: pluggable chart of accounts + journal type seeds
155
+ - Tax: NOT in ledger. Use `@classytic/bd-tax` for Bangladesh tax calculations.
410
156
 
411
157
  ## License
412
158
 
@@ -1,2 +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 };
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, u as ExchangeRateBridge } from "../index-Bl0gP9lD.mjs";
2
+ export { EntryReversedNotification, ExchangeRateBridge, LedgerBridges, NotificationBridge, NotificationBridgeContext, PeriodLockedNotification, ReconciliationMismatchNotification, SourceBridge, SourceBridgeContext, SourceRef };
@@ -109,7 +109,7 @@ function classifyDuplicateKey(err) {
109
109
  let keyPattern = e.keyPattern;
110
110
  if (!keyPattern && Array.isArray(e.writeErrors)) keyPattern = e.writeErrors.find((w) => w?.code === 11e3)?.keyPattern;
111
111
  if (!keyPattern && wrappedMsgMatch) {
112
- const fields = wrappedMsgMatch[1].split(",").map((f) => f.trim()).filter(Boolean);
112
+ const fields = wrappedMsgMatch[1]?.split(",").map((f) => f.trim()).filter(Boolean);
113
113
  keyPattern = Object.fromEntries(fields.map((f) => [f, 1]));
114
114
  }
115
115
  return {
@@ -1,2 +1,3 @@
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 };
1
+ import { $ as ReconciliationUnmatchedPayload, A as LedgerEventDefinition, B as InProcessLedgerBusOptions, C as EntryPostedPayloadSchema, D as EntryUnpostedPayloadSchema, E as EntryUnposted, F as ReconciliationUnmatched, G as EntryArchivedPayload, H as createEvent, I as ReconciliationUnmatchedPayloadSchema, J as EntryPostedPayload, K as EntryCreatedPayload, L as ledgerEventDefinitions, M as LedgerEventSchema, N as ReconciliationMatched, O as JournalSeeded, P as ReconciliationMatchedPayloadSchema, Q as ReconciliationMatchedPayload, R as EventLogger, S as EntryPosted, T as EntryReversedPayloadSchema, U as AccountBulkCreatedPayload, V as EventContext, W as AccountSeededPayload, X as EntryUnpostedPayload, Y as EntryReversedPayload, Z as JournalSeededPayload, _ as EntryArchivedPayloadSchema, a as OutboxFailOptions, b as EntryDuplicated, c as OutboxFailurePolicy, d as OutboxWriteOptions, et as LEDGER_EVENTS, f as AccountBulkCreated, g as EntryArchived, h as AccountSeededPayloadSchema, i as OutboxErrorInfo, j as LedgerEventPayloadOf, k as JournalSeededPayloadSchema, l as OutboxOwnershipError, m as AccountSeeded, n as OutboxAcknowledgeOptions, o as OutboxFailureContext, p as AccountBulkCreatedPayloadSchema, q as EntryDuplicatedPayload, r as OutboxClaimOptions, s as OutboxFailureDecision, t as InvalidOutboxEventError, tt as LedgerEventName, u as OutboxStore, v as EntryCreated, w as EntryReversed, x as EntryDuplicatedPayloadSchema, y as EntryCreatedPayloadSchema, z as InProcessLedgerBus } from "../outbox-store-BcCiHMPw.mjs";
2
+ import { DomainEvent, EventHandler, EventTransport, PublishManyResult } from "@classytic/primitives/events";
3
+ export { AccountBulkCreated, type AccountBulkCreatedPayload, type AccountBulkCreatedPayloadSchema, AccountSeeded, type AccountSeededPayload, type AccountSeededPayloadSchema, type DomainEvent, EntryArchived, type EntryArchivedPayload, type EntryArchivedPayloadSchema, EntryCreated, type EntryCreatedPayload, type EntryCreatedPayloadSchema, EntryDuplicated, type EntryDuplicatedPayload, type EntryDuplicatedPayloadSchema, EntryPosted, type EntryPostedPayload, type EntryPostedPayloadSchema, EntryReversed, type EntryReversedPayload, type EntryReversedPayloadSchema, EntryUnposted, type EntryUnpostedPayload, type EntryUnpostedPayloadSchema, type EventContext, type EventHandler, type EventLogger, type EventTransport, InProcessLedgerBus, type InProcessLedgerBusOptions, InvalidOutboxEventError, JournalSeeded, type JournalSeededPayload, type JournalSeededPayloadSchema, LEDGER_EVENTS, type LedgerEventDefinition, type LedgerEventName, type LedgerEventPayloadOf, type LedgerEventSchema, type OutboxAcknowledgeOptions, type OutboxClaimOptions, type OutboxErrorInfo, type OutboxFailOptions, type OutboxFailureContext, type OutboxFailureDecision, type OutboxFailurePolicy, OutboxOwnershipError, type OutboxStore, type OutboxWriteOptions, type PublishManyResult, ReconciliationMatched, type ReconciliationMatchedPayload, type ReconciliationMatchedPayloadSchema, ReconciliationUnmatched, type ReconciliationUnmatchedPayload, type ReconciliationUnmatchedPayloadSchema, createEvent, ledgerEventDefinitions };
@@ -1,2 +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
+ import { _ as LEDGER_EVENTS, a as EntryArchived, c as EntryPosted, d as JournalSeeded, f as ReconciliationMatched, g as createEvent, h as InProcessLedgerBus, i as AccountSeeded, l as EntryReversed, m as ledgerEventDefinitions, n as OutboxOwnershipError, o as EntryCreated, p as ReconciliationUnmatched, r as AccountBulkCreated, s as EntryDuplicated, t as InvalidOutboxEventError, u as EntryUnposted } from "../outbox-store-BbKdQ2eT.mjs";
2
+ export { AccountBulkCreated, AccountSeeded, EntryArchived, EntryCreated, EntryDuplicated, EntryPosted, EntryReversed, EntryUnposted, InProcessLedgerBus, InvalidOutboxEventError, JournalSeeded, LEDGER_EVENTS, OutboxOwnershipError, ReconciliationMatched, ReconciliationUnmatched, createEvent, ledgerEventDefinitions };
@@ -1,4 +1,4 @@
1
- import { a as IdempotencyConflictError, i as Errors, t as AccountingError } from "./errors-BI5k4iak.mjs";
1
+ import { a as IdempotencyConflictError, i as Errors, t as AccountingError } from "./errors-vXd932rB.mjs";
2
2
  //#region src/plugins/double-entry.plugin.ts
3
3
  function doubleEntryPlugin(options = {}) {
4
4
  const { onlyOnPost = true, JournalEntryModel, AccountModel, orgField } = options;
@@ -1,3 +1,67 @@
1
+ //#region src/bridges/exchange-rate.bridge.d.ts
2
+ /**
3
+ * Exchange Rate Bridge — host-injected rate sourcing.
4
+ *
5
+ * The ledger never hardcodes where exchange rates come from. The host
6
+ * provides an implementation at engine init time:
7
+ *
8
+ * - ManualRateBridge: user-entered Currency Exchange records
9
+ * - BangladeshBankRateBridge: BB daily rate API
10
+ * - FixedRateBridge: hardcoded for testing
11
+ * - ProviderRateBridge: third-party rate API (Wise, Open Exchange Rates)
12
+ *
13
+ * When no bridge is provided, the caller must supply the exchange rate
14
+ * explicitly on every journal item (the existing behavior). The bridge
15
+ * is a convenience for hosts that want automatic rate lookup.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * const engine = createAccountingEngine({
20
+ * country: bdPack,
21
+ * currency: 'BDT',
22
+ * multiCurrency: { enabled: true, currencies: ['USD', 'EUR'] },
23
+ * bridges: {
24
+ * exchangeRate: {
25
+ * async getRate(from, to, date) {
26
+ * const row = await CurrencyExchange.findOne({ from, to, date });
27
+ * if (!row) throw new Error(`No rate for ${from}→${to} on ${date}`);
28
+ * return row.rate;
29
+ * },
30
+ * },
31
+ * },
32
+ * });
33
+ * ```
34
+ */
35
+ interface ExchangeRateBridge {
36
+ /**
37
+ * Get the exchange rate for converting `fromCurrency` to `toCurrency`
38
+ * on a given date.
39
+ *
40
+ * @param fromCurrency ISO 4217 code (e.g., 'USD')
41
+ * @param toCurrency ISO 4217 code (e.g., 'BDT')
42
+ * @param date Transaction date (rate as of this date)
43
+ * @param purpose Optional: 'buying' or 'selling' rate (some central
44
+ * banks publish separate rates)
45
+ * @returns The rate as a positive decimal. Example: 120.50 means
46
+ * 1 USD = 120.50 BDT.
47
+ * @throws When no rate is available for the requested pair + date.
48
+ */
49
+ getRate(fromCurrency: string, toCurrency: string, date: Date, purpose?: 'buying' | 'selling'): Promise<number>;
50
+ /**
51
+ * Optional batch lookup — avoids N+1 when posting multi-line entries
52
+ * with different currencies. Host implementations that cache rates
53
+ * benefit from a single DB round-trip.
54
+ *
55
+ * When absent, the engine falls back to calling `getRate()` per item.
56
+ */
57
+ getRates?(requests: Array<{
58
+ fromCurrency: string;
59
+ toCurrency: string;
60
+ date: Date;
61
+ purpose?: 'buying' | 'selling';
62
+ }>): Promise<Map<string, number>>;
63
+ }
64
+ //#endregion
1
65
  //#region src/bridges/notification.bridge.d.ts
2
66
  /**
3
67
  * NotificationBridge — host-implemented delivery for ledger-originated alerts.
@@ -95,10 +159,11 @@ interface SourceBridge {
95
159
  }
96
160
  //#endregion
97
161
  //#region src/bridges/index.d.ts
98
- /** Collected bridges exposed as `engine.bridges`. */
162
+ /** Collected bridges exposed as `engine.bridges`. All optional per PACKAGE_RULES §23. */
99
163
  interface LedgerBridges {
100
- source?: SourceBridge;
101
- notification?: NotificationBridge;
164
+ source?: SourceBridge | undefined;
165
+ notification?: NotificationBridge | undefined;
166
+ exchangeRate?: ExchangeRateBridge | undefined;
102
167
  }
103
168
  //#endregion
104
- export { EntryReversedNotification as a, PeriodLockedNotification as c, SourceRef as i, ReconciliationMismatchNotification as l, SourceBridge as n, NotificationBridge as o, SourceBridgeContext as r, NotificationBridgeContext as s, LedgerBridges as t };
169
+ export { EntryReversedNotification as a, PeriodLockedNotification as c, SourceRef as i, ReconciliationMismatchNotification as l, SourceBridge as n, NotificationBridge as o, SourceBridgeContext as r, NotificationBridgeContext as s, LedgerBridges as t, ExchangeRateBridge as u };