@debito/hippo-lib
Version:
Double-entry accounting library for CouchDB
422 lines (367 loc) • 14.7 kB
JavaScript
import config from './config.js';
import { ACCOUNT_TYPES, DEBIT, CREDIT } from './constants.js';
import COA from './coa.js';
// Getter to access database instance
function getDb() {
return config.db;
}
/**
* Account class for double-entry accounting system
* Provides CRUD operations and change tracking for accounts
*/
class Account {
key;
label;
balance;
accountType;
hierarchy;
tags;
_doc;
/**
* Creates an Account instance from a CouchDB document
* @param {Object} doc - CouchDB document containing account data
*/
constructor(doc){
this._doc = doc;
this.mapFields();
}
/**
* Maps document fields to instance properties
* @private
*/
mapFields(){
this.key = this._doc.key;
this.label = this._doc.label;
this.balance = this._doc.balance;
this.accountType = this._doc.accountType;
this.hierarchy = this._doc.hierarchy || '';
this.tags = this._doc.tags || [];
}
/**
* Creates a new account and saves it to the database
* @param {string} key - Account key (unique identifier)
* @param {string} label - Account label (display name)
* @param {string} accountType - Account type (asset, liability, equity, revenue, expense)
* @param {number} balance - Initial balance (default: 0)
* @param {string} hierarchy - Hierarchical path (default: '')
* @param {string[]} tags - Array of tags for categorization (default: [])
* @returns {Promise<Account>} The created account instance
*/
static async new(key, label, accountType, balance = 0, hierarchy = '', tags = []) {
if (!key || typeof key !== 'string') {
throw new Error('Key is required and must be a string');
}
if (!Object.values(ACCOUNT_TYPES).includes(accountType)) {
throw new Error(`Invalid account type: ${accountType}`);
}
if (!Array.isArray(tags)) {
throw new Error('Tags must be an array of strings');
}
// Validate hierarchy path if provided
if (hierarchy && hierarchy.trim() !== '') {
try {
const hierarchyNode = await COA.validateHierarchyPath(hierarchy);
// Check if trying to create account on a group node
if (hierarchyNode.isGroup) {
throw new Error(`Cannot create account on group hierarchy path: ${hierarchy}. Use a leaf node path instead.`);
}
} catch (error) {
throw new Error(`Hierarchy validation failed: ${error.message}`);
}
}
let doc = {
_id: `account-${key}`,
key,
label,
balance,
accountType,
hierarchy,
tags: tags.filter(tag => typeof tag === 'string'),
docType: 'account'
};
try {
const response = await getDb().insert(doc);
doc._id = response.id;
doc._rev = response.rev;
// Add account to COA settings
try {
await COA.addAccountToCOA({
key,
label,
accountType,
hierarchy,
tags: tags.filter(tag => typeof tag === 'string')
});
} catch (coaError) {
// Log warning but don't fail account creation
console.warn(`Warning: Failed to add account to COA settings: ${coaError.message}`);
}
return new Account(doc);
} catch (error) {
throw new Error(`Failed to create account: ${error.message}`);
}
}
/**
* Retrieves an account from the database by ID
* @param {string} accountId - The account ID to retrieve
* @returns {Promise<Account>} The account instance
*/
static async get(accountId) {
try {
const doc = await getDb().get(accountId);
return new Account(doc);
} catch (error) {
throw new Error(`Failed to get account: ${error.message}`);
}
}
/**
* Updates the account balance and saves to database
* @param {number} newBalance - The new balance amount
* @returns {Promise<Account>} The updated account instance
*/
async updateBalance(newBalance) {
this.balance = newBalance;
return this.save();
}
/**
* Checks if the account has unsaved changes
* @returns {boolean} True if there are unsaved changes
*/
isDirty() {
return this.key !== this._doc.key ||
this.label !== this._doc.label ||
this.balance !== this._doc.balance ||
this.accountType !== this._doc.accountType ||
this.hierarchy !== (this._doc.hierarchy || '') ||
JSON.stringify(this.tags) !== JSON.stringify(this._doc.tags || []);
}
/**
* Returns an object containing only the changed fields
* @returns {Object} Object with changed fields and their new values
*/
getChanges() {
const changes = {};
if (this.key !== this._doc.key) changes.key = this.key;
if (this.label !== this._doc.label) changes.label = this.label;
if (this.balance !== this._doc.balance) changes.balance = this.balance;
if (this.accountType !== this._doc.accountType) changes.accountType = this.accountType;
if (this.hierarchy !== (this._doc.hierarchy || '')) changes.hierarchy = this.hierarchy;
if (JSON.stringify(this.tags) !== JSON.stringify(this._doc.tags || [])) changes.tags = this.tags;
return changes;
}
/**
* Reverts all unsaved changes to the last saved state
*/
rollback() {
this.mapFields();
}
/**
* Updates account balance based on ledger entry activity
* @param {string} type - DEBIT or CREDIT
* @param {number} amount - The amount to apply
* @param {string} activity - 'add' for normal posting, 'reverse' for undoing
* @returns {Promise<Account>} The updated account instance
*/
async updateBalance(type, amount, activity = 'add') {
if (!type || !['debit', 'credit'].includes(type)) {
throw new Error('Type must be "debit" or "credit"');
}
if (typeof amount !== 'number' || amount <= 0) {
throw new Error('Amount must be a positive number');
}
if (!['add', 'reverse'].includes(activity)) {
throw new Error('Activity must be "add" or "reverse"');
}
// Calculate balance change based on account type and activity
let balanceChange = 0;
if (type === 'debit') {
balanceChange = activity === 'add' ? amount : -amount;
} else { // credit
balanceChange = activity === 'add' ? -amount : amount;
}
this.balance += balanceChange;
// Save the updated balance
return await this.save();
}
/**
* Saves the account to the database (only if dirty)
* @returns {Promise<Account>} The saved account instance
*/
async save() {
if (!this._doc) {
throw new Error('Account must be saved before updating balance');
}
if (!this.isDirty()) return this; // No changes, skip save
// Validate hierarchy if it has changed
if (this.hierarchy !== (this._doc.hierarchy || '')) {
if (this.hierarchy && this.hierarchy.trim() !== '') {
try {
const hierarchyNode = await COA.validateHierarchyPath(this.hierarchy);
// Check if trying to save account on a group node
if (hierarchyNode.isGroup) {
throw new Error(`Cannot save account with group hierarchy path: ${this.hierarchy}. Use a leaf node path instead.`);
}
} catch (error) {
throw new Error(`Hierarchy validation failed: ${error.message}`);
}
}
}
var doc = { ...this._doc };
doc.key = this.key;
doc.label = this.label;
doc.balance = this.balance;
doc.accountType = this.accountType;
doc.hierarchy = this.hierarchy;
doc.tags = this.tags;
try {
const response = await getDb().insert(doc);
this._doc = doc;
this._doc._rev = response.rev;
// Update account in COA settings if relevant fields changed
const changes = this.getChanges();
if (changes.label || changes.accountType || changes.hierarchy || changes.tags) {
try {
await COA.updateAccountInCOA(this.key, {
label: this.label,
accountType: this.accountType,
hierarchy: this.hierarchy,
tags: this.tags
});
} catch (coaError) {
// Log warning but don't fail account save
console.warn(`Warning: Failed to update account in COA settings: ${coaError.message}`);
}
}
this.mapFields();
return this;
} catch (error) {
throw new Error(`Failed to save account: ${error.message}`);
}
}
/**
* Removes the account from the database (only if balance is zero)
* @returns {Promise<boolean>} True if successfully removed
*/
async remove() {
if (!this._doc) {
throw new Error('Account must be saved before removing');
}
if (this.balance !== 0) {
throw new Error('Cannot remove account with non-zero balance');
}
try {
await getDb().destroy(this._doc._id, this._doc._rev);
// Remove account from COA settings
try {
await COA.removeAccountFromCOA(this.key);
} catch (coaError) {
// Log warning but don't fail account removal
console.warn(`Warning: Failed to remove account from COA settings: ${coaError.message}`);
}
this._doc = null;
return true;
} catch (error) {
throw new Error(`Failed to remove account: ${error.message}`);
}
}
/**
* Determines if this account increases with debits (true) or credits (false)
* @returns {boolean} True for debit accounts (assets, expenses), false for credit accounts
*/
isDebitAccount() {
return this.accountType === ACCOUNT_TYPES.ASSET || this.accountType === ACCOUNT_TYPES.EXPENSE;
}
/**
* Adds a ledger entry to this account and updates the balance
* @param {number} amount - The amount (always positive)
* @param {string} type - DEBIT or CREDIT constant
* @param {string} description - Entry description
* @param {string} journalEntryId - Associated journal entry ID
* @param {string[]} tags - Array of tags for categorization (e.g., template keys)
* @param {string} date - ISO date string (defaults to current timestamp)
* @returns {Promise<LedgerEntry>} The created ledger entry
*/
async addLedgerEntry(amount, type, description = '', journalEntryId = null, tags = [], date = null) {
const { default: LedgerEntry } = await import('./LedgerEntry.js');
// Create the ledger entry
const ledgerEntry = await LedgerEntry.new(
this._doc._id,
amount,
type,
description,
journalEntryId,
tags,
date
);
// Update account balance using centralized method
await this.updateBalance(type, amount, 'add');
return ledgerEntry;
}
/**
* Finds an account by key
* @param {string} key - The account key to search for
* @returns {Promise<Account|null>} The account instance or null if not found
*/
static async findByKey(key) {
try {
const doc = await getDb().get(`account-${key}`);
return new Account(doc);
} catch (error) {
if (error.status === 404) return null;
throw new Error(`Failed to find account by key: ${error.message}`);
}
}
/**
* Lists all accounts from the database
* @returns {Promise<Account[]>} Array of all account instances
*/
static async list() {
try {
const response = await getDb().list({
startkey: 'account-',
endkey: 'account-\ufff0',
include_docs: true
});
return response.rows.map(row => {
return new Account(row.doc);
});
} catch (error) {
throw new Error(`Failed to list accounts: ${error.message}`);
}
}
/**
* Lists accounts that contain any of the specified tags
* @param {string|string[]} tags - Tag or array of tags to search for
* @returns {Promise<Account[]>} Array of account instances that contain the specified tags
*/
static async listByTag(tags) {
if (!tags) {
throw new Error('Tags parameter is required');
}
// Convert single tag to array
const searchTags = Array.isArray(tags) ? tags : [tags];
if (searchTags.length === 0) {
throw new Error('At least one tag must be provided');
}
try {
// Get all accounts first
const allAccounts = await this.list();
// Filter accounts that contain any of the specified tags
const matchingAccounts = allAccounts.filter(account => {
if (!account.tags || account.tags.length === 0) {
return false;
}
// Check if account has any of the search tags
return searchTags.some(searchTag =>
account.tags.some(accountTag =>
accountTag.toLowerCase().includes(searchTag.toLowerCase())
)
);
});
return matchingAccounts;
} catch (error) {
throw new Error(`Failed to list accounts by tag: ${error.message}`);
}
}
}
export default Account;