Add household fair split calculator
This commit is contained in:
parent
f7160fceab
commit
a33d9b8f00
9
src/households/dto/fair-split-calculator.dto.ts
Normal file
9
src/households/dto/fair-split-calculator.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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 { 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";
|
||||||
import { HouseholdsService } from "./households.service";
|
import { HouseholdsService } from "./households.service";
|
||||||
@ -38,6 +39,15 @@ export class HouseholdsController {
|
|||||||
return ok(await this.householdsService.listMembers(userId, id));
|
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")
|
@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 { 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";
|
||||||
|
|
||||||
@ -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) {
|
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({
|
||||||
@ -595,6 +653,20 @@ export class HouseholdsService {
|
|||||||
return crypto.createHash("sha256").update(token).digest("hex");
|
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[]) {
|
private buildOwnershipBreakdown(accounts: any[]) {
|
||||||
const initial = {
|
const initial = {
|
||||||
mine: { accountCount: 0, balance: 0 },
|
mine: { accountCount: 0, balance: 0 },
|
||||||
|
|||||||
@ -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 () => {
|
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