@debito/hippo-lib
Version:
Double-entry accounting library for CouchDB
364 lines (313 loc) • 12.2 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';
// 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;
_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 || [];
}
/**
* 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)
* @returns {Promise<JournalEntry>} The created journal entry instance
*/
static async new(description = '', tags = []) {
let doc = {
_id: `jentry_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
lines: [],
description,
date: new Date().toISOString(),
status: JOURNAL_STATUS.POSTED,
tags: Array.isArray(tags) ? tags : [],
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 = this.lines
.filter(line => line.type === DEBIT)
.reduce((sum, line) => sum + line.amount, 0);
const creditTotal = this.lines
.filter(line => line.type === CREDIT)
.reduce((sum, line) => sum + line.amount, 0);
return Math.abs(debitTotal - creditTotal) < 0.01; // Account for floating point precision
}
/**
* Gets the debit total for this journal entry
* @returns {number} Total debit amount
*/
getDebitTotal() {
return this.lines
.filter(line => line.type === DEBIT)
.reduce((sum, line) => sum + line.amount, 0);
}
/**
* Gets the credit total for this journal entry
* @returns {number} Total credit amount
*/
getCreditTotal() {
return this.lines
.filter(line => line.type === CREDIT)
.reduce((sum, line) => sum + line.amount, 0);
}
/**
* Saves journal entry metadata (description, tags, etc.) without affecting ledger
* @returns {Promise<JournalEntry>} The saved journal entry instance
*/
async 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;
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}`);
}
}
/**
* 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
for (const line of this.lines) {
const account = await Account.findByKey(line.accountKey);
if (!account) {
throw new Error(`Account not found: ${line.accountKey}`);
}
}
try {
// Save the journal entry document
const doc = { ...this._doc };
doc.lines = this.lines;
doc.description = this.description;
doc.tags = this.tags;
doc.status = JOURNAL_STATUS.POSTED;
doc.postedDate = new Date().toISOString();
const response = await getDb().insert(doc);
this._doc = doc;
this._doc._rev = response.rev;
this.mapFields();
// Create ledger entries for each line
const ledgerEntries = [];
for (const line of this.lines) {
const account = await Account.findByKey(line.accountKey);
const ledgerEntry = await account.addLedgerEntry(
line.amount,
line.type,
line.description || this.description,
this.id,
this.tags,
this.date
);
ledgerEntries.push(ledgerEntry);
}
return this;
} catch (error) {
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 all journal entries with optional sorting
* @param {Object} options - Query options
* @param {boolean} options.descending - Sort order (default: true for latest first)
* @returns {Promise<JournalEntry[]>} Array of JournalEntry instances
*/
static async list(options = {}) {
try {
const { descending = true } = options;
const response = await getDb().find({
selector: {
docType: 'journal_entry'
},
sort: [
{ date: descending ? 'desc' : 'asc' }
]
});
return response.docs.map(doc => new JournalEntry(doc));
} catch (error) {
throw new Error(`Failed to list 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.listByJournalEntry(this.id);
// Reverse the account balance changes and delete ledger entries
for (const ledgerEntry of ledgerEntries) {
const account = await Account.get(ledgerEntry.accountId);
// Reverse the balance change using centralized method
await account.updateBalance(ledgerEntry.type, ledgerEntry.amount, 'reverse');
// Delete the ledger entry
await getDb().destroy(ledgerEntry._doc._id, ledgerEntry._doc._rev);
}
}
// 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) {
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}`
);
}
// Save reversing entry
await reversingEntry.save();
// 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) {
throw new Error(`Failed to reverse journal entry: ${error.message}`);
}
}
}
export default JournalEntry;