Files
HOA_Financial_Platform/backend/src/modules/accounts/accounts.service.ts
olsch01 2b331bb3ef feat: investment chart alignment, auto-renew records, fund transfers, capital planning report, and upcoming activities (v2026.3.24)
- Lock InvestmentTimeline and ProjectionChart to shared X axis range
- Auto-create renewal scenario_investments records when auto_renew is true
- Add fund transfer mechanism between asset accounts with journal entries
- Add Capital Planning Report (5-year forecast grouped by category)
- Add Upcoming Investment Activities dashboard card (maturities + planned purchases)
- Bump version to 2026.3.24

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 14:41:17 -04:00

447 lines
17 KiB
TypeScript

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);
}
}