@debito/hippo-lib
Version:
Double-entry accounting library for CouchDB
243 lines (212 loc) • 9.97 kB
JavaScript
import Account from '../Account.js';
import { ACCOUNT_TYPES } from '../constants.js';
/**
* Trial Balance report generator
* Provides current trial balance and utility methods for display
*/
class TrialBalance {
/**
* Generates trial balance as of a specific date (current balances)
* @param {Date} asOfDate - Date for trial balance (default: current date)
* @returns {Promise<Object>} Trial balance data in JSON format
*/
static async asOfDate(asOfDate = new Date()) {
try {
// Get all accounts
const accounts = await Account.list();
// Group accounts by type
const accountGroups = {
[ACCOUNT_TYPES.ASSET]: [],
[ACCOUNT_TYPES.LIABILITY]: [],
[ACCOUNT_TYPES.EQUITY]: [],
[ACCOUNT_TYPES.REVENUE]: [],
[ACCOUNT_TYPES.EXPENSE]: []
};
// Organize accounts by type
for (const account of accounts) {
if (accountGroups[account.accountType]) {
accountGroups[account.accountType].push({
key: account.key,
label: account.label,
balance: account.balance,
debit: (account.isDebitAccount() && account.balance > 0) ? account.balance
: (!account.isDebitAccount() && account.balance < 0) ? Math.abs(account.balance)
: 0,
credit: (!account.isDebitAccount() && account.balance > 0) ? account.balance
: (account.isDebitAccount() && account.balance < 0) ? Math.abs(account.balance)
: 0
});
}
}
// Calculate totals for each group
const groupTotals = {};
let totalDebits = 0;
let totalCredits = 0;
for (const [accountType, accountList] of Object.entries(accountGroups)) {
const groupDebitTotal = Math.round(accountList.reduce((sum, acc) => sum + acc.debit, 0) * 100) / 100;
const groupCreditTotal = Math.round(accountList.reduce((sum, acc) => sum + acc.credit, 0) * 100) / 100;
groupTotals[accountType] = {
debit: groupDebitTotal,
credit: groupCreditTotal,
count: accountList.length
};
totalDebits = Math.round((totalDebits + groupDebitTotal) * 100) / 100;
totalCredits = Math.round((totalCredits + groupCreditTotal) * 100) / 100;
}
return {
metadata: {
asOfDate: asOfDate.toISOString(),
generatedAt: new Date().toISOString(),
totalAccounts: accounts.length,
isBalanced: Math.abs(totalDebits - totalCredits) < 0.01
},
accounts: accountGroups,
totals: {
byGroup: groupTotals,
overall: {
debit: totalDebits,
credit: totalCredits,
difference: totalDebits - totalCredits
}
}
};
} catch (error) {
throw new Error(`Failed to generate trial balance: ${error.message}`);
}
}
/**
* TODO: Generates trial balance for a specific period (activity-based)
* @param {Date} startDate - Period start date
* @param {Date} endDate - Period end date
* @returns {Promise<Object>} Period trial balance data
*/
static async forPeriod(startDate, endDate) {
// TODO: Implement period trial balance
// This will require querying LedgerEntry by date range
// and calculating net activity for each account during the period
throw new Error('Period trial balance not yet implemented');
}
/**
* TODO: Generates comparative trial balance for multiple periods
* @param {Array} periods - Array of period objects {startDate, endDate, label}
* @returns {Promise<Object>} Comparative trial balance data
*/
static async comparative(periods) {
// TODO: Implement comparative trial balance
// This will show side-by-side comparison of multiple periods
throw new Error('Comparative trial balance not yet implemented');
}
/**
* Utility method to print trial balance in tabular format to console
* @param {Object} trialBalanceData - Trial balance JSON data
* @param {Object} options - Display options {showZeroBalances: boolean}
*/
static printToConsole(trialBalanceData, options = {}) {
const { showZeroBalances = false } = options;
console.log('\n' + '='.repeat(80));
console.log('TRIAL BALANCE');
console.log(`As of: ${new Date(trialBalanceData.metadata.asOfDate).toLocaleDateString()}`);
console.log(`Generated: ${new Date(trialBalanceData.metadata.generatedAt).toLocaleString()}`);
console.log('='.repeat(80));
// Helper function to format currency
const formatAmount = (amount) => {
return amount.toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2
});
};
// Helper function to pad text
const pad = (text, length, align = 'left') => {
const str = String(text);
if (align === 'right') {
return str.padStart(length);
}
return str.padEnd(length);
};
// Print header
console.log(pad('Account', 35) + pad('Debit', 15, 'right') + pad('Credit', 15, 'right'));
console.log('-'.repeat(65));
// Account type display order
const typeOrder = [
ACCOUNT_TYPES.ASSET,
ACCOUNT_TYPES.LIABILITY,
ACCOUNT_TYPES.EQUITY,
ACCOUNT_TYPES.REVENUE,
ACCOUNT_TYPES.EXPENSE
];
const typeLabels = {
[ACCOUNT_TYPES.ASSET]: 'ASSETS',
[ACCOUNT_TYPES.LIABILITY]: 'LIABILITIES',
[ACCOUNT_TYPES.EQUITY]: 'EQUITY',
[ACCOUNT_TYPES.REVENUE]: 'REVENUE',
[ACCOUNT_TYPES.EXPENSE]: 'EXPENSES'
};
// Print accounts by type
for (const accountType of typeOrder) {
const accounts = trialBalanceData.accounts[accountType];
const groupTotal = trialBalanceData.totals.byGroup[accountType];
if (accounts.length === 0 && !showZeroBalances) continue;
// Print account type header
console.log('\n' + typeLabels[accountType] + ':');
// Print accounts in this group
for (const account of accounts) {
if (!showZeroBalances && account.balance === 0) continue;
const debitStr = account.debit > 0 ? formatAmount(account.debit) : '';
const creditStr = account.credit > 0 ? formatAmount(account.credit) : '';
console.log(
' ' + pad(account.label, 33) +
pad(debitStr, 15, 'right') +
pad(creditStr, 15, 'right')
);
}
// Print group subtotal if there are accounts
if (accounts.length > 0) {
const groupDebitStr = groupTotal.debit > 0 ? formatAmount(groupTotal.debit) : '';
const groupCreditStr = groupTotal.credit > 0 ? formatAmount(groupTotal.credit) : '';
console.log(' ' + '-'.repeat(63));
console.log(
' ' + pad(`Total ${typeLabels[accountType]}`, 33) +
pad(groupDebitStr, 15, 'right') +
pad(groupCreditStr, 15, 'right')
);
}
}
// Print grand totals
console.log('\n' + '='.repeat(65));
console.log(
pad('TOTAL', 35) +
pad(formatAmount(trialBalanceData.totals.overall.debit), 15, 'right') +
pad(formatAmount(trialBalanceData.totals.overall.credit), 15, 'right')
);
console.log('='.repeat(65));
// Show balance status
const isBalanced = trialBalanceData.metadata.isBalanced;
const difference = trialBalanceData.totals.overall.difference;
console.log(`\nBalance Status: ${isBalanced ? '✅ BALANCED' : '❌ OUT OF BALANCE'}`);
if (!isBalanced) {
console.log(`Difference: ${formatAmount(Math.abs(difference))} ${difference > 0 ? '(Debit Heavy)' : '(Credit Heavy)'}`);
}
console.log(`Total Accounts: ${trialBalanceData.metadata.totalAccounts}`);
console.log('='.repeat(80) + '\n');
}
/**
* Utility method to get trial balance summary
* @param {Object} trialBalanceData - Trial balance JSON data
* @returns {Object} Summary statistics
*/
static getSummary(trialBalanceData) {
return {
totalAccounts: trialBalanceData.metadata.totalAccounts,
totalDebits: trialBalanceData.totals.overall.debit,
totalCredits: trialBalanceData.totals.overall.credit,
isBalanced: trialBalanceData.metadata.isBalanced,
difference: trialBalanceData.totals.overall.difference,
accountsByType: Object.keys(trialBalanceData.totals.byGroup).reduce((acc, type) => {
acc[type] = trialBalanceData.totals.byGroup[type].count;
return acc;
}, {})
};
}
}
export default TrialBalance;