Implement browser-safe finance presentation APIs
This commit is contained in:
parent
cce14fa5c6
commit
5dc1cd796b
@ -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
|
||||
|
||||
84
package-lock.json
generated
84
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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": {
|
||||
|
||||
52
prisma/migrations/20260716001300_notifications/migration.sql
Normal file
52
prisma/migrations/20260716001300_notifications/migration.sql
Normal 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;
|
||||
63
prisma/migrations/20260716001400_bill_pay/migration.sql
Normal file
63
prisma/migrations/20260716001400_bill_pay/migration.sql
Normal 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;
|
||||
@ -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;
|
||||
@ -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 {
|
||||
|
||||
@ -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 {}
|
||||
|
||||
45
src/bill-pay/bill-pay.controller.ts
Normal file
45
src/bill-pay/bill-pay.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
11
src/bill-pay/bill-pay.module.ts
Normal file
11
src/bill-pay/bill-pay.module.ts
Normal 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 {}
|
||||
235
src/bill-pay/bill-pay.service.ts
Normal file
235
src/bill-pay/bill-pay.service.ts
Normal 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
132
src/bill-pay/dto.ts
Normal 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;
|
||||
}
|
||||
23
src/common/browser-untrusted.interceptor.ts
Normal file
23
src/common/browser-untrusted.interceptor.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
21
src/common/view-ref.service.ts
Normal file
21
src/common/view-ref.service.ts
Normal 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()));
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
|
||||
25
src/credit-score/credit-score.controller.ts
Normal file
25
src/credit-score/credit-score.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
11
src/credit-score/credit-score.module.ts
Normal file
11
src/credit-score/credit-score.module.ts
Normal 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 {}
|
||||
121
src/credit-score/credit-score.service.ts
Normal file
121
src/credit-score/credit-score.service.ts
Normal 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
31
src/credit-score/dto.ts
Normal 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>;
|
||||
}
|
||||
@ -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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
54
src/notifications/notifications.controller.ts
Normal file
54
src/notifications/notifications.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
33
src/notifications/notifications.dto.ts
Normal file
33
src/notifications/notifications.dto.ts
Normal 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;
|
||||
}
|
||||
10
src/notifications/notifications.module.ts
Normal file
10
src/notifications/notifications.module.ts
Normal 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 {}
|
||||
241
src/notifications/notifications.service.ts
Normal file
241
src/notifications/notifications.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
46
src/view/view.controller.ts
Normal file
46
src/view/view.controller.ts
Normal 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
9
src/view/view.module.ts
Normal 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
203
src/view/view.service.ts
Normal 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(" ");
|
||||
}
|
||||
}
|
||||
125
test/bill-pay.service.spec.ts
Normal file
125
test/bill-pay.service.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
71
test/credit-score.service.spec.ts
Normal file
71
test/credit-score.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
103
test/notifications.service.spec.ts
Normal file
103
test/notifications.service.spec.ts
Normal 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 },
|
||||
}));
|
||||
});
|
||||
});
|
||||
@ -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 () => {
|
||||
|
||||
@ -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(),
|
||||
|
||||
99
test/view.service.spec.ts
Normal file
99
test/view.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user