Implement browser-safe finance presentation APIs

This commit is contained in:
MOHAN 2026-07-16 22:07:33 +05:30
parent cce14fa5c6
commit 5dc1cd796b
36 changed files with 2147 additions and 12 deletions

View File

@ -40,6 +40,9 @@ SMTP_PORT=587
SMTP_USER= SMTP_USER=
SMTP_PASS= SMTP_PASS=
SMTP_FROM=noreply@ledgerone.app SMTP_FROM=noreply@ledgerone.app
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:support@ledgerone.app
APP_URL=http://localhost:3052 APP_URL=http://localhost:3052
PORT=3051 PORT=3051

84
package-lock.json generated
View File

@ -21,6 +21,7 @@
"@supabase/supabase-js": "^2.49.1", "@supabase/supabase-js": "^2.49.1",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/speakeasy": "^2.0.10", "@types/speakeasy": "^2.0.10",
"@types/web-push": "^3.6.4",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.4", "class-validator": "^0.14.4",
@ -41,6 +42,7 @@
"speakeasy": "^2.0.0", "speakeasy": "^2.0.0",
"stripe": "^20.4.0", "stripe": "^20.4.0",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"web-push": "^3.6.7",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
@ -3370,6 +3372,15 @@
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/ws": { "node_modules/@types/ws": {
"version": "8.18.1", "version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@ -3800,6 +3811,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/asynckit": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@ -4034,6 +4057,12 @@
"readable-stream": "^3.4.0" "readable-stream": "^3.4.0"
} }
}, },
"node_modules/bn.js": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
"license": "MIT"
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.4", "version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@ -6120,6 +6149,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@ -7814,6 +7852,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -10413,6 +10457,46 @@
"defaults": "^1.0.3" "defaults": "^1.0.3"
} }
}, },
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/web-push/node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/web-push/node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/web-streams-polyfill": { "node_modules/web-streams-polyfill": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",

View File

@ -25,6 +25,7 @@
"@supabase/supabase-js": "^2.49.1", "@supabase/supabase-js": "^2.49.1",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/speakeasy": "^2.0.10", "@types/speakeasy": "^2.0.10",
"@types/web-push": "^3.6.4",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.4", "class-validator": "^0.14.4",
@ -45,6 +46,7 @@
"speakeasy": "^2.0.0", "speakeasy": "^2.0.0",
"stripe": "^20.4.0", "stripe": "^20.4.0",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
"web-push": "^3.6.7",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {

View File

@ -0,0 +1,52 @@
CREATE TABLE "NotificationPreference" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"emailEnabled" BOOLEAN NOT NULL DEFAULT true,
"pushEnabled" BOOLEAN NOT NULL DEFAULT false,
"minSeverity" TEXT NOT NULL DEFAULT 'info',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NotificationPreference_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "Notification" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"severity" TEXT NOT NULL DEFAULT 'info',
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"metadata" JSONB NOT NULL DEFAULT '{}',
"channels" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"readAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "PushSubscription" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"endpoint" TEXT NOT NULL,
"p256dh" TEXT NOT NULL,
"auth" TEXT NOT NULL,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"lastUsedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "NotificationPreference_userId_key" ON "NotificationPreference"("userId");
CREATE INDEX "Notification_userId_createdAt_idx" ON "Notification"("userId", "createdAt");
CREATE INDEX "Notification_userId_readAt_idx" ON "Notification"("userId", "readAt");
CREATE INDEX "Notification_type_createdAt_idx" ON "Notification"("type", "createdAt");
CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint");
CREATE INDEX "PushSubscription_userId_revokedAt_idx" ON "PushSubscription"("userId", "revokedAt");
ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,63 @@
CREATE TABLE "BillPayee" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"nickname" TEXT,
"category" TEXT,
"website" TEXT,
"accountNumberLast4" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BillPayee_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "Bill" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"payeeId" TEXT,
"name" TEXT NOT NULL,
"amount" DECIMAL(65,30) NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'USD',
"dueDate" TIMESTAMP(3) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"recurrence" TEXT NOT NULL DEFAULT 'none',
"autopay" BOOLEAN NOT NULL DEFAULT false,
"reminderDays" INTEGER NOT NULL DEFAULT 3,
"notes" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"paidAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Bill_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "BillPayment" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"billId" TEXT NOT NULL,
"amount" DECIMAL(65,30) NOT NULL,
"paidAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"method" TEXT NOT NULL DEFAULT 'manual',
"confirmationNumber" TEXT,
"notes" TEXT,
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BillPayment_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "BillPayee_userId_name_idx" ON "BillPayee"("userId", "name");
CREATE INDEX "Bill_userId_dueDate_idx" ON "Bill"("userId", "dueDate");
CREATE INDEX "Bill_userId_status_idx" ON "Bill"("userId", "status");
CREATE INDEX "Bill_payeeId_idx" ON "Bill"("payeeId");
CREATE INDEX "BillPayment_userId_paidAt_idx" ON "BillPayment"("userId", "paidAt");
CREATE INDEX "BillPayment_billId_idx" ON "BillPayment"("billId");
ALTER TABLE "BillPayee" ADD CONSTRAINT "BillPayee_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Bill" ADD CONSTRAINT "Bill_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Bill" ADD CONSTRAINT "Bill_payeeId_fkey" FOREIGN KEY ("payeeId") REFERENCES "BillPayee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "BillPayment" ADD CONSTRAINT "BillPayment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "BillPayment" ADD CONSTRAINT "BillPayment_billId_fkey" FOREIGN KEY ("billId") REFERENCES "Bill"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,20 @@
CREATE TABLE "CreditScoreEntry" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"score" INTEGER NOT NULL,
"bureau" TEXT NOT NULL DEFAULT 'unknown',
"source" TEXT NOT NULL DEFAULT 'manual',
"model" TEXT NOT NULL DEFAULT 'vantage_score_3',
"scoreDate" TIMESTAMP(3) NOT NULL,
"factors" JSONB NOT NULL DEFAULT '{}',
"metadata" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CreditScoreEntry_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "CreditScoreEntry_userId_scoreDate_idx" ON "CreditScoreEntry"("userId", "scoreDate");
CREATE INDEX "CreditScoreEntry_userId_bureau_idx" ON "CreditScoreEntry"("userId", "bureau");
ALTER TABLE "CreditScoreEntry" ADD CONSTRAINT "CreditScoreEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -49,6 +49,144 @@ 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")
notificationPreferences NotificationPreference?
notifications Notification[]
pushSubscriptions PushSubscription[]
billPayees BillPayee[]
bills Bill[]
billPayments BillPayment[]
creditScoreEntries CreditScoreEntry[]
}
model NotificationPreference {
id String @id @default(uuid())
userId String @unique
emailEnabled Boolean @default(true)
pushEnabled Boolean @default(false)
minSeverity String @default("info")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Notification {
id String @id @default(uuid())
userId String
type String
severity String @default("info")
title String
body String
metadata Json @default("{}")
channels String[] @default([])
readAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
@@index([userId, readAt])
@@index([type, createdAt])
}
model PushSubscription {
id String @id @default(uuid())
userId String
endpoint String @unique
p256dh String
auth String
userAgent String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastUsedAt DateTime?
revokedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, revokedAt])
}
model BillPayee {
id String @id @default(uuid())
userId String
name String
nickname String?
category String?
website String?
accountNumberLast4 String?
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
bills Bill[]
@@index([userId, name])
}
model Bill {
id String @id @default(uuid())
userId String
payeeId String?
name String
amount Decimal
currency String @default("USD")
dueDate DateTime
status String @default("pending")
recurrence String @default("none")
autopay Boolean @default(false)
reminderDays Int @default(3)
notes String?
metadata Json @default("{}")
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
payee BillPayee? @relation(fields: [payeeId], references: [id], onDelete: SetNull)
payments BillPayment[]
@@index([userId, dueDate])
@@index([userId, status])
@@index([payeeId])
}
model BillPayment {
id String @id @default(uuid())
userId String
billId String
amount Decimal
paidAt DateTime @default(now())
method String @default("manual")
confirmationNumber String?
notes String?
metadata Json @default("{}")
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
bill Bill @relation(fields: [billId], references: [id], onDelete: Cascade)
@@index([userId, paidAt])
@@index([billId])
}
model CreditScoreEntry {
id String @id @default(uuid())
userId String
score Int
bureau String @default("unknown")
source String @default("manual")
model String @default("vantage_score_3")
scoreDate DateTime
factors Json @default("{}")
metadata Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, scoreDate])
@@index([userId, bureau])
} }
model Household { model Household {

View File

@ -1,7 +1,7 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config"; import { ConfigModule } from "@nestjs/config";
import { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler"; import { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler";
import { APP_GUARD } from "@nestjs/core"; import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
import { envValidationSchema } from "./config/env.validation"; import { envValidationSchema } from "./config/env.validation";
import { CommonModule } from "./common/common.module"; import { CommonModule } from "./common/common.module";
@ -24,8 +24,13 @@ import { AbuseModule } from "./abuse/abuse.module";
import { PublicApiModule } from "./public-api/public-api.module"; import { PublicApiModule } from "./public-api/public-api.module";
import { AdminModule } from "./admin/admin.module"; import { AdminModule } from "./admin/admin.module";
import { HouseholdsModule } from "./households/households.module"; import { HouseholdsModule } from "./households/households.module";
import { NotificationsModule } from "./notifications/notifications.module";
import { BillPayModule } from "./bill-pay/bill-pay.module";
import { CreditScoreModule } from "./credit-score/credit-score.module";
import { ViewModule } from "./view/view.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";
@Module({ @Module({
imports: [ imports: [
@ -79,12 +84,18 @@ import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
PublicApiModule, PublicApiModule,
AdminModule, AdminModule,
HouseholdsModule, HouseholdsModule,
NotificationsModule,
BillPayModule,
CreditScoreModule,
ViewModule,
], ],
providers: [ providers: [
// Apply rate limiting globally // Apply rate limiting globally
{ provide: APP_GUARD, useClass: ThrottlerGuard }, { provide: APP_GUARD, useClass: ThrottlerGuard },
// Apply JWT auth globally (routes decorated with @Public() are exempt) // Apply JWT auth globally (routes decorated with @Public() are exempt)
{ provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: JwtAuthGuard },
// Treat browser/API clients as untrusted presentation clients by default.
{ provide: APP_INTERCEPTOR, useClass: BrowserUntrustedInterceptor },
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@ -0,0 +1,45 @@
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 { BillPayService } from "./bill-pay.service";
import { CreateBillDto, CreateBillPayeeDto, MarkBillPaidDto, UpdateBillDto } from "./dto";
@Controller("bill-pay")
export class BillPayController {
constructor(private readonly billPayService: BillPayService) {}
@Get("summary")
async summary(@CurrentUser() userId: string) {
return ok(await this.billPayService.summary(userId));
}
@Get("payees")
async payees(@CurrentUser() userId: string) {
return ok(await this.billPayService.listPayees(userId));
}
@Post("payees")
async createPayee(@CurrentUser() userId: string, @Body() body: CreateBillPayeeDto) {
return ok(await this.billPayService.createPayee(userId, body));
}
@Get("bills")
async bills(@CurrentUser() userId: string, @Query("status") status?: string) {
return ok(await this.billPayService.listBills(userId, status));
}
@Post("bills")
async createBill(@CurrentUser() userId: string, @Body() body: CreateBillDto) {
return ok(await this.billPayService.createBill(userId, body));
}
@Patch("bills/:id")
async updateBill(@CurrentUser() userId: string, @Param("id") id: string, @Body() body: UpdateBillDto) {
return ok(await this.billPayService.updateBill(userId, id, body));
}
@Post("bills/:id/pay")
async markPaid(@CurrentUser() userId: string, @Param("id") id: string, @Body() body: MarkBillPaidDto) {
return ok(await this.billPayService.markPaid(userId, id, body));
}
}

View File

@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { NotificationsModule } from "../notifications/notifications.module";
import { BillPayController } from "./bill-pay.controller";
import { BillPayService } from "./bill-pay.service";
@Module({
imports: [NotificationsModule],
controllers: [BillPayController],
providers: [BillPayService],
})
export class BillPayModule {}

View File

@ -0,0 +1,235 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { NotificationsService } from "../notifications/notifications.service";
import { PrismaService } from "../prisma/prisma.service";
import { CreateBillDto, CreateBillPayeeDto, MarkBillPaidDto, UpdateBillDto } from "./dto";
const ACTIVE_STATUSES = ["pending", "scheduled"];
@Injectable()
export class BillPayService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
) {}
async listPayees(userId: string) {
return this.prisma.billPayee.findMany({
where: { userId },
include: { bills: { orderBy: { dueDate: "asc" }, take: 5 } },
orderBy: { name: "asc" },
});
}
async createPayee(userId: string, dto: CreateBillPayeeDto) {
return this.prisma.billPayee.create({
data: {
userId,
name: dto.name.trim(),
nickname: this.optionalString(dto.nickname),
category: this.optionalString(dto.category),
website: this.optionalString(dto.website),
accountNumberLast4: this.optionalString(dto.accountNumberLast4),
},
});
}
async listBills(userId: string, status?: string) {
const bills = await this.prisma.bill.findMany({
where: {
userId,
...(status && status !== "all" ? { status } : {}),
},
include: {
payee: true,
payments: { orderBy: { paidAt: "desc" } },
},
orderBy: [{ dueDate: "asc" }, { createdAt: "desc" }],
take: 100,
});
return bills.map((bill) => this.withComputedStatus(bill));
}
async createBill(userId: string, dto: CreateBillDto) {
if (dto.payeeId) await this.assertPayee(userId, dto.payeeId);
const dueDate = this.parseDate(dto.dueDate, "due date");
const bill = await this.prisma.bill.create({
data: {
userId,
payeeId: dto.payeeId,
name: dto.name.trim(),
amount: new Prisma.Decimal(dto.amount),
currency: (dto.currency ?? "USD").toUpperCase(),
dueDate,
status: dto.status ?? "pending",
recurrence: dto.recurrence ?? "none",
autopay: dto.autopay ?? false,
reminderDays: dto.reminderDays ?? 3,
notes: this.optionalString(dto.notes),
},
include: { payee: true, payments: true },
});
await this.prisma.auditLog.create({
data: {
userId,
action: "bill_pay.bill_create",
metadata: { billId: bill.id, dueDate: bill.dueDate, amount: bill.amount.toString() },
},
});
await this.notifications.notifyUser(userId, {
type: "bill.created",
severity: this.isDueSoon(bill.dueDate, bill.reminderDays) ? "warning" : "info",
title: `Bill added: ${bill.name}`,
body: `${this.formatMoney(bill.amount, bill.currency)} is due on ${bill.dueDate.toISOString().slice(0, 10)}.`,
metadata: { billId: bill.id },
});
return this.withComputedStatus(bill);
}
async updateBill(userId: string, billId: string, dto: UpdateBillDto) {
const existing = await this.assertBill(userId, billId);
if (dto.payeeId) await this.assertPayee(userId, dto.payeeId);
const bill = await this.prisma.bill.update({
where: { id: existing.id },
data: {
...(dto.payeeId !== undefined ? { payeeId: dto.payeeId } : {}),
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.amount !== undefined ? { amount: new Prisma.Decimal(dto.amount) } : {}),
...(dto.currency !== undefined ? { currency: dto.currency.toUpperCase() } : {}),
...(dto.dueDate !== undefined ? { dueDate: this.parseDate(dto.dueDate, "due date") } : {}),
...(dto.status !== undefined ? { status: dto.status, paidAt: dto.status === "paid" ? existing.paidAt ?? new Date() : existing.paidAt } : {}),
...(dto.recurrence !== undefined ? { recurrence: dto.recurrence } : {}),
...(dto.autopay !== undefined ? { autopay: dto.autopay } : {}),
...(dto.reminderDays !== undefined ? { reminderDays: dto.reminderDays } : {}),
...(dto.notes !== undefined ? { notes: dto.notes } : {}),
},
include: { payee: true, payments: { orderBy: { paidAt: "desc" } } },
});
await this.prisma.auditLog.create({
data: {
userId,
action: "bill_pay.bill_update",
metadata: { billId: bill.id, fields: Object.keys(dto) },
},
});
return this.withComputedStatus(bill);
}
async markPaid(userId: string, billId: string, dto: MarkBillPaidDto) {
const bill = await this.assertBill(userId, billId);
const paidAt = dto.paidAt ? this.parseDate(dto.paidAt, "paid date") : new Date();
const amount = new Prisma.Decimal(dto.amount ?? Number(bill.amount));
const payment = await this.prisma.billPayment.create({
data: {
userId,
billId: bill.id,
amount,
paidAt,
method: dto.method ?? "manual",
confirmationNumber: this.optionalString(dto.confirmationNumber),
notes: this.optionalString(dto.notes),
},
});
const updated = await this.prisma.bill.update({
where: { id: bill.id },
data: { status: "paid", paidAt },
include: { payee: true, payments: { orderBy: { paidAt: "desc" } } },
});
await this.prisma.auditLog.create({
data: {
userId,
action: "bill_pay.bill_paid",
metadata: {
billId: bill.id,
paymentId: payment.id,
amount: amount.toString(),
method: payment.method,
},
},
});
await this.notifications.notifyUser(userId, {
type: "bill.paid",
severity: "info",
title: `Bill paid: ${bill.name}`,
body: `${this.formatMoney(amount, bill.currency)} was recorded as paid.`,
metadata: { billId: bill.id, paymentId: payment.id },
});
return { bill: this.withComputedStatus(updated), payment };
}
async summary(userId: string) {
const bills = await this.listBills(userId, "all");
const now = new Date();
const next30 = new Date(now);
next30.setDate(next30.getDate() + 30);
const active = bills.filter((bill: any) => ACTIVE_STATUSES.includes(bill.status));
const upcoming = active.filter((bill: any) => new Date(bill.dueDate) <= next30);
const overdue = bills.filter((bill: any) => bill.computedStatus === "overdue");
const totalDueNext30 = upcoming.reduce((sum: Prisma.Decimal, bill: any) => sum.plus(bill.amount), new Prisma.Decimal(0));
return {
activeCount: active.length,
upcomingCount: upcoming.length,
overdueCount: overdue.length,
totalDueNext30: totalDueNext30.toString(),
nextBills: upcoming.slice(0, 5),
overdueBills: overdue.slice(0, 5),
};
}
private async assertPayee(userId: string, payeeId: string) {
const payee = await this.prisma.billPayee.findFirst({ where: { id: payeeId, userId } });
if (!payee) throw new BadRequestException("Bill payee not found.");
return payee;
}
private async assertBill(userId: string, billId: string) {
const bill = await this.prisma.bill.findFirst({ where: { id: billId, userId } });
if (!bill) throw new BadRequestException("Bill not found.");
return bill;
}
private withComputedStatus<T extends { dueDate: Date; status: string }>(bill: T) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const due = new Date(bill.dueDate);
due.setHours(0, 0, 0, 0);
return {
...bill,
computedStatus: ACTIVE_STATUSES.includes(bill.status) && due < today ? "overdue" : bill.status,
};
}
private isDueSoon(dueDate: Date, reminderDays: number) {
const now = new Date();
const threshold = new Date(now);
threshold.setDate(threshold.getDate() + reminderDays);
return dueDate <= threshold;
}
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 ? trimmed : null;
}
private formatMoney(amount: Prisma.Decimal, currency: string) {
return `${currency} ${amount.toFixed(2)}`;
}
}

132
src/bill-pay/dto.ts Normal file
View File

@ -0,0 +1,132 @@
import { IsBoolean, IsDateString, IsIn, IsInt, IsNumber, IsOptional, IsString, Max, Min } from "class-validator";
export class CreateBillPayeeDto {
@IsString()
name!: string;
@IsOptional()
@IsString()
nickname?: string;
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
website?: string;
@IsOptional()
@IsString()
accountNumberLast4?: string;
}
export class CreateBillDto {
@IsOptional()
@IsString()
payeeId?: string;
@IsString()
name!: string;
@IsNumber()
@Min(0.01)
amount!: number;
@IsOptional()
@IsString()
currency?: string;
@IsDateString()
dueDate!: string;
@IsOptional()
@IsIn(["pending", "scheduled"])
status?: "pending" | "scheduled";
@IsOptional()
@IsIn(["none", "weekly", "monthly", "quarterly", "yearly"])
recurrence?: "none" | "weekly" | "monthly" | "quarterly" | "yearly";
@IsOptional()
@IsBoolean()
autopay?: boolean;
@IsOptional()
@IsInt()
@Min(0)
@Max(30)
reminderDays?: number;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateBillDto {
@IsOptional()
@IsString()
payeeId?: string | null;
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsNumber()
@Min(0.01)
amount?: number;
@IsOptional()
@IsString()
currency?: string;
@IsOptional()
@IsDateString()
dueDate?: string;
@IsOptional()
@IsIn(["pending", "scheduled", "paid", "skipped", "cancelled"])
status?: "pending" | "scheduled" | "paid" | "skipped" | "cancelled";
@IsOptional()
@IsIn(["none", "weekly", "monthly", "quarterly", "yearly"])
recurrence?: "none" | "weekly" | "monthly" | "quarterly" | "yearly";
@IsOptional()
@IsBoolean()
autopay?: boolean;
@IsOptional()
@IsInt()
@Min(0)
@Max(30)
reminderDays?: number;
@IsOptional()
@IsString()
notes?: string | null;
}
export class MarkBillPaidDto {
@IsOptional()
@IsNumber()
@Min(0.01)
amount?: number;
@IsOptional()
@IsDateString()
paidAt?: string;
@IsOptional()
@IsIn(["manual", "autopay", "bank_bill_pay", "card", "ach", "check", "cash", "other"])
method?: string;
@IsOptional()
@IsString()
confirmationNumber?: string;
@IsOptional()
@IsString()
notes?: string;
}

View File

@ -0,0 +1,23 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { Observable } from "rxjs";
@Injectable()
export class BrowserUntrustedInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const response = context.switchToHttp().getResponse<{
setHeader?: (name: string, value: string) => void;
headersSent?: boolean;
}>();
if (response?.setHeader && !response.headersSent) {
response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, private");
response.setHeader("Pragma", "no-cache");
response.setHeader("Expires", "0");
response.setHeader("X-LedgerOne-Browser-Trust", "untrusted");
response.setHeader("X-LedgerOne-Data-Boundary", "server-authoritative");
response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()");
}
return next.handle();
}
}

View File

@ -1,10 +1,11 @@
import { Global, Module } from "@nestjs/common"; import { Global, Module } from "@nestjs/common";
import { EncryptionService } from "./encryption.service"; import { EncryptionService } from "./encryption.service";
import { OpaqueIdService } from "./opaque-id.service"; import { OpaqueIdService } from "./opaque-id.service";
import { ViewRefService } from "./view-ref.service";
@Global() @Global()
@Module({ @Module({
providers: [EncryptionService, OpaqueIdService], providers: [EncryptionService, OpaqueIdService, ViewRefService],
exports: [EncryptionService, OpaqueIdService], exports: [EncryptionService, OpaqueIdService, ViewRefService],
}) })
export class CommonModule {} export class CommonModule {}

View File

@ -0,0 +1,21 @@
import { Injectable } from "@nestjs/common";
import * as crypto from "crypto";
@Injectable()
export class ViewRefService {
create(userId: string, type: string, id: string) {
const day = new Date().toISOString().slice(0, 10);
const secret = process.env.JWT_SECRET ?? "ledgerone-view-boundary";
return crypto
.createHmac("sha256", secret)
.update(`${day}:${userId}:${type}:${id}`)
.digest("hex")
.slice(0, 24);
}
matches(userId: string, type: string, id: string, candidate: string) {
if (!/^[a-f0-9]{24}$/i.test(candidate)) return false;
const expected = this.create(userId, type, id);
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(candidate.toLowerCase()));
}
}

View File

@ -43,6 +43,9 @@ export const envValidationSchema = Joi.object({
SMTP_USER: Joi.string().optional().allow(""), SMTP_USER: Joi.string().optional().allow(""),
SMTP_PASS: Joi.string().optional().allow(""), SMTP_PASS: Joi.string().optional().allow(""),
SMTP_FROM: Joi.string().default("noreply@ledgerone.app"), SMTP_FROM: Joi.string().default("noreply@ledgerone.app"),
VAPID_PUBLIC_KEY: Joi.string().optional().allow(""),
VAPID_PRIVATE_KEY: Joi.string().optional().allow(""),
VAPID_SUBJECT: Joi.string().default("mailto:support@ledgerone.app"),
APP_URL: Joi.string().uri().default("http://localhost:3052"), APP_URL: Joi.string().uri().default("http://localhost:3052"),
PORT: Joi.number().default(3051), PORT: Joi.number().default(3051),

View File

@ -0,0 +1,25 @@
import { Body, Controller, Get, Post, Query } from "@nestjs/common";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { ok } from "../common/response";
import { CreditScoreService } from "./credit-score.service";
import { CreateCreditScoreEntryDto } from "./dto";
@Controller("credit-score")
export class CreditScoreController {
constructor(private readonly creditScoreService: CreditScoreService) {}
@Get("summary")
async summary(@CurrentUser() userId: string) {
return ok(await this.creditScoreService.summary(userId));
}
@Get("entries")
async entries(@CurrentUser() userId: string, @Query("bureau") bureau?: string) {
return ok(await this.creditScoreService.listEntries(userId, bureau));
}
@Post("entries")
async createEntry(@CurrentUser() userId: string, @Body() body: CreateCreditScoreEntryDto) {
return ok(await this.creditScoreService.createEntry(userId, body));
}
}

View File

@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { NotificationsModule } from "../notifications/notifications.module";
import { CreditScoreController } from "./credit-score.controller";
import { CreditScoreService } from "./credit-score.service";
@Module({
imports: [NotificationsModule],
controllers: [CreditScoreController],
providers: [CreditScoreService],
})
export class CreditScoreModule {}

View File

@ -0,0 +1,121 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { NotificationsService } from "../notifications/notifications.service";
import { PrismaService } from "../prisma/prisma.service";
import { CreateCreditScoreEntryDto } from "./dto";
@Injectable()
export class CreditScoreService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
) {}
async listEntries(userId: string, bureau?: string) {
return this.prisma.creditScoreEntry.findMany({
where: {
userId,
...(bureau && bureau !== "all" ? { bureau } : {}),
},
orderBy: [{ scoreDate: "desc" }, { createdAt: "desc" }],
take: 100,
});
}
async createEntry(userId: string, dto: CreateCreditScoreEntryDto) {
const scoreDate = new Date(dto.scoreDate);
if (Number.isNaN(scoreDate.getTime())) throw new BadRequestException("Invalid score date.");
const previous = await this.prisma.creditScoreEntry.findFirst({
where: {
userId,
bureau: dto.bureau ?? "unknown",
scoreDate: { lt: scoreDate },
},
orderBy: { scoreDate: "desc" },
});
const entry = await this.prisma.creditScoreEntry.create({
data: {
userId,
score: dto.score,
bureau: dto.bureau ?? "unknown",
source: dto.source ?? "manual",
model: dto.model ?? "vantage_score_3",
scoreDate,
factors: (dto.factors ?? {}) as Prisma.InputJsonValue,
metadata: (dto.metadata ?? {}) as Prisma.InputJsonValue,
},
});
const change = previous ? entry.score - previous.score : null;
await this.prisma.auditLog.create({
data: {
userId,
action: "credit_score.entry_create",
metadata: {
entryId: entry.id,
bureau: entry.bureau,
score: entry.score,
scoreDate: entry.scoreDate,
change,
},
},
});
if (change !== null && Math.abs(change) >= 20) {
await this.notifications.notifyUser(userId, {
type: "credit_score.change",
severity: change < 0 ? "warning" : "info",
title: change < 0 ? "Credit score dropped" : "Credit score improved",
body: `${this.label(entry.bureau)} score changed by ${change > 0 ? "+" : ""}${change} points to ${entry.score}.`,
metadata: { entryId: entry.id, bureau: entry.bureau, score: entry.score, change },
});
}
return {
...entry,
change,
};
}
async summary(userId: string) {
const entries = await this.listEntries(userId, "all");
const latestByBureau = new Map<string, any>();
for (const entry of entries) {
if (!latestByBureau.has(entry.bureau)) latestByBureau.set(entry.bureau, entry);
}
const latest = entries[0] ?? null;
const previous = latest
? entries.find((entry) => entry.id !== latest.id && entry.bureau === latest.bureau) ?? null
: null;
const change = latest && previous ? latest.score - previous.score : null;
return {
latest,
previous,
change,
averageScore: entries.length
? Math.round(entries.reduce((sum, entry) => sum + entry.score, 0) / entries.length)
: null,
entryCount: entries.length,
latestByBureau: Array.from(latestByBureau.values()),
trend: entries
.slice()
.reverse()
.map((entry) => ({
id: entry.id,
score: entry.score,
bureau: entry.bureau,
scoreDate: entry.scoreDate,
})),
};
}
private label(bureau: string) {
if (bureau === "experian") return "Experian";
if (bureau === "equifax") return "Equifax";
if (bureau === "transunion") return "TransUnion";
return "Credit";
}
}

31
src/credit-score/dto.ts Normal file
View File

@ -0,0 +1,31 @@
import { IsDateString, IsIn, IsInt, IsObject, IsOptional, IsString, Max, Min } from "class-validator";
export class CreateCreditScoreEntryDto {
@IsInt()
@Min(300)
@Max(850)
score!: number;
@IsOptional()
@IsIn(["experian", "equifax", "transunion", "unknown"])
bureau?: "experian" | "equifax" | "transunion" | "unknown";
@IsOptional()
@IsIn(["manual", "import", "provider"])
source?: "manual" | "import" | "provider";
@IsOptional()
@IsString()
model?: string;
@IsDateString()
scoreDate!: string;
@IsOptional()
@IsObject()
factors?: Record<string, unknown>;
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}

View File

@ -101,4 +101,27 @@ export class EmailService {
this.logger.error(`Failed to send household invite email to ${email}`, err); this.logger.error(`Failed to send household invite email to ${email}`, err);
} }
} }
async sendNotificationEmail(email: string, title: string, body: string, actionUrl?: string, severity = "info"): Promise<void> {
const url = actionUrl ?? `${this.appUrl}/notifications`;
try {
const info = await this.transporter.sendMail({
from: this.from,
to: email,
subject: `[LedgerOne ${severity.toUpperCase()}] ${title}`,
html: `
<h2>${title}</h2>
<p>${body}</p>
<p><a href="${url}" style="background:#316263;color:#B6FF3B;padding:12px 24px;text-decoration:none;border-radius:6px;display:inline-block;">Open LedgerOne</a></p>
<p>Or copy this link: ${url}</p>
`,
});
if (!process.env.SMTP_HOST) {
this.logger.log(`[DEV] Notification email for ${email}: ${title}`);
this.logger.debug(JSON.stringify(info));
}
} catch (err) {
this.logger.error(`Failed to send notification email to ${email}`, err);
}
}
} }

View File

@ -0,0 +1,54 @@
import { Body, Controller, Get, Headers, Param, Patch, Post, Query } from "@nestjs/common";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { ok } from "../common/response";
import { SavePushSubscriptionDto, UpdateNotificationPreferencesDto } from "./notifications.dto";
import { NotificationsService } from "./notifications.service";
@Controller("notifications")
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@Get()
async list(@CurrentUser() userId: string, @Query("unreadOnly") unreadOnly?: string) {
return ok(await this.notificationsService.list(userId, unreadOnly === "true"));
}
@Get("preferences")
async preferences(@CurrentUser() userId: string) {
return ok(await this.notificationsService.getPreferences(userId));
}
@Patch("preferences")
async updatePreferences(@CurrentUser() userId: string, @Body() body: UpdateNotificationPreferencesDto) {
return ok(await this.notificationsService.updatePreferences(userId, body));
}
@Get("vapid-public-key")
vapidPublicKey() {
return ok(this.notificationsService.getVapidStatus());
}
@Post("push-subscriptions")
async savePushSubscription(
@CurrentUser() userId: string,
@Body() body: SavePushSubscriptionDto,
@Headers("user-agent") userAgent?: string,
) {
return ok(await this.notificationsService.savePushSubscription(userId, body, userAgent));
}
@Patch(":id/read")
async markRead(@CurrentUser() userId: string, @Param("id") id: string) {
return ok(await this.notificationsService.markRead(userId, id));
}
@Post("read-all")
async markAllRead(@CurrentUser() userId: string) {
return ok(await this.notificationsService.markAllRead(userId));
}
@Post("test")
async test(@CurrentUser() userId: string) {
return ok(await this.notificationsService.sendTestNotification(userId));
}
}

View File

@ -0,0 +1,33 @@
import { IsBoolean, IsIn, IsOptional, IsString, IsUrl, ValidateNested } from "class-validator";
import { Type } from "class-transformer";
export class UpdateNotificationPreferencesDto {
@IsOptional()
@IsBoolean()
emailEnabled?: boolean;
@IsOptional()
@IsBoolean()
pushEnabled?: boolean;
@IsOptional()
@IsIn(["info", "warning", "critical"])
minSeverity?: "info" | "warning" | "critical";
}
class PushKeysDto {
@IsString()
p256dh!: string;
@IsString()
auth!: string;
}
export class SavePushSubscriptionDto {
@IsUrl({ require_tld: false })
endpoint!: string;
@ValidateNested()
@Type(() => PushKeysDto)
keys!: PushKeysDto;
}

View File

@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { NotificationsController } from "./notifications.controller";
import { NotificationsService } from "./notifications.service";
@Module({
controllers: [NotificationsController],
providers: [NotificationsService],
exports: [NotificationsService],
})
export class NotificationsModule {}

View File

@ -0,0 +1,241 @@
import { Injectable, Logger } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import * as webPush from "web-push";
import { EmailService } from "../email/email.service";
import { PrismaService } from "../prisma/prisma.service";
import { SavePushSubscriptionDto, UpdateNotificationPreferencesDto } from "./notifications.dto";
type NotificationSeverity = "info" | "warning" | "critical";
type NotifyUserInput = {
type: string;
severity?: NotificationSeverity;
title: string;
body: string;
metadata?: Record<string, unknown>;
};
const SEVERITY_RANK: Record<NotificationSeverity, number> = {
info: 1,
warning: 2,
critical: 3,
};
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
private readonly pushEnabled: boolean;
private readonly appUrl = process.env.APP_URL ?? "http://localhost:3052";
constructor(
private readonly prisma: PrismaService,
private readonly emailService: EmailService,
) {
const publicKey = process.env.VAPID_PUBLIC_KEY;
const privateKey = process.env.VAPID_PRIVATE_KEY;
const subject = process.env.VAPID_SUBJECT ?? "mailto:support@ledgerone.app";
this.pushEnabled = Boolean(publicKey && privateKey);
if (this.pushEnabled) {
webPush.setVapidDetails(subject, publicKey as string, privateKey as string);
}
}
getVapidStatus() {
return {
enabled: this.pushEnabled,
publicKey: this.pushEnabled ? process.env.VAPID_PUBLIC_KEY : null,
};
}
async list(userId: string, unreadOnly = false) {
return this.prisma.notification.findMany({
where: {
userId,
...(unreadOnly ? { readAt: null } : {}),
},
orderBy: { createdAt: "desc" },
take: 50,
});
}
async getPreferences(userId: string) {
const existing = await this.prisma.notificationPreference.findUnique({ where: { userId } });
if (existing) return existing;
return this.prisma.notificationPreference.create({
data: {
userId,
emailEnabled: true,
pushEnabled: false,
minSeverity: "info",
},
});
}
async updatePreferences(userId: string, dto: UpdateNotificationPreferencesDto) {
return this.prisma.notificationPreference.upsert({
where: { userId },
create: {
userId,
emailEnabled: dto.emailEnabled ?? true,
pushEnabled: dto.pushEnabled ?? false,
minSeverity: dto.minSeverity ?? "info",
},
update: {
...(dto.emailEnabled !== undefined ? { emailEnabled: dto.emailEnabled } : {}),
...(dto.pushEnabled !== undefined ? { pushEnabled: dto.pushEnabled } : {}),
...(dto.minSeverity ? { minSeverity: dto.minSeverity } : {}),
},
});
}
async savePushSubscription(userId: string, dto: SavePushSubscriptionDto, userAgent?: string) {
const subscription = await this.prisma.pushSubscription.upsert({
where: { endpoint: dto.endpoint },
create: {
userId,
endpoint: dto.endpoint,
p256dh: dto.keys.p256dh,
auth: dto.keys.auth,
userAgent,
},
update: {
userId,
p256dh: dto.keys.p256dh,
auth: dto.keys.auth,
userAgent,
revokedAt: null,
},
});
await this.updatePreferences(userId, { pushEnabled: true });
return subscription;
}
async markRead(userId: string, notificationId: string) {
return this.prisma.notification.updateMany({
where: { id: notificationId, userId },
data: { readAt: new Date() },
});
}
async markAllRead(userId: string) {
return this.prisma.notification.updateMany({
where: { userId, readAt: null },
data: { readAt: new Date() },
});
}
async sendTestNotification(userId: string) {
return this.notifyUser(userId, {
type: "notification.test",
severity: "info",
title: "LedgerOne notification test",
body: "SMTP and push notification delivery are configured for your account.",
metadata: { source: "settings_test" },
});
}
async notifyUser(userId: string, input: NotifyUserInput) {
const severity = input.severity ?? "info";
const channels = ["in_app"];
const notification = await this.prisma.notification.create({
data: {
userId,
type: input.type,
severity,
title: input.title,
body: input.body,
metadata: (input.metadata ?? {}) as Prisma.InputJsonValue,
channels: [...channels],
},
});
const [preferences, user] = await Promise.all([
this.getPreferences(userId),
this.prisma.user.findUnique({ where: { id: userId }, select: { email: true } }),
]);
if (this.shouldSend(preferences.minSeverity as NotificationSeverity, severity)) {
if (preferences.emailEnabled && user?.email) {
await this.emailService.sendNotificationEmail(
user.email,
input.title,
input.body,
`${this.appUrl}/notifications`,
severity,
);
channels.push("email");
}
if (preferences.pushEnabled && this.pushEnabled) {
const sent = await this.sendPushNotifications(userId, notification.id, input, severity);
if (sent > 0) channels.push("push");
}
}
return this.prisma.notification.update({
where: { id: notification.id },
data: { channels },
});
}
private shouldSend(minSeverity: NotificationSeverity, severity: NotificationSeverity) {
return SEVERITY_RANK[severity] >= SEVERITY_RANK[minSeverity ?? "info"];
}
private async sendPushNotifications(
userId: string,
notificationId: string,
input: NotifyUserInput,
severity: NotificationSeverity,
) {
const subscriptions = await this.prisma.pushSubscription.findMany({
where: { userId, revokedAt: null },
});
let sent = 0;
const payload = JSON.stringify({
title: input.title,
body: input.body,
url: "/notifications",
notificationId,
type: input.type,
severity,
});
for (const subscription of subscriptions) {
try {
await webPush.sendNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
},
payload,
);
sent += 1;
await this.prisma.pushSubscription.update({
where: { id: subscription.id },
data: { lastUsedAt: new Date() },
});
} catch (err) {
const statusCode = typeof err === "object" && err && "statusCode" in err
? Number((err as { statusCode?: number }).statusCode)
: 0;
if (statusCode === 404 || statusCode === 410) {
await this.prisma.pushSubscription.update({
where: { id: subscription.id },
data: { revokedAt: new Date() },
});
} else {
this.logger.warn(`Push notification failed for subscription ${subscription.id}`);
}
}
}
return sent;
}
}

View File

@ -1,16 +1,20 @@
import { Body, Controller, Headers, Post, Req } from "@nestjs/common"; import { BadRequestException, Body, Controller, Headers, Post, Req } from "@nestjs/common";
import { Request } from "express"; import { Request } from "express";
import { ok } from "../common/response"; import { ok } from "../common/response";
import { PlaidService } from "./plaid.service"; import { PlaidService } from "./plaid.service";
import { CurrentUser } from "../common/decorators/current-user.decorator"; import { CurrentUser } from "../common/decorators/current-user.decorator";
import { Public } from "../common/decorators/public.decorator"; import { Public } from "../common/decorators/public.decorator";
import { OpaqueIdService } from "../common/opaque-id.service"; import { OpaqueIdService } from "../common/opaque-id.service";
import { ViewRefService } from "../common/view-ref.service";
import { PrismaService } from "../prisma/prisma.service";
@Controller("plaid") @Controller("plaid")
export class PlaidController { export class PlaidController {
constructor( constructor(
private readonly plaidService: PlaidService, private readonly plaidService: PlaidService,
private readonly opaqueIds: OpaqueIdService, private readonly opaqueIds: OpaqueIdService,
private readonly viewRefs: ViewRefService,
private readonly prisma: PrismaService,
) {} ) {}
@Post("link-token") @Post("link-token")
@ -33,7 +37,7 @@ export class PlaidController {
@CurrentUser() userId: string, @CurrentUser() userId: string,
@Body() payload: { accountId: string }, @Body() payload: { accountId: string },
) { ) {
const accountId = this.opaqueIds.decode("account", userId, payload.accountId); const accountId = await this.resolveAccountHandle(userId, payload.accountId);
const data = await this.plaidService.createUpdateModeLinkToken(userId, accountId); const data = await this.plaidService.createUpdateModeLinkToken(userId, accountId);
return ok(data); return ok(data);
} }
@ -43,7 +47,7 @@ export class PlaidController {
@CurrentUser() userId: string, @CurrentUser() userId: string,
@Body() payload: { accountId: string }, @Body() payload: { accountId: string },
) { ) {
const accountId = this.opaqueIds.decode("account", userId, payload.accountId); const accountId = await this.resolveAccountHandle(userId, payload.accountId);
const data = await this.plaidService.markItemRepairComplete(userId, accountId); const data = await this.plaidService.markItemRepairComplete(userId, accountId);
return ok(data); return ok(data);
} }
@ -58,4 +62,18 @@ export class PlaidController {
const data = await this.plaidService.handleWebhook(payload, verification, request.rawBody); const data = await this.plaidService.handleWebhook(payload, verification, request.rawBody);
return ok(data); return ok(data);
} }
private async resolveAccountHandle(userId: string, handle: string) {
try {
return this.opaqueIds.decode("account", userId, handle);
} catch {
const accounts = await this.prisma.account.findMany({
where: { userId, isActive: true },
select: { id: true },
});
const match = accounts.find((account) => this.viewRefs.matches(userId, "account", account.id, handle));
if (!match) throw new BadRequestException("Invalid resource identifier.");
return match.id;
}
}
} }

View File

@ -5,6 +5,7 @@ import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { PlaidService } from "../plaid/plaid.service"; import { PlaidService } from "../plaid/plaid.service";
import { OpaqueIdService } from "../common/opaque-id.service"; import { OpaqueIdService } from "../common/opaque-id.service";
import { ViewRefService } from "../common/view-ref.service";
import { ExportsService } from "../exports/exports.service"; import { ExportsService } from "../exports/exports.service";
import { UpdateDerivedDto } from "./dto/update-derived.dto"; import { UpdateDerivedDto } from "./dto/update-derived.dto";
import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto"; import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto";
@ -153,6 +154,7 @@ export class TransactionsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly plaidService: PlaidService, private readonly plaidService: PlaidService,
private readonly opaqueIds: OpaqueIdService, private readonly opaqueIds: OpaqueIdService,
private readonly viewRefs: ViewRefService,
private readonly exportsService?: ExportsService, private readonly exportsService?: ExportsService,
) {} ) {}
@ -278,7 +280,7 @@ export class TransactionsService {
fileName: file.originalname, fileName: file.originalname,
headerSignature: signature, headerSignature: signature,
headers, headers,
sampleRows: rows.slice(0, 5), rowCount: rows.length,
mapping: remembered?.mapping ?? inferMapping(headers), mapping: remembered?.mapping ?? inferMapping(headers),
remembered: Boolean(remembered), remembered: Boolean(remembered),
}; };
@ -436,7 +438,7 @@ export class TransactionsService {
async createManualTransaction(userId: string, payload: CreateManualTransactionDto) { async createManualTransaction(userId: string, payload: CreateManualTransactionDto) {
const accountId = payload.accountId const accountId = payload.accountId
? this.opaqueIds.decode("account", userId, payload.accountId) ? await this.resolveAccountHandle(userId, payload.accountId)
: undefined; : undefined;
const account = accountId const account = accountId
? await this.prisma.account.findFirst({ where: { id: accountId, userId } }) ? await this.prisma.account.findFirst({ where: { id: accountId, userId } })
@ -485,7 +487,7 @@ export class TransactionsService {
} }
async updateDerived(userId: string, id: string, payload: UpdateDerivedDto) { async updateDerived(userId: string, id: string, payload: UpdateDerivedDto) {
const transactionId = this.opaqueIds.decode("transaction", userId, id); const transactionId = await this.resolveTransactionHandle(userId, id);
const attribution = this.normalizeAttribution(payload.attribution); const attribution = this.normalizeAttribution(payload.attribution);
const split = this.normalizeSplit(payload); const split = this.normalizeSplit(payload);
// Ensure the transaction belongs to the user // Ensure the transaction belongs to the user
@ -532,6 +534,35 @@ export class TransactionsService {
return derived; return derived;
} }
private async resolveAccountHandle(userId: string, handle: string) {
try {
return this.opaqueIds.decode("account", userId, handle);
} catch {
const accounts = await this.prisma.account.findMany({
where: { userId, isActive: true },
select: { id: true },
});
const match = accounts.find((account) => this.viewRefs.matches(userId, "account", account.id, handle));
if (!match) throw new BadRequestException("Invalid resource identifier.");
return match.id;
}
}
private async resolveTransactionHandle(userId: string, handle: string) {
try {
return this.opaqueIds.decode("transaction", userId, handle);
} catch {
const transactions = await this.prisma.transactionRaw.findMany({
where: { account: { userId } },
select: { id: true },
take: 5000,
});
const match = transactions.find((tx) => this.viewRefs.matches(userId, "transaction", tx.id, handle));
if (!match) throw new BadRequestException("Invalid resource identifier.");
return match.id;
}
}
async sync(userId: string, startDate: string, endDate: string) { async sync(userId: string, startDate: string, endDate: string) {
const result = await this.plaidService.syncTransactionsForUser(userId, startDate, endDate); const result = await this.plaidService.syncTransactionsForUser(userId, startDate, endDate);
await this.syncGoogleSheetsBestEffort(userId, "plaid_sync"); await this.syncGoogleSheetsBestEffort(userId, "plaid_sync");

View File

@ -0,0 +1,46 @@
import { Controller, Get, Query } from "@nestjs/common";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { ok } from "../common/response";
import { ViewService } from "./view.service";
@Controller("view")
export class ViewController {
constructor(private readonly viewService: ViewService) {}
@Get("accounts")
async accounts(
@CurrentUser() userId: string,
@Query("page") page = 1,
@Query("limit") limit = 25,
) {
return ok(await this.viewService.accounts(userId, +page, +limit));
}
@Get("transactions")
async transactions(
@CurrentUser() userId: string,
@Query("start_date") startDate?: string,
@Query("end_date") endDate?: string,
@Query("search") search?: string,
@Query("category") category?: string,
@Query("source") source?: string,
@Query("min_amount") minAmount?: string,
@Query("max_amount") maxAmount?: string,
@Query("include_hidden") includeHidden?: string,
@Query("page") page = 1,
@Query("limit") limit = 25,
) {
return ok(await this.viewService.transactions(userId, {
startDate,
endDate,
search,
category,
source,
minAmount,
maxAmount,
includeHidden,
page: +page,
limit: +limit,
}));
}
}

9
src/view/view.module.ts Normal file
View File

@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { ViewController } from "./view.controller";
import { ViewService } from "./view.service";
@Module({
controllers: [ViewController],
providers: [ViewService],
})
export class ViewModule {}

203
src/view/view.service.ts Normal file
View File

@ -0,0 +1,203 @@
import { Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { ViewRefService } from "../common/view-ref.service";
const VIEW_PAGE_SIZE_LIMIT = 25;
@Injectable()
export class ViewService {
constructor(
private readonly prisma: PrismaService,
private readonly viewRefs: ViewRefService,
) {}
async accounts(userId: string, page = 1, limit = VIEW_PAGE_SIZE_LIMIT) {
const take = this.clampLimit(limit);
const skip = (Math.max(page, 1) - 1) * take;
const [accounts, total] = await Promise.all([
this.prisma.account.findMany({
where: { userId, isActive: true },
orderBy: { createdAt: "desc" },
skip,
take,
select: {
id: true,
institutionName: true,
accountType: true,
mask: true,
currentBalance: true,
availableBalance: true,
isoCurrencyCode: true,
syncStatus: true,
lastSyncError: true,
syncConsecutiveFailures: true,
plaidWebhookCode: true,
plaidWebhookAt: true,
tellerAccountId: true,
ownershipType: true,
lastBalanceSync: true,
lastTransactionSync: true,
lastSyncAttemptAt: true,
},
}),
this.prisma.account.count({ where: { userId, isActive: true } }),
]);
return {
accounts: accounts.map((account) => ({
viewRef: this.viewRefs.create(userId, "account", account.id),
institutionName: account.institutionName,
accountType: account.accountType,
mask: account.mask,
currentBalance: this.money(account.currentBalance),
availableBalance: this.money(account.availableBalance),
isoCurrencyCode: account.isoCurrencyCode ?? "USD",
syncStatus: account.syncStatus,
lastSyncError: account.lastSyncError,
syncConsecutiveFailures: account.syncConsecutiveFailures,
plaidWebhookCode: account.plaidWebhookCode,
plaidWebhookAt: account.plaidWebhookAt,
tellerConnected: Boolean(account.tellerAccountId),
ownershipType: account.ownershipType,
lastBalanceSync: account.lastBalanceSync,
lastTransactionSync: account.lastTransactionSync,
lastSyncAttemptAt: account.lastSyncAttemptAt,
})),
total,
page: Math.max(page, 1),
limit: take,
};
}
async transactions(
userId: string,
filters: {
startDate?: string;
endDate?: string;
search?: string;
category?: string;
source?: string;
minAmount?: string;
maxAmount?: string;
includeHidden?: string;
page?: number;
limit?: number;
},
) {
const end = filters.endDate ? new Date(filters.endDate) : new Date();
const start = filters.startDate
? new Date(filters.startDate)
: new Date(new Date().setDate(end.getDate() - 30));
const where: Prisma.TransactionRawWhereInput = {
account: { userId },
date: { gte: start, lte: end },
};
if (filters.includeHidden !== "true") {
where.OR = [{ derived: null }, { derived: { isHidden: false } }];
}
if (filters.minAmount || filters.maxAmount) {
const min = filters.minAmount ? parseFloat(filters.minAmount) : undefined;
const max = filters.maxAmount ? parseFloat(filters.maxAmount) : undefined;
where.amount = { gte: min, lte: max };
}
if (filters.search) {
where.description = { contains: filters.search, mode: "insensitive" };
}
if (filters.category) {
where.derived = { is: { userCategory: { contains: filters.category, mode: "insensitive" } } };
}
if (filters.source) {
where.source = { contains: filters.source, mode: "insensitive" };
}
const take = this.clampLimit(filters.limit);
const page = Math.max(filters.page ?? 1, 1);
const skip = (page - 1) * take;
const [rows, total] = await Promise.all([
this.prisma.transactionRaw.findMany({
where,
orderBy: { date: "desc" },
take,
skip,
select: {
id: true,
date: true,
amount: true,
description: true,
source: true,
derived: {
select: {
userCategory: true,
userNotes: true,
attribution: true,
splitMode: true,
splitMinePercent: true,
splitYoursPercent: true,
isHidden: true,
},
},
account: {
select: {
institutionName: true,
accountType: true,
mask: true,
},
},
},
}),
this.prisma.transactionRaw.count({ where }),
]);
return {
transactions: rows.map((row) => ({
viewRef: this.viewRefs.create(userId, "transaction", row.id),
name: row.description,
date: row.date.toISOString().slice(0, 10),
description: row.description,
amount: Number(row.amount).toFixed(2),
category: row.derived?.userCategory ?? "Uncategorized",
note: row.derived?.userNotes ?? "",
source: row.source,
accountLabel: this.accountLabel(row.account),
attribution: row.derived?.attribution ?? null,
splitMode: row.derived?.splitMode ?? "none",
split: {
mode: row.derived?.splitMode ?? "none",
minePercent: row.derived?.splitMinePercent ?? 50,
yoursPercent: row.derived?.splitYoursPercent ?? 50,
mineAmount: this.splitAmount(row.amount, row.derived?.splitMinePercent),
yoursAmount: this.splitAmount(row.amount, row.derived?.splitYoursPercent),
},
status: row.derived ? "user" : "raw",
hidden: row.derived?.isHidden ?? false,
})),
total,
page,
limit: take,
};
}
private clampLimit(limit?: number) {
const requested = Number.isFinite(limit) && limit ? Number(limit) : VIEW_PAGE_SIZE_LIMIT;
return Math.min(Math.max(requested, 1), VIEW_PAGE_SIZE_LIMIT);
}
private money(value: unknown) {
if (value === null || value === undefined) return null;
return Number(value).toFixed(2);
}
private splitAmount(amount: unknown, percent?: unknown) {
return Number(((Number(amount) * Number(percent ?? 50)) / 100).toFixed(2));
}
private accountLabel(account?: { institutionName?: string | null; accountType?: string | null; mask?: string | null }) {
return [
account?.institutionName ?? "Account",
account?.accountType ? `(${account.accountType})` : "",
account?.mask ? `••${account.mask}` : "",
].filter(Boolean).join(" ");
}
}

View File

@ -0,0 +1,125 @@
/// <reference types="jest" />
import { Prisma } from "@prisma/client";
import { BillPayService } from "../src/bill-pay/bill-pay.service";
import { createPrismaMock } from "./utils/mock-prisma";
describe("BillPayService", () => {
const userId = "user_1";
let prisma: ReturnType<typeof createPrismaMock>;
let notifications: { notifyUser: jest.Mock };
let service: BillPayService;
beforeEach(() => {
prisma = createPrismaMock();
notifications = { notifyUser: jest.fn().mockResolvedValue({}) };
service = new BillPayService(prisma as never, notifications as never);
prisma.auditLog.create.mockResolvedValue({});
});
it("creates a bill and sends a notification", async () => {
const dueDate = new Date("2026-08-01T00:00:00.000Z");
prisma.bill.create.mockResolvedValue({
id: "bill_1",
userId,
name: "Electric",
amount: new Prisma.Decimal(125),
currency: "USD",
dueDate,
status: "pending",
recurrence: "monthly",
autopay: false,
reminderDays: 3,
payee: null,
payments: [],
createdAt: dueDate,
updatedAt: dueDate,
});
const bill = await service.createBill(userId, {
name: "Electric",
amount: 125,
dueDate: "2026-08-01",
recurrence: "monthly",
});
expect(prisma.bill.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
userId,
name: "Electric",
amount: expect.any(Prisma.Decimal),
recurrence: "monthly",
}),
}));
expect(notifications.notifyUser).toHaveBeenCalledWith(userId, expect.objectContaining({
type: "bill.created",
title: "Bill added: Electric",
}));
expect(bill.computedStatus).toBe("pending");
});
it("marks a bill paid and records a payment", async () => {
const dueDate = new Date("2026-08-01T00:00:00.000Z");
prisma.bill.findFirst.mockResolvedValue({
id: "bill_1",
userId,
name: "Internet",
amount: new Prisma.Decimal(80),
currency: "USD",
dueDate,
status: "pending",
});
prisma.billPayment.create.mockResolvedValue({
id: "payment_1",
billId: "bill_1",
userId,
amount: new Prisma.Decimal(80),
method: "bank_bill_pay",
});
prisma.bill.update.mockResolvedValue({
id: "bill_1",
userId,
name: "Internet",
amount: new Prisma.Decimal(80),
currency: "USD",
dueDate,
status: "paid",
paidAt: new Date("2026-08-02T00:00:00.000Z"),
payee: null,
payments: [],
});
const result = await service.markPaid(userId, "bill_1", {
method: "bank_bill_pay",
confirmationNumber: "ABC123",
paidAt: "2026-08-02",
});
expect(prisma.billPayment.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
billId: "bill_1",
method: "bank_bill_pay",
confirmationNumber: "ABC123",
}),
}));
expect(prisma.bill.update).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ status: "paid" }),
}));
expect(result.bill.computedStatus).toBe("paid");
});
it("summarizes upcoming and overdue bills", async () => {
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
const nextWeek = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
prisma.bill.findMany.mockResolvedValue([
{ id: "b1", userId, name: "Overdue", amount: new Prisma.Decimal(10), dueDate: yesterday, status: "pending", payments: [] },
{ id: "b2", userId, name: "Soon", amount: new Prisma.Decimal(20), dueDate: nextWeek, status: "scheduled", payments: [] },
]);
const result = await service.summary(userId);
expect(result.activeCount).toBe(2);
expect(result.overdueCount).toBe(1);
expect(result.totalDueNext30).toBe("30");
});
});

View File

@ -0,0 +1,71 @@
/// <reference types="jest" />
import { CreditScoreService } from "../src/credit-score/credit-score.service";
import { createPrismaMock } from "./utils/mock-prisma";
describe("CreditScoreService", () => {
const userId = "user_1";
let prisma: ReturnType<typeof createPrismaMock>;
let notifications: { notifyUser: jest.Mock };
let service: CreditScoreService;
beforeEach(() => {
prisma = createPrismaMock();
notifications = { notifyUser: jest.fn().mockResolvedValue({}) };
service = new CreditScoreService(prisma as never, notifications as never);
prisma.auditLog.create.mockResolvedValue({});
});
it("creates a score entry and sends an alert for a large drop", async () => {
prisma.creditScoreEntry.findFirst.mockResolvedValue({
id: "old_1",
userId,
score: 760,
bureau: "experian",
scoreDate: new Date("2026-06-01T00:00:00.000Z"),
});
prisma.creditScoreEntry.create.mockResolvedValue({
id: "score_1",
userId,
score: 730,
bureau: "experian",
source: "manual",
model: "fico_8",
scoreDate: new Date("2026-07-01T00:00:00.000Z"),
factors: {},
metadata: {},
});
const result = await service.createEntry(userId, {
score: 730,
bureau: "experian",
model: "fico_8",
scoreDate: "2026-07-01",
});
expect(result.change).toBe(-30);
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ action: "credit_score.entry_create" }),
}));
expect(notifications.notifyUser).toHaveBeenCalledWith(userId, expect.objectContaining({
type: "credit_score.change",
severity: "warning",
}));
});
it("summarizes latest score, bureau snapshots, and trend", async () => {
prisma.creditScoreEntry.findMany.mockResolvedValue([
{ id: "s2", score: 720, bureau: "equifax", scoreDate: new Date("2026-07-05T00:00:00.000Z") },
{ id: "s1", score: 700, bureau: "equifax", scoreDate: new Date("2026-06-05T00:00:00.000Z") },
{ id: "s3", score: 740, bureau: "experian", scoreDate: new Date("2026-07-01T00:00:00.000Z") },
]);
const summary = await service.summary(userId);
expect(summary.latest.id).toBe("s2");
expect(summary.change).toBe(20);
expect(summary.averageScore).toBe(720);
expect(summary.latestByBureau).toHaveLength(2);
expect(summary.trend).toHaveLength(3);
});
});

View File

@ -0,0 +1,103 @@
/// <reference types="jest" />
import { NotificationsService } from "../src/notifications/notifications.service";
import { createPrismaMock } from "./utils/mock-prisma";
describe("NotificationsService", () => {
const userId = "user-1";
let prisma: ReturnType<typeof createPrismaMock>;
let emailService: { sendNotificationEmail: jest.Mock };
let service: NotificationsService;
beforeEach(() => {
prisma = createPrismaMock();
emailService = { sendNotificationEmail: jest.fn().mockResolvedValue(undefined) };
service = new NotificationsService(prisma as never, emailService as never);
prisma.notification.create.mockResolvedValue({
id: "notification-1",
userId,
type: "test",
severity: "info",
title: "Test",
body: "Body",
channels: ["in_app"],
});
prisma.notification.update.mockImplementation(({ data }) => Promise.resolve({ id: "notification-1", ...data }));
prisma.notificationPreference.findUnique.mockResolvedValue({
userId,
emailEnabled: true,
pushEnabled: false,
minSeverity: "info",
});
prisma.user.findUnique.mockResolvedValue({ email: "user@example.com" });
});
it("creates an in-app notification and sends SMTP email when enabled", async () => {
const result = await service.notifyUser(userId, {
type: "test",
title: "Test",
body: "Body",
});
expect(prisma.notification.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ userId, channels: ["in_app"] }),
}));
expect(emailService.sendNotificationEmail).toHaveBeenCalledWith(
"user@example.com",
"Test",
"Body",
expect.stringContaining("/notifications"),
"info",
);
expect(result.channels).toEqual(["in_app", "email"]);
});
it("does not send email below the configured severity threshold", async () => {
prisma.notificationPreference.findUnique.mockResolvedValue({
userId,
emailEnabled: true,
pushEnabled: false,
minSeverity: "critical",
});
await service.notifyUser(userId, {
type: "test",
severity: "warning",
title: "Warning",
body: "Body",
});
expect(emailService.sendNotificationEmail).not.toHaveBeenCalled();
expect(prisma.notification.update).toHaveBeenCalledWith(expect.objectContaining({
data: { channels: ["in_app"] },
}));
});
it("stores a push subscription and enables push preferences", async () => {
prisma.pushSubscription.upsert.mockResolvedValue({ id: "push-1", userId });
prisma.notificationPreference.upsert.mockResolvedValue({
userId,
emailEnabled: true,
pushEnabled: true,
minSeverity: "info",
});
await service.savePushSubscription(
userId,
{
endpoint: "https://push.example.test/sub",
keys: { p256dh: "p256dh", auth: "auth" },
},
"jest",
);
expect(prisma.pushSubscription.upsert).toHaveBeenCalledWith(expect.objectContaining({
create: expect.objectContaining({ userId, userAgent: "jest" }),
update: expect.objectContaining({ userId, revokedAt: null }),
}));
expect(prisma.notificationPreference.upsert).toHaveBeenCalledWith(expect.objectContaining({
update: { pushEnabled: true },
}));
});
});

View File

@ -9,9 +9,10 @@ const createService = () => {
encode: jest.fn((kind: string, _userId: string, id: string) => `opaque_${kind}_${id}`), encode: jest.fn((kind: string, _userId: string, id: string) => `opaque_${kind}_${id}`),
decode: jest.fn((_kind: string, _userId: string, token: string) => token.replace(/^opaque_[^_]+_/, "")), decode: jest.fn((_kind: string, _userId: string, token: string) => token.replace(/^opaque_[^_]+_/, "")),
}; };
const viewRefs = { matches: jest.fn() };
const exportsService = { syncGoogleSheets: jest.fn().mockResolvedValue({ status: "synced" }) }; const exportsService = { syncGoogleSheets: jest.fn().mockResolvedValue({ status: "synced" }) };
const service = new TransactionsService(prisma as any, plaid as any, opaqueIds as any, exportsService as any); const service = new TransactionsService(prisma as any, plaid as any, opaqueIds as any, viewRefs as any, exportsService as any);
return { service, prisma, plaid, opaqueIds, exportsService }; return { service, prisma, plaid, opaqueIds, viewRefs, exportsService };
}; };
describe("TransactionsService", () => { describe("TransactionsService", () => {
@ -231,7 +232,8 @@ describe("TransactionsService", () => {
amount: "Amount", amount: "Amount",
})); }));
expect(result.remembered).toBe(false); expect(result.remembered).toBe(false);
expect(result.sampleRows).toHaveLength(1); expect(result.rowCount).toBe(1);
expect(result).not.toHaveProperty("sampleRows");
}); });
it("uses mapped CSV import and remembers the mapping", async () => { it("uses mapped CSV import and remembers the mapping", async () => {

View File

@ -84,6 +84,41 @@ export const createPrismaMock = () => ({
create: jest.fn(), create: jest.fn(),
findMany: jest.fn() findMany: jest.fn()
}, },
notificationPreference: {
create: jest.fn(),
findUnique: jest.fn(),
upsert: jest.fn()
},
notification: {
create: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
updateMany: jest.fn()
},
pushSubscription: {
findMany: jest.fn(),
upsert: jest.fn(),
update: jest.fn()
},
billPayee: {
create: jest.fn(),
findFirst: jest.fn(),
findMany: jest.fn()
},
bill: {
create: jest.fn(),
findFirst: jest.fn(),
findMany: jest.fn(),
update: jest.fn()
},
billPayment: {
create: jest.fn()
},
creditScoreEntry: {
create: jest.fn(),
findFirst: jest.fn(),
findMany: jest.fn()
},
rule: { rule: {
count: jest.fn(), count: jest.fn(),
create: jest.fn(), create: jest.fn(),

99
test/view.service.spec.ts Normal file
View File

@ -0,0 +1,99 @@
/// <reference types="jest" />
import { Prisma } from "@prisma/client";
import { ViewService } from "../src/view/view.service";
import { createPrismaMock } from "./utils/mock-prisma";
describe("ViewService", () => {
const userId = "user_1";
let prisma: ReturnType<typeof createPrismaMock>;
let service: ViewService;
beforeEach(() => {
prisma = createPrismaMock();
service = new ViewService(
prisma as never,
{ create: jest.fn((userId, type, id) => `${type}_${id}_view`) } as never,
);
});
it("returns account presentation data without stable database or provider ids", async () => {
prisma.account.findMany.mockResolvedValue([
{
id: "acct_1",
institutionName: "Bank",
accountType: "checking",
mask: "1234",
currentBalance: new Prisma.Decimal(100),
availableBalance: new Prisma.Decimal(90),
isoCurrencyCode: "USD",
syncStatus: "synced",
lastSyncError: null,
syncConsecutiveFailures: 0,
plaidWebhookCode: null,
plaidWebhookAt: null,
tellerAccountId: null,
ownershipType: "mine",
lastBalanceSync: new Date("2026-07-16T00:00:00.000Z"),
lastTransactionSync: null,
lastSyncAttemptAt: null,
},
]);
prisma.account.count.mockResolvedValue(1);
const result = await service.accounts(userId, 1, 100);
expect(prisma.account.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 25 }));
expect(result.accounts[0]).toEqual(expect.objectContaining({
viewRef: expect.any(String),
institutionName: "Bank",
currentBalance: "100.00",
}));
expect(result.accounts[0].viewRef).not.toBe("acct_1");
expect(result.accounts[0]).not.toHaveProperty("id");
expect(result.accounts[0]).not.toHaveProperty("plaidAccountId");
expect(result.accounts[0]).not.toHaveProperty("tellerAccountId");
expect(result.limit).toBe(25);
});
it("returns transaction presentation data without raw payloads or action-capable ids", async () => {
prisma.transactionRaw.findMany.mockResolvedValue([
{
id: "tx_1",
date: new Date("2026-07-15T00:00:00.000Z"),
amount: new Prisma.Decimal(42.5),
description: "Coffee",
source: "plaid",
derived: {
userCategory: "Food",
userNotes: "",
attribution: "mine",
splitMode: "none",
splitMinePercent: 50,
splitYoursPercent: 50,
isHidden: false,
},
account: { institutionName: "Bank", accountType: "checking", mask: "1234" },
},
]);
prisma.transactionRaw.count.mockResolvedValue(1);
const result = await service.transactions(userId, { limit: 100 });
expect(prisma.transactionRaw.findMany).toHaveBeenCalledWith(expect.objectContaining({
take: 25,
select: expect.not.objectContaining({ rawPayload: true, bankTransactionId: true, accountId: true }),
}));
expect(result.transactions[0]).toEqual(expect.objectContaining({
viewRef: expect.any(String),
description: "Coffee",
amount: "42.50",
accountLabel: "Bank (checking) ••1234",
}));
expect(result.transactions[0]).not.toHaveProperty("id");
expect(result.transactions[0]).not.toHaveProperty("rawPayload");
expect(result.transactions[0]).not.toHaveProperty("bankTransactionId");
expect(result.transactions[0]).not.toHaveProperty("accountId");
expect(result.limit).toBe(25);
});
});