UNPKG

@debito/hippo-lib

Version:

Double-entry accounting library for CouchDB

380 lines (326 loc) 14.6 kB
import config from './config.js'; import defaultTemplate from './coa-templates/default.json' with { type: 'json' }; // Getter to access database instance function getDb() { return config.db; } /** * Chart of Accounts loader and utility functions */ class COA { /** * Loads a COA template and stores it in database * @param {string} templateName - Name of the template ('default' is built-in) * @param {boolean} createAccounts - Whether to create accounts from template (default: true) * @returns {Promise<Object>} The stored COA document with creation results */ static async loadTemplate(templateName = 'default', createAccounts = true) { let template; if (templateName === 'default') { template = defaultTemplate; } else { throw new Error(`COA template '${templateName}' not found. Only 'default' template is built-in.`); } try { // Store in database const coaDocument = await this.storeCOATemplate(template, templateName); // Create accounts from template if requested if (createAccounts && template.accounts && template.accounts.length > 0) { const creationResults = await this.createAccountsFromTemplate(template.accounts); coaDocument.accountCreationResults = creationResults; } return coaDocument; } catch (error) { throw new Error(`Failed to load COA template: ${error.message}`); } } /** * Creates accounts from template account definitions * @param {Array} templateAccounts - Array of account definitions from template * @returns {Promise<Object>} Results of account creation {created: [], updated: [], errors: []} */ static async createAccountsFromTemplate(templateAccounts) { const { default: Account } = await import('./Account.js'); const { ACCOUNT_TYPES } = await import('./constants.js'); const results = { created: [], updated: [], errors: [] }; for (const accountDef of templateAccounts) { try { // Validate account type if (!Object.values(ACCOUNT_TYPES).includes(accountDef.accountType)) { results.errors.push({ key: accountDef.key, error: `Invalid account type: ${accountDef.accountType}` }); continue; } // Check if account already exists const existingAccount = await Account.findByKey(accountDef.key); if (existingAccount) { // Update existing account with template data let updated = false; if (existingAccount.label !== accountDef.label) { existingAccount.label = accountDef.label; updated = true; } if (existingAccount.accountType !== accountDef.accountType) { existingAccount.accountType = accountDef.accountType; updated = true; } // Normalize hierarchy separator from / to . const normalizedHierarchy = (accountDef.hierarchy || '').replace(/\//g, '.'); if (existingAccount.hierarchy !== normalizedHierarchy) { existingAccount.hierarchy = normalizedHierarchy; updated = true; } // Only update tags if they're different const currentTags = JSON.stringify((existingAccount.tags || []).sort()); const templateTags = JSON.stringify((accountDef.tags || []).sort()); if (currentTags !== templateTags) { existingAccount.tags = accountDef.tags || []; updated = true; } if (updated) { await existingAccount.save(); results.updated.push({ key: accountDef.key, label: accountDef.label }); } } else { // Create new account await Account.new( accountDef.key, accountDef.label, accountDef.accountType, 0, // Initial balance accountDef.hierarchy || '', accountDef.tags || [] ); results.created.push({ key: accountDef.key, label: accountDef.label }); } } catch (error) { results.errors.push({ key: accountDef.key, error: error.message }); } } return results; } /** * Flattens the hierarchical structure into a flat map for easy navigation * @param {Array} hierarchy - The hierarchical structure from COA template * @param {string} parentPath - Parent path for recursion * @returns {Object} Flat hierarchy map */ static flattenHierarchy(hierarchy, parentPath = '') { const flatMap = {}; for (const node of hierarchy) { const currentPath = parentPath ? `${parentPath}.${node.code}` : node.code; // Add current node to flat map flatMap[currentPath] = { label: node.label, code: node.code, isGroup: node.children && node.children.length > 0, path: currentPath, level: currentPath.split('.').length }; // Recursively process children if (node.children && node.children.length > 0) { const childrenMap = this.flattenHierarchy(node.children, currentPath); Object.assign(flatMap, childrenMap); } } return flatMap; } /** * Stores COA template in database with flattened hierarchy * @param {Object} template - The template object to store * @param {string} templateName - Name of the template * @returns {Promise<Object>} The stored COA document */ static async storeCOATemplate(template, templateName) { try { // Generate flat hierarchy for easy validation const flatHierarchy = this.flattenHierarchy(template.hierarchy); // Prepare document for database storage const coaDocument = { _id: 'settings-coa', docType: 'coa_template', templateName, version: template.version || '1.0', description: template.description, originalHierarchy: template.hierarchy, flatHierarchy, accounts: template.accounts || [], ledgerTemplates: template.ledgerTemplates || [], journelTemplates: template.journelTemplates || [], createdAt: new Date().toISOString() }; // Store in database const response = await getDb().insert(coaDocument); coaDocument._rev = response.rev; return coaDocument; } catch (error) { throw new Error(`Failed to store COA template: ${error.message}`); } } /** * Loads COA template from database * @returns {Promise<Object>} The COA template document */ static async loadCOAFromDB() { try { const doc = await getDb().get('settings-coa'); return doc; } catch (error) { if (error.status === 404) { throw new Error('COA template not found in database. Please initialize COA first.'); } throw new Error(`Failed to load COA from database: ${error.message}`); } } /** * Validates if a hierarchy path exists in the stored COA * @param {string} hierarchyPath - The hierarchy path to validate (e.g., 'assets.cash-bank.cash') * @returns {Promise<Object>} Hierarchy node info or throws error */ static async validateHierarchyPath(hierarchyPath) { try { const coa = await this.loadCOAFromDB(); if (!coa.flatHierarchy[hierarchyPath]) { throw new Error(`Invalid hierarchy path: ${hierarchyPath}`); } return coa.flatHierarchy[hierarchyPath]; } catch (error) { throw new Error(`Hierarchy validation failed: ${error.message}`); } } /** * Checks if a hierarchy path represents a group account (cannot have transactions) * @param {string} hierarchyPath - The hierarchy path to check * @returns {Promise<boolean>} True if it's a group account */ static async isGroupAccount(hierarchyPath) { try { const hierarchyNode = await this.validateHierarchyPath(hierarchyPath); return hierarchyNode.isGroup; } catch (error) { throw new Error(`Group account check failed: ${error.message}`); } } /** * Gets all hierarchy paths at a specific level or under a parent * @param {string} parentPath - Parent path to search under (optional) * @param {number} level - Specific level to filter (optional) * @returns {Promise<Array>} Array of hierarchy paths */ static async getHierarchyPaths(parentPath = null, level = null) { try { const coa = await this.loadCOAFromDB(); let paths = Object.keys(coa.flatHierarchy); if (parentPath) { paths = paths.filter(path => path.startsWith(`${parentPath}.`)); } if (level !== null) { paths = paths.filter(path => coa.flatHierarchy[path].level === level); } return paths.map(path => ({ path, ...coa.flatHierarchy[path] })); } catch (error) { throw new Error(`Failed to get hierarchy paths: ${error.message}`); } } /** * Adds a new account to the COA settings document * @param {Object} accountData - Account data to add {key, label, accountType, hierarchy, tags} * @returns {Promise<Object>} Updated COA document */ static async addAccountToCOA(accountData) { try { const coa = await this.loadCOAFromDB(); // Check if account already exists const existingAccount = coa.accounts.find(acc => acc.key === accountData.key); if (existingAccount) { throw new Error(`Account with key '${accountData.key}' already exists in COA`); } // Add account to the accounts array const newAccount = { key: accountData.key, label: accountData.label, accountType: accountData.accountType, hierarchy: accountData.hierarchy || '', tags: accountData.tags || [] }; coa.accounts.push(newAccount); coa.updatedAt = new Date().toISOString(); // Update the document in database const response = await getDb().insert(coa); coa._rev = response.rev; return coa; } catch (error) { throw new Error(`Failed to add account to COA: ${error.message}`); } } /** * Removes an account from the COA settings document * @param {string} accountKey - Key of the account to remove * @returns {Promise<Object>} Updated COA document */ static async removeAccountFromCOA(accountKey) { try { const coa = await this.loadCOAFromDB(); // Find and remove the account const accountIndex = coa.accounts.findIndex(acc => acc.key === accountKey); if (accountIndex === -1) { throw new Error(`Account with key '${accountKey}' not found in COA`); } coa.accounts.splice(accountIndex, 1); coa.updatedAt = new Date().toISOString(); // Update the document in database const response = await getDb().insert(coa); coa._rev = response.rev; return coa; } catch (error) { throw new Error(`Failed to remove account from COA: ${error.message}`); } } /** * Updates an existing account in the COA settings document * @param {string} accountKey - Key of the account to update * @param {Object} updates - Updates to apply {label, accountType, hierarchy, tags} * @returns {Promise<Object>} Updated COA document */ static async updateAccountInCOA(accountKey, updates) { try { const coa = await this.loadCOAFromDB(); // Find the account const account = coa.accounts.find(acc => acc.key === accountKey); if (!account) { throw new Error(`Account with key '${accountKey}' not found in COA`); } // Apply updates if (updates.label !== undefined) account.label = updates.label; if (updates.accountType !== undefined) account.accountType = updates.accountType; if (updates.hierarchy !== undefined) account.hierarchy = updates.hierarchy; if (updates.tags !== undefined) account.tags = updates.tags; coa.updatedAt = new Date().toISOString(); // Update the document in database const response = await getDb().insert(coa); coa._rev = response.rev; return coa; } catch (error) { throw new Error(`Failed to update account in COA: ${error.message}`); } } } export default COA;