UNPKG

@debito/hippo-lib

Version:

Double-entry accounting library for CouchDB

432 lines (384 loc) 15 kB
import config from './config.js'; import { CHECK_STATUS } from './constants.js'; import JournalEntry from './JournalEntry.js'; import ErrorLog from './ErrorLog.js'; // Getter to access database instance function getDb() { return config.db; } /** * Check class for managing check documents * Tracks check lifecycle independent of journal entries */ class Check { number; bank; payee; amount; date; status; journalEntryId; accountId; memo; createdAt; updatedAt; _doc; /** * Creates a Check instance from a CouchDB document * @param {Object} doc - CouchDB document containing check data */ constructor(doc) { this._doc = doc; this.mapFields(); } /** * Maps document fields to instance properties * @private */ mapFields() { this.number = this._doc.number; this.bank = this._doc.bank; this.payee = this._doc.payee; this.amount = this._doc.amount; this.date = this._doc.date; this.status = this._doc.status; this.journalEntryId = this._doc.journalEntryId; this.accountId = this._doc.accountId; this.memo = this._doc.memo; this.createdAt = this._doc.createdAt; this.updatedAt = this._doc.updatedAt; } /** * Checks if the check has unsaved changes * @returns {boolean} True if there are unsaved changes */ isDirty() { return this.number !== this._doc.number || this.bank !== this._doc.bank || this.payee !== this._doc.payee || this.amount !== this._doc.amount || this.date !== this._doc.date || this.status !== this._doc.status || this.journalEntryId !== this._doc.journalEntryId || this.accountId !== this._doc.accountId || this.memo !== this._doc.memo || this.createdAt !== this._doc.createdAt || this.updatedAt !== this._doc.updatedAt; } /** * Returns an object containing only the changed fields * @returns {Object} Object with changed fields and their new values */ getChanges() { const changes = {}; if (this.number !== this._doc.number) changes.number = this.number; if (this.bank !== this._doc.bank) changes.bank = this.bank; if (this.payee !== this._doc.payee) changes.payee = this.payee; if (this.amount !== this._doc.amount) changes.amount = this.amount; if (this.date !== this._doc.date) changes.date = this.date; if (this.status !== this._doc.status) changes.status = this.status; if (this.journalEntryId !== this._doc.journalEntryId) changes.journalEntryId = this.journalEntryId; if (this.accountId !== this._doc.accountId) changes.accountId = this.accountId; if (this.memo !== this._doc.memo) changes.memo = this.memo; if (this.createdAt !== this._doc.createdAt) changes.createdAt = this.createdAt; if (this.updatedAt !== this._doc.updatedAt) changes.updatedAt = this.updatedAt; return changes; } /** * Reverts all unsaved changes to the last saved state */ rollback() { this.mapFields(); } /** * Saves the check to the database (only if dirty) * @returns {Promise<Check>} The saved check instance */ async save() { if (!this._doc) { throw new Error('Check must be created before saving'); } if (!this.isDirty()) return this; var doc = { ...this._doc }; doc.number = this.number; doc.bank = this.bank; doc.payee = this.payee; doc.amount = this.amount; doc.date = this.date; doc.status = this.status; doc.journalEntryId = this.journalEntryId; doc.accountId = this.accountId; doc.memo = this.memo; doc.createdAt = this.createdAt; doc.updatedAt = this.updatedAt; 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 check: ${error.message}`); } } /** * Reloads the check from the database * @returns {Promise<Check>} The reloaded check instance */ async reload() { try { const doc = await getDb().get(this._doc._id); this._doc = doc; this.mapFields(); return this; } catch (error) { throw new Error(`Failed to reload check: ${error.message}`); } } /** * Creates a new check and saves it to the database * @param {Object} params - Check parameters * @param {string} params.number - Check number (string — leading zeros) * @param {string} params.bank - Bank name * @param {string} params.payee - Supplier name * @param {number} params.amount - Amount (always positive) * @param {string} params.date - Date on the check (may be post-dated) * @param {string} [params.accountId] - Bank account ID * @param {string} [params.memo] - Optional notes * @param {string} [params.journalEntryId] - Link to journal entry * @returns {Promise<Check>} The created check instance */ static async new({ number, bank, payee, amount, date, accountId, memo, journalEntryId }) { if (!number) throw new Error('Check number is required'); if (!bank) throw new Error('Bank is required'); if (!payee) throw new Error('Payee is required'); if (!amount) throw new Error('Amount is required'); if (!date) throw new Error('Date is required'); if (amount <= 0) { throw new Error('Amount must be positive'); } // Uniqueness check const existing = await getDb().find({ selector: { docType: 'check', number } }); if (existing.docs.length > 0) { throw new Error(`Check number '${number}' already exists`); } const now = new Date().toISOString(); let doc = { _id: `check_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, docType: 'check', number, bank, payee, amount, date, status: CHECK_STATUS.UNUSED, journalEntryId: journalEntryId || null, accountId: accountId || null, memo: memo || '', createdAt: now, updatedAt: now }; try { const response = await getDb().insert(doc); doc._id = response.id; doc._rev = response.rev; return new Check(doc); } catch (error) { throw new Error(`Failed to create check: ${error.message}`); } } /** * Retrieves a check from the database by ID * @param {string} checkId - The check ID to retrieve * @returns {Promise<Check>} The check instance */ static async get(checkId) { try { const doc = await getDb().get(checkId); return new Check(doc); } catch (error) { throw new Error(`Failed to get check: ${error.message}`); } } /** * Lists checks with optional filtering * @param {Object} options - Query options * @param {string} [options.status] - Filter by status * @param {string} [options.accountId] - Filter by account * @param {string} [options.payee] - Filter by payee * @param {string} [options.startDate] - Start date filter (inclusive) * @param {string} [options.endDate] - End date filter (inclusive) * @param {number} [options.limit] - Limit results * @param {number} [options.skip] - Skip results * @param {boolean} [options.descending] - Sort order (default: true) * @returns {Promise<{entries: Check[], totalCount: number}>} Paginated entries with total count */ static async list(options = {}) { const { status, accountId, payee, startDate, endDate, limit, skip, descending = true } = options; const selector = { docType: 'check' }; if (status) selector.status = status; if (accountId) selector.accountId = accountId; if (payee) selector.payee = payee; if (startDate || endDate) { selector.date = {}; if (startDate) selector.date.$gte = startDate; if (endDate) selector.date.$lte = endDate; } try { const query = { selector, sort: [{ date: descending ? 'desc' : 'asc' }], limit, }; if (skip) query.skip = skip; const response = await getDb().find(query); const entries = response.docs.map(doc => new Check(doc)); const countResponse = await getDb().find({ selector, fields: ['_id'], limit: 999999 }); return { entries, totalCount: countResponse.docs.length }; } catch (error) { throw new Error(`Failed to list checks: ${error.message}`); } } /** * Lists all checks (returns plain array, no count query) * @param {Object} options - Query options * @param {string} [options.status] - Filter by status * @param {string} [options.accountId] - Filter by account * @param {string} [options.payee] - Filter by payee * @param {string} [options.startDate] - Start date filter (inclusive) * @param {string} [options.endDate] - End date filter (inclusive) * @param {boolean} [options.descending] - Sort order (default: true) * @returns {Promise<Check[]>} */ static async listAll(options = {}) { const { status, accountId, payee, startDate, endDate, descending = true } = options; const selector = { docType: 'check' }; if (status) selector.status = status; if (accountId) selector.accountId = accountId; if (payee) selector.payee = payee; if (startDate || endDate) { selector.date = {}; if (startDate) selector.date.$gte = startDate; if (endDate) selector.date.$lte = endDate; } try { const query = { selector, sort: [{ date: descending ? 'desc' : 'asc' }], limit: 999999 }; const response = await getDb().find(query); return response.docs.map(doc => new Check(doc)); } catch (error) { throw new Error(`Failed to list all checks: ${error.message}`); } } /** * Finds a check by its number * @param {string} number - Check number * @returns {Promise<Check|null>} The check instance or null */ static async findByNumber(number) { try { const response = await getDb().find({ selector: { docType: 'check', number }, limit: 1 }); if (response.docs.length === 0) return null; return new Check(response.docs[0]); } catch (error) { throw new Error(`Failed to find check by number: ${error.message}`); } } /** * Updates the check status * Only transitions from 'issued' are allowed * @param {string} newStatus - New status (cleared, bounced, voided) * @returns {Promise<Check>} The updated check instance */ /** * Deletes the check document from the database * @returns {Promise<void>} */ async delete() { try { await getDb().destroy(this._doc._id, this._doc._rev); } catch (error) { throw new Error(`Failed to delete check: ${error.message}`); } } /** * Attaches this check to a journal entry, setting status to 'issued' * @param {string} journalEntryId - Journal entry ID to attach to * @returns {Promise<Check>} The updated check instance */ async attachJournalEntry(journalEntryId) { if (this.status !== CHECK_STATUS.UNUSED) { throw new Error(`Can only attach journal entry to unused checks. Current: '${this.status}'`); } if (!journalEntryId) throw new Error('Journal entry ID is required'); const je = await JournalEntry.get(journalEntryId); this.journalEntryId = journalEntryId; this.status = CHECK_STATUS.ISSUED; this.updatedAt = new Date().toISOString(); await this.save(); je.check = { checkId: this._doc._id, number: this.number, bank: this.bank, payee: this.payee, date: this.date, status: CHECK_STATUS.ISSUED }; await je.save(); return this; } /** * Updates check status with transition validation * Unused → voided only. Issued → cleared/bounced/voided. Cleared/bounced/voided are terminal. * Also syncs embedded check.status on the linked journal entry. * @param {string} newStatus - New status (unused, issued, cleared, bounced, voided) * @returns {Promise<void>} */ async updateStatus(newStatus) { const validStatuses = Object.values(CHECK_STATUS); if (!validStatuses.includes(newStatus)) { throw new Error(`Invalid status: ${newStatus}. Must be one of: ${validStatuses.join(', ')}`); } if (this.status === CHECK_STATUS.UNUSED) { if (newStatus !== CHECK_STATUS.VOIDED) { throw new Error(`Unused checks can only be voided. Requested: '${newStatus}'`); } } else if (this.status === CHECK_STATUS.ISSUED) { if (![CHECK_STATUS.CLEARED, CHECK_STATUS.BOUNCED, CHECK_STATUS.VOIDED].includes(newStatus)) { throw new Error(`Issued checks can only be cleared, bounced, or voided. Requested: '${newStatus}'`); } } else { throw new Error(`Cannot update status from '${this.status}'`); } this.status = newStatus; this.updatedAt = new Date().toISOString(); await this.save(); // Update embedded check status on linked journal entry if (this.journalEntryId) { try { const je = await JournalEntry.get(this.journalEntryId); if (je.check) { je.check.status = newStatus; await je.save(); } } catch (error) { console.warn(`Warning: Could not update JE check status: ${error.message}`); ErrorLog.log('warn', 'Check.updateStatus.jeSync', error, { entityType: 'Check', entityId: this._doc._id }); } } return this; } } export default Check;