UNPKG

@debito/hippo-lib

Version:

Double-entry accounting library for CouchDB

256 lines (228 loc) 9.13 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; _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 || []; } /** * 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) * @returns {Promise<LedgerEntry>} The created ledger entry instance */ static async new(accountId, amount, type, description = '', journalEntryId = null, tags = [], date = 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 : [], 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; } /** * 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; 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 var doc = { ...this._doc }; doc.accountId = this.accountId; doc.amount = this.amount; doc.type = this.type; doc.description = this.description; doc.journalEntryId = this.journalEntryId; 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 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) * @returns {Promise<LedgerEntry[]>} Array of ledger entry instances ordered by date * @private */ static async _findLedgerEntries(selector, options = {}) { const { descending = true, startDate, endDate } = options; // Create a copy of the selector to avoid modifying the original const finalSelector = { ...selector }; // Add date filtering to selector if provided 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' } ] }; const response = await getDb().find(query); return response.docs.map(doc => new LedgerEntry(doc)); } catch (error) { throw new Error(`Failed to list ledger entries: ${error.message}`); } } /** * Lists all ledger entries for a specific account, ordered by date (latest first) * @param {string} accountId - The account ID to get entries for * @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) * @returns {Promise<LedgerEntry[]>} Array of ledger entries for the account */ static async listByAccount(accountId, options = {}) { return this._findLedgerEntries({ docType: 'ledger_entry', accountId: accountId }, options); } /** * Lists all ledger entries for a specific journal entry, ordered by date (latest first) * @param {string} journalEntryId - The journal entry ID to get entries for * @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) * @returns {Promise<LedgerEntry[]>} Array of ledger entries for the journal entry */ static async listByJournalEntry(journalEntryId, options = {}) { return this._findLedgerEntries({ docType: 'ledger_entry', journalEntryId: journalEntryId }, options); } /** * Lists all ledger entries from the database, ordered by date (latest first) * @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) * @returns {Promise<LedgerEntry[]>} Array of ledger entry instances ordered by date */ static async list(options = {}) { return this._findLedgerEntries({ docType: 'ledger_entry' }, options); } } export default LedgerEntry;