972 lines
34 KiB
TypeScript
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);
|
|
}
|
|
}
|