Add planning budgets goals investments and recurring detection
This commit is contained in:
parent
fabe6e341b
commit
49acee6b6a
107
prisma/migrations/20260717170000_planning_core/migration.sql
Normal file
107
prisma/migrations/20260717170000_planning_core/migration.sql
Normal 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;
|
||||
@ -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])
|
||||
|
||||
@ -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
|
||||
|
||||
188
src/planning/dto/index.ts
Normal file
188
src/planning/dto/index.ts
Normal 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;
|
||||
}
|
||||
77
src/planning/planning.controller.ts
Normal file
77
src/planning/planning.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
9
src/planning/planning.module.ts
Normal file
9
src/planning/planning.module.ts
Normal 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 {}
|
||||
309
src/planning/planning.service.ts
Normal file
309
src/planning/planning.service.ts
Normal 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 } });
|
||||
}
|
||||
}
|
||||
110
test/planning.service.spec.ts
Normal file
110
test/planning.service.spec.ts
Normal 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 })],
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user