Add household future scenario planning
This commit is contained in:
parent
2b19fb876a
commit
e7549f3dde
24
src/households/dto/future-planning-scenario.dto.ts
Normal file
24
src/households/dto/future-planning-scenario.dto.ts
Normal 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[];
|
||||
}
|
||||
@ -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));
|
||||
|
||||
@ -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<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 {
|
||||
if (!mode) return "income_weighted";
|
||||
if (["equal", "income_weighted", "custom"].includes(mode)) return mode as FairSplitMode;
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user