Phase 3: Optimize & clean up — unified projects, account enhancements, new tenant fix
- Unify reserve_components + capital_projects into single projects model with full CRUD backend and new Projects page frontend - Rewrite Capital Planning to read from unified projects/planning endpoint; add empty state directing users to Projects page when no planning items exist - Add default designation to assessment groups with auto-set on first creation; units now require an assessment group (pre-populated with default) - Add primary account designation (one per fund type) and balance adjustment via journal entries against equity offset accounts (3000/3100) - Add computed investment fields (interest earned, maturity value, days remaining) with PostgreSQL date arithmetic fix for DATE - DATE integer result - Restructure sidebar: investments in Accounts tab, Year-End under Reports, Planning section with Projects and Capital Planning - Fix new tenant creation seeding unwanted default chart of accounts — new tenants now start with a blank slate Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,21 @@ export class AccountsController {
|
||||
return this.accountsService.getTrialBalance(asOfDate);
|
||||
}
|
||||
|
||||
@Put(':id/set-primary')
|
||||
@ApiOperation({ summary: 'Set account as primary for its fund type' })
|
||||
setPrimary(@Param('id') id: string) {
|
||||
return this.accountsService.setPrimary(id);
|
||||
}
|
||||
|
||||
@Post(':id/adjust-balance')
|
||||
@ApiOperation({ summary: 'Adjust account balance to a target amount' })
|
||||
adjustBalance(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
|
||||
) {
|
||||
return this.accountsService.adjustBalance(id, dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get account by ID' })
|
||||
findOne(@Param('id') id: string) {
|
||||
|
||||
@@ -109,6 +109,16 @@ export class AccountsService {
|
||||
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;
|
||||
@@ -120,6 +130,7 @@ export class AccountsService {
|
||||
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 (!sets.length) return account;
|
||||
|
||||
@@ -133,6 +144,136 @@ export class AccountsService {
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
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 adjustBalance(id: string, dto: { targetBalance: number; asOfDate: string; memo?: string }) {
|
||||
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 memo = dto.memo || `Balance adjustment to ${dto.targetBalance}`;
|
||||
|
||||
// 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, 'adjustment', $3, true, NOW(), $4)
|
||||
RETURNING *`,
|
||||
[
|
||||
dto.asOfDate,
|
||||
memo,
|
||||
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 getTrialBalance(asOfDate?: string) {
|
||||
const dateFilter = asOfDate
|
||||
? `AND je.entry_date <= $1`
|
||||
|
||||
@@ -36,4 +36,9 @@ export class UpdateAccountDto {
|
||||
@IsIn(['operating', 'reserve'])
|
||||
@IsOptional()
|
||||
fundType?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isPrimary?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user