@debito/hippo-lib
Version:
Double-entry accounting library for CouchDB
256 lines (228 loc) • 9.13 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;
_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;