Add household money date prompts
This commit is contained in:
parent
c0118b6986
commit
00fb77ec1a
@ -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));
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user