TypeScript SDK for the OHADA/SYSCOHADA chart of accounts
O(1) lookups · fluent query builder · custom accounts · i18n · export · zero dependencies
OhadaKit is a production-ready SDK for working with the OHADA/SYSCOHADA accounting plan — the standardised chart of accounts used across 17 West and Central African states. It ships the full 1 000+ account tree out of the box and exposes a composable, type-safe API for lookups, querying, custom extensions, multilingual names, and data export.
Live demo: https://ohadakit.claviscore.com
It manages the chart of accounts. Journal entries, balances, and financial statements belong in your application.
- Table of Contents
- Features
- Installation
- Quick Start
- Core API
- Integrating with an Accounting App
- OHADA Chart Structure
- Environment Support
- Contributing
- License
| Complete chart | All 1 000+ official OHADA accounts across 9 classes |
| O(1) lookups | Map-based indexes; Trie for fast prefix search |
| Fluent query builder | Filter by class, level, parent, name, regex, or custom predicate |
| Fuzzy search | Typo-tolerant name search with configurable threshold |
| Account tree | Parent, children, ancestors, siblings, path — lazily computed |
| i18n | French, English, Portuguese, Spanish with graceful fallback |
| Custom accounts | Non-destructive overlay for sub-accounts and label overrides |
| Pluggable storage | Notes on any account; memory and localStorage adapters included |
| Snapshot / restore | Serialisable chart state for database persistence |
| Export | JSON (flat or hierarchical) and CSV |
| Type-safe | Branded types, Result<T, E> error handling, full generics |
| Zero dependencies | No runtime dependencies |
# npm
npm install ohadakit
# yarn
yarn add ohadakit
# pnpm
pnpm add ohadakitimport { LedgerEngine } from 'ohadakit';
const ledger = new LedgerEngine();
// Safe lookup — returns a Result<Account>
const result = ledger.get('4111');
if (result.ok) {
console.log(result.data.name); // "Clients"
console.log(result.data.pathString); // "41 > 411 > 4111"
}
// Convenience accessors
const account = ledger.getOrNull('4111'); // Account | null
const account2 = ledger.getOrThrow('4111'); // Account (throws on miss)
// Fluent query builder
const staffExpenses = ledger
.query()
.inClass('6')
.atLevel(3)
.nameContains('personnel')
.sortBy('code', 'asc')
.execute(); // Account[]// Safe — never throws
ledger.get('4111') // Result<Account>
ledger.getOrNull('4111') // Account | null
// Eager — throws AccountNotFoundError or InvalidAccountCodeFormatError
ledger.getOrThrow('4111') // Account
// Registry helpers
ledger.registry.has('4111') // boolean
ledger.registry.getByClass('4') // Account[]
ledger.registry.getByLevel(3) // Account[]
ledger.registry.searchByPrefix('41') // Account[] (Trie-based)ledger
.query()
.inClass(['4', '5']) // one or more classes
.atLevel([3, 4]) // one or more levels
.withParent('41') // direct children only
.nameContains('client') // case-insensitive substring
.codeMatches(/^6[0-3]/) // regex on code
.where(a => a.isLeaf) // arbitrary predicate
.sortBy('code', 'asc') // 'code' | 'name' | 'level'
.offset(0).limit(25) // pagination
.execute(); // → Account[]
// Aggregation shortcuts
ledger.query().inClass('4').count(); // number
ledger.query().inClass('4').first(); // Account | null
ledger.query().inClass('4').exists(); // boolean
// Fuzzy / typo-tolerant search
ledger
.query()
.search('cliens', { fuzzy: true, threshold: 0.6 })
.execute();Relationship properties are lazily computed and cached on first access.
const account = ledger.getOrThrow('4111');
account.parent // Account | null
account.children // Account[]
account.ancestors // Account[] (nearest-first)
account.siblings // Account[]
account.path // Account[] (root → self)
account.pathString // "41 > 411 > 4111"
account.isLeaf // boolean
account.isRoot // boolean
account.depth // number (1-based)
account.isDescendantOf('4') // true
account.isAncestorOf('41111') // true (if account exists)
account.getDescendants() // Account[] (all levels)
account.getDescendantsAtLevel(4) // Account[] (specific level only)Supports four OHADA locales. Names fall back to French when a translation is missing.
const ledger = new LedgerEngine({ locale: 'en' });
ledger.setLocale('pt');
ledger.getLocale(); // 'pt'
ledger.getAvailableLocales(); // ['fr', 'en', 'pt', 'es']
// Localised name with automatic fallback
ledger.getLocalizedName('4111'); // English name, or French if not translated
// Low-level TranslationService
ledger.i18n.getAccountName('10', 'Capital');
ledger.i18n.hasTranslation('10');Extend the immutable OHADA tree without modifying it. Custom accounts and label overrides live in an isolated overlay.
Rules
- 2-character main accounts (
10,11, …) cannot be created or renamed. - Custom accounts must be ≥ 3 characters and start with their parent's code.
- Any 3+-character account (official or custom) can have its label overridden.
import { CustomAccountManager, MemoryStorage } from 'ohadakit';
const manager = new CustomAccountManager({ storage: new MemoryStorage() });
await manager.initialize();
// Create a custom sub-account
await manager.createAccount({
code: '411-VIP',
name: 'Clients VIP',
parentCode: '411',
});
// Override an official account label
await manager.updateLabel('4111', 'Clients — Particuliers');
// Query the merged chart
manager.getByCode('411-VIP'); // Account
manager.getAll(); // official + custom
manager.getCustomAccounts(); // custom onlyAccountBook combines every OhadaKit feature behind a single async-initialised object. Start here for any real application.
import { AccountBook, MemoryStorage } from 'ohadakit';
const book = new AccountBook({ storage: new MemoryStorage() });
await book.initialize();
// Lookup (official + custom, overrides applied)
const account = book.getAccountOrNull('411');
// Mutate
await book.createAccount({ code: '411-VIP', name: 'Clients VIP', parentCode: '411' });
await book.updateLabel('4111', 'Clients — Particuliers');
await book.setNote('411-VIP', 'High-value clients segment');
// Export the merged chart
const json = book.exportToJSON({ pretty: true });
const csv = book.exportToCSV();
// i18n
book.setLocale('en');
book.getLocalizedName('10');
// Aggregate stats
const stats = await book.getStats();
// → { total, byClass, byLevel, customAccountCount, labelOverrideCount, noteCount }Persist the complete chart state — custom accounts, overrides, and notes — as a plain JSON object that can be stored anywhere.
// Capture
const snapshot = await book.snapshot();
// { version, timestamp, locale, customAccounts, labelOverrides, notes }
await db.put('chart-state', JSON.stringify(snapshot));
// Restore into a fresh book
const result = await book.restore(JSON.parse(savedJson));
if (!result.ok) {
console.error('Restore failed:', result.error.message);
}| Need | Use |
|---|---|
| Quick lookups on the official chart | LedgerEngine |
| Custom accounts, label overrides, notes | AccountBook |
| Snapshot / restore of chart state | AccountBook |
| Minimal setup with no persistence wiring | LedgerEngine |
Attach free-text notes to any account code. The storage backend is swappable.
import { LedgerEngine, LocalStorageAdapter } from 'ohadakit';
// Default: in-memory (no persistence)
const ledger = new LedgerEngine();
// Browser: localStorage with a namespaced prefix
const ledger = new LedgerEngine({
storage: new LocalStorageAdapter('myapp:'),
});
await ledger.setNote('5121', 'Mobile Money Orange');
await ledger.getNote('5121'); // 'Mobile Money Orange'
await ledger.hasNote('5121'); // true
await ledger.deleteNote('5121');
await ledger.getAllNotes(); // Map<string, string>Custom adapter — implement the StorageAdapter interface to integrate any backend (Redis, IndexedDB, a REST API, etc.).
// JSON
ledger.exportToJSON({ structure: 'flat', pretty: true });
ledger.exportToJSON({ structure: 'hierarchical' });
// CSV
ledger.exportToCSV({ columns: ['code', 'name', 'level'] });
ledger.exportToCSV({ delimiter: ';', includeHeader: true });
// Scoped to a single class
ledger.exportClass('4', 'json', { structure: 'flat' });
ledger.exportClass('4', 'csv', { columns: ['code', 'name'] });import { validateAccountCodeFormat, AccountNotFoundError } from 'ohadakit';
// Format check (no registry lookup required)
const fmt = validateAccountCodeFormat('4111'); // Result<string>
// Typed error handling via Result
const result = ledger.get('9999');
if (!result.ok && result.error instanceof AccountNotFoundError) {
console.error(result.error.message);
}
// Batch validation
const { valid, invalid } = ledger.validateBatch(['4111', '5121', '9999']);
// valid → [{ code, account }, …]
// invalid → [{ code, error }, …]OhadaKit manages the chart of accounts only. Journal entries, balances, and financial statements are your application's responsibility — they link to OhadaKit via account codes used as foreign keys.
┌────────────────────────┐ ┌────────────────────────┐
│ Your App │ │ OhadaKit │
│ │ │ │
│ Journal entries ─────┼─ FK ──▶ AccountBook │
│ Balances ─────┼─ FK ──▶ ├─ Official chart │
│ Trial balance ─────┼─ FK ──▶ ├─ Custom accounts │
│ Financial stmts │ │ ├─ Label overrides │
│ │ │ ├─ Notes │
│ Your DB / API │ │ └─ i18n + Export │
└────────────────────────┘ └────────────────────────┘
import { AccountBook, MemoryStorage } from 'ohadakit';
const book = new AccountBook({ storage: new MemoryStorage() });
await book.initialize();
// Validate codes before persisting journal entries
function createEntry(debitCode: string, creditCode: string, amount: number) {
if (!book.has(debitCode)) throw new Error(`Unknown account: ${debitCode}`);
if (!book.has(creditCode)) throw new Error(`Unknown account: ${creditCode}`);
return { debitCode, creditCode, amount, date: new Date() };
}
// Resolve display names
function accountLabel(code: string): string {
return book.getAccountOrNull(code)?.name ?? `Unknown (${code})`;
}
// Persist chart state alongside your application data
async function saveState(db: Database) {
const snapshot = await book.snapshot();
await db.put('ohadakit:chart-state', JSON.stringify(snapshot));
}| Concern | Owner |
|---|---|
| Official chart of accounts (1 000+ entries) | OhadaKit |
| Custom sub-accounts & label overrides | OhadaKit — AccountBook |
| Account notes | OhadaKit — AccountBook |
| Name translations (fr / en / pt / es) | OhadaKit |
| Journal entries & postings | Your app |
| Account balances & trial balance | Your app |
| Financial statements | Your app |
| Authentication & authorisation | Your app |
SYSCOHADA uses a decimal codification across 9 classes (codes 1–9). Each class digit is the first digit of the account codes within that class — it is a grouping designator, not a postable account code. Accounts proper are identified by codes of 2 to 4 digits, constituting 3 levels:
[Class — grouping designator, not a postable account]
e.g. 4 → Tiers
[Account levels — postable codes, 2 to 4 digits]
41 Main account (2 digits) ← first postable level
411 Sub-account (3 digits)
4111 Detail (4 digits) ← base maximum per SYSCOHADA
The base codification of SYSCOHADA is limited to a maximum of four digits for divisional accounts (comptes divisionnaires). Enterprises may open further subdivisions beyond four digits to meet operational needs without altering the mandatory normative codes.
| Class | Label | Notes |
|---|---|---|
| 1 | Ressources durables | Mandatory |
| 2 | Actif immobilisé | Mandatory |
| 3 | Stocks | Mandatory |
| 4 | Tiers | Mandatory |
| 5 | Trésorerie | Mandatory |
| 6 | Charges des activités ordinaires | Mandatory |
| 7 | Produits des activités ordinaires | Mandatory |
| 8 | Autres charges et autres produits | Mandatory |
| 9 | Engagements hors bilan et comptabilité analytique de gestion (CAGE) | Optional |
Class 9 is split into two sub-sections: accounts 90–91 record off-balance-sheet commitments (engagements hors bilan); accounts 92–99 are reserved for management accounting (comptabilité analytique de gestion, CAGE). Its use is explicitly optional under the SYSCOHADA Uniform Act.
| Environment | Support |
|---|---|
| Node.js ≥ 16 | ✅ ESM + CJS |
| Modern browsers | ✅ UMD + IIFE bundles via ohadakit/browser |
| TypeScript | ✅ Full type declarations included |
| Bun / Deno | ✅ ESM entry point |
Contributions are welcome. Please open an issue first to discuss significant changes.
# Clone and install
git clone https://github.com/Dahkenangnon/ohadakit.git
cd ohadakit
npm install
# Run tests
npm test
# Type-check
npm run type-check
# Build
npm run buildAll pull requests must pass npm run typecheck && npm test before review.
Apache-2.0 © Justin Dah-kenangnon