UNPKG

@debito/hippo-lib

Version:

Double-entry accounting library for CouchDB

843 lines (730 loc) 33.6 kB
import config from './config.js'; import { ACCOUNT_TYPES, DEBIT, CREDIT } from './constants.js'; import COA from './coa.js'; import LedgerEntry from './LedgerEntry.js'; import ErrorLog from './ErrorLog.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; template; meta; openingBalance; openingBalanceJournalId; closingBalance; _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 || []; this.template = this._doc.template || null; this.meta = this._doc.meta || {}; this.openingBalance = this._doc.openingBalance || 0; this.openingBalanceJournalId = this._doc.openingBalanceJournalId || null; this.closingBalance = this._doc.closingBalance !== undefined ? this._doc.closingBalance : null; } /** * 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: []) * @param {Object} templateInfo - Optional template metadata {id, label, description, createdFrom} * @param {boolean} skipCOAUpdate - Skip adding account to COA settings (default: false) * @param {Object} meta - Optional metadata object (default: {}) * @returns {Promise<Account>} The created account instance */ static async new(key, label, accountType, balance = 0, hierarchy = '', tags = [], templateInfo = null, skipCOAUpdate = false, meta = {}) { 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 { await COA.validateHierarchyPath(hierarchy); } 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'), meta: meta || {}, openingBalance: balance, // Store the initial balance as opening balance openingBalanceJournalId: null, closingBalance: null, docType: 'account' }; // Add template metadata if provided if (templateInfo) { doc.template = { key: templateInfo.key, id: templateInfo.id, label: templateInfo.label, description: templateInfo.description }; } try { const response = await getDb().insert(doc); doc._id = response.id; doc._rev = response.rev; // Add account to COA settings (unless skipped for template creation) if (!skipCOAUpdate) { 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}`); ErrorLog.log('warn', 'Account.new.coaSync', coaError, { entityType: 'Account', entityId: key }); } } 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 || []) || this.openingBalance !== (this._doc.openingBalance || 0) || this.openingBalanceJournalId !== (this._doc.openingBalanceJournalId || null) || this.closingBalance !== (this._doc.closingBalance !== undefined ? this._doc.closingBalance : null); } /** * 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; if (this.openingBalance !== (this._doc.openingBalance || 0)) changes.openingBalance = this.openingBalance; if (this.openingBalanceJournalId !== (this._doc.openingBalanceJournalId || null)) changes.openingBalanceJournalId = this.openingBalanceJournalId; if (this.closingBalance !== (this._doc.closingBalance !== undefined ? this._doc.closingBalance : null)) changes.closingBalance = this.closingBalance; return changes; } /** * Reverts all unsaved changes to the last saved state */ rollback() { this.mapFields(); } /** * Reloads the account data from the database * @returns {Promise<Account>} This account instance with fresh data */ async reload() { if (!this._doc || !this._doc._id) { throw new Error('Cannot reload account without valid document ID'); } try { const freshDoc = await getDb().get(this._doc._id); this._doc = freshDoc; this.mapFields(); return this; } catch (error) { throw new Error(`Failed to reload account: ${error.message}`); } } /** * Calculates new balance after applying a transaction * @param {number} currentBalance - Current account balance * @param {string} type - DEBIT or CREDIT * @param {number} amount - The amount to apply * @param {string} activity - 'add' for normal posting, 'reverse' for undoing * @returns {number} The new balance after applying the transaction */ calculateNewBalance(currentBalance, 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"'); } let balanceChange = 0; if (this.isDebitAccount()) { // For debit balance accounts (assets, expenses) if (type === 'debit') { balanceChange = activity === 'add' ? amount : -amount; } else { // credit balanceChange = activity === 'add' ? -amount : amount; } } else { // For credit balance accounts (liabilities, equity, revenue) if (type === 'debit') { balanceChange = activity === 'add' ? -amount : amount; } else { // credit balanceChange = activity === 'add' ? amount : -amount; } } return Math.round((currentBalance + balanceChange) * 100) / 100; } /** * 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 (this.isDebitAccount()) { // For debit balance accounts (assets, expenses) if (type === 'debit') { balanceChange = activity === 'add' ? amount : -amount; } else { // credit balanceChange = activity === 'add' ? -amount : amount; } } else { // For credit balance accounts (liabilities, equity, revenue) if (type === 'debit') { balanceChange = activity === 'add' ? -amount : amount; } else { // credit balanceChange = activity === 'add' ? amount : -amount; } } this.balance = Math.round((this.balance + balanceChange) * 100) / 100; // 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 { await COA.validateHierarchyPath(this.hierarchy); } 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; doc.openingBalance = this.openingBalance; doc.openingBalanceJournalId = this.openingBalanceJournalId; doc.closingBalance = this.closingBalance; 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}`); ErrorLog.log('warn', 'Account.save.coaSync', coaError, { entityType: 'Account', entityId: this.key }); } } this.mapFields(); return this; } catch (error) { throw new Error(`Failed to save account: ${error.message}`); } } /** * Checks if an account can be safely deleted (no transactions, zero balance, not referenced in templates) * @returns {Promise<{canDelete: boolean, reason?: string}>} Deletion eligibility status */ async canDelete() { if (!this._doc) { return { canDelete: false, reason: 'Account must be saved before checking deletion eligibility' }; } if (this.balance !== 0) { return { canDelete: false, reason: 'Cannot delete account with non-zero balance' }; } // Check for existing ledger entries try { const entries = await LedgerEntry.listAllByAccount(this._doc._id); if (entries.length > 0) { return { canDelete: false, reason: 'Cannot delete account with existing transactions' }; } } catch (error) { return { canDelete: false, reason: `Error checking transactions: ${error.message}` }; } // Check for references in journal templates try { const coa = await COA.loadCOAFromDB(); const referencingTemplates = []; // Check journal templates if (coa.journelTemplates && coa.journelTemplates.length > 0) { for (const template of coa.journelTemplates) { if (template.lines && template.lines.length > 0) { for (const line of template.lines) { // Check accountKey field, ignore placeholder references starting with # if (line.accountKey === this.key && !line.accountKey.startsWith('#')) { referencingTemplates.push(template.label); break; // Don't add same template multiple times } } } } } if (referencingTemplates.length > 0) { return { canDelete: false, reason: `Cannot delete account referenced in journal templates: ${referencingTemplates.join(', ')}` }; } return { canDelete: true }; } catch (error) { return { canDelete: false, reason: `Error checking template references: ${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'); } // Use the enhanced validation const deleteCheck = await this.canDelete(); if (!deleteCheck.canDelete) { throw new Error(deleteCheck.reason); } 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}`); ErrorLog.log('warn', 'Account.remove.coaSync', coaError, { entityType: 'Account', entityId: this.key }); } this._doc = null; return true; } catch (error) { ErrorLog.log('error', 'Account.remove', error, { entityType: 'Account', entityId: this._doc._id }); 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; } /** * Creates an opening balance journal entry for this account * @param {string} openingBalanceAccountKey - Account key for the offsetting opening balance account * @param {number} amount - The opening balance amount (can be positive or negative) * @param {string} [date] - Optional ISO date string for the journal entry * @returns {Promise<JournalEntry|null>} The created journal entry or null if amount is zero */ async createOpeningBalanceJournalEntry(openingBalanceAccountKey, amount, date = null) { if (!this._doc) { throw new Error('Account must be saved before creating opening balance journal'); } if (amount === 0) { return null; // Skip for zero amount } if (!openingBalanceAccountKey) { throw new Error('Opening balance account key is required'); } // Validate that the opening balance account exists const openingBalanceAccount = await Account.findByKey(openingBalanceAccountKey); if (!openingBalanceAccount) { throw new Error(`Opening balance account not found: ${openingBalanceAccountKey}`); } try { // Auto-derive fiscal year start date if not provided if (!date) { const Book = (await import('./metadata/Book.js')).default; const { getCurrentBookId } = await import('./config.js'); const book = await Book.get(getCurrentBookId()); const fiscalStart = book._doc?.settings?.fiscalYearStart || '01-01'; const [month, day] = fiscalStart.split('-').map(Number); const now = new Date(); let year = now.getFullYear(); if (now < new Date(year, month - 1, day)) year--; date = new Date(year, month - 1, day).toISOString(); } // Import JournalEntry here to avoid circular dependency const JournalEntry = (await import('./JournalEntry.js')).default; // Create journal entry const journalEntry = await JournalEntry.new( `Opening Balance - ${this.label}`, ['opening-balance'] ); journalEntry._doc.date = date; // Determine posting sides based on account type and amount sign const absAmount = Math.abs(amount); const isPositiveAmount = amount >= 0; const postToDebit = isPositiveAmount ? this.isDebitAccount() : !this.isDebitAccount(); if (postToDebit) { // Post to debit side of this account journalEntry.addLine(this.key, DEBIT, absAmount, `Opening balance for ${this.label}`); journalEntry.addLine(openingBalanceAccountKey, CREDIT, absAmount, `Opening balance offset for ${this.label}`); } else { // Post to credit side of this account journalEntry.addLine(this.key, CREDIT, absAmount, `Opening balance for ${this.label}`); journalEntry.addLine(openingBalanceAccountKey, DEBIT, absAmount, `Opening balance offset for ${this.label}`); } // Post the journal entry await journalEntry.post(); await this.reload(); // Store the journal ID reference in this account this.openingBalanceJournalId = journalEntry.id; this.openingBalance = amount; await this.save(); return journalEntry; } catch (error) { console.warn(`Warning: Failed to create opening balance journal for account ${this.key}: ${error.message}`); ErrorLog.log('warn', 'Account.createOpeningBalanceJE', error, { entityType: 'Account', entityId: this.key }); return null; } } /** * Updates the opening balance for this account by reversing the existing entry and creating a new one * @param {string} openingBalanceAccountKey - Account key for the offsetting opening balance account * @param {number} newAmount - The new opening balance amount (can be positive or negative) * @param {string} [date] - Optional ISO date string for the journal entry * @returns {Promise<JournalEntry|null>} The new opening balance journal entry */ async updateOpeningBalance(newAmount, openingBalanceAccountKey, date = null, { forceDelete = false } = {}) { if (!this._doc) { throw new Error('Account must be saved before updating opening balance'); } if (!openingBalanceAccountKey) { throw new Error('Opening balance account key is required'); } try { // Reverse existing opening balance journal entry if it exists if (this.openingBalanceJournalId) { try { const JournalEntry = (await import('./JournalEntry.js')).default; const existingJournal = await JournalEntry.get(this.openingBalanceJournalId); if (existingJournal && existingJournal.status !== 'reversed' && existingJournal.status !== 'deleted') { if (forceDelete) { await existingJournal.forceDelete(true); console.log(`✅ Force deleted existing opening balance journal: ${this.openingBalanceJournalId}`); } else { await existingJournal.reverse('Opening balance updated'); console.log(`✅ Reversed existing opening balance journal: ${this.openingBalanceJournalId}`); } } } catch (error) { console.warn(`Warning: Failed to reverse existing opening balance journal ${this.openingBalanceJournalId}: ${error.message}`); ErrorLog.log('warn', 'Account.updateOpeningBalance.reverseExisting', error, { entityType: 'Account', entityId: this.key }); } } // Reload account to get latest _rev after JE force delete/reversal if (this.openingBalanceJournalId) { await this.reload(); } // Create new opening balance journal entry if amount is not zero if (newAmount !== 0) { const newJournal = await this.createOpeningBalanceJournalEntry(openingBalanceAccountKey, newAmount, date); console.log(`✅ Created new opening balance journal: ${newJournal?.id} for amount: ${newAmount}`); return newJournal; } else { // If new amount is zero, just clear the journal reference this.openingBalanceJournalId = null; this.openingBalance = 0; await this.save(); console.log(`✅ Cleared opening balance journal reference (amount set to zero)`); return null; } } catch (error) { throw new Error(`Failed to update opening balance: ${error.message}`); } } /** * Adds a ledger entry to this account and updates the balance * Handles backdated entries by recalculating subsequent entry balances * @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) { // Check if backdated (date provided and earlier than latest entry) const latestEntry = await LedgerEntry.getLatestByAccount(this._doc._id); const isBackdated = date !== null && latestEntry && date < latestEntry.date; if (!isBackdated) { // Not backdated - use current balance const newBalance = this.calculateNewBalance(this.balance, type, amount, 'add'); const ledgerEntry = await LedgerEntry.new( this._doc._id, amount, type, description, journalEntryId, tags, date, newBalance ); this.balance = newBalance; await this.save(); return ledgerEntry; } // Backdated entry - calculate correct balance and recalculate subsequent entries const priorEntry = await LedgerEntry.getEntryBeforeDate(this._doc._id, date); const startingBalance = priorEntry ? priorEntry.balance : 0; // Calculate new entry's balance const newEntryBalance = this.calculateNewBalance(startingBalance, type, amount, 'add'); // Create the backdated entry with correct balance const ledgerEntry = await LedgerEntry.new( this._doc._id, amount, type, description, journalEntryId, tags, date, newEntryBalance ); // Recalculate all entries after this date await this.recalculateRunningBalances(date, newEntryBalance); return ledgerEntry; } /** * Recalculates running balances for all ledger entries after a specific date * @param {string} fromDate - ISO date string to start recalculation from (exclusive) * @param {number} startingBalance - The balance to start calculation from * @returns {Promise<void>} */ async recalculateRunningBalances(fromDate, startingBalance) { try { // Get all ledger entries after the specified date, ordered by date const subsequentEntries = await LedgerEntry.listAllByAccount(this._doc._id, { startDate: fromDate, descending: false // oldest first for proper calculation }); // Filter out entries with the exact same timestamp to avoid including the deleted entry const entriesToUpdate = subsequentEntries.filter(entry => entry.date > fromDate); let runningBalance = startingBalance; const docsToUpdate = []; // Recalculate balances and collect changed entries for (const entry of entriesToUpdate) { runningBalance = this.calculateNewBalance(runningBalance, entry.type, entry.amount, 'add'); if (entry.balance !== runningBalance) { entry.balance = runningBalance; entry._doc.balance = runningBalance; docsToUpdate.push(entry._doc); } } // Bulk-write all changed entries in a single CouchDB call if (docsToUpdate.length > 0) { const bulkResponse = await getDb().bulk({ docs: docsToUpdate }); for (const result of bulkResponse) { if (result.error) { throw new Error(`Bulk balance update failed for ${result.id}: ${result.error}`); } } } // Update the account's final balance this.balance = runningBalance; await this.save(); } catch (error) { throw new Error(`Failed to recalculate running balances: ${error.message}`); } } /** * 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 * @param {Object} options - Optional filtering options * @param {string} options.templateKey - Filter accounts by template key * @returns {Promise<Account[]>} Array of all account instances */ static async list(options = {}) { try { const response = await getDb().list({ startkey: 'account-', endkey: 'account-\ufff0', include_docs: true }); let accounts = response.rows.map(row => { return new Account(row.doc); }); // Apply template filtering if specified if (options.templateKey) { accounts = accounts.filter(account => { return account._doc.template && account._doc.template.key === options.templateKey; }); } return accounts; } catch (error) { throw new Error(`Failed to list accounts: ${error.message}`); } } /** * Get all-time debit and credit totals for this account via CouchDB reduce view * @returns {Promise<{totalDebits: number, totalCredits: number}>} */ async getTotals() { try { const accountId = this._doc._id; const result = await getDb().view('ledger', 'account-totals', { startkey: JSON.stringify([accountId, 'credit']), endkey: JSON.stringify([accountId, 'debit']), group: true }); let totalDebits = 0; let totalCredits = 0; for (const row of (result.rows || [])) { if (row.key[1] === 'debit') totalDebits = row.value; if (row.key[1] === 'credit') totalCredits = row.value; } return { totalDebits, totalCredits }; } catch (error) { throw new Error(`Failed to get account totals: ${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'); } const searchTags = Array.isArray(tags) ? tags : [tags]; if (searchTags.length === 0) { throw new Error('At least one tag must be provided'); } try { const selector = searchTags.length === 1 ? { docType: 'account', tags: { $elemMatch: { $eq: searchTags[0] } } } : { docType: 'account', $or: searchTags.map(tag => ({ tags: { $elemMatch: { $eq: tag } } })) }; const result = await getDb().find({ selector, limit: 999999 }); return result.docs.map(doc => new Account(doc)); } catch (error) { throw new Error(`Failed to list accounts by tag: ${error.message}`); } } } export default Account;