UNPKG

@debito/hippo-lib

Version:

Double-entry accounting library for CouchDB

574 lines (500 loc) 21.1 kB
import config from './config.js'; import { DEBIT, CREDIT, JOURNAL_STATUS } from './constants.js'; import LedgerEntry from './LedgerEntry.js'; import Account from './Account.js'; import ErrorLog from './ErrorLog.js'; // Getter to access database instance function getDb() { return config.db; } /** * JournalEntry class for managing double-entry accounting transactions * Each journal entry contains multiple lines that must balance (debits = credits) */ class JournalEntry { id; lines; description; date; status; tags; meta; externalRefId; check; _doc; /** * Creates a JournalEntry instance from a CouchDB document * @param {Object} doc - CouchDB document containing journal entry data */ constructor(doc) { this._doc = doc; this.mapFields(); } /** * Maps document fields to instance properties * @private */ mapFields() { this.id = this._doc._id; this.lines = this._doc.lines || []; this.description = this._doc.description || ''; this.date = this._doc.date; this.status = this._doc.status || JOURNAL_STATUS.DRAFT; this.tags = this._doc.tags || []; this.meta = this._doc.meta || { imageUrls: [] }; this.externalRefId = this._doc.externalRefId || null; this.check = this._doc.check || null; } /** * Creates a new journal entry * @param {string} description - Description of the journal entry * @param {string[]} tags - Array of tags for categorization (e.g., template keys) * @param {string} [externalRefId] - Optional external reference ID for duplicate prevention * @returns {Promise<JournalEntry>} The created journal entry instance */ static async new(description = '', tags = [], externalRefId = null, date = null) { // Check for duplicate external reference ID if provided if (externalRefId) { const existingEntry = await getDb().find({ selector: { docType: 'journal_entry', externalRefId: externalRefId }, limit: 1 }); if (existingEntry.docs.length > 0) { throw new Error(`Journal entry with external reference '${externalRefId}' already exists`); } } let doc = { _id: `jentry_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, externalRefId: externalRefId, lines: [], description, date: date || new Date().toISOString(), status: JOURNAL_STATUS.POSTED, tags: Array.isArray(tags) ? tags : [], meta: { imageUrls: [] }, docType: 'journal_entry' }; try { const response = await getDb().insert(doc); doc._id = response.id; doc._rev = response.rev; return new JournalEntry(doc); } catch (error) { throw new Error(`Failed to create journal entry: ${error.message}`); } } /** * Adds a line to the journal entry * @param {string} accountKey - The account key for this line * @param {string} type - DEBIT or CREDIT * @param {number} amount - The amount (always positive) * @param {string} description - Optional description for this line */ addLine(accountKey, type, amount, description = '') { if (!accountKey || typeof accountKey !== 'string') { throw new Error('Account key is required and must be a string'); } if (![DEBIT, CREDIT].includes(type)) { throw new Error(`Type must be "${DEBIT}" or "${CREDIT}"`); } if (typeof amount !== 'number' || amount <= 0) { throw new Error('Amount must be a positive number'); } if (this.status !== JOURNAL_STATUS.POSTED) { throw new Error('Cannot add lines to a non-posted journal entry'); } this.lines.push({ accountKey, type, amount, description }); } /** * Validates that the journal entry balances (debits = credits) * @returns {boolean} True if the entry balances, false otherwise */ validate() { if (this.lines.length === 0) { return false; } const debitTotal = Math.round(this.lines .filter(line => line.type === DEBIT) .reduce((sum, line) => sum + line.amount, 0) * 100) / 100; const creditTotal = Math.round(this.lines .filter(line => line.type === CREDIT) .reduce((sum, line) => sum + line.amount, 0) * 100) / 100; return debitTotal === creditTotal; } /** * Gets the debit total for this journal entry * @returns {number} Total debit amount */ getDebitTotal() { return Math.round(this.lines .filter(line => line.type === DEBIT) .reduce((sum, line) => sum + line.amount, 0) * 100) / 100; } /** * Gets the credit total for this journal entry * @returns {number} Total credit amount */ getCreditTotal() { return Math.round(this.lines .filter(line => line.type === CREDIT) .reduce((sum, line) => sum + line.amount, 0) * 100) / 100; } /** * Saves journal entry metadata (description, tags, etc.) without affecting ledger * @returns {Promise<JournalEntry>} The saved journal entry instance */ async save() { // TODO: Add isDirty() method for consistency with Account.js pattern // Should check: description, tags, meta, externalRefId changes // Then add: if (!this.isDirty()) return this; // No changes, skip save if (this.status === JOURNAL_STATUS.DELETED) { throw new Error('Cannot save deleted journal entry'); } try { // Save the journal entry document const doc = { ...this._doc }; doc.description = this.description; doc.tags = this.tags; doc.meta = this.meta; doc.check = this.check; const response = await getDb().insert(doc); this._doc = doc; this._doc._rev = response.rev; this.mapFields(); return this; } catch (error) { throw new Error(`Failed to save journal entry: ${error.message}`); } } /** * Adds an image URL to the meta object * @param {string} imageUrl - The image URL to add */ addImageUrl(imageUrl) { if (!imageUrl || typeof imageUrl !== 'string') { throw new Error('Image URL must be a non-empty string'); } if (!this.meta.imageUrls.includes(imageUrl)) { this.meta.imageUrls.push(imageUrl); } } /** * Removes an image URL from the meta object * @param {string} imageUrl - The image URL to remove */ removeImageUrl(imageUrl) { const index = this.meta.imageUrls.indexOf(imageUrl); if (index > -1) { this.meta.imageUrls.splice(index, 1); } } /** * Gets all image URLs from the meta object * @returns {string[]} Array of image URLs */ getImageUrls() { return [...this.meta.imageUrls]; } /** * Sets the image URLs array in the meta object * @param {string[]} imageUrls - Array of image URLs */ setImageUrls(imageUrls) { if (!Array.isArray(imageUrls)) { throw new Error('Image URLs must be an array'); } // Validate all URLs are strings for (const url of imageUrls) { if (typeof url !== 'string' || !url.trim()) { throw new Error('All image URLs must be non-empty strings'); } } this.meta.imageUrls = [...imageUrls]; } /** * Posts the journal entry (creates ledger entries and updates account balances) * @returns {Promise<JournalEntry>} The posted journal entry instance */ async post() { if (this.status !== JOURNAL_STATUS.POSTED) { throw new Error('Can only post new journal entries'); } if (!this.validate()) { throw new Error('Journal entry does not balance. Debits must equal credits.'); } if (this.lines.length === 0) { throw new Error('Journal entry must have at least one line'); } // Validate all account keys exist and build accounts map const uniqueKeys = [...new Set(this.lines.map(l => l.accountKey))]; const accountsByKey = {}; for (const key of uniqueKeys) { const account = await Account.findByKey(key); if (!account) { throw new Error(`Account not found: ${key}`); } accountsByKey[key] = account; } for (const line of this.lines) { line.accountLabel = accountsByKey[line.accountKey].label; } try { // Ensure unique timestamp — only check if collision exists const existing = await getDb().find({ selector: { docType: 'journal_entry', date: this._doc.date }, limit: 1 }); if (existing.docs.length > 0) { // Collision — fetch more entries and walk forward to find a gap const consecutive = await getDb().find({ selector: { docType: 'journal_entry', date: { $gte: this._doc.date } }, sort: [{ date: 'asc' }], limit: 200 }); let targetDate = new Date(this._doc.date).getTime(); for (const doc of consecutive.docs) { const docTime = new Date(doc.date).getTime(); if (docTime === targetDate) { targetDate = docTime + 1; } else { break; } } this._doc.date = new Date(targetDate).toISOString(); } // Build journal doc const journalDoc = { ...this._doc }; journalDoc.lines = this.lines; journalDoc.description = this.description; journalDoc.tags = this.tags; journalDoc.meta = this.meta; journalDoc.status = JOURNAL_STATUS.POSTED; journalDoc.postedDate = new Date().toISOString(); // Build ledger entry docs in memory const ledgerDocs = this.lines.map((line, index) => { return { _id: `lentry_${Date.now()}_${index}_${Math.random().toString(36).substr(2, 9)}`, accountId: `account-${line.accountKey}`, amount: line.amount, type: line.type, description: line.description || this.description, journalEntryId: this.id, date: this._doc.date, tags: Array.isArray(this.tags) ? [...this.tags] : [], balance: 0, docType: 'ledger_entry' }; }); // Atomic bulk write — journal + all ledger entries const bulkResponse = await getDb().bulk({ docs: [journalDoc, ...ledgerDocs] }); // Check for errors for (const result of bulkResponse) { if (result.error) { throw new Error(`Bulk write failed for ${result.id}: ${result.error} - ${result.reason}`); } } // Update journal in-memory state from bulk response this._doc = journalDoc; this._doc._rev = bulkResponse[0].rev; this.mapFields(); // Recalculate running balances for each affected account for (const key of uniqueKeys) { const account = accountsByKey[key]; const priorEntry = await LedgerEntry.getEntryBeforeDate(account._doc._id, this.date); const startingBalance = priorEntry ? priorEntry.balance : 0; const fromDate = priorEntry ? priorEntry.date : '1970-01-01T00:00:00.000Z'; await account.recalculateRunningBalances(fromDate, startingBalance); } return this; } catch (error) { ErrorLog.log('error', 'JournalEntry.post', error, { entityType: 'JournalEntry', entityId: this.id }); throw new Error(`Failed to post journal entry: ${error.message}`); } } /** * Retrieves a journal entry from the database by ID * @param {string} journalId - The journal entry ID to retrieve * @returns {Promise<JournalEntry>} The journal entry instance */ static async get(journalId) { try { const doc = await getDb().get(journalId); return new JournalEntry(doc); } catch (error) { throw new Error(`Failed to get journal entry: ${error.message}`); } } /** * List journal entries with pagination * @param {Object} options - Query options * @param {boolean} [options.descending=true] - Sort order (default: true for latest first) * @param {Object} [options.selector] - Additional CouchDB selector criteria to merge * @param {number} [options.limit] - Max entries to return (pagination) * @param {number} [options.skip] - Number of entries to skip (pagination) * @returns {Promise<{entries: JournalEntry[], totalCount: number}>} Paginated entries with total count */ static async list(options = {}) { try { const { descending = true, selector = {}, limit, skip } = options; const finalSelector = { docType: 'journal_entry', ...selector }; const query = { selector: finalSelector, sort: [{ date: descending ? 'desc' : 'asc' }], limit, }; if (skip) query.skip = skip; const response = await getDb().find(query); const entries = response.docs.map(doc => new JournalEntry(doc)); const countResponse = await getDb().find({ selector: finalSelector, fields: ['_id'], limit: 999999 }); return { entries, totalCount: countResponse.docs.length }; } catch (error) { throw new Error(`Failed to list journal entries: ${error.message}`); } } /** * List all journal entries (returns plain array, no count query) * @param {Object} options - Query options * @param {boolean} [options.descending=true] - Sort order * @param {Object} [options.selector] - Additional CouchDB selector criteria * @returns {Promise<JournalEntry[]>} */ static async listAll(options = {}) { try { const { descending = true, selector = {} } = options; const query = { selector: { docType: 'journal_entry', ...selector }, sort: [{ date: descending ? 'desc' : 'asc' }], limit: 999999 }; const response = await getDb().find(query); return response.docs.map(doc => new JournalEntry(doc)); } catch (error) { throw new Error(`Failed to list all journal entries: ${error.message}`); } } /** * Force deletes a journal entry and all related ledger entries (ADMIN FUNCTION) * WARNING: This breaks accounting audit trail and should only be used for cleanup/corrections * @param {boolean} confirmForceDelete - Must be true to proceed with force delete * @returns {Promise<void>} */ async forceDelete(confirmForceDelete = false) { if (!confirmForceDelete) { throw new Error('Force delete requires explicit confirmation. Set confirmForceDelete=true'); } if (this.status === JOURNAL_STATUS.DELETED) { throw new Error('Journal entry is already deleted'); } try { // If posted, remove all related ledger entries and update account balances if (this.status === JOURNAL_STATUS.POSTED) { // Get all ledger entries for this journal entry const ledgerEntries = await LedgerEntry.listAllByJournalEntry(this.id); // Group ledger entries by account for efficient recalculation const entriesByAccount = {}; for (const ledgerEntry of ledgerEntries) { if (!entriesByAccount[ledgerEntry.accountId]) { entriesByAccount[ledgerEntry.accountId] = []; } entriesByAccount[ledgerEntry.accountId].push(ledgerEntry); } // Delete ledger entries and recalculate balances per account for (const [accountId, entries] of Object.entries(entriesByAccount)) { const account = await Account.get(accountId); // Find the earliest entry to determine recalculation starting point const earliestEntry = entries.reduce((earliest, current) => current.date < earliest.date ? current : earliest ); // Calculate what the balance was before the earliest entry const balanceBeforeEarliest = account.calculateNewBalance( earliestEntry.balance, earliestEntry.type, earliestEntry.amount, 'reverse' ); // Delete all ledger entries for this account for (const ledgerEntry of entries) { await ledgerEntry.forceDelete(); } // Recalculate running balances for all subsequent entries await account.recalculateRunningBalances(earliestEntry.date, balanceBeforeEarliest); } } // Mark journal entry as deleted (keep document for audit trail) this._doc.status = JOURNAL_STATUS.DELETED; const response = await getDb().insert(this._doc); this._doc._rev = response.rev; this.mapFields(); } catch (error) { ErrorLog.log('error', 'JournalEntry.forceDelete', error, { entityType: 'JournalEntry', entityId: this.id }); throw new Error(`Failed to force delete journal entry: ${error.message}`); } } /** * Reverses a posted journal entry by creating an offsetting entry * @param {string} reason - Reason for the reversal * @returns {Promise<JournalEntry>} The reversing journal entry */ async reverse(reason = '') { if (this.status !== JOURNAL_STATUS.POSTED) { throw new Error('Can only reverse posted entries'); } try { // Create reversing entry with opposite amounts const reversingEntry = await JournalEntry.new( `REVERSAL: ${this.description}`, [...this.tags, 'reversal'] ); // Add opposite lines for (const line of this.lines) { const oppositeType = line.type === DEBIT ? CREDIT : DEBIT; reversingEntry.addLine( line.accountKey, oppositeType, line.amount, `REVERSAL: ${line.description}` ); } // Use original entry's date + 1ms so reversal sorts immediately after original // This ensures backdated reversal triggers recalculateRunningBalances() const reversalDate = new Date(new Date(this.date).getTime() + 1); reversingEntry._doc.date = reversalDate.toISOString(); // Post reversing entry (creates ledger entries and updates balances) await reversingEntry.post(); // Update original entry status and link this._doc.status = JOURNAL_STATUS.REVERSED; // this._doc.reversedBy = reversingEntry.id; // this._doc.reversalReason = reason; // this._doc.reversalDate = new Date().toISOString(); const response = await getDb().insert(this._doc); this._doc._rev = response.rev; this.mapFields(); return reversingEntry; } catch (error) { ErrorLog.log('error', 'JournalEntry.reverse', error, { entityType: 'JournalEntry', entityId: this.id }); throw new Error(`Failed to reverse journal entry: ${error.message}`); } } } export default JournalEntry;