@classytic/ledger 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -348
- package/dist/bridges/index.d.mts +2 -0
- package/dist/bridges/index.mjs +1 -0
- package/dist/constants/index.d.mts +1 -1
- package/dist/constants/index.mjs +2 -2
- package/dist/country/index.d.mts +1 -1
- package/dist/errors-BI5k4iak.mjs +121 -0
- package/dist/events/index.d.mts +2 -0
- package/dist/events/index.mjs +2 -0
- package/dist/exports/index.d.mts +1 -1
- package/dist/exports/index.mjs +1 -1
- package/dist/{fx-realization.plugin-DDVK-oYO.mjs → fx-realization.plugin-Bxlb8cIx.mjs} +2 -2
- package/dist/{index-RNZsX0Yo.d.mts → index-08IpHhrU.d.mts} +1 -1
- package/dist/{index-BSsvrf3m.d.mts → index-ClLwzNRF.d.mts} +3 -3
- package/dist/index-Dih0lM65.d.mts +169 -0
- package/dist/index.d.mts +233 -65
- package/dist/index.mjs +400 -99
- package/dist/{journals-Dd4A9TN3.d.mts → journals-DUpWwFt1.d.mts} +1 -1
- package/dist/outbox-store-DQbL-KYT.mjs +132 -0
- package/dist/outbox-store-UYC4eZpI.d.mts +249 -0
- package/dist/{partner-ledger-D9H5hegI.mjs → partner-ledger-BoebloHk.mjs} +2 -2
- package/dist/plugins/index.d.mts +1 -1
- package/dist/plugins/index.mjs +1 -1
- package/dist/reports/index.d.mts +1 -1
- package/dist/reports/index.mjs +1 -1
- package/dist/sync/index.d.mts +1 -1
- package/dist/sync/index.mjs +1 -1
- package/dist/{sync-CnuVf441.d.mts → sync-JvchM3FO.d.mts} +1 -1
- package/dist/{trial-balance-DTj-c21f.d.mts → trial-balance-DyNm5bFu.d.mts} +2 -2
- package/package.json +27 -14
- package/dist/errors-CSDQPNyt.mjs +0 -33
- /package/dist/{categories-BkKdv16V.mjs → categories-FJlrvzcl.mjs} +0 -0
- /package/dist/{core-MpgjCqK0.d.mts → core-DwjkrRkJ.d.mts} +0 -0
- /package/dist/{currencies-CsuBGfgs.mjs → currencies-Jo5oaM_4.mjs} +0 -0
- /package/dist/{exports-B3whucXe.mjs → exports-C30yRapf.mjs} +0 -0
- /package/dist/{index-bCEeSzdO.d.mts → index-J-XIbXH-.d.mts} +0 -0
- /package/dist/{opening-balance-DPXmAIzN.mjs → opening-balance-1cixYh6Y.mjs} +0 -0
package/README.md
CHANGED
|
@@ -1,412 +1,158 @@
|
|
|
1
1
|
# @classytic/ledger
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
19
|
-
import { createAccountingEngine } from
|
|
20
|
-
import {
|
|
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:
|
|
25
|
-
currency:
|
|
26
|
-
multiTenant: { orgField:
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
42
|
+
## Multi-Currency (0.9.0+)
|
|
93
43
|
|
|
94
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
date: new Date(
|
|
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
|
-
{
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
##
|
|
75
|
+
## Features
|
|
177
76
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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 {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
105
|
+
All reports accept `{ startDate, endDate, organizationId }` and return typed result objects.
|
|
296
106
|
|
|
297
|
-
##
|
|
107
|
+
## Bridges
|
|
298
108
|
|
|
299
|
-
|
|
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 {
|
|
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
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
127
|
+
import {
|
|
128
|
+
doubleEntryPlugin,
|
|
129
|
+
idempotencyPlugin,
|
|
130
|
+
creditLimitPlugin,
|
|
131
|
+
fxRealizationPlugin,
|
|
132
|
+
immutableGuardPlugin,
|
|
133
|
+
createLockPlugin,
|
|
134
|
+
} from '@classytic/ledger';
|
|
360
135
|
```
|
|
361
136
|
|
|
362
|
-
|
|
137
|
+
Plugins attach to mongokit repository hooks. They run at POLICY priority before any query.
|
|
363
138
|
|
|
364
139
|
## Subpath Exports
|
|
365
140
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
407
|
-
-
|
|
408
|
-
-
|
|
409
|
-
-
|
|
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
|
|
|
@@ -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, u as ExchangeRateBridge } from "../index-Dih0lM65.mjs";
|
|
2
|
+
export { EntryReversedNotification, ExchangeRateBridge, 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-
|
|
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 };
|
package/dist/constants/index.mjs
CHANGED
|
@@ -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-
|
|
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-
|
|
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 };
|
package/dist/country/index.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { i as defineCountryPack, n as CountryPackInput, r as JournalTemplate, t as CountryPack } from "../index-
|
|
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 };
|