@debito/hippo-lib
Version:
Double-entry accounting library for CouchDB
270 lines (227 loc) • 10.2 kB
JavaScript
import JournalEntry from '../JournalEntry.js';
import COA from '../coa.js';
import config from '../config.js';
// Getter to access database instance
function getDb() {
return config.db;
}
/**
* JournalTemplate class for creating journal entries from predefined templates
*/
class JournalTemplate {
/**
* List all available journal templates
* @returns {Promise<Array>} Array of journal template objects
*/
static async listTemplates() {
try {
// Get templates from COA settings
const coa = await COA.loadCOAFromDB();
return coa.journelTemplates || [];
} catch (error) {
throw new Error(`Failed to list journal templates: ${error.message}`);
}
}
/**
* Create a journal entry from a template
* @param {string} templateKey - The key of the template to use
* @param {number} amount - The transaction amount
* @param {Object} options - Options containing placeholder values (e.g., {vendor: "abc-corp"})
* @param {string} [description] - Optional description for the journal entry
* @param {string} [date] - Optional ISO date string for the journal entry
* @param {Object} [meta] - Optional meta object (e.g., {imageUrls: ["url1", "url2"]})
* @param {string} [externalRefId] - Optional external reference ID for duplicate prevention
* @param {Array} [tags] - Optional array of additional tags to attach to the journal entry
* @returns {Promise<JournalEntry>} The created and saved journal entry
*/
static async createJournalEntryFromTemplate(templateKey, amount, options = {}, description = null, date = null, meta = null, externalRefId = null, tags = []) {
if (!templateKey || !amount) {
throw new Error('Template key and amount are required');
}
// Get the COA document to access journal templates
const coaDoc = await COA.loadCOAFromDB();
if (!coaDoc.journelTemplates) {
throw new Error('No journal templates found in COA');
}
// Find the specified template
const template = coaDoc.journelTemplates.find(t => t.key === templateKey);
if (!template) {
throw new Error(`Journal template '${templateKey}' not found`);
}
// Create the journal entry with description and template tag
const entryDescription = description || template.description || `Entry from template: ${template.label}`;
const journalEntry = await JournalEntry.new(entryDescription, tags.concat([templateKey]), externalRefId, date);
// Set the meta object if provided
if (meta) {
journalEntry.meta = { ...journalEntry.meta, ...meta };
}
// Add template information to the journal entry
journalEntry._doc.templateInfo = {
key: template.key,
label: template.label,
description: template.description
};
// Process each line in the template
for (const line of template.lines) {
// Resolve account keys by replacing placeholders
const accountKey = this._resolveAccountKey(line.accountKey, options);
// Create line descriptions
let lineDescription = line.description || entryDescription;
lineDescription = lineDescription.replace('{description}', entryDescription);
// Add the lines to the journal entry
journalEntry.addLine(accountKey, line.type, amount, lineDescription);
}
// Post the journal entry (creates ledger entries and updates balances)
await journalEntry.post();
return journalEntry;
}
/**
* Add a new journal template
* @param {Object} templateData - Template definition
* @returns {Promise<Object>} Updated COA document
*/
static async addTemplate(templateData) {
try {
// Validate template data
const requiredFields = ['key', 'label', 'lines'];
for (const field of requiredFields) {
if (!templateData[field]) {
throw new Error(`Template must have ${field}`);
}
}
if (!Array.isArray(templateData.lines) || templateData.lines.length === 0) {
throw new Error('Template must have at least one line');
}
// Validate each line
for (const line of templateData.lines) {
if (!line.accountKey || !line.type) {
throw new Error('Each line must have accountKey and type');
}
if (!['debit', 'credit'].includes(line.type)) {
throw new Error('Line type must be "debit" or "credit"');
}
}
// Get current COA
const coa = await COA.loadCOAFromDB();
// Check if template already exists
const existingTemplates = coa.journelTemplates || [];
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 || '',
tags: templateData.tags || [],
lines: templateData.lines
};
// Add to templates array
if (!coa.journelTemplates) {
coa.journelTemplates = [];
}
coa.journelTemplates.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 journal template: ${error.message}`);
}
}
/**
* Remove a journal template
* @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.journelTemplates) {
throw new Error('No templates found');
}
// Find template
const templateIndex = coa.journelTemplates.findIndex(t => t.key === templateKey);
if (templateIndex === -1) {
throw new Error(`Template '${templateKey}' not found`);
}
// Remove template
coa.journelTemplates.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 journal template: ${error.message}`);
}
}
/**
* Update an existing journal template
* @param {string} templateKey - Key of template to update
* @param {Object} updateData - Data to update {label, description, tags}
* @returns {Promise<Object>} Updated COA document
*/
static async updateTemplate(templateKey, updateData) {
try {
// Get current COA
const coa = await COA.loadCOAFromDB();
if (!coa.journelTemplates) {
throw new Error('No templates found');
}
// Find template
const templateIndex = coa.journelTemplates.findIndex(t => t.key === templateKey);
if (templateIndex === -1) {
throw new Error(`Template '${templateKey}' not found`);
}
// Get existing template
const existingTemplate = coa.journelTemplates[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
};
// Replace template in array
coa.journelTemplates[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 journal template: ${error.message}`);
}
}
/**
* Resolve account key by replacing placeholders with actual values
* @param {string} accountKey - The account key that may contain placeholders
* @param {Object} options - Options containing placeholder values
* @returns {string} The resolved account key
* @private
*/
static _resolveAccountKey(accountKey, options) {
if (!accountKey) {
throw new Error('Account key is required');
}
// If it's a regular account key (no placeholder), return as-is
if (!accountKey.startsWith('#')) {
return accountKey;
}
// Extract the placeholder key (remove the $ prefix)
const placeholderKey = accountKey.substring(1);
// Look for the key in options
if (options[placeholderKey]) {
return options[placeholderKey];
}
throw new Error(`Placeholder '${placeholderKey}' not found in options. Available: ${Object.keys(options).join(', ')}`);
}
}
export default JournalTemplate;