Skip to main content

@happyvertical/smrt-ledgers

Double-entry accounting ledger for the s-m-r-t framework. Hierarchical chart of accounts, journal lifecycle with immutability after posting, and balance enforcement with epsilon tolerance.

Installation

pnpm add @happyvertical/smrt-ledgers

Usage

import {
Account, AccountCollection,
Journal, JournalCollection,
JournalEntry, JournalEntryCollection
} from '@happyvertical/smrt-ledgers';

// Set up chart of accounts
const accounts = await AccountCollection.create({ db });
const cash = await accounts.create({
number: '1000',
name: 'Cash',
type: 'asset',
});
await cash.save();

const revenue = await accounts.create({
number: '4000',
name: 'Sales Revenue',
type: 'revenue',
});
await revenue.save();

// Create a sub-account under Cash
const checking = await cash.createChild({
number: '1010',
name: 'Checking Account',
});

// Create a balanced journal with entries
const journals = await JournalCollection.create({ db });
const journal = await journals.createWithEntries({
description: 'Cash sale',
sourceModule: 'manual',
entries: [
{ accountId: cash.id, debit: 100.00 },
{ accountId: revenue.id, credit: 100.00 },
],
});

// Post the journal (validates balance, then immutable)
await journal.post();

// Query balances
const cashBalance = await cash.getBalance();

// Get trial balance across all active accounts
const entries = await JournalEntryCollection.create({ db });
const trialBalance = await entries.getTrialBalance();

// Void a journal (cannot edit after posting, only void)
await journal.void('Duplicate entry');

Double-Entry Accounting

Every journal must balance before it can be posted. The balance check uses BALANCE_EPSILON = 0.001 to handle floating-point rounding:

Math.abs(totalDebits - totalCredits) < 0.001

Account types follow standard accounting rules:

  • Debit-normal (Asset, Expense): balance = debits - credits
  • Credit-normal (Liability, Equity, Revenue): balance = credits - debits

Journal Lifecycle

Journals follow a strict draft -> posted -> voided lifecycle:

  • Draft: editable, entries can be added via journal.addEntry()
  • Posted: immutable, balance validated, postedAt timestamp set
  • Voided: marked with voidReason and voidedAt, cannot be edited or re-posted

Each JournalEntry must have either a debit or a credit (not both, not zero). Amounts must be non-negative. Multi-currency is supported via exchangeRate on each entry.

API

Models

ExportDescription
AccountChart of accounts entry with type, number, hierarchical parent, and balance queries
JournalTransaction journal with status lifecycle, auto-numbered (JNL-*), sourceModule/sourceRef for cross-package attribution
JournalEntryIndividual debit or credit line within a journal, with currency and exchange rate

Collections

ExportKey Methods
AccountCollectionfindChildren(), findActive()
JournalCollectioncreateWithEntries(), findByNumber(), findByDateRange(), findBySource(), findByStatus(), findDrafts(), findPosted()
JournalEntryCollectionfindByJournal(), findByAccount(), getAccountBalance(), getTrialBalance(), getAccountLedger(), getTotalsForDateRange()

Types

ExportDescription
AccountType'asset', 'liability', 'equity', 'revenue', 'expense'
JournalStatus'draft', 'posted', 'voided'
AccountOptionsOptions for creating an Account
JournalOptionsOptions for creating a Journal
JournalEntryOptionsOptions for creating a JournalEntry
JournalEntryDataEntry data for addEntry() / createWithEntries()
CreateJournalDataFull journal + entries creation payload
TrialBalanceRowRow in trial balance report (accountId, number, name, type, debit/credit balances)
AccountTreeTree of account nodes (roots array)
AccountTreeNodeSingle node in account tree (account + children)

Dependencies

  • @happyvertical/smrt-core -- ORM and code generation
  • @happyvertical/smrt-tenancy -- multi-tenant scoping

Contributor guide

See AGENTS.md for package architecture, invariants, validation, and contributor guidance.