@happyvertical/smrt-ledgers
Double-entry accounting with chart of accounts, journals, and financial reporting.
Overview
smrt-ledgers is a production-ready double-entry accounting system built on the SMRT framework. It provides the core accounting engine for financial systems, commerce modules, and business applications that need to track monetary transactions with proper accounting principles.
The module enforces double-entry bookkeeping rules (debits = credits), supports hierarchical chart of accounts, multi-currency transactions, and tracks journal lifecycle from draft to posted to voided. Running balances and trial balances are calculated automatically from posted entries.
Installation
npm install @happyvertical/smrt-ledgers
# or
pnpm add @happyvertical/smrt-ledgersThe module depends on @happyvertical/smrt-core for SmrtObject and SmrtCollection base classes.
It integrates seamlessly with smrt-tenancy for multi-tenant isolation and smrt-commerce for automatic transaction recording.
Quick Start (5 Minutes)
Here's a minimal example showing how to set up a chart of accounts, create a journal entry, and post a transaction:
1. Initialize Collections
import { AccountCollection, JournalCollection, JournalEntryCollection } from '@happyvertical/smrt-ledgers';
const accounts = new AccountCollection({ db: {...} });
const journals = new JournalCollection({ db: {...} });
const entries = new JournalEntryCollection({ db: {...} });2. Create Chart of Accounts
// Create asset account (debit-normal)
const cashAccount = await accounts.create({
number: '1000',
name: 'Cash',
type: 'asset',
description: 'Cash on hand and in bank'
});
// Create revenue account (credit-normal)
const salesAccount = await accounts.create({
number: '4000',
name: 'Sales Revenue',
type: 'revenue',
description: 'Product and service sales'
});3. Create and Post a Journal Entry
// Record a $150 cash sale
const journal = await journals.createWithEntries({
date: new Date('2025-01-12'),
description: 'Cash sale - Product #123',
entries: [
{
accountId: cashAccount.id,
debit: 150,
credit: 0,
currency: 'USD',
memo: 'Cash received'
},
{
accountId: salesAccount.id,
debit: 0,
credit: 150,
currency: 'USD',
memo: 'Revenue recognized'
}
]
});
// Post the journal (validates balance first)
await journal.post();
console.log('Journal posted:', journal.number); // "JNL-1736640000000-abc123"4. Query Account Balances
// Get current balance for cash account
const cashBalance = await entries.getAccountBalance(cashAccount.id);
console.log('Cash balance:', cashBalance); // 150 (debit-normal, so positive)
// Get trial balance for all accounts
const trialBalance = await entries.getTrialBalance();
console.log('Trial balance:', trialBalance);
// [
// { accountNumber: '1000', accountName: 'Cash', debit: 150, credit: 0 },
// { accountNumber: '4000', accountName: 'Sales Revenue', debit: 0, credit: 150 }
// ]
// Total debits: 150, Total credits: 150 ✓Core Concepts
Double-Entry Bookkeeping
Every transaction must balance: Total Debits = Total Credits. The system enforces this rule before allowing journals to be posted. A floating-point tolerance (BALANCE_EPSILON = 0.001) is used for comparisons.
Account Types
The five fundamental account types follow standard accounting principles. Each type has a "normal balance" (debit or credit) that determines how increases and decreases are recorded:
1. Asset Accounts (Debit-Normal)
Resources owned by the business. Increased by debits, decreased by credits.
Examples: Cash, Bank Accounts, Accounts Receivable, Inventory, Equipment
Balance = Debits - Credits
2. Liability Accounts (Credit-Normal)
Obligations owed to others. Increased by credits, decreased by debits.
Examples: Accounts Payable, Credit Cards, Loans, Accrued Expenses
Balance = Credits - Debits
3. Equity Accounts (Credit-Normal)
Owner's stake in the business. Increased by credits, decreased by debits.
Examples: Owner Capital, Retained Earnings, Common Stock
Balance = Credits - Debits
4. Revenue Accounts (Credit-Normal)
Income earned from operations. Increased by credits, decreased by debits.
Examples: Sales, Service Income, Interest Income, Rental Income
Balance = Credits - Debits
5. Expense Accounts (Debit-Normal)
Costs of doing business. Increased by debits, decreased by credits.
Examples: Rent, Utilities, Supplies, Cost of Goods Sold, Salaries
Balance = Debits - Credits
Hierarchical Chart of Accounts
Accounts can be organized in parent-child hierarchies for better organization and reporting:
// Create parent account
const assets = await accounts.create({
number: '1000',
name: 'Assets',
type: 'asset'
});
// Create child account
const currentAssets = await assets.createChild({
number: '1100',
name: 'Current Assets'
});
// Create grandchild
const cash = await currentAssets.createChild({
number: '1110',
name: 'Cash'
});
// Get full path
console.log(await cash.getFullPath()); // "Assets > Current Assets > Cash"Journal Lifecycle
Journals progress through three states:
Draft Status
Editable state where entries can be added or removed. Journals start in draft and must balance before posting. Draft journals do not affect account balances.
Posted Status
Immutable, locked state after validation. Posted journals are included in balance calculations.
The postedAt timestamp is recorded. Cannot be edited or deleted.
Voided Status
Marked as cancelled with reason. Voided journals are excluded from balance calculations.
The voidedAt timestamp and voidReason are recorded.
// Create draft journal
const journal = await journals.createWithEntries({ /* ... */ });
console.log(journal.isDraft()); // true
// Add more entries while in draft
await journal.addEntry({
accountId: taxAccount.id,
debit: 10,
credit: 0,
memo: 'Sales tax'
});
// Post when ready (validates balance)
await journal.post();
console.log(journal.isPosted()); // true
console.log(journal.isEditable()); // false
// Void if needed
await journal.void('Entry error - wrong amount');
console.log(journal.isVoided()); // trueAPI Reference
Account Model
class Account extends SmrtObject {
// Core properties
number: string // e.g., "1000", "5030"
name: string // e.g., "Cash", "Coffee Expense"
description: string
type: AccountType // 'asset' | 'liability' | 'equity' | 'revenue' | 'expense'
parentId: string | null // Parent account for hierarchy
active: boolean
metadata: Record<string, unknown>
// Type-checking methods
isDebitNormal(): boolean // true for asset, expense
isCreditNormal(): boolean // true for liability, equity, revenue
isTopLevel(): boolean
// Hierarchy navigation
async getParent(): Account | null
async getChildren(): Account[]
async getAncestors(): Account[]
async getFullPath(): string // "Assets > Current > Cash"
async toTreeNode(): AccountTreeNode
async createChild(options): Account
// Balance queries
async getBalance(asOfDate?: Date): number
}Journal Model
class Journal extends SmrtObject {
// Core properties
number: string // Auto-generated JNL-{timestamp}-{random}
date: Date
description: string
sourceModule: string // e.g., "smrt-commerce", "manual"
sourceRef: string | null // External reference
status: JournalStatus // 'draft' | 'posted' | 'voided'
postedAt: Date | null
voidedAt: Date | null
voidReason: string | null
metadata: Record<string, unknown>
// Status checks
isDraft(): boolean
isPosted(): boolean
isVoided(): boolean
isEditable(): boolean // Only true for draft
// Entry operations
async getEntries(): JournalEntry[]
async addEntry(data: JournalEntryData): void // Draft-only
// Balance validation
async getTotalDebits(): number
async getTotalCredits(): number
async isBalanced(): boolean
// State transitions
async post(): void // Validates balance first
async void(reason: string): void
// Summary
async summarize(): string // AI-generated summary
}JournalEntry Model
class JournalEntry extends SmrtObject {
// Core properties
journalId: string // Parent journal
accountId: string // Account affected
debit: number // Left side (0 if credit)
credit: number // Right side (0 if debit)
currency: string // e.g., "USD", "EUR"
exchangeRate: number // To base currency
memo: string
metadata: Record<string, unknown>
// Amount checks
isDebit(): boolean
isCredit(): boolean
getAmount(): number // Positive value
getBaseAmount(): number // Amount * exchangeRate
// Navigation
async getJournal(): Journal | null
async getAccount(): Account | null
async getDescription(): string // "DR Cash: $100 - Sales"
}AccountCollection Methods
// Find accounts
await accounts.findByNumber('1000')
await accounts.findByType('asset')
await accounts.findActive()
await accounts.findTopLevel()
await accounts.findChildren(parentId)
// Organization
await accounts.getTree() // Full tree structure
await accounts.groupByType() // Organized by type
await accounts.getDescendants(accountId) // All children recursively
// Create or retrieve
await accounts.getOrCreateByNumber('1000', {
name: 'Cash',
type: 'asset'
})JournalCollection Methods
// Find journals
await journals.findByNumber('JNL-123')
await journals.findByDateRange(startDate, endDate)
await journals.findBySource('smrt-commerce')
await journals.findByStatus('posted')
await journals.findDrafts()
await journals.findPosted()
await journals.findBySourceRef(orderId)
await journals.findByMonth(2025, 1)
// Create and manage
await journals.createWithEntries({
date: new Date(),
description: 'Transaction',
entries: [...]
})
await journals.post(journalId)
await journals.void(journalId, 'Reason')JournalEntryCollection Methods
// Find entries
await entries.findByJournal(journalId)
await entries.findByAccount(accountId)
await entries.findByAccounts([id1, id2]) // Avoids N+1
// Balance calculations
await entries.getAccountBalance(accountId, asOfDate)
// Returns signed balance: Asset/Expense = Debits - Credits
// Liability/Equity/Revenue = Credits - Debits
await entries.getTrialBalance(asOfDate)
// Returns: Array<{
// accountNumber: string,
// accountName: string,
// debit: number,
// credit: number
// }>
await entries.getTotalsForDateRange(startDate, endDate)
// Returns: { totalDebits: number; totalCredits: number }
await entries.getAccountLedger(accountId)
// Returns: Array<{
// entry: JournalEntry,
// runningBalance: number
// }>Tutorials
Tutorial 1: Basic Ledger Setup and First Transaction (10-15 min)
Learn the fundamentals:
- Initialize account and journal collections
- Create simple chart of accounts (Cash, Sales Revenue)
- Create a draft journal with two entries
- Validate balance and post the journal
- Query account balances
Tutorial 2: Chart of Accounts and Account Management (15-20 min)
Build hierarchical account structures:
- Create parent-child account relationships (Assets > Current > Cash)
- Use getOrCreateByNumber for idempotent operations
- Organize accounts by type with groupByType()
- Display account tree structure
- Navigate hierarchy with getFullPath()
Tutorial 3: Complex Multi-Entry Transactions (15-20 min)
Handle real-world scenarios:
- Record product purchase with inventory tracking
- Account for Cost of Goods Sold (COGS)
- Handle tax entries in multi-entry journals
- Manage draft → posted workflow
- Void transactions with detailed reasons
Tutorial 4: Reporting and Reconciliation (15-20 min)
Generate financial reports:
- Generate trial balance reports
- Calculate account balances as of specific dates
- Create account ledgers with running balances
- Calculate totals for date ranges
- Integrate with external source modules
Real-World Examples
Example 1: Small Business Daily Operations
Track daily transactions for a small business:
// Morning: Owner invests $5,000 cash
await journals.createWithEntries({
date: new Date('2025-01-12 09:00'),
description: 'Initial capital investment',
entries: [
{ accountId: cashId, debit: 5000, credit: 0, memo: 'Owner investment' },
{ accountId: equityId, debit: 0, credit: 5000, memo: 'Owner capital' }
]
}).then(j => j.post());
// Purchase coffee supplies on credit card
await journals.createWithEntries({
date: new Date('2025-01-12 10:30'),
description: 'Coffee supplies purchase',
entries: [
{ accountId: suppliesId, debit: 50, credit: 0, memo: 'Coffee beans' },
{ accountId: creditCardId, debit: 0, credit: 50, memo: 'Business credit card' }
]
}).then(j => j.post());
// Product sold for $150 cash
await journals.createWithEntries({
date: new Date('2025-01-12 14:00'),
description: 'Cash sale - Product #123',
entries: [
{ accountId: cashId, debit: 150, credit: 0, memo: 'Cash received' },
{ accountId: salesId, debit: 0, credit: 150, memo: 'Revenue' }
]
}).then(j => j.post());
// Record COGS for inventory sold
await journals.createWithEntries({
date: new Date('2025-01-12 14:00'),
description: 'COGS for sale #123',
entries: [
{ accountId: cogsId, debit: 60, credit: 0, memo: 'Cost of goods' },
{ accountId: inventoryId, debit: 0, credit: 60, memo: 'Inventory reduction' }
]
}).then(j => j.post());
// End-of-day balance check
const cashBalance = await entries.getAccountBalance(cashId);
console.log('Cash on hand:', cashBalance); // $5,100 (5000 + 150 - 50)
const apBalance = await entries.getAccountBalance(creditCardId);
console.log('Credit card payable:', apBalance); // $50Example 2: SaaS Subscription Billing
Handle recurring subscription revenue:
// Set up revenue accounts
const subscriptionRevenue = await accounts.create({
number: '4100',
name: 'Subscription Revenue',
type: 'revenue'
});
const accountsReceivable = await accounts.create({
number: '1200',
name: 'Accounts Receivable',
type: 'asset'
});
// Bill customer for monthly subscription
const invoice = await journals.createWithEntries({
date: new Date('2025-01-01'),
description: 'Monthly subscription - Customer ABC',
sourceModule: 'smrt-commerce',
sourceRef: 'invoice-12345',
entries: [
{
accountId: accountsReceivable.id,
debit: 99,
credit: 0,
memo: 'Invoice #12345'
},
{
accountId: subscriptionRevenue.id,
debit: 0,
credit: 99,
memo: 'Monthly Pro plan'
}
]
});
await invoice.post();
// When payment received
const payment = await journals.createWithEntries({
date: new Date('2025-01-05'),
description: 'Payment received - Customer ABC',
sourceModule: 'smrt-commerce',
sourceRef: 'payment-67890',
entries: [
{ accountId: cashId, debit: 99, credit: 0, memo: 'Payment received' },
{ accountId: accountsReceivable.id, debit: 0, credit: 99, memo: 'Invoice paid' }
]
});
await payment.post();
// Calculate monthly recurring revenue
const revenueBalance = await entries.getAccountBalance(
subscriptionRevenue.id,
new Date('2025-01-31')
);
console.log('January MRR:', revenueBalance);Example 3: Multi-Currency E-Commerce
Handle international transactions with exchange rates:
// Order received in EUR (€100) with exchange rate 1.1
const orderJournal = await journals.createWithEntries({
date: new Date('2025-01-10'),
description: 'International order - Customer EU',
sourceModule: 'smrt-commerce',
sourceRef: 'order-999',
entries: [
{
accountId: accountsReceivable.id,
debit: 100,
credit: 0,
currency: 'EUR',
exchangeRate: 1.1,
memo: 'Order in EUR'
},
{
accountId: salesRevenue.id,
debit: 0,
credit: 100,
currency: 'EUR',
exchangeRate: 1.1,
memo: 'Revenue in EUR'
}
]
});
await orderJournal.post();
// Payment received in USD: $110 (100 * 1.1)
const paymentJournal = await journals.createWithEntries({
date: new Date('2025-01-15'),
description: 'Payment received - Customer EU',
entries: [
{
accountId: cashId,
debit: 110,
credit: 0,
currency: 'USD',
exchangeRate: 1.0,
memo: 'USD payment'
},
{
accountId: accountsReceivable.id,
debit: 0,
credit: 110,
currency: 'USD',
exchangeRate: 1.0,
memo: 'AR paid in USD (base amount = 100 EUR * 1.1)'
}
]
});
await paymentJournal.post();
// Both entries have same base amount (110 USD), so they balance correctlyExample 4: Month-End Close Process
Generate reports and close the month:
// Generate trial balance for January
const janTrialBalance = await entries.getTrialBalance(
new Date('2025-01-31')
);
console.log('Trial Balance - January 2025');
console.log('================================');
janTrialBalance.forEach(row => {
console.log(
row.accountNumber.padEnd(6),
row.accountName.padEnd(30),
'DR:', row.debit.toFixed(2).padStart(10),
'CR:', row.credit.toFixed(2).padStart(10)
);
});
// Verify balance
const totalDebits = janTrialBalance.reduce((sum, row) => sum + row.debit, 0);
const totalCredits = janTrialBalance.reduce((sum, row) => sum + row.credit, 0);
console.log('Total Debits:', totalDebits.toFixed(2));
console.log('Total Credits:', totalCredits.toFixed(2));
console.log('Balanced:', Math.abs(totalDebits - totalCredits) < 0.01 ? '✓' : '✗');
// Post accrual entries for month-end
await journals.createWithEntries({
date: new Date('2025-01-31'),
description: 'Accrued rent expense',
entries: [
{ accountId: rentExpenseId, debit: 2000, credit: 0, memo: 'January rent' },
{ accountId: accruedExpensesId, debit: 0, credit: 2000, memo: 'Accrued rent' }
]
}).then(j => j.post());
// Get account ledger with running balance
const cashLedger = await entries.getAccountLedger(cashId);
console.log('Cash Ledger:');
cashLedger.forEach(({ entry, runningBalance }) => {
console.log(
entry.date.toISOString().split('T')[0],
entry.memo.padEnd(30),
entry.isDebit() ? '+' + entry.getAmount() : '-' + entry.getAmount(),
'Balance:', runningBalance.toFixed(2)
);
});Example 5: Integration with Commerce Module
Automatically create journals from commerce transactions:
// In your smrt-commerce order processing
import { JournalCollection } from '@happyvertical/smrt-ledgers';
async function recordOrderSale(order: Order) {
const journals = new JournalCollection({ db: {...} });
// Create journal for the sale
const saleJournal = await journals.createWithEntries({
date: order.createdAt,
description: 'Sale - Order #' + order.number,
sourceModule: 'smrt-commerce',
sourceRef: order.id,
entries: [
// Record revenue
{
accountId: cashAccountId,
debit: order.total,
credit: 0,
currency: order.currency,
memo: 'Payment received'
},
{
accountId: salesRevenueId,
debit: 0,
credit: order.subtotal,
currency: order.currency,
memo: 'Product sales'
},
// Record tax collected
{
accountId: salesTaxPayableId,
debit: 0,
credit: order.taxAmount,
currency: order.currency,
memo: 'Sales tax collected'
}
]
});
await saleJournal.post();
// Create separate journal for COGS
const cogsJournal = await journals.createWithEntries({
date: order.createdAt,
description: 'COGS - Order #' + order.number,
sourceModule: 'smrt-commerce',
sourceRef: order.id,
entries: [
{
accountId: cogsAccountId,
debit: order.costOfGoods,
credit: 0,
memo: 'Cost of goods sold'
},
{
accountId: inventoryAccountId,
debit: 0,
credit: order.costOfGoods,
memo: 'Inventory reduction'
}
]
});
await cogsJournal.post();
return { saleJournal, cogsJournal };
}Integration Patterns
With smrt-core
All models extend SmrtObject for standard CRUD, validation, and lifecycle hooks.
Collections extend SmrtCollection for querying, pagination, and advanced filtering.
@smrtdecorator generates API endpoints, MCP tools, and CLI commands- Automatic schema generation from TypeScript types
- Built-in validation and error handling
- Lifecycle hooks: beforeSave, afterSave, beforeDelete
With smrt-commerce
Commerce orders automatically create ledger entries for sales, COGS, and inventory tracking:
- Orders trigger journal creation via
sourceModule = "smrt-commerce" - Automatic COGS calculation and inventory reduction
- Revenue and tax accounts updated per order
- Source tracking with
sourceRef = orderId
With smrt-tenancy
Ledgers are automatically isolated per tenant using @TenantScoped decorator:
- Each tenant has separate chart of accounts
- Journals and entries filtered by tenant context
- Shared schema, separate data per tenant
- Tenant ID injected automatically via AsyncLocalStorage
With smrt-users
User permissions control ledger access and modifications:
- RBAC controls who can create/post/void journals
- Audit trail tracks
createdByandupdatedBy - Permission checks before posting or voiding
- User-specific ledger views based on roles
With smrt-profiles
Organization profiles link to chart of accounts:
- Profile metadata stores company-specific account numbering
- Multi-entity organizations have separate account trees
- Profile-level fiscal year and currency settings
Best Practices
Account Numbering
- Use standardized ranges (1000-1999 Assets, 2000-2999 Liabilities, etc.)
- Include consistent leading zeros (e.g., "1000" not "1000")
- Reserve ranges for future sub-accounts (1100-1199 for Current Assets)
- Document your numbering scheme in organization metadata
Data Validation
- Always create accounts before creating journal entries
- Use
getOrCreateByNumber()for idempotent account setup - Validate journal balance before calling
post() - Check account
activestatus before use - Verify source references exist before linking
Posting Workflow
- Keep journals in draft while building entries
- Validate complete transaction logic before posting
- Post only when all entries are finalized and balanced
- Never attempt to modify after posting (immutable)
- Use
void()with detailed reasons for corrections
Performance
- Use
findByAccounts()for bulk queries (avoids N+1) - Cache account tree structure for frequent access
- Filter by date early in queries (use indexed columns)
- Include only active accounts in reports
- Consider archiving old journals for historical data
Multi-Currency
- Always set
exchangeRatefor non-base currency entries - Store amounts in original currency for audit trail
- Calculate base amount:
amount * exchangeRate - Track realized and unrealized gains/losses separately
- Use consistent decimal precision (2 places for most currencies)
Common Mistakes to Avoid
- Don't create entries with both debit and credit amounts
- Don't modify journal entries after posting (immutable)
- Don't delete accounts with existing entries (set inactive instead)
- Don't include draft journals in balance calculations
- Don't forget to post journals (draft won't affect balances)
- Don't use negative amounts (use opposite side instead)
Common Issues and Troubleshooting
Error: "Journal is not balanced"
Cause: Total debits ≠ total credits
Solution: Check all entry amounts. Ensure the sum of debits equals the sum of credits
before calling post(). Use journal.getTotalDebits() and journal.getTotalCredits() to debug.
const debits = await journal.getTotalDebits();
const credits = await journal.getTotalCredits();
console.log('Debits:', debits, 'Credits:', credits);
console.log('Difference:', Math.abs(debits - credits));Error: "Cannot add entries to posted journal"
Cause: Trying to modify a posted (immutable) journal
Solution: Posted journals cannot be edited. Void the journal with a reason and create a new correcting journal instead.
// Void the incorrect journal
await journal.void('Entry error - wrong amount');
// Create new correcting journal
const corrected = await journals.createWithEntries({...});
await corrected.post();Error: "Account not found"
Cause: Referenced account doesn't exist or hasn't been saved
Solution: Create and save the account before creating journal entries that reference it.
// Create account first
const account = await accounts.create({
number: '1000',
name: 'Cash',
type: 'asset'
});
// Now use account.id in entries
await journals.createWithEntries({
entries: [{ accountId: account.id, ... }]
});Error: "Entry cannot have both debit and credit"
Cause: Entry validation failed
Solution: Each entry must be debit-only OR credit-only (never both). Set the unused side to 0.
// Correct: debit-only
{ accountId: cashId, debit: 100, credit: 0 }
// Correct: credit-only
{ accountId: salesId, debit: 0, credit: 100 }
// WRONG: both sides
{ accountId: cashId, debit: 100, credit: 50 } // ✗ ErrorIssue: Unexpected balance calculation
Cause: Including draft or voided journals in calculation
Solution: Only posted journals affect balances. Draft and voided journals are excluded.
Check journal status with isPosted().
// Only posted journals count
const balance = await entries.getAccountBalance(accountId);
// Check which journals are included
const posted = await journals.findPosted();
console.log('Posted journals:', posted.length);Issue: Balance includes future transactions
Cause: Missing asOfDate parameter
Solution: Use asOfDate to calculate balance up to specific date.
// Get balance as of January 31st
const janBalance = await entries.getAccountBalance(
accountId,
new Date('2025-01-31')
);
// Get current balance (all posted entries)
const currentBalance = await entries.getAccountBalance(accountId);Issue: Cannot create child account
Cause: Parent account hasn't been saved yet
Solution: Call save() on parent before calling createChild().
// Create and save parent first
const parent = await accounts.create({
number: '1000',
name: 'Assets',
type: 'asset'
});
// Now create child
const child = await parent.createChild({
number: '1100',
name: 'Current Assets'
});Related Modules
- smrt-core - Base classes, decorators, and AI integration
- smrt-tenancy - Multi-tenant ledger isolation
- smrt-commerce - Automatic journal creation from orders
- smrt-users - RBAC permissions for ledger access