@debito/hippo-lib
Version:
Double-entry accounting library for CouchDB
366 lines (322 loc) ⢠15.1 kB
JavaScript
import config from '../config.js';
import Account from '../Account.js';
import COA from '../coa.js';
import ErrorLog from '../ErrorLog.js';
import { v4 as uuidv4 } from 'uuid';
// Getter to access database instance
function getDb() {
return config.db;
}
/**
* LedgerTemplate helper utility for creating accounts from templates
* Provides standardized account creation patterns for vendors, customers, etc.
*/
class LedgerTemplate {
/**
* Creates an account using a template
* @param {string} templateKey - Key of the template to use
* @param {string} name - Name to use for the account (e.g., "ABC Corporation")
* @param {Object} overrides - Optional field overrides {key, balance, hierarchy, tags, accountType}
* @param {boolean} [skipCOAUpdate=false] - Skip COA sync (used during bulk book initialization)
* @returns {Promise<Account>} The created account instance
*/
static async createAccountFromTemplate(templateKey, name, overrides = {}, skipCOAUpdate = false) {
try {
// Get the template
const template = await this.getTemplate(templateKey);
if (!template) {
throw new Error(`Template '${templateKey}' not found`);
}
// Use override key if provided, otherwise generate from name and template pattern
const key = overrides.key || this.generateKey(name, template.keyPattern);
// Check for duplicate account
const existing = await Account.findByKey(key);
if (existing) {
throw new Error(`Account '${name}' already exists`);
}
// Generate label from name
const label = this.generateLabel(name, template.labelPattern);
// Apply template values with overrides
const openingBalance = overrides.balance !== undefined ? overrides.balance : template.defaultBalance;
const accountData = {
key,
label,
accountType: overrides.accountType || template.accountType,
balance: 0, // Always create account with zero balance initially
hierarchy: overrides.hierarchy || template.hierarchy,
tags: overrides.tags || [...template.tags]
};
// Validate that we have all required fields
if (!accountData.key || !accountData.label || !accountData.accountType) {
throw new Error('Template must specify key, label, and accountType');
}
// Create the account with template metadata
const account = await Account.new(
accountData.key,
accountData.label,
accountData.accountType,
accountData.balance,
accountData.hierarchy,
accountData.tags,
{
key: template.key,
id: template.key, // Using key as id for consistency
label: template.label,
description: template.description
},
skipCOAUpdate
);
// Set the opening balance property and create journal entry if needed
if (openingBalance !== 0 && template.openingbalanceAccountKey) {
// Update the opening balance property in the account document
try {
await account.createOpeningBalanceJournalEntry(template.openingbalanceAccountKey, openingBalance);
} catch (error) {
console.warn(`Warning: Failed to create opening balance journal for account ${account.key}: ${error.message}`);
ErrorLog.log('warn', 'LedgerTemplate.createAccount.openingBalance', error, { entityType: 'Account', entityId: account.key });
}
}
return account;
} catch (error) {
throw new Error(`Failed to create account from template: ${error.message}`);
}
}
/**
* Gets a template by key from the stored templates
* @param {string} templateKey - Key of the template
* @returns {Promise<Object|null>} Template object or null if not found
*/
static async getTemplate(templateKey) {
try {
const templates = await this.listTemplates();
return templates.find(template => template.key === templateKey) || null;
} catch (error) {
throw new Error(`Failed to get template: ${error.message}`);
}
}
/**
* Lists all available ledger templates from COA settings
* @returns {Promise<Object[]>} Array of template definition objects
*/
static async listTemplates() {
try {
// Get templates from COA settings
const coa = await COA.loadCOAFromDB();
return coa.ledgerTemplates || [];
} catch (error) {
throw new Error(`Failed to list templates: ${error.message}`);
}
}
/**
* Adds a new custom template
* @param {Object} templateData - Template definition
* @returns {Promise<Object>} Updated COA document
*/
static async addTemplate(templateData) {
try {
// Validate template data
const requiredFields = ['key', 'label', 'accountType', 'hierarchy', 'labelPattern', 'keyPattern'];
for (const field of requiredFields) {
if (!templateData[field]) {
throw new Error(`Template must have ${field}`);
}
}
// Get current COA
const coa = await COA.loadCOAFromDB();
// Check if template already exists
const existingTemplates = coa.ledgerTemplates || [];
const existingTemplate = existingTemplates.find(t => t.key === templateData.key);
if (existingTemplate) {
throw new Error(`Template '${templateData.key}' already exists`);
}
// Add template with defaults
const newTemplate = {
key: templateData.key,
label: templateData.label,
description: templateData.description || '',
accountType: templateData.accountType,
hierarchy: templateData.hierarchy,
tags: templateData.tags || [],
labelPattern: templateData.labelPattern,
keyPattern: templateData.keyPattern,
defaultBalance: templateData.defaultBalance || 0,
openingbalanceAccountKey: templateData.openingbalanceAccountKey || null
};
// Add to templates array
if (!coa.ledgerTemplates) {
coa.ledgerTemplates = [];
}
coa.ledgerTemplates.push(newTemplate);
coa.updatedAt = new Date().toISOString();
// Update COA document
const response = await getDb().insert(coa);
coa._rev = response.rev;
COA.updateCache(coa);
return coa;
} catch (error) {
throw new Error(`Failed to add template: ${error.message}`);
}
}
/**
* Removes a custom template (cannot remove default templates)
* @param {string} templateKey - Key of template to remove
* @returns {Promise<Object>} Updated COA document
*/
static async removeTemplate(templateKey) {
try {
// Get current COA
const coa = await COA.loadCOAFromDB();
if (!coa.ledgerTemplates) {
throw new Error('No templates found');
}
// Find template
const templateIndex = coa.ledgerTemplates.findIndex(t => t.key === templateKey);
if (templateIndex === -1) {
throw new Error(`Template '${templateKey}' not found`);
}
// Remove template
coa.ledgerTemplates.splice(templateIndex, 1);
coa.updatedAt = new Date().toISOString();
// Update COA document
const response = await getDb().insert(coa);
coa._rev = response.rev;
COA.updateCache(coa);
return coa;
} catch (error) {
throw new Error(`Failed to remove template: ${error.message}`);
}
}
/**
* Updates an existing template
* @param {string} templateKey - Key of template to update
* @param {Object} updateData - Data to update {label, description, tags, etc.}
* @returns {Promise<Object>} Updated COA document
*/
static async updateTemplate(templateKey, updateData) {
try {
// Get current COA
const coa = await COA.loadCOAFromDB();
if (!coa.ledgerTemplates) {
throw new Error('No templates found');
}
// Find template
const templateIndex = coa.ledgerTemplates.findIndex(t => t.key === templateKey);
if (templateIndex === -1) {
throw new Error(`Template '${templateKey}' not found`);
}
// Get existing template
const existingTemplate = coa.ledgerTemplates[templateIndex];
// Update template with provided data, keeping existing values for unspecified fields
const updatedTemplate = {
...existingTemplate,
label: updateData.label !== undefined ? updateData.label : existingTemplate.label,
description: updateData.description !== undefined ? updateData.description : existingTemplate.description,
tags: updateData.tags !== undefined ? updateData.tags : existingTemplate.tags,
// Allow updating other fields if provided
accountType: updateData.accountType !== undefined ? updateData.accountType : existingTemplate.accountType,
hierarchy: updateData.hierarchy !== undefined ? updateData.hierarchy : existingTemplate.hierarchy,
labelPattern: updateData.labelPattern !== undefined ? updateData.labelPattern : existingTemplate.labelPattern,
keyPattern: updateData.keyPattern !== undefined ? updateData.keyPattern : existingTemplate.keyPattern,
defaultBalance: updateData.defaultBalance !== undefined ? updateData.defaultBalance : existingTemplate.defaultBalance,
openingbalanceAccountKey: updateData.openingbalanceAccountKey !== undefined ? updateData.openingbalanceAccountKey : existingTemplate.openingbalanceAccountKey
};
// Replace template in array
coa.ledgerTemplates[templateIndex] = updatedTemplate;
coa.updatedAt = new Date().toISOString();
// Update COA document
const response = await getDb().insert(coa);
coa._rev = response.rev;
COA.updateCache(coa);
return coa;
} catch (error) {
throw new Error(`Failed to update template: ${error.message}`);
}
}
/**
* Generates a key from name using the pattern
* @param {string} name - The name to convert
* @param {string} pattern - The key pattern (e.g., "{name-slug}")
* @returns {string} Generated key
*/
static generateKey(name, pattern) {
// Convert name to slug format
const slug = name
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
// Replace pattern placeholders
return pattern
.replace('{name-slug}', slug)
.replace('{name}', name)
.replace('{uuid}', uuidv4());
}
/**
* Generates a label from name using the pattern
* @param {string} name - The name to use
* @param {string} pattern - The label pattern (e.g., "{name}")
* @returns {string} Generated label
*/
static generateLabel(name, pattern) {
// Replace pattern placeholders
return pattern
.replace('{name}', name)
.replace('{name-slug}', this.generateKey(name, '{name-slug}'));
}
/**
* Utility method to print available templates to console
* @param {Array} templates - Optional templates array, if not provided will fetch from DB
*/
static async printTemplates(templates = null) {
try {
if (!templates) {
templates = await this.listTemplates();
}
console.log('\n' + '='.repeat(60));
console.log('AVAILABLE LEDGER TEMPLATES');
console.log('='.repeat(60));
if (templates.length === 0) {
console.log('No templates available');
return;
}
for (const template of templates) {
console.log(`\nš ${template.label.toUpperCase()} (${template.key})`);
console.log(` Description: ${template.description || 'No description'}`);
console.log(` Account Type: ${template.accountType}`);
console.log(` Hierarchy: ${template.hierarchy}`);
console.log(` Tags: [${template.tags.join(', ')}]`);
console.log(` Example Key: ${this.generateKey('ABC Corporation', template.keyPattern)}`);
console.log(` Example Label: ${this.generateLabel('ABC Corporation', template.labelPattern)}`);
}
console.log('\n' + '='.repeat(60));
} catch (error) {
console.error('Failed to print templates:', error.message);
}
}
/**
* Utility method to demonstrate template usage
* @param {string} templateKey - Template key to demonstrate
* @param {string} exampleName - Example name to use
*/
static async demonstrateTemplate(templateKey, exampleName = 'Example Company') {
try {
const template = await this.getTemplate(templateKey);
if (!template) {
throw new Error(`Template '${templateKey}' not found`);
}
console.log(`\nš TEMPLATE DEMONSTRATION: ${template.label} (${template.key})`);
console.log(`Using example name: "${exampleName}"`);
console.log(`Generated key: ${this.generateKey(exampleName, template.keyPattern)}`);
console.log(`Generated label: ${this.generateLabel(exampleName, template.labelPattern)}`);
console.log(`Account type: ${template.accountType}`);
console.log(`Hierarchy: ${template.hierarchy}`);
console.log(`Tags: [${template.tags.join(', ')}]`);
console.log(`Default balance: ${template.defaultBalance}`);
} catch (error) {
console.error('Template demonstration failed:', error.message);
}
}
}
export default LedgerTemplate;