@debito/hippo-lib
Version:
Double-entry accounting library for CouchDB
655 lines (562 loc) • 25.6 kB
JavaScript
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;