Add household money date prompts

This commit is contained in:
MOHAN 2026-07-16 23:31:28 +05:30
parent c0118b6986
commit 00fb77ec1a
3 changed files with 319 additions and 0 deletions

View File

@ -58,6 +58,11 @@ export class HouseholdsController {
return ok(await this.householdsService.calculateFairSplit(userId, id, payload)); 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") @Get(":id/goals")
async goals(@CurrentUser() userId: string, @Param("id") id: string) { async goals(@CurrentUser() userId: string, @Param("id") id: string) {
return ok(await this.householdsService.listGoals(userId, id)); return ok(await this.householdsService.listGoals(userId, id));

View File

@ -18,6 +18,15 @@ type HouseholdPrivacyMode = {
hideIndividualTransactions: boolean; hideIndividualTransactions: boolean;
}; };
type MoneyDatePrompt = {
id: string;
topic: string;
question: string;
why: string;
actionLabel: string;
priority: "high" | "medium" | "low";
};
@Injectable() @Injectable()
export class HouseholdsService { export class HouseholdsService {
constructor( 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) { async listGoals(userId: string, householdId: string) {
await this.requireActiveMember(userId, householdId); await this.requireActiveMember(userId, householdId);
const goals = await this.prisma.householdGoal.findMany({ const goals = await this.prisma.householdGoal.findMany({
@ -913,6 +1020,158 @@ export class HouseholdsService {
return recommendations.slice(0, 4); 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<string, number>();
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) { private healthRating(score: number) {
if (score >= 85) return "excellent"; if (score >= 85) return "excellent";
if (score >= 70) return "strong"; if (score >= 70) return "strong";

View File

@ -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 () => { it("allows owners to update household member roles", async () => {
const { prisma, service } = createService(); const { prisma, service } = createService();
prisma.householdMember.findFirst prisma.householdMember.findFirst