diff --git a/.env.example b/.env.example index 4f6908a..e00178b 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,9 @@ SMTP_PORT=587 SMTP_USER= SMTP_PASS= SMTP_FROM=noreply@ledgerone.app +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT=mailto:support@ledgerone.app APP_URL=http://localhost:3052 PORT=3051 diff --git a/package-lock.json b/package-lock.json index b77a0f1..7bff3c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@supabase/supabase-js": "^2.49.1", "@types/qrcode": "^1.5.6", "@types/speakeasy": "^2.0.10", + "@types/web-push": "^3.6.4", "bcryptjs": "^2.4.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.4", @@ -41,6 +42,7 @@ "speakeasy": "^2.0.0", "stripe": "^20.4.0", "swagger-ui-express": "^5.0.1", + "web-push": "^3.6.7", "xlsx": "^0.18.5" }, "devDependencies": { @@ -3370,6 +3372,15 @@ "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "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": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -3800,6 +3811,18 @@ "dev": true, "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": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -4034,6 +4057,12 @@ "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": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -6120,6 +6149,15 @@ "dev": true, "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -7814,6 +7852,12 @@ "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": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -10413,6 +10457,46 @@ "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": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", diff --git a/package.json b/package.json index 53e4485..8158405 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@supabase/supabase-js": "^2.49.1", "@types/qrcode": "^1.5.6", "@types/speakeasy": "^2.0.10", + "@types/web-push": "^3.6.4", "bcryptjs": "^2.4.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.4", @@ -45,6 +46,7 @@ "speakeasy": "^2.0.0", "stripe": "^20.4.0", "swagger-ui-express": "^5.0.1", + "web-push": "^3.6.7", "xlsx": "^0.18.5" }, "devDependencies": { diff --git a/prisma/migrations/20260716001300_notifications/migration.sql b/prisma/migrations/20260716001300_notifications/migration.sql new file mode 100644 index 0000000..41ba1e6 --- /dev/null +++ b/prisma/migrations/20260716001300_notifications/migration.sql @@ -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; diff --git a/prisma/migrations/20260716001400_bill_pay/migration.sql b/prisma/migrations/20260716001400_bill_pay/migration.sql new file mode 100644 index 0000000..3da42f3 --- /dev/null +++ b/prisma/migrations/20260716001400_bill_pay/migration.sql @@ -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; diff --git a/prisma/migrations/20260716001500_credit_score_tracking/migration.sql b/prisma/migrations/20260716001500_credit_score_tracking/migration.sql new file mode 100644 index 0000000..048e735 --- /dev/null +++ b/prisma/migrations/20260716001500_credit_score_tracking/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dbb4517..47e9023 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -49,6 +49,144 @@ model User { acceptedHouseholdInvites HouseholdInvite[] @relation("HouseholdInviteAccepter") ownedAccounts Account[] @relation("AccountOwnerUser") 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 { diff --git a/src/app.module.ts b/src/app.module.ts index 6f29b05..2d07487 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,7 +1,7 @@ import { Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; 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 { CommonModule } from "./common/common.module"; @@ -24,8 +24,13 @@ import { AbuseModule } from "./abuse/abuse.module"; import { PublicApiModule } from "./public-api/public-api.module"; import { AdminModule } from "./admin/admin.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 { JwtAuthGuard } from "./common/guards/jwt-auth.guard"; +import { BrowserUntrustedInterceptor } from "./common/browser-untrusted.interceptor"; @Module({ imports: [ @@ -79,12 +84,18 @@ import { JwtAuthGuard } from "./common/guards/jwt-auth.guard"; PublicApiModule, AdminModule, HouseholdsModule, + NotificationsModule, + BillPayModule, + CreditScoreModule, + ViewModule, ], providers: [ // Apply rate limiting globally { provide: APP_GUARD, useClass: ThrottlerGuard }, // Apply JWT auth globally (routes decorated with @Public() are exempt) { provide: APP_GUARD, useClass: JwtAuthGuard }, + // Treat browser/API clients as untrusted presentation clients by default. + { provide: APP_INTERCEPTOR, useClass: BrowserUntrustedInterceptor }, ], }) export class AppModule {} diff --git a/src/bill-pay/bill-pay.controller.ts b/src/bill-pay/bill-pay.controller.ts new file mode 100644 index 0000000..0e89e2a --- /dev/null +++ b/src/bill-pay/bill-pay.controller.ts @@ -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)); + } +} diff --git a/src/bill-pay/bill-pay.module.ts b/src/bill-pay/bill-pay.module.ts new file mode 100644 index 0000000..48463a2 --- /dev/null +++ b/src/bill-pay/bill-pay.module.ts @@ -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 {} diff --git a/src/bill-pay/bill-pay.service.ts b/src/bill-pay/bill-pay.service.ts new file mode 100644 index 0000000..f794116 --- /dev/null +++ b/src/bill-pay/bill-pay.service.ts @@ -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(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)}`; + } +} diff --git a/src/bill-pay/dto.ts b/src/bill-pay/dto.ts new file mode 100644 index 0000000..9f048ae --- /dev/null +++ b/src/bill-pay/dto.ts @@ -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; +} diff --git a/src/common/browser-untrusted.interceptor.ts b/src/common/browser-untrusted.interceptor.ts new file mode 100644 index 0000000..c75ae88 --- /dev/null +++ b/src/common/browser-untrusted.interceptor.ts @@ -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 { + 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(); + } +} diff --git a/src/common/common.module.ts b/src/common/common.module.ts index 40d2c45..280e7c8 100644 --- a/src/common/common.module.ts +++ b/src/common/common.module.ts @@ -1,10 +1,11 @@ import { Global, Module } from "@nestjs/common"; import { EncryptionService } from "./encryption.service"; import { OpaqueIdService } from "./opaque-id.service"; +import { ViewRefService } from "./view-ref.service"; @Global() @Module({ - providers: [EncryptionService, OpaqueIdService], - exports: [EncryptionService, OpaqueIdService], + providers: [EncryptionService, OpaqueIdService, ViewRefService], + exports: [EncryptionService, OpaqueIdService, ViewRefService], }) export class CommonModule {} diff --git a/src/common/view-ref.service.ts b/src/common/view-ref.service.ts new file mode 100644 index 0000000..abbae2c --- /dev/null +++ b/src/common/view-ref.service.ts @@ -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())); + } +} diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index a60a46b..467b534 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -43,6 +43,9 @@ export const envValidationSchema = Joi.object({ SMTP_USER: Joi.string().optional().allow(""), SMTP_PASS: Joi.string().optional().allow(""), 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"), PORT: Joi.number().default(3051), diff --git a/src/credit-score/credit-score.controller.ts b/src/credit-score/credit-score.controller.ts new file mode 100644 index 0000000..261e2cf --- /dev/null +++ b/src/credit-score/credit-score.controller.ts @@ -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)); + } +} diff --git a/src/credit-score/credit-score.module.ts b/src/credit-score/credit-score.module.ts new file mode 100644 index 0000000..f31e4a2 --- /dev/null +++ b/src/credit-score/credit-score.module.ts @@ -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 {} diff --git a/src/credit-score/credit-score.service.ts b/src/credit-score/credit-score.service.ts new file mode 100644 index 0000000..91f0efa --- /dev/null +++ b/src/credit-score/credit-score.service.ts @@ -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(); + 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"; + } +} diff --git a/src/credit-score/dto.ts b/src/credit-score/dto.ts new file mode 100644 index 0000000..3290570 --- /dev/null +++ b/src/credit-score/dto.ts @@ -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; + + @IsOptional() + @IsObject() + metadata?: Record; +} diff --git a/src/email/email.service.ts b/src/email/email.service.ts index 6518e65..e3a0d1b 100644 --- a/src/email/email.service.ts +++ b/src/email/email.service.ts @@ -101,4 +101,27 @@ export class EmailService { 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 { + const url = actionUrl ?? `${this.appUrl}/notifications`; + try { + const info = await this.transporter.sendMail({ + from: this.from, + to: email, + subject: `[LedgerOne ${severity.toUpperCase()}] ${title}`, + html: ` +

${title}

+

${body}

+

Open LedgerOne

+

Or copy this link: ${url}

+ `, + }); + 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); + } + } } diff --git a/src/notifications/notifications.controller.ts b/src/notifications/notifications.controller.ts new file mode 100644 index 0000000..8ed2a96 --- /dev/null +++ b/src/notifications/notifications.controller.ts @@ -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)); + } +} diff --git a/src/notifications/notifications.dto.ts b/src/notifications/notifications.dto.ts new file mode 100644 index 0000000..995d586 --- /dev/null +++ b/src/notifications/notifications.dto.ts @@ -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; +} diff --git a/src/notifications/notifications.module.ts b/src/notifications/notifications.module.ts new file mode 100644 index 0000000..3afff78 --- /dev/null +++ b/src/notifications/notifications.module.ts @@ -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 {} diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts new file mode 100644 index 0000000..bb66d8c --- /dev/null +++ b/src/notifications/notifications.service.ts @@ -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; +}; + +const SEVERITY_RANK: Record = { + 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; + } +} diff --git a/src/plaid/plaid.controller.ts b/src/plaid/plaid.controller.ts index 75cd0f1..cb761a9 100644 --- a/src/plaid/plaid.controller.ts +++ b/src/plaid/plaid.controller.ts @@ -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 { ok } from "../common/response"; import { PlaidService } from "./plaid.service"; import { CurrentUser } from "../common/decorators/current-user.decorator"; import { Public } from "../common/decorators/public.decorator"; import { OpaqueIdService } from "../common/opaque-id.service"; +import { ViewRefService } from "../common/view-ref.service"; +import { PrismaService } from "../prisma/prisma.service"; @Controller("plaid") export class PlaidController { constructor( private readonly plaidService: PlaidService, private readonly opaqueIds: OpaqueIdService, + private readonly viewRefs: ViewRefService, + private readonly prisma: PrismaService, ) {} @Post("link-token") @@ -33,7 +37,7 @@ export class PlaidController { @CurrentUser() userId: 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); return ok(data); } @@ -43,7 +47,7 @@ export class PlaidController { @CurrentUser() userId: 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); return ok(data); } @@ -58,4 +62,18 @@ export class PlaidController { const data = await this.plaidService.handleWebhook(payload, verification, request.rawBody); 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; + } + } } diff --git a/src/transactions/transactions.service.ts b/src/transactions/transactions.service.ts index b477a08..c1d5fe5 100644 --- a/src/transactions/transactions.service.ts +++ b/src/transactions/transactions.service.ts @@ -5,6 +5,7 @@ import { Prisma } from "@prisma/client"; import { PrismaService } from "../prisma/prisma.service"; import { PlaidService } from "../plaid/plaid.service"; import { OpaqueIdService } from "../common/opaque-id.service"; +import { ViewRefService } from "../common/view-ref.service"; import { ExportsService } from "../exports/exports.service"; import { UpdateDerivedDto } from "./dto/update-derived.dto"; import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto"; @@ -153,6 +154,7 @@ export class TransactionsService { private readonly prisma: PrismaService, private readonly plaidService: PlaidService, private readonly opaqueIds: OpaqueIdService, + private readonly viewRefs: ViewRefService, private readonly exportsService?: ExportsService, ) {} @@ -278,7 +280,7 @@ export class TransactionsService { fileName: file.originalname, headerSignature: signature, headers, - sampleRows: rows.slice(0, 5), + rowCount: rows.length, mapping: remembered?.mapping ?? inferMapping(headers), remembered: Boolean(remembered), }; @@ -436,7 +438,7 @@ export class TransactionsService { async createManualTransaction(userId: string, payload: CreateManualTransactionDto) { const accountId = payload.accountId - ? this.opaqueIds.decode("account", userId, payload.accountId) + ? await this.resolveAccountHandle(userId, payload.accountId) : undefined; const account = accountId ? await this.prisma.account.findFirst({ where: { id: accountId, userId } }) @@ -485,7 +487,7 @@ export class TransactionsService { } 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 split = this.normalizeSplit(payload); // Ensure the transaction belongs to the user @@ -532,6 +534,35 @@ export class TransactionsService { 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) { const result = await this.plaidService.syncTransactionsForUser(userId, startDate, endDate); await this.syncGoogleSheetsBestEffort(userId, "plaid_sync"); diff --git a/src/view/view.controller.ts b/src/view/view.controller.ts new file mode 100644 index 0000000..f757858 --- /dev/null +++ b/src/view/view.controller.ts @@ -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, + })); + } +} diff --git a/src/view/view.module.ts b/src/view/view.module.ts new file mode 100644 index 0000000..5824c81 --- /dev/null +++ b/src/view/view.module.ts @@ -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 {} diff --git a/src/view/view.service.ts b/src/view/view.service.ts new file mode 100644 index 0000000..0d43a55 --- /dev/null +++ b/src/view/view.service.ts @@ -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(" "); + } +} diff --git a/test/bill-pay.service.spec.ts b/test/bill-pay.service.spec.ts new file mode 100644 index 0000000..0496d38 --- /dev/null +++ b/test/bill-pay.service.spec.ts @@ -0,0 +1,125 @@ +/// + +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; + 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"); + }); +}); diff --git a/test/credit-score.service.spec.ts b/test/credit-score.service.spec.ts new file mode 100644 index 0000000..3f70492 --- /dev/null +++ b/test/credit-score.service.spec.ts @@ -0,0 +1,71 @@ +/// + +import { CreditScoreService } from "../src/credit-score/credit-score.service"; +import { createPrismaMock } from "./utils/mock-prisma"; + +describe("CreditScoreService", () => { + const userId = "user_1"; + let prisma: ReturnType; + 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); + }); +}); diff --git a/test/notifications.service.spec.ts b/test/notifications.service.spec.ts new file mode 100644 index 0000000..4c5b074 --- /dev/null +++ b/test/notifications.service.spec.ts @@ -0,0 +1,103 @@ +/// + +import { NotificationsService } from "../src/notifications/notifications.service"; +import { createPrismaMock } from "./utils/mock-prisma"; + +describe("NotificationsService", () => { + const userId = "user-1"; + let prisma: ReturnType; + 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 }, + })); + }); +}); diff --git a/test/transactions.service.spec.ts b/test/transactions.service.spec.ts index bb51474..f777859 100644 --- a/test/transactions.service.spec.ts +++ b/test/transactions.service.spec.ts @@ -9,9 +9,10 @@ const createService = () => { encode: jest.fn((kind: string, _userId: string, id: string) => `opaque_${kind}_${id}`), 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 service = new TransactionsService(prisma as any, plaid as any, opaqueIds as any, exportsService as any); - return { service, prisma, plaid, opaqueIds, exportsService }; + const service = new TransactionsService(prisma as any, plaid as any, opaqueIds as any, viewRefs as any, exportsService as any); + return { service, prisma, plaid, opaqueIds, viewRefs, exportsService }; }; describe("TransactionsService", () => { @@ -231,7 +232,8 @@ describe("TransactionsService", () => { amount: "Amount", })); 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 () => { diff --git a/test/utils/mock-prisma.ts b/test/utils/mock-prisma.ts index 2b228ab..a2e55f1 100644 --- a/test/utils/mock-prisma.ts +++ b/test/utils/mock-prisma.ts @@ -84,6 +84,41 @@ export const createPrismaMock = () => ({ create: 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: { count: jest.fn(), create: jest.fn(), diff --git a/test/view.service.spec.ts b/test/view.service.spec.ts new file mode 100644 index 0000000..403dd42 --- /dev/null +++ b/test/view.service.spec.ts @@ -0,0 +1,99 @@ +/// + +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; + 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); + }); +});