Add household future scenario planning

This commit is contained in:
MOHAN 2026-07-16 23:57:07 +05:30
parent 2b19fb876a
commit e7549f3dde
4 changed files with 227 additions and 0 deletions

View File

@ -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[];
}

View File

@ -7,6 +7,7 @@ import { CreateHouseholdDto } from "./dto/create-household.dto";
import { CreateHouseholdInviteDto } from "./dto/create-household-invite.dto"; import { CreateHouseholdInviteDto } from "./dto/create-household-invite.dto";
import { DebtPayoffPlannerDto } from "./dto/debt-payoff-planner.dto"; import { DebtPayoffPlannerDto } from "./dto/debt-payoff-planner.dto";
import { FairSplitCalculatorDto } from "./dto/fair-split-calculator.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 { UpdateHouseholdGoalDto } from "./dto/update-household-goal.dto";
import { UpdateHouseholdMemberDto } from "./dto/update-household-member.dto"; import { UpdateHouseholdMemberDto } from "./dto/update-household-member.dto";
import { UpdateHouseholdPrivacyDto } from "./dto/update-household-privacy.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)); 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") @Get(":id/goals")
async goals(@CurrentUser() userId: string, @Param("id") id: string) { async goals(@CurrentUser() userId: string, @Param("id") id: string) {
return ok(await this.householdsService.listGoals(userId, id)); return ok(await this.householdsService.listGoals(userId, id));

View File

@ -9,6 +9,7 @@ import { CreateHouseholdDto } from "./dto/create-household.dto";
import { CreateHouseholdInviteDto } from "./dto/create-household-invite.dto"; import { CreateHouseholdInviteDto } from "./dto/create-household-invite.dto";
import { DebtPayoffPlannerDto, DebtPayoffSplitMode, DebtPayoffStrategy } from "./dto/debt-payoff-planner.dto"; import { DebtPayoffPlannerDto, DebtPayoffSplitMode, DebtPayoffStrategy } from "./dto/debt-payoff-planner.dto";
import { FairSplitCalculatorDto, FairSplitMode } from "./dto/fair-split-calculator.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 { UpdateHouseholdGoalDto } from "./dto/update-household-goal.dto";
import { UpdateHouseholdMemberDto } from "./dto/update-household-member.dto"; import { UpdateHouseholdMemberDto } from "./dto/update-household-member.dto";
import { UpdateHouseholdPrivacyDto } from "./dto/update-household-privacy.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) { async listGoals(userId: string, householdId: string) {
await this.requireActiveMember(userId, householdId); await this.requireActiveMember(userId, householdId);
const goals = await this.prisma.householdGoal.findMany({ const goals = await this.prisma.householdGoal.findMany({
@ -1049,6 +1068,124 @@ export class HouseholdsService {
return recommendations; 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<HouseholdsService["normalizeFutureScenario"]>) {
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<number, Array<{ label: string; amount: number }>>();
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<HouseholdsService["normalizeFutureScenario"]>,
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 { private normalizeFairSplitMode(mode?: string): FairSplitMode {
if (!mode) return "income_weighted"; if (!mode) return "income_weighted";
if (["equal", "income_weighted", "custom"].includes(mode)) return mode as FairSplitMode; if (["equal", "income_weighted", "custom"].includes(mode)) return mode as FairSplitMode;

View File

@ -148,6 +148,62 @@ describe("HouseholdsService", () => {
})).rejects.toBeInstanceOf(BadRequestException); })).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 () => { it("blocks reading households when the user is not an active member", async () => {
const { prisma, service } = createService(); const { prisma, service } = createService();
prisma.householdMember.findFirst.mockResolvedValue(null); prisma.householdMember.findFirst.mockResolvedValue(null);