UNPKG

@debito/hippo-lib

Version:

Double-entry accounting library for CouchDB

422 lines (367 loc) 14.7 kB
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;