ledgerone_backend/src/households/households.service.ts
2026-07-16 23:22:30 +05:30

972 lines
34 KiB
TypeScript

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<string, any> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return value as Record<string, any>;
}
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<string, { month: string; income: number; expenses: number; net: number; transactionCount: number }>();
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);
}
}