feat: add household health score

This commit is contained in:
MOHAN 2026-07-16 15:37:53 +05:30
parent 5794203c80
commit aeb23530cd
2 changed files with 164 additions and 6 deletions

View File

@ -137,7 +137,7 @@ export class HouseholdsService {
const now = new Date(); const now = new Date();
const cashflowStart = new Date(now.getFullYear(), now.getMonth() - 5, 1); const cashflowStart = new Date(now.getFullYear(), now.getMonth() - 5, 1);
const [recentRows, cashflowRows] = await Promise.all([ const [recentRows, cashflowRows, goalRows] = await Promise.all([
this.prisma.transactionRaw.findMany({ this.prisma.transactionRaw.findMany({
where: { where: {
account: { householdId, isActive: true }, account: { householdId, isActive: true },
@ -171,11 +171,35 @@ export class HouseholdsService {
}, },
orderBy: { date: "asc" }, orderBy: { date: "asc" },
}), }),
this.prisma.householdGoal.findMany({
where: { householdId, status: { in: ["active", "completed"] } },
select: {
targetAmount: true,
currentAmount: true,
status: true,
},
}),
]); ]);
const activeTransactions = cashflowRows.filter((row: any) => !row.derived?.isHidden); const activeTransactions = cashflowRows.filter((row: any) => !row.derived?.isHidden);
const balanceByOwnership = this.buildOwnershipBreakdown(accounts); const balanceByOwnership = this.buildOwnershipBreakdown(accounts);
const cashflow = this.buildCashflow(activeTransactions, cashflowStart, now); const cashflow = this.buildCashflow(activeTransactions, cashflowStart, now);
const monthlyIncome = cashflow.currentMonth.income;
const monthlyExpenses = cashflow.currentMonth.expenses;
const monthlyNet = cashflow.currentMonth.net;
const totalBalance = this.roundCurrency(accounts.reduce((sum: number, account: any) => sum + this.toNumber(account.currentBalance), 0));
const availableBalance = this.roundCurrency(accounts.reduce((sum: number, account: any) => sum + this.toNumber(account.availableBalance), 0));
const healthScore = this.buildCouplesHealthScore({
memberCount: household.members.length,
accounts,
activeTransactions,
goals: goalRows,
totalBalance,
availableBalance,
monthlyIncome,
monthlyExpenses,
monthlyNet,
});
return { return {
household: { household: {
@ -194,12 +218,13 @@ export class HouseholdsService {
summary: { summary: {
memberCount: household.members.length, memberCount: household.members.length,
accountCount: accounts.length, accountCount: accounts.length,
totalBalance: this.roundCurrency(accounts.reduce((sum: number, account: any) => sum + this.toNumber(account.currentBalance), 0)), totalBalance,
availableBalance: this.roundCurrency(accounts.reduce((sum: number, account: any) => sum + this.toNumber(account.availableBalance), 0)), availableBalance,
monthlyIncome: cashflow.currentMonth.income, monthlyIncome,
monthlyExpenses: cashflow.currentMonth.expenses, monthlyExpenses,
monthlyNet: cashflow.currentMonth.net, monthlyNet,
}, },
healthScore,
ownershipBreakdown: balanceByOwnership, ownershipBreakdown: balanceByOwnership,
accounts: accounts.map((account: any, index: number) => ({ accounts: accounts.map((account: any, index: number) => ({
displayId: `household_account_${index + 1}`, displayId: `household_account_${index + 1}`,
@ -650,6 +675,107 @@ export class HouseholdsService {
}; };
} }
private buildCouplesHealthScore(input: {
memberCount: number;
accounts: any[];
activeTransactions: any[];
goals: any[];
totalBalance: number;
availableBalance: number;
monthlyIncome: number;
monthlyExpenses: number;
monthlyNet: number;
}) {
const savingsRate = input.monthlyIncome > 0 ? input.monthlyNet / input.monthlyIncome : 0;
const cashflowScore = this.clampScore(35 * Math.max(0, Math.min(savingsRate / 0.25, 1)));
const bufferMonths = input.monthlyExpenses > 0 ? input.availableBalance / input.monthlyExpenses : input.availableBalance > 0 ? 3 : 0;
const balanceScore = this.clampScore(
(input.totalBalance > 0 ? 8 : 0) +
(input.availableBalance > 0 ? 4 : 0) +
(8 * Math.max(0, Math.min(bufferMonths / 3, 1))),
);
const activeGoals = input.goals.filter((goal) => goal.status !== "archived");
const goalProgress = activeGoals.length
? activeGoals.reduce((sum, goal) => {
const target = this.toNumber(goal.targetAmount);
const current = this.toNumber(goal.currentAmount);
return sum + (target > 0 ? Math.min(current / target, 1) : 0);
}, 0) / activeGoals.length
: 0;
const goalScore = this.clampScore(activeGoals.length ? 8 + (goalProgress * 12) : 0);
const jointAccountCount = input.accounts.filter((account) => account.ownershipType === "joint").length;
const collaborativeTransactionCount = input.activeTransactions.filter((row) => {
const attribution = row.derived?.attribution ?? this.defaultAttributionForOwnership(row.account?.ownershipType);
const splitMode = row.derived?.splitMode ?? "none";
return attribution === "ours" || splitMode !== "none" || row.account?.ownershipType === "joint";
}).length;
const collaborationScore = this.clampScore(
(input.memberCount > 1 ? 5 : 0) +
(jointAccountCount > 0 ? 5 : 0) +
(collaborativeTransactionCount > 0 ? 5 : 0),
);
const healthyAccounts = input.accounts.filter((account) => ["idle", "synced", "ok"].includes(account.syncStatus)).length;
const syncScore = this.clampScore(input.accounts.length ? (healthyAccounts / input.accounts.length) * 10 : 0);
const score = this.clampScore(cashflowScore + balanceScore + goalScore + collaborationScore + syncScore);
const recommendations = this.buildHealthRecommendations({
savingsRate,
bufferMonths,
activeGoalCount: activeGoals.length,
jointAccountCount,
collaborativeTransactionCount,
unhealthyAccountCount: input.accounts.length - healthyAccounts,
});
return {
score,
rating: this.healthRating(score),
components: {
cashflow: cashflowScore,
balanceBuffer: balanceScore,
goalProgress: goalScore,
collaboration: collaborationScore,
syncHealth: syncScore,
},
metrics: {
savingsRate: this.roundCurrency(savingsRate * 100),
bufferMonths: this.roundCurrency(bufferMonths),
activeGoalCount: activeGoals.length,
jointAccountCount,
collaborativeTransactionCount,
},
recommendations,
};
}
private buildHealthRecommendations(input: {
savingsRate: number;
bufferMonths: number;
activeGoalCount: number;
jointAccountCount: number;
collaborativeTransactionCount: number;
unhealthyAccountCount: number;
}) {
const recommendations: string[] = [];
if (input.savingsRate < 0.1) recommendations.push("Raise the monthly net savings rate above 10%.");
if (input.bufferMonths < 1) recommendations.push("Build at least one month of shared expense buffer.");
if (!input.activeGoalCount) recommendations.push("Create one shared goal for the next household milestone.");
if (!input.jointAccountCount) recommendations.push("Mark at least one shared account as joint if both partners use it.");
if (!input.collaborativeTransactionCount) recommendations.push("Use ours attribution or split rules on shared expenses.");
if (input.unhealthyAccountCount > 0) recommendations.push("Reconnect or sync unhealthy shared accounts.");
return recommendations.slice(0, 4);
}
private healthRating(score: number) {
if (score >= 85) return "excellent";
if (score >= 70) return "strong";
if (score >= 50) return "building";
return "needs_attention";
}
private clampScore(value: number) {
return Math.round(Math.max(0, Math.min(value, 100)));
}
private serializeGoal(goal: any) { private serializeGoal(goal: any) {
const targetAmount = this.toNumber(goal.targetAmount); const targetAmount = this.toNumber(goal.targetAmount);
const currentAmount = this.toNumber(goal.currentAmount); const currentAmount = this.toNumber(goal.currentAmount);

View File

@ -159,6 +159,13 @@ describe("HouseholdsService", () => {
account: { ownershipType: "joint" }, account: { ownershipType: "joint" },
}, },
]); ]);
prisma.householdGoal.findMany.mockResolvedValue([
{
targetAmount: "10000",
currentAmount: "5000",
status: "active",
},
]);
const result = await service.getDashboard("user_1", "household_1"); const result = await service.getDashboard("user_1", "household_1");
@ -171,7 +178,32 @@ describe("HouseholdsService", () => {
expect(result.recentTransactions[0].category).toBe("Food"); expect(result.recentTransactions[0].category).toBe("Food");
expect(result.summary.monthlyIncome).toBe(2500); expect(result.summary.monthlyIncome).toBe(2500);
expect(result.summary.monthlyExpenses).toBe(42.5); expect(result.summary.monthlyExpenses).toBe(42.5);
expect(result.healthScore).toEqual(expect.objectContaining({
score: expect.any(Number),
rating: expect.any(String),
components: expect.objectContaining({
cashflow: expect.any(Number),
balanceBuffer: expect.any(Number),
goalProgress: expect.any(Number),
collaboration: expect.any(Number),
syncHealth: expect.any(Number),
}),
metrics: expect.objectContaining({
activeGoalCount: 1,
jointAccountCount: 1,
collaborativeTransactionCount: 2,
}),
}));
expect(result.healthScore.score).toBeGreaterThan(70);
expect(prisma.transactionRaw.findMany).toHaveBeenCalledTimes(2); expect(prisma.transactionRaw.findMany).toHaveBeenCalledTimes(2);
expect(prisma.householdGoal.findMany).toHaveBeenCalledWith({
where: { householdId: "household_1", status: { in: ["active", "completed"] } },
select: {
targetAmount: true,
currentAmount: true,
status: true,
},
});
}); });
it("allows owners to update household member roles", async () => { it("allows owners to update household member roles", async () => {