Add household debt payoff planner
This commit is contained in:
parent
7e8e440c43
commit
2b19fb876a
20
src/households/dto/debt-payoff-planner.dto.ts
Normal file
20
src/households/dto/debt-payoff-planner.dto.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export type DebtPayoffStrategy = "avalanche" | "snowball" | "custom";
|
||||||
|
export type DebtPayoffSplitMode = "equal" | "income_weighted" | "custom";
|
||||||
|
|
||||||
|
export class DebtPayoffItemDto {
|
||||||
|
name!: string;
|
||||||
|
balance!: number;
|
||||||
|
annualPercentageRate?: number;
|
||||||
|
minimumPayment!: number;
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DebtPayoffPlannerDto {
|
||||||
|
debts!: DebtPayoffItemDto[];
|
||||||
|
monthlyExtraPayment?: number;
|
||||||
|
strategy?: DebtPayoffStrategy;
|
||||||
|
splitMode?: DebtPayoffSplitMode;
|
||||||
|
mineMonthlyIncome?: number;
|
||||||
|
yoursMonthlyIncome?: number;
|
||||||
|
customMinePercent?: number;
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import { AcceptHouseholdInviteDto } from "./dto/accept-household-invite.dto";
|
|||||||
import { CreateHouseholdGoalDto } from "./dto/create-household-goal.dto";
|
import { CreateHouseholdGoalDto } from "./dto/create-household-goal.dto";
|
||||||
import { CreateHouseholdDto } from "./dto/create-household.dto";
|
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 { FairSplitCalculatorDto } from "./dto/fair-split-calculator.dto";
|
import { FairSplitCalculatorDto } from "./dto/fair-split-calculator.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";
|
||||||
@ -63,6 +64,15 @@ export class HouseholdsController {
|
|||||||
return ok(await this.householdsService.listMoneyDatePrompts(userId, id));
|
return ok(await this.householdsService.listMoneyDatePrompts(userId, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(":id/debt-payoff")
|
||||||
|
async debtPayoff(
|
||||||
|
@CurrentUser() userId: string,
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() payload: DebtPayoffPlannerDto,
|
||||||
|
) {
|
||||||
|
return ok(await this.householdsService.calculateDebtPayoff(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));
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { AcceptHouseholdInviteDto } from "./dto/accept-household-invite.dto";
|
|||||||
import { CreateHouseholdGoalDto } from "./dto/create-household-goal.dto";
|
import { CreateHouseholdGoalDto } from "./dto/create-household-goal.dto";
|
||||||
import { CreateHouseholdDto } from "./dto/create-household.dto";
|
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 { FairSplitCalculatorDto, FairSplitMode } from "./dto/fair-split-calculator.dto";
|
import { FairSplitCalculatorDto, FairSplitMode } from "./dto/fair-split-calculator.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";
|
||||||
@ -490,6 +491,44 @@ export class HouseholdsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async calculateDebtPayoff(userId: string, householdId: string, payload: DebtPayoffPlannerDto) {
|
||||||
|
await this.requireActiveMember(userId, householdId);
|
||||||
|
const debts = this.normalizeDebtPayoffItems(payload.debts);
|
||||||
|
const monthlyExtraPayment = this.nonNegativeMoney(payload.monthlyExtraPayment ?? 0, "Monthly extra payment");
|
||||||
|
const strategy = this.normalizeDebtPayoffStrategy(payload.strategy);
|
||||||
|
const split = this.calculateContributionSplit({
|
||||||
|
amount: debts.reduce((sum, debt) => sum + debt.minimumPayment, 0) + monthlyExtraPayment,
|
||||||
|
splitMode: payload.splitMode ?? "equal",
|
||||||
|
mineMonthlyIncome: payload.mineMonthlyIncome ?? 0,
|
||||||
|
yoursMonthlyIncome: payload.yoursMonthlyIncome ?? 0,
|
||||||
|
customMinePercent: payload.customMinePercent,
|
||||||
|
});
|
||||||
|
|
||||||
|
const orderedDebts = this.orderDebtsForPayoff(debts, strategy);
|
||||||
|
const payoff = this.simulateDebtPayoff(orderedDebts, monthlyExtraPayment);
|
||||||
|
|
||||||
|
return {
|
||||||
|
householdId,
|
||||||
|
strategy,
|
||||||
|
monthlyExtraPayment,
|
||||||
|
monthlyPayment: payoff.monthlyPayment,
|
||||||
|
contributionSplit: split,
|
||||||
|
totalStartingBalance: this.roundCurrency(debts.reduce((sum, debt) => sum + debt.balance, 0)),
|
||||||
|
totalInterestPaid: payoff.totalInterestPaid,
|
||||||
|
payoffMonths: payoff.payoffMonths,
|
||||||
|
payoffYears: this.roundCurrency(payoff.payoffMonths / 12),
|
||||||
|
orderedDebts: orderedDebts.map((debt, index) => ({
|
||||||
|
name: debt.name,
|
||||||
|
startingBalance: debt.balance,
|
||||||
|
annualPercentageRate: debt.annualPercentageRate,
|
||||||
|
minimumPayment: debt.minimumPayment,
|
||||||
|
payoffOrder: index + 1,
|
||||||
|
})),
|
||||||
|
timeline: payoff.timeline,
|
||||||
|
recommendations: this.buildDebtPayoffRecommendations(strategy, monthlyExtraPayment, payoff),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
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({
|
||||||
@ -835,6 +874,181 @@ export class HouseholdsService {
|
|||||||
return value as Record<string, any>;
|
return value as Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeDebtPayoffItems(items: DebtPayoffPlannerDto["debts"]) {
|
||||||
|
if (!Array.isArray(items) || !items.length) {
|
||||||
|
throw new BadRequestException("At least one debt is required.");
|
||||||
|
}
|
||||||
|
if (items.length > 12) {
|
||||||
|
throw new BadRequestException("Debt payoff planner supports up to 12 debts.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.map((item, index) => {
|
||||||
|
const name = this.requiredTrim(item.name ?? `Debt ${index + 1}`, "Debt name");
|
||||||
|
const balance = this.positiveMoney(item.balance, `${name} balance`);
|
||||||
|
const minimumPayment = this.positiveMoney(item.minimumPayment, `${name} minimum payment`);
|
||||||
|
const annualPercentageRate = this.nonNegativeMoney(item.annualPercentageRate ?? 0, `${name} APR`);
|
||||||
|
if (annualPercentageRate > 100) {
|
||||||
|
throw new BadRequestException(`${name} APR must be 100 or lower.`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
balance,
|
||||||
|
minimumPayment,
|
||||||
|
annualPercentageRate,
|
||||||
|
priority: Number.isFinite(item.priority) ? Number(item.priority) : index + 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeDebtPayoffStrategy(strategy?: string): DebtPayoffStrategy {
|
||||||
|
if (!strategy) return "avalanche";
|
||||||
|
if (["avalanche", "snowball", "custom"].includes(strategy)) return strategy as DebtPayoffStrategy;
|
||||||
|
throw new BadRequestException("Debt payoff strategy must be avalanche, snowball, or custom.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculateContributionSplit(input: {
|
||||||
|
amount: number;
|
||||||
|
splitMode: DebtPayoffSplitMode;
|
||||||
|
mineMonthlyIncome: number;
|
||||||
|
yoursMonthlyIncome: number;
|
||||||
|
customMinePercent?: number;
|
||||||
|
}) {
|
||||||
|
const mineIncome = this.nonNegativeMoney(input.mineMonthlyIncome, "Mine monthly income");
|
||||||
|
const yoursIncome = this.nonNegativeMoney(input.yoursMonthlyIncome, "Yours monthly income");
|
||||||
|
const totalIncome = mineIncome + yoursIncome;
|
||||||
|
let minePercent = 50;
|
||||||
|
let rationale = "Equal split divides the payoff contribution 50/50.";
|
||||||
|
|
||||||
|
if (input.splitMode === "income_weighted" && totalIncome > 0) {
|
||||||
|
minePercent = (mineIncome / totalIncome) * 100;
|
||||||
|
rationale = "Income-weighted split divides the payoff contribution in proportion to monthly income.";
|
||||||
|
} else if (input.splitMode === "income_weighted") {
|
||||||
|
rationale = "Income-weighted split falls back to 50/50 when both incomes are zero.";
|
||||||
|
} else if (input.splitMode === "custom") {
|
||||||
|
minePercent = this.percent(input.customMinePercent, "Custom mine percent");
|
||||||
|
rationale = "Custom split uses the percentage entered by the household.";
|
||||||
|
} else if (input.splitMode !== "equal") {
|
||||||
|
throw new BadRequestException("Debt contribution split mode must be equal, income_weighted, or custom.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundedMinePercent = this.roundCurrency(minePercent);
|
||||||
|
const yoursPercent = this.roundCurrency(100 - roundedMinePercent);
|
||||||
|
const mineAmount = this.roundCurrency(input.amount * (roundedMinePercent / 100));
|
||||||
|
return {
|
||||||
|
splitMode: input.splitMode,
|
||||||
|
minePercent: roundedMinePercent,
|
||||||
|
yoursPercent,
|
||||||
|
mineAmount,
|
||||||
|
yoursAmount: this.roundCurrency(input.amount - mineAmount),
|
||||||
|
rationale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private orderDebtsForPayoff(
|
||||||
|
debts: Array<{ name: string; balance: number; minimumPayment: number; annualPercentageRate: number; priority: number }>,
|
||||||
|
strategy: DebtPayoffStrategy,
|
||||||
|
) {
|
||||||
|
return [...debts].sort((a, b) => {
|
||||||
|
if (strategy === "snowball") return a.balance - b.balance;
|
||||||
|
if (strategy === "custom") return a.priority - b.priority;
|
||||||
|
return b.annualPercentageRate - a.annualPercentageRate;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private simulateDebtPayoff(
|
||||||
|
debts: Array<{ name: string; balance: number; minimumPayment: number; annualPercentageRate: number }>,
|
||||||
|
monthlyExtraPayment: number,
|
||||||
|
) {
|
||||||
|
const working = debts.map((debt) => ({ ...debt, remainingBalance: debt.balance, paidOffMonth: null as number | null }));
|
||||||
|
const monthlyPayment = this.roundCurrency(working.reduce((sum, debt) => sum + debt.minimumPayment, 0) + monthlyExtraPayment);
|
||||||
|
const timeline: Array<{
|
||||||
|
month: number;
|
||||||
|
targetDebt: string;
|
||||||
|
totalRemainingBalance: number;
|
||||||
|
interestPaid: number;
|
||||||
|
principalPaid: number;
|
||||||
|
paidOffDebts: string[];
|
||||||
|
}> = [];
|
||||||
|
let totalInterestPaid = 0;
|
||||||
|
let month = 0;
|
||||||
|
|
||||||
|
while (working.some((debt) => debt.remainingBalance > 0.005) && month < 600) {
|
||||||
|
month += 1;
|
||||||
|
const activeDebts = working.filter((debt) => debt.remainingBalance > 0.005);
|
||||||
|
const targetDebt = activeDebts[0];
|
||||||
|
let availableRollover = monthlyExtraPayment + working.filter((debt) => debt.paidOffMonth !== null).reduce((sum, debt) => sum + debt.minimumPayment, 0);
|
||||||
|
let monthInterest = 0;
|
||||||
|
let monthPrincipal = 0;
|
||||||
|
const paidOffDebts: string[] = [];
|
||||||
|
|
||||||
|
for (const debt of activeDebts) {
|
||||||
|
const interest = this.roundCurrency(debt.remainingBalance * (debt.annualPercentageRate / 100 / 12));
|
||||||
|
debt.remainingBalance = this.roundCurrency(debt.remainingBalance + interest);
|
||||||
|
monthInterest += interest;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const debt of activeDebts) {
|
||||||
|
const payment = Math.min(debt.remainingBalance, debt.minimumPayment);
|
||||||
|
debt.remainingBalance = this.roundCurrency(debt.remainingBalance - payment);
|
||||||
|
monthPrincipal += payment;
|
||||||
|
if (debt.remainingBalance <= 0.005 && debt.paidOffMonth === null) {
|
||||||
|
debt.paidOffMonth = month;
|
||||||
|
paidOffDebts.push(debt.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const debt of working) {
|
||||||
|
if (availableRollover <= 0.005) break;
|
||||||
|
if (debt.remainingBalance <= 0.005) continue;
|
||||||
|
const payment = Math.min(debt.remainingBalance, availableRollover);
|
||||||
|
debt.remainingBalance = this.roundCurrency(debt.remainingBalance - payment);
|
||||||
|
availableRollover = this.roundCurrency(availableRollover - payment);
|
||||||
|
monthPrincipal += payment;
|
||||||
|
if (debt.remainingBalance <= 0.005 && debt.paidOffMonth === null) {
|
||||||
|
debt.paidOffMonth = month;
|
||||||
|
paidOffDebts.push(debt.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalInterestPaid += monthInterest;
|
||||||
|
if (month <= 12 || paidOffDebts.length || !working.some((debt) => debt.remainingBalance > 0.005)) {
|
||||||
|
timeline.push({
|
||||||
|
month,
|
||||||
|
targetDebt: targetDebt?.name ?? "All debts",
|
||||||
|
totalRemainingBalance: this.roundCurrency(working.reduce((sum, debt) => sum + Math.max(debt.remainingBalance, 0), 0)),
|
||||||
|
interestPaid: this.roundCurrency(monthInterest),
|
||||||
|
principalPaid: this.roundCurrency(monthPrincipal),
|
||||||
|
paidOffDebts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (month >= 600 && working.some((debt) => debt.remainingBalance > 0.005)) {
|
||||||
|
throw new BadRequestException("Debt payoff plan does not finish within 50 years. Increase monthly payments.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
monthlyPayment,
|
||||||
|
totalInterestPaid: this.roundCurrency(totalInterestPaid),
|
||||||
|
payoffMonths: month,
|
||||||
|
timeline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDebtPayoffRecommendations(strategy: DebtPayoffStrategy, monthlyExtraPayment: number, payoff: { payoffMonths: number; totalInterestPaid: number }) {
|
||||||
|
const recommendations = [
|
||||||
|
strategy === "avalanche"
|
||||||
|
? "Keep the highest-APR debt first to reduce total interest."
|
||||||
|
: strategy === "snowball"
|
||||||
|
? "Use the smallest balance first for faster visible wins."
|
||||||
|
: "Keep the custom priority order agreed by the household.",
|
||||||
|
`Agree who contributes each month before the ${payoff.payoffMonths}-month payoff plan starts.`,
|
||||||
|
];
|
||||||
|
if (monthlyExtraPayment <= 0) recommendations.push("Add even a small extra monthly payment to shorten the payoff timeline.");
|
||||||
|
if (payoff.totalInterestPaid > 0) recommendations.push("Re-run the plan after every payoff or APR change.");
|
||||||
|
return recommendations;
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@ -105,6 +105,49 @@ describe("HouseholdsService", () => {
|
|||||||
})).rejects.toBeInstanceOf(BadRequestException);
|
})).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("calculates household debt payoff teamwork plans", async () => {
|
||||||
|
const { prisma, service } = createService();
|
||||||
|
prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", status: "active" });
|
||||||
|
|
||||||
|
const result = await service.calculateDebtPayoff("user_1", "household_1", {
|
||||||
|
strategy: "avalanche",
|
||||||
|
monthlyExtraPayment: 300,
|
||||||
|
splitMode: "income_weighted",
|
||||||
|
mineMonthlyIncome: 7000,
|
||||||
|
yoursMonthlyIncome: 3000,
|
||||||
|
debts: [
|
||||||
|
{ name: "Card A", balance: 3000, annualPercentageRate: 24, minimumPayment: 90 },
|
||||||
|
{ name: "Loan B", balance: 10000, annualPercentageRate: 8, minimumPayment: 250 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.strategy).toBe("avalanche");
|
||||||
|
expect(result.orderedDebts[0]).toEqual(expect.objectContaining({ name: "Card A", payoffOrder: 1 }));
|
||||||
|
expect(result.contributionSplit).toEqual(expect.objectContaining({
|
||||||
|
minePercent: 70,
|
||||||
|
yoursPercent: 30,
|
||||||
|
mineAmount: 448,
|
||||||
|
yoursAmount: 192,
|
||||||
|
}));
|
||||||
|
expect(result.monthlyPayment).toBe(640);
|
||||||
|
expect(result.payoffMonths).toBeGreaterThan(0);
|
||||||
|
expect(result.totalInterestPaid).toBeGreaterThan(0);
|
||||||
|
expect(result.timeline[0]).toEqual(expect.objectContaining({
|
||||||
|
month: 1,
|
||||||
|
targetDebt: "Card A",
|
||||||
|
totalRemainingBalance: expect.any(Number),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects debt payoff plans with no debts", async () => {
|
||||||
|
const { prisma, service } = createService();
|
||||||
|
prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", status: "active" });
|
||||||
|
|
||||||
|
await expect(service.calculateDebtPayoff("user_1", "household_1", {
|
||||||
|
debts: [],
|
||||||
|
})).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