From c0118b69863fd17d137f86b9ee99e07f641ec427 Mon Sep 17 00:00:00 2001 From: MOHAN Date: Thu, 16 Jul 2026 23:22:30 +0530 Subject: [PATCH] Add household privacy mode --- .../dto/update-household-privacy.dto.ts | 5 ++ src/households/households.controller.ts | 10 +++ src/households/households.service.ts | 89 +++++++++++++++++-- test/households.service.spec.ts | 35 ++++++++ test/utils/mock-prisma.ts | 3 +- 5 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 src/households/dto/update-household-privacy.dto.ts diff --git a/src/households/dto/update-household-privacy.dto.ts b/src/households/dto/update-household-privacy.dto.ts new file mode 100644 index 0000000..3e8e0ed --- /dev/null +++ b/src/households/dto/update-household-privacy.dto.ts @@ -0,0 +1,5 @@ +export class UpdateHouseholdPrivacyDto { + enabled!: boolean; + hideIndividualBalances?: boolean; + hideIndividualTransactions?: boolean; +} diff --git a/src/households/households.controller.ts b/src/households/households.controller.ts index 6ea481c..d4bda19 100644 --- a/src/households/households.controller.ts +++ b/src/households/households.controller.ts @@ -8,6 +8,7 @@ import { CreateHouseholdInviteDto } from "./dto/create-household-invite.dto"; import { FairSplitCalculatorDto } 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"; import { HouseholdsService } from "./households.service"; @Controller("households") @@ -34,6 +35,15 @@ export class HouseholdsController { return ok(await this.householdsService.getForUser(userId, id)); } + @Patch(":id/privacy") + async updatePrivacy( + @CurrentUser() userId: string, + @Param("id") id: string, + @Body() payload: UpdateHouseholdPrivacyDto, + ) { + return ok(await this.householdsService.updatePrivacyMode(userId, id, payload)); + } + @Get(":id/members") async members(@CurrentUser() userId: string, @Param("id") id: string) { return ok(await this.householdsService.listMembers(userId, id)); diff --git a/src/households/households.service.ts b/src/households/households.service.ts index ee02e86..91ef67f 100644 --- a/src/households/households.service.ts +++ b/src/households/households.service.ts @@ -10,6 +10,13 @@ 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 { @@ -117,6 +124,7 @@ export class HouseholdsService { }, }); 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 }, @@ -182,17 +190,20 @@ export class HouseholdsService { }), ]); - const activeTransactions = cashflowRows.filter((row: any) => !row.derived?.isHidden); - const balanceByOwnership = this.buildOwnershipBreakdown(accounts); + 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(accounts.reduce((sum: number, account: any) => sum + this.toNumber(account.currentBalance), 0)); - const availableBalance = this.roundCurrency(accounts.reduce((sum: number, account: any) => sum + this.toNumber(account.availableBalance), 0)); + 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, + accounts: visibleAccounts, activeTransactions, goals: goalRows, totalBalance, @@ -226,9 +237,10 @@ export class HouseholdsService { monthlyExpenses, monthlyNet, }, + privacyMode, healthScore, ownershipBreakdown: balanceByOwnership, - accounts: accounts.map((account: any, index: number) => ({ + accounts: visibleAccounts.map((account: any, index: number) => ({ displayId: `household_account_${index + 1}`, institutionName: account.institutionName, accountType: account.accountType, @@ -242,7 +254,7 @@ export class HouseholdsService { syncStatus: account.syncStatus, })), cashflow: cashflow.months, - recentTransactions: recentRows + recentTransactions: visibleRecentRows .filter((row: any) => !row.derived?.isHidden) .slice(0, 10) .map((row: any) => ({ @@ -263,6 +275,44 @@ export class HouseholdsService { }; } + 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({ @@ -653,6 +703,31 @@ export class HouseholdsService { 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; diff --git a/test/households.service.spec.ts b/test/households.service.spec.ts index e20f17e..e893a38 100644 --- a/test/households.service.spec.ts +++ b/test/households.service.spec.ts @@ -113,6 +113,41 @@ describe("HouseholdsService", () => { expect(prisma.household.findFirst).not.toHaveBeenCalled(); }); + it("updates household privacy mode for managers", async () => { + const { prisma, service } = createService(); + prisma.householdMember.findFirst.mockResolvedValue({ id: "owner_member", userId: "user_1", role: "owner", status: "active" }); + prisma.household.findFirst.mockResolvedValue({ id: "household_1", metadata: { type: "couple" } }); + prisma.household.update.mockResolvedValue({ id: "household_1" }); + prisma.auditLog.create.mockResolvedValue({}); + + const result = await service.updatePrivacyMode("user_1", "household_1", { + enabled: true, + hideIndividualBalances: true, + hideIndividualTransactions: true, + }); + + expect(result.privacyMode.enabled).toBe(true); + expect(prisma.household.update).toHaveBeenCalledWith({ + where: { id: "household_1" }, + data: { + metadata: { + type: "couple", + privacyMode: { + enabled: true, + hideIndividualBalances: true, + hideIndividualTransactions: true, + }, + }, + }, + }); + expect(prisma.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: "household.privacy.update", + metadata: expect.objectContaining({ householdId: "household_1" }), + }), + }); + }); + it("builds a shared household financial dashboard for active members", async () => { const { prisma, service } = createService(); prisma.householdMember.findFirst.mockResolvedValue({ id: "member_1", userId: "user_1", role: "owner", status: "active" }); diff --git a/test/utils/mock-prisma.ts b/test/utils/mock-prisma.ts index c1282ee..f360d96 100644 --- a/test/utils/mock-prisma.ts +++ b/test/utils/mock-prisma.ts @@ -8,7 +8,8 @@ export const createPrismaMock = () => ({ household: { create: jest.fn(), findFirst: jest.fn(), - findMany: jest.fn() + findMany: jest.fn(), + update: jest.fn() }, householdMember: { count: jest.fn(),