UNPKG

@debito/hippo-lib

Version:

Double-entry accounting library for CouchDB

655 lines (562 loc) 25.6 kB
import config, { getMetaDB } from './config.js'; // Getter to access database instance function getDb() { return config.db; } /** * Chart of Accounts loader and utility functions */ class COA { /** * Loads a COA template from hippo-meta database and applies it to current book * @param {string} templateName - Name of the template (e.g., 'default', 'base') * @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 = 'coa-template-default', createAccounts = true) { try { // Load template from hippo-meta database const template = await this.getTemplate(templateName); // Store in current book 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 '${templateName}': ${error.message}`); } } /** * Get a COA template from hippo-meta database * @param {string} templateName - Name of the template * @returns {Promise<Object>} Template data */ static async getTemplate(templateName) { const metaDB = getMetaDB(); const docId = templateName.startsWith('coa-template-') ? templateName : `coa-template-${templateName}`; try { const templateDoc = await metaDB.get(docId); if(!templateDoc){ throw new Error("Failed to fetch Template from Meta"); } // TODO: Add template structure validation return templateDoc; } catch (error) { if (error.status === 404) { throw new Error(`COA template '${templateName}' not found in database`); } throw error; } } /** * List available COA templates * @returns {Promise<Array>} Array of available templates */ static async listTemplates() { const metaDB = getMetaDB(); try { const result = await metaDB.find({ selector: { type: 'coa_template' }, sort: [{ 'name': 'asc' }], limit: 999999 }); return result.docs.map(doc => ({ _id: doc._id, name: doc.name, description: doc.description, created: doc.created, isSystem: doc.isSystem || false })); } catch (error) { throw new Error(`Failed to list templates: ${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 if (accountDef.template) { // Create via ledger template for proper template metadata const { default: LedgerTemplate } = await import('./helpers/LedgerTemplate.js'); await LedgerTemplate.createAccountFromTemplate( accountDef.template.key, accountDef.label, { key: accountDef.key }, true // skipCOAUpdate — COA already populated by storeCOATemplate() ); results.created.push({ key: accountDef.key, label: accountDef.label }); } else { // Create new account (skip COA update since we're initializing COA) await Account.new( accountDef.key, accountDef.label, accountDef.accountType, 0, // Initial balance accountDef.hierarchy || '', accountDef.tags || [], null, // templateInfo true // skipCOAUpdate ); 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, hierarchy: template.hierarchy, flatHierarchy, accounts: template.accounts || [], ledgerTemplates: template.ledgerTemplates || [], journelTemplates: template.journelTemplates || [], createdAt: new Date().toISOString() }; console.log( JSON.stringify(coaDocument) ) // Store in database const response = await getDb().insert(coaDocument); coaDocument._rev = response.rev; COA.updateCache(coaDocument); 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() { if (config.cachedCOA) return config.cachedCOA; try { const doc = await getDb().get('settings-coa'); config.cachedCOA = doc; 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}`); } } /** * Invalidates the cached COA document, forcing next loadCOAFromDB() to fetch from DB */ static invalidateCache() { config.cachedCOA = null; } /** * Updates the cached COA document with a fresh copy (warm cache) * @param {Object} coa - The updated COA document (must have current _rev) */ static updateCache(coa) { config.cachedCOA = coa; } /** * 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; COA.updateCache(coa); 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; COA.updateCache(coa); 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; COA.updateCache(coa); return coa; } catch (error) { throw new Error(`Failed to update account in COA: ${error.message}`); } } /** * Adds a new hierarchy node to the COA structure * @param {string} parentPath - Parent hierarchy path (e.g., 'assets.current-assets' or '' for root) * @param {Object} nodeData - Node data {label, code} * @returns {Promise<Object>} Updated COA document */ static async addHierarchyNode(parentPath = '', nodeData) { try { // Validate input if (!nodeData.label || !nodeData.code) { throw new Error('Both label and code are required for hierarchy node'); } // Validate code format (lowercase, dashes only) if (!/^[a-z0-9-]+$/.test(nodeData.code)) { throw new Error('Code must contain only lowercase letters, numbers, and dashes'); } const coa = await this.loadCOAFromDB(); // Helper function to find parent node and add child const addNodeToHierarchy = (nodes, targetPath, newNode) => { if (!targetPath) { // Adding to root level - check for duplicate codes const existing = nodes.find(node => node.code === newNode.code); if (existing) { throw new Error(`Node with code '${newNode.code}' already exists at root level`); } nodes.push(newNode); return true; } // Split path to find parent const pathParts = targetPath.split('.'); const immediateParent = pathParts[pathParts.length - 1]; const parentOfParent = pathParts.slice(0, -1).join('.'); // Find the parent node for (const node of nodes) { if (node.code === immediateParent && (!parentOfParent || isAtPath(node, targetPath, coa.hierarchy))) { // Found the parent - check for duplicate codes in children if (!node.children) node.children = []; const existing = node.children.find(child => child.code === newNode.code); if (existing) { throw new Error(`Node with code '${newNode.code}' already exists under '${targetPath}'`); } node.children.push(newNode); return true; } // Recursively search in children if (node.children && node.children.length > 0) { if (addNodeToHierarchy(node.children, targetPath, newNode)) { return true; } } } return false; }; // Helper to check if a node is at the correct path const isAtPath = (node, targetPath, allHierarchy) => { // Build the actual path of this node by traversing from root const findNodePath = (nodes, targetNode, currentPath = '') => { for (const n of nodes) { const nodePath = currentPath ? `${currentPath}.${n.code}` : n.code; // Found the target node - return its path if (n === targetNode) { return nodePath; } // Search in children if (n.children && n.children.length > 0) { const childPath = findNodePath(n.children, targetNode, nodePath); if (childPath) return childPath; } } return null; }; const actualPath = findNodePath(allHierarchy, node); return actualPath === targetPath; }; // Create new node structure const newNode = { label: nodeData.label, code: nodeData.code, children: [] }; // Add the node to hierarchy const success = addNodeToHierarchy(coa.hierarchy, parentPath, newNode); if (!success) { throw new Error(`Parent path '${parentPath}' not found in hierarchy`); } // Regenerate flat hierarchy for validation coa.flatHierarchy = this.flattenHierarchy(coa.hierarchy); // Update timestamp coa.updatedAt = new Date().toISOString(); // Save updated COA back to database const response = await getDb().insert(coa); coa._rev = response.rev; COA.updateCache(coa); return coa; } catch (error) { throw new Error(`Failed to add hierarchy node: ${error.message}`); } } /** * Removes a hierarchy node from the COA structure * @param {string} nodePath - Full path to the node to remove (e.g., 'assets.test-4') * @returns {Promise<Object>} Updated COA document */ static async removeHierarchyNode(nodePath) { try { if (!nodePath) { throw new Error('Node path is required for hierarchy node removal'); } // Prevent removal of root level nodes if (!nodePath.includes('.')) { throw new Error('Root level hierarchy nodes cannot be removed'); } const coa = await this.loadCOAFromDB(); // Check if any accounts use this hierarchy path or any sub-paths const accountsUsingPath = coa.accounts.filter(account => account.hierarchy === nodePath || account.hierarchy.startsWith(`${nodePath}.`) ); if (accountsUsingPath.length > 0) { throw new Error(`Cannot remove hierarchy node - ${accountsUsingPath.length} account(s) are using this hierarchy`); } // Helper function to remove node from hierarchy const removeNodeFromHierarchy = (nodes, targetPath, currentPath = '') => { for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; const nodePath = currentPath ? `${currentPath}.${node.code}` : node.code; if (nodePath === targetPath) { // Found the node to remove nodes.splice(i, 1); return true; } // Search in children if (node.children && node.children.length > 0) { if (removeNodeFromHierarchy(node.children, targetPath, nodePath)) { return true; } } } return false; }; // Remove the node from hierarchy const success = removeNodeFromHierarchy(coa.hierarchy, nodePath); if (!success) { throw new Error(`Hierarchy node '${nodePath}' not found`); } // Regenerate flat hierarchy for validation coa.flatHierarchy = this.flattenHierarchy(coa.hierarchy); // Update timestamp coa.updatedAt = new Date().toISOString(); // Save updated COA back to database const response = await getDb().insert(coa); coa._rev = response.rev; COA.updateCache(coa); return coa; } catch (error) { throw new Error(`Failed to remove hierarchy node: ${error.message}`); } } } export default COA;