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")
|
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])
|
||||||
|
|||||||
@ -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
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