feat: add household health score
This commit is contained in:
parent
5794203c80
commit
aeb23530cd
@ -137,7 +137,7 @@ export class HouseholdsService {
|
||||
|
||||
const now = new Date();
|
||||
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({
|
||||
where: {
|
||||
account: { householdId, isActive: true },
|
||||
@ -171,11 +171,35 @@ export class HouseholdsService {
|
||||
},
|
||||
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 balanceByOwnership = this.buildOwnershipBreakdown(accounts);
|
||||
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 {
|
||||
household: {
|
||||
@ -194,12 +218,13 @@ export class HouseholdsService {
|
||||
summary: {
|
||||
memberCount: household.members.length,
|
||||
accountCount: accounts.length,
|
||||
totalBalance: this.roundCurrency(accounts.reduce((sum: number, account: any) => sum + this.toNumber(account.currentBalance), 0)),
|
||||
availableBalance: this.roundCurrency(accounts.reduce((sum: number, account: any) => sum + this.toNumber(account.availableBalance), 0)),
|
||||
monthlyIncome: cashflow.currentMonth.income,
|
||||
monthlyExpenses: cashflow.currentMonth.expenses,
|
||||
monthlyNet: cashflow.currentMonth.net,
|
||||
totalBalance,
|
||||
availableBalance,
|
||||
monthlyIncome,
|
||||
monthlyExpenses,
|
||||
monthlyNet,
|
||||
},
|
||||
healthScore,
|
||||
ownershipBreakdown: balanceByOwnership,
|
||||
accounts: accounts.map((account: any, index: number) => ({
|
||||
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) {
|
||||
const targetAmount = this.toNumber(goal.targetAmount);
|
||||
const currentAmount = this.toNumber(goal.currentAmount);
|
||||
|
||||
@ -159,6 +159,13 @@ describe("HouseholdsService", () => {
|
||||
account: { ownershipType: "joint" },
|
||||
},
|
||||
]);
|
||||
prisma.householdGoal.findMany.mockResolvedValue([
|
||||
{
|
||||
targetAmount: "10000",
|
||||
currentAmount: "5000",
|
||||
status: "active",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.getDashboard("user_1", "household_1");
|
||||
|
||||
@ -171,7 +178,32 @@ describe("HouseholdsService", () => {
|
||||
expect(result.recentTransactions[0].category).toBe("Food");
|
||||
expect(result.summary.monthlyIncome).toBe(2500);
|
||||
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.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 () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user