@classytic/ledger 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/dist/account.repository-1C2sZvB2.d.mts +29 -0
  4. package/dist/account.repository-1C2sZvB2.d.mts.map +1 -0
  5. package/dist/account.repository-Crf5DGO4.mjs +393 -0
  6. package/dist/account.repository-Crf5DGO4.mjs.map +1 -0
  7. package/dist/categories-BNJBd4ze.mjs +70 -0
  8. package/dist/categories-BNJBd4ze.mjs.map +1 -0
  9. package/dist/constants/index.d.mts +2 -0
  10. package/dist/constants/index.mjs +5 -0
  11. package/dist/core-Cx0baosR.d.mts +104 -0
  12. package/dist/core-Cx0baosR.d.mts.map +1 -0
  13. package/dist/country/index.d.mts +105 -0
  14. package/dist/country/index.d.mts.map +1 -0
  15. package/dist/country/index.mjs +27 -0
  16. package/dist/country/index.mjs.map +1 -0
  17. package/dist/currencies-BBk3NwXn.mjs +82 -0
  18. package/dist/currencies-BBk3NwXn.mjs.map +1 -0
  19. package/dist/currencies-Bkn3FNkC.d.mts +38 -0
  20. package/dist/currencies-Bkn3FNkC.d.mts.map +1 -0
  21. package/dist/engine-Cd73EOT6.d.mts +72 -0
  22. package/dist/engine-Cd73EOT6.d.mts.map +1 -0
  23. package/dist/errors-CeqRahE-.mjs +28 -0
  24. package/dist/errors-CeqRahE-.mjs.map +1 -0
  25. package/dist/exports/index.d.mts +2 -0
  26. package/dist/exports/index.mjs +3 -0
  27. package/dist/fiscal-close-CNOwv_ud.mjs +934 -0
  28. package/dist/fiscal-close-CNOwv_ud.mjs.map +1 -0
  29. package/dist/fiscal-close-CzUzpnMg.d.mts +270 -0
  30. package/dist/fiscal-close-CzUzpnMg.d.mts.map +1 -0
  31. package/dist/fiscal-period.schema-CbALaaKl.mjs +477 -0
  32. package/dist/fiscal-period.schema-CbALaaKl.mjs.map +1 -0
  33. package/dist/fiscal-period.schema-DI2scngu.d.mts +38 -0
  34. package/dist/fiscal-period.schema-DI2scngu.d.mts.map +1 -0
  35. package/dist/idempotency.plugin-BESs9YPD.d.mts +58 -0
  36. package/dist/idempotency.plugin-BESs9YPD.d.mts.map +1 -0
  37. package/dist/idempotency.plugin-C6r8RI8d.mjs +165 -0
  38. package/dist/idempotency.plugin-C6r8RI8d.mjs.map +1 -0
  39. package/dist/index.d.mts +308 -0
  40. package/dist/index.d.mts.map +1 -0
  41. package/dist/index.mjs +171 -0
  42. package/dist/index.mjs.map +1 -0
  43. package/dist/journals-CI3Wb4EF.mjs +92 -0
  44. package/dist/journals-CI3Wb4EF.mjs.map +1 -0
  45. package/dist/logger-Cv6VVc4r.d.mts +15 -0
  46. package/dist/logger-Cv6VVc4r.d.mts.map +1 -0
  47. package/dist/money.d.mts +129 -0
  48. package/dist/money.d.mts.map +1 -0
  49. package/dist/money.mjs +197 -0
  50. package/dist/money.mjs.map +1 -0
  51. package/dist/plugins/index.d.mts +2 -0
  52. package/dist/plugins/index.mjs +3 -0
  53. package/dist/reports/index.d.mts +2 -0
  54. package/dist/reports/index.mjs +3 -0
  55. package/dist/repositories/index.d.mts +2 -0
  56. package/dist/repositories/index.mjs +3 -0
  57. package/dist/schemas/index.d.mts +2 -0
  58. package/dist/schemas/index.mjs +3 -0
  59. package/dist/session-Dh0s6zG4.mjs +87 -0
  60. package/dist/session-Dh0s6zG4.mjs.map +1 -0
  61. package/dist/universal-CMfrZ2hG.mjs +257 -0
  62. package/dist/universal-CMfrZ2hG.mjs.map +1 -0
  63. package/dist/universal-x33ZJODp.d.mts +137 -0
  64. package/dist/universal-x33ZJODp.d.mts.map +1 -0
  65. package/docs/country-packs.md +117 -0
  66. package/docs/engine.md +147 -0
  67. package/docs/exports.md +81 -0
  68. package/docs/money.md +81 -0
  69. package/docs/plugins.md +136 -0
  70. package/docs/reports.md +154 -0
  71. package/docs/repositories.md +239 -0
  72. package/docs/schemas.md +146 -0
  73. package/docs/subledger-integration.md +287 -0
  74. package/package.json +116 -0
@@ -0,0 +1,287 @@
1
+ # Subledger Integration
2
+
3
+ This guide explains how to integrate external subledgers (billing, inventory, payroll, etc.) with `@classytic/ledger`. The ledger provides **type-only posting contracts** — your application code is responsible for wiring subledgers to the journal entry repository.
4
+
5
+ ## Responsibility Boundaries
6
+
7
+ | Concern | Ledger (`@classytic/ledger`) | App / Subledger Package |
8
+ |---|---|---|
9
+ | Double-entry validation | Yes — plugin enforces `debits === credits` | — |
10
+ | Account existence check | Yes — plugin validates account ObjectIds | — |
11
+ | Cross-tenant integrity | Yes — plugin ensures accounts belong to same org | — |
12
+ | Fiscal period lock | Yes — plugin blocks posting into closed periods | — |
13
+ | Idempotency (duplicate guard) | Yes — plugin checks `idempotencyKey` uniqueness | Must generate deterministic keys |
14
+ | Posted-entry protection | Yes — plugin blocks field changes on posted entries; fully immutable when `strictness.immutable` enabled | Use `reverse()` for corrections; `unpost()` available when immutable mode is off |
15
+ | Account code → ObjectId resolution | No | Yes — look up account by `accountType` code |
16
+ | Tax calculation | No — country packs define tax codes, not tax logic | Yes — compute tax amounts before posting |
17
+ | Source document validation | No | Yes — implement `validate()` on the contract |
18
+ | Creating the journal entry | No — provides `repo.post()` | Yes — call `repo.create()` then `repo.post()` |
19
+ | Transaction coordination | No | Yes — wrap subledger + ledger writes in a session |
20
+ | Approval workflow | Enforces `approvedBy`/`approvedAt` if `requireApproval` is on | Yes — set these fields before calling `post()` |
21
+ | Reversal coordination | Provides `repo.reverse()` for the ledger side | Yes — update subledger state (void invoice, etc.) |
22
+
23
+ **In short:** the ledger validates and stores journal entries. Everything upstream — deciding _what_ to post, _when_ to post, and _how_ to resolve account codes — is the application's job.
24
+
25
+ ## Posting Contracts
26
+
27
+ The ledger exports four TypeScript interfaces for structuring subledger integrations. These are **type-only** — they carry no runtime behavior or dependencies.
28
+
29
+ ### `PostingContract<TSource>`
30
+
31
+ The top-level interface that a subledger adapter implements:
32
+
33
+ ```typescript
34
+ import type { PostingContract } from '@classytic/ledger';
35
+
36
+ interface PostingContract<TSource = unknown> {
37
+ readonly name: string; // e.g. 'billing', 'inventory'
38
+ toJournalEntries(source: TSource): SubledgerPostingInput[];
39
+ validate(source: TSource): void; // throws on failure
40
+ }
41
+ ```
42
+
43
+ ### `SubledgerPostingInput`
44
+
45
+ The shape of a journal entry that the subledger produces:
46
+
47
+ ```typescript
48
+ interface SubledgerPostingInput {
49
+ journalType: string; // e.g. 'SALE', 'PURCHASE', 'PAYROLL'
50
+ label: string; // human-readable description
51
+ date: Date; // posting date
52
+ journalItems: SubledgerJournalItem[]; // debit/credit lines
53
+ idempotencyKey?: string; // prevents duplicate postings on retry
54
+ metadata?: Record<string, unknown>; // arbitrary extra data
55
+ }
56
+ ```
57
+
58
+ ### `SubledgerJournalItem`
59
+
60
+ A single debit or credit line:
61
+
62
+ ```typescript
63
+ interface SubledgerJournalItem {
64
+ accountCode: string; // account type code (e.g. '1000', '4000')
65
+ debit: number; // integer cents
66
+ credit: number; // integer cents
67
+ label?: string; // line description
68
+ extraFields?: Record<string, unknown>; // dimension fields (departmentId, etc.)
69
+ }
70
+ ```
71
+
72
+ ### `PostingResult`
73
+
74
+ Returned by your posting function after entries are created:
75
+
76
+ ```typescript
77
+ interface PostingResult {
78
+ journalEntryIds: (string | ObjectId)[];
79
+ idempotencyKeys?: string[];
80
+ }
81
+ ```
82
+
83
+ ## Integration Pattern
84
+
85
+ A typical subledger integration has three layers:
86
+
87
+ 1. **Contract** — maps source documents to journal entry inputs (pure logic, no DB calls)
88
+ 2. **Resolver** — converts account type codes to ObjectIds for the current tenant
89
+ 3. **Poster** — creates and posts journal entries via the ledger repository
90
+
91
+ ### Step 1: Implement the Contract
92
+
93
+ ```typescript
94
+ import type { PostingContract, SubledgerPostingInput } from '@classytic/ledger';
95
+
96
+ interface Invoice {
97
+ _id: string;
98
+ number: string;
99
+ date: Date;
100
+ lineItems: Array<{
101
+ description: string;
102
+ amount: number; // integer cents
103
+ taxAmount: number; // integer cents
104
+ departmentId?: string;
105
+ }>;
106
+ totalAmount: number;
107
+ totalTax: number;
108
+ }
109
+
110
+ const billingContract: PostingContract<Invoice> = {
111
+ name: 'billing',
112
+
113
+ validate(invoice) {
114
+ if (!invoice.lineItems.length) throw new Error('Invoice has no line items');
115
+ if (invoice.totalAmount <= 0) throw new Error('Invoice total must be positive');
116
+ // Add your business-specific validations here
117
+ },
118
+
119
+ toJournalEntries(invoice): SubledgerPostingInput[] {
120
+ const items = invoice.lineItems.flatMap((line) => [
121
+ {
122
+ accountCode: '4000', // Revenue
123
+ debit: 0,
124
+ credit: line.amount,
125
+ label: line.description,
126
+ extraFields: line.departmentId ? { departmentId: line.departmentId } : undefined,
127
+ },
128
+ ...(line.taxAmount > 0
129
+ ? [{
130
+ accountCode: '2100', // Tax Payable
131
+ debit: 0,
132
+ credit: line.taxAmount,
133
+ label: `Tax – ${line.description}`,
134
+ }]
135
+ : []),
136
+ ]);
137
+
138
+ items.push({
139
+ accountCode: '1200', // Accounts Receivable
140
+ debit: invoice.totalAmount + invoice.totalTax,
141
+ credit: 0,
142
+ label: `Invoice ${invoice.number}`,
143
+ });
144
+
145
+ return [{
146
+ journalType: 'SALE',
147
+ label: `Invoice ${invoice.number}`,
148
+ date: invoice.date,
149
+ journalItems: items,
150
+ idempotencyKey: `billing:invoice:${invoice._id}`,
151
+ }];
152
+ },
153
+ };
154
+ ```
155
+
156
+ ### Step 2: Resolve Account Codes
157
+
158
+ The contract produces account type codes (strings like `'4000'`). Your app must resolve these to real account ObjectIds for the tenant:
159
+
160
+ ```typescript
161
+ async function resolveAccounts(
162
+ input: SubledgerPostingInput,
163
+ organizationId: string,
164
+ AccountModel: Model<any>,
165
+ ): Promise<Array<{ account: ObjectId; debit: number; credit: number; label?: string; [key: string]: unknown }>> {
166
+ // Build a map of accountCode → ObjectId for this tenant
167
+ const codes = [...new Set(input.journalItems.map((i) => i.accountCode))];
168
+ const accounts = await AccountModel.find({
169
+ business: organizationId,
170
+ accountTypeCode: { $in: codes },
171
+ }).lean();
172
+
173
+ const codeToId = new Map(accounts.map((a) => [a.accountTypeCode, a._id]));
174
+
175
+ return input.journalItems.map((item) => {
176
+ const accountId = codeToId.get(item.accountCode);
177
+ if (!accountId) throw new Error(`No account found for type ${item.accountCode} in org ${organizationId}`);
178
+ return {
179
+ account: accountId,
180
+ debit: item.debit,
181
+ credit: item.credit,
182
+ label: item.label,
183
+ ...item.extraFields,
184
+ };
185
+ });
186
+ }
187
+ ```
188
+
189
+ ### Step 3: Create and Post
190
+
191
+ ```typescript
192
+ async function postInvoice(
193
+ invoice: Invoice,
194
+ organizationId: string,
195
+ journalRepo: any,
196
+ AccountModel: Model<any>,
197
+ session?: ClientSession,
198
+ ) {
199
+ // 1. Validate
200
+ billingContract.validate(invoice);
201
+
202
+ // 2. Map to journal inputs
203
+ const [input] = billingContract.toJournalEntries(invoice);
204
+
205
+ // 3. Resolve account codes → ObjectIds
206
+ const journalItems = await resolveAccounts(input, organizationId, AccountModel);
207
+
208
+ // 4. Create draft entry
209
+ const entry = await journalRepo.create({
210
+ business: organizationId,
211
+ journalType: input.journalType,
212
+ label: input.label,
213
+ date: input.date,
214
+ journalItems,
215
+ idempotencyKey: input.idempotencyKey,
216
+ }, { session });
217
+
218
+ // 5. Post (triggers double-entry validation, fiscal lock, idempotency)
219
+ await journalRepo.post(entry._id, organizationId, { session });
220
+
221
+ return { journalEntryIds: [entry._id], idempotencyKeys: [input.idempotencyKey!] };
222
+ }
223
+ ```
224
+
225
+ ## Idempotency
226
+
227
+ Subledger integrations should always set `idempotencyKey` to prevent duplicate postings from retries, queue redelivery, or webhook replays.
228
+
229
+ Key format convention: `{subledger}:{document-type}:{document-id}`
230
+
231
+ Examples:
232
+ - `billing:invoice:INV-001`
233
+ - `inventory:receipt:RCV-1234`
234
+ - `payroll:run:PR-2025-03`
235
+
236
+ The ledger's idempotency plugin returns a 409 Conflict if a journal entry with the same key already exists. Your posting code should catch this and treat it as a no-op (the entry was already posted).
237
+
238
+ **Prerequisite:** Enable `idempotency: true` in the engine config so the schema includes the `idempotencyKey` field with its unique sparse index.
239
+
240
+ ## Dimension Fields
241
+
242
+ If your subledger items carry dimension fields (`departmentId`, `projectId`, etc.), these must be:
243
+
244
+ 1. Declared in `extraItemFields` when creating the journal entry schema
245
+ 2. Passed via `extraFields` on `SubledgerJournalItem` (contract level)
246
+ 3. Spread onto journal items during account resolution (see Step 2 above)
247
+
248
+ Once posted, dimension fields are:
249
+ - Preserved through `duplicate()` and `reverse()` operations
250
+ - Available as filters on all report types (trial balance, balance sheet, income statement, general ledger, cash flow)
251
+
252
+ ## Reversal Coordination
253
+
254
+ To void or reverse a subledger transaction:
255
+
256
+ ```typescript
257
+ // 1. Reverse the ledger entry
258
+ await journalRepo.reverse(journalEntryId, organizationId, {
259
+ actorId: userId,
260
+ session,
261
+ });
262
+
263
+ // 2. Update subledger state (your responsibility)
264
+ await Invoice.updateOne({ _id: invoiceId }, { status: 'voided' }, { session });
265
+ ```
266
+
267
+ The ledger's `reverse()` creates a new journal entry with debits and credits swapped, links it to the original via `reversedBy`, and marks the original as `reversed: true`. All dimension fields from the original entry are preserved on the reversal.
268
+
269
+ ## Tax Handling
270
+
271
+ The ledger's country packs define **tax code metadata** (names, rates, regions) but do **not** compute tax amounts. Tax calculation is the application's responsibility:
272
+
273
+ 1. Look up applicable tax codes from the country pack (`accounting.getTaxCodesForRegion('ON')`)
274
+ 2. Compute tax amounts in your subledger / business logic
275
+ 3. Include tax lines as separate `SubledgerJournalItem` entries with the appropriate tax liability account code
276
+ 4. The ledger stores and reports on whatever you post — it does not validate tax arithmetic
277
+
278
+ ## What the Ledger Does Not Do
279
+
280
+ To set clear expectations, the ledger intentionally does **not**:
281
+
282
+ - **Compute taxes** — country packs provide tax code catalogs, not calculation engines
283
+ - **Manage invoices, bills, or payments** — these are subledger concerns
284
+ - **Orchestrate multi-step workflows** — approval routing, email notifications, etc. are app-level
285
+ - **Resolve account codes to ObjectIds** — the app must map country-pack codes to tenant accounts
286
+ - **Coordinate distributed transactions** — the app must wrap cross-collection writes in a MongoDB session
287
+ - **Generate direct-method cash flow** — the cash flow report classifies by `cashFlowCategory` on account types (indirect method only)
package/package.json ADDED
@@ -0,0 +1,116 @@
1
+ {
2
+ "name": "@classytic/ledger",
3
+ "version": "0.1.3",
4
+ "description": "Production-grade double-entry accounting engine for MongoDB — schemas, reports, tax, multi-tenant",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "main": "./dist/index.mjs",
8
+ "module": "./dist/index.mjs",
9
+ "types": "./dist/index.d.mts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.mts",
13
+ "import": "./dist/index.mjs",
14
+ "default": "./dist/index.mjs"
15
+ },
16
+ "./money": {
17
+ "types": "./dist/money.d.mts",
18
+ "import": "./dist/money.mjs",
19
+ "default": "./dist/money.mjs"
20
+ },
21
+ "./schemas": {
22
+ "types": "./dist/schemas/index.d.mts",
23
+ "import": "./dist/schemas/index.mjs",
24
+ "default": "./dist/schemas/index.mjs"
25
+ },
26
+ "./reports": {
27
+ "types": "./dist/reports/index.d.mts",
28
+ "import": "./dist/reports/index.mjs",
29
+ "default": "./dist/reports/index.mjs"
30
+ },
31
+ "./plugins": {
32
+ "types": "./dist/plugins/index.d.mts",
33
+ "import": "./dist/plugins/index.mjs",
34
+ "default": "./dist/plugins/index.mjs"
35
+ },
36
+ "./constants": {
37
+ "types": "./dist/constants/index.d.mts",
38
+ "import": "./dist/constants/index.mjs",
39
+ "default": "./dist/constants/index.mjs"
40
+ },
41
+ "./country": {
42
+ "types": "./dist/country/index.d.mts",
43
+ "import": "./dist/country/index.mjs",
44
+ "default": "./dist/country/index.mjs"
45
+ },
46
+ "./repositories": {
47
+ "types": "./dist/repositories/index.d.mts",
48
+ "import": "./dist/repositories/index.mjs",
49
+ "default": "./dist/repositories/index.mjs"
50
+ },
51
+ "./exports": {
52
+ "types": "./dist/exports/index.d.mts",
53
+ "import": "./dist/exports/index.mjs",
54
+ "default": "./dist/exports/index.mjs"
55
+ }
56
+ },
57
+ "files": [
58
+ "dist",
59
+ "docs",
60
+ "README.md",
61
+ "LICENSE"
62
+ ],
63
+ "keywords": [
64
+ "accounting",
65
+ "double-entry",
66
+ "bookkeeping",
67
+ "ledger",
68
+ "journal",
69
+ "chart-of-accounts",
70
+ "financial-reports",
71
+ "balance-sheet",
72
+ "income-statement",
73
+ "trial-balance",
74
+ "general-ledger",
75
+ "tax",
76
+ "gst",
77
+ "hst",
78
+ "vat",
79
+ "multi-tenant",
80
+ "mongodb",
81
+ "mongoose",
82
+ "typescript"
83
+ ],
84
+ "author": "Classytic (https://github.com/classytic)",
85
+ "license": "MIT",
86
+ "repository": {
87
+ "type": "git",
88
+ "url": "git+https://github.com/classytic/accounting.git"
89
+ },
90
+ "peerDependencies": {
91
+ "@classytic/mongokit": ">=3.3.2",
92
+ "mongoose": ">=9.0.0"
93
+ },
94
+ "engines": {
95
+ "node": ">=22"
96
+ },
97
+ "scripts": {
98
+ "build": "tsdown",
99
+ "dev": "tsdown --watch",
100
+ "test": "vitest run",
101
+ "test:watch": "vitest",
102
+ "test:coverage": "vitest run --coverage",
103
+ "typecheck": "tsc --noEmit",
104
+ "prepublishOnly": "npm run build && npm run typecheck",
105
+ "release": "npm run build && npm run typecheck && npm publish --access public"
106
+ },
107
+ "devDependencies": {
108
+ "@types/node": "^22.0.0",
109
+ "@vitest/coverage-v8": "^3.2.4",
110
+ "mongodb-memory-server": "^10.2.3",
111
+ "mongoose": "^9.3.1",
112
+ "tsdown": "^0.20.3",
113
+ "typescript": "^5.7.0",
114
+ "vitest": "^3.0.0"
115
+ }
116
+ }