import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { TenantService } from '../../database/tenant.service'; import { FiscalPeriodsService } from '../fiscal-periods/fiscal-periods.service'; import { CreateJournalEntryDto } from './dto/create-journal-entry.dto'; @Injectable() export class JournalEntriesService { constructor( private tenant: TenantService, private fiscalPeriodsService: FiscalPeriodsService, ) {} async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) { let sql = ` SELECT je.*, json_agg(json_build_object( 'id', jel.id, 'account_id', jel.account_id, 'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo, 'account_name', a.name, 'account_number', a.account_number )) as lines FROM journal_entries je LEFT JOIN journal_entry_lines jel ON jel.journal_entry_id = je.id LEFT JOIN accounts a ON a.id = jel.account_id WHERE 1=1 `; const params: any[] = []; let idx = 1; if (filters.from) { sql += ` AND je.entry_date >= $${idx++}`; params.push(filters.from); } if (filters.to) { sql += ` AND je.entry_date <= $${idx++}`; params.push(filters.to); } if (filters.accountId) { sql += ` AND je.id IN (SELECT journal_entry_id FROM journal_entry_lines WHERE account_id = $${idx++})`; params.push(filters.accountId); } if (filters.type) { sql += ` AND je.entry_type = $${idx++}`; params.push(filters.type); } sql += ' GROUP BY je.id ORDER BY je.entry_date DESC, je.created_at DESC'; return this.tenant.query(sql, params); } async findOne(id: string) { const rows = await this.tenant.query( `SELECT je.*, json_agg(json_build_object( 'id', jel.id, 'account_id', jel.account_id, 'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo, 'account_name', a.name, 'account_number', a.account_number )) as lines FROM journal_entries je LEFT JOIN journal_entry_lines jel ON jel.journal_entry_id = je.id LEFT JOIN accounts a ON a.id = jel.account_id WHERE je.id = $1 GROUP BY je.id`, [id], ); if (!rows.length) throw new NotFoundException('Journal entry not found'); return rows[0]; } async create(dto: CreateJournalEntryDto, userId: string) { // Validate debits = credits const totalDebits = dto.lines.reduce((sum, l) => sum + (l.debit || 0), 0); const totalCredits = dto.lines.reduce((sum, l) => sum + (l.credit || 0), 0); if (Math.abs(totalDebits - totalCredits) > 0.001) { throw new BadRequestException( `Debits ($${totalDebits.toFixed(2)}) must equal credits ($${totalCredits.toFixed(2)})`, ); } if (dto.lines.length < 2) { throw new BadRequestException('Journal entry must have at least 2 lines'); } // Find or create fiscal period const entryDate = new Date(dto.entryDate); const fp = await this.fiscalPeriodsService.findOrCreate( entryDate.getFullYear(), entryDate.getMonth() + 1, ); if (fp.status === 'locked') { throw new BadRequestException('Cannot post to a locked fiscal period'); } if (fp.status === 'closed') { throw new BadRequestException('Cannot post to a closed fiscal period'); } // Create journal entry const jeRows = await this.tenant.query( `INSERT INTO journal_entries (entry_date, description, reference_number, entry_type, fiscal_period_id, source_type, source_id, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [ dto.entryDate, dto.description, dto.referenceNumber || null, dto.entryType || 'manual', fp.id, dto.sourceType || null, dto.sourceId || null, userId, ], ); const je = jeRows[0]; // Create lines for (const line of dto.lines) { await this.tenant.query( `INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo) VALUES ($1, $2, $3, $4, $5)`, [je.id, line.accountId, line.debit || 0, line.credit || 0, line.memo || null], ); } return this.findOne(je.id); } async post(id: string, userId: string) { const je = await this.findOne(id); if (je.is_posted) throw new BadRequestException('Already posted'); if (je.is_void) throw new BadRequestException('Cannot post a voided entry'); // Update account balances for (const line of je.lines) { const debit = parseFloat(line.debit) || 0; const credit = parseFloat(line.credit) || 0; const netAmount = debit - credit; await this.tenant.query( `UPDATE accounts SET balance = balance + $1, updated_at = NOW() WHERE id = $2`, [netAmount, line.account_id], ); } await this.tenant.query( `UPDATE journal_entries SET is_posted = true, posted_by = $1, posted_at = NOW() WHERE id = $2`, [userId, id], ); return this.findOne(id); } async void(id: string, userId: string, reason: string) { const je = await this.findOne(id); if (!je.is_posted) throw new BadRequestException('Cannot void an unposted entry'); if (je.is_void) throw new BadRequestException('Already voided'); // Reverse account balances for (const line of je.lines) { const debit = parseFloat(line.debit) || 0; const credit = parseFloat(line.credit) || 0; const reverseAmount = credit - debit; await this.tenant.query( `UPDATE accounts SET balance = balance + $1, updated_at = NOW() WHERE id = $2`, [reverseAmount, line.account_id], ); } await this.tenant.query( `UPDATE journal_entries SET is_void = true, voided_by = $1, voided_at = NOW(), void_reason = $2 WHERE id = $3`, [userId, reason, id], ); // Create reversing entry const reverseDto: CreateJournalEntryDto = { entryDate: new Date().toISOString().split('T')[0], description: `VOID: ${je.description}`, referenceNumber: `VOID-${je.reference_number || je.id.slice(0, 8)}`, entryType: 'adjustment', lines: je.lines.map((l: any) => ({ accountId: l.account_id, debit: parseFloat(l.credit) || 0, credit: parseFloat(l.debit) || 0, memo: `Reversal of voided entry`, })), }; const reversing = await this.create(reverseDto, userId); await this.post(reversing.id, userId); return this.findOne(id); } }