diff --git a/src/households/households.controller.ts b/src/households/households.controller.ts index d4bda19..7770370 100644 --- a/src/households/households.controller.ts +++ b/src/households/households.controller.ts @@ -58,6 +58,11 @@ export class HouseholdsController { return ok(await this.householdsService.calculateFairSplit(userId, id, payload)); } + @Get(":id/money-date-prompts") + async moneyDatePrompts(@CurrentUser() userId: string, @Param("id") id: string) { + return ok(await this.householdsService.listMoneyDatePrompts(userId, id)); + } + @Get(":id/goals") async goals(@CurrentUser() userId: string, @Param("id") id: string) { return ok(await this.householdsService.listGoals(userId, id)); diff --git a/src/households/households.service.ts b/src/households/households.service.ts index 91ef67f..107187d 100644 --- a/src/households/households.service.ts +++ b/src/households/households.service.ts @@ -18,6 +18,15 @@ type HouseholdPrivacyMode = { hideIndividualTransactions: boolean; }; +type MoneyDatePrompt = { + id: string; + topic: string; + question: string; + why: string; + actionLabel: string; + priority: "high" | "medium" | "low"; +}; + @Injectable() export class HouseholdsService { constructor( @@ -383,6 +392,104 @@ export class HouseholdsService { }; } + async listMoneyDatePrompts(userId: string, householdId: string) { + await this.requireActiveMember(userId, householdId); + + const household = await this.prisma.household.findFirst({ + where: { id: householdId }, + include: { + members: { + where: { status: "active" }, + include: { + user: { + select: { id: true, email: true, fullName: true }, + }, + }, + orderBy: { joinedAt: "asc" }, + }, + }, + }); + if (!household) throw new BadRequestException("Household not found."); + + const privacyMode = this.getPrivacyMode(household.metadata); + const accounts = await this.prisma.account.findMany({ + where: { householdId, isActive: true }, + select: { + currentBalance: true, + availableBalance: true, + ownershipType: true, + syncStatus: true, + }, + }); + + const now = new Date(); + const cashflowStart = new Date(now.getFullYear(), now.getMonth() - 2, 1); + const [cashflowRows, goals] = await Promise.all([ + this.prisma.transactionRaw.findMany({ + where: { + date: { gte: cashflowStart, lte: now }, + account: { householdId, isActive: true }, + }, + include: { + derived: true, + account: { + select: { + ownershipType: true, + }, + }, + }, + orderBy: { date: "asc" }, + }), + this.prisma.householdGoal.findMany({ + where: { householdId, status: { in: ["active", "paused", "completed"] } }, + select: { + name: true, + targetAmount: true, + currentAmount: true, + priority: true, + status: true, + targetDate: true, + }, + orderBy: [{ status: "asc" }, { priority: "desc" }, { targetDate: "asc" }], + }), + ]); + + const visibleAccounts = this.applyAccountPrivacy(accounts, privacyMode); + const visibleRows = this.applyTransactionPrivacy(cashflowRows, privacyMode); + const activeRows = visibleRows.filter((row: any) => !row.derived?.isHidden); + const cashflow = this.buildCashflow(activeRows, cashflowStart, now); + const totalBalance = this.roundCurrency(visibleAccounts.reduce((sum: number, account: any) => sum + this.toNumber(account.currentBalance), 0)); + const availableBalance = this.roundCurrency(visibleAccounts.reduce((sum: number, account: any) => sum + this.toNumber(account.availableBalance), 0)); + const healthScore = this.buildCouplesHealthScore({ + memberCount: household.members.length, + accounts: visibleAccounts, + activeTransactions: activeRows, + goals, + totalBalance, + availableBalance, + monthlyIncome: cashflow.currentMonth.income, + monthlyExpenses: cashflow.currentMonth.expenses, + monthlyNet: cashflow.currentMonth.net, + }); + + const prompts = this.buildMoneyDatePrompts({ + memberCount: household.members.length, + privacyMode, + accounts: visibleAccounts, + cashflow, + goals, + healthScore, + activeRows, + }); + + return { + householdId, + generatedAt: now.toISOString(), + cadenceSuggestion: prompts.some((prompt) => prompt.priority === "high") ? "weekly" : "monthly", + prompts, + }; + } + async listGoals(userId: string, householdId: string) { await this.requireActiveMember(userId, householdId); const goals = await this.prisma.householdGoal.findMany({ @@ -913,6 +1020,158 @@ export class HouseholdsService { return recommendations.slice(0, 4); } + private buildMoneyDatePrompts(input: { + memberCount: number; + privacyMode: HouseholdPrivacyMode; + accounts: any[]; + cashflow: { + currentMonth: { income: number; expenses: number; net: number; transactionCount: number }; + }; + goals: any[]; + healthScore: { + score: number; + recommendations: string[]; + }; + activeRows: any[]; + }) { + const prompts: MoneyDatePrompt[] = []; + const currentMonth = input.cashflow.currentMonth; + const activeGoals = input.goals.filter((goal) => goal.status === "active"); + const lowestProgressGoal = activeGoals + .map((goal) => { + const targetAmount = this.toNumber(goal.targetAmount); + const currentAmount = this.toNumber(goal.currentAmount); + return { + name: goal.name, + progress: targetAmount > 0 ? currentAmount / targetAmount : 0, + remainingAmount: Math.max(targetAmount - currentAmount, 0), + }; + }) + .sort((a, b) => a.progress - b.progress)[0]; + + if (currentMonth.net < 0) { + prompts.push({ + id: "cashflow-reset", + topic: "Cashflow", + question: `This month is ${this.formatSignedMoney(currentMonth.net)} net. Which one expense category should we adjust before the next check-in?`, + why: "A negative shared cashflow month needs a specific agreement, not a vague budget promise.", + actionLabel: "Pick one spending adjustment", + priority: "high", + }); + } else { + prompts.push({ + id: "cashflow-allocation", + topic: "Cashflow", + question: `This month is ${this.formatSignedMoney(currentMonth.net)} net. How much of that should move to shared goals versus staying as buffer?`, + why: "Positive cashflow is easiest to allocate while both partners can see the same number.", + actionLabel: "Agree on allocation", + priority: currentMonth.net > 0 ? "medium" : "low", + }); + } + + if (lowestProgressGoal) { + prompts.push({ + id: "goal-progress", + topic: "Goals", + question: `What is the next concrete contribution for ${lowestProgressGoal.name}, with ${this.formatMoneyValue(lowestProgressGoal.remainingAmount)} still remaining?`, + why: "A shared goal with low progress benefits from one clear next contribution.", + actionLabel: "Set next contribution", + priority: lowestProgressGoal.progress < 0.25 ? "high" : "medium", + }); + } else { + prompts.push({ + id: "first-shared-goal", + topic: "Goals", + question: "What is the one shared milestone we want LedgerOne to track before our next money date?", + why: "A household without an active shared goal has no visible target for joint progress.", + actionLabel: "Create shared goal", + priority: "medium", + }); + } + + const jointAccountCount = input.accounts.filter((account) => account.ownershipType === "joint").length; + if (input.memberCount > 1 && jointAccountCount === 0) { + prompts.push({ + id: "joint-account-boundary", + topic: "Account ownership", + question: "Which account should be treated as joint for shared bills, and which accounts should remain mine/theirs?", + why: "Clear ownership labels make dashboards, splits, and privacy mode easier to trust.", + actionLabel: "Review account ownership", + priority: "medium", + }); + } + + if (!input.privacyMode.enabled) { + prompts.push({ + id: "privacy-boundaries", + topic: "Privacy", + question: "Do we want the household dashboard to show only joint money, or are we comfortable showing individual balances too?", + why: "Privacy expectations should be explicit before partners rely on the shared dashboard.", + actionLabel: "Choose privacy mode", + priority: "low", + }); + } + + const unhealthyAccountCount = input.accounts.filter((account) => !["idle", "synced", "ok"].includes(account.syncStatus)).length; + if (unhealthyAccountCount > 0) { + prompts.push({ + id: "sync-health", + topic: "Account sync", + question: `${unhealthyAccountCount} shared account${unhealthyAccountCount === 1 ? "" : "s"} need attention. Who will reconnect or clean them up before the next review?`, + why: "Money-date decisions are only useful when the shared account data is current.", + actionLabel: "Assign sync owner", + priority: "high", + }); + } + + const topCategory = this.topExpenseCategory(input.activeRows); + if (topCategory) { + prompts.push({ + id: "spending-pattern", + topic: "Spending pattern", + question: `${topCategory.category} is the largest recent shared spending area at ${this.formatMoneyValue(topCategory.amount)}. Is that expected or should we set a rule?`, + why: "Reviewing one concrete category keeps the conversation focused.", + actionLabel: "Review category rule", + priority: "medium", + }); + } + + for (const recommendation of input.healthScore.recommendations) { + if (prompts.length >= 6) break; + prompts.push({ + id: `health-${prompts.length + 1}`, + topic: "Health score", + question: recommendation, + why: `Current couples health score is ${input.healthScore.score}, so this is one of the next best moves.`, + actionLabel: "Discuss next move", + priority: input.healthScore.score < 50 ? "high" : "medium", + }); + } + + return prompts.slice(0, 6); + } + + private topExpenseCategory(rows: any[]) { + const totals = new Map(); + for (const row of rows) { + const amount = this.toNumber(row.amount); + if (amount <= 0 || row.derived?.isHidden) continue; + const category = row.derived?.userCategory ?? "Uncategorized"; + totals.set(category, (totals.get(category) ?? 0) + amount); + } + const [category, amount] = Array.from(totals.entries()).sort((a, b) => b[1] - a[1])[0] ?? []; + return category ? { category, amount: this.roundCurrency(amount) } : null; + } + + private formatMoneyValue(value: number) { + return `$${Math.abs(this.roundCurrency(value)).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + } + + private formatSignedMoney(value: number) { + const prefix = value < 0 ? "-" : ""; + return `${prefix}${this.formatMoneyValue(value)}`; + } + private healthRating(score: number) { if (score >= 85) return "excellent"; if (score >= 70) return "strong"; diff --git a/test/households.service.spec.ts b/test/households.service.spec.ts index e893a38..edb9e52 100644 --- a/test/households.service.spec.ts +++ b/test/households.service.spec.ts @@ -277,6 +277,61 @@ describe("HouseholdsService", () => { }); }); + it("generates money date prompts from household cashflow and goals", async () => { + const { prisma, service } = createService(); + prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", role: "member", status: "active" }); + prisma.household.findFirst.mockResolvedValue({ + id: "household_1", + metadata: {}, + members: [ + { id: "member_1", userId: "user_1", role: "owner", status: "active", joinedAt: new Date(), user: { id: "user_1", email: "owner@example.com" } }, + { id: "member_2", userId: "user_2", role: "member", status: "active", joinedAt: new Date(), user: { id: "user_2", email: "partner@example.com" } }, + ], + }); + prisma.account.findMany.mockResolvedValue([ + { currentBalance: "500", availableBalance: "400", ownershipType: "joint", syncStatus: "idle" }, + ]); + prisma.transactionRaw.findMany.mockResolvedValue([ + { + date: new Date(), + amount: "850", + derived: { userCategory: "Dining", isHidden: false }, + account: { ownershipType: "joint" }, + }, + { + date: new Date(), + amount: "-500", + derived: { userCategory: "Income", isHidden: false }, + account: { ownershipType: "joint" }, + }, + ]); + prisma.householdGoal.findMany.mockResolvedValue([ + { + name: "Emergency Fund", + targetAmount: "10000", + currentAmount: "1000", + priority: "high", + status: "active", + targetDate: new Date("2026-12-31T00:00:00.000Z"), + }, + ]); + + const result = await service.listMoneyDatePrompts("user_1", "household_1"); + + expect(result.cadenceSuggestion).toBe("weekly"); + expect(result.prompts).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: "cashflow-reset", priority: "high" }), + expect.objectContaining({ id: "goal-progress", topic: "Goals" }), + expect.objectContaining({ id: "spending-pattern", topic: "Spending pattern" }), + ])); + expect(result.prompts[0]).not.toHaveProperty("transactionId"); + expect(prisma.transactionRaw.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + account: { householdId: "household_1", isActive: true }, + }), + })); + }); + it("allows owners to update household member roles", async () => { const { prisma, service } = createService(); prisma.householdMember.findFirst