@debito/hippo-lib
Version:
Double-entry accounting library for CouchDB
574 lines (500 loc) • 21.1 kB
JavaScript
import config from './config.js';
import { DEBIT, CREDIT, JOURNAL_STATUS } from './constants.js';
import LedgerEntry from './LedgerEntry.js';
import Account from './Account.js';
import ErrorLog from './ErrorLog.js';
// Getter to access database instance
function getDb() {
return config.db;
}
/**
* JournalEntry class for managing double-entry accounting transactions
* Each journal entry contains multiple lines that must balance (debits = credits)
*/
class JournalEntry {
id;
lines;
description;
date;
status;
tags;
meta;
externalRefId;
check;
_doc;
/**
* Creates a JournalEntry instance from a CouchDB document
* @param {Object} doc - CouchDB document containing journal entry data
*/
constructor(doc) {
this._doc = doc;
this.mapFields();
}
/**
* Maps document fields to instance properties
* @private
*/
mapFields() {
this.id = this._doc._id;
this.lines = this._doc.lines || [];
this.description = this._doc.description || '';
this.date = this._doc.date;
this.status = this._doc.status || JOURNAL_STATUS.DRAFT;
this.tags = this._doc.tags || [];
this.meta = this._doc.meta || { imageUrls: [] };
this.externalRefId = this._doc.externalRefId || null;
this.check = this._doc.check || null;
}
/**
* Creates a new journal entry
* @param {string} description - Description of the journal entry
* @param {string[]} tags - Array of tags for categorization (e.g., template keys)
* @param {string} [externalRefId] - Optional external reference ID for duplicate prevention
* @returns {Promise<JournalEntry>} The created journal entry instance
*/
static async new(description = '', tags = [], externalRefId = null, date = null) {
// Check for duplicate external reference ID if provided
if (externalRefId) {
const existingEntry = await getDb().find({
selector: {
docType: 'journal_entry',
externalRefId: externalRefId
},
limit: 1
});
if (existingEntry.docs.length > 0) {
throw new Error(`Journal entry with external reference '${externalRefId}' already exists`);
}
}
let doc = {
_id: `jentry_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
externalRefId: externalRefId,
lines: [],
description,
date: date || new Date().toISOString(),
status: JOURNAL_STATUS.POSTED,
tags: Array.isArray(tags) ? tags : [],
meta: { imageUrls: [] },
docType: 'journal_entry'
};
try {
const response = await getDb().insert(doc);
doc._id = response.id;
doc._rev = response.rev;
return new JournalEntry(doc);
} catch (error) {
throw new Error(`Failed to create journal entry: ${error.message}`);
}
}
/**
* Adds a line to the journal entry
* @param {string} accountKey - The account key for this line
* @param {string} type - DEBIT or CREDIT
* @param {number} amount - The amount (always positive)
* @param {string} description - Optional description for this line
*/
addLine(accountKey, type, amount, description = '') {
if (!accountKey || typeof accountKey !== 'string') {
throw new Error('Account key is required and must be a string');
}
if (![DEBIT, CREDIT].includes(type)) {
throw new Error(`Type must be "${DEBIT}" or "${CREDIT}"`);
}
if (typeof amount !== 'number' || amount <= 0) {
throw new Error('Amount must be a positive number');
}
if (this.status !== JOURNAL_STATUS.POSTED) {
throw new Error('Cannot add lines to a non-posted journal entry');
}
this.lines.push({
accountKey,
type,
amount,
description
});
}
/**
* Validates that the journal entry balances (debits = credits)
* @returns {boolean} True if the entry balances, false otherwise
*/
validate() {
if (this.lines.length === 0) {
return false;
}
const debitTotal = Math.round(this.lines
.filter(line => line.type === DEBIT)
.reduce((sum, line) => sum + line.amount, 0) * 100) / 100;
const creditTotal = Math.round(this.lines
.filter(line => line.type === CREDIT)
.reduce((sum, line) => sum + line.amount, 0) * 100) / 100;
return debitTotal === creditTotal;
}
/**
* Gets the debit total for this journal entry
* @returns {number} Total debit amount
*/
getDebitTotal() {
return Math.round(this.lines
.filter(line => line.type === DEBIT)
.reduce((sum, line) => sum + line.amount, 0) * 100) / 100;
}
/**
* Gets the credit total for this journal entry
* @returns {number} Total credit amount
*/
getCreditTotal() {
return Math.round(this.lines
.filter(line => line.type === CREDIT)
.reduce((sum, line) => sum + line.amount, 0) * 100) / 100;
}
/**
* Saves journal entry metadata (description, tags, etc.) without affecting ledger
* @returns {Promise<JournalEntry>} The saved journal entry instance
*/
async save() {
// TODO: Add isDirty() method for consistency with Account.js pattern
// Should check: description, tags, meta, externalRefId changes
// Then add: if (!this.isDirty()) return this; // No changes, skip save
if (this.status === JOURNAL_STATUS.DELETED) {
throw new Error('Cannot save deleted journal entry');
}
try {
// Save the journal entry document
const doc = { ...this._doc };
doc.description = this.description;
doc.tags = this.tags;
doc.meta = this.meta;
doc.check = this.check;
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 journal entry: ${error.message}`);
}
}
/**
* Adds an image URL to the meta object
* @param {string} imageUrl - The image URL to add
*/
addImageUrl(imageUrl) {
if (!imageUrl || typeof imageUrl !== 'string') {
throw new Error('Image URL must be a non-empty string');
}
if (!this.meta.imageUrls.includes(imageUrl)) {
this.meta.imageUrls.push(imageUrl);
}
}
/**
* Removes an image URL from the meta object
* @param {string} imageUrl - The image URL to remove
*/
removeImageUrl(imageUrl) {
const index = this.meta.imageUrls.indexOf(imageUrl);
if (index > -1) {
this.meta.imageUrls.splice(index, 1);
}
}
/**
* Gets all image URLs from the meta object
* @returns {string[]} Array of image URLs
*/
getImageUrls() {
return [...this.meta.imageUrls];
}
/**
* Sets the image URLs array in the meta object
* @param {string[]} imageUrls - Array of image URLs
*/
setImageUrls(imageUrls) {
if (!Array.isArray(imageUrls)) {
throw new Error('Image URLs must be an array');
}
// Validate all URLs are strings
for (const url of imageUrls) {
if (typeof url !== 'string' || !url.trim()) {
throw new Error('All image URLs must be non-empty strings');
}
}
this.meta.imageUrls = [...imageUrls];
}
/**
* Posts the journal entry (creates ledger entries and updates account balances)
* @returns {Promise<JournalEntry>} The posted journal entry instance
*/
async post() {
if (this.status !== JOURNAL_STATUS.POSTED) {
throw new Error('Can only post new journal entries');
}
if (!this.validate()) {
throw new Error('Journal entry does not balance. Debits must equal credits.');
}
if (this.lines.length === 0) {
throw new Error('Journal entry must have at least one line');
}
// Validate all account keys exist and build accounts map
const uniqueKeys = [...new Set(this.lines.map(l => l.accountKey))];
const accountsByKey = {};
for (const key of uniqueKeys) {
const account = await Account.findByKey(key);
if (!account) {
throw new Error(`Account not found: ${key}`);
}
accountsByKey[key] = account;
}
for (const line of this.lines) {
line.accountLabel = accountsByKey[line.accountKey].label;
}
try {
// Ensure unique timestamp — only check if collision exists
const existing = await getDb().find({
selector: {
docType: 'journal_entry',
date: this._doc.date
},
limit: 1
});
if (existing.docs.length > 0) {
// Collision — fetch more entries and walk forward to find a gap
const consecutive = await getDb().find({
selector: {
docType: 'journal_entry',
date: { $gte: this._doc.date }
},
sort: [{ date: 'asc' }],
limit: 200
});
let targetDate = new Date(this._doc.date).getTime();
for (const doc of consecutive.docs) {
const docTime = new Date(doc.date).getTime();
if (docTime === targetDate) {
targetDate = docTime + 1;
} else {
break;
}
}
this._doc.date = new Date(targetDate).toISOString();
}
// Build journal doc
const journalDoc = { ...this._doc };
journalDoc.lines = this.lines;
journalDoc.description = this.description;
journalDoc.tags = this.tags;
journalDoc.meta = this.meta;
journalDoc.status = JOURNAL_STATUS.POSTED;
journalDoc.postedDate = new Date().toISOString();
// Build ledger entry docs in memory
const ledgerDocs = this.lines.map((line, index) => {
return {
_id: `lentry_${Date.now()}_${index}_${Math.random().toString(36).substr(2, 9)}`,
accountId: `account-${line.accountKey}`,
amount: line.amount,
type: line.type,
description: line.description || this.description,
journalEntryId: this.id,
date: this._doc.date,
tags: Array.isArray(this.tags) ? [...this.tags] : [],
balance: 0,
docType: 'ledger_entry'
};
});
// Atomic bulk write — journal + all ledger entries
const bulkResponse = await getDb().bulk({ docs: [journalDoc, ...ledgerDocs] });
// Check for errors
for (const result of bulkResponse) {
if (result.error) {
throw new Error(`Bulk write failed for ${result.id}: ${result.error} - ${result.reason}`);
}
}
// Update journal in-memory state from bulk response
this._doc = journalDoc;
this._doc._rev = bulkResponse[0].rev;
this.mapFields();
// Recalculate running balances for each affected account
for (const key of uniqueKeys) {
const account = accountsByKey[key];
const priorEntry = await LedgerEntry.getEntryBeforeDate(account._doc._id, this.date);
const startingBalance = priorEntry ? priorEntry.balance : 0;
const fromDate = priorEntry ? priorEntry.date : '1970-01-01T00:00:00.000Z';
await account.recalculateRunningBalances(fromDate, startingBalance);
}
return this;
} catch (error) {
ErrorLog.log('error', 'JournalEntry.post', error, { entityType: 'JournalEntry', entityId: this.id });
throw new Error(`Failed to post journal entry: ${error.message}`);
}
}
/**
* Retrieves a journal entry from the database by ID
* @param {string} journalId - The journal entry ID to retrieve
* @returns {Promise<JournalEntry>} The journal entry instance
*/
static async get(journalId) {
try {
const doc = await getDb().get(journalId);
return new JournalEntry(doc);
} catch (error) {
throw new Error(`Failed to get journal entry: ${error.message}`);
}
}
/**
* List journal entries with pagination
* @param {Object} options - Query options
* @param {boolean} [options.descending=true] - Sort order (default: true for latest first)
* @param {Object} [options.selector] - Additional CouchDB selector criteria to merge
* @param {number} [options.limit] - Max entries to return (pagination)
* @param {number} [options.skip] - Number of entries to skip (pagination)
* @returns {Promise<{entries: JournalEntry[], totalCount: number}>} Paginated entries with total count
*/
static async list(options = {}) {
try {
const { descending = true, selector = {}, limit, skip } = options;
const finalSelector = {
docType: 'journal_entry',
...selector
};
const query = {
selector: finalSelector,
sort: [{ date: descending ? 'desc' : 'asc' }],
limit,
};
if (skip) query.skip = skip;
const response = await getDb().find(query);
const entries = response.docs.map(doc => new JournalEntry(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 journal entries: ${error.message}`);
}
}
/**
* List all journal entries (returns plain array, no count query)
* @param {Object} options - Query options
* @param {boolean} [options.descending=true] - Sort order
* @param {Object} [options.selector] - Additional CouchDB selector criteria
* @returns {Promise<JournalEntry[]>}
*/
static async listAll(options = {}) {
try {
const { descending = true, selector = {} } = options;
const query = {
selector: { docType: 'journal_entry', ...selector },
sort: [{ date: descending ? 'desc' : 'asc' }],
limit: 999999
};
const response = await getDb().find(query);
return response.docs.map(doc => new JournalEntry(doc));
} catch (error) {
throw new Error(`Failed to list all journal entries: ${error.message}`);
}
}
/**
* Force deletes a journal entry and all related ledger entries (ADMIN FUNCTION)
* WARNING: This breaks accounting audit trail and should only be used for cleanup/corrections
* @param {boolean} confirmForceDelete - Must be true to proceed with force delete
* @returns {Promise<void>}
*/
async forceDelete(confirmForceDelete = false) {
if (!confirmForceDelete) {
throw new Error('Force delete requires explicit confirmation. Set confirmForceDelete=true');
}
if (this.status === JOURNAL_STATUS.DELETED) {
throw new Error('Journal entry is already deleted');
}
try {
// If posted, remove all related ledger entries and update account balances
if (this.status === JOURNAL_STATUS.POSTED) {
// Get all ledger entries for this journal entry
const ledgerEntries = await LedgerEntry.listAllByJournalEntry(this.id);
// Group ledger entries by account for efficient recalculation
const entriesByAccount = {};
for (const ledgerEntry of ledgerEntries) {
if (!entriesByAccount[ledgerEntry.accountId]) {
entriesByAccount[ledgerEntry.accountId] = [];
}
entriesByAccount[ledgerEntry.accountId].push(ledgerEntry);
}
// Delete ledger entries and recalculate balances per account
for (const [accountId, entries] of Object.entries(entriesByAccount)) {
const account = await Account.get(accountId);
// Find the earliest entry to determine recalculation starting point
const earliestEntry = entries.reduce((earliest, current) =>
current.date < earliest.date ? current : earliest
);
// Calculate what the balance was before the earliest entry
const balanceBeforeEarliest = account.calculateNewBalance(
earliestEntry.balance,
earliestEntry.type,
earliestEntry.amount,
'reverse'
);
// Delete all ledger entries for this account
for (const ledgerEntry of entries) {
await ledgerEntry.forceDelete();
}
// Recalculate running balances for all subsequent entries
await account.recalculateRunningBalances(earliestEntry.date, balanceBeforeEarliest);
}
}
// Mark journal entry as deleted (keep document for audit trail)
this._doc.status = JOURNAL_STATUS.DELETED;
const response = await getDb().insert(this._doc);
this._doc._rev = response.rev;
this.mapFields();
} catch (error) {
ErrorLog.log('error', 'JournalEntry.forceDelete', error, { entityType: 'JournalEntry', entityId: this.id });
throw new Error(`Failed to force delete journal entry: ${error.message}`);
}
}
/**
* Reverses a posted journal entry by creating an offsetting entry
* @param {string} reason - Reason for the reversal
* @returns {Promise<JournalEntry>} The reversing journal entry
*/
async reverse(reason = '') {
if (this.status !== JOURNAL_STATUS.POSTED) {
throw new Error('Can only reverse posted entries');
}
try {
// Create reversing entry with opposite amounts
const reversingEntry = await JournalEntry.new(
`REVERSAL: ${this.description}`,
[...this.tags, 'reversal']
);
// Add opposite lines
for (const line of this.lines) {
const oppositeType = line.type === DEBIT ? CREDIT : DEBIT;
reversingEntry.addLine(
line.accountKey,
oppositeType,
line.amount,
`REVERSAL: ${line.description}`
);
}
// Use original entry's date + 1ms so reversal sorts immediately after original
// This ensures backdated reversal triggers recalculateRunningBalances()
const reversalDate = new Date(new Date(this.date).getTime() + 1);
reversingEntry._doc.date = reversalDate.toISOString();
// Post reversing entry (creates ledger entries and updates balances)
await reversingEntry.post();
// Update original entry status and link
this._doc.status = JOURNAL_STATUS.REVERSED;
// this._doc.reversedBy = reversingEntry.id;
// this._doc.reversalReason = reason;
// this._doc.reversalDate = new Date().toISOString();
const response = await getDb().insert(this._doc);
this._doc._rev = response.rev;
this.mapFields();
return reversingEntry;
} catch (error) {
ErrorLog.log('error', 'JournalEntry.reverse', error, { entityType: 'JournalEntry', entityId: this.id });
throw new Error(`Failed to reverse journal entry: ${error.message}`);
}
}
}
export default JournalEntry;