Add household debt payoff planner

This commit is contained in:
MOHAN 2026-07-16 23:49:50 +05:30
parent 7e8e440c43
commit 2b19fb876a
4 changed files with 287 additions and 0 deletions

View 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;
}

View File

@ -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));

View File

@ -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;

View File

@ -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);