Add planning budgets goals investments and recurring detection

This commit is contained in:
MOHAN 2026-07-17 23:19:36 +05:30
parent fabe6e341b
commit 49acee6b6a
8 changed files with 920 additions and 0 deletions

View File

@ -0,0 +1,107 @@
CREATE TABLE "HouseholdBudget" (
"id" TEXT NOT NULL,
"householdId" TEXT NOT NULL,
"createdByUserId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT,
"period" TEXT NOT NULL DEFAULT 'monthly',
"limitAmount" DECIMAL(65,30) NOT NULL,
"spentAmount" DECIMAL(65,30) NOT NULL DEFAULT 0,
"isoCurrencyCode" TEXT NOT NULL DEFAULT 'USD',
"startDate" TIMESTAMP(3),
"endDate" TIMESTAMP(3),
"status" TEXT NOT NULL DEFAULT 'active',
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HouseholdBudget_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "PersonalGoal" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"targetAmount" DECIMAL(65,30) NOT NULL,
"currentAmount" DECIMAL(65,30) NOT NULL DEFAULT 0,
"isoCurrencyCode" TEXT NOT NULL DEFAULT 'USD',
"targetDate" TIMESTAMP(3),
"priority" TEXT NOT NULL DEFAULT 'medium',
"status" TEXT NOT NULL DEFAULT 'active',
"automation" JSONB NOT NULL DEFAULT '{}',
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PersonalGoal_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "InvestmentHolding" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"accountId" TEXT,
"symbol" TEXT NOT NULL,
"name" TEXT NOT NULL,
"assetClass" TEXT NOT NULL DEFAULT 'stock',
"quantity" DECIMAL(65,30) NOT NULL,
"price" DECIMAL(65,30) NOT NULL,
"marketValue" DECIMAL(65,30) NOT NULL,
"costBasis" DECIMAL(65,30),
"isoCurrencyCode" TEXT NOT NULL DEFAULT 'USD',
"asOfDate" TIMESTAMP(3) NOT NULL,
"source" TEXT NOT NULL DEFAULT 'manual',
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "InvestmentHolding_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "NetWorthSnapshot" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"snapshotDate" TIMESTAMP(3) NOT NULL,
"assets" DECIMAL(65,30) NOT NULL,
"liabilities" DECIMAL(65,30) NOT NULL,
"netWorth" DECIMAL(65,30) NOT NULL,
"isoCurrencyCode" TEXT NOT NULL DEFAULT 'USD',
"source" TEXT NOT NULL DEFAULT 'computed',
"breakdown" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "NetWorthSnapshot_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "RecurringTransaction" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"merchant" TEXT NOT NULL,
"cadence" TEXT NOT NULL,
"averageAmount" DECIMAL(65,30) NOT NULL,
"isoCurrencyCode" TEXT NOT NULL DEFAULT 'USD',
"nextExpectedDate" TIMESTAMP(3),
"lastSeenDate" TIMESTAMP(3),
"occurrenceCount" INTEGER NOT NULL DEFAULT 0,
"confidence" DECIMAL(65,30) NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'active',
"source" TEXT NOT NULL DEFAULT 'detected',
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RecurringTransaction_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "HouseholdBudget_householdId_status_idx" ON "HouseholdBudget"("householdId", "status");
CREATE INDEX "HouseholdBudget_createdByUserId_createdAt_idx" ON "HouseholdBudget"("createdByUserId", "createdAt");
CREATE INDEX "PersonalGoal_userId_status_idx" ON "PersonalGoal"("userId", "status");
CREATE INDEX "PersonalGoal_targetDate_idx" ON "PersonalGoal"("targetDate");
CREATE INDEX "InvestmentHolding_userId_asOfDate_idx" ON "InvestmentHolding"("userId", "asOfDate");
CREATE INDEX "InvestmentHolding_userId_symbol_idx" ON "InvestmentHolding"("userId", "symbol");
CREATE INDEX "NetWorthSnapshot_userId_snapshotDate_idx" ON "NetWorthSnapshot"("userId", "snapshotDate");
CREATE INDEX "RecurringTransaction_userId_status_idx" ON "RecurringTransaction"("userId", "status");
CREATE INDEX "RecurringTransaction_userId_merchant_idx" ON "RecurringTransaction"("userId", "merchant");
ALTER TABLE "HouseholdBudget" ADD CONSTRAINT "HouseholdBudget_householdId_fkey" FOREIGN KEY ("householdId") REFERENCES "Household"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "HouseholdBudget" ADD CONSTRAINT "HouseholdBudget_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PersonalGoal" ADD CONSTRAINT "PersonalGoal_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "InvestmentHolding" ADD CONSTRAINT "InvestmentHolding_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "InvestmentHolding" ADD CONSTRAINT "InvestmentHolding_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "NetWorthSnapshot" ADD CONSTRAINT "NetWorthSnapshot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "RecurringTransaction" ADD CONSTRAINT "RecurringTransaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -49,6 +49,11 @@ model User {
acceptedHouseholdInvites HouseholdInvite[] @relation("HouseholdInviteAccepter") acceptedHouseholdInvites HouseholdInvite[] @relation("HouseholdInviteAccepter")
ownedAccounts Account[] @relation("AccountOwnerUser") ownedAccounts Account[] @relation("AccountOwnerUser")
createdHouseholdGoals HouseholdGoal[] @relation("HouseholdGoalCreator") createdHouseholdGoals HouseholdGoal[] @relation("HouseholdGoalCreator")
personalGoals PersonalGoal[]
investmentHoldings InvestmentHolding[]
netWorthSnapshots NetWorthSnapshot[]
recurringTransactions RecurringTransaction[]
createdHouseholdBudgets HouseholdBudget[] @relation("HouseholdBudgetCreator")
notificationPreferences NotificationPreference? notificationPreferences NotificationPreference?
notifications Notification[] notifications Notification[]
pushSubscriptions PushSubscription[] pushSubscriptions PushSubscription[]
@ -203,10 +208,122 @@ model Household {
invites HouseholdInvite[] invites HouseholdInvite[]
accounts Account[] accounts Account[]
goals HouseholdGoal[] goals HouseholdGoal[]
budgets HouseholdBudget[]
@@index([createdByUserId, createdAt]) @@index([createdByUserId, createdAt])
} }
model HouseholdBudget {
id String @id @default(uuid())
householdId String
createdByUserId String
name String
category String?
period String @default("monthly")
limitAmount Decimal
spentAmount Decimal @default(0)
isoCurrencyCode String @default("USD")
startDate DateTime?
endDate DateTime?
status String @default("active")
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
household Household @relation(fields: [householdId], references: [id], onDelete: Cascade)
createdBy User @relation("HouseholdBudgetCreator", fields: [createdByUserId], references: [id], onDelete: Cascade)
@@index([householdId, status])
@@index([createdByUserId, createdAt])
}
model PersonalGoal {
id String @id @default(uuid())
userId String
name String
description String?
targetAmount Decimal
currentAmount Decimal @default(0)
isoCurrencyCode String @default("USD")
targetDate DateTime?
priority String @default("medium")
status String @default("active")
automation Json @default("{}")
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, status])
@@index([targetDate])
}
model InvestmentHolding {
id String @id @default(uuid())
userId String
accountId String?
symbol String
name String
assetClass String @default("stock")
quantity Decimal
price Decimal
marketValue Decimal
costBasis Decimal?
isoCurrencyCode String @default("USD")
asOfDate DateTime
source String @default("manual")
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
account Account? @relation(fields: [accountId], references: [id], onDelete: SetNull)
@@index([userId, asOfDate])
@@index([userId, symbol])
}
model NetWorthSnapshot {
id String @id @default(uuid())
userId String
snapshotDate DateTime
assets Decimal
liabilities Decimal
netWorth Decimal
isoCurrencyCode String @default("USD")
source String @default("computed")
breakdown Json @default("{}")
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, snapshotDate])
}
model RecurringTransaction {
id String @id @default(uuid())
userId String
merchant String
cadence String
averageAmount Decimal
isoCurrencyCode String @default("USD")
nextExpectedDate DateTime?
lastSeenDate DateTime?
occurrenceCount Int @default(0)
confidence Decimal @default(0)
status String @default("active")
source String @default("detected")
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, status])
@@index([userId, merchant])
}
model HouseholdGoal { model HouseholdGoal {
id String @id @default(uuid()) id String @id @default(uuid())
householdId String householdId String
@ -321,6 +438,7 @@ model Account {
household Household? @relation(fields: [householdId], references: [id], onDelete: SetNull) household Household? @relation(fields: [householdId], references: [id], onDelete: SetNull)
ownerUser User? @relation("AccountOwnerUser", fields: [ownerUserId], references: [id], onDelete: SetNull) ownerUser User? @relation("AccountOwnerUser", fields: [ownerUserId], references: [id], onDelete: SetNull)
transactionsRaw TransactionRaw[] transactionsRaw TransactionRaw[]
investmentHoldings InvestmentHolding[]
@@index([householdId, ownershipType]) @@index([householdId, ownershipType])
@@index([ownerUserId]) @@index([ownerUserId])

View File

@ -29,6 +29,7 @@ import { BillPayModule } from "./bill-pay/bill-pay.module";
import { CreditScoreModule } from "./credit-score/credit-score.module"; import { CreditScoreModule } from "./credit-score/credit-score.module";
import { ViewModule } from "./view/view.module"; import { ViewModule } from "./view/view.module";
import { ComplianceModule } from "./compliance/compliance.module"; import { ComplianceModule } from "./compliance/compliance.module";
import { PlanningModule } from "./planning/planning.module";
import { LoggerModule } from "nestjs-pino"; import { LoggerModule } from "nestjs-pino";
import { JwtAuthGuard } from "./common/guards/jwt-auth.guard"; import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
import { BrowserUntrustedInterceptor } from "./common/browser-untrusted.interceptor"; import { BrowserUntrustedInterceptor } from "./common/browser-untrusted.interceptor";
@ -90,6 +91,7 @@ import { BrowserUntrustedInterceptor } from "./common/browser-untrusted.intercep
CreditScoreModule, CreditScoreModule,
ViewModule, ViewModule,
ComplianceModule, ComplianceModule,
PlanningModule,
], ],
providers: [ providers: [
// Apply rate limiting globally // Apply rate limiting globally

188
src/planning/dto/index.ts Normal file
View File

@ -0,0 +1,188 @@
import { IsDateString, IsIn, IsNumber, IsObject, IsOptional, IsString, MaxLength, Min } from "class-validator";
export class CreateHouseholdBudgetDto {
@IsString()
householdId!: string;
@IsString()
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
@MaxLength(80)
category?: string;
@IsOptional()
@IsIn(["weekly", "monthly", "quarterly", "annual"])
period?: string;
@IsNumber()
@Min(0.01)
limitAmount!: number;
@IsOptional()
@IsNumber()
@Min(0)
spentAmount?: number;
@IsOptional()
@IsString()
@MaxLength(3)
isoCurrencyCode?: string;
@IsOptional()
@IsDateString()
startDate?: string;
@IsOptional()
@IsDateString()
endDate?: string;
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}
export class UpdateHouseholdBudgetDto {
@IsOptional()
@IsString()
@MaxLength(120)
name?: string;
@IsOptional()
@IsString()
@MaxLength(80)
category?: string;
@IsOptional()
@IsIn(["weekly", "monthly", "quarterly", "annual"])
period?: string;
@IsOptional()
@IsNumber()
@Min(0.01)
limitAmount?: number;
@IsOptional()
@IsNumber()
@Min(0)
spentAmount?: number;
@IsOptional()
@IsIn(["active", "paused", "archived"])
status?: string;
}
export class CreatePersonalGoalDto {
@IsString()
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
@IsNumber()
@Min(0.01)
targetAmount!: number;
@IsOptional()
@IsNumber()
@Min(0)
currentAmount?: number;
@IsOptional()
@IsString()
@MaxLength(3)
isoCurrencyCode?: string;
@IsOptional()
@IsDateString()
targetDate?: string;
@IsOptional()
@IsIn(["low", "medium", "high"])
priority?: string;
@IsOptional()
@IsObject()
automation?: Record<string, unknown>;
}
export class UpdatePersonalGoalDto {
@IsOptional()
@IsString()
@MaxLength(120)
name?: string;
@IsOptional()
@IsNumber()
@Min(0)
currentAmount?: number;
@IsOptional()
@IsIn(["active", "paused", "completed", "archived"])
status?: string;
@IsOptional()
@IsObject()
automation?: Record<string, unknown>;
}
export class CreateInvestmentHoldingDto {
@IsOptional()
@IsString()
accountId?: string;
@IsString()
@MaxLength(20)
symbol!: string;
@IsString()
@MaxLength(120)
name!: string;
@IsOptional()
@IsIn(["stock", "etf", "mutual_fund", "bond", "crypto", "cash", "other"])
assetClass?: string;
@IsNumber()
@Min(0)
quantity!: number;
@IsNumber()
@Min(0)
price!: number;
@IsOptional()
@IsNumber()
@Min(0)
costBasis?: number;
@IsOptional()
@IsString()
@MaxLength(3)
isoCurrencyCode?: string;
@IsOptional()
@IsDateString()
asOfDate?: string;
}
export class CreateNetWorthSnapshotDto {
@IsOptional()
@IsDateString()
snapshotDate?: string;
@IsOptional()
@IsNumber()
@Min(0)
assets?: number;
@IsOptional()
@IsNumber()
@Min(0)
liabilities?: number;
}

View File

@ -0,0 +1,77 @@
import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { ok } from "../common/response";
import {
CreateHouseholdBudgetDto,
CreateInvestmentHoldingDto,
CreateNetWorthSnapshotDto,
CreatePersonalGoalDto,
UpdateHouseholdBudgetDto,
UpdatePersonalGoalDto,
} from "./dto";
import { PlanningService } from "./planning.service";
@Controller("planning")
export class PlanningController {
constructor(private readonly planningService: PlanningService) {}
@Get("budgets")
async budgets(@CurrentUser() userId: string, @Query("householdId") householdId?: string) {
return ok(await this.planningService.listBudgets(userId, householdId));
}
@Post("budgets")
async createBudget(@CurrentUser() userId: string, @Body() body: CreateHouseholdBudgetDto) {
return ok(await this.planningService.createBudget(userId, body));
}
@Patch("budgets/:id")
async updateBudget(@CurrentUser() userId: string, @Param("id") id: string, @Body() body: UpdateHouseholdBudgetDto) {
return ok(await this.planningService.updateBudget(userId, id, body));
}
@Get("goals")
async goals(@CurrentUser() userId: string) {
return ok(await this.planningService.listPersonalGoals(userId));
}
@Post("goals")
async createGoal(@CurrentUser() userId: string, @Body() body: CreatePersonalGoalDto) {
return ok(await this.planningService.createPersonalGoal(userId, body));
}
@Patch("goals/:id")
async updateGoal(@CurrentUser() userId: string, @Param("id") id: string, @Body() body: UpdatePersonalGoalDto) {
return ok(await this.planningService.updatePersonalGoal(userId, id, body));
}
@Get("investments")
async investments(@CurrentUser() userId: string) {
return ok(await this.planningService.listInvestments(userId));
}
@Post("investments")
async createInvestment(@CurrentUser() userId: string, @Body() body: CreateInvestmentHoldingDto) {
return ok(await this.planningService.createInvestment(userId, body));
}
@Get("net-worth")
async netWorth(@CurrentUser() userId: string) {
return ok(await this.planningService.netWorthSummary(userId));
}
@Post("net-worth/snapshots")
async createNetWorthSnapshot(@CurrentUser() userId: string, @Body() body: CreateNetWorthSnapshotDto) {
return ok(await this.planningService.createNetWorthSnapshot(userId, body));
}
@Get("recurring")
async recurring(@CurrentUser() userId: string) {
return ok(await this.planningService.listRecurring(userId));
}
@Post("recurring/detect")
async detectRecurring(@CurrentUser() userId: string) {
return ok(await this.planningService.detectRecurring(userId));
}
}

View File

@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { PlanningController } from "./planning.controller";
import { PlanningService } from "./planning.service";
@Module({
controllers: [PlanningController],
providers: [PlanningService],
})
export class PlanningModule {}

View File

@ -0,0 +1,309 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import {
CreateHouseholdBudgetDto,
CreateInvestmentHoldingDto,
CreateNetWorthSnapshotDto,
CreatePersonalGoalDto,
UpdateHouseholdBudgetDto,
UpdatePersonalGoalDto,
} from "./dto";
@Injectable()
export class PlanningService {
constructor(private readonly prisma: PrismaService) {}
async listBudgets(userId: string, householdId?: string) {
const where: any = householdId
? { householdId }
: { household: { members: { some: { userId, status: "active" } } } };
if (householdId) await this.requireHouseholdManager(userId, householdId, false);
const budgets = await (this.prisma as any).householdBudget.findMany({
where,
orderBy: [{ status: "asc" }, { createdAt: "desc" }],
});
return budgets.map((budget: any) => this.withBudgetProgress(budget));
}
async createBudget(userId: string, dto: CreateHouseholdBudgetDto) {
await this.requireHouseholdManager(userId, dto.householdId);
const budget = await (this.prisma as any).householdBudget.create({
data: {
householdId: dto.householdId,
createdByUserId: userId,
name: dto.name.trim(),
category: this.optionalString(dto.category),
period: dto.period ?? "monthly",
limitAmount: new Prisma.Decimal(dto.limitAmount),
spentAmount: new Prisma.Decimal(dto.spentAmount ?? 0),
isoCurrencyCode: (dto.isoCurrencyCode ?? "USD").toUpperCase(),
startDate: dto.startDate ? this.parseDate(dto.startDate, "start date") : null,
endDate: dto.endDate ? this.parseDate(dto.endDate, "end date") : null,
metadata: (dto.metadata ?? {}) as Prisma.InputJsonValue,
},
});
await this.audit(userId, "planning.household_budget.create", { budgetId: budget.id, householdId: dto.householdId });
return this.withBudgetProgress(budget);
}
async updateBudget(userId: string, budgetId: string, dto: UpdateHouseholdBudgetDto) {
const existing = await this.assertBudgetAccess(userId, budgetId, true);
const budget = await (this.prisma as any).householdBudget.update({
where: { id: existing.id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.category !== undefined ? { category: this.optionalString(dto.category) } : {}),
...(dto.period !== undefined ? { period: dto.period } : {}),
...(dto.limitAmount !== undefined ? { limitAmount: new Prisma.Decimal(dto.limitAmount) } : {}),
...(dto.spentAmount !== undefined ? { spentAmount: new Prisma.Decimal(dto.spentAmount) } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
},
});
await this.audit(userId, "planning.household_budget.update", { budgetId, fields: Object.keys(dto) });
return this.withBudgetProgress(budget);
}
async listPersonalGoals(userId: string) {
const goals = await (this.prisma as any).personalGoal.findMany({
where: { userId },
orderBy: [{ status: "asc" }, { targetDate: "asc" }, { createdAt: "desc" }],
});
return goals.map((goal: any) => this.withGoalProgress(goal));
}
async createPersonalGoal(userId: string, dto: CreatePersonalGoalDto) {
const goal = await (this.prisma as any).personalGoal.create({
data: {
userId,
name: dto.name.trim(),
description: this.optionalString(dto.description),
targetAmount: new Prisma.Decimal(dto.targetAmount),
currentAmount: new Prisma.Decimal(dto.currentAmount ?? 0),
isoCurrencyCode: (dto.isoCurrencyCode ?? "USD").toUpperCase(),
targetDate: dto.targetDate ? this.parseDate(dto.targetDate, "target date") : null,
priority: dto.priority ?? "medium",
automation: (dto.automation ?? {}) as Prisma.InputJsonValue,
},
});
await this.audit(userId, "planning.personal_goal.create", { goalId: goal.id });
return this.withGoalProgress(goal);
}
async updatePersonalGoal(userId: string, goalId: string, dto: UpdatePersonalGoalDto) {
const existing = await (this.prisma as any).personalGoal.findFirst({ where: { id: goalId, userId } });
if (!existing) throw new BadRequestException("Goal not found.");
const goal = await (this.prisma as any).personalGoal.update({
where: { id: existing.id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.currentAmount !== undefined ? { currentAmount: new Prisma.Decimal(dto.currentAmount) } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
...(dto.automation !== undefined ? { automation: dto.automation as Prisma.InputJsonValue } : {}),
},
});
await this.audit(userId, "planning.personal_goal.update", { goalId, fields: Object.keys(dto) });
return this.withGoalProgress(goal);
}
async listInvestments(userId: string) {
const holdings = await (this.prisma as any).investmentHolding.findMany({
where: { userId },
orderBy: [{ asOfDate: "desc" }, { symbol: "asc" }],
take: 200,
});
const totalMarketValue = holdings.reduce((sum: number, holding: any) => sum + this.toNumber(holding.marketValue), 0);
return { holdings, totalMarketValue: this.round(totalMarketValue) };
}
async createInvestment(userId: string, dto: CreateInvestmentHoldingDto) {
if (dto.accountId) {
const account = await this.prisma.account.findFirst({ where: { id: dto.accountId, userId } });
if (!account) throw new BadRequestException("Investment account not found.");
}
const marketValue = dto.quantity * dto.price;
const holding = await (this.prisma as any).investmentHolding.create({
data: {
userId,
accountId: dto.accountId,
symbol: dto.symbol.trim().toUpperCase(),
name: dto.name.trim(),
assetClass: dto.assetClass ?? "stock",
quantity: new Prisma.Decimal(dto.quantity),
price: new Prisma.Decimal(dto.price),
marketValue: new Prisma.Decimal(marketValue),
costBasis: dto.costBasis !== undefined ? new Prisma.Decimal(dto.costBasis) : null,
isoCurrencyCode: (dto.isoCurrencyCode ?? "USD").toUpperCase(),
asOfDate: dto.asOfDate ? this.parseDate(dto.asOfDate, "as-of date") : new Date(),
},
});
await this.audit(userId, "planning.investment.create", { holdingId: holding.id, symbol: holding.symbol });
return holding;
}
async netWorthSummary(userId: string) {
const [snapshots, accounts, investments] = await Promise.all([
(this.prisma as any).netWorthSnapshot.findMany({ where: { userId }, orderBy: { snapshotDate: "desc" }, take: 24 }),
this.prisma.account.findMany({ where: { userId, isActive: true }, select: { currentBalance: true, accountType: true } }),
(this.prisma as any).investmentHolding.findMany({ where: { userId }, orderBy: { asOfDate: "desc" }, take: 200 }),
]);
const cashAssets = accounts.reduce((sum: number, account: any) => sum + Math.max(0, this.toNumber(account.currentBalance)), 0);
const liabilities = accounts.reduce((sum: number, account: any) => sum + Math.abs(Math.min(0, this.toNumber(account.currentBalance))), 0);
const investmentAssets = investments.reduce((sum: number, holding: any) => sum + this.toNumber(holding.marketValue), 0);
const assets = this.round(cashAssets + investmentAssets);
return {
latest: snapshots[0] ?? null,
computed: {
assets,
liabilities: this.round(liabilities),
netWorth: this.round(assets - liabilities),
breakdown: { cashAssets: this.round(cashAssets), investmentAssets: this.round(investmentAssets), liabilities: this.round(liabilities) },
},
history: snapshots.slice().reverse(),
};
}
async createNetWorthSnapshot(userId: string, dto: CreateNetWorthSnapshotDto) {
const computed = await this.netWorthSummary(userId);
const assets = dto.assets ?? computed.computed.assets;
const liabilities = dto.liabilities ?? computed.computed.liabilities;
const snapshot = await (this.prisma as any).netWorthSnapshot.create({
data: {
userId,
snapshotDate: dto.snapshotDate ? this.parseDate(dto.snapshotDate, "snapshot date") : new Date(),
assets: new Prisma.Decimal(assets),
liabilities: new Prisma.Decimal(liabilities),
netWorth: new Prisma.Decimal(assets - liabilities),
breakdown: computed.computed.breakdown as Prisma.InputJsonValue,
},
});
await this.audit(userId, "planning.net_worth.snapshot", { snapshotId: snapshot.id, netWorth: snapshot.netWorth.toString() });
return snapshot;
}
async listRecurring(userId: string) {
return (this.prisma as any).recurringTransaction.findMany({
where: { userId },
orderBy: [{ status: "asc" }, { confidence: "desc" }, { merchant: "asc" }],
});
}
async detectRecurring(userId: string) {
const rows = await this.prisma.transactionRaw.findMany({
where: { account: { userId, isActive: true }, derived: { isNot: { isHidden: true } } },
select: { date: true, amount: true, description: true, account: { select: { isoCurrencyCode: true } } },
orderBy: { date: "asc" },
take: 1000,
});
const grouped = new Map<string, any[]>();
for (const row of rows) {
const merchant = this.normalizeMerchant(row.description);
if (!merchant) continue;
const list = grouped.get(merchant) ?? [];
list.push(row);
grouped.set(merchant, list);
}
const results: any[] = [];
for (const [merchant, items] of grouped) {
if (items.length < 3) continue;
const cadence = this.inferCadence(items.map((item) => item.date));
if (!cadence) continue;
const averageAmount = this.round(items.reduce((sum, item) => sum + Math.abs(this.toNumber(item.amount)), 0) / items.length);
const lastSeenDate = items[items.length - 1].date;
const nextExpectedDate = this.addCadence(lastSeenDate, cadence);
const confidence = Math.min(0.95, 0.55 + items.length * 0.08);
const record = await (this.prisma as any).recurringTransaction.create({
data: {
userId,
merchant,
cadence,
averageAmount: new Prisma.Decimal(averageAmount),
isoCurrencyCode: items[0].account?.isoCurrencyCode ?? "USD",
nextExpectedDate,
lastSeenDate,
occurrenceCount: items.length,
confidence: new Prisma.Decimal(confidence),
metadata: { sampleDescriptions: items.slice(-3).map((item) => item.description) },
},
});
results.push(record);
}
await this.audit(userId, "planning.recurring.detect", { detected: results.length });
return { detected: results.length, recurring: results };
}
private async requireHouseholdManager(userId: string, householdId: string, managerOnly = true) {
const member = await this.prisma.householdMember.findFirst({ where: { userId, householdId, status: "active" } });
if (!member) throw new BadRequestException("Household not found.");
if (managerOnly && !["owner", "admin"].includes(member.role)) {
throw new BadRequestException("Only household owners and admins can manage budgets.");
}
return member;
}
private async assertBudgetAccess(userId: string, budgetId: string, managerOnly: boolean) {
const budget = await (this.prisma as any).householdBudget.findFirst({ where: { id: budgetId } });
if (!budget) throw new BadRequestException("Budget not found.");
await this.requireHouseholdManager(userId, budget.householdId, managerOnly);
return budget;
}
private withBudgetProgress(budget: any) {
const limit = this.toNumber(budget.limitAmount);
const spent = this.toNumber(budget.spentAmount);
return { ...budget, progressPercent: limit > 0 ? Math.min(999, Math.round((spent / limit) * 100)) : 0, remainingAmount: this.round(limit - spent) };
}
private withGoalProgress(goal: any) {
const target = this.toNumber(goal.targetAmount);
const current = this.toNumber(goal.currentAmount);
return { ...goal, progressPercent: target > 0 ? Math.min(100, Math.round((current / target) * 100)) : 0, remainingAmount: this.round(Math.max(0, target - current)) };
}
private inferCadence(dates: Date[]) {
const gaps = dates.slice(1).map((date, index) => Math.round((date.getTime() - dates[index].getTime()) / 86400000));
const average = gaps.reduce((sum, gap) => sum + gap, 0) / gaps.length;
if (average >= 6 && average <= 8) return "weekly";
if (average >= 13 && average <= 16) return "biweekly";
if (average >= 27 && average <= 33) return "monthly";
if (average >= 85 && average <= 96) return "quarterly";
return null;
}
private addCadence(date: Date, cadence: string) {
const next = new Date(date);
if (cadence === "weekly") next.setDate(next.getDate() + 7);
else if (cadence === "biweekly") next.setDate(next.getDate() + 14);
else if (cadence === "quarterly") next.setMonth(next.getMonth() + 3);
else next.setMonth(next.getMonth() + 1);
return next;
}
private normalizeMerchant(description: string) {
return description.toLowerCase().replace(/[^a-z0-9 ]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 80);
}
private parseDate(value: string, label: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) throw new BadRequestException(`Invalid ${label}.`);
return date;
}
private optionalString(value?: string | null) {
const trimmed = value?.trim();
return trimmed || null;
}
private toNumber(value: unknown) {
if (value === null || value === undefined) return 0;
return Number(value);
}
private round(value: number) {
return Math.round(value * 100) / 100;
}
private async audit(userId: string, action: string, metadata: Record<string, unknown>) {
await this.prisma.auditLog.create({ data: { userId, action, metadata: metadata as Prisma.InputJsonValue } });
}
}

View File

@ -0,0 +1,110 @@
import { BadRequestException } from "@nestjs/common";
import { PlanningService } from "../src/planning/planning.service";
const model = () => ({
findMany: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
});
const createService = () => {
const prisma = {
householdMember: model(),
householdBudget: model(),
personalGoal: model(),
investmentHolding: model(),
netWorthSnapshot: model(),
recurringTransaction: model(),
account: model(),
transactionRaw: model(),
auditLog: { create: jest.fn() },
};
const service = new PlanningService(prisma as any);
return { service, prisma };
};
describe("PlanningService", () => {
it("creates shared household budgets for household managers", async () => {
const { service, prisma } = createService();
prisma.householdMember.findFirst.mockResolvedValue({ role: "owner" });
prisma.householdBudget.create.mockResolvedValue({
id: "budget_1",
householdId: "household_1",
limitAmount: 1000,
spentAmount: 250,
});
await expect(service.createBudget("user_1", {
householdId: "household_1",
name: "Groceries",
limitAmount: 1000,
spentAmount: 250,
})).resolves.toMatchObject({
id: "budget_1",
progressPercent: 25,
remainingAmount: 750,
});
expect(prisma.householdBudget.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ householdId: "household_1", createdByUserId: "user_1" }),
}));
});
it("blocks budget management for non-managers", async () => {
const { service, prisma } = createService();
prisma.householdMember.findFirst.mockResolvedValue({ role: "member" });
await expect(service.createBudget("user_1", {
householdId: "household_1",
name: "Groceries",
limitAmount: 1000,
})).rejects.toBeInstanceOf(BadRequestException);
});
it("creates personal goals with progress", async () => {
const { service, prisma } = createService();
prisma.personalGoal.create.mockResolvedValue({
id: "goal_1",
targetAmount: 5000,
currentAmount: 1250,
});
await expect(service.createPersonalGoal("user_1", {
name: "Emergency fund",
targetAmount: 5000,
currentAmount: 1250,
})).resolves.toMatchObject({ progressPercent: 25, remainingAmount: 3750 });
});
it("creates investment holdings and includes them in net worth", async () => {
const { service, prisma } = createService();
prisma.investmentHolding.create.mockResolvedValue({ id: "holding_1", symbol: "VOO", marketValue: 1200 });
prisma.netWorthSnapshot.findMany.mockResolvedValue([]);
prisma.account.findMany.mockResolvedValue([{ currentBalance: 800 }, { currentBalance: -200 }]);
prisma.investmentHolding.findMany.mockResolvedValue([{ marketValue: 1200 }]);
await service.createInvestment("user_1", { symbol: "voo", name: "Vanguard S&P 500", quantity: 4, price: 300 });
await expect(service.netWorthSummary("user_1")).resolves.toMatchObject({
computed: {
assets: 2000,
liabilities: 200,
netWorth: 1800,
},
});
});
it("detects monthly recurring transactions", async () => {
const { service, prisma } = createService();
prisma.transactionRaw.findMany.mockResolvedValue([
{ date: new Date("2026-01-01"), amount: -20, description: "Netflix", account: { isoCurrencyCode: "USD" } },
{ date: new Date("2026-02-01"), amount: -20, description: "Netflix", account: { isoCurrencyCode: "USD" } },
{ date: new Date("2026-03-01"), amount: -22, description: "Netflix", account: { isoCurrencyCode: "USD" } },
]);
prisma.recurringTransaction.create.mockImplementation(async ({ data }) => ({ id: "rec_1", ...data }));
await expect(service.detectRecurring("user_1")).resolves.toMatchObject({
detected: 1,
recurring: [expect.objectContaining({ merchant: "netflix", cadence: "monthly", occurrenceCount: 3 })],
});
});
});