import * as plugins from './plugins.js'; import { Account } from './skr.classes.account.js'; import { Transaction } from './skr.classes.transaction.js'; import { JournalEntry } from './skr.classes.journalentry.js'; import type { TSKRType, ITransactionData, IJournalEntry, IJournalEntryLine, IAccountBalance, } from './skr.types.js'; export class Ledger { private logger: plugins.smartlog.Smartlog; constructor(private skrType: TSKRType) { this.logger = new plugins.smartlog.Smartlog({ logContext: { company: 'fin.cx', companyunit: 'skr', containerName: 'Ledger', environment: 'local', runtime: 'node', zone: 'local', }, }); } /** * Post a transaction with validation */ public async postTransaction( transactionData: ITransactionData, ): Promise { this.logger.log( 'info', `Posting transaction: ${transactionData.description}`, ); // Ensure SKR type matches const fullTransactionData: ITransactionData = { ...transactionData, skrType: this.skrType, }; // Validate accounts exist await this.validateAccounts([ transactionData.debitAccount, transactionData.creditAccount, ]); // Create and post transaction const transaction = await Transaction.createTransaction(fullTransactionData); this.logger.log( 'info', `Transaction ${transaction.transactionNumber} posted successfully`, ); return transaction; } /** * Post a journal entry with validation */ public async postJournalEntry( journalData: IJournalEntry, ): Promise { this.logger.log( 'info', `Posting journal entry: ${journalData.description}`, ); // Ensure SKR type matches const fullJournalData: IJournalEntry = { ...journalData, skrType: this.skrType, }; // Validate all accounts exist const accountNumbers = journalData.lines.map((line) => line.accountNumber); await this.validateAccounts(accountNumbers); // Validate journal entry is balanced this.validateJournalBalance(journalData.lines); // Create and post journal entry const journalEntry = await JournalEntry.createJournalEntry(fullJournalData); await journalEntry.post(); this.logger.log( 'info', `Journal entry ${journalEntry.journalNumber} posted successfully`, ); return journalEntry; } /** * Validate that accounts exist and are active */ private async validateAccounts(accountNumbers: string[]): Promise { const uniqueAccountNumbers = [...new Set(accountNumbers)]; for (const accountNumber of uniqueAccountNumbers) { const account = await Account.getAccountByNumber( accountNumber, this.skrType, ); if (!account) { throw new Error( `Account ${accountNumber} not found for ${this.skrType}`, ); } if (!account.isActive) { throw new Error(`Account ${accountNumber} is not active`); } } } /** * Validate journal entry balance */ private validateJournalBalance(lines: IJournalEntryLine[]): void { let totalDebits = 0; let totalCredits = 0; for (const line of lines) { if (line.debit) totalDebits += line.debit; if (line.credit) totalCredits += line.credit; } const difference = Math.abs(totalDebits - totalCredits); if (difference >= 0.01) { throw new Error( `Journal entry is not balanced. Debits: ${totalDebits}, Credits: ${totalCredits}`, ); } } /** * Reverse a transaction */ public async reverseTransaction(transactionId: string): Promise { this.logger.log('info', `Reversing transaction: ${transactionId}`); const transaction = await Transaction.getTransactionById(transactionId); if (!transaction) { throw new Error(`Transaction ${transactionId} not found`); } if (transaction.skrType !== this.skrType) { throw new Error( `Transaction ${transactionId} belongs to different SKR type`, ); } const reversalTransaction = await transaction.reverseTransaction(); this.logger.log( 'info', `Transaction reversed: ${reversalTransaction.transactionNumber}`, ); return reversalTransaction; } /** * Reverse a journal entry */ public async reverseJournalEntry(journalId: string): Promise { this.logger.log('info', `Reversing journal entry: ${journalId}`); const journalEntry = await JournalEntry.getInstance({ id: journalId }); if (!journalEntry) { throw new Error(`Journal entry ${journalId} not found`); } if (journalEntry.skrType !== this.skrType) { throw new Error( `Journal entry ${journalId} belongs to different SKR type`, ); } const reversalEntry = await journalEntry.reverse(); this.logger.log( 'info', `Journal entry reversed: ${reversalEntry.journalNumber}`, ); return reversalEntry; } /** * Get account history (all transactions for an account) */ public async getAccountHistory( accountNumber: string, dateFrom?: Date, dateTo?: Date, ): Promise { const account = await Account.getAccountByNumber( accountNumber, this.skrType, ); if (!account) { throw new Error(`Account ${accountNumber} not found`); } let transactions = await Transaction.getTransactionsByAccount( accountNumber, this.skrType, ); // Apply date filter if provided if (dateFrom || dateTo) { transactions = transactions.filter((transaction) => { if (dateFrom && transaction.date < dateFrom) return false; if (dateTo && transaction.date > dateTo) return false; return true; }); } // Sort by date transactions.sort((a, b) => a.date.getTime() - b.date.getTime()); return transactions; } /** * Get account balance at a specific date */ public async getAccountBalance( accountNumber: string, asOfDate?: Date, ): Promise { const account = await Account.getAccountByNumber( accountNumber, this.skrType, ); if (!account) { throw new Error(`Account ${accountNumber} not found`); } let transactions = await Transaction.getTransactionsByAccount( accountNumber, this.skrType, ); // Filter transactions up to the specified date if (asOfDate) { transactions = transactions.filter((t) => t.date <= asOfDate); } // Calculate balance let debitTotal = 0; let creditTotal = 0; for (const transaction of transactions) { if (transaction.debitAccount === accountNumber) { debitTotal += transaction.amount; } if (transaction.creditAccount === accountNumber) { creditTotal += transaction.amount; } } // Calculate net balance based on account type let balance: number; switch (account.accountType) { case 'asset': case 'expense': // Normal debit accounts balance = debitTotal - creditTotal; break; case 'liability': case 'equity': case 'revenue': // Normal credit accounts balance = creditTotal - debitTotal; break; } return { accountNumber, debitTotal, creditTotal, balance, lastUpdated: new Date(), }; } /** * Close accounting period (create closing entries) */ public async closeAccountingPeriod( period: string, // Format: YYYY-MM closingAccountNumber: string = '9400', // Default P&L account ): Promise { this.logger.log('info', `Closing accounting period: ${period}`); const closingEntries: JournalEntry[] = []; // Get all revenue and expense accounts const revenueAccounts = await Account.getAccountsByType( 'revenue', this.skrType, ); const expenseAccounts = await Account.getAccountsByType( 'expense', this.skrType, ); // Calculate totals for each account in the period const periodTransactions = await Transaction.getTransactionsByPeriod( period, this.skrType, ); // Create closing entry for revenue accounts const revenueLines: IJournalEntryLine[] = []; let totalRevenue = 0; for (const account of revenueAccounts) { const balance = await this.getAccountBalanceForPeriod( account.accountNumber, periodTransactions, ); if (balance !== 0) { // Revenue accounts have credit balance, so debit to close revenueLines.push({ accountNumber: account.accountNumber, debit: Math.abs(balance), description: `Closing ${account.accountName}`, }); totalRevenue += Math.abs(balance); } } if (totalRevenue > 0) { // Credit the closing account revenueLines.push({ accountNumber: closingAccountNumber, credit: totalRevenue, description: 'Revenue closing to P&L', }); const revenueClosingEntry = await this.postJournalEntry({ date: new Date(), description: `Closing revenue accounts for period ${period}`, reference: `CLOSE-REV-${period}`, lines: revenueLines, skrType: this.skrType, }); closingEntries.push(revenueClosingEntry); } // Create closing entry for expense accounts const expenseLines: IJournalEntryLine[] = []; let totalExpense = 0; for (const account of expenseAccounts) { const balance = await this.getAccountBalanceForPeriod( account.accountNumber, periodTransactions, ); if (balance !== 0) { // Expense accounts have debit balance, so credit to close expenseLines.push({ accountNumber: account.accountNumber, credit: Math.abs(balance), description: `Closing ${account.accountName}`, }); totalExpense += Math.abs(balance); } } if (totalExpense > 0) { // Debit the closing account expenseLines.push({ accountNumber: closingAccountNumber, debit: totalExpense, description: 'Expense closing to P&L', }); const expenseClosingEntry = await this.postJournalEntry({ date: new Date(), description: `Closing expense accounts for period ${period}`, reference: `CLOSE-EXP-${period}`, lines: expenseLines, skrType: this.skrType, }); closingEntries.push(expenseClosingEntry); } this.logger.log( 'info', `Period ${period} closed with ${closingEntries.length} entries`, ); return closingEntries; } /** * Calculate account balance for a specific set of transactions */ private async getAccountBalanceForPeriod( accountNumber: string, transactions: Transaction[], ): Promise { const account = await Account.getAccountByNumber( accountNumber, this.skrType, ); if (!account) return 0; let debitTotal = 0; let creditTotal = 0; for (const transaction of transactions) { if (transaction.debitAccount === accountNumber) { debitTotal += transaction.amount; } if (transaction.creditAccount === accountNumber) { creditTotal += transaction.amount; } } // Calculate net balance based on account type switch (account.accountType) { case 'asset': case 'expense': return debitTotal - creditTotal; case 'liability': case 'equity': case 'revenue': return creditTotal - debitTotal; } } /** * Validate double-entry rules */ public validateDoubleEntry( debitAmount: number, creditAmount: number, ): boolean { return Math.abs(debitAmount - creditAmount) < 0.01; } /** * Get unbalanced transactions (for audit) */ public async getUnbalancedTransactions(): Promise { // In a proper double-entry system, all posted transactions should be balanced // This method is mainly for audit purposes const allTransactions = await Transaction.getInstances({ skrType: this.skrType, status: 'posted', }); // Group transactions by journal entry if they have one const unbalanced: Transaction[] = []; // Since our system ensures balance at posting time, // this should typically return an empty array // But we include it for completeness and audit purposes return unbalanced; } /** * Recalculate all account balances */ public async recalculateAllBalances(): Promise { this.logger.log('info', 'Recalculating all account balances'); // Get all accounts const accounts = await Account.getInstances({ skrType: this.skrType }); for (const account of accounts) { // Reset balances account.debitTotal = 0; account.creditTotal = 0; account.balance = 0; // Get all transactions for this account const transactions = await Transaction.getTransactionsByAccount( account.accountNumber, this.skrType, ); // Recalculate totals for (const transaction of transactions) { if (transaction.debitAccount === account.accountNumber) { account.debitTotal += transaction.amount; } if (transaction.creditAccount === account.accountNumber) { account.creditTotal += transaction.amount; } } // Calculate balance based on account type switch (account.accountType) { case 'asset': case 'expense': account.balance = account.debitTotal - account.creditTotal; break; case 'liability': case 'equity': case 'revenue': account.balance = account.creditTotal - account.debitTotal; break; } account.updatedAt = new Date(); await account.save(); } this.logger.log( 'info', `Recalculated balances for ${accounts.length} accounts`, ); } }