Add household fair split calculator

This commit is contained in:
MOHAN 2026-07-16 23:15:20 +05:30
parent f7160fceab
commit a33d9b8f00
4 changed files with 127 additions and 0 deletions

View File

@ -0,0 +1,9 @@
export type FairSplitMode = "equal" | "income_weighted" | "custom";
export class FairSplitCalculatorDto {
expenseAmount!: number;
mineMonthlyIncome?: number;
yoursMonthlyIncome?: number;
splitMode?: FairSplitMode;
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 { 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));

View File

@ -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 },

View File

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