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