UNPKG

@debito/hippo-lib

Version:

Double-entry accounting library for CouchDB

364 lines (313 loc) 12.2 kB
import config from './config.js'; import { DEBIT, CREDIT, JOURNAL_STATUS } from './constants.js'; import LedgerEntry from './LedgerEntry.js'; import Account from './Account.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; _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 || []; } /** * 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) * @returns {Promise<JournalEntry>} The created journal entry instance */ static async new(description = '', tags = []) { let doc = { _id: `jentry_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, lines: [], description, date: new Date().toISOString(), status: JOURNAL_STATUS.POSTED, tags: Array.isArray(tags) ? tags : [], 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 = this.lines .filter(line => line.type === DEBIT) .reduce((sum, line) => sum + line.amount, 0); const creditTotal = this.lines .filter(line => line.type === CREDIT) .reduce((sum, line) => sum + line.amount, 0); return Math.abs(debitTotal - creditTotal) < 0.01; // Account for floating point precision } /** * Gets the debit total for this journal entry * @returns {number} Total debit amount */ getDebitTotal() { return this.lines .filter(line => line.type === DEBIT) .reduce((sum, line) => sum + line.amount, 0); } /** * Gets the credit total for this journal entry * @returns {number} Total credit amount */ getCreditTotal() { return this.lines .filter(line => line.type === CREDIT) .reduce((sum, line) => sum + line.amount, 0); } /** * Saves journal entry metadata (description, tags, etc.) without affecting ledger * @returns {Promise<JournalEntry>} The saved journal entry instance */ async 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; 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}`); } } /** * 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 for (const line of this.lines) { const account = await Account.findByKey(line.accountKey); if (!account) { throw new Error(`Account not found: ${line.accountKey}`); } } try { // Save the journal entry document const doc = { ...this._doc }; doc.lines = this.lines; doc.description = this.description; doc.tags = this.tags; doc.status = JOURNAL_STATUS.POSTED; doc.postedDate = new Date().toISOString(); const response = await getDb().insert(doc); this._doc = doc; this._doc._rev = response.rev; this.mapFields(); // Create ledger entries for each line const ledgerEntries = []; for (const line of this.lines) { const account = await Account.findByKey(line.accountKey); const ledgerEntry = await account.addLedgerEntry( line.amount, line.type, line.description || this.description, this.id, this.tags, this.date ); ledgerEntries.push(ledgerEntry); } return this; } catch (error) { 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 all journal entries with optional sorting * @param {Object} options - Query options * @param {boolean} options.descending - Sort order (default: true for latest first) * @returns {Promise<JournalEntry[]>} Array of JournalEntry instances */ static async list(options = {}) { try { const { descending = true } = options; const response = await getDb().find({ selector: { docType: 'journal_entry' }, sort: [ { date: descending ? 'desc' : 'asc' } ] }); return response.docs.map(doc => new JournalEntry(doc)); } catch (error) { throw new Error(`Failed to list 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.listByJournalEntry(this.id); // Reverse the account balance changes and delete ledger entries for (const ledgerEntry of ledgerEntries) { const account = await Account.get(ledgerEntry.accountId); // Reverse the balance change using centralized method await account.updateBalance(ledgerEntry.type, ledgerEntry.amount, 'reverse'); // Delete the ledger entry await getDb().destroy(ledgerEntry._doc._id, ledgerEntry._doc._rev); } } // 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) { 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}` ); } // Save reversing entry await reversingEntry.save(); // 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) { throw new Error(`Failed to reverse journal entry: ${error.message}`); } } } export default JournalEntry;