UNPKG

@debito/hippo-lib

Version:

Double-entry accounting library for CouchDB

428 lines (381 loc) 15.9 kB
import config from './config.js'; import { DEBIT, CREDIT } from './constants.js'; // Getter to access database instance function getDb() { return config.db; } /** * LedgerEntry class for individual account postings * Each entry represents one debit or credit to a specific account */ class LedgerEntry { accountId; amount; type; description; journalEntryId; date; tags; balance; _doc; /** * Creates a LedgerEntry instance from a CouchDB document * @param {Object} doc - CouchDB document containing ledger entry data */ constructor(doc) { this._doc = doc; this.mapFields(); } /** * Maps document fields to instance properties * @private */ mapFields() { this.accountId = this._doc.accountId; this.amount = this._doc.amount; this.type = this._doc.type; this.description = this._doc.description; this.journalEntryId = this._doc.journalEntryId; this.date = this._doc.date; this.tags = this._doc.tags || []; this.balance = this._doc.balance; } /** * Creates a new ledger entry and saves it to the database * @param {string} accountId - The account ID this entry affects * @param {number} amount - The amount (always positive) * @param {string} type - DEBIT or CREDIT * @param {string} description - Entry description * @param {string} journalEntryId - Associated journal entry ID * @param {string[]} tags - Array of tags for categorization (e.g., template keys) * @param {string} date - ISO date string (defaults to current timestamp) * @param {number} balance - Running balance after this entry * @returns {Promise<LedgerEntry>} The created ledger entry instance */ static async new(accountId, amount, type, description = '', journalEntryId = null, tags = [], date = null, balance = null) { if (![DEBIT, CREDIT].includes(type)) { throw new Error(`Type must be "${DEBIT}" or "${CREDIT}"`); } if (amount <= 0) { throw new Error('Amount must be positive'); } let doc = { _id: `lentry_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, accountId, amount, type, description, journalEntryId, date: date || new Date().toISOString(), tags: Array.isArray(tags) ? tags : [], balance, docType: 'ledger_entry' }; try { const response = await getDb().insert(doc); doc._id = response.id; doc._rev = response.rev; return new LedgerEntry(doc); } catch (error) { throw new Error(`Failed to create ledger entry: ${error.message}`); } } /** * Retrieves a ledger entry from the database by ID * @param {string} entryId - The ledger entry ID to retrieve * @returns {Promise<LedgerEntry>} The ledger entry instance */ static async get(entryId) { try { const doc = await getDb().get(entryId); return new LedgerEntry(doc); } catch (error) { throw new Error(`Failed to get ledger entry: ${error.message}`); } } /** * Checks if the ledger entry has unsaved changes * @returns {boolean} True if there are unsaved changes */ isDirty() { return this.accountId !== this._doc.accountId || this.amount !== this._doc.amount || this.type !== this._doc.type || this.description !== this._doc.description || this.journalEntryId !== this._doc.journalEntryId || this.balance !== this._doc.balance; } /** * Returns an object containing only the changed fields * @returns {Object} Object with changed fields and their new values */ getChanges() { const changes = {}; if (this.accountId !== this._doc.accountId) changes.accountId = this.accountId; if (this.amount !== this._doc.amount) changes.amount = this.amount; if (this.type !== this._doc.type) changes.type = this.type; if (this.description !== this._doc.description) changes.description = this.description; if (this.journalEntryId !== this._doc.journalEntryId) changes.journalEntryId = this.journalEntryId; if (this.balance !== this._doc.balance) changes.balance = this.balance; return changes; } /** * Reverts all unsaved changes to the last saved state */ rollback() { this.mapFields(); } /** * Saves the ledger entry to the database (only if dirty) * @returns {Promise<LedgerEntry>} The saved ledger entry instance */ async save() { if (!this._doc) { throw new Error('Ledger entry must be created before saving'); } if (!this.isDirty()) return this; // No changes, skip save const doc = { ...this._doc }; doc.accountId = this.accountId; doc.amount = this.amount; doc.type = this.type; doc.description = this.description; doc.journalEntryId = this.journalEntryId; doc.balance = this.balance; try { 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 ledger entry: ${error.message}`); } } /** * Private helper method to find ledger entries with pagination and consistent date ordering * @param {Object} selector - CouchDB selector object * @param {Object} options - Query options * @param {boolean} options.descending - Sort order (default: true for latest first) * @param {string} options.startDate - Start date filter (ISO string, inclusive) * @param {string} options.endDate - End date filter (ISO string, inclusive) * @param {number} options.limit - Max entries to return (pagination) * @param {number} options.skip - Number of entries to skip (pagination) * @returns {Promise<{entries: LedgerEntry[], totalCount: number}>} Paginated entries with total count * @private */ static async _findLedgerEntries(selector, options = {}) { const { descending = true, startDate, endDate, limit, skip } = options; const finalSelector = { ...selector }; if (startDate || endDate) { finalSelector.date = {}; if (startDate) finalSelector.date.$gte = startDate; if (endDate) finalSelector.date.$lte = endDate; } try { const query = { selector: finalSelector, sort: [{ date: descending ? 'desc' : 'asc' }], limit, }; if (skip !== undefined) query.skip = skip; const response = await getDb().find(query); const entries = response.docs.map(doc => new LedgerEntry(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 ledger entries: ${error.message}`); } } /** * Lists ledger entries for a specific account with pagination * @param {string} accountId - The account ID to get entries for * @param {Object} options - Query options (descending, startDate, endDate, limit, skip) * @param {Object} selector - Additional CouchDB selector fields to merge * @returns {Promise<{entries: LedgerEntry[], totalCount: number}>} Paginated entries with total count */ static async listByAccount(accountId, options = {}, selector = {}) { const finalSelector = { ...selector }; finalSelector.docType = 'ledger_entry'; finalSelector.accountId = accountId; return this._findLedgerEntries(finalSelector, options); } /** * Lists all ledger entries for a specific account (returns plain array, no pagination) * @param {string} accountId - The account ID * @param {Object} options - Query options (descending, startDate, endDate) * @param {Object} selector - Additional CouchDB selector fields to merge * @returns {Promise<LedgerEntry[]>} */ static async listAllByAccount(accountId, options = {}, selector = {}) { const { descending = true, startDate, endDate } = options; const finalSelector = { ...selector }; finalSelector.docType = 'ledger_entry'; finalSelector.accountId = accountId; if (startDate || endDate) { finalSelector.date = {}; if (startDate) finalSelector.date.$gte = startDate; if (endDate) finalSelector.date.$lte = endDate; } try { const query = { selector: finalSelector, sort: [{ date: descending ? 'desc' : 'asc' }], limit: 999999 }; const response = await getDb().find(query); return response.docs.map(doc => new LedgerEntry(doc)); } catch (error) { throw new Error(`Failed to list all ledger entries: ${error.message}`); } } /** * Lists all ledger entries for a specific journal entry (returns plain array, no pagination) * @param {string} journalEntryId - The journal entry ID * @param {Object} options - Query options (descending, startDate, endDate) * @param {Object} selector - Additional CouchDB selector fields to merge * @returns {Promise<LedgerEntry[]>} */ static async listAllByJournalEntry(journalEntryId, options = {}, selector = {}) { const { descending = true, startDate, endDate } = options; const finalSelector = { ...selector }; finalSelector.docType = 'ledger_entry'; finalSelector.journalEntryId = journalEntryId; if (startDate || endDate) { finalSelector.date = {}; if (startDate) finalSelector.date.$gte = startDate; if (endDate) finalSelector.date.$lte = endDate; } try { const query = { selector: finalSelector, sort: [{ date: descending ? 'desc' : 'asc' }], limit: 999999 }; const response = await getDb().find(query); return response.docs.map(doc => new LedgerEntry(doc)); } catch (error) { throw new Error(`Failed to list all ledger entries by journal: ${error.message}`); } } /** * Gets the latest (most recent) ledger entry for a specific account * @param {string} accountId - The account ID to get the latest entry for * @returns {Promise<LedgerEntry|null>} The latest ledger entry or null if none exist */ static async getLatestByAccount(accountId) { try { const response = await getDb().find({ selector: { docType: 'ledger_entry', accountId: accountId }, sort: [{ date: 'desc' }], limit: 1 }); if (response.docs.length === 0) { return null; } return new LedgerEntry(response.docs[0]); } catch (error) { throw new Error(`Failed to get latest ledger entry: ${error.message}`); } } /** * Gets the most recent ledger entry before a specific date for an account * @param {string} accountId - The account ID to get the entry for * @param {string} date - ISO date string (exclusive - entries before this date) * @returns {Promise<LedgerEntry|null>} The entry before the date or null if none exist */ static async getEntryBeforeDate(accountId, date) { try { const response = await getDb().find({ selector: { docType: 'ledger_entry', accountId: accountId, date: { $lt: date } }, sort: [{ date: 'desc' }], limit: 1 }); if (response.docs.length === 0) { return null; } return new LedgerEntry(response.docs[0]); } catch (error) { throw new Error(`Failed to get entry before date: ${error.message}`); } } /** * Gets the most recent ledger entry before a specific date for multiple accounts in a single query * @param {string[]} accountIds - Array of account IDs * @param {string} date - ISO date string (exclusive - entries before this date) * @returns {Promise<Object<string, LedgerEntry|null>>} Map of accountId → entry (or null) */ static async getEntriesBeforeDate(accountIds, date) { if (!accountIds.length) return {}; try { const queries = accountIds.map(id => ({ startkey: [id, date], endkey: [id], descending: true, limit: 1, include_docs: true })); const response = await getDb().viewQueries('ledger', 'entry-before-date', queries); const map = {}; accountIds.forEach((id, i) => { const rows = response.results[i].rows || []; map[id] = rows.length ? new LedgerEntry(rows[0].doc) : null; }); return map; } catch (error) { throw new Error(`Failed to get entries before date for batch: ${error.message}`); } } /** * Lists ledger entries for a specific journal entry with pagination * @param {string} journalEntryId - The journal entry ID to get entries for * @param {Object} options - Query options (descending, startDate, endDate, limit, skip) * @param {Object} selector - Additional CouchDB selector fields to merge * @returns {Promise<{entries: LedgerEntry[], totalCount: number}>} Paginated entries with total count */ static async listByJournalEntry(journalEntryId, options = {}, selector = {}) { const finalSelector = { ...selector }; finalSelector.docType = 'ledger_entry'; finalSelector.journalEntryId = journalEntryId; return this._findLedgerEntries(finalSelector, options); } /** * Lists all ledger entries from the database with pagination * @param {Object} options - Query options (descending, startDate, endDate, limit, skip) * @param {Object} selector - Additional CouchDB selector fields to merge * @returns {Promise<{entries: LedgerEntry[], totalCount: number}>} Paginated entries with total count */ static async list(options = {}, selector = {}) { const finalSelector = { ...selector }; finalSelector.docType = 'ledger_entry'; return this._findLedgerEntries(finalSelector, options); } /** * Force deletes the ledger entry from the database (used by journal entry force delete) * WARNING: This bypasses normal accounting safeguards and should only be used during journal entry deletion * @returns {Promise<boolean>} True if successfully deleted */ async forceDelete() { if (!this._doc) { throw new Error('Ledger entry must exist before force deleting'); } try { await getDb().destroy(this._doc._id, this._doc._rev); this._doc = null; return true; } catch (error) { throw new Error(`Failed to force delete ledger entry: ${error.message}`); } } } export default LedgerEntry;