- 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>
447 lines
17 KiB
TypeScript
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);
|
|
}
|
|
}
|