import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { TenantService } from '../../database/tenant.service'; import { CreateAccountDto } from './dto/create-account.dto'; import { UpdateAccountDto } from './dto/update-account.dto'; @Injectable() export class AccountsService { constructor(private tenant: TenantService) {} async findAll(fundType?: string, includeArchived?: boolean) { const conditions: string[] = []; const params: any[] = []; if (!includeArchived) { conditions.push('a.is_active = true'); } if (fundType) { params.push(fundType); conditions.push(`a.fund_type = $${params.length}`); } const whereClause = conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''; const sql = ` SELECT a.*, CASE WHEN a.account_type IN ('asset', 'expense') THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) END as balance FROM accounts a LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false ${whereClause} GROUP BY a.id ORDER BY a.account_number `; return this.tenant.query(sql, params); } async findOne(id: string) { const rows = await this.tenant.query('SELECT * FROM accounts WHERE id = $1', [id]); if (!rows.length) throw new NotFoundException('Account not found'); return rows[0]; } async create(dto: CreateAccountDto) { const existing = await this.tenant.query( 'SELECT id FROM accounts WHERE account_number = $1', [dto.accountNumber], ); if (existing.length) { throw new BadRequestException(`Account number ${dto.accountNumber} already exists`); } const insertResult = await this.tenant.query( `INSERT INTO accounts (account_number, name, description, account_type, fund_type, parent_account_id, is_1099_reportable, interest_rate) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`, [ dto.accountNumber, dto.name, dto.description || null, dto.accountType, dto.fundType, dto.parentAccountId || null, dto.is1099Reportable || false, dto.interestRate || null, ], ); const accountId = Array.isArray(insertResult[0]) ? insertResult[0][0].id : insertResult[0].id; const account = await this.findOne(accountId); // Create opening balance journal entry if initialBalance is provided and non-zero if (dto.initialBalance && dto.initialBalance !== 0) { const balanceDate = dto.initialBalanceDate ? new Date(dto.initialBalanceDate) : new Date(); const year = balanceDate.getFullYear(); const month = balanceDate.getMonth() + 1; // Find the current fiscal period const periods = await this.tenant.query( 'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2', [year, month], ); if (periods.length) { const fiscalPeriodId = periods[0].id; const absAmount = Math.abs(dto.initialBalance); // Determine debit/credit based on account type const isDebitNormal = ['asset', 'expense'].includes(dto.accountType); const acctDebit = isDebitNormal ? absAmount : 0; const acctCredit = isDebitNormal ? 0 : absAmount; // Determine equity offset account based on fund type (auto-create if missing) const equityAccountNumber = dto.fundType === 'reserve' ? '3100' : '3000'; const equityName = dto.fundType === 'reserve' ? 'Reserve Fund Balance' : 'Operating Fund Balance'; let equityRows = await this.tenant.query( 'SELECT id FROM accounts WHERE account_number = $1', [equityAccountNumber], ); if (!equityRows.length) { await this.tenant.query( `INSERT INTO accounts (account_number, name, account_type, fund_type, is_system) VALUES ($1, $2, 'equity', $3, true)`, [equityAccountNumber, equityName, dto.fundType], ); equityRows = await this.tenant.query( 'SELECT id FROM accounts WHERE account_number = $1', [equityAccountNumber], ); } // Create the journal entry (use provided balance date or today) const entryDate = dto.initialBalanceDate || new Date().toISOString().split('T')[0]; const jeInsert = await this.tenant.query( `INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by) VALUES ($1::date, $2, 'opening_balance', $3, true, NOW(), $4) RETURNING id`, [ entryDate, `Opening balance for ${dto.name}`, fiscalPeriodId, '00000000-0000-0000-0000-000000000000', ], ); const jeId = Array.isArray(jeInsert[0]) ? jeInsert[0][0].id : jeInsert[0].id; // Line 1: debit/credit the target account await this.tenant.query( `INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo) VALUES ($1, $2, $3, $4, $5)`, [jeId, accountId, acctDebit, acctCredit, 'Opening balance'], ); // Line 2: balancing entry to equity offset account if (equityRows.length) { await this.tenant.query( `INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo) VALUES ($1, $2, $3, $4, $5)`, [jeId, equityRows[0].id, acctCredit, acctDebit, `Opening balance for ${dto.name}`], ); } } } // Auto-set as primary if this is the first asset account for this fund_type if (dto.accountType === 'asset') { const existingPrimary = await this.tenant.query( 'SELECT id FROM accounts WHERE fund_type = $1 AND is_primary = true AND id != $2', [dto.fundType, accountId], ); if (!existingPrimary.length) { await this.tenant.query( 'UPDATE accounts SET is_primary = true WHERE id = $1', [accountId], ); } } return this.findOne(accountId); } async update(id: string, dto: UpdateAccountDto) { const account = await this.findOne(id); if (account.is_system && dto.accountType && dto.accountType !== account.account_type) { throw new BadRequestException('Cannot change type of system account'); } // Handle isPrimary: clear other primary accounts in the same fund_type first if (dto.isPrimary === true) { await this.tenant.query( `UPDATE accounts SET is_primary = false WHERE fund_type = (SELECT fund_type FROM accounts WHERE id = $1) AND is_primary = true`, [id], ); } const sets: string[] = []; const params: any[] = []; let idx = 1; if (dto.name !== undefined) { sets.push(`name = $${idx++}`); params.push(dto.name); } if (dto.description !== undefined) { sets.push(`description = $${idx++}`); params.push(dto.description); } if (dto.accountNumber !== undefined) { sets.push(`account_number = $${idx++}`); params.push(dto.accountNumber); } if (dto.accountType !== undefined) { sets.push(`account_type = $${idx++}`); params.push(dto.accountType); } if (dto.fundType !== undefined) { sets.push(`fund_type = $${idx++}`); params.push(dto.fundType); } if (dto.is1099Reportable !== undefined) { sets.push(`is_1099_reportable = $${idx++}`); params.push(dto.is1099Reportable); } if (dto.isActive !== undefined) { sets.push(`is_active = $${idx++}`); params.push(dto.isActive); } if (dto.isPrimary !== undefined) { sets.push(`is_primary = $${idx++}`); params.push(dto.isPrimary); } if (dto.interestRate !== undefined) { sets.push(`interest_rate = $${idx++}`); params.push(dto.interestRate); } if (!sets.length) return account; sets.push(`updated_at = NOW()`); params.push(id); await this.tenant.query( `UPDATE accounts SET ${sets.join(', ')} WHERE id = $${idx}`, params, ); return this.findOne(id); } async setPrimary(id: string) { const account = await this.findOne(id); // Clear other primary accounts in the same fund_type await this.tenant.query( `UPDATE accounts SET is_primary = false WHERE fund_type = $1 AND is_primary = true`, [account.fund_type], ); // Set this account as primary await this.tenant.query( `UPDATE accounts SET is_primary = true, updated_at = NOW() WHERE id = $1`, [id], ); return this.findOne(id); } async setOpeningBalance(id: string, dto: { targetBalance: number; asOfDate: string; memo?: string }) { return this.adjustBalance(id, dto, 'opening_balance'); } async bulkSetOpeningBalances(dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] }) { let processed = 0, skipped = 0; const errors: string[] = []; for (const entry of dto.entries) { try { const result = await this.setOpeningBalance(entry.accountId, { targetBalance: entry.targetBalance, asOfDate: dto.asOfDate, }); if (result.message === 'No adjustment needed') skipped++; else processed++; } catch (err: any) { errors.push(`${entry.accountId}: ${err.message}`); } } return { processed, skipped, errors }; } async adjustBalance(id: string, dto: { targetBalance: number; asOfDate: string; memo?: string }, entryType = 'adjustment') { const account = await this.findOne(id); // Get current balance for this account using trial balance logic const balanceRows = await this.tenant.query( `SELECT CASE WHEN a.account_type IN ('asset', 'expense') THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) END as balance FROM accounts a LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false AND je.entry_date <= $1 WHERE a.id = $2 GROUP BY a.id, a.account_type`, [dto.asOfDate, id], ); const currentBalance = balanceRows.length ? parseFloat(balanceRows[0].balance) : 0; const difference = dto.targetBalance - currentBalance; if (difference === 0) { return { message: 'No adjustment needed' }; } // Find fiscal period for the asOfDate const asOf = new Date(dto.asOfDate); const year = asOf.getFullYear(); const month = asOf.getMonth() + 1; const periods = await this.tenant.query( 'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2', [year, month], ); if (!periods.length) { throw new BadRequestException(`No fiscal period found for ${year}-${String(month).padStart(2, '0')}`); } const fiscalPeriodId = periods[0].id; // Determine the equity offset account based on fund_type const equityAccountNumber = account.fund_type === 'reserve' ? '3100' : '3000'; const equityRows = await this.tenant.query( 'SELECT id, account_type FROM accounts WHERE account_number = $1', [equityAccountNumber], ); if (!equityRows.length) { throw new BadRequestException( `Equity offset account ${equityAccountNumber} not found`, ); } const equityAccount = equityRows[0]; // Calculate debit/credit for the target account line // For debit-normal accounts (asset, expense): increase = debit, decrease = credit // For credit-normal accounts (liability, equity, income): increase = credit, decrease = debit const isDebitNormal = ['asset', 'expense'].includes(account.account_type); const absDifference = Math.abs(difference); let targetDebit: number; let targetCredit: number; if (isDebitNormal) { // Debit-normal: positive difference means we need more debit targetDebit = difference > 0 ? absDifference : 0; targetCredit = difference > 0 ? 0 : absDifference; } else { // Credit-normal: positive difference means we need more credit targetDebit = difference > 0 ? 0 : absDifference; targetCredit = difference > 0 ? absDifference : 0; } // Balancing line to equity account is the opposite const equityDebit = targetCredit > 0 ? targetCredit : 0; const equityCredit = targetDebit > 0 ? targetDebit : 0; const defaultMemo = entryType === 'opening_balance' ? `Opening balance for ${account.name}` : `Balance adjustment to ${dto.targetBalance}`; const memo = dto.memo || defaultMemo; // Create journal entry const jeRows = await this.tenant.query( `INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by) VALUES ($1, $2, $3, $4, true, NOW(), $5) RETURNING *`, [ dto.asOfDate, memo, entryType, fiscalPeriodId, '00000000-0000-0000-0000-000000000000', ], ); const journalEntry = jeRows[0]; // Create the two journal entry lines await this.tenant.query( `INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo) VALUES ($1, $2, $3, $4, $5)`, [journalEntry.id, id, targetDebit, targetCredit, memo], ); await this.tenant.query( `INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo) VALUES ($1, $2, $3, $4, $5)`, [journalEntry.id, equityAccount.id, equityDebit, equityCredit, memo], ); return journalEntry; } async transferFunds(dto: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo?: string; }) { if (dto.amount <= 0) throw new BadRequestException('Transfer amount must be positive'); if (dto.fromAccountId === dto.toAccountId) throw new BadRequestException('Cannot transfer to the same account'); const fromAccount = await this.findOne(dto.fromAccountId); const toAccount = await this.findOne(dto.toAccountId); if (fromAccount.account_type !== 'asset') throw new BadRequestException('Source account must be an asset account'); if (toAccount.account_type !== 'asset') throw new BadRequestException('Destination account must be an asset account'); // Find fiscal period const asOf = new Date(dto.transferDate); const year = asOf.getFullYear(); const month = asOf.getMonth() + 1; const periods = await this.tenant.query( 'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2', [year, month], ); if (!periods.length) { throw new BadRequestException(`No fiscal period found for ${year}-${String(month).padStart(2, '0')}`); } const memo = dto.memo || `Transfer from ${fromAccount.name} to ${toAccount.name}`; // Create journal entry: debit destination (increase), credit source (decrease) const jeRows = await this.tenant.query( `INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by) VALUES ($1, $2, 'transfer', $3, true, NOW(), $4) RETURNING *`, [dto.transferDate, memo, periods[0].id, '00000000-0000-0000-0000-000000000000'], ); const je = jeRows[0]; // Credit source account (reduces asset balance) await this.tenant.query( `INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo) VALUES ($1, $2, 0, $3, $4)`, [je.id, dto.fromAccountId, dto.amount, memo], ); // Debit destination account (increases asset balance) await this.tenant.query( `INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo) VALUES ($1, $2, $3, 0, $4)`, [je.id, dto.toAccountId, dto.amount, memo], ); return je; } async getTrialBalance(asOfDate?: string) { const dateFilter = asOfDate ? `AND je.entry_date <= $1` : ''; const params = asOfDate ? [asOfDate] : []; const sql = ` SELECT a.id, a.account_number, a.name, a.account_type, a.fund_type, COALESCE(SUM(jel.debit), 0) as total_debits, COALESCE(SUM(jel.credit), 0) as total_credits, CASE WHEN a.account_type IN ('asset', 'expense') THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) END as balance FROM accounts a LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false ${dateFilter} WHERE a.is_active = true GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type ORDER BY a.account_number `; return this.tenant.query(sql, params); } }