diff --git a/backend/package-lock.json b/backend/package-lock.json index 9a43a93..848adb0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hoa-ledgeriq-backend", - "version": "2026.3.17", + "version": "2026.3.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hoa-ledgeriq-backend", - "version": "2026.3.17", + "version": "2026.3.19", "dependencies": { "@nestjs/common": "^10.4.15", "@nestjs/config": "^3.3.0", diff --git a/backend/package.json b/backend/package.json index b91551f..47d0896 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "hoa-ledgeriq-backend", - "version": "2026.3.19", + "version": "2026.3.24", "description": "HOA LedgerIQ - Backend API", "private": true, "scripts": { diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 3154b6d..e05aafd 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -33,6 +33,7 @@ import { BoardPlanningModule } from './modules/board-planning/board-planning.mod import { BillingModule } from './modules/billing/billing.module'; import { EmailModule } from './modules/email/email.module'; import { OnboardingModule } from './modules/onboarding/onboarding.module'; +import { IdeasModule } from './modules/ideas/ideas.module'; import { ScheduleModule } from '@nestjs/schedule'; @Module({ @@ -88,6 +89,7 @@ import { ScheduleModule } from '@nestjs/schedule'; BillingModule, EmailModule, OnboardingModule, + IdeasModule, ScheduleModule.forRoot(), ], controllers: [AppController], diff --git a/backend/src/modules/accounts/accounts.controller.ts b/backend/src/modules/accounts/accounts.controller.ts index 038ce4f..2c07733 100644 --- a/backend/src/modules/accounts/accounts.controller.ts +++ b/backend/src/modules/accounts/accounts.controller.ts @@ -58,6 +58,14 @@ export class AccountsController { return this.accountsService.adjustBalance(id, dto); } + @Post('transfer') + @ApiOperation({ summary: 'Transfer funds between asset accounts' }) + transferFunds( + @Body() dto: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo?: string }, + ) { + return this.accountsService.transferFunds(dto); + } + @Get(':id') @ApiOperation({ summary: 'Get account by ID' }) findOne(@Param('id') id: string) { diff --git a/backend/src/modules/accounts/accounts.service.ts b/backend/src/modules/accounts/accounts.service.ts index 911b457..b99c77b 100644 --- a/backend/src/modules/accounts/accounts.service.ts +++ b/backend/src/modules/accounts/accounts.service.ts @@ -360,6 +360,62 @@ export class AccountsService { 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` diff --git a/backend/src/modules/auth/admin.controller.ts b/backend/src/modules/auth/admin.controller.ts index 4a4a68e..0ae7fa3 100644 --- a/backend/src/modules/auth/admin.controller.ts +++ b/backend/src/modules/auth/admin.controller.ts @@ -5,6 +5,7 @@ import { AuthService } from './auth.service'; import { UsersService } from '../users/users.service'; import { OrganizationsService } from '../organizations/organizations.service'; import { AdminAnalyticsService } from './admin-analytics.service'; +import { IdeasService } from '../ideas/ideas.service'; import * as bcrypt from 'bcryptjs'; @ApiTags('admin') @@ -17,6 +18,7 @@ export class AdminController { private usersService: UsersService, private orgService: OrganizationsService, private analyticsService: AdminAnalyticsService, + private ideasService: IdeasService, ) {} private async requireSuperadmin(req: any) { @@ -196,4 +198,45 @@ export class AdminController { return { success: true, organization: org }; } + + // ── Ideation ── + + @Get('ideas') + async listAllIdeas(@Req() req: any) { + await this.requireSuperadmin(req); + return this.ideasService.findAll(); + } + + @Put('ideas/:id/status') + async updateIdeaStatus( + @Req() req: any, + @Param('id') id: string, + @Body() body: { status: string }, + ) { + await this.requireSuperadmin(req); + const idea = await this.ideasService.updateStatus(id, body.status); + return { success: true, idea }; + } + + @Put('ideas/:id/note') + async updateIdeaNote( + @Req() req: any, + @Param('id') id: string, + @Body() body: { adminNote: string }, + ) { + await this.requireSuperadmin(req); + const idea = await this.ideasService.updateNote(id, body.adminNote); + return { success: true, idea }; + } + + @Put('organizations/:id/settings') + async updateOrgSettings( + @Req() req: any, + @Param('id') id: string, + @Body() body: Record, + ) { + await this.requireSuperadmin(req); + const org = await this.orgService.updateSettings(id, body); + return { success: true, organization: org }; + } } diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 66bb361..188ebd9 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -17,11 +17,13 @@ import { JwtStrategy } from './strategies/jwt.strategy'; import { LocalStrategy } from './strategies/local.strategy'; import { UsersModule } from '../users/users.module'; import { OrganizationsModule } from '../organizations/organizations.module'; +import { IdeasModule } from '../ideas/ideas.module'; @Module({ imports: [ UsersModule, OrganizationsModule, + IdeasModule, PassportModule, JwtModule.registerAsync({ imports: [ConfigModule], diff --git a/backend/src/modules/board-planning/board-planning-projection.service.ts b/backend/src/modules/board-planning/board-planning-projection.service.ts index ebd63ac..3a8172b 100644 --- a/backend/src/modules/board-planning/board-planning-projection.service.ts +++ b/backend/src/modules/board-planning/board-planning-projection.service.ts @@ -25,12 +25,15 @@ export class BoardPlanningProjectionService { return this.computeProjection(scenarioId); } - /** Compute full projection for a scenario. */ + /** Compute full projection for a scenario. Also auto-creates renewal records for auto_renew investments. */ async computeProjection(scenarioId: string) { const scenarioRows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [scenarioId]); if (!scenarioRows.length) throw new NotFoundException('Scenario not found'); const scenario = scenarioRows[0]; + // Auto-create renewal investment records for auto_renew investments that have maturity dates + await this.ensureRenewalRecords(scenarioId); + const investments = await this.tenant.query( 'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY purchase_date', [scenarioId], ); @@ -152,6 +155,53 @@ export class BoardPlanningProjectionService { // ── Private Helpers ── + /** + * For each auto_renew investment with a maturity_date, ensure a corresponding + * renewal investment record exists (starting at maturity_date, same term). + * The renewal record has auto_renew=false so it won't create infinite chains. + */ + private async ensureRenewalRecords(scenarioId: string) { + const autoRenewInvestments = await this.tenant.query( + `SELECT * FROM scenario_investments + WHERE scenario_id = $1 AND auto_renew = true AND maturity_date IS NOT NULL AND executed_investment_id IS NULL`, + [scenarioId], + ); + + for (const inv of autoRenewInvestments) { + // Check if a renewal record already exists (linked by notes convention or same label pattern) + const renewalLabel = `${inv.label} (Renewal)`; + const existing = await this.tenant.query( + `SELECT id FROM scenario_investments WHERE scenario_id = $1 AND label = $2 AND purchase_date = $3`, + [scenarioId, renewalLabel, inv.maturity_date], + ); + + if (existing.length > 0) continue; // Already created + + // Compute new maturity date from original term + let newMaturityDate: string | null = null; + const termMonths = parseInt(inv.term_months) || 0; + if (termMonths > 0 && inv.maturity_date) { + const d = new Date(inv.maturity_date); + d.setMonth(d.getMonth() + termMonths); + newMaturityDate = d.toISOString().split('T')[0]; + } + + await this.tenant.query( + `INSERT INTO scenario_investments + (scenario_id, label, investment_type, fund_type, principal, interest_rate, + term_months, institution, purchase_date, maturity_date, auto_renew, notes, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, false, $11, $12)`, + [ + scenarioId, renewalLabel, inv.investment_type, inv.fund_type, + inv.principal, inv.interest_rate, inv.term_months || null, + inv.institution, inv.maturity_date, newMaturityDate, + `Auto-created renewal of "${inv.label}". Modify as needed.`, + (parseInt(inv.sort_order) || 0) + 1, + ], + ); + } + } + private async getBaselineState(startYear: number, months: number) { // Current balances from asset accounts const opCashRows = await this.tenant.query(` @@ -403,11 +453,9 @@ export class BoardPlanningProjectionService { if (isOp) { opCashFlow += maturityTotal; opInvChange -= principal; } else { resCashFlow += maturityTotal; resInvChange -= principal; } - // Auto-renew: immediately reinvest - if (inv.auto_renew) { - if (isOp) { opCashFlow -= principal; opInvChange += principal; } - else { resCashFlow -= principal; resInvChange += principal; } - } + // Note: auto_renew investments now create separate renewal records + // (via ensureRenewalRecords), so the renewal purchase is handled by + // that record's purchase_date logic above — no inline reinvest needed. } } } diff --git a/backend/src/modules/health-scores/health-scores.service.ts b/backend/src/modules/health-scores/health-scores.service.ts index 6d0e3c9..78049cf 100644 --- a/backend/src/modules/health-scores/health-scores.service.ts +++ b/backend/src/modules/health-scores/health-scores.service.ts @@ -625,14 +625,16 @@ export class HealthScoresService { .filter((b: any) => b.account_type === 'expense') .reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0); - // Components needing replacement within 5 years — use whichever source has data - const urgentComponents = useComponentsTable - ? reserveComponents.filter( - (c: any) => c.remaining_life_years !== null && parseFloat(c.remaining_life_years) <= 5, - ) - : reserveProjects.filter( - (p: any) => p.remaining_life_years !== null && parseFloat(p.remaining_life_years) <= 5, - ); + // Projects due within 5 years — based on planned date (target_year/target_month), + // NOT remaining_life_years. The planned date is the board's decision on when to act; + // remaining life is documentation-only reference info. + const now = new Date(); + const fiveYearsFromNow = new Date(now.getFullYear() + 5, now.getMonth(), 1); + const urgentProjects = reserveProjects.filter((p: any) => { + if (!p.target_year) return false; + const targetDate = new Date(parseInt(p.target_year), (parseInt(p.target_month) || 6) - 1, 1); + return targetDate <= fiveYearsFromNow; + }); // ── Build 12-month forward reserve cash flow projection ── @@ -773,7 +775,7 @@ export class HealthScoresService { totalProjectCost, annualReserveContribution, annualReserveExpenses, - urgentComponents, + urgentProjects, monthlySpecialAssessmentIncome, year, forecast, @@ -940,12 +942,13 @@ SCORING GUIDELINES: KEY FACTORS TO EVALUATE: 1. Percent funded (total reserve assets vs total replacement costs) -2. Annual contribution adequacy (is annual contribution enough to keep pace with aging components?) -3. Component urgency (components due within 5 years and their funding status) -4. Capital project readiness (are planned projects adequately funded?) +2. Annual contribution adequacy (is annual contribution enough to keep pace with planned projects?) +3. Project urgency — based ONLY on the "Planned Date" field. The Planned Date is the board's decision on when a project will be executed. Do NOT use "Useful Life" or "Remaining Life" to determine urgency — those are reference information only. A project is only urgent if its Planned Date falls within the next 1-3 years. +4. Capital project readiness (are planned projects adequately funded by their planned dates?) 5. Investment strategy (are reserves earning returns through CDs, money markets, etc.?) -6. Diversity of reserve components (is the full building covered?) +6. Diversity of reserve components (is the full scope of community infrastructure tracked?) 7. CRITICAL — Projected cash flow: Use the 12-MONTH RESERVE CASH FLOW FORECAST to assess future liquidity. The forecast shows month-by-month projected income (from special assessments collected from homeowners AND budgeted reserve income), expenses, capital project costs, and investment maturities returning cash. Check whether the reserve fund will have sufficient liquidity when capital projects are due. If special assessment income arrives before project costs, the position may be adequate even if current cash seems low. +8. IMPORTANT — Projects with no Planned Date or with "Not scheduled" should be noted but NOT treated as urgent or imminent. Only assess urgency for projects with actual planned dates. RESPONSE FORMAT: Respond with ONLY valid JSON (no markdown, no code fences): @@ -974,7 +977,8 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar `- ${i.name} | ${i.investment_type} @ ${i.institution} | $${parseFloat(i.current_value || i.principal || '0').toFixed(2)} | Rate: ${parseFloat(i.interest_rate || '0').toFixed(2)}% | Maturity: ${i.maturity_date ? new Date(i.maturity_date).toLocaleDateString() : 'N/A'}`, ).join('\n'); - // Build component lines from reserve_components if available, otherwise from reserve-funded projects + // Build component lines from reserve_components if available, otherwise from reserve-funded projects. + // Use planned date (target_year/target_month) as the authoritative timeline, not remaining_life_years. const componentSource = data.reserveComponents.length > 0 ? data.reserveComponents : data.reserveProjects; const componentLines = componentSource.length === 0 ? 'No reserve components or reserve projects tracked.' @@ -982,7 +986,8 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0'); const funded = parseFloat(c.current_fund_balance || '0'); const pct = cost > 0 ? ((funded / cost) * 100).toFixed(0) : '0'; - return `- ${c.name} [${c.category || 'N/A'}] | Life: ${c.useful_life_years || '?'}yr, Remaining: ${c.remaining_life_years || '?'}yr | Cost: $${cost.toFixed(0)} | Funded: $${funded.toFixed(0)} (${pct}%) | Condition: ${c.condition_rating || '?'}/10 | Annual Contribution: $${parseFloat(c.annual_contribution || '0').toFixed(0)}`; + const plannedDate = c.target_year ? `${c.target_year}/${c.target_month || '?'}` : 'Not scheduled'; + return `- ${c.name} [${c.category || 'N/A'}] | Planned Date: ${plannedDate} | Useful Life: ${c.useful_life_years || '?'}yr (reference only) | Cost: $${cost.toFixed(0)} | Funded: $${funded.toFixed(0)} (${pct}%) | Condition: ${c.condition_rating || '?'}/10 | Annual Contribution: $${parseFloat(c.annual_contribution || '0').toFixed(0)}`; }).join('\n'); const projectLines = data.projects.length === 0 @@ -995,13 +1000,14 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar .map((b: any) => `- ${b.name} (${b.account_number}) [${b.account_type}]: $${parseFloat(b.annual_total || '0').toFixed(2)}/yr`) .join('\n') || 'No reserve budget line items.'; - const urgentLines = data.urgentComponents.length === 0 - ? 'None — no components due within 5 years.' - : data.urgentComponents.map((c: any) => { - const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0'); - const funded = parseFloat(c.current_fund_balance || '0'); + const urgentLines = data.urgentProjects.length === 0 + ? 'None — no reserve projects planned within 5 years.' + : data.urgentProjects.map((p: any) => { + const cost = parseFloat(p.estimated_cost || '0'); + const funded = parseFloat(p.current_fund_balance || '0'); const gap = cost - funded; - return `- ${c.name}: ${c.remaining_life_years} years remaining, $${gap.toFixed(0)} funding gap`; + const targetDate = `${p.target_year}/${p.target_month || '?'}`; + return `- ${p.name}: planned for ${targetDate}, Cost: $${cost.toFixed(0)}, $${gap.toFixed(0)} funding gap`; }).join('\n'); const userPrompt = `Evaluate this HOA's reserve fund health. @@ -1027,10 +1033,10 @@ ${accountLines} === RESERVE INVESTMENTS === ${investmentLines} -=== RESERVE COMPONENTS (ordered by urgency) === +=== RESERVE COMPONENTS (ordered by planned date) === ${componentLines} -=== COMPONENTS DUE WITHIN 5 YEARS (URGENT) === +=== PROJECTS PLANNED WITHIN 5 YEARS (by planned date) === ${urgentLines} === CAPITAL PROJECTS === diff --git a/backend/src/modules/ideas/dto/create-idea.dto.ts b/backend/src/modules/ideas/dto/create-idea.dto.ts new file mode 100644 index 0000000..ab7a255 --- /dev/null +++ b/backend/src/modules/ideas/dto/create-idea.dto.ts @@ -0,0 +1,12 @@ +import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator'; + +export class CreateIdeaDto { + @IsString() + @IsNotEmpty() + @MaxLength(255) + title: string; + + @IsString() + @IsOptional() + description?: string; +} diff --git a/backend/src/modules/ideas/entities/idea.entity.ts b/backend/src/modules/ideas/entities/idea.entity.ts new file mode 100644 index 0000000..e3229b2 --- /dev/null +++ b/backend/src/modules/ideas/entities/idea.entity.ts @@ -0,0 +1,49 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Organization } from '../../organizations/entities/organization.entity'; +import { User } from '../../users/entities/user.entity'; + +@Entity({ schema: 'shared', name: 'ideas' }) +export class Idea { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'org_id' }) + orgId: string; + + @Column({ name: 'user_id' }) + userId: string; + + @Column({ length: 255 }) + title: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ length: 20, default: 'new' }) + status: string; + + @Column({ name: 'admin_note', type: 'text', nullable: true }) + adminNote: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => Organization) + @JoinColumn({ name: 'org_id' }) + organization: Organization; + + @ManyToOne(() => User) + @JoinColumn({ name: 'user_id' }) + user: User; +} diff --git a/backend/src/modules/ideas/ideas.controller.ts b/backend/src/modules/ideas/ideas.controller.ts new file mode 100644 index 0000000..b4a395b --- /dev/null +++ b/backend/src/modules/ideas/ideas.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Post, Body, Req, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { IdeasService } from './ideas.service'; +import { CreateIdeaDto } from './dto/create-idea.dto'; + +@ApiTags('ideas') +@Controller('ideas') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +export class IdeasController { + constructor(private ideasService: IdeasService) {} + + @Post() + async create(@Req() req: any, @Body() dto: CreateIdeaDto) { + const orgId = req.user.orgId; + const userId = req.user.userId || req.user.sub; + const idea = await this.ideasService.create(orgId, userId, dto); + return { success: true, idea }; + } + + @Get() + async findByOrg(@Req() req: any) { + const orgId = req.user.orgId; + return this.ideasService.findByOrg(orgId); + } +} diff --git a/backend/src/modules/ideas/ideas.module.ts b/backend/src/modules/ideas/ideas.module.ts new file mode 100644 index 0000000..c3a7fcc --- /dev/null +++ b/backend/src/modules/ideas/ideas.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Idea } from './entities/idea.entity'; +import { Organization } from '../organizations/entities/organization.entity'; +import { IdeasController } from './ideas.controller'; +import { IdeasService } from './ideas.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Idea, Organization])], + controllers: [IdeasController], + providers: [IdeasService], + exports: [IdeasService], +}) +export class IdeasModule {} diff --git a/backend/src/modules/ideas/ideas.service.ts b/backend/src/modules/ideas/ideas.service.ts new file mode 100644 index 0000000..e780b1b --- /dev/null +++ b/backend/src/modules/ideas/ideas.service.ts @@ -0,0 +1,89 @@ +import { Injectable, ForbiddenException, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Idea } from './entities/idea.entity'; +import { Organization } from '../organizations/entities/organization.entity'; +import { CreateIdeaDto } from './dto/create-idea.dto'; + +@Injectable() +export class IdeasService { + constructor( + @InjectRepository(Idea) + private ideasRepository: Repository, + @InjectRepository(Organization) + private orgRepository: Repository, + ) {} + + async create(orgId: string, userId: string, dto: CreateIdeaDto): Promise { + const org = await this.orgRepository.findOne({ where: { id: orgId } }); + if (!org) { + throw new NotFoundException('Organization not found'); + } + if (org.settings?.ideationEnabled !== true) { + throw new ForbiddenException('Ideation is not enabled for this organization'); + } + + const idea = this.ideasRepository.create({ + orgId, + userId, + title: dto.title, + description: dto.description, + }); + return this.ideasRepository.save(idea); + } + + async findByOrg(orgId: string): Promise { + return this.ideasRepository.find({ + where: { orgId }, + order: { createdAt: 'DESC' }, + }); + } + + async findAll(): Promise { + return this.ideasRepository + .createQueryBuilder('idea') + .leftJoin('idea.organization', 'org') + .leftJoin('idea.user', 'user') + .select([ + 'idea.id AS id', + 'idea.title AS title', + 'idea.description AS description', + 'idea.status AS status', + 'idea.createdAt AS "createdAt"', + 'idea.adminNote AS "adminNote"', + 'org.id AS "orgId"', + 'org.name AS "orgName"', + 'user.id AS "userId"', + 'user.email AS "userEmail"', + 'user.firstName AS "userFirstName"', + 'user.lastName AS "userLastName"', + ]) + .orderBy('idea.createdAt', 'DESC') + .getRawMany(); + } + + async updateStatus(id: string, status: string): Promise { + const validStatuses = ['new', 'reviewed', 'accepted', 'rejected']; + if (!validStatuses.includes(status)) { + throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`); + } + + const idea = await this.ideasRepository.findOne({ where: { id } }); + if (!idea) { + throw new NotFoundException('Idea not found'); + } + + idea.status = status; + return this.ideasRepository.save(idea); + } + + async updateNote(id: string, adminNote: string): Promise { + const idea = await this.ideasRepository.findOne({ where: { id } }); + if (!idea) { + throw new NotFoundException('Idea not found'); + } + + idea.adminNote = adminNote; + return this.ideasRepository.save(idea); + } +} diff --git a/backend/src/modules/reports/reports.controller.ts b/backend/src/modules/reports/reports.controller.ts index 8f9e270..9fc2294 100644 --- a/backend/src/modules/reports/reports.controller.ts +++ b/backend/src/modules/reports/reports.controller.ts @@ -65,6 +65,11 @@ export class ReportsController { return this.reportsService.getDashboardKPIs(); } + @Get('upcoming-investment-activities') + getUpcomingInvestmentActivities() { + return this.reportsService.getUpcomingInvestmentActivities(); + } + @Get('cash-flow-forecast') getCashFlowForecast( @Query('startYear') startYear?: string, @@ -75,6 +80,13 @@ export class ReportsController { return this.reportsService.getCashFlowForecast(yr, mo); } + @Get('capital-planning') + getCapitalPlanningReport(@Query('startYear') startYear?: string) { + return this.reportsService.getCapitalPlanningReport( + parseInt(startYear || '') || undefined, + ); + } + @Get('quarterly') getQuarterlyFinancial( @Query('year') year?: string, diff --git a/backend/src/modules/reports/reports.service.ts b/backend/src/modules/reports/reports.service.ts index 8df2e58..a1e9554 100644 --- a/backend/src/modules/reports/reports.service.ts +++ b/backend/src/modules/reports/reports.service.ts @@ -780,6 +780,78 @@ export class ReportsService { }; } + async getUpcomingInvestmentActivities() { + const now = new Date(); + const in45Days = new Date(now); + in45Days.setDate(in45Days.getDate() + 45); + const in60Days = new Date(now); + in60Days.setDate(in60Days.getDate() + 60); + + // 1. Investments maturing within 45 days + const maturingInvestments = await this.tenant.query(` + SELECT id, name, institution, investment_type, fund_type, current_value, principal, + interest_rate, maturity_date, purchase_date + FROM investment_accounts + WHERE is_active = true + AND maturity_date IS NOT NULL + AND maturity_date BETWEEN CURRENT_DATE AND $1::date + ORDER BY maturity_date ASC + `, [in45Days.toISOString().split('T')[0]]); + + // Compute interest earned and days remaining for each + const maturing = maturingInvestments.map((inv: any) => { + const principal = parseFloat(inv.principal) || parseFloat(inv.current_value) || 0; + const rate = parseFloat(inv.interest_rate) || 0; + const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : now; + const maturityDate = new Date(inv.maturity_date); + const daysHeld = Math.max((maturityDate.getTime() - purchaseDate.getTime()) / 86400000, 1); + const interestEarned = principal * (rate / 100) * (daysHeld / 365); + const daysRemaining = Math.max(Math.ceil((maturityDate.getTime() - now.getTime()) / 86400000), 0); + return { + ...inv, + interest_earned: interestEarned.toFixed(2), + maturity_value: (principal + interestEarned).toFixed(2), + days_remaining: daysRemaining, + activity_type: 'maturity', + }; + }); + + // 2. Approved scenario investments due to execute within 60 days + let scenarioItems: any[] = []; + try { + scenarioItems = await this.tenant.query(` + SELECT si.id, si.label, si.investment_type, si.fund_type, si.principal, + si.interest_rate, si.purchase_date, si.maturity_date, si.institution, + bs.name as scenario_name, bs.status as scenario_status + FROM scenario_investments si + JOIN board_scenarios bs ON bs.id = si.scenario_id + WHERE bs.status = 'approved' + AND si.executed_investment_id IS NULL + AND si.purchase_date IS NOT NULL + AND si.purchase_date BETWEEN CURRENT_DATE AND $1::date + ORDER BY si.purchase_date ASC + `, [in60Days.toISOString().split('T')[0]]); + } catch { + // scenario tables may not exist + } + + const upcoming = scenarioItems.map((si: any) => { + const purchaseDate = new Date(si.purchase_date); + const daysUntil = Math.max(Math.ceil((purchaseDate.getTime() - now.getTime()) / 86400000), 0); + return { + ...si, + days_until: daysUntil, + activity_type: 'planned_purchase', + }; + }); + + return { + maturing_investments: maturing, + upcoming_scenario_investments: upcoming, + total_activities: maturing.length + upcoming.length, + }; + } + /** * Cash Flow Forecast: monthly datapoints with actuals (historical) and projections (future). * Each month has: operating_cash, operating_investments, reserve_cash, reserve_investments. @@ -1264,4 +1336,120 @@ export class ReportsService { over_budget_items: overBudgetItems, }; } + + async getCapitalPlanningReport(startYear?: number) { + const baseYear = startYear || new Date().getFullYear(); + const years = [baseYear, baseYear + 1, baseYear + 2, baseYear + 3, baseYear + 4]; + + // Get all active projects + const projects = await this.tenant.query( + `SELECT id, name, description, category, estimated_cost, target_year, target_month, + useful_life_years, last_replacement_date, next_replacement_date, fund_source, + status, priority, condition_rating + FROM projects + WHERE is_active = true + ORDER BY category NULLS LAST, priority, name`, + ); + + // Also try capital_projects table + let capitalProjects: any[] = []; + try { + capitalProjects = await this.tenant.query( + `SELECT id, name, description, estimated_cost, target_year, target_month, + fund_source, status, priority, notes + FROM capital_projects + WHERE status NOT IN ('cancelled') + ORDER BY priority, name`, + ); + } catch { + // Table may not exist + } + + // Merge and group by category + const allProjects = [ + ...projects.map((p: any) => ({ + id: p.id, + name: p.name, + description: p.description, + category: p.category || 'Uncategorized', + estimated_cost: parseFloat(p.estimated_cost) || 0, + target_year: parseInt(p.target_year) || null, + useful_life_years: parseInt(p.useful_life_years) || null, + last_replacement_date: p.last_replacement_date, + fund_source: p.fund_source || 'reserve', + status: p.status, + priority: parseInt(p.priority) || 3, + condition_rating: parseInt(p.condition_rating) || null, + })), + ...capitalProjects + .filter((cp: any) => !projects.some((p: any) => p.name === cp.name && p.target_year === cp.target_year)) + .map((cp: any) => ({ + id: cp.id, + name: cp.name, + description: cp.description, + category: 'Capital Projects', + estimated_cost: parseFloat(cp.estimated_cost) || 0, + target_year: parseInt(cp.target_year) || null, + useful_life_years: null, + last_replacement_date: null, + fund_source: cp.fund_source || 'reserve', + status: cp.status, + priority: parseInt(cp.priority) || 3, + condition_rating: null, + })), + ]; + + // Group by category + const categories: Record = {}; + for (const project of allProjects) { + const cat = project.category; + if (!categories[cat]) categories[cat] = []; + categories[cat].push(project); + } + + // Build year columns for each project + const categoryData = Object.entries(categories).map(([category, items]) => ({ + category, + projects: items.map((p) => { + const yearAmounts: Record = {}; + let beyond = 0; + if (p.target_year) { + if (p.target_year >= years[0] && p.target_year <= years[4]) { + yearAmounts[p.target_year] = p.estimated_cost; + } else if (p.target_year > years[4]) { + beyond = p.estimated_cost; + } + } + return { + ...p, + year_amounts: yearAmounts, + beyond, + }; + }), + })); + + // Compute totals per year + const yearTotals: Record = {}; + let beyondTotal = 0; + for (const y of years) yearTotals[y] = 0; + for (const cat of categoryData) { + for (const p of cat.projects) { + for (const y of years) { + yearTotals[y] += p.year_amounts[y] || 0; + } + beyondTotal += p.beyond; + } + } + + return { + title: `${years[4] - years[0] + 1}-YEAR CAPITAL PROJECT FORECAST`, + start_year: years[0], + years, + categories: categoryData, + year_totals: yearTotals, + beyond_total: beyondTotal, + grand_total: Object.values(yearTotals).reduce((a, b) => a + b, 0) + beyondTotal, + generated_at: new Date().toISOString(), + }; + } } diff --git a/db/migrations/018-ideas.sql b/db/migrations/018-ideas.sql new file mode 100644 index 0000000..9d1f9d6 --- /dev/null +++ b/db/migrations/018-ideas.sql @@ -0,0 +1,15 @@ +-- Ideation feature: shared ideas table for cross-tenant idea submissions +CREATE TABLE IF NOT EXISTS shared.ideas ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + org_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'new', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ideas_org_id ON shared.ideas(org_id); +CREATE INDEX IF NOT EXISTS idx_ideas_status ON shared.ideas(status); +CREATE INDEX IF NOT EXISTS idx_ideas_created_at ON shared.ideas(created_at DESC); diff --git a/db/migrations/019-ideas-admin-note.sql b/db/migrations/019-ideas-admin-note.sql new file mode 100644 index 0000000..189a04a --- /dev/null +++ b/db/migrations/019-ideas-admin-note.sql @@ -0,0 +1,2 @@ +-- Add private admin note column to ideas table +ALTER TABLE shared.ideas ADD COLUMN IF NOT EXISTS admin_note TEXT; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5a53fd5..dfcd69e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hoa-ledgeriq-frontend", - "version": "2026.3.17", + "version": "2026.3.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hoa-ledgeriq-frontend", - "version": "2026.3.17", + "version": "2026.3.19", "dependencies": { "@mantine/core": "^7.15.3", "@mantine/dates": "^7.15.3", diff --git a/frontend/package.json b/frontend/package.json index 1fb3b5f..3e9e175 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hoa-ledgeriq-frontend", - "version": "2026.3.19", + "version": "2026.3.24", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dc7ae07..bcc6117 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,10 +24,12 @@ import { CashFlowPage } from './pages/reports/CashFlowPage'; import { AgingReportPage } from './pages/reports/AgingReportPage'; import { YearEndPage } from './pages/reports/YearEndPage'; import { QuarterlyReportPage } from './pages/reports/QuarterlyReportPage'; +import { CapitalPlanningPage } from './pages/reports/CapitalPlanningPage'; import { SettingsPage } from './pages/settings/SettingsPage'; import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage'; import { OrgMembersPage } from './pages/org-members/OrgMembersPage'; import { AdminPage } from './pages/admin/AdminPage'; +import { AdminIdeasPage } from './pages/admin/AdminIdeasPage'; import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage'; import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage'; import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage'; @@ -132,6 +134,7 @@ export function App() { } > } /> + } /> {/* Main app routes (require auth + org) */} @@ -167,6 +170,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/ideas/IdeaModal.tsx b/frontend/src/components/ideas/IdeaModal.tsx new file mode 100644 index 0000000..ff74887 --- /dev/null +++ b/frontend/src/components/ideas/IdeaModal.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react'; +import { Modal, TextInput, Textarea, Button, Stack } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { useMutation } from '@tanstack/react-query'; +import api from '../../services/api'; + +interface IdeaModalProps { + opened: boolean; + onClose: () => void; +} + +export function IdeaModal({ opened, onClose }: IdeaModalProps) { + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + + const submitIdea = useMutation({ + mutationFn: async () => { + const { data } = await api.post('/ideas', { title, description }); + return data; + }, + onSuccess: () => { + notifications.show({ message: 'Idea submitted — thank you!', color: 'green' }); + setTitle(''); + setDescription(''); + onClose(); + }, + onError: (err: any) => { + notifications.show({ + message: err.response?.data?.message || 'Failed to submit idea', + color: 'red', + }); + }, + }); + + const handleClose = () => { + setTitle(''); + setDescription(''); + onClose(); + }; + + return ( + + + setTitle(e.currentTarget.value)} + maxLength={255} + /> +