Monthly Actuals — Option A: - Replace operating cash account offset with per-fund equity account clearing - Equity accounts 3000/3100 now absorb the net P&L from actuals entries - Cash account is never touched by monthly actuals, eliminating the balance discrepancy that required manual cash adjustments - Per-fund routing: operating income/expense clears to 3000, reserve to 3100 - Falls back gracefully if only one equity account exists Scenario Activation (Issue 4): - updateScenario now accepts userId and triggers materialisation when status transitions to 'active' - Each pending scenario investment is created as a real investment_accounts record dated to its purchase_date (future dates are supported) - Journal entries are posted at the purchase_date using the fund's primary cash account and equity offset (matching manual account creation) - Rollover detection: if an existing active investment matures within 7 days of the new investment's purchase_date and shares the same fund_type, the system creates a maturity JE (proceeds → cash) and a reinvestment JE (cash → new CD) rather than a fresh cash deduction, then retires the source investment - Per-investment failures are logged but do not abort the rest of the batch Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
230 lines
7.4 KiB
TypeScript
230 lines
7.4 KiB
TypeScript
import { Controller, Get, Post, Put, Delete, Body, Param, Query, Req, Res, UseGuards } from '@nestjs/common';
|
|
import { Response } from 'express';
|
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
|
import { BoardPlanningService } from './board-planning.service';
|
|
import { BoardPlanningProjectionService } from './board-planning-projection.service';
|
|
import { BudgetPlanningService } from './budget-planning.service';
|
|
|
|
@ApiTags('board-planning')
|
|
@Controller('board-planning')
|
|
@ApiBearerAuth()
|
|
@UseGuards(JwtAuthGuard)
|
|
export class BoardPlanningController {
|
|
constructor(
|
|
private service: BoardPlanningService,
|
|
private projection: BoardPlanningProjectionService,
|
|
private budgetPlanning: BudgetPlanningService,
|
|
) {}
|
|
|
|
// ── Scenarios ──
|
|
|
|
@Get('scenarios')
|
|
@AllowViewer()
|
|
@RequireCapability('planning.scenarios.view')
|
|
listScenarios(@Query('type') type?: string) {
|
|
return this.service.listScenarios(type);
|
|
}
|
|
|
|
@Get('scenarios/:id')
|
|
@AllowViewer()
|
|
@RequireCapability('planning.scenarios.view')
|
|
getScenario(@Param('id') id: string) {
|
|
return this.service.getScenario(id);
|
|
}
|
|
|
|
@Post('scenarios')
|
|
@RequireCapability('planning.scenarios.edit')
|
|
createScenario(@Body() dto: any, @Req() req: any) {
|
|
return this.service.createScenario(dto, req.user.sub);
|
|
}
|
|
|
|
@Put('scenarios/:id')
|
|
@RequireCapability('planning.scenarios.edit')
|
|
updateScenario(@Param('id') id: string, @Body() dto: any, @Req() req: any) {
|
|
return this.service.updateScenario(id, dto, req.user.sub);
|
|
}
|
|
|
|
@Delete('scenarios/:id')
|
|
@RequireCapability('planning.scenarios.edit')
|
|
deleteScenario(@Param('id') id: string) {
|
|
return this.service.deleteScenario(id);
|
|
}
|
|
|
|
// ── Scenario Investments ──
|
|
|
|
@Get('scenarios/:scenarioId/investments')
|
|
@AllowViewer()
|
|
@RequireCapability('planning.scenarios.view')
|
|
listInvestments(@Param('scenarioId') scenarioId: string) {
|
|
return this.service.listInvestments(scenarioId);
|
|
}
|
|
|
|
@Post('scenarios/:scenarioId/investments')
|
|
@RequireCapability('planning.scenarios.edit')
|
|
addInvestment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
|
return this.service.addInvestment(scenarioId, dto);
|
|
}
|
|
|
|
@Post('scenarios/:scenarioId/investments/from-recommendation')
|
|
@RequireCapability('planning.scenarios.edit')
|
|
addFromRecommendation(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
|
return this.service.addInvestmentFromRecommendation(scenarioId, dto);
|
|
}
|
|
|
|
@Put('investments/:id')
|
|
@RequireCapability('planning.scenarios.edit')
|
|
updateInvestment(@Param('id') id: string, @Body() dto: any) {
|
|
return this.service.updateInvestment(id, dto);
|
|
}
|
|
|
|
@Delete('investments/:id')
|
|
@RequireCapability('planning.scenarios.edit')
|
|
removeInvestment(@Param('id') id: string) {
|
|
return this.service.removeInvestment(id);
|
|
}
|
|
|
|
// ── Scenario Assessments ──
|
|
|
|
@Get('scenarios/:scenarioId/assessments')
|
|
@AllowViewer()
|
|
@RequireCapability('planning.scenarios.view')
|
|
listAssessments(@Param('scenarioId') scenarioId: string) {
|
|
return this.service.listAssessments(scenarioId);
|
|
}
|
|
|
|
@Post('scenarios/:scenarioId/assessments')
|
|
@RequireCapability('planning.scenarios.edit')
|
|
addAssessment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
|
return this.service.addAssessment(scenarioId, dto);
|
|
}
|
|
|
|
@Put('assessments/:id')
|
|
@RequireCapability('planning.scenarios.edit')
|
|
updateAssessment(@Param('id') id: string, @Body() dto: any) {
|
|
return this.service.updateAssessment(id, dto);
|
|
}
|
|
|
|
@Delete('assessments/:id')
|
|
@RequireCapability('planning.scenarios.edit')
|
|
removeAssessment(@Param('id') id: string) {
|
|
return this.service.removeAssessment(id);
|
|
}
|
|
|
|
// ── Projections ──
|
|
|
|
@Get('scenarios/:id/projection')
|
|
@AllowViewer()
|
|
@RequireCapability('planning.scenarios.view')
|
|
getProjection(@Param('id') id: string) {
|
|
return this.projection.getProjection(id);
|
|
}
|
|
|
|
@Post('scenarios/:id/projection/refresh')
|
|
@RequireCapability('planning.scenarios.edit')
|
|
refreshProjection(@Param('id') id: string) {
|
|
return this.projection.computeProjection(id);
|
|
}
|
|
|
|
// ── Comparison ──
|
|
|
|
@Get('compare')
|
|
@AllowViewer()
|
|
@RequireCapability('planning.scenarios.view')
|
|
compareScenarios(@Query('ids') ids: string) {
|
|
const scenarioIds = ids.split(',').map((s) => s.trim()).filter(Boolean);
|
|
return this.projection.compareScenarios(scenarioIds);
|
|
}
|
|
|
|
// ── Execute Investment ──
|
|
|
|
@Post('investments/:id/execute')
|
|
@RequireCapability('planning.scenarios.edit')
|
|
executeInvestment(
|
|
@Param('id') id: string,
|
|
@Body() dto: { executionDate: string },
|
|
@Req() req: any,
|
|
) {
|
|
return this.service.executeInvestment(id, dto.executionDate, req.user.sub);
|
|
}
|
|
|
|
// ── Budget Planning ──
|
|
|
|
@Get('budget-plans')
|
|
@AllowViewer()
|
|
@RequireCapability('planning.scenarios.view')
|
|
listBudgetPlans() {
|
|
return this.budgetPlanning.listPlans();
|
|
}
|
|
|
|
@Get('budget-plans/available-years')
|
|
@AllowViewer()
|
|
@RequireCapability('planning.scenarios.view')
|
|
getAvailableYears() {
|
|
return this.budgetPlanning.getAvailableYears();
|
|
}
|
|
|
|
@Get('budget-plans/:year')
|
|
@AllowViewer()
|
|
@RequireCapability('planning.scenarios.view')
|
|
getBudgetPlan(@Param('year') year: string) {
|
|
return this.budgetPlanning.getPlan(parseInt(year, 10));
|
|
}
|
|
|
|
@Post('budget-plans')
|
|
@RequireCapability('planning.scenarios.edit')
|
|
createBudgetPlan(@Body() dto: { fiscalYear: number; baseYear: number; inflationRate?: number }, @Req() req: any) {
|
|
return this.budgetPlanning.createPlan(dto.fiscalYear, dto.baseYear, dto.inflationRate ?? 2.5, req.user.sub);
|
|
}
|
|
|
|
@Put('budget-plans/:year/lines')
|
|
@RequireCapability('planning.scenarios.edit')
|
|
updateBudgetPlanLines(@Param('year') year: string, @Body() dto: { planId: string; lines: any[] }) {
|
|
return this.budgetPlanning.updateLines(dto.planId, dto.lines);
|
|
}
|
|
|
|
@Put('budget-plans/:year/inflation')
|
|
@RequireCapability('planning.scenarios.edit')
|
|
updateBudgetPlanInflation(@Param('year') year: string, @Body() dto: { inflationRate: number }) {
|
|
return this.budgetPlanning.updateInflation(parseInt(year, 10), dto.inflationRate);
|
|
}
|
|
|
|
@Put('budget-plans/:year/status')
|
|
@RequireCapability('planning.scenarios.edit')
|
|
advanceBudgetPlanStatus(@Param('year') year: string, @Body() dto: { status: string }, @Req() req: any) {
|
|
return this.budgetPlanning.advanceStatus(parseInt(year, 10), dto.status, req.user.sub);
|
|
}
|
|
|
|
@Post('budget-plans/:year/import')
|
|
@RequireCapability('planning.scenarios.edit')
|
|
importBudgetPlanLines(
|
|
@Param('year') year: string,
|
|
@Body() lines: any[],
|
|
@Req() req: any,
|
|
) {
|
|
return this.budgetPlanning.importLines(parseInt(year, 10), lines, req.user.sub);
|
|
}
|
|
|
|
@Get('budget-plans/:year/template')
|
|
@RequireCapability('planning.scenarios.view')
|
|
async getBudgetPlanTemplate(
|
|
@Param('year') year: string,
|
|
@Res() res: Response,
|
|
) {
|
|
const csv = await this.budgetPlanning.getTemplate(parseInt(year, 10));
|
|
res.set({
|
|
'Content-Type': 'text/csv',
|
|
'Content-Disposition': `attachment; filename="budget_template_${year}.csv"`,
|
|
});
|
|
res.send(csv);
|
|
}
|
|
|
|
@Delete('budget-plans/:year')
|
|
@RequireCapability('planning.scenarios.edit')
|
|
deleteBudgetPlan(@Param('year') year: string) {
|
|
return this.budgetPlanning.deletePlan(parseInt(year, 10));
|
|
}
|
|
}
|