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

View File

@@ -16,6 +16,9 @@ export class AssessmentGroupsController {
@Get('summary')
getSummary() { return this.service.getSummary(); }
@Get('default')
getDefault() { return this.service.getDefault(); }
@Get(':id')
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@@ -24,4 +27,7 @@ export class AssessmentGroupsController {
@Put(':id')
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
@Put(':id/set-default')
setDefault(@Param('id') id: string) { return this.service.setDefault(id); }
}

View File

@@ -6,10 +6,6 @@ export class AssessmentGroupsService {
constructor(private tenant: TenantService) {}
async findAll() {
// Normalize all income calculations to monthly equivalent
// monthly: amount * units (already monthly)
// quarterly: amount/3 * units (convert to monthly)
// annual: amount/12 * units (convert to monthly)
return this.tenant.query(`
SELECT ag.*,
(SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id) as actual_unit_count,
@@ -39,17 +35,38 @@ export class AssessmentGroupsService {
return rows[0];
}
async create(dto: any) {
async getDefault() {
const rows = await this.tenant.query(
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0, dto.unitCount || 0, dto.frequency || 'monthly'],
'SELECT * FROM assessment_groups WHERE is_default = true AND is_active = true LIMIT 1',
);
return rows.length ? rows[0] : null;
}
async create(dto: any) {
const existingGroups = await this.tenant.query('SELECT COUNT(*) as cnt FROM assessment_groups');
const isFirstGroup = parseInt(existingGroups[0].cnt) === 0;
const shouldBeDefault = dto.isDefault || isFirstGroup;
if (shouldBeDefault) {
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
}
const rows = await this.tenant.query(
`INSERT INTO assessment_groups (name, description, regular_assessment, special_assessment, unit_count, frequency, is_default)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
[dto.name, dto.description || null, dto.regularAssessment || 0, dto.specialAssessment || 0,
dto.unitCount || 0, dto.frequency || 'monthly', shouldBeDefault],
);
return rows[0];
}
async update(id: string, dto: any) {
await this.findOne(id);
if (dto.isDefault === true) {
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
}
const sets: string[] = [];
const params: any[] = [];
let idx = 1;
@@ -61,6 +78,7 @@ export class AssessmentGroupsService {
if (dto.unitCount !== undefined) { sets.push(`unit_count = $${idx++}`); params.push(dto.unitCount); }
if (dto.isActive !== undefined) { sets.push(`is_active = $${idx++}`); params.push(dto.isActive); }
if (dto.frequency !== undefined) { sets.push(`frequency = $${idx++}`); params.push(dto.frequency); }
if (dto.isDefault !== undefined) { sets.push(`is_default = $${idx++}`); params.push(dto.isDefault); }
if (!sets.length) return this.findOne(id);
@@ -74,6 +92,16 @@ export class AssessmentGroupsService {
return rows[0];
}
async setDefault(id: string) {
await this.findOne(id);
await this.tenant.query('UPDATE assessment_groups SET is_default = false WHERE is_default = true');
await this.tenant.query(
'UPDATE assessment_groups SET is_default = true, updated_at = NOW() WHERE id = $1',
[id],
);
return this.findOne(id);
}
async getSummary() {
const rows = await this.tenant.query(`
SELECT

View File

@@ -6,7 +6,30 @@ export class InvestmentsService {
constructor(private tenant: TenantService) {}
async findAll() {
return this.tenant.query('SELECT * FROM investment_accounts WHERE is_active = true ORDER BY name');
return this.tenant.query(`
SELECT ia.*,
CASE
WHEN ia.purchase_date IS NOT NULL AND ia.interest_rate IS NOT NULL AND ia.interest_rate > 0 THEN
ROUND(ia.principal * (ia.interest_rate / 100.0) *
(LEAST(COALESCE(ia.maturity_date, CURRENT_DATE), CURRENT_DATE) - ia.purchase_date)::numeric
/ 365.0, 2)
ELSE NULL
END as interest_earned,
CASE
WHEN ia.purchase_date IS NOT NULL AND ia.maturity_date IS NOT NULL AND ia.interest_rate IS NOT NULL THEN
ROUND(ia.principal * (1 + ia.interest_rate / 100.0 *
(ia.maturity_date - ia.purchase_date)::numeric / 365.0), 2)
ELSE NULL
END as maturity_value,
CASE
WHEN ia.maturity_date IS NOT NULL THEN
GREATEST(ia.maturity_date - CURRENT_DATE, 0)
ELSE NULL
END as days_remaining
FROM investment_accounts ia
WHERE ia.is_active = true
ORDER BY ia.name
`);
}
async findOne(id: string) {

View File

@@ -0,0 +1,32 @@
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ProjectsService } from './projects.service';
@ApiTags('projects')
@Controller('projects')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class ProjectsController {
constructor(private service: ProjectsService) {}
@Get()
findAll() { return this.service.findAll(); }
@Get('planning')
findForPlanning() { return this.service.findForPlanning(); }
@Get(':id')
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post()
create(@Body() dto: any) { return this.service.create(dto); }
@Put(':id')
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
@Put(':id/planned-date')
updatePlannedDate(@Param('id') id: string, @Body() dto: { planned_date: string }) {
return this.service.updatePlannedDate(id, dto.planned_date);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../../database/database.module';
import { ProjectsService } from './projects.service';
import { ProjectsController } from './projects.controller';
@Module({
imports: [DatabaseModule],
controllers: [ProjectsController],
providers: [ProjectsService],
exports: [ProjectsService],
})
export class ProjectsModule {}

View File

@@ -0,0 +1,108 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { TenantService } from '../../database/tenant.service';
@Injectable()
export class ProjectsService {
constructor(private tenant: TenantService) {}
async findAll() {
// Return all active projects ordered by name
return this.tenant.query('SELECT * FROM projects WHERE is_active = true ORDER BY name');
}
async findOne(id: string) {
const rows = await this.tenant.query('SELECT * FROM projects WHERE id = $1', [id]);
if (!rows.length) throw new NotFoundException('Project not found');
return rows[0];
}
async findForPlanning() {
// Only return projects that have target_year set (for the Capital Planning kanban)
return this.tenant.query(`
SELECT * FROM projects
WHERE is_active = true AND target_year IS NOT NULL
ORDER BY target_year, target_month NULLS LAST, priority
`);
}
async create(dto: any) {
// Default planned_date to next_replacement_date if not provided
const plannedDate = dto.planned_date || dto.next_replacement_date || null;
// If fund_source is not 'reserve', funded_percentage stays 0
const fundedPct = dto.fund_source === 'reserve' ? (dto.funded_percentage || 0) : 0;
const rows = await this.tenant.query(
`INSERT INTO projects (
name, description, category, estimated_cost, actual_cost,
current_fund_balance, annual_contribution, fund_source, funded_percentage,
useful_life_years, remaining_life_years, condition_rating,
last_replacement_date, next_replacement_date, planned_date,
target_year, target_month, status, priority, account_id, notes
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)
RETURNING *`,
[
dto.name, dto.description || null, dto.category || null,
dto.estimated_cost || 0, dto.actual_cost || null,
dto.current_fund_balance || 0, dto.annual_contribution || 0,
dto.fund_source || 'reserve', fundedPct,
dto.useful_life_years || null, dto.remaining_life_years || null,
dto.condition_rating || null,
dto.last_replacement_date || null, dto.next_replacement_date || null,
plannedDate,
dto.target_year || null, dto.target_month || null,
dto.status || 'planned', dto.priority || 3,
dto.account_id || null, dto.notes || null,
],
);
return rows[0];
}
async update(id: string, dto: any) {
await this.findOne(id);
const sets: string[] = [];
const params: any[] = [];
let idx = 1;
// Build dynamic SET clause
const fields: [string, string][] = [
['name', 'name'], ['description', 'description'], ['category', 'category'],
['estimated_cost', 'estimated_cost'], ['actual_cost', 'actual_cost'],
['current_fund_balance', 'current_fund_balance'], ['annual_contribution', 'annual_contribution'],
['fund_source', 'fund_source'], ['funded_percentage', 'funded_percentage'],
['useful_life_years', 'useful_life_years'], ['remaining_life_years', 'remaining_life_years'],
['condition_rating', 'condition_rating'],
['last_replacement_date', 'last_replacement_date'], ['next_replacement_date', 'next_replacement_date'],
['planned_date', 'planned_date'],
['target_year', 'target_year'], ['target_month', 'target_month'],
['status', 'status'], ['priority', 'priority'],
['account_id', 'account_id'], ['notes', 'notes'], ['is_active', 'is_active'],
];
for (const [dtoKey, dbCol] of fields) {
if (dto[dtoKey] !== undefined) {
sets.push(`${dbCol} = $${idx++}`);
params.push(dto[dtoKey]);
}
}
if (!sets.length) return this.findOne(id);
sets.push('updated_at = NOW()');
params.push(id);
const rows = await this.tenant.query(
`UPDATE projects SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
params,
);
return rows[0];
}
async updatePlannedDate(id: string, planned_date: string) {
await this.findOne(id);
const rows = await this.tenant.query(
'UPDATE projects SET planned_date = $2, updated_at = NOW() WHERE id = $1 RETURNING *',
[id, planned_date],
);
return rows[0];
}
}

View File

@@ -32,10 +32,29 @@ export class UnitsService {
const existing = await this.tenant.query('SELECT id FROM units WHERE unit_number = $1', [dto.unit_number]);
if (existing.length) throw new BadRequestException(`Unit ${dto.unit_number} already exists`);
// Resolve assessment group: use provided, fall back to default, or error
let groupId = dto.assessment_group_id || null;
if (!groupId) {
const defaultGroup = await this.tenant.query(
'SELECT id FROM assessment_groups WHERE is_default = true AND is_active = true LIMIT 1',
);
if (defaultGroup.length) {
groupId = defaultGroup[0].id;
} else {
// Check if any groups exist at all
const anyGroup = await this.tenant.query('SELECT id FROM assessment_groups WHERE is_active = true LIMIT 1');
if (!anyGroup.length) {
throw new BadRequestException('An assessment group must exist before creating units. Please create an assessment group first.');
}
// Use the first available group
groupId = anyGroup[0].id;
}
}
const rows = await this.tenant.query(
`INSERT INTO units (unit_number, address_line1, city, state, zip_code, owner_name, owner_email, owner_phone, monthly_assessment, assessment_group_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
[dto.unit_number, dto.address_line1, dto.city, dto.state, dto.zip_code, dto.owner_name, dto.owner_email, dto.owner_phone, dto.monthly_assessment || 0, dto.assessment_group_id || null],
[dto.unit_number, dto.address_line1, dto.city, dto.state, dto.zip_code, dto.owner_name, dto.owner_email, dto.owner_phone, dto.monthly_assessment || 0, groupId],
);
return rows[0];
}