diff --git a/src/households/households.service.ts b/src/households/households.service.ts index ad219fa..5237992 100644 --- a/src/households/households.service.ts +++ b/src/households/households.service.ts @@ -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); diff --git a/test/households.service.spec.ts b/test/households.service.spec.ts index cc14703..d1ec622 100644 --- a/test/households.service.spec.ts +++ b/test/households.service.spec.ts @@ -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 () => {