@classytic/ledger 0.6.0 → 0.8.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 +221 -115
- package/dist/constants/index.d.mts +1 -1
- package/dist/country/index.d.mts +2 -2
- package/dist/country/index.mjs +0 -3
- package/dist/exports/index.d.mts +1 -1
- package/dist/exports/index.mjs +1 -1
- package/dist/{fx-realization.plugin-CgQFDGv2.mjs → fx-realization.plugin-DDVK-oYO.mjs} +44 -37
- package/dist/{tax-hooks-BnVenul5.d.mts → index-BSsvrf3m.d.mts} +18 -67
- package/dist/index-RNZsX0Yo.d.mts +130 -0
- package/dist/index.d.mts +133 -68
- package/dist/index.mjs +166 -142
- package/dist/{journals-C50E9mpo.d.mts → journals-Dd4A9TN3.d.mts} +1 -1
- package/dist/opening-balance-DPXmAIzN.mjs +60 -0
- package/dist/plugins/index.d.mts +2 -16
- package/dist/plugins/index.mjs +2 -57
- package/dist/reports/index.d.mts +1 -1
- package/dist/sync/index.d.mts +313 -0
- package/dist/sync/index.mjs +527 -0
- package/dist/sync-CnuVf441.d.mts +152 -0
- package/dist/{trial-balance-s92GEvRR.d.mts → trial-balance-DTj-c21f.d.mts} +3 -34
- package/docs/country-packs.md +71 -47
- package/docs/engine.md +3 -2
- package/docs/subledger-integration.md +29 -8
- package/docs/sync.md +330 -0
- package/package.json +26 -14
- package/dist/index-BthGypsI.d.mts +0 -228
- /package/dist/{core-BkGjuVZj.d.mts → core-MpgjCqK0.d.mts} +0 -0
- /package/dist/{exports-BP-0Ni5W.mjs → exports-B3whucXe.mjs} +0 -0
- /package/dist/{index-D1ZjgVxn.d.mts → index-bCEeSzdO.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
|
+
> **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
|
|
12
|
-
npm install @classytic/ledger-bd # Bangladesh (BFRS
|
|
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:
|
|
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,
|
|
58
|
-
| `engine.reports.*` | All
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
-
|
|
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,
|
|
198
|
-
country:
|
|
199
|
-
currency: "CAD",
|
|
200
|
-
multiTenant: { orgField, orgRef },
|
|
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,
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
schemaOptions: {
|
|
234
|
+
fiscalYearStartMonth: 1,
|
|
235
|
+
idempotency: true,
|
|
236
|
+
strictness: { immutable: true, requireActor: true },
|
|
237
|
+
schemaOptions: {
|
|
206
238
|
journalEntry: {
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
229
|
-
| `
|
|
230
|
-
| `
|
|
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
|
|
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",
|
|
283
|
+
code: "US",
|
|
284
|
+
name: "United States",
|
|
285
|
+
defaultCurrency: "USD",
|
|
255
286
|
retainedEarningsAccountCode: "3200",
|
|
256
287
|
accountTypes: [/* chart of accounts */],
|
|
257
|
-
|
|
258
|
-
|
|
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:
|
|
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 #
|
|
280
|
-
|
|
281
|
-
npx vitest run tests/
|
|
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
|
|
391
|
+
- Multi-currency trading with realized + unrealized FX
|
|
290
392
|
- Multi-tenant report isolation (org A cannot see org B)
|
|
291
|
-
- All
|
|
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.
|
|
409
|
+
- @classytic/mongokit >= 3.5.6
|
|
304
410
|
|
|
305
411
|
## License
|
|
306
412
|
|
|
@@ -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-Dd4A9TN3.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/country/index.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export { CountryPack, CountryPackInput, JournalTemplate,
|
|
1
|
+
import { i as defineCountryPack, n as CountryPackInput, r as JournalTemplate, t as CountryPack } from "../index-RNZsX0Yo.mjs";
|
|
2
|
+
export { CountryPack, CountryPackInput, JournalTemplate, defineCountryPack };
|
package/dist/country/index.mjs
CHANGED
|
@@ -15,9 +15,6 @@ function defineCountryPack(input) {
|
|
|
15
15
|
const at = accountMap.get(code);
|
|
16
16
|
return at !== void 0 && !at.isTotal && !at.isGroup;
|
|
17
17
|
},
|
|
18
|
-
getTaxCodesForRegion: (region) => {
|
|
19
|
-
return (input.taxCodesByRegion[region] ?? []).map((c) => input.taxCodes[c]).filter(Boolean);
|
|
20
|
-
},
|
|
21
18
|
flattenAccountTypes: () => input.accountTypes
|
|
22
19
|
};
|
|
23
20
|
}
|
package/dist/exports/index.d.mts
CHANGED
|
@@ -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-
|
|
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-bCEeSzdO.mjs";
|
|
2
2
|
export { CsvOptions, ExportField, ExportFieldMap, FlatJournalRow, PopulatedAccount, PopulatedJournalEntry, PopulatedJournalItem, buildCsv, escapeCell, exportToCsv, extractAllRows, extractRow, flattenJournalEntries, flattenJournalEntry, getHeaders, quickbooksFieldMap, serializeCsv, universalFieldMap };
|
package/dist/exports/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as exportToCsv, c as getHeaders, d as serializeCsv, i as quickbooksFieldMap, l as buildCsv, n as flattenJournalEntry, o as extractAllRows, r as universalFieldMap, s as extractRow, t as flattenJournalEntries, u as escapeCell } from "../exports-
|
|
1
|
+
import { a as exportToCsv, c as getHeaders, d as serializeCsv, i as quickbooksFieldMap, l as buildCsv, n as flattenJournalEntry, o as extractAllRows, r as universalFieldMap, s as extractRow, t as flattenJournalEntries, u as escapeCell } from "../exports-B3whucXe.mjs";
|
|
2
2
|
export { buildCsv, escapeCell, exportToCsv, extractAllRows, extractRow, flattenJournalEntries, flattenJournalEntry, getHeaders, quickbooksFieldMap, serializeCsv, universalFieldMap };
|
|
@@ -132,7 +132,23 @@ function doubleEntryPlugin(options = {}) {
|
|
|
132
132
|
...existing
|
|
133
133
|
}, context);
|
|
134
134
|
};
|
|
135
|
+
const validateMany = async (context) => {
|
|
136
|
+
const docs = context.dataArray;
|
|
137
|
+
if (!docs || docs.length === 0) return;
|
|
138
|
+
for (const data of docs) {
|
|
139
|
+
if (onlyOnPost && data.state !== "posted") continue;
|
|
140
|
+
const items = data.journalItems;
|
|
141
|
+
if (data.state === "posted" && (!items || items.length < 2)) throw Errors.validation(`Cannot post entry: at least 2 journal items required, got ${items?.length ?? 0}.`);
|
|
142
|
+
if (!items || items.length === 0) continue;
|
|
143
|
+
validateItems(items, data);
|
|
144
|
+
if (data.state === "posted") {
|
|
145
|
+
if (!AccountModel) throw new Error("doubleEntryPlugin: AccountModel is required to validate posted entries. Pass AccountModel in plugin options to enable account existence and tenant integrity checks.");
|
|
146
|
+
await validateAccounts(items, data, context);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
};
|
|
135
150
|
repo.on("before:create", validate);
|
|
151
|
+
repo.on("before:createMany", validateMany);
|
|
136
152
|
repo.on("before:update", validateUpdate);
|
|
137
153
|
}
|
|
138
154
|
};
|
|
@@ -152,6 +168,20 @@ function idempotencyPlugin(options) {
|
|
|
152
168
|
const existing = await JournalEntryModel.findOne(query).select("_id").session(context.session ?? null).lean();
|
|
153
169
|
if (existing) throw Errors.conflict(`Duplicate idempotency key: "${data.idempotencyKey}". Existing entry: ${existing._id}`);
|
|
154
170
|
});
|
|
171
|
+
repo.on("before:createMany", async (context) => {
|
|
172
|
+
const docs = context.dataArray;
|
|
173
|
+
if (!docs || docs.length === 0) return;
|
|
174
|
+
const keys = docs.map((d) => d.idempotencyKey).filter((k) => !!k);
|
|
175
|
+
if (keys.length === 0) return;
|
|
176
|
+
const query = { idempotencyKey: { $in: keys } };
|
|
177
|
+
const firstOrg = orgField && docs[0]?.[orgField];
|
|
178
|
+
if (orgField && firstOrg) query[orgField] = firstOrg;
|
|
179
|
+
const existingDocs = await JournalEntryModel.find(query).select("idempotencyKey").session(context.session ?? null).lean();
|
|
180
|
+
if (existingDocs.length > 0) {
|
|
181
|
+
const existingKeys = existingDocs.map((d) => d.idempotencyKey);
|
|
182
|
+
throw Errors.conflict(`Duplicate idempotency keys: ${existingKeys.join(", ")}. ${existingDocs.length} entries already exist.`);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
155
185
|
}
|
|
156
186
|
};
|
|
157
187
|
}
|
|
@@ -215,7 +245,20 @@ function createLockPlugin(options) {
|
|
|
215
245
|
const refPart = hit.externalRef ? ` (ref: ${hit.externalRef})` : "";
|
|
216
246
|
throw Errors.locked(hit.scope, `Cannot post entry dated ${datePart}: ${hit.scope}${subTypePart} period "${hit.label}" is closed${refPart}.`);
|
|
217
247
|
};
|
|
248
|
+
const runMany = async (context) => {
|
|
249
|
+
const docs = context.dataArray;
|
|
250
|
+
if (!docs || docs.length === 0) return;
|
|
251
|
+
for (const data of docs) {
|
|
252
|
+
if (data.state !== "posted") continue;
|
|
253
|
+
await run({
|
|
254
|
+
...context,
|
|
255
|
+
data,
|
|
256
|
+
dataArray: void 0
|
|
257
|
+
}, false);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
218
260
|
repo.on("before:create", (ctx) => run(ctx, false));
|
|
261
|
+
repo.on("before:createMany", runMany);
|
|
219
262
|
repo.on("before:update", (ctx) => run(ctx, true));
|
|
220
263
|
}
|
|
221
264
|
};
|
|
@@ -275,42 +318,6 @@ function fiscalLockPlugin(options) {
|
|
|
275
318
|
})
|
|
276
319
|
});
|
|
277
320
|
}
|
|
278
|
-
const defaultTaxSelector = (acc) => acc.taxMetadata != null;
|
|
279
|
-
function taxLockPlugin(options) {
|
|
280
|
-
const { TaxPeriodModel, AccountModel, JournalEntryModel, orgField } = options;
|
|
281
|
-
const isTaxAffecting = options.isTaxAffecting ?? defaultTaxSelector;
|
|
282
|
-
const deriveFilter = options.deriveFilter ?? ((data) => ({
|
|
283
|
-
jurisdiction: data.jurisdiction,
|
|
284
|
-
taxType: data.taxType
|
|
285
|
-
}));
|
|
286
|
-
return createLockPlugin({
|
|
287
|
-
scope: "tax",
|
|
288
|
-
accountSelector: isTaxAffecting,
|
|
289
|
-
AccountModel,
|
|
290
|
-
JournalEntryModel,
|
|
291
|
-
orgField,
|
|
292
|
-
resolve: periodResolver({
|
|
293
|
-
scope: "tax",
|
|
294
|
-
PeriodModel: TaxPeriodModel,
|
|
295
|
-
startField: "periodStart",
|
|
296
|
-
endField: "periodEnd",
|
|
297
|
-
closedField: "status",
|
|
298
|
-
closedValue: { $ne: "open" },
|
|
299
|
-
labelField: "jurisdiction",
|
|
300
|
-
subTypeField: "taxType",
|
|
301
|
-
externalRefField: "returnRef",
|
|
302
|
-
orgField,
|
|
303
|
-
extraQuery: (ctx) => {
|
|
304
|
-
const filter = deriveFilter(ctx.data);
|
|
305
|
-
if (!filter) return void 0;
|
|
306
|
-
const out = {};
|
|
307
|
-
if (filter.jurisdiction) out.jurisdiction = filter.jurisdiction;
|
|
308
|
-
if (filter.taxType) out.taxType = filter.taxType;
|
|
309
|
-
return Object.keys(out).length ? out : void 0;
|
|
310
|
-
}
|
|
311
|
-
})
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
321
|
function dailyLockPlugin(options) {
|
|
315
322
|
return createLockPlugin({
|
|
316
323
|
scope: "daily",
|
|
@@ -456,4 +463,4 @@ function fxRealizationPlugin(options) {
|
|
|
456
463
|
};
|
|
457
464
|
}
|
|
458
465
|
//#endregion
|
|
459
|
-
export {
|
|
466
|
+
export { watermarkResolver as a, idempotencyPlugin as c, fiscalLockPlugin as i, doubleEntryPlugin as l, creditLimitPlugin as n, periodResolver as o, dailyLockPlugin as r, createLockPlugin as s, fxRealizationPlugin as t };
|