import * as plugins from './plugins.js'; import { getDbSync } from './skr.database.js'; import { Account } from './skr.classes.account.js'; import { Transaction } from './skr.classes.transaction.js'; import type { TSKRType, IJournalEntry, IJournalEntryLine, } from './skr.types.js'; const { SmartDataDbDoc, svDb, unI, index, searchable } = plugins.smartdata; @plugins.smartdata.Collection(() => getDbSync()) export class JournalEntry extends SmartDataDbDoc { @unI() public id: string; @svDb() @index() public journalNumber: string; @svDb() @index() public date: Date; @svDb() @searchable() public description: string; @svDb() @index() public reference: string; @svDb() public lines: IJournalEntryLine[]; @svDb() @index() public skrType: TSKRType; @svDb() public totalDebits: number; @svDb() public totalCredits: number; @svDb() public isBalanced: boolean; @svDb() @index() public status: 'draft' | 'posted' | 'reversed'; @svDb() public transactionIds: string[]; @svDb() @index() public period: string; @svDb() public fiscalYear: number; @svDb() public createdAt: Date; @svDb() public postedAt: Date; @svDb() public createdBy: string; constructor(data?: Partial) { super(); if (data) { this.id = plugins.smartunique.shortId(); this.journalNumber = this.generateJournalNumber(); this.date = data.date || new Date(); this.description = data.description || ''; this.reference = data.reference || ''; this.lines = data.lines || []; this.skrType = data.skrType || 'SKR03'; this.totalDebits = 0; this.totalCredits = 0; this.isBalanced = false; this.status = 'draft'; this.transactionIds = []; // Set period and fiscal year const entryDate = new Date(this.date); this.period = `${entryDate.getFullYear()}-${String(entryDate.getMonth() + 1).padStart(2, '0')}`; this.fiscalYear = entryDate.getFullYear(); this.createdAt = new Date(); this.postedAt = null; this.createdBy = 'system'; // Calculate totals this.calculateTotals(); } } private generateJournalNumber(): string { const timestamp = Date.now(); const random = Math.floor(Math.random() * 1000); return `JE-${timestamp}-${random}`; } private calculateTotals(): void { this.totalDebits = 0; this.totalCredits = 0; for (const line of this.lines) { if (line.debit) { this.totalDebits += line.debit; } if (line.credit) { this.totalCredits += line.credit; } } // Check if balanced (allowing for small rounding differences) const difference = Math.abs(this.totalDebits - this.totalCredits); this.isBalanced = difference < 0.01; } public static async createJournalEntry( data: IJournalEntry, ): Promise { const journalEntry = new JournalEntry(data); await journalEntry.validate(); await journalEntry.save(); return journalEntry; } public addLine(line: IJournalEntryLine): void { // Validate line if (!line.accountNumber) { throw new Error('Account number is required for journal entry line'); } if (!line.debit && !line.credit) { throw new Error('Either debit or credit amount is required'); } if (line.debit && line.credit) { throw new Error('A line cannot have both debit and credit amounts'); } if (line.debit && line.debit < 0) { throw new Error('Debit amount must be positive'); } if (line.credit && line.credit < 0) { throw new Error('Credit amount must be positive'); } this.lines.push(line); this.calculateTotals(); } public removeLine(index: number): void { if (index >= 0 && index < this.lines.length) { this.lines.splice(index, 1); this.calculateTotals(); } } public async validate(): Promise { // Check if entry is balanced if (!this.isBalanced) { throw new Error( `Journal entry is not balanced. Debits: ${this.totalDebits}, Credits: ${this.totalCredits}`, ); } // Check minimum lines if (this.lines.length < 2) { throw new Error('Journal entry must have at least 2 lines'); } // Validate all accounts exist and are active for (const line of this.lines) { const account = await Account.getAccountByNumber( line.accountNumber, this.skrType, ); if (!account) { throw new Error( `Account ${line.accountNumber} not found for ${this.skrType}`, ); } if (!account.isActive) { throw new Error(`Account ${line.accountNumber} is not active`); } } } public async post(): Promise { if (this.status === 'posted') { throw new Error('Journal entry is already posted'); } // Validate before posting await this.validate(); // Create individual transactions for each debit-credit pair const transactions: Transaction[] = []; // Simple posting logic: match debits with credits // For complex entries, this could be enhanced with specific pairing logic const debitLines = this.lines.filter((l) => l.debit); const creditLines = this.lines.filter((l) => l.credit); if (debitLines.length === 1 && creditLines.length === 1) { // Simple entry: one debit, one credit const transaction = await Transaction.createTransaction({ date: this.date, debitAccount: debitLines[0].accountNumber, creditAccount: creditLines[0].accountNumber, amount: debitLines[0].debit, description: this.description, reference: this.reference, skrType: this.skrType, costCenter: debitLines[0].costCenter, }); transactions.push(transaction); } else { // Complex entry: multiple debits and/or credits // Create transactions to balance the entry for (const debitLine of debitLines) { for (const creditLine of creditLines) { const amount = Math.min(debitLine.debit || 0, creditLine.credit || 0); if (amount > 0) { const transaction = await Transaction.createTransaction({ date: this.date, debitAccount: debitLine.accountNumber, creditAccount: creditLine.accountNumber, amount: amount, description: `${this.description} - ${debitLine.description || creditLine.description || ''}`, reference: this.reference, skrType: this.skrType, costCenter: debitLine.costCenter || creditLine.costCenter, }); transactions.push(transaction); // Reduce amounts for tracking if (debitLine.debit) debitLine.debit -= amount; if (creditLine.credit) creditLine.credit -= amount; } } } } // Store transaction IDs this.transactionIds = transactions.map((t) => t.id); // Update status this.status = 'posted'; this.postedAt = new Date(); await this.save(); } public async reverse(): Promise { if (this.status !== 'posted') { throw new Error('Can only reverse posted journal entries'); } // Create reversal entry with swapped debits and credits const reversalLines: IJournalEntryLine[] = this.lines.map((line) => ({ accountNumber: line.accountNumber, debit: line.credit, // Swap credit: line.debit, // Swap description: `Reversal: ${line.description || ''}`, costCenter: line.costCenter, })); const reversalEntry = new JournalEntry({ date: new Date(), description: `Reversal of ${this.journalNumber}: ${this.description}`, reference: `REV-${this.journalNumber}`, lines: reversalLines, skrType: this.skrType, }); await reversalEntry.validate(); await reversalEntry.post(); // Update original entry status this.status = 'reversed'; await this.save(); return reversalEntry; } public async beforeSave(): Promise { // Recalculate totals before saving this.calculateTotals(); // Validate required fields if (!this.date) { throw new Error('Journal entry date is required'); } if (!this.description) { throw new Error('Journal entry description is required'); } if (this.lines.length === 0) { throw new Error('Journal entry must have at least one line'); } } }