From 2b19fb876acdd353b415197f408132c2a3657f55 Mon Sep 17 00:00:00 2001 From: MOHAN Date: Thu, 16 Jul 2026 23:49:50 +0530 Subject: [PATCH] Add household debt payoff planner --- src/households/dto/debt-payoff-planner.dto.ts | 20 ++ src/households/households.controller.ts | 10 + src/households/households.service.ts | 214 ++++++++++++++++++ test/households.service.spec.ts | 43 ++++ 4 files changed, 287 insertions(+) create mode 100644 src/households/dto/debt-payoff-planner.dto.ts diff --git a/src/households/dto/debt-payoff-planner.dto.ts b/src/households/dto/debt-payoff-planner.dto.ts new file mode 100644 index 0000000..fbff873 --- /dev/null +++ b/src/households/dto/debt-payoff-planner.dto.ts @@ -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; +} diff --git a/src/households/households.controller.ts b/src/households/households.controller.ts index 7770370..f7e97e5 100644 --- a/src/households/households.controller.ts +++ b/src/households/households.controller.ts @@ -5,6 +5,7 @@ import { AcceptHouseholdInviteDto } from "./dto/accept-household-invite.dto"; import { CreateHouseholdGoalDto } from "./dto/create-household-goal.dto"; 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 { UpdateHouseholdGoalDto } from "./dto/update-household-goal.dto"; import { UpdateHouseholdMemberDto } from "./dto/update-household-member.dto"; @@ -63,6 +64,15 @@ export class HouseholdsController { 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") async goals(@CurrentUser() userId: string, @Param("id") id: string) { return ok(await this.householdsService.listGoals(userId, id)); diff --git a/src/households/households.service.ts b/src/households/households.service.ts index 107187d..cde1eed 100644 --- a/src/households/households.service.ts +++ b/src/households/households.service.ts @@ -7,6 +7,7 @@ import { AcceptHouseholdInviteDto } from "./dto/accept-household-invite.dto"; import { CreateHouseholdGoalDto } from "./dto/create-household-goal.dto"; 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 { UpdateHouseholdGoalDto } from "./dto/update-household-goal.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) { await this.requireActiveMember(userId, householdId); const goals = await this.prisma.householdGoal.findMany({ @@ -835,6 +874,181 @@ export class HouseholdsService { return value as Record; } + 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 { if (!mode) return "income_weighted"; if (["equal", "income_weighted", "custom"].includes(mode)) return mode as FairSplitMode; diff --git a/test/households.service.spec.ts b/test/households.service.spec.ts index edb9e52..2b4f144 100644 --- a/test/households.service.spec.ts +++ b/test/households.service.spec.ts @@ -105,6 +105,49 @@ describe("HouseholdsService", () => { })).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 () => { const { prisma, service } = createService(); prisma.householdMember.findFirst.mockResolvedValue(null);