@debito/hippo-lib
Version:
Double-entry accounting library for CouchDB
432 lines (384 loc) • 15 kB
JavaScript
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;