From e7549f3dde17b57c9ba1fa4bef33e4f15cef3688 Mon Sep 17 00:00:00 2001 From: MOHAN Date: Thu, 16 Jul 2026 23:57:07 +0530 Subject: [PATCH] Add household future scenario planning --- .../dto/future-planning-scenario.dto.ts | 24 +++ src/households/households.controller.ts | 10 ++ src/households/households.service.ts | 137 ++++++++++++++++++ test/households.service.spec.ts | 56 +++++++ 4 files changed, 227 insertions(+) create mode 100644 src/households/dto/future-planning-scenario.dto.ts diff --git a/src/households/dto/future-planning-scenario.dto.ts b/src/households/dto/future-planning-scenario.dto.ts new file mode 100644 index 0000000..73d3593 --- /dev/null +++ b/src/households/dto/future-planning-scenario.dto.ts @@ -0,0 +1,24 @@ +export type FutureScenarioType = "goal" | "net_worth" | "income_change" | "expense_change"; + +export class FuturePlanningEventDto { + month!: number; + label!: string; + amount!: number; +} + +export class FuturePlanningScenarioDto { + name!: string; + type?: FutureScenarioType; + startingBalance!: number; + monthlyContribution!: number; + monthlyIncome?: number; + monthlyExpenses?: number; + targetAmount?: number; + horizonMonths?: number; + annualGrowthRate?: number; + events?: FuturePlanningEventDto[]; +} + +export class FuturePlanningScenariosDto { + scenarios!: FuturePlanningScenarioDto[]; +} diff --git a/src/households/households.controller.ts b/src/households/households.controller.ts index f7e97e5..00e80e3 100644 --- a/src/households/households.controller.ts +++ b/src/households/households.controller.ts @@ -7,6 +7,7 @@ import { CreateHouseholdDto } from "./dto/create-household.dto"; import { CreateHouseholdInviteDto } from "./dto/create-household-invite.dto"; import { DebtPayoffPlannerDto } from "./dto/debt-payoff-planner.dto"; import { FairSplitCalculatorDto } from "./dto/fair-split-calculator.dto"; +import { FuturePlanningScenariosDto } from "./dto/future-planning-scenario.dto"; import { UpdateHouseholdGoalDto } from "./dto/update-household-goal.dto"; import { UpdateHouseholdMemberDto } from "./dto/update-household-member.dto"; import { UpdateHouseholdPrivacyDto } from "./dto/update-household-privacy.dto"; @@ -73,6 +74,15 @@ export class HouseholdsController { return ok(await this.householdsService.calculateDebtPayoff(userId, id, payload)); } + @Post(":id/future-scenarios") + async futureScenarios( + @CurrentUser() userId: string, + @Param("id") id: string, + @Body() payload: FuturePlanningScenariosDto, + ) { + return ok(await this.householdsService.calculateFutureScenarios(userId, id, payload)); + } + @Get(":id/goals") async goals(@CurrentUser() userId: string, @Param("id") id: string) { return ok(await this.householdsService.listGoals(userId, id)); diff --git a/src/households/households.service.ts b/src/households/households.service.ts index cde1eed..9d31d9d 100644 --- a/src/households/households.service.ts +++ b/src/households/households.service.ts @@ -9,6 +9,7 @@ import { CreateHouseholdDto } from "./dto/create-household.dto"; import { CreateHouseholdInviteDto } from "./dto/create-household-invite.dto"; import { DebtPayoffPlannerDto, DebtPayoffSplitMode, DebtPayoffStrategy } from "./dto/debt-payoff-planner.dto"; import { FairSplitCalculatorDto, FairSplitMode } from "./dto/fair-split-calculator.dto"; +import { FuturePlanningScenarioDto, FuturePlanningScenariosDto, FutureScenarioType } from "./dto/future-planning-scenario.dto"; import { UpdateHouseholdGoalDto } from "./dto/update-household-goal.dto"; import { UpdateHouseholdMemberDto } from "./dto/update-household-member.dto"; import { UpdateHouseholdPrivacyDto } from "./dto/update-household-privacy.dto"; @@ -529,6 +530,24 @@ export class HouseholdsService { }; } + async calculateFutureScenarios(userId: string, householdId: string, payload: FuturePlanningScenariosDto) { + await this.requireActiveMember(userId, householdId); + const scenarios = this.normalizeFutureScenarios(payload.scenarios); + const projections = scenarios.map((scenario) => this.projectFutureScenario(scenario)); + + return { + householdId, + generatedAt: new Date().toISOString(), + scenarios: projections, + comparison: { + bestFinalBalance: projections.reduce((best, item) => item.finalBalance > best.finalBalance ? item : best, projections[0]), + earliestTarget: projections + .filter((item) => item.targetReachedMonth !== null) + .sort((a, b) => Number(a.targetReachedMonth) - Number(b.targetReachedMonth))[0] ?? null, + }, + }; + } + async listGoals(userId: string, householdId: string) { await this.requireActiveMember(userId, householdId); const goals = await this.prisma.householdGoal.findMany({ @@ -1049,6 +1068,124 @@ export class HouseholdsService { return recommendations; } + private normalizeFutureScenarios(scenarios: FuturePlanningScenariosDto["scenarios"]) { + if (!Array.isArray(scenarios) || !scenarios.length) { + throw new BadRequestException("At least one future planning scenario is required."); + } + if (scenarios.length > 6) { + throw new BadRequestException("Future planning supports up to 6 scenarios at a time."); + } + return scenarios.map((scenario, index) => this.normalizeFutureScenario(scenario, index)); + } + + private normalizeFutureScenario(scenario: FuturePlanningScenarioDto, index: number) { + const name = this.requiredTrim(scenario.name ?? `Scenario ${index + 1}`, "Scenario name"); + const type = this.normalizeFutureScenarioType(scenario.type); + const horizonMonths = Number(scenario.horizonMonths ?? 60); + if (!Number.isInteger(horizonMonths) || horizonMonths < 1 || horizonMonths > 600) { + throw new BadRequestException("Scenario horizon must be between 1 and 600 months."); + } + const annualGrowthRate = this.nonNegativeMoney(scenario.annualGrowthRate ?? 0, `${name} annual growth rate`); + if (annualGrowthRate > 100) throw new BadRequestException(`${name} annual growth rate must be 100 or lower.`); + + const events = (scenario.events ?? []).map((event) => { + const month = Number(event.month); + if (!Number.isInteger(month) || month < 1 || month > horizonMonths) { + throw new BadRequestException(`${name} event month must be within the scenario horizon.`); + } + return { + month, + label: this.requiredTrim(event.label, "Event label"), + amount: Number(event.amount ?? 0), + }; + }); + + return { + name, + type, + startingBalance: Number(scenario.startingBalance ?? 0), + monthlyContribution: Number(scenario.monthlyContribution ?? 0), + monthlyIncome: Number(scenario.monthlyIncome ?? 0), + monthlyExpenses: Number(scenario.monthlyExpenses ?? 0), + targetAmount: scenario.targetAmount !== undefined ? this.nonNegativeMoney(scenario.targetAmount, `${name} target amount`) : null, + horizonMonths, + annualGrowthRate, + events, + }; + } + + private normalizeFutureScenarioType(type?: string): FutureScenarioType { + if (!type) return "goal"; + if (["goal", "net_worth", "income_change", "expense_change"].includes(type)) return type as FutureScenarioType; + throw new BadRequestException("Scenario type must be goal, net_worth, income_change, or expense_change."); + } + + private projectFutureScenario(scenario: ReturnType) { + const monthlyGrowthRate = scenario.annualGrowthRate / 100 / 12; + let balance = scenario.startingBalance; + let targetReachedMonth: number | null = null; + const milestones: Array<{ month: number; label: string; balance: number }> = []; + const eventMap = new Map>(); + for (const event of scenario.events) { + eventMap.set(event.month, [...(eventMap.get(event.month) ?? []), event]); + } + + for (let month = 1; month <= scenario.horizonMonths; month += 1) { + const netMonthlyChange = scenario.monthlyContribution + scenario.monthlyIncome - scenario.monthlyExpenses; + balance = this.roundCurrency((balance + netMonthlyChange) * (1 + monthlyGrowthRate)); + for (const event of eventMap.get(month) ?? []) { + balance = this.roundCurrency(balance + event.amount); + milestones.push({ month, label: event.label, balance }); + } + if (scenario.targetAmount !== null && targetReachedMonth === null && balance >= scenario.targetAmount) { + targetReachedMonth = month; + milestones.push({ month, label: "Target reached", balance }); + } + if (month % 12 === 0 || month === scenario.horizonMonths) { + milestones.push({ month, label: `Month ${month}`, balance }); + } + } + + const finalBalance = this.roundCurrency(balance); + const totalContributions = this.roundCurrency((scenario.monthlyContribution + scenario.monthlyIncome - scenario.monthlyExpenses) * scenario.horizonMonths); + const eventTotal = this.roundCurrency(scenario.events.reduce((sum, event) => sum + event.amount, 0)); + const projectedGrowth = this.roundCurrency(finalBalance - scenario.startingBalance - totalContributions - eventTotal); + + return { + name: scenario.name, + type: scenario.type, + horizonMonths: scenario.horizonMonths, + startingBalance: this.roundCurrency(scenario.startingBalance), + finalBalance, + targetAmount: scenario.targetAmount, + targetReachedMonth, + monthlyNetChange: this.roundCurrency(scenario.monthlyContribution + scenario.monthlyIncome - scenario.monthlyExpenses), + totalContributions, + eventTotal, + projectedGrowth, + milestones: milestones.slice(0, 18), + recommendation: this.futureScenarioRecommendation(scenario, finalBalance, targetReachedMonth), + }; + } + + private futureScenarioRecommendation( + scenario: ReturnType, + finalBalance: number, + targetReachedMonth: number | null, + ) { + if (scenario.targetAmount !== null && targetReachedMonth === null) { + const gap = this.roundCurrency(scenario.targetAmount - finalBalance); + return `Increase monthly net contribution or reduce the target gap of ${this.formatMoneyValue(gap)}.`; + } + if (scenario.targetAmount !== null) { + return `Target is projected for month ${targetReachedMonth}; review this after major income or expense changes.`; + } + if (scenario.monthlyIncome - scenario.monthlyExpenses + scenario.monthlyContribution < 0) { + return "Monthly net change is negative; agree which expense or contribution changes before using this plan."; + } + return "Scenario is on track under the current assumptions."; + } + private normalizeFairSplitMode(mode?: string): FairSplitMode { if (!mode) return "income_weighted"; if (["equal", "income_weighted", "custom"].includes(mode)) return mode as FairSplitMode; diff --git a/test/households.service.spec.ts b/test/households.service.spec.ts index 2b4f144..9883c52 100644 --- a/test/households.service.spec.ts +++ b/test/households.service.spec.ts @@ -148,6 +148,62 @@ describe("HouseholdsService", () => { })).rejects.toBeInstanceOf(BadRequestException); }); + it("calculates future planning scenarios and comparisons", async () => { + const { prisma, service } = createService(); + prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", status: "active" }); + + const result = await service.calculateFutureScenarios("user_1", "household_1", { + scenarios: [ + { + name: "Emergency fund", + type: "goal", + startingBalance: 1000, + monthlyContribution: 500, + targetAmount: 5000, + horizonMonths: 12, + annualGrowthRate: 0, + }, + { + name: "Move plan", + type: "expense_change", + startingBalance: 1000, + monthlyContribution: 300, + monthlyIncome: 6000, + monthlyExpenses: 5200, + targetAmount: 10000, + horizonMonths: 12, + annualGrowthRate: 3, + events: [{ month: 6, label: "Moving deposit", amount: -1500 }], + }, + ], + }); + + expect(result.scenarios).toHaveLength(2); + expect(result.scenarios[0]).toEqual(expect.objectContaining({ + name: "Emergency fund", + finalBalance: 7000, + targetReachedMonth: 8, + monthlyNetChange: 500, + })); + expect(result.scenarios[1].eventTotal).toBe(-1500); + expect(result.comparison.bestFinalBalance.name).toBe("Move plan"); + expect(result.comparison.earliestTarget.name).toBe("Emergency fund"); + }); + + it("rejects future planning scenarios outside the allowed horizon", async () => { + const { prisma, service } = createService(); + prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", status: "active" }); + + await expect(service.calculateFutureScenarios("user_1", "household_1", { + scenarios: [{ + name: "Too long", + startingBalance: 0, + monthlyContribution: 100, + horizonMonths: 601, + }], + })).rejects.toBeInstanceOf(BadRequestException); + }); + it("blocks reading households when the user is not an active member", async () => { const { prisma, service } = createService(); prisma.householdMember.findFirst.mockResolvedValue(null);