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