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