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 { 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));
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user