@classytic/ledger 0.7.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 +1 -1
- package/dist/exports/index.d.mts +1 -1
- package/dist/exports/index.mjs +1 -1
- package/dist/{fx-realization.plugin-CfYy1tB6.mjs → fx-realization.plugin-DDVK-oYO.mjs} +43 -0
- package/dist/{index-Bl0_ak5w.d.mts → index-BSsvrf3m.d.mts} +1 -1
- package/dist/{index-BX8miYdu.d.mts → index-RNZsX0Yo.d.mts} +12 -1
- package/dist/index.d.mts +125 -8
- package/dist/index.mjs +161 -18
- 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 +1 -1
- package/dist/plugins/index.mjs +1 -1
- 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-DTc8kzTD.d.mts → trial-balance-DTj-c21f.d.mts} +2 -2
- 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/{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/docs/country-packs.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Country Packs
|
|
2
2
|
|
|
3
|
-
A country pack provides
|
|
3
|
+
A country pack provides the **chart of accounts** + accounting conventions for a jurisdiction (retained-earnings code, COGS group, journal templates, fiscal year start month, report labels). Country packs are intentionally **tax-agnostic** in 0.7+ — tax computation, return templates, and tax code tables live in dedicated tax packages (`@classytic/bd-tax`, the planned `@classytic/ca-tax`, or your own).
|
|
4
4
|
|
|
5
5
|
## Using a Country Pack
|
|
6
6
|
|
|
@@ -9,6 +9,7 @@ import { createAccountingEngine } from '@classytic/ledger';
|
|
|
9
9
|
import { canadaPack } from '@classytic/ledger-ca';
|
|
10
10
|
|
|
11
11
|
const accounting = createAccountingEngine({
|
|
12
|
+
mongoose: mongoose.connection,
|
|
12
13
|
country: canadaPack,
|
|
13
14
|
currency: 'CAD',
|
|
14
15
|
});
|
|
@@ -16,46 +17,44 @@ const accounting = createAccountingEngine({
|
|
|
16
17
|
|
|
17
18
|
## Available Packs
|
|
18
19
|
|
|
19
|
-
| Package | Country | Account Types |
|
|
20
|
+
| Package | Country | Account Types | Notes |
|
|
20
21
|
|---|---|---|---|
|
|
21
|
-
| `@classytic/ledger-ca` | Canada | GIFI (CRA) |
|
|
22
|
+
| `@classytic/ledger-ca` | Canada | GIFI (CRA-aligned) | Re-exports `TAX_CODES`, `TAX_CODES_BY_REGION`, `craReturnTemplate` as raw constants for tax engines to lift |
|
|
23
|
+
| `@classytic/ledger-bd` | Bangladesh | BFRS (~600 accounts) | Re-exports `TAX_CODES`, `TAX_CODES_BY_DIVISION`, `mushakReturnTemplate` as raw constants for tax engines to lift |
|
|
22
24
|
|
|
23
25
|
## Creating a Custom Country Pack
|
|
24
26
|
|
|
25
27
|
```typescript
|
|
26
28
|
import { defineCountryPack } from '@classytic/ledger';
|
|
27
|
-
import type { AccountType
|
|
29
|
+
import type { AccountType } from '@classytic/ledger';
|
|
28
30
|
|
|
29
31
|
const myPack = defineCountryPack({
|
|
30
32
|
code: 'US',
|
|
31
33
|
name: 'United States',
|
|
32
34
|
defaultCurrency: 'USD',
|
|
35
|
+
retainedEarningsAccountCode: '3200',
|
|
36
|
+
cogsGroupCode: 'Cost of Sales',
|
|
33
37
|
accountTypes: [
|
|
34
38
|
{
|
|
35
39
|
code: '1000',
|
|
36
40
|
name: 'Cash',
|
|
37
|
-
category: 'Balance Sheet-
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
category: 'Balance Sheet-Asset',
|
|
42
|
+
description: 'Cash and equivalents',
|
|
43
|
+
parentCode: null,
|
|
40
44
|
isGroup: false,
|
|
41
45
|
isTotal: false,
|
|
46
|
+
cashFlowCategory: 'Operating',
|
|
42
47
|
},
|
|
43
48
|
// ... more account types
|
|
44
49
|
],
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
active: true,
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
|
-
taxCodesByRegion: { CA: ['STATE_TAX'] },
|
|
57
|
-
regions: ['CA', 'NY', 'TX'],
|
|
58
|
-
taxReport: undefined, // optional tax return template
|
|
50
|
+
// Optional: declarative journal templates seeded per organization
|
|
51
|
+
journalTemplates: [
|
|
52
|
+
{ code: 'SALES', name: 'Sales', journalType: 'SALES', kind: 'sale', sequencePrefix: 'INV' },
|
|
53
|
+
{ code: 'PURCHASE', name: 'Purchases', journalType: 'PURCHASES', kind: 'purchase', sequencePrefix: 'BILL' },
|
|
54
|
+
{ code: 'BANK', name: 'Bank', journalType: 'CASH_RECEIPTS', kind: 'bank', sequencePrefix: 'BNK' },
|
|
55
|
+
{ code: 'CASH', name: 'Cash', journalType: 'CASH_PAYMENTS', kind: 'cash', sequencePrefix: 'CSH' },
|
|
56
|
+
{ code: 'MISC', name: 'Miscellaneous', journalType: 'MISC', kind: 'general', sequencePrefix: 'JE' },
|
|
57
|
+
],
|
|
59
58
|
});
|
|
60
59
|
```
|
|
61
60
|
|
|
@@ -66,19 +65,30 @@ interface CountryPack {
|
|
|
66
65
|
code: string; // ISO 3166-1 alpha-2
|
|
67
66
|
name: string;
|
|
68
67
|
defaultCurrency: string;
|
|
69
|
-
accountTypes: AccountType[];
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
accountTypes: readonly AccountType[];
|
|
69
|
+
|
|
70
|
+
// Optional declarative journal templates for engine.repositories.journals.seedDefaults()
|
|
71
|
+
journalTemplates?: readonly JournalTemplate[];
|
|
72
|
+
|
|
73
|
+
// Country-specific report defaults
|
|
74
|
+
retainedEarningsAccountCode?: string;
|
|
75
|
+
retainedEarningsDisplayCode?: string;
|
|
76
|
+
currentYearEarningsCode?: string;
|
|
77
|
+
cogsGroupCode?: string;
|
|
78
|
+
reportLabels?: {
|
|
79
|
+
assets?: string;
|
|
80
|
+
liabilities?: string;
|
|
81
|
+
equity?: string;
|
|
82
|
+
revenue?: string;
|
|
83
|
+
expenses?: string;
|
|
84
|
+
};
|
|
74
85
|
|
|
75
86
|
// Auto-generated helpers:
|
|
76
|
-
getPostingAccountTypes(): AccountType[];
|
|
87
|
+
getPostingAccountTypes(): readonly AccountType[];
|
|
77
88
|
getAccountType(code: string): AccountType | undefined;
|
|
78
89
|
isValidAccountType(code: string): boolean;
|
|
79
90
|
isPostingAccount(code: string): boolean;
|
|
80
|
-
|
|
81
|
-
flattenAccountTypes(): AccountType[];
|
|
91
|
+
flattenAccountTypes(): readonly AccountType[];
|
|
82
92
|
}
|
|
83
93
|
```
|
|
84
94
|
|
|
@@ -88,30 +98,44 @@ interface CountryPack {
|
|
|
88
98
|
interface AccountType {
|
|
89
99
|
code: string;
|
|
90
100
|
name: string;
|
|
91
|
-
category: CategoryKey; // e.g. 'Balance Sheet-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
isGroup
|
|
95
|
-
isTotal
|
|
96
|
-
cashFlowCategory?:
|
|
97
|
-
taxMetadata?: TaxMetadata; //
|
|
101
|
+
category: CategoryKey; // e.g. 'Balance Sheet-Asset', 'Income Statement-Income'
|
|
102
|
+
description: string;
|
|
103
|
+
parentCode: string | null; // grouping hierarchy
|
|
104
|
+
isGroup?: boolean; // structural grouping header (not postable)
|
|
105
|
+
isTotal?: boolean; // calculated total row (not postable)
|
|
106
|
+
cashFlowCategory?: 'Operating' | 'Investing' | 'Financing' | null;
|
|
107
|
+
taxMetadata?: TaxMetadata; // opaque metadata pass-through (no logic)
|
|
108
|
+
deprecated?: boolean;
|
|
109
|
+
replacedBy?: string;
|
|
110
|
+
notes?: string;
|
|
98
111
|
}
|
|
99
112
|
```
|
|
100
113
|
|
|
101
|
-
Only accounts where `isGroup
|
|
114
|
+
Only accounts where `isGroup !== true && isTotal !== true` are posting accounts.
|
|
102
115
|
|
|
103
|
-
##
|
|
116
|
+
## JournalTemplate Structure
|
|
104
117
|
|
|
105
118
|
```typescript
|
|
106
|
-
interface
|
|
107
|
-
code: string;
|
|
119
|
+
interface JournalTemplate {
|
|
120
|
+
code: string; // 'SALES', 'PURCHASE', 'BANK', ...
|
|
108
121
|
name: string;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
active: boolean;
|
|
122
|
+
journalType: string; // one of the registered JOURNAL_TYPES codes
|
|
123
|
+
sequencePrefix?: string; // defaults to `code`
|
|
124
|
+
sequenceStartNum?: number; // defaults to 1
|
|
125
|
+
kind?: 'general' | 'sale' | 'purchase' | 'bank' | 'cash' | string;
|
|
126
|
+
defaultDebitAccountRole?: string;
|
|
127
|
+
defaultCreditAccountRole?: string;
|
|
116
128
|
}
|
|
117
129
|
```
|
|
130
|
+
|
|
131
|
+
When the consumer calls `engine.repositories.journals.seedDefaults(orgId)`, the engine creates one Journal document per template with an isolated sequence counter.
|
|
132
|
+
|
|
133
|
+
## Tax — out of scope
|
|
134
|
+
|
|
135
|
+
Country packs in 0.7+ do **not** carry tax code tables, tax return templates, or tax repartition mappings. That work belongs in tax engine packages:
|
|
136
|
+
|
|
137
|
+
- `@classytic/bd-tax` — Bangladesh income tax slabs, IT-11GA forms, VAT/TDS/VDS computation, Mushak 9.1 return generator, deduction optimizer
|
|
138
|
+
- `@classytic/ca-tax` (planned) — Canadian GST/HST/PST/QST computation, CRA GST34 form, ITC tracking
|
|
139
|
+
- Or roll your own — a tax engine just calls `engine.repositories.journalEntries.create()` with the tax line items it wants posted
|
|
140
|
+
|
|
141
|
+
Country packs that previously bundled tax data (`ledger-bd`, `ledger-ca`) still re-export it as named constants — `TAX_CODES`, `TAX_CODES_BY_REGION` / `TAX_CODES_BY_DIVISION`, `mushakReturnTemplate`, `craReturnTemplate` — so tax engines can lift it directly without re-typing.
|
package/docs/engine.md
CHANGED
|
@@ -21,7 +21,7 @@ const accounting = createAccountingEngine({
|
|
|
21
21
|
|
|
22
22
|
| Option | Type | Required | Description |
|
|
23
23
|
|---|---|---|---|
|
|
24
|
-
| `country` | `CountryPack` | Yes | Country pack (
|
|
24
|
+
| `country` | `CountryPack` | Yes | Country pack (chart of accounts + journal templates) |
|
|
25
25
|
| `currency` | `string` | Yes | ISO 4217 currency code |
|
|
26
26
|
| `multiTenant` | `{ orgField, orgRef }` | No | Multi-tenant configuration |
|
|
27
27
|
| `fiscalYearStartMonth` | `number` | No | 1-12, default 1 (January) |
|
|
@@ -129,9 +129,10 @@ See [Reports](reports.md) for details.
|
|
|
129
129
|
accounting.getPostingAccountTypes() // → AccountType[]
|
|
130
130
|
accounting.isValidAccountType('1000') // → boolean
|
|
131
131
|
accounting.getAccountType('1000') // → AccountType | undefined
|
|
132
|
-
accounting.getTaxCodesForRegion('ON') // → TaxCode[]
|
|
133
132
|
```
|
|
134
133
|
|
|
134
|
+
> Tax code lookups have moved out of the ledger in 0.7+ — they live in dedicated tax engine packages (`@classytic/bd-tax`, etc.). See [Country Packs](country-packs.md#tax--out-of-scope).
|
|
135
|
+
|
|
135
136
|
## Logger Interface
|
|
136
137
|
|
|
137
138
|
The engine accepts a logger that implements:
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
# Subledger Integration
|
|
2
2
|
|
|
3
|
-
This guide explains how to integrate external subledgers (billing, inventory, payroll, etc.) with `@classytic/ledger`.
|
|
3
|
+
This guide explains how to integrate external subledgers (billing, inventory, payroll, etc.) with `@classytic/ledger`.
|
|
4
|
+
|
|
5
|
+
## Which pattern should I use?
|
|
6
|
+
|
|
7
|
+
| Subledger | Recommended approach |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `@classytic/invoice` | Use `createLedgerBridge()` from `@classytic/ledger/sync` — see [sync.md](sync.md) |
|
|
10
|
+
| Custom invoicing, billing, or any system with the `LedgerBridge` interface | Use `createLedgerBridge()` — same bridge, no `@classytic/invoice` dependency needed |
|
|
11
|
+
| Inventory, payroll, or other custom subledgers | Use the `PostingContract` pattern described below |
|
|
12
|
+
|
|
13
|
+
If you're integrating with `@classytic/invoice`, **start with [sync.md](sync.md)** — the bridge handles all the wiring for you. This document covers the manual `PostingContract` pattern for custom subledgers that don't use the invoice engine.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
The ledger provides **type-only posting contracts** — your application code is responsible for wiring subledgers to the journal entry repository.
|
|
4
18
|
|
|
5
19
|
## Responsibility Boundaries
|
|
6
20
|
|
|
@@ -13,7 +27,7 @@ This guide explains how to integrate external subledgers (billing, inventory, pa
|
|
|
13
27
|
| Idempotency (duplicate guard) | Yes — plugin checks `idempotencyKey` uniqueness | Must generate deterministic keys |
|
|
14
28
|
| 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
29
|
| Account code → ObjectId resolution | No | Yes — look up account by `accountType` code |
|
|
16
|
-
| Tax calculation | No —
|
|
30
|
+
| Tax calculation | No — tax engines are separate packages (`@classytic/bd-tax`, `@classytic/ca-tax`, etc.) | Yes — compute tax amounts before posting via your tax engine of choice |
|
|
17
31
|
| Source document validation | No | Yes — implement `validate()` on the contract |
|
|
18
32
|
| Creating the journal entry | No — provides `repo.post()` | Yes — call `repo.create()` then `repo.post()` |
|
|
19
33
|
| Transaction coordination | No | Yes — wrap subledger + ledger writes in a session |
|
|
@@ -268,18 +282,25 @@ The ledger's `reverse()` creates a new journal entry with debits and credits swa
|
|
|
268
282
|
|
|
269
283
|
## Tax Handling
|
|
270
284
|
|
|
271
|
-
|
|
285
|
+
`@classytic/ledger@0.7+` is intentionally tax-agnostic. Country packs ship the chart of accounts and journal templates only — tax computation, return generation, and tax-period filing locks all live in dedicated tax engine packages:
|
|
286
|
+
|
|
287
|
+
- **`@classytic/bd-tax`** — Bangladesh income tax (IT-11GA), VAT/TDS/VDS computation, Mushak 9.1 returns
|
|
288
|
+
- **`@classytic/ca-tax`** *(planned)* — Canadian GST/HST/PST/QST computation, CRA GST34 form
|
|
289
|
+
- **Or roll your own** — a tax engine just computes amounts and posts the resulting tax line items via `engine.repositories.journalEntries.create()`
|
|
290
|
+
|
|
291
|
+
The country packs `@classytic/ledger-bd` and `@classytic/ledger-ca` still re-export the raw tax data tables (`TAX_CODES`, `TAX_CODES_BY_DIVISION` / `TAX_CODES_BY_REGION`, `mushakReturnTemplate`, `craReturnTemplate`) as named constants so tax engines can lift them.
|
|
292
|
+
|
|
293
|
+
The integration pattern:
|
|
272
294
|
|
|
273
|
-
1.
|
|
274
|
-
2.
|
|
275
|
-
3.
|
|
276
|
-
4. The ledger stores and reports on whatever you post — it does not validate tax arithmetic
|
|
295
|
+
1. Compute tax amounts in your tax engine (input: invoice line items + jurisdiction; output: tax line items with account codes)
|
|
296
|
+
2. Include those tax line items in the same `journalEntries.create()` call as the rest of the entry
|
|
297
|
+
3. The ledger stores and reports on whatever you post — it does not validate tax arithmetic
|
|
277
298
|
|
|
278
299
|
## What the Ledger Does Not Do
|
|
279
300
|
|
|
280
301
|
To set clear expectations, the ledger intentionally does **not**:
|
|
281
302
|
|
|
282
|
-
- **Compute taxes** —
|
|
303
|
+
- **Compute taxes** — that lives in dedicated tax engine packages (see above)
|
|
283
304
|
- **Manage invoices, bills, or payments** — these are subledger concerns
|
|
284
305
|
- **Orchestrate multi-step workflows** — approval routing, email notifications, etc. are app-level
|
|
285
306
|
- **Resolve account codes to ObjectIds** — the app must map country-pack codes to tenant accounts
|
package/docs/sync.md
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# Sync — Invoice Bridge, Import & Export
|
|
2
|
+
|
|
3
|
+
`@classytic/ledger/sync` is the integration subpath for connecting external systems to the ledger.
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import {
|
|
7
|
+
// Invoice engine bridge (recommended)
|
|
8
|
+
createLedgerBridge,
|
|
9
|
+
|
|
10
|
+
// Import/export pipeline
|
|
11
|
+
wireImport,
|
|
12
|
+
wireExport,
|
|
13
|
+
|
|
14
|
+
// Mapper factories (fin-io canonical shapes → JournalEntry)
|
|
15
|
+
bankStatementMapper,
|
|
16
|
+
invoiceMapper,
|
|
17
|
+
journalEntryMapper,
|
|
18
|
+
openingBalanceMapper,
|
|
19
|
+
} from '@classytic/ledger/sync';
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Invoice Engine Integration
|
|
25
|
+
|
|
26
|
+
### Recommended: `createLedgerBridge()`
|
|
27
|
+
|
|
28
|
+
Wire `@classytic/invoice` to `@classytic/ledger` with one call. The bridge handles account mapping, tax lines, credit notes, payments, and reversals — no manual journal wiring needed.
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
import { createAccountingEngine } from '@classytic/ledger';
|
|
32
|
+
import { createLedgerBridge } from '@classytic/ledger/sync';
|
|
33
|
+
import { createInvoiceEngine } from '@classytic/invoice';
|
|
34
|
+
import { canadaPack } from '@classytic/ledger-ca';
|
|
35
|
+
|
|
36
|
+
// 1. Create the accounting engine
|
|
37
|
+
const accounting = createAccountingEngine({
|
|
38
|
+
mongoose: connection,
|
|
39
|
+
country: canadaPack,
|
|
40
|
+
currency: 'CAD',
|
|
41
|
+
multiTenant: { orgField: 'organizationId', orgRef: 'Organization' },
|
|
42
|
+
idempotency: true,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// 2. Create the bridge — map your chart of accounts once
|
|
46
|
+
const bridge = createLedgerBridge(accounting, {
|
|
47
|
+
accounts: {
|
|
48
|
+
receivable: '1200', // Accounts Receivable
|
|
49
|
+
payable: '2000', // Accounts Payable
|
|
50
|
+
revenue: '4000', // Revenue
|
|
51
|
+
expense: '5000', // Cost of Goods Sold / Expenses
|
|
52
|
+
taxPayable: '2100', // HST/GST/VAT Payable
|
|
53
|
+
taxReceivable: '1150', // HST/GST/VAT Receivable (Input Tax Credit)
|
|
54
|
+
cash: '1000', // Cash / Bank
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// 3. Pass the bridge to the invoice engine — done
|
|
59
|
+
const invoicing = createInvoiceEngine({
|
|
60
|
+
mongoose: connection,
|
|
61
|
+
ledger: bridge,
|
|
62
|
+
// ... other invoice config
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
From this point, every invoice lifecycle operation automatically posts to the ledger:
|
|
67
|
+
|
|
68
|
+
- **`invoicing.services.posting.post(id)`** → creates a balanced journal entry
|
|
69
|
+
- **`invoicing.services.payment.recordPayment(input)`** → posts a payment entry (DR Cash, CR AR)
|
|
70
|
+
- **`invoicing.services.posting.cancel(id, reason)`** → reverses the journal entry
|
|
71
|
+
- **`invoicing.services.posting.void(id, reason)`** → reverses the journal entry (even if partially paid)
|
|
72
|
+
|
|
73
|
+
### How the bridge maps each move type
|
|
74
|
+
|
|
75
|
+
| Invoice Move Type | Journal Lines | Journal Type |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `out_invoice` (Customer Invoice) | DR Receivable (total), CR Revenue (per line), CR Tax Payable | `SALES` |
|
|
78
|
+
| `in_invoice` (Vendor Bill) | DR Expense (per line), DR Tax Receivable, CR Payable (total) | `PURCHASES` |
|
|
79
|
+
| `out_refund` (Customer Credit Note) | CR Receivable, DR Revenue (per line), DR Tax Payable | `SALES` |
|
|
80
|
+
| `in_refund` (Vendor Credit Note) | DR Payable, CR Expense (per line), CR Tax Receivable | `PURCHASES` |
|
|
81
|
+
| `receipt` (POS Receipt) | DR Receivable/Cash, CR Revenue (per line), CR Tax Payable | `CASH_RECEIPTS` |
|
|
82
|
+
|
|
83
|
+
All amounts are integer cents. Tax lines are only added when `taxAmount > 0`.
|
|
84
|
+
|
|
85
|
+
### Payment recording
|
|
86
|
+
|
|
87
|
+
When the invoice engine records a payment, the bridge calls `engine.record.payment()`:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
DR Cash (1000) $500.00
|
|
91
|
+
CR Receivable (1200) $500.00
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
An idempotency key is automatically derived from the payment ID (`payment:{paymentId}`), preventing duplicate journal entries on retry.
|
|
95
|
+
|
|
96
|
+
### Reversal
|
|
97
|
+
|
|
98
|
+
When an invoice is cancelled or voided, the bridge calls `engine.repositories.journalEntries.reverse()`, which creates a mirror entry with debits and credits swapped and links both entries bidirectionally.
|
|
99
|
+
|
|
100
|
+
### Bridge configuration options
|
|
101
|
+
|
|
102
|
+
#### `receiptAccount`
|
|
103
|
+
|
|
104
|
+
Override the debit account for POS receipts. By default, receipts debit the `receivable` account. If your receipts are immediately paid (no A/R), point this at cash:
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
createLedgerBridge(accounting, {
|
|
108
|
+
accounts: { ... },
|
|
109
|
+
receiptAccount: '1000', // Receipts debit Cash directly
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
#### `resolvePaymentAccounts`
|
|
114
|
+
|
|
115
|
+
Custom resolver for payment accounts. Use when you need to determine AR vs AP based on context (e.g., vendor bill payments should clear AP, not AR):
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
createLedgerBridge(accounting, {
|
|
119
|
+
accounts: { ... },
|
|
120
|
+
resolvePaymentAccounts: (input) => {
|
|
121
|
+
const isVendor = vendorInvoiceIds.has(input.invoiceId);
|
|
122
|
+
return {
|
|
123
|
+
receivableOrPayable: isVendor ? '2000' : '1200',
|
|
124
|
+
cash: '1000',
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Double-entry guarantee
|
|
131
|
+
|
|
132
|
+
The bridge uses `engine.record.adjustment()` internally. This routes through the ledger's double-entry plugin, which validates `sum(debits) === sum(credits)` before persisting. If the invoice engine sends unbalanced data, the ledger rejects it with a structured validation error.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
### Alternative: Manual wiring (without `createLedgerBridge`)
|
|
137
|
+
|
|
138
|
+
If you need full control over the journal entry shape — for example, to add dimension fields, use different accounts per line, or handle complex tax scenarios — you can implement the `LedgerBridge` interface yourself:
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
import type { LedgerBridge } from '@classytic/ledger/sync';
|
|
142
|
+
|
|
143
|
+
const ledgerBridge: LedgerBridge = {
|
|
144
|
+
async createJournalEntry(input) {
|
|
145
|
+
// Use record.adjustment() for multi-line entries with tax
|
|
146
|
+
const entry = await accounting.record.adjustment(input.organizationId, {
|
|
147
|
+
date: input.date,
|
|
148
|
+
label: `Invoice ${input.invoiceId}`,
|
|
149
|
+
journalType: 'SALES',
|
|
150
|
+
lines: [
|
|
151
|
+
{ account: '1200', debit: input.totalAmount },
|
|
152
|
+
...input.lines.map(line => ({
|
|
153
|
+
account: '4000',
|
|
154
|
+
credit: line.amount,
|
|
155
|
+
label: line.description,
|
|
156
|
+
})),
|
|
157
|
+
...(input.taxAmount > 0
|
|
158
|
+
? [{ account: '2100', credit: input.taxAmount, label: 'Tax' }]
|
|
159
|
+
: []),
|
|
160
|
+
],
|
|
161
|
+
}, {
|
|
162
|
+
idempotencyKey: input.idempotencyKey,
|
|
163
|
+
});
|
|
164
|
+
return String((entry as any)._id);
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
async reverseJournalEntry(journalEntryId, reason) {
|
|
168
|
+
const { reversal } = await accounting.repositories.journalEntries
|
|
169
|
+
.reverse(journalEntryId);
|
|
170
|
+
return String((reversal as any)._id);
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
async recordPayment(input) {
|
|
174
|
+
const entry = await accounting.record.payment(input.organizationId, {
|
|
175
|
+
date: input.date,
|
|
176
|
+
amount: input.amount,
|
|
177
|
+
fromReceivableAccount: '1200',
|
|
178
|
+
toCashAccount: '1000',
|
|
179
|
+
label: `Payment ${input.paymentId} for ${input.invoiceId}`,
|
|
180
|
+
}, {
|
|
181
|
+
idempotencyKey: `payment:${input.paymentId}`,
|
|
182
|
+
});
|
|
183
|
+
return String((entry as any)._id);
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// Then pass to the invoice engine
|
|
188
|
+
const invoicing = createInvoiceEngine({
|
|
189
|
+
mongoose: connection,
|
|
190
|
+
ledger: ledgerBridge,
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
This gives you the same integration but with full control over account resolution, dimension fields, and tax line construction.
|
|
195
|
+
|
|
196
|
+
### Using `LedgerBridge` without `@classytic/invoice`
|
|
197
|
+
|
|
198
|
+
The bridge types are generic — any invoicing system that calls these 3 methods works:
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
import type { LedgerBridge, LedgerPostInput, LedgerPaymentInput } from '@classytic/ledger/sync';
|
|
202
|
+
|
|
203
|
+
// Use createLedgerBridge() for standard mapping
|
|
204
|
+
const bridge: LedgerBridge = createLedgerBridge(accounting, { accounts: { ... } });
|
|
205
|
+
|
|
206
|
+
// Post an invoice
|
|
207
|
+
const jeId = await bridge.createJournalEntry({
|
|
208
|
+
organizationId: 'org_1',
|
|
209
|
+
invoiceId: 'INV-001',
|
|
210
|
+
moveType: 'out_invoice',
|
|
211
|
+
partnerId: 'customer-123',
|
|
212
|
+
date: new Date(),
|
|
213
|
+
currency: 'USD',
|
|
214
|
+
lines: [
|
|
215
|
+
{ description: 'Consulting', amount: 100000, taxAmount: 13000, taxCode: 'HST' },
|
|
216
|
+
],
|
|
217
|
+
totalAmount: 113000,
|
|
218
|
+
taxAmount: 13000,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Record a payment
|
|
222
|
+
await bridge.recordPayment({
|
|
223
|
+
organizationId: 'org_1',
|
|
224
|
+
invoiceId: 'INV-001',
|
|
225
|
+
paymentId: 'PAY-001',
|
|
226
|
+
amount: 113000,
|
|
227
|
+
currency: 'USD',
|
|
228
|
+
date: new Date(),
|
|
229
|
+
method: 'bank_transfer',
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Reverse (cancel/void)
|
|
233
|
+
await bridge.reverseJournalEntry(jeId, 'Invoice cancelled');
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Bank Statement Import
|
|
239
|
+
|
|
240
|
+
Import bank transactions from any format supported by `@classytic/fin-io`:
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
import { parseOfx } from '@classytic/fin-io/ofx';
|
|
244
|
+
import { wireImport, bankStatementMapper } from '@classytic/ledger/sync';
|
|
245
|
+
|
|
246
|
+
const parsed = parseOfx(buffer);
|
|
247
|
+
if (!parsed.ok) throw new Error(parsed.error);
|
|
248
|
+
|
|
249
|
+
const report = await wireImport({
|
|
250
|
+
source: parsed.data.flatMap(s => s.transactions),
|
|
251
|
+
mapper: bankStatementMapper({
|
|
252
|
+
bankAccountId: bankAccount._id,
|
|
253
|
+
suspenseAccountId: suspenseAccount._id,
|
|
254
|
+
categorize: (txn) => knownVendors[txn.counterparty?.name]?.accountId,
|
|
255
|
+
}),
|
|
256
|
+
journalEntries: engine.repositories.journalEntries,
|
|
257
|
+
context: { organizationId },
|
|
258
|
+
}).run();
|
|
259
|
+
|
|
260
|
+
console.log(`Imported ${report.inserted}, skipped ${report.skipped} duplicates`);
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Available mappers
|
|
264
|
+
|
|
265
|
+
| Mapper | Source | Output |
|
|
266
|
+
|---|---|---|
|
|
267
|
+
| `bankStatementMapper` | `CanonicalTransaction` (OFX, CAMT, MT940, CSV, Plaid) | 2-line JE: Cash ↔ Suspense |
|
|
268
|
+
| `invoiceMapper` | `CanonicalInvoice` (QBO, Xero JSON) | Multi-line JE: AR/AP ↔ Revenue/Expense ↔ Tax |
|
|
269
|
+
| `journalEntryMapper` | `CanonicalJournalEntry` (QBO, Xero manual journals) | 1:1 mapping |
|
|
270
|
+
| `openingBalanceMapper` | `TrialBalanceInput` | Multi-line opening balance entry |
|
|
271
|
+
|
|
272
|
+
### Idempotency
|
|
273
|
+
|
|
274
|
+
Re-running an import on the same file produces zero duplicates. Each mapper extracts a stable `externalId` from the source record (e.g., OFX `FITID`, CAMT `NtryRef`). The `wireImport` pipeline checks for existing entries before creating.
|
|
275
|
+
|
|
276
|
+
For best performance, provide a `findExisting` callback and add a partial index on `{ organizationId: 1, _externalId: 1 }`.
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## Export
|
|
281
|
+
|
|
282
|
+
Stream ledger data to external formats:
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
import { wireExport } from '@classytic/ledger/sync';
|
|
286
|
+
|
|
287
|
+
const report = await wireExport({
|
|
288
|
+
query: { organizationId: 'org_1', state: 'posted' },
|
|
289
|
+
sink: {
|
|
290
|
+
fromJournalEntry: (entry) => transformToCSVRow(entry),
|
|
291
|
+
emit: async (rows) => csvStream.write(rows),
|
|
292
|
+
flush: async () => csvStream.end(),
|
|
293
|
+
},
|
|
294
|
+
journalEntries: engine.repositories.journalEntries,
|
|
295
|
+
options: { batchSize: 500 },
|
|
296
|
+
}).run();
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## Writing Custom Mappers
|
|
302
|
+
|
|
303
|
+
Implement `ImportMapper<TRaw>` for any data source:
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
import type { ImportMapper } from '@classytic/ledger/sync';
|
|
307
|
+
|
|
308
|
+
interface MyPayrollRecord {
|
|
309
|
+
id: string;
|
|
310
|
+
employeeName: string;
|
|
311
|
+
grossPay: number;
|
|
312
|
+
taxWithheld: number;
|
|
313
|
+
netPay: number;
|
|
314
|
+
date: Date;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const payrollMapper: ImportMapper<MyPayrollRecord> = {
|
|
318
|
+
externalId: (record) => `payroll:${record.id}`,
|
|
319
|
+
|
|
320
|
+
toJournalEntry: (record, ctx) => ({
|
|
321
|
+
date: record.date,
|
|
322
|
+
label: `Payroll — ${record.employeeName}`,
|
|
323
|
+
journalItems: [
|
|
324
|
+
{ account: salaryExpenseId, debit: record.grossPay, credit: 0 },
|
|
325
|
+
{ account: taxPayableId, debit: 0, credit: record.taxWithheld },
|
|
326
|
+
{ account: cashId, debit: 0, credit: record.netPay },
|
|
327
|
+
],
|
|
328
|
+
}),
|
|
329
|
+
};
|
|
330
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/ledger",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Production-grade double-entry accounting engine for MongoDB — schemas, reports, tax, multi-tenant",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -42,6 +42,11 @@
|
|
|
42
42
|
"types": "./dist/exports/index.d.mts",
|
|
43
43
|
"import": "./dist/exports/index.mjs",
|
|
44
44
|
"default": "./dist/exports/index.mjs"
|
|
45
|
+
},
|
|
46
|
+
"./sync": {
|
|
47
|
+
"types": "./dist/sync/index.d.mts",
|
|
48
|
+
"import": "./dist/sync/index.mjs",
|
|
49
|
+
"default": "./dist/sync/index.mjs"
|
|
45
50
|
}
|
|
46
51
|
},
|
|
47
52
|
"files": [
|
|
@@ -78,9 +83,28 @@
|
|
|
78
83
|
"url": "git+https://github.com/classytic/accounting.git"
|
|
79
84
|
},
|
|
80
85
|
"peerDependencies": {
|
|
81
|
-
"@classytic/
|
|
86
|
+
"@classytic/fin-io": ">=0.1.0",
|
|
87
|
+
"@classytic/mongokit": ">=3.5.6",
|
|
82
88
|
"mongoose": ">=9.4.1"
|
|
83
89
|
},
|
|
90
|
+
"peerDependenciesMeta": {
|
|
91
|
+
"@classytic/fin-io": {
|
|
92
|
+
"optional": true
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"devDependencies": {
|
|
96
|
+
"@biomejs/biome": "^2.4.10",
|
|
97
|
+
"@classytic/fin-io": "file:../fin-io",
|
|
98
|
+
"@classytic/mongokit": "^3.5.6",
|
|
99
|
+
"@types/node": "^22.0.0",
|
|
100
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
101
|
+
"knip": "^6.3.0",
|
|
102
|
+
"mongodb-memory-server": "^10.2.3",
|
|
103
|
+
"mongoose": "^9.4.1",
|
|
104
|
+
"tsdown": "^0.21.5",
|
|
105
|
+
"typescript": "^5.7.0",
|
|
106
|
+
"vitest": "^3.0.0"
|
|
107
|
+
},
|
|
84
108
|
"engines": {
|
|
85
109
|
"node": ">=22"
|
|
86
110
|
},
|
|
@@ -99,17 +123,5 @@
|
|
|
99
123
|
"smoke": "node scripts/smoke.mjs",
|
|
100
124
|
"prepublishOnly": "npm run check && npm run build && npm run typecheck && npm test && npm run smoke",
|
|
101
125
|
"release": "npm run check && npm run build && npm run typecheck && npm test && npm run smoke && npm publish --access public"
|
|
102
|
-
},
|
|
103
|
-
"devDependencies": {
|
|
104
|
-
"@biomejs/biome": "^2.4.10",
|
|
105
|
-
"@classytic/mongokit": "^3.5.5",
|
|
106
|
-
"@types/node": "^22.0.0",
|
|
107
|
-
"@vitest/coverage-v8": "^3.2.4",
|
|
108
|
-
"knip": "^6.3.0",
|
|
109
|
-
"mongodb-memory-server": "^10.2.3",
|
|
110
|
-
"mongoose": "^9.4.1",
|
|
111
|
-
"tsdown": "^0.21.5",
|
|
112
|
-
"typescript": "^5.7.0",
|
|
113
|
-
"vitest": "^3.0.0"
|
|
114
126
|
}
|
|
115
127
|
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|