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:
2026-02-19 14:32:35 -05:00
parent 17fdacc0f2
commit 301f8a7bde
20 changed files with 1760 additions and 145 deletions

View File

@@ -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) {

View File

@@ -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`

View File

@@ -36,4 +36,9 @@ export class UpdateAccountDto {
@IsIn(['operating', 'reserve'])
@IsOptional()
fundType?: string;
@ApiProperty({ required: false })
@IsBoolean()
@IsOptional()
isPrimary?: boolean;
}