import { BadRequestException, ForbiddenException, Injectable } from "@nestjs/common"; import * as crypto from "crypto"; import { Prisma } from "@prisma/client"; import { PrismaService } from "../prisma/prisma.service"; import { EmailService } from "../email/email.service"; import { AcceptHouseholdInviteDto } from "./dto/accept-household-invite.dto"; import { CreateHouseholdGoalDto } from "./dto/create-household-goal.dto"; import { CreateHouseholdDto } from "./dto/create-household.dto"; import { CreateHouseholdInviteDto } from "./dto/create-household-invite.dto"; import { FairSplitCalculatorDto, FairSplitMode } from "./dto/fair-split-calculator.dto"; import { UpdateHouseholdGoalDto } from "./dto/update-household-goal.dto"; import { UpdateHouseholdMemberDto } from "./dto/update-household-member.dto"; import { UpdateHouseholdPrivacyDto } from "./dto/update-household-privacy.dto"; type HouseholdPrivacyMode = { enabled: boolean; hideIndividualBalances: boolean; hideIndividualTransactions: boolean; }; @Injectable() export class HouseholdsService { constructor( private readonly prisma: PrismaService, private readonly emailService: EmailService, ) {} async listForUser(userId: string) { return this.prisma.household.findMany({ where: { members: { some: { userId, status: "active" }, }, }, include: { members: { include: { user: { select: { id: true, email: true, fullName: true }, }, }, orderBy: { joinedAt: "asc" }, }, }, orderBy: { updatedAt: "desc" }, }); } async create(userId: string, payload: CreateHouseholdDto) { const name = payload.name.trim(); if (!name) throw new BadRequestException("Household name is required."); const household = await this.prisma.household.create({ data: { name, createdByUserId: userId, metadata: (payload.metadata ?? {}) as Prisma.InputJsonValue, members: { create: { userId, role: "owner", status: "active", }, }, }, include: { members: { include: { user: { select: { id: true, email: true, fullName: true }, }, }, }, }, }); await this.prisma.auditLog.create({ data: { userId, action: "household.create", metadata: { householdId: household.id, role: "owner", }, }, }); return household; } async getForUser(userId: string, householdId: string) { await this.requireActiveMember(userId, householdId); return this.prisma.household.findFirst({ where: { id: householdId }, include: { members: { include: { user: { select: { id: true, email: true, fullName: true }, }, }, orderBy: { joinedAt: "asc" }, }, }, }); } async getDashboard(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: { institutionName: true, accountType: true, mask: true, currentBalance: true, availableBalance: true, isoCurrencyCode: true, ownerUserId: true, ownershipType: true, lastBalanceSync: true, syncStatus: true, createdAt: true, }, orderBy: { createdAt: "desc" }, }); const now = new Date(); const cashflowStart = new Date(now.getFullYear(), now.getMonth() - 5, 1); const [recentRows, cashflowRows, goalRows] = await Promise.all([ this.prisma.transactionRaw.findMany({ where: { account: { householdId, isActive: true }, }, include: { derived: true, account: { select: { institutionName: true, mask: true, ownerUserId: true, ownershipType: true, }, }, }, orderBy: { date: "desc" }, take: 25, }), 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", "completed"] } }, select: { targetAmount: true, currentAmount: true, status: true, }, }), ]); const visibleAccounts = this.applyAccountPrivacy(accounts, privacyMode); const visibleCashflowRows = this.applyTransactionPrivacy(cashflowRows, privacyMode); const visibleRecentRows = this.applyTransactionPrivacy(recentRows, privacyMode); const activeTransactions = visibleCashflowRows.filter((row: any) => !row.derived?.isHidden); const balanceByOwnership = this.buildOwnershipBreakdown(visibleAccounts); 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(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, goals: goalRows, totalBalance, availableBalance, monthlyIncome, monthlyExpenses, monthlyNet, }); return { household: { id: household.id, name: household.name, createdAt: household.createdAt, updatedAt: household.updatedAt, }, members: household.members.map((member: any) => ({ id: member.id, userId: member.userId, role: member.role, status: member.status, joinedAt: member.joinedAt, user: member.user, })), summary: { memberCount: household.members.length, accountCount: accounts.length, totalBalance, availableBalance, monthlyIncome, monthlyExpenses, monthlyNet, }, privacyMode, healthScore, ownershipBreakdown: balanceByOwnership, accounts: visibleAccounts.map((account: any, index: number) => ({ displayId: `household_account_${index + 1}`, institutionName: account.institutionName, accountType: account.accountType, mask: account.mask, currentBalance: this.roundCurrency(this.toNumber(account.currentBalance)), availableBalance: this.roundCurrency(this.toNumber(account.availableBalance)), isoCurrencyCode: account.isoCurrencyCode ?? "USD", ownerUserId: account.ownerUserId, ownershipType: account.ownershipType, lastBalanceSync: account.lastBalanceSync, syncStatus: account.syncStatus, })), cashflow: cashflow.months, recentTransactions: visibleRecentRows .filter((row: any) => !row.derived?.isHidden) .slice(0, 10) .map((row: any) => ({ date: row.date, description: row.description, amount: this.roundCurrency(this.toNumber(row.amount)), source: row.source, category: row.derived?.userCategory ?? "Uncategorized", attribution: row.derived?.attribution ?? this.defaultAttributionForOwnership(row.account?.ownershipType), split: this.resolveSplit(row.derived, this.toNumber(row.amount)), account: { institutionName: row.account?.institutionName, mask: row.account?.mask, ownerUserId: row.account?.ownerUserId, ownershipType: row.account?.ownershipType, }, })), }; } async updatePrivacyMode(userId: string, householdId: string, payload: UpdateHouseholdPrivacyDto) { await this.requireManager(userId, householdId); const household = await this.prisma.household.findFirst({ where: { id: householdId } }); if (!household) throw new BadRequestException("Household not found."); const existingMetadata = this.asRecord(household.metadata); const privacyMode: HouseholdPrivacyMode = { enabled: Boolean(payload.enabled), hideIndividualBalances: payload.hideIndividualBalances ?? true, hideIndividualTransactions: payload.hideIndividualTransactions ?? true, }; const updated = await this.prisma.household.update({ where: { id: householdId }, data: { metadata: { ...existingMetadata, privacyMode, } as Prisma.InputJsonValue, }, }); await this.prisma.auditLog.create({ data: { userId, action: "household.privacy.update", metadata: { householdId, privacyMode, }, }, }); return { householdId: updated.id, privacyMode, }; } async listMembers(userId: string, householdId: string) { await this.requireActiveMember(userId, householdId); return this.prisma.householdMember.findMany({ where: { householdId }, include: { user: { select: { id: true, email: true, fullName: true }, }, }, orderBy: [{ role: "asc" }, { joinedAt: "asc" }], }); } async calculateFairSplit(userId: string, householdId: string, payload: FairSplitCalculatorDto) { await this.requireActiveMember(userId, householdId); const expenseAmount = this.positiveMoney(payload.expenseAmount, "Expense amount"); const mineMonthlyIncome = this.nonNegativeMoney(payload.mineMonthlyIncome ?? 0, "Mine monthly income"); const yoursMonthlyIncome = this.nonNegativeMoney(payload.yoursMonthlyIncome ?? 0, "Yours monthly income"); const splitMode = this.normalizeFairSplitMode(payload.splitMode); const totalIncome = mineMonthlyIncome + yoursMonthlyIncome; let minePercent: number; let rationale: string; if (splitMode === "equal") { minePercent = 50; rationale = "Equal split divides the expense 50/50."; } else if (splitMode === "custom") { minePercent = this.percent(payload.customMinePercent, "Custom mine percent"); rationale = "Custom split uses the percentage entered by the household."; } else if (totalIncome > 0) { minePercent = (mineMonthlyIncome / totalIncome) * 100; rationale = "Income-weighted split divides the expense in proportion to monthly income."; } else { minePercent = 50; rationale = "Income-weighted split falls back to 50/50 when both incomes are zero."; } const roundedMinePercent = this.roundCurrency(minePercent); const yoursPercent = this.roundCurrency(100 - roundedMinePercent); const mineAmount = this.roundCurrency(expenseAmount * (roundedMinePercent / 100)); const yoursAmount = this.roundCurrency(expenseAmount - mineAmount); return { householdId, expenseAmount, splitMode, mineMonthlyIncome, yoursMonthlyIncome, totalIncome: this.roundCurrency(totalIncome), incomeShares: { mine: totalIncome > 0 ? this.roundCurrency((mineMonthlyIncome / totalIncome) * 100) : 50, yours: totalIncome > 0 ? this.roundCurrency((yoursMonthlyIncome / totalIncome) * 100) : 50, }, split: { minePercent: roundedMinePercent, yoursPercent, mineAmount, yoursAmount, }, transactionDefaults: { attribution: "ours", splitMode: splitMode === "equal" ? "equal" : "custom", splitMinePercent: roundedMinePercent, splitYoursPercent: yoursPercent, }, rationale, }; } async listGoals(userId: string, householdId: string) { await this.requireActiveMember(userId, householdId); const goals = await this.prisma.householdGoal.findMany({ where: { householdId }, include: { createdBy: { select: { id: true, email: true, fullName: true }, }, }, orderBy: [{ status: "asc" }, { priority: "desc" }, { targetDate: "asc" }, { createdAt: "desc" }], }); return goals.map((goal: any) => this.serializeGoal(goal)); } async createGoal(userId: string, householdId: string, payload: CreateHouseholdGoalDto) { await this.requireManager(userId, householdId); const targetAmount = this.positiveMoney(payload.targetAmount, "Target amount"); const currentAmount = this.nonNegativeMoney(payload.currentAmount ?? 0, "Current amount"); if (currentAmount > targetAmount) { throw new BadRequestException("Current amount cannot exceed target amount."); } const goal = await this.prisma.householdGoal.create({ data: { householdId, createdByUserId: userId, name: this.requiredTrim(payload.name, "Goal name"), description: payload.description?.trim() || null, targetAmount, currentAmount, isoCurrencyCode: (payload.isoCurrencyCode ?? "USD").toUpperCase(), targetDate: payload.targetDate ? new Date(payload.targetDate) : null, priority: payload.priority ?? "medium", status: "active", metadata: (payload.metadata ?? {}) as Prisma.InputJsonValue, }, include: { createdBy: { select: { id: true, email: true, fullName: true }, }, }, }); await this.prisma.auditLog.create({ data: { userId, action: "household.goal.create", metadata: { householdId, goalId: goal.id, targetAmount, }, }, }); return this.serializeGoal(goal); } async updateGoal(userId: string, householdId: string, goalId: string, payload: UpdateHouseholdGoalDto) { if (!Object.keys(payload).length) { throw new BadRequestException("At least one goal field is required."); } await this.requireManager(userId, householdId); const existing = await this.prisma.householdGoal.findFirst({ where: { id: goalId, householdId }, }); if (!existing) throw new BadRequestException("Household goal not found."); const targetAmount = payload.targetAmount !== undefined ? this.positiveMoney(payload.targetAmount, "Target amount") : this.toNumber((existing as any).targetAmount); const currentAmount = payload.currentAmount !== undefined ? this.nonNegativeMoney(payload.currentAmount, "Current amount") : this.toNumber((existing as any).currentAmount); if (currentAmount > targetAmount) { throw new BadRequestException("Current amount cannot exceed target amount."); } const updated = await this.prisma.householdGoal.update({ where: { id: goalId }, data: { ...(payload.name !== undefined && { name: this.requiredTrim(payload.name, "Goal name") }), ...(payload.description !== undefined && { description: payload.description?.trim() || null }), ...(payload.targetAmount !== undefined && { targetAmount }), ...(payload.currentAmount !== undefined && { currentAmount }), ...(payload.isoCurrencyCode !== undefined && { isoCurrencyCode: payload.isoCurrencyCode.toUpperCase() }), ...(payload.targetDate !== undefined && { targetDate: payload.targetDate ? new Date(payload.targetDate) : null }), ...(payload.priority !== undefined && { priority: payload.priority }), ...(payload.status !== undefined && { status: payload.status }), ...(payload.metadata !== undefined && { metadata: payload.metadata as Prisma.InputJsonValue }), }, include: { createdBy: { select: { id: true, email: true, fullName: true }, }, }, }); await this.prisma.auditLog.create({ data: { userId, action: "household.goal.update", metadata: { householdId, goalId, status: updated.status, currentAmount: this.toNumber((updated as any).currentAmount), targetAmount: this.toNumber((updated as any).targetAmount), }, }, }); return this.serializeGoal(updated); } async updateMember(userId: string, householdId: string, memberId: string, payload: UpdateHouseholdMemberDto) { if (payload.role === undefined && payload.status === undefined) { throw new BadRequestException("Role or status is required."); } await this.requireManager(userId, householdId); const target = await this.prisma.householdMember.findFirst({ where: { id: memberId, householdId }, }); if (!target) throw new BadRequestException("Household member not found."); if (target.role === "owner" && (payload.role && payload.role !== "owner" || payload.status && payload.status !== "active")) { const ownerCount = await this.prisma.householdMember.count({ where: { householdId, role: "owner", status: "active" }, }); if (ownerCount <= 1) { throw new BadRequestException("A household must keep at least one active owner."); } } const updated = await this.prisma.householdMember.update({ where: { id: memberId }, data: { ...(payload.role !== undefined && { role: payload.role }), ...(payload.status !== undefined && { status: payload.status }), }, include: { user: { select: { id: true, email: true, fullName: true }, }, }, }); await this.prisma.auditLog.create({ data: { userId, action: "household.member.update", metadata: { householdId, memberId, role: updated.role, status: updated.status, }, }, }); return updated; } async listInvites(userId: string, householdId: string) { await this.requireManager(userId, householdId); return this.prisma.householdInvite.findMany({ where: { householdId }, select: { id: true, email: true, role: true, status: true, expiresAt: true, acceptedAt: true, acceptedById: true, createdAt: true, }, orderBy: { createdAt: "desc" }, }); } async invite(userId: string, householdId: string, payload: CreateHouseholdInviteDto) { await this.requireManager(userId, householdId); const household = await this.prisma.household.findFirst({ where: { id: householdId } }); if (!household) throw new BadRequestException("Household not found."); const inviter = await this.prisma.user.findUnique({ where: { id: userId }, select: { email: true, fullName: true } }); const email = payload.email.trim().toLowerCase(); const role = payload.role ?? "member"; if (role === "owner") { throw new BadRequestException("Invite collaborators as admin, member, viewer, accountant, or advisor. Promote owners after acceptance."); } const token = crypto.randomBytes(32).toString("base64url"); const tokenHash = this.hashInviteToken(token); const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); const invite = await this.prisma.householdInvite.create({ data: { householdId, invitedById: userId, email, role, tokenHash, status: "pending", expiresAt, }, select: { id: true, email: true, role: true, status: true, expiresAt: true, createdAt: true, }, }); await this.emailService.sendHouseholdInviteEmail( email, household.name, inviter?.fullName ?? inviter?.email ?? "A LedgerOne user", token, ); await this.prisma.auditLog.create({ data: { userId, action: "household.invite.create", metadata: { householdId, inviteId: invite.id, email, role, }, }, }); return invite; } async acceptInvite(userId: string, payload: AcceptHouseholdInviteDto) { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { email: true } }); if (!user) throw new BadRequestException("User not found."); const tokenHash = this.hashInviteToken(payload.token); const invite = await this.prisma.householdInvite.findUnique({ where: { tokenHash }, include: { household: true }, }); if (!invite || invite.status !== "pending") throw new BadRequestException("Invite is invalid or already used."); if (invite.expiresAt < new Date()) throw new BadRequestException("Invite has expired."); if (invite.email.toLowerCase() !== user.email.toLowerCase()) { throw new ForbiddenException("This invite was sent to a different email address."); } const member = await this.prisma.householdMember.upsert({ where: { householdId_userId: { householdId: invite.householdId, userId } }, create: { householdId: invite.householdId, userId, role: invite.role, status: "active", }, update: { role: invite.role, status: "active", joinedAt: new Date(), }, include: { user: { select: { id: true, email: true, fullName: true }, }, }, }); await this.prisma.householdInvite.update({ where: { id: invite.id }, data: { status: "accepted", acceptedAt: new Date(), acceptedById: userId, }, }); await this.prisma.auditLog.create({ data: { userId, action: "household.invite.accept", metadata: { householdId: invite.householdId, inviteId: invite.id, role: invite.role, }, }, }); return { household: invite.household, member, }; } private async requireActiveMember(userId: string, householdId: string) { const membership = await this.prisma.householdMember.findFirst({ where: { householdId, userId, status: "active" }, }); if (!membership) throw new BadRequestException("Household not found."); return membership; } private async requireManager(userId: string, householdId: string) { const membership = await this.requireActiveMember(userId, householdId); if (!["owner", "admin"].includes(membership.role)) { throw new ForbiddenException("Only household owners and admins can manage members."); } return membership; } private hashInviteToken(token: string) { return crypto.createHash("sha256").update(token).digest("hex"); } private getPrivacyMode(metadata: unknown): HouseholdPrivacyMode { const value = this.asRecord(metadata).privacyMode; const mode = this.asRecord(value); return { enabled: Boolean(mode.enabled), hideIndividualBalances: mode.hideIndividualBalances !== false, hideIndividualTransactions: mode.hideIndividualTransactions !== false, }; } private applyAccountPrivacy(accounts: any[], privacyMode: HouseholdPrivacyMode) { if (!privacyMode.enabled || !privacyMode.hideIndividualBalances) return accounts; return accounts.filter((account) => account.ownershipType === "joint"); } private applyTransactionPrivacy(rows: any[], privacyMode: HouseholdPrivacyMode) { if (!privacyMode.enabled || !privacyMode.hideIndividualTransactions) return rows; return rows.filter((row) => row.account?.ownershipType === "joint"); } private asRecord(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; return value as Record; } private normalizeFairSplitMode(mode?: string): FairSplitMode { if (!mode) return "income_weighted"; if (["equal", "income_weighted", "custom"].includes(mode)) return mode as FairSplitMode; throw new BadRequestException("Fair split mode must be equal, income_weighted, or custom."); } private percent(value: unknown, label: string) { const percent = Number(value); if (!Number.isFinite(percent) || percent < 0 || percent > 100) { throw new BadRequestException(`${label} must be between 0 and 100.`); } return percent; } private buildOwnershipBreakdown(accounts: any[]) { const initial = { mine: { accountCount: 0, balance: 0 }, theirs: { accountCount: 0, balance: 0 }, joint: { accountCount: 0, balance: 0 }, }; for (const account of accounts) { const key = (["mine", "theirs", "joint"].includes(account.ownershipType) ? account.ownershipType : "mine") as "mine" | "theirs" | "joint"; initial[key].accountCount += 1; initial[key].balance += this.toNumber(account.currentBalance); } return { mine: { ...initial.mine, balance: this.roundCurrency(initial.mine.balance) }, theirs: { ...initial.theirs, balance: this.roundCurrency(initial.theirs.balance) }, joint: { ...initial.joint, balance: this.roundCurrency(initial.joint.balance) }, }; } private buildCashflow(rows: any[], start: Date, end: Date) { const buckets = new Map(); for (let date = new Date(start.getFullYear(), start.getMonth(), 1); date <= end; date.setMonth(date.getMonth() + 1)) { const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; buckets.set(key, { month: key, income: 0, expenses: 0, net: 0, transactionCount: 0 }); } for (const row of rows) { const date = new Date(row.date); const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; const bucket = buckets.get(key); if (!bucket) continue; const amount = this.toNumber(row.amount); if (amount < 0) bucket.income += Math.abs(amount); else bucket.expenses += amount; bucket.transactionCount += 1; bucket.net = bucket.income - bucket.expenses; } const months = Array.from(buckets.values()).map((bucket) => ({ ...bucket, income: this.roundCurrency(bucket.income), expenses: this.roundCurrency(bucket.expenses), net: this.roundCurrency(bucket.net), })); const currentKey = `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, "0")}`; const currentMonth = months.find((bucket) => bucket.month === currentKey) ?? { month: currentKey, income: 0, expenses: 0, net: 0, transactionCount: 0 }; return { months, currentMonth }; } private toNumber(value: unknown) { if (value === null || value === undefined) return 0; return Number(value); } private roundCurrency(value: number) { return Math.round((value + Number.EPSILON) * 100) / 100; } private defaultAttributionForOwnership(ownershipType?: string | null) { if (ownershipType === "joint") return "ours"; if (ownershipType === "theirs") return "yours"; return "mine"; } private resolveSplit( derived: { splitMode?: string | null; splitMinePercent?: unknown; splitYoursPercent?: unknown } | null | undefined, amount: number, ) { const mode = derived?.splitMode && ["none", "equal", "custom"].includes(derived.splitMode) ? derived.splitMode : "none"; const minePercent = mode === "none" ? 100 : this.toNumber(derived?.splitMinePercent ?? (mode === "equal" ? 50 : 0)); const yoursPercent = mode === "none" ? 0 : this.toNumber(derived?.splitYoursPercent ?? (mode === "equal" ? 50 : 0)); return { mode, minePercent: this.roundCurrency(minePercent), yoursPercent: this.roundCurrency(yoursPercent), mineAmount: this.roundCurrency(amount * (minePercent / 100)), yoursAmount: this.roundCurrency(amount * (yoursPercent / 100)), }; } 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); return { id: goal.id, householdId: goal.householdId, name: goal.name, description: goal.description, targetAmount: this.roundCurrency(targetAmount), currentAmount: this.roundCurrency(currentAmount), remainingAmount: this.roundCurrency(Math.max(targetAmount - currentAmount, 0)), progressPercent: targetAmount > 0 ? this.roundCurrency(Math.min((currentAmount / targetAmount) * 100, 100)) : 0, isoCurrencyCode: goal.isoCurrencyCode, targetDate: goal.targetDate, priority: goal.priority, status: goal.status, metadata: goal.metadata ?? {}, createdAt: goal.createdAt, updatedAt: goal.updatedAt, createdBy: goal.createdBy, }; } private requiredTrim(value: string, label: string) { const trimmed = value.trim(); if (!trimmed) throw new BadRequestException(`${label} is required.`); return trimmed; } private positiveMoney(value: number, label: string) { const amount = Number(value); if (!Number.isFinite(amount) || amount <= 0) { throw new BadRequestException(`${label} must be greater than zero.`); } return this.roundCurrency(amount); } private nonNegativeMoney(value: number, label: string) { const amount = Number(value); if (!Number.isFinite(amount) || amount < 0) { throw new BadRequestException(`${label} must be zero or greater.`); } return this.roundCurrency(amount); } }