From a33d9b8f000fea85b9a2f55de19d1fd73416961f Mon Sep 17 00:00:00 2001 From: MOHAN Date: Thu, 16 Jul 2026 23:15:20 +0530 Subject: [PATCH] Add household fair split calculator --- .../dto/fair-split-calculator.dto.ts | 9 +++ src/households/households.controller.ts | 10 +++ src/households/households.service.ts | 72 +++++++++++++++++++ test/households.service.spec.ts | 36 ++++++++++ 4 files changed, 127 insertions(+) create mode 100644 src/households/dto/fair-split-calculator.dto.ts diff --git a/src/households/dto/fair-split-calculator.dto.ts b/src/households/dto/fair-split-calculator.dto.ts new file mode 100644 index 0000000..b7d5e88 --- /dev/null +++ b/src/households/dto/fair-split-calculator.dto.ts @@ -0,0 +1,9 @@ +export type FairSplitMode = "equal" | "income_weighted" | "custom"; + +export class FairSplitCalculatorDto { + expenseAmount!: number; + mineMonthlyIncome?: number; + yoursMonthlyIncome?: number; + splitMode?: FairSplitMode; + customMinePercent?: number; +} diff --git a/src/households/households.controller.ts b/src/households/households.controller.ts index 4613b67..6ea481c 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 { FairSplitCalculatorDto } from "./dto/fair-split-calculator.dto"; import { UpdateHouseholdGoalDto } from "./dto/update-household-goal.dto"; import { UpdateHouseholdMemberDto } from "./dto/update-household-member.dto"; import { HouseholdsService } from "./households.service"; @@ -38,6 +39,15 @@ export class HouseholdsController { return ok(await this.householdsService.listMembers(userId, id)); } + @Post(":id/fair-split") + async fairSplit( + @CurrentUser() userId: string, + @Param("id") id: string, + @Body() payload: FairSplitCalculatorDto, + ) { + return ok(await this.householdsService.calculateFairSplit(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 584fc91..ee02e86 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 { FairSplitCalculatorDto, FairSplitMode } from "./dto/fair-split-calculator.dto"; import { UpdateHouseholdGoalDto } from "./dto/update-household-goal.dto"; import { UpdateHouseholdMemberDto } from "./dto/update-household-member.dto"; @@ -275,6 +276,63 @@ export class HouseholdsService { }); } + async calculateFairSplit(userId: string, householdId: string, payload: FairSplitCalculatorDto) { + await this.requireActiveMember(userId, householdId); + + const expenseAmount = this.positiveMoney(payload.expenseAmount, "Expense amount"); + const mineMonthlyIncome = this.nonNegativeMoney(payload.mineMonthlyIncome ?? 0, "Mine monthly income"); + const yoursMonthlyIncome = this.nonNegativeMoney(payload.yoursMonthlyIncome ?? 0, "Yours monthly income"); + const splitMode = this.normalizeFairSplitMode(payload.splitMode); + const totalIncome = mineMonthlyIncome + yoursMonthlyIncome; + + let minePercent: number; + let rationale: string; + if (splitMode === "equal") { + minePercent = 50; + rationale = "Equal split divides the expense 50/50."; + } else if (splitMode === "custom") { + minePercent = this.percent(payload.customMinePercent, "Custom mine percent"); + rationale = "Custom split uses the percentage entered by the household."; + } else if (totalIncome > 0) { + minePercent = (mineMonthlyIncome / totalIncome) * 100; + rationale = "Income-weighted split divides the expense in proportion to monthly income."; + } else { + minePercent = 50; + rationale = "Income-weighted split falls back to 50/50 when both incomes are zero."; + } + + const roundedMinePercent = this.roundCurrency(minePercent); + const yoursPercent = this.roundCurrency(100 - roundedMinePercent); + const mineAmount = this.roundCurrency(expenseAmount * (roundedMinePercent / 100)); + const yoursAmount = this.roundCurrency(expenseAmount - mineAmount); + + return { + householdId, + expenseAmount, + splitMode, + mineMonthlyIncome, + yoursMonthlyIncome, + totalIncome: this.roundCurrency(totalIncome), + incomeShares: { + mine: totalIncome > 0 ? this.roundCurrency((mineMonthlyIncome / totalIncome) * 100) : 50, + yours: totalIncome > 0 ? this.roundCurrency((yoursMonthlyIncome / totalIncome) * 100) : 50, + }, + split: { + minePercent: roundedMinePercent, + yoursPercent, + mineAmount, + yoursAmount, + }, + transactionDefaults: { + attribution: "ours", + splitMode: splitMode === "equal" ? "equal" : "custom", + splitMinePercent: roundedMinePercent, + splitYoursPercent: yoursPercent, + }, + rationale, + }; + } + async listGoals(userId: string, householdId: string) { await this.requireActiveMember(userId, householdId); const goals = await this.prisma.householdGoal.findMany({ @@ -595,6 +653,20 @@ export class HouseholdsService { return crypto.createHash("sha256").update(token).digest("hex"); } + private normalizeFairSplitMode(mode?: string): FairSplitMode { + if (!mode) return "income_weighted"; + if (["equal", "income_weighted", "custom"].includes(mode)) return mode as FairSplitMode; + throw new BadRequestException("Fair split mode must be equal, income_weighted, or custom."); + } + + private percent(value: unknown, label: string) { + const percent = Number(value); + if (!Number.isFinite(percent) || percent < 0 || percent > 100) { + throw new BadRequestException(`${label} must be between 0 and 100.`); + } + return percent; + } + private buildOwnershipBreakdown(accounts: any[]) { const initial = { mine: { accountCount: 0, balance: 0 }, diff --git a/test/households.service.spec.ts b/test/households.service.spec.ts index 74bab82..e20f17e 100644 --- a/test/households.service.spec.ts +++ b/test/households.service.spec.ts @@ -69,6 +69,42 @@ describe("HouseholdsService", () => { }); }); + it("calculates income-weighted fair splits for household members", async () => { + const { prisma, service } = createService(); + prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", status: "active" }); + + const result = await service.calculateFairSplit("user_1", "household_1", { + expenseAmount: 1000, + mineMonthlyIncome: 7000, + yoursMonthlyIncome: 3000, + splitMode: "income_weighted", + }); + + expect(result.split).toEqual({ + minePercent: 70, + yoursPercent: 30, + mineAmount: 700, + yoursAmount: 300, + }); + expect(result.transactionDefaults).toEqual({ + attribution: "ours", + splitMode: "custom", + splitMinePercent: 70, + splitYoursPercent: 30, + }); + }); + + it("rejects invalid custom fair split percentages", async () => { + const { prisma, service } = createService(); + prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", status: "active" }); + + await expect(service.calculateFairSplit("user_1", "household_1", { + expenseAmount: 100, + splitMode: "custom", + customMinePercent: 140, + })).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);