diff --git a/prisma/migrations/20260717170000_planning_core/migration.sql b/prisma/migrations/20260717170000_planning_core/migration.sql new file mode 100644 index 0000000..e13243e --- /dev/null +++ b/prisma/migrations/20260717170000_planning_core/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a849004..14d8a54 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -49,6 +49,11 @@ model User { acceptedHouseholdInvites HouseholdInvite[] @relation("HouseholdInviteAccepter") ownedAccounts Account[] @relation("AccountOwnerUser") createdHouseholdGoals HouseholdGoal[] @relation("HouseholdGoalCreator") + personalGoals PersonalGoal[] + investmentHoldings InvestmentHolding[] + netWorthSnapshots NetWorthSnapshot[] + recurringTransactions RecurringTransaction[] + createdHouseholdBudgets HouseholdBudget[] @relation("HouseholdBudgetCreator") notificationPreferences NotificationPreference? notifications Notification[] pushSubscriptions PushSubscription[] @@ -203,10 +208,122 @@ model Household { invites HouseholdInvite[] accounts Account[] goals HouseholdGoal[] + budgets HouseholdBudget[] @@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 { id String @id @default(uuid()) householdId String @@ -321,6 +438,7 @@ model Account { household Household? @relation(fields: [householdId], references: [id], onDelete: SetNull) ownerUser User? @relation("AccountOwnerUser", fields: [ownerUserId], references: [id], onDelete: SetNull) transactionsRaw TransactionRaw[] + investmentHoldings InvestmentHolding[] @@index([householdId, ownershipType]) @@index([ownerUserId]) diff --git a/src/app.module.ts b/src/app.module.ts index 34348cb..fd22d02 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -29,6 +29,7 @@ import { BillPayModule } from "./bill-pay/bill-pay.module"; import { CreditScoreModule } from "./credit-score/credit-score.module"; import { ViewModule } from "./view/view.module"; import { ComplianceModule } from "./compliance/compliance.module"; +import { PlanningModule } from "./planning/planning.module"; import { LoggerModule } from "nestjs-pino"; import { JwtAuthGuard } from "./common/guards/jwt-auth.guard"; import { BrowserUntrustedInterceptor } from "./common/browser-untrusted.interceptor"; @@ -90,6 +91,7 @@ import { BrowserUntrustedInterceptor } from "./common/browser-untrusted.intercep CreditScoreModule, ViewModule, ComplianceModule, + PlanningModule, ], providers: [ // Apply rate limiting globally diff --git a/src/planning/dto/index.ts b/src/planning/dto/index.ts new file mode 100644 index 0000000..2fa896e --- /dev/null +++ b/src/planning/dto/index.ts @@ -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; +} + +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; +} + +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; +} + +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; +} diff --git a/src/planning/planning.controller.ts b/src/planning/planning.controller.ts new file mode 100644 index 0000000..aa60885 --- /dev/null +++ b/src/planning/planning.controller.ts @@ -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)); + } +} diff --git a/src/planning/planning.module.ts b/src/planning/planning.module.ts new file mode 100644 index 0000000..c582b2e --- /dev/null +++ b/src/planning/planning.module.ts @@ -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 {} diff --git a/src/planning/planning.service.ts b/src/planning/planning.service.ts new file mode 100644 index 0000000..6e49c84 --- /dev/null +++ b/src/planning/planning.service.ts @@ -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(); + 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) { + await this.prisma.auditLog.create({ data: { userId, action, metadata: metadata as Prisma.InputJsonValue } }); + } +} diff --git a/test/planning.service.spec.ts b/test/planning.service.spec.ts new file mode 100644 index 0000000..5c2dfe1 --- /dev/null +++ b/test/planning.service.spec.ts @@ -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 })], + }); + }); +});