@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.
@@ -1,5 +1,5 @@
1
- import { c as DateRange } from "./core-BkGjuVZj.mjs";
2
- import { t as CountryPack } from "./index-BthGypsI.mjs";
1
+ import { c as DateRange } from "./core-MpgjCqK0.mjs";
2
+ import { t as CountryPack } from "./index-RNZsX0Yo.mjs";
3
3
  import { ClientSession, Model } from "mongoose";
4
4
 
5
5
  //#region src/utils/logger.d.ts
@@ -178,37 +178,6 @@ interface CashFlowReport {
178
178
  financing: CashFlowSection;
179
179
  netCashFlow: number;
180
180
  }
181
- interface TaxAccountBalance {
182
- code: string;
183
- name: string;
184
- balance: number;
185
- taxMetadata?: unknown;
186
- }
187
- interface TaxReturnSummary {
188
- totalSales: number;
189
- gstHstCollected: number;
190
- inputTaxCredits: number;
191
- netTax: number;
192
- finalAmount: number;
193
- isRefund: boolean;
194
- refundAmount: number;
195
- paymentAmount: number;
196
- }
197
- interface TaxReport {
198
- period: {
199
- startDate: string;
200
- endDate: string;
201
- province: string;
202
- };
203
- accountBalances: {
204
- collected: Record<string, TaxAccountBalance>;
205
- itc: Record<string, TaxAccountBalance>;
206
- instalments: Record<string, TaxAccountBalance>;
207
- };
208
- craLines: Record<string | number, number>;
209
- summary: TaxReturnSummary;
210
- calculatedAt: string;
211
- }
212
181
  //#endregion
213
182
  //#region src/reports/balance-sheet.d.ts
214
183
  interface BalanceSheetOptions {
@@ -612,4 +581,4 @@ declare function generateTrialBalance(opts: TrialBalanceOptions, params: {
612
581
  filters?: Record<string, unknown>;
613
582
  }): Promise<TrialBalanceReport>;
614
583
  //#endregion
615
- export { TrialBalanceRow as $, generateDimensionBreakdown as A, BalanceSheetReport as B, FiscalReopenResult as C, DimensionBreakdownParams as D, DimensionBreakdownOptions as E, BudgetVsActualReport as F, IncomeStatementReport as G, CashFlowSection as H, BudgetVsActualRow as I, ReportCategory as J, LedgerEntry as K, generateBudgetVsActual as L, generateCashFlow as M, BudgetVsActualOptions as N, DimensionBreakdownReport as O, BudgetVsActualParams as P, TrialBalanceReport as Q, BalanceSheetOptions as R, FiscalCloseResult as S, reopenFiscalPeriod as T, GeneralLedgerAccount as U, CashFlowReport as V, GeneralLedgerReport as W, TaxReport as X, ReportGroup as Y, TaxReturnSummary as Z, IncomeStatementOptions as _, RevaluationReport as a, DEFAULT_BUCKETS as at, generateGeneralLedger as b, RevaluationRate as c, defaultLogger as ct, computeRevaluation as d, AgedBalanceOptions as et, PartnerLedgerLine as f, generatePartnerLedger as g, PartnerLedgerReport as h, RevaluationParams as i, AgedBucketConfig as it, CashFlowOptions as j, DimensionBreakdownRow as k, RevaluationResult as l, PartnerLedgerParams as m, generateTrialBalance as n, AgedBalanceReport as nt, generateRevaluation as o, generateAgedBalance as ot, PartnerLedgerOptions as p, ReportAccount as q, RevaluationOptions as r, AgedBalanceRow as rt, AccountForeignBalance as s, Logger as st, TrialBalanceOptions as t, AgedBalanceParams as tt, buildRevaluationEntry as u, generateIncomeStatement as v, closeFiscalPeriod as w, FiscalCloseOptions as x, GeneralLedgerOptions as y, generateBalanceSheet as z };
584
+ export { AgedBalanceParams as $, generateDimensionBreakdown as A, BalanceSheetReport as B, FiscalReopenResult as C, DimensionBreakdownParams as D, DimensionBreakdownOptions as E, BudgetVsActualReport as F, IncomeStatementReport as G, CashFlowSection as H, BudgetVsActualRow as I, ReportCategory as J, LedgerEntry as K, generateBudgetVsActual as L, generateCashFlow as M, BudgetVsActualOptions as N, DimensionBreakdownReport as O, BudgetVsActualParams as P, AgedBalanceOptions as Q, BalanceSheetOptions as R, FiscalCloseResult as S, reopenFiscalPeriod as T, GeneralLedgerAccount as U, CashFlowReport as V, GeneralLedgerReport as W, TrialBalanceReport as X, ReportGroup as Y, TrialBalanceRow as Z, IncomeStatementOptions as _, RevaluationReport as a, Logger as at, generateGeneralLedger as b, RevaluationRate as c, computeRevaluation as d, AgedBalanceReport as et, PartnerLedgerLine as f, generatePartnerLedger as g, PartnerLedgerReport as h, RevaluationParams as i, generateAgedBalance as it, CashFlowOptions as j, DimensionBreakdownRow as k, RevaluationResult as l, PartnerLedgerParams as m, generateTrialBalance as n, AgedBucketConfig as nt, generateRevaluation as o, defaultLogger as ot, PartnerLedgerOptions as p, ReportAccount as q, RevaluationOptions as r, DEFAULT_BUCKETS as rt, AccountForeignBalance as s, TrialBalanceOptions as t, AgedBalanceRow as tt, buildRevaluationEntry as u, generateIncomeStatement as v, closeFiscalPeriod as w, FiscalCloseOptions as x, GeneralLedgerOptions as y, generateBalanceSheet as z };
@@ -1,6 +1,6 @@
1
1
  # Country Packs
2
2
 
3
- A country pack provides everything country-specific: chart of accounts template, tax codes, tax report templates, and region definitions.
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 | Tax Codes |
20
+ | Package | Country | Account Types | Notes |
20
21
  |---|---|---|---|
21
- | `@classytic/ledger-ca` | Canada | GIFI (CRA) | GST/HST/PST/QST |
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, TaxCode } from '@classytic/ledger';
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-Assets',
38
- mainType: 'Assets',
39
- normalBalance: 'debit',
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
- taxCodes: {
46
- STATE_TAX: {
47
- code: 'STATE_TAX',
48
- name: 'State Sales Tax',
49
- taxType: 'sales',
50
- rate: 0.06,
51
- direction: 'collected',
52
- description: 'State sales tax',
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
- taxCodes: Record<string, TaxCode>;
71
- taxCodesByRegion: Record<string, string[]>;
72
- regions: string[];
73
- taxReport?: TaxReportTemplate;
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
- getTaxCodesForRegion(region: string): TaxCode[];
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-Assets', 'Income Statement-Income'
92
- mainType: MainType; // 'Assets', 'Liabilities', 'Equity', etc.
93
- normalBalance: 'debit' | 'credit';
94
- isGroup: boolean; // structural grouping header (not postable)
95
- isTotal: boolean; // calculated total row (not postable)
96
- cashFlowCategory?: string; // 'Operating', 'Investing', 'Financing'
97
- taxMetadata?: TaxMetadata; // for virtual tax sub-accounts
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 === false && isTotal === false` are posting accounts.
114
+ Only accounts where `isGroup !== true && isTotal !== true` are posting accounts.
102
115
 
103
- ## TaxCode Structure
116
+ ## JournalTemplate Structure
104
117
 
105
118
  ```typescript
106
- interface TaxCode {
107
- code: string;
119
+ interface JournalTemplate {
120
+ code: string; // 'SALES', 'PURCHASE', 'BANK', ...
108
121
  name: string;
109
- taxType: string; // e.g. 'GST', 'HST', 'PST'
110
- rate: number; // e.g. 0.05 for 5%
111
- direction: 'collected' | 'recoverable' | 'paid';
112
- province?: string;
113
- reportLines?: number[]; // lines on the tax return
114
- description: string;
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 (account types, tax codes) |
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`. The ledger provides **type-only posting contracts** — your application code is responsible for wiring subledgers to the journal entry repository.
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 — country packs define tax codes, not tax logic | Yes — compute tax amounts before posting |
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
- 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:
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. 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
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** — country packs provide tax code catalogs, not calculation engines
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
+ ```