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 { 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));
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user