implemented all the new changes
This commit is contained in:
parent
d1a5a1d1a0
commit
21a8f093d1
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
coverage
|
||||
.git
|
||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@ -0,0 +1,27 @@
|
||||
# ─── Stage 1: Build ──────────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
# ─── Stage 2: Production ─────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS production
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev --legacy-peer-deps
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
|
||||
EXPOSE 3051
|
||||
|
||||
CMD ["node", "dist/main.js"]
|
||||
2275
package-lock.json
generated
2275
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@ -14,23 +14,47 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.3.4",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^10.3.4",
|
||||
"@nestjs/platform-express": "^10.3.4",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/platform-express": "^10.3.4",
|
||||
"@nestjs/swagger": "^11.2.6",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@prisma/client": "^5.18.0",
|
||||
"@sentry/node": "^10.40.0",
|
||||
"@supabase/supabase-js": "^2.49.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/speakeasy": "^2.0.10",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.4",
|
||||
"csv-parse": "^6.1.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"googleapis": "^171.4.0",
|
||||
"helmet": "^8.1.0",
|
||||
"joi": "^18.0.2",
|
||||
"nestjs-pino": "^4.6.0",
|
||||
"nodemailer": "^8.0.1",
|
||||
"otplib": "^13.3.0",
|
||||
"pino-http": "^11.0.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"plaid": "^17.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1"
|
||||
"rxjs": "^7.8.1",
|
||||
"speakeasy": "^2.0.0",
|
||||
"stripe": "^20.4.0",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.2",
|
||||
"@nestjs/schematics": "^10.1.1",
|
||||
"@nestjs/testing": "^10.3.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^20.11.20",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^2.0.16",
|
||||
"jest": "^29.7.0",
|
||||
"prisma": "^5.18.0",
|
||||
|
||||
@ -8,99 +8,108 @@ datasource db {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
fullName String?
|
||||
phone String?
|
||||
companyName String?
|
||||
addressLine1 String?
|
||||
addressLine2 String?
|
||||
city String?
|
||||
state String?
|
||||
postalCode String?
|
||||
country String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
fullName String?
|
||||
phone String?
|
||||
companyName String?
|
||||
addressLine1 String?
|
||||
addressLine2 String?
|
||||
city String?
|
||||
state String?
|
||||
postalCode String?
|
||||
country String?
|
||||
emailVerified Boolean @default(false)
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorSecret String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accounts Account[]
|
||||
rules Rule[]
|
||||
exports ExportLog[]
|
||||
auditLogs AuditLog[]
|
||||
accounts Account[]
|
||||
rules Rule[]
|
||||
exports ExportLog[]
|
||||
auditLogs AuditLog[]
|
||||
googleConnection GoogleConnection?
|
||||
emailVerificationToken EmailVerificationToken?
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
refreshTokens RefreshToken[]
|
||||
subscription Subscription?
|
||||
taxReturns TaxReturn[]
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
institutionName String
|
||||
accountType String
|
||||
mask String?
|
||||
plaidAccessToken String?
|
||||
plaidItemId String?
|
||||
plaidAccountId String? @unique
|
||||
plaidAccountId String? @unique
|
||||
currentBalance Decimal?
|
||||
availableBalance Decimal?
|
||||
isoCurrencyCode String?
|
||||
lastBalanceSync DateTime?
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
transactionsRaw TransactionRaw[]
|
||||
}
|
||||
|
||||
model TransactionRaw {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
accountId String
|
||||
bankTransactionId String @unique
|
||||
bankTransactionId String @unique
|
||||
date DateTime
|
||||
amount Decimal
|
||||
description String
|
||||
rawPayload Json
|
||||
ingestedAt DateTime @default(now())
|
||||
ingestedAt DateTime @default(now())
|
||||
source String
|
||||
|
||||
account Account @relation(fields: [accountId], references: [id])
|
||||
account Account @relation(fields: [accountId], references: [id])
|
||||
derived TransactionDerived?
|
||||
ruleExecutions RuleExecution[]
|
||||
}
|
||||
|
||||
model TransactionDerived {
|
||||
id String @id @default(uuid())
|
||||
rawTransactionId String @unique
|
||||
id String @id @default(uuid())
|
||||
rawTransactionId String @unique
|
||||
userCategory String?
|
||||
userNotes String?
|
||||
isHidden Boolean @default(false)
|
||||
modifiedAt DateTime @default(now())
|
||||
isHidden Boolean @default(false)
|
||||
modifiedAt DateTime @default(now())
|
||||
modifiedBy String
|
||||
|
||||
raw TransactionRaw @relation(fields: [rawTransactionId], references: [id])
|
||||
}
|
||||
|
||||
model Rule {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
name String
|
||||
priority Int
|
||||
conditions Json
|
||||
actions Json
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
executions RuleExecution[]
|
||||
}
|
||||
|
||||
model RuleExecution {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
ruleId String
|
||||
transactionId String
|
||||
executedAt DateTime @default(now())
|
||||
executedAt DateTime @default(now())
|
||||
result Json
|
||||
|
||||
rule Rule @relation(fields: [ruleId], references: [id])
|
||||
transaction TransactionRaw @relation(fields: [transactionId], references: [id])
|
||||
rule Rule @relation(fields: [ruleId], references: [id])
|
||||
transaction TransactionRaw @relation(fields: [transactionId], references: [id])
|
||||
}
|
||||
|
||||
model ExportLog {
|
||||
@ -122,3 +131,90 @@ model AuditLog {
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
}
|
||||
|
||||
model GoogleConnection {
|
||||
id String @id @default(uuid())
|
||||
userId String @unique
|
||||
googleEmail String
|
||||
refreshToken String
|
||||
accessToken String?
|
||||
spreadsheetId String?
|
||||
isConnected Boolean @default(true)
|
||||
connectedAt DateTime @default(now())
|
||||
lastSyncedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model EmailVerificationToken {
|
||||
id String @id @default(uuid())
|
||||
userId String @unique
|
||||
token String @unique
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model PasswordResetToken {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
token String @unique
|
||||
expiresAt DateTime
|
||||
usedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
revokedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
id String @id @default(uuid())
|
||||
userId String @unique
|
||||
plan String @default("free")
|
||||
stripeCustomerId String?
|
||||
stripeSubId String?
|
||||
currentPeriodEnd DateTime?
|
||||
cancelAtPeriodEnd Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model TaxReturn {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
taxYear Int
|
||||
filingType String
|
||||
jurisdictions Json
|
||||
status String @default("draft")
|
||||
summary Json @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
documents TaxDocument[]
|
||||
}
|
||||
|
||||
model TaxDocument {
|
||||
id String @id @default(uuid())
|
||||
taxReturnId String
|
||||
docType String
|
||||
metadata Json @default("{}")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
taxReturn TaxReturn @relation(fields: [taxReturnId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
407
seed-demo.mjs
Normal file
407
seed-demo.mjs
Normal file
@ -0,0 +1,407 @@
|
||||
/**
|
||||
* LedgerOne Demo Account Seed Script
|
||||
* Creates a fully-populated demo account for testing all features.
|
||||
*
|
||||
* Usage: node seed-demo.mjs
|
||||
* Creds: demo@ledgerone.app / Demo1234!
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import * as crypto from "crypto";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const DEMO_EMAIL = "demo@ledgerone.app";
|
||||
const DEMO_PASSWORD = "Demo1234!";
|
||||
|
||||
function hashPassword(password) {
|
||||
const salt = crypto.randomBytes(16).toString("hex");
|
||||
const hash = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex");
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
function daysAgo(n) {
|
||||
const d = new Date();
|
||||
d.setHours(12, 0, 0, 0);
|
||||
d.setDate(d.getDate() - n);
|
||||
return d;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("🧹 Cleaning up existing demo account...");
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email: DEMO_EMAIL } });
|
||||
if (existing) {
|
||||
const uid = existing.id;
|
||||
// Delete leaf models first, then parents
|
||||
await prisma.auditLog.deleteMany({ where: { userId: uid } });
|
||||
await prisma.refreshToken.deleteMany({ where: { userId: uid } });
|
||||
await prisma.emailVerificationToken.deleteMany({ where: { userId: uid } });
|
||||
await prisma.passwordResetToken.deleteMany({ where: { userId: uid } });
|
||||
await prisma.subscription.deleteMany({ where: { userId: uid } });
|
||||
await prisma.exportLog.deleteMany({ where: { userId: uid } });
|
||||
await prisma.googleConnection.deleteMany({ where: { userId: uid } });
|
||||
|
||||
const taxReturns = await prisma.taxReturn.findMany({ where: { userId: uid } });
|
||||
for (const tr of taxReturns) {
|
||||
await prisma.taxDocument.deleteMany({ where: { taxReturnId: tr.id } });
|
||||
}
|
||||
await prisma.taxReturn.deleteMany({ where: { userId: uid } });
|
||||
|
||||
const accounts = await prisma.account.findMany({ where: { userId: uid } });
|
||||
for (const account of accounts) {
|
||||
const txRaws = await prisma.transactionRaw.findMany({ where: { accountId: account.id } });
|
||||
for (const tx of txRaws) {
|
||||
await prisma.ruleExecution.deleteMany({ where: { transactionId: tx.id } });
|
||||
await prisma.transactionDerived.deleteMany({ where: { rawTransactionId: tx.id } });
|
||||
}
|
||||
await prisma.transactionRaw.deleteMany({ where: { accountId: account.id } });
|
||||
}
|
||||
await prisma.account.deleteMany({ where: { userId: uid } });
|
||||
|
||||
const rules = await prisma.rule.findMany({ where: { userId: uid } });
|
||||
for (const rule of rules) {
|
||||
await prisma.ruleExecution.deleteMany({ where: { ruleId: rule.id } });
|
||||
}
|
||||
await prisma.rule.deleteMany({ where: { userId: uid } });
|
||||
|
||||
await prisma.user.delete({ where: { id: uid } });
|
||||
console.log(" ✓ Removed previous demo account");
|
||||
}
|
||||
|
||||
// ── User ──────────────────────────────────────────────────────────────────
|
||||
console.log("\n👤 Creating demo user...");
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: DEMO_EMAIL,
|
||||
passwordHash: hashPassword(DEMO_PASSWORD),
|
||||
fullName: "Alex Chen",
|
||||
emailVerified: true, // skip email verification step
|
||||
companyName: "LedgerOne Demo Corp",
|
||||
city: "San Francisco",
|
||||
state: "CA",
|
||||
country: "US",
|
||||
},
|
||||
});
|
||||
console.log(` ✓ ${user.email} (id: ${user.id})`);
|
||||
|
||||
// ── Subscription ──────────────────────────────────────────────────────────
|
||||
await prisma.subscription.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
plan: "pro",
|
||||
currentPeriodEnd: new Date(Date.now() + 30 * 86_400_000),
|
||||
cancelAtPeriodEnd: false,
|
||||
},
|
||||
});
|
||||
console.log(" ✓ Pro subscription");
|
||||
|
||||
// ── Accounts ──────────────────────────────────────────────────────────────
|
||||
console.log("\n🏦 Creating accounts...");
|
||||
const checking = await prisma.account.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
institutionName: "Chase Bank",
|
||||
accountType: "checking",
|
||||
mask: "4521",
|
||||
currentBalance: 12847.53,
|
||||
availableBalance: 12347.53,
|
||||
isoCurrencyCode: "USD",
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
const credit = await prisma.account.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
institutionName: "American Express",
|
||||
accountType: "credit",
|
||||
mask: "2834",
|
||||
currentBalance: -2341.88, // negative = you owe this
|
||||
availableBalance: 7658.12,
|
||||
isoCurrencyCode: "USD",
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
const savings = await prisma.account.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
institutionName: "Ally Bank",
|
||||
accountType: "savings",
|
||||
mask: "9012",
|
||||
currentBalance: 28500.00,
|
||||
availableBalance: 28500.00,
|
||||
isoCurrencyCode: "USD",
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
console.log(" ✓ Chase Checking *4521");
|
||||
console.log(" ✓ Amex Credit *2834");
|
||||
console.log(" ✓ Ally Savings *9012");
|
||||
|
||||
// ── Transactions ──────────────────────────────────────────────────────────
|
||||
// Convention: positive = money leaving (expense), negative = money entering (income/deposit)
|
||||
const txDataset = [
|
||||
// ── INCOME (checking) ──
|
||||
{ acct: checking, d: 2, amt: -5800.00, desc: "PAYROLL DIRECT DEPOSIT - ACME CORP", cat: "Income" },
|
||||
{ acct: checking, d: 32, amt: -5800.00, desc: "PAYROLL DIRECT DEPOSIT - ACME CORP", cat: "Income" },
|
||||
{ acct: checking, d: 62, amt: -5800.00, desc: "PAYROLL DIRECT DEPOSIT - ACME CORP", cat: "Income" },
|
||||
{ acct: checking, d: 92, amt: -5800.00, desc: "PAYROLL DIRECT DEPOSIT - ACME CORP", cat: "Income" },
|
||||
{ acct: checking, d: 122, amt: -5800.00, desc: "PAYROLL DIRECT DEPOSIT - ACME CORP", cat: "Income" },
|
||||
{ acct: checking, d: 152, amt: -5800.00, desc: "PAYROLL DIRECT DEPOSIT - ACME CORP", cat: "Income" },
|
||||
{ acct: savings, d: 5, amt: -200.00, desc: "INTEREST EARNED - ALLY BANK", cat: "Income" },
|
||||
{ acct: savings, d: 35, amt: -194.50, desc: "INTEREST EARNED - ALLY BANK", cat: "Income" },
|
||||
{ acct: savings, d: 65, amt: -201.20, desc: "INTEREST EARNED - ALLY BANK", cat: "Income" },
|
||||
|
||||
// ── RENT (checking) ──
|
||||
{ acct: checking, d: 5, amt: 2200.00, desc: "ACH - RENT - HARBOR VIEW APARTMENTS", cat: "Rent & Mortgage" },
|
||||
{ acct: checking, d: 35, amt: 2200.00, desc: "ACH - RENT - HARBOR VIEW APARTMENTS", cat: "Rent & Mortgage" },
|
||||
{ acct: checking, d: 65, amt: 2200.00, desc: "ACH - RENT - HARBOR VIEW APARTMENTS", cat: "Rent & Mortgage" },
|
||||
{ acct: checking, d: 95, amt: 2200.00, desc: "ACH - RENT - HARBOR VIEW APARTMENTS", cat: "Rent & Mortgage" },
|
||||
{ acct: checking, d: 125, amt: 2200.00, desc: "ACH - RENT - HARBOR VIEW APARTMENTS", cat: "Rent & Mortgage" },
|
||||
{ acct: checking, d: 155, amt: 2200.00, desc: "ACH - RENT - HARBOR VIEW APARTMENTS", cat: "Rent & Mortgage" },
|
||||
|
||||
// ── UTILITIES (checking) ──
|
||||
{ acct: checking, d: 8, amt: 94.50, desc: "PG&E ELECTRIC BILL PAYMENT", cat: "Utilities" },
|
||||
{ acct: checking, d: 38, amt: 87.20, desc: "PG&E ELECTRIC BILL PAYMENT", cat: "Utilities" },
|
||||
{ acct: checking, d: 68, amt: 112.80, desc: "PG&E ELECTRIC BILL PAYMENT", cat: "Utilities" },
|
||||
{ acct: checking, d: 98, amt: 103.40, desc: "PG&E ELECTRIC BILL PAYMENT", cat: "Utilities" },
|
||||
{ acct: checking, d: 10, amt: 65.00, desc: "COMCAST XFINITY INTERNET", cat: "Utilities" },
|
||||
{ acct: checking, d: 40, amt: 65.00, desc: "COMCAST XFINITY INTERNET", cat: "Utilities" },
|
||||
{ acct: checking, d: 70, amt: 65.00, desc: "COMCAST XFINITY INTERNET", cat: "Utilities" },
|
||||
{ acct: checking, d: 100, amt: 65.00, desc: "COMCAST XFINITY INTERNET", cat: "Utilities" },
|
||||
|
||||
// ── GROCERIES (checking) ──
|
||||
{ acct: checking, d: 3, amt: 127.43, desc: "WHOLE FOODS MARKET #1234 SAN FRANCISCO", cat: "Groceries" },
|
||||
{ acct: checking, d: 11, amt: 89.22, desc: "TRADER JOE S #456 SF", cat: "Groceries" },
|
||||
{ acct: checking, d: 18, amt: 145.67, desc: "WHOLE FOODS MARKET #1234 SAN FRANCISCO", cat: "Groceries" },
|
||||
{ acct: checking, d: 25, amt: 73.11, desc: "SAFEWAY #789", cat: "Groceries" },
|
||||
{ acct: checking, d: 33, amt: 118.54, desc: "WHOLE FOODS MARKET #1234 SAN FRANCISCO", cat: "Groceries" },
|
||||
{ acct: checking, d: 45, amt: 92.30, desc: "TRADER JOE S #456 SF", cat: "Groceries" },
|
||||
{ acct: checking, d: 55, amt: 131.20, desc: "WHOLE FOODS MARKET #1234 SAN FRANCISCO", cat: "Groceries" },
|
||||
{ acct: checking, d: 70, amt: 85.75, desc: "SAFEWAY #789", cat: "Groceries" },
|
||||
{ acct: checking, d: 82, amt: 104.88, desc: "WHOLE FOODS MARKET #1234 SAN FRANCISCO", cat: "Groceries" },
|
||||
{ acct: checking, d: 105, amt: 76.42, desc: "TRADER JOE S #456 SF", cat: "Groceries" },
|
||||
|
||||
// ── HEALTHCARE (checking) ──
|
||||
{ acct: checking, d: 15, amt: 30.00, desc: "CVS PHARMACY #1122", cat: "Healthcare" },
|
||||
{ acct: checking, d: 60, amt: 250.00, desc: "KAISER PERMANENTE COPAY", cat: "Healthcare" },
|
||||
{ acct: checking, d: 110, amt: 45.00, desc: "CVS PHARMACY #1122", cat: "Healthcare" },
|
||||
{ acct: checking, d: 140, amt: 180.00, desc: "UCSF MEDICAL CENTER", cat: "Healthcare" },
|
||||
|
||||
// ── DINING (credit) ──
|
||||
{ acct: credit, d: 1, amt: 68.40, desc: "NOBU RESTAURANT SF", cat: "Dining & Restaurants" },
|
||||
{ acct: credit, d: 4, amt: 14.50, desc: "STARBUCKS #3421 SAN FRANCISCO CA", cat: "Dining & Restaurants" },
|
||||
{ acct: credit, d: 7, amt: 42.80, desc: "CHIPOTLE MEXICAN GRILL", cat: "Dining & Restaurants" },
|
||||
{ acct: credit, d: 13, amt: 89.20, desc: "MOURAD RESTAURANT SF", cat: "Dining & Restaurants" },
|
||||
{ acct: credit, d: 16, amt: 23.60, desc: "SWEETGREEN #55 SF", cat: "Dining & Restaurants" },
|
||||
{ acct: credit, d: 22, amt: 112.50, desc: "BENU RESTAURANT SAN FRANCISCO", cat: "Dining & Restaurants" },
|
||||
{ acct: credit, d: 29, amt: 18.90, desc: "STARBUCKS #3421 SAN FRANCISCO CA", cat: "Dining & Restaurants" },
|
||||
{ acct: credit, d: 36, amt: 55.30, desc: "IN-N-OUT BURGER #77", cat: "Dining & Restaurants" },
|
||||
{ acct: credit, d: 48, amt: 78.10, desc: "NOBU RESTAURANT SF", cat: "Dining & Restaurants" },
|
||||
{ acct: credit, d: 75, amt: 32.40, desc: "CHIPOTLE MEXICAN GRILL", cat: "Dining & Restaurants" },
|
||||
{ acct: credit, d: 90, amt: 44.20, desc: "THE FRENCH LAUNDRY YOUNTVILLE", cat: "Dining & Restaurants" },
|
||||
|
||||
// ── SUBSCRIPTIONS (credit) ──
|
||||
{ acct: credit, d: 9, amt: 15.99, desc: "NETFLIX.COM SUBSCRIPTION", cat: "Subscriptions" },
|
||||
{ acct: credit, d: 9, amt: 9.99, desc: "SPOTIFY PREMIUM", cat: "Subscriptions" },
|
||||
{ acct: credit, d: 9, amt: 14.99, desc: "OPENAI CHATGPT PLUS", cat: "Subscriptions" },
|
||||
{ acct: credit, d: 9, amt: 19.99, desc: "GITHUB COPILOT SUBSCRIPTION", cat: "Subscriptions" },
|
||||
{ acct: credit, d: 39, amt: 15.99, desc: "NETFLIX.COM SUBSCRIPTION", cat: "Subscriptions" },
|
||||
{ acct: credit, d: 39, amt: 9.99, desc: "SPOTIFY PREMIUM", cat: "Subscriptions" },
|
||||
{ acct: credit, d: 39, amt: 14.99, desc: "OPENAI CHATGPT PLUS", cat: "Subscriptions" },
|
||||
{ acct: credit, d: 39, amt: 19.99, desc: "GITHUB COPILOT SUBSCRIPTION", cat: "Subscriptions" },
|
||||
{ acct: credit, d: 69, amt: 15.99, desc: "NETFLIX.COM SUBSCRIPTION", cat: "Subscriptions" },
|
||||
{ acct: credit, d: 69, amt: 9.99, desc: "SPOTIFY PREMIUM", cat: "Subscriptions" },
|
||||
|
||||
// ── ENTERTAINMENT (credit) ──
|
||||
{ acct: credit, d: 20, amt: 24.99, desc: "AMC THEATRES TICKET SAN FRANCISCO", cat: "Entertainment" },
|
||||
{ acct: credit, d: 85, amt: 189.00, desc: "GOLDEN STATE WARRIORS - CHASE CENTER", cat: "Entertainment" },
|
||||
{ acct: credit, d: 130, amt: 95.00, desc: "TICKETMASTER CONCERT TICKETS", cat: "Entertainment" },
|
||||
|
||||
// ── TRANSPORTATION (credit) ──
|
||||
{ acct: credit, d: 2, amt: 18.40, desc: "UBER TRIP SAN FRANCISCO CA", cat: "Transportation" },
|
||||
{ acct: credit, d: 6, amt: 22.10, desc: "UBER TRIP SAN FRANCISCO CA", cat: "Transportation" },
|
||||
{ acct: credit, d: 13, amt: 15.80, desc: "LYFT RIDE SAN FRANCISCO", cat: "Transportation" },
|
||||
{ acct: credit, d: 27, amt: 89.00, desc: "SHELL OIL GAS STATION #4421", cat: "Transportation" },
|
||||
{ acct: credit, d: 48, amt: 76.50, desc: "SHELL OIL GAS STATION #4421", cat: "Transportation" },
|
||||
{ acct: credit, d: 58, amt: 21.60, desc: "UBER TRIP SAN FRANCISCO CA", cat: "Transportation" },
|
||||
{ acct: credit, d: 72, amt: 88.00, desc: "BART CLIPPER CARD RELOAD", cat: "Transportation" },
|
||||
|
||||
// ── SHOPPING (credit) ──
|
||||
{ acct: credit, d: 14, amt: 243.50, desc: "AMAZON.COM PURCHASE", cat: "Shopping" },
|
||||
{ acct: credit, d: 30, amt: 89.99, desc: "AMAZON.COM PURCHASE", cat: "Shopping" },
|
||||
{ acct: credit, d: 53, amt: 1450.00, desc: "APPLE.COM/BILL - IPAD PRO", cat: "Shopping" },
|
||||
{ acct: credit, d: 66, amt: 178.00, desc: "NORDSTROM #0234 SF", cat: "Shopping" },
|
||||
{ acct: credit, d: 90, amt: 340.00, desc: "BEST BUY #1234 DALY CITY", cat: "Shopping" },
|
||||
{ acct: credit, d: 115, amt: 67.50, desc: "AMAZON.COM PURCHASE", cat: "Shopping" },
|
||||
|
||||
// ── CREDIT CARD PAYMENTS (checking) ──
|
||||
{ acct: checking, d: 20, amt: 1800.00, desc: "AMEX AUTOPAY - CREDIT CARD PAYMENT", cat: "Transfer" },
|
||||
{ acct: checking, d: 50, amt: 2100.00, desc: "AMEX AUTOPAY - CREDIT CARD PAYMENT", cat: "Transfer" },
|
||||
{ acct: checking, d: 80, amt: 1650.00, desc: "AMEX AUTOPAY - CREDIT CARD PAYMENT", cat: "Transfer" },
|
||||
{ acct: checking, d: 110, amt: 1950.00, desc: "AMEX AUTOPAY - CREDIT CARD PAYMENT", cat: "Transfer" },
|
||||
|
||||
// ── SAVINGS TRANSFERS (checking → savings) ──
|
||||
{ acct: checking, d: 3, amt: 500.00, desc: "TRANSFER TO ALLY SAVINGS *9012", cat: "Transfer" },
|
||||
{ acct: checking, d: 33, amt: 500.00, desc: "TRANSFER TO ALLY SAVINGS *9012", cat: "Transfer" },
|
||||
{ acct: checking, d: 63, amt: 500.00, desc: "TRANSFER TO ALLY SAVINGS *9012", cat: "Transfer" },
|
||||
];
|
||||
|
||||
console.log("\n💳 Creating transactions...");
|
||||
let txCount = 0;
|
||||
for (const [i, tx] of txDataset.entries()) {
|
||||
const raw = await prisma.transactionRaw.create({
|
||||
data: {
|
||||
accountId: tx.acct.id,
|
||||
bankTransactionId: `demo-${user.id.slice(0, 8)}-${String(i).padStart(3, "0")}`,
|
||||
date: daysAgo(tx.d),
|
||||
amount: tx.amt,
|
||||
description: tx.desc,
|
||||
source: "manual",
|
||||
rawPayload: { demo: true },
|
||||
},
|
||||
});
|
||||
await prisma.transactionDerived.create({
|
||||
data: {
|
||||
rawTransactionId: raw.id,
|
||||
userCategory: tx.cat,
|
||||
isHidden: false,
|
||||
modifiedBy: user.id,
|
||||
},
|
||||
});
|
||||
txCount++;
|
||||
}
|
||||
console.log(` ✓ ${txCount} transactions created`);
|
||||
|
||||
// ── Categorization Rules ──────────────────────────────────────────────────
|
||||
console.log("\n📋 Creating categorization rules...");
|
||||
const rules = [
|
||||
{
|
||||
name: "Whole Foods → Groceries",
|
||||
priority: 10,
|
||||
conditions: { operator: "AND", filters: [{ field: "description", op: "contains", value: "WHOLE FOODS" }] },
|
||||
actions: [{ type: "setCategory", value: "Groceries" }],
|
||||
},
|
||||
{
|
||||
name: "Trader Joe's → Groceries",
|
||||
priority: 10,
|
||||
conditions: { operator: "AND", filters: [{ field: "description", op: "contains", value: "TRADER JOE" }] },
|
||||
actions: [{ type: "setCategory", value: "Groceries" }],
|
||||
},
|
||||
{
|
||||
name: "Netflix → Subscriptions",
|
||||
priority: 10,
|
||||
conditions: { operator: "AND", filters: [{ field: "description", op: "contains", value: "NETFLIX" }] },
|
||||
actions: [{ type: "setCategory", value: "Subscriptions" }],
|
||||
},
|
||||
{
|
||||
name: "Uber/Lyft → Transportation",
|
||||
priority: 10,
|
||||
conditions: { operator: "OR", filters: [
|
||||
{ field: "description", op: "contains", value: "UBER" },
|
||||
{ field: "description", op: "contains", value: "LYFT" },
|
||||
]},
|
||||
actions: [{ type: "setCategory", value: "Transportation" }],
|
||||
},
|
||||
{
|
||||
name: "Amazon → Shopping",
|
||||
priority: 10,
|
||||
conditions: { operator: "AND", filters: [{ field: "description", op: "contains", value: "AMAZON" }] },
|
||||
actions: [{ type: "setCategory", value: "Shopping" }],
|
||||
},
|
||||
{
|
||||
name: "Payroll → Income",
|
||||
priority: 20,
|
||||
conditions: { operator: "AND", filters: [{ field: "description", op: "contains", value: "PAYROLL" }] },
|
||||
actions: [{ type: "setCategory", value: "Income" }],
|
||||
},
|
||||
{
|
||||
name: "Starbucks → Dining",
|
||||
priority: 10,
|
||||
conditions: { operator: "AND", filters: [{ field: "description", op: "contains", value: "STARBUCKS" }] },
|
||||
actions: [{ type: "setCategory", value: "Dining & Restaurants" }],
|
||||
},
|
||||
{
|
||||
name: "Large purchase note (>$500)",
|
||||
priority: 5,
|
||||
conditions: { operator: "AND", filters: [{ field: "amount", op: "gt", value: 500 }] },
|
||||
actions: [{ type: "addNote", value: "Large purchase — review for tax deduction" }],
|
||||
},
|
||||
];
|
||||
|
||||
for (const rule of rules) {
|
||||
await prisma.rule.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: rule.name,
|
||||
priority: rule.priority,
|
||||
conditions: rule.conditions,
|
||||
actions: rule.actions,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(` ✓ ${rules.length} rules created`);
|
||||
|
||||
// ── Draft Tax Return ──────────────────────────────────────────────────────
|
||||
console.log("\n📄 Creating draft tax return...");
|
||||
await prisma.taxReturn.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
taxYear: 2025,
|
||||
filingType: "individual",
|
||||
jurisdictions: ["federal", "CA"],
|
||||
status: "draft",
|
||||
summary: {
|
||||
totalIncome: 69600,
|
||||
totalExpenses: 42580,
|
||||
netIncome: 27020,
|
||||
categories: {
|
||||
"Income": 69600.00,
|
||||
"Rent & Mortgage": 13200.00,
|
||||
"Groceries": 1044.52,
|
||||
"Dining & Restaurants": 679.90,
|
||||
"Transportation": 411.40,
|
||||
"Subscriptions": 165.93,
|
||||
"Shopping": 2469.99,
|
||||
"Healthcare": 505.00,
|
||||
"Utilities": 755.90,
|
||||
"Entertainment": 308.99,
|
||||
"Transfer": 10500.00,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
console.log(" ✓ Draft 2025 tax return");
|
||||
|
||||
// ── Audit Log ─────────────────────────────────────────────────────────────
|
||||
await prisma.auditLog.create({
|
||||
data: { userId: user.id, action: "auth.register", metadata: { email: DEMO_EMAIL, source: "seed-script" } },
|
||||
});
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────────────
|
||||
console.log(`
|
||||
╔══════════════════════════════════════════════╗
|
||||
║ Demo Account Ready ║
|
||||
╠══════════════════════════════════════════════╣
|
||||
║ Email: demo@ledgerone.app ║
|
||||
║ Password: Demo1234! ║
|
||||
╠══════════════════════════════════════════════╣
|
||||
║ Features populated: ║
|
||||
║ ✓ Email verified (no confirmation needed) ║
|
||||
║ ✓ Pro subscription (30-day period) ║
|
||||
║ ✓ 3 accounts (checking/credit/savings) ║
|
||||
║ ✓ ${String(txCount).padEnd(3)} transactions (6 months of data) ║
|
||||
║ ✓ ${String(rules.length).padEnd(3)} categorization rules ║
|
||||
║ ✓ Draft 2025 tax return ║
|
||||
║ ✓ 2FA disabled (enable via Settings → 2FA) ║
|
||||
╚══════════════════════════════════════════════╝
|
||||
`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error("❌ Seed failed:", e.message);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@ -1,35 +1,40 @@
|
||||
import { Body, Controller, Get, Post, Query } from "@nestjs/common";
|
||||
import { ok } from "../common/response";
|
||||
import { AccountsService } from "./accounts.service";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
|
||||
@Controller("accounts")
|
||||
export class AccountsController {
|
||||
constructor(private readonly accountsService: AccountsService) {}
|
||||
|
||||
@Get()
|
||||
async list(@Query("user_id") userId?: string) {
|
||||
const data = await this.accountsService.list(userId);
|
||||
async list(
|
||||
@CurrentUser() userId: string,
|
||||
@Query("page") page = 1,
|
||||
@Query("limit") limit = 20,
|
||||
) {
|
||||
const data = await this.accountsService.list(userId, +page, +limit);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("link")
|
||||
async link() {
|
||||
const data = await this.accountsService.createLinkToken();
|
||||
async link(@CurrentUser() userId: string) {
|
||||
const data = await this.accountsService.createLinkToken(userId);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("manual")
|
||||
async manual(
|
||||
@Body()
|
||||
payload: { userId: string; institutionName: string; accountType: string; mask?: string }
|
||||
@CurrentUser() userId: string,
|
||||
@Body() payload: { institutionName: string; accountType: string; mask?: string },
|
||||
) {
|
||||
const data = await this.accountsService.createManualAccount(payload.userId, payload);
|
||||
const data = await this.accountsService.createManualAccount(userId, payload);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("balances")
|
||||
async balances(@Body() payload: { userId: string }) {
|
||||
const data = await this.accountsService.refreshBalances(payload.userId);
|
||||
async balances(@CurrentUser() userId: string) {
|
||||
const data = await this.accountsService.refreshBalances(userId);
|
||||
return ok(data);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,44 +2,71 @@ import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { PlaidService } from "../plaid/plaid.service";
|
||||
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
|
||||
@Injectable()
|
||||
export class AccountsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly plaidService: PlaidService
|
||||
private readonly plaidService: PlaidService,
|
||||
) {}
|
||||
|
||||
async list(userId?: string) {
|
||||
if (!userId) {
|
||||
return [];
|
||||
}
|
||||
return this.prisma.account.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" }
|
||||
});
|
||||
async list(userId: string, page = 1, limit = 20) {
|
||||
const take = Math.min(limit, MAX_PAGE_SIZE);
|
||||
const skip = (page - 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,
|
||||
lastBalanceSync: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
// Intentionally omit plaidAccessToken — never expose the encrypted token
|
||||
},
|
||||
}),
|
||||
this.prisma.account.count({ where: { userId, isActive: true } }),
|
||||
]);
|
||||
return { accounts, total, page, limit: take };
|
||||
}
|
||||
|
||||
createLinkToken() {
|
||||
return { linkToken: "stub_link_token" };
|
||||
async createLinkToken(userId: string) {
|
||||
return this.plaidService.createLinkToken(userId);
|
||||
}
|
||||
|
||||
async refreshBalances(userId: string) {
|
||||
return this.plaidService.syncBalancesForUser(userId);
|
||||
}
|
||||
|
||||
async createManualAccount(userId: string, payload: {
|
||||
institutionName: string;
|
||||
accountType: string;
|
||||
mask?: string;
|
||||
}) {
|
||||
async createManualAccount(
|
||||
userId: string,
|
||||
payload: { institutionName: string; accountType: string; mask?: string },
|
||||
) {
|
||||
return this.prisma.account.create({
|
||||
data: {
|
||||
userId,
|
||||
institutionName: payload.institutionName,
|
||||
accountType: payload.accountType,
|
||||
mask: payload.mask ?? null,
|
||||
isActive: true
|
||||
}
|
||||
isActive: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
institutionName: true,
|
||||
accountType: true,
|
||||
mask: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,27 +1,80 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AccountsModule } from "./accounts/accounts.module";
|
||||
import { ExportsModule } from "./exports/exports.module";
|
||||
import { RulesModule } from "./rules/rules.module";
|
||||
import { TransactionsModule } from "./transactions/transactions.module";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler";
|
||||
import { APP_GUARD } from "@nestjs/core";
|
||||
|
||||
import { envValidationSchema } from "./config/env.validation";
|
||||
import { CommonModule } from "./common/common.module";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { StorageModule } from "./storage/storage.module";
|
||||
import { SupabaseModule } from "./supabase/supabase.module";
|
||||
import { EmailModule } from "./email/email.module";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { PlaidModule } from "./plaid/plaid.module";
|
||||
import { StorageModule } from "./storage/storage.module";
|
||||
import { TaxModule } from "./tax/tax.module";
|
||||
import { SupabaseModule } from "./supabase/supabase.module";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { TransactionsModule } from "./transactions/transactions.module";
|
||||
import { AccountsModule } from "./accounts/accounts.module";
|
||||
import { RulesModule } from "./rules/rules.module";
|
||||
import { ExportsModule } from "./exports/exports.module";
|
||||
import { StripeModule } from "./stripe/stripe.module";
|
||||
import { TwoFactorModule } from "./auth/twofa/two-factor.module";
|
||||
import { GoogleModule } from "./google/google.module";
|
||||
import { LoggerModule } from "nestjs-pino";
|
||||
import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
StorageModule,
|
||||
// ─── Env validation ──────────────────────────────────────────────────────
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validationSchema: envValidationSchema,
|
||||
validationOptions: { allowUnknown: true, abortEarly: false },
|
||||
}),
|
||||
|
||||
// ─── Rate limiting: 100 requests / 60 seconds per IP ─────────────────────
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
name: "default",
|
||||
ttl: 60_000,
|
||||
limit: 100,
|
||||
},
|
||||
]),
|
||||
|
||||
// ─── Structured logging (Pino) ────────────────────────────────────────────
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
level: process.env.NODE_ENV === "production" ? "info" : "debug",
|
||||
transport: process.env.NODE_ENV !== "production"
|
||||
? { target: "pino-pretty", options: { colorize: true, singleLine: true } }
|
||||
: undefined,
|
||||
redact: ["req.headers.authorization", "req.headers.cookie"],
|
||||
},
|
||||
}),
|
||||
|
||||
// ─── Core infrastructure ─────────────────────────────────────────────────
|
||||
CommonModule,
|
||||
PrismaModule,
|
||||
StorageModule,
|
||||
SupabaseModule,
|
||||
EmailModule,
|
||||
|
||||
// ─── Feature modules ─────────────────────────────────────────────────────
|
||||
AuthModule,
|
||||
PlaidModule,
|
||||
TaxModule,
|
||||
TransactionsModule,
|
||||
AccountsModule,
|
||||
RulesModule,
|
||||
ExportsModule
|
||||
]
|
||||
ExportsModule,
|
||||
StripeModule,
|
||||
TwoFactorModule,
|
||||
GoogleModule,
|
||||
],
|
||||
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 },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@ -1,42 +1,68 @@
|
||||
import { Body, Controller, Headers, Patch, Post, UnauthorizedException } from "@nestjs/common";
|
||||
import { Body, Controller, Get, Post, Patch, Query, UseGuards } from "@nestjs/common";
|
||||
import { ok } from "../common/response";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { LoginDto } from "./dto/login.dto";
|
||||
import { RegisterDto } from "./dto/register.dto";
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
|
||||
import { ResetPasswordDto } from "./dto/reset-password.dto";
|
||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { Public } from "../common/decorators/public.decorator";
|
||||
|
||||
@Controller("auth")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post("register")
|
||||
async register(@Body() payload: RegisterDto) {
|
||||
const data = await this.authService.register(payload);
|
||||
return ok(data);
|
||||
return ok(await this.authService.register(payload));
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("login")
|
||||
async login(@Body() payload: LoginDto) {
|
||||
const data = await this.authService.login(payload);
|
||||
return ok(data);
|
||||
return ok(await this.authService.login(payload));
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get("verify-email")
|
||||
async verifyEmail(@Query("token") token: string) {
|
||||
return ok(await this.authService.verifyEmail(token));
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("refresh")
|
||||
async refresh(@Body("refreshToken") refreshToken: string) {
|
||||
return ok(await this.authService.refreshAccessToken(refreshToken));
|
||||
}
|
||||
|
||||
@Post("logout")
|
||||
async logout(@Body("refreshToken") refreshToken: string) {
|
||||
return ok(await this.authService.logout(refreshToken));
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("forgot-password")
|
||||
async forgotPassword(@Body() payload: ForgotPasswordDto) {
|
||||
return ok(await this.authService.forgotPassword(payload));
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("reset-password")
|
||||
async resetPassword(@Body() payload: ResetPasswordDto) {
|
||||
return ok(await this.authService.resetPassword(payload));
|
||||
}
|
||||
|
||||
@Get("me")
|
||||
async me(@CurrentUser() userId: string) {
|
||||
return ok(await this.authService.getProfile(userId));
|
||||
}
|
||||
|
||||
@Patch("profile")
|
||||
async updateProfile(
|
||||
@Headers("authorization") authorization: string | undefined,
|
||||
@Body() payload: UpdateProfileDto
|
||||
) {
|
||||
const token = authorization?.startsWith("Bearer ")
|
||||
? authorization.slice("Bearer ".length)
|
||||
: "";
|
||||
if (!token) {
|
||||
throw new UnauthorizedException("Missing bearer token.");
|
||||
}
|
||||
const decoded = this.authService.verifyToken(token);
|
||||
if (!decoded?.sub) {
|
||||
throw new UnauthorizedException("Invalid token.");
|
||||
}
|
||||
const data = await this.authService.updateProfile(decoded.sub, payload);
|
||||
return ok(data);
|
||||
async updateProfile(@CurrentUser() userId: string, @Body() payload: UpdateProfileDto) {
|
||||
return ok(await this.authService.updateProfile(userId, payload));
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,15 +2,17 @@ import { Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET ?? "change_me",
|
||||
signOptions: { expiresIn: "7d" }
|
||||
})
|
||||
secret: process.env.JWT_SECRET,
|
||||
signOptions: { expiresIn: "15m" },
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService]
|
||||
providers: [AuthService, JwtAuthGuard],
|
||||
exports: [AuthService, JwtModule, JwtAuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@ -1,146 +1,215 @@
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import * as crypto from "crypto";
|
||||
import * as speakeasy from "speakeasy";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { EmailService } from "../email/email.service";
|
||||
import { EncryptionService } from "../common/encryption.service";
|
||||
import { LoginDto } from "./dto/login.dto";
|
||||
import { RegisterDto } from "./dto/register.dto";
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
|
||||
import { ResetPasswordDto } from "./dto/reset-password.dto";
|
||||
|
||||
const VERIFY_TOKEN_TTL_HOURS = 24;
|
||||
const RESET_TOKEN_TTL_HOURS = 1;
|
||||
const REFRESH_TOKEN_TTL_DAYS = 30;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly encryption: EncryptionService,
|
||||
) {}
|
||||
|
||||
async register(payload: RegisterDto) {
|
||||
const email = payload.email?.toLowerCase().trim();
|
||||
if (!email || !payload.password) {
|
||||
throw new BadRequestException("Email and password are required.");
|
||||
}
|
||||
|
||||
const email = payload.email.toLowerCase().trim();
|
||||
const existing = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
throw new BadRequestException("Email already registered.");
|
||||
}
|
||||
if (existing) throw new BadRequestException("Email already registered.");
|
||||
|
||||
const passwordHash = this.hashPassword(payload.password);
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
fullName: null,
|
||||
phone: null
|
||||
}
|
||||
});
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "auth.register",
|
||||
metadata: { email }
|
||||
}
|
||||
});
|
||||
const user = await this.prisma.user.create({ data: { email, passwordHash } });
|
||||
await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.register", metadata: { email } } });
|
||||
|
||||
const token = this.signToken(user.id);
|
||||
return { user: { id: user.id, email: user.email, fullName: user.fullName }, token };
|
||||
const verifyToken = crypto.randomBytes(32).toString("hex");
|
||||
const expiresAt = new Date(Date.now() + VERIFY_TOKEN_TTL_HOURS * 3600 * 1000);
|
||||
await this.prisma.emailVerificationToken.upsert({
|
||||
where: { userId: user.id },
|
||||
update: { token: verifyToken, expiresAt },
|
||||
create: { userId: user.id, token: verifyToken, expiresAt },
|
||||
});
|
||||
await this.emailService.sendVerificationEmail(email, verifyToken);
|
||||
|
||||
const accessToken = this.signAccessToken(user.id);
|
||||
const refreshToken = await this.createRefreshToken(user.id);
|
||||
return {
|
||||
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
|
||||
accessToken,
|
||||
refreshToken,
|
||||
message: "Registration successful. Please verify your email.",
|
||||
};
|
||||
}
|
||||
|
||||
async login(payload: LoginDto) {
|
||||
const email = payload.email?.toLowerCase().trim();
|
||||
if (!email || !payload.password) {
|
||||
throw new BadRequestException("Email and password are required.");
|
||||
}
|
||||
|
||||
const email = payload.email.toLowerCase().trim();
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
if (!user || !this.verifyPassword(payload.password, user.passwordHash)) {
|
||||
throw new UnauthorizedException("Invalid credentials.");
|
||||
}
|
||||
|
||||
if (!this.verifyPassword(payload.password, user.passwordHash)) {
|
||||
throw new UnauthorizedException("Invalid credentials.");
|
||||
}
|
||||
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "auth.login",
|
||||
metadata: { email }
|
||||
// ── 2FA enforcement ──────────────────────────────────────────────────────
|
||||
if (user.twoFactorEnabled && user.twoFactorSecret) {
|
||||
if (!payload.totpToken) {
|
||||
return { requiresTwoFactor: true, accessToken: null, refreshToken: null };
|
||||
}
|
||||
});
|
||||
const secret = this.encryption.decrypt(user.twoFactorSecret);
|
||||
const isValid = speakeasy.totp.verify({ secret, encoding: "base32", token: payload.totpToken, window: 1 });
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException("Invalid TOTP code.");
|
||||
}
|
||||
}
|
||||
|
||||
const token = this.signToken(user.id);
|
||||
return { user: { id: user.id, email: user.email, fullName: user.fullName }, token };
|
||||
await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.login", metadata: { email } } });
|
||||
const accessToken = this.signAccessToken(user.id);
|
||||
const refreshToken = await this.createRefreshToken(user.id);
|
||||
return {
|
||||
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyEmail(token: string) {
|
||||
const record = await this.prisma.emailVerificationToken.findUnique({ where: { token } });
|
||||
if (!record || record.expiresAt < new Date()) {
|
||||
throw new BadRequestException("Invalid or expired verification token.");
|
||||
}
|
||||
await this.prisma.user.update({ where: { id: record.userId }, data: { emailVerified: true } });
|
||||
await this.prisma.emailVerificationToken.delete({ where: { token } });
|
||||
return { message: "Email verified successfully." };
|
||||
}
|
||||
|
||||
async refreshAccessToken(rawRefreshToken: string) {
|
||||
const tokenHash = this.hashToken(rawRefreshToken);
|
||||
const record = await this.prisma.refreshToken.findUnique({ where: { tokenHash } });
|
||||
if (!record || record.revokedAt || record.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException("Invalid or expired refresh token.");
|
||||
}
|
||||
await this.prisma.refreshToken.update({ where: { id: record.id }, data: { revokedAt: new Date() } });
|
||||
const accessToken = this.signAccessToken(record.userId);
|
||||
const refreshToken = await this.createRefreshToken(record.userId);
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async logout(rawRefreshToken: string) {
|
||||
const tokenHash = this.hashToken(rawRefreshToken);
|
||||
await this.prisma.refreshToken.updateMany({
|
||||
where: { tokenHash, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return { message: "Logged out." };
|
||||
}
|
||||
|
||||
async forgotPassword(payload: ForgotPasswordDto) {
|
||||
const email = payload.email.toLowerCase().trim();
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user) return { message: "If that email exists, a reset link has been sent." };
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_HOURS * 3600 * 1000);
|
||||
await this.prisma.passwordResetToken.create({ data: { userId: user.id, token, expiresAt } });
|
||||
await this.emailService.sendPasswordResetEmail(email, token);
|
||||
return { message: "If that email exists, a reset link has been sent." };
|
||||
}
|
||||
|
||||
async resetPassword(payload: ResetPasswordDto) {
|
||||
const record = await this.prisma.passwordResetToken.findUnique({ where: { token: payload.token } });
|
||||
if (!record || record.usedAt || record.expiresAt < new Date()) {
|
||||
throw new BadRequestException("Invalid or expired reset token.");
|
||||
}
|
||||
const passwordHash = this.hashPassword(payload.password);
|
||||
await this.prisma.user.update({ where: { id: record.userId }, data: { passwordHash } });
|
||||
await this.prisma.passwordResetToken.update({ where: { id: record.id }, data: { usedAt: new Date() } });
|
||||
await this.prisma.refreshToken.updateMany({
|
||||
where: { userId: record.userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return { message: "Password reset successfully. Please log in." };
|
||||
}
|
||||
|
||||
async updateProfile(userId: string, payload: UpdateProfileDto) {
|
||||
const data: UpdateProfileDto = {
|
||||
fullName: payload.fullName?.trim(),
|
||||
phone: payload.phone?.trim(),
|
||||
companyName: payload.companyName?.trim(),
|
||||
addressLine1: payload.addressLine1?.trim(),
|
||||
addressLine2: payload.addressLine2?.trim(),
|
||||
city: payload.city?.trim(),
|
||||
state: payload.state?.trim(),
|
||||
postalCode: payload.postalCode?.trim(),
|
||||
country: payload.country?.trim()
|
||||
};
|
||||
Object.keys(data).forEach((key) => {
|
||||
const value = data[key as keyof UpdateProfileDto];
|
||||
if (value === undefined || value === "") {
|
||||
delete data[key as keyof UpdateProfileDto];
|
||||
}
|
||||
});
|
||||
if (Object.keys(data).length === 0) {
|
||||
throw new BadRequestException("No profile fields provided.");
|
||||
const data: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
const trimmed = (value as string | undefined)?.trim();
|
||||
if (trimmed) data[key] = trimmed;
|
||||
}
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data
|
||||
});
|
||||
if (!Object.keys(data).length) throw new BadRequestException("No profile fields provided.");
|
||||
const user = await this.prisma.user.update({ where: { id: userId }, data });
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "auth.profile.update",
|
||||
metadata: { updatedFields: Object.keys(data) }
|
||||
}
|
||||
data: { userId: user.id, action: "auth.profile.update", metadata: { updatedFields: Object.keys(data) } },
|
||||
});
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
fullName: user.fullName,
|
||||
phone: user.phone,
|
||||
companyName: user.companyName,
|
||||
addressLine1: user.addressLine1,
|
||||
addressLine2: user.addressLine2,
|
||||
city: user.city,
|
||||
state: user.state,
|
||||
postalCode: user.postalCode,
|
||||
country: user.country
|
||||
}
|
||||
id: user.id, email: user.email, fullName: user.fullName, phone: user.phone,
|
||||
companyName: user.companyName, addressLine1: user.addressLine1,
|
||||
addressLine2: user.addressLine2, city: user.city, state: user.state,
|
||||
postalCode: user.postalCode, country: user.country,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private hashPassword(password: string) {
|
||||
const salt = crypto.randomBytes(16).toString("hex");
|
||||
const hash = crypto.pbkdf2Sync(password, salt, 100000, 64, "sha512").toString("hex");
|
||||
return `${salt}:${hash}`;
|
||||
async getProfile(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException("User not found.");
|
||||
return {
|
||||
user: {
|
||||
id: user.id, email: user.email, fullName: user.fullName, phone: user.phone,
|
||||
companyName: user.companyName, addressLine1: user.addressLine1,
|
||||
addressLine2: user.addressLine2, city: user.city, state: user.state,
|
||||
postalCode: user.postalCode, country: user.country,
|
||||
emailVerified: user.emailVerified,
|
||||
twoFactorEnabled: user.twoFactorEnabled,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private verifyPassword(password: string, stored: string) {
|
||||
const [salt, hash] = stored.split(":");
|
||||
if (!salt || !hash) {
|
||||
return false;
|
||||
}
|
||||
const computed = crypto.pbkdf2Sync(password, salt, 100000, 64, "sha512").toString("hex");
|
||||
return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(computed));
|
||||
verifyToken(token: string): { sub: string } {
|
||||
return this.jwtService.verify<{ sub: string }>(token);
|
||||
}
|
||||
|
||||
private signToken(userId: string) {
|
||||
private signAccessToken(userId: string): string {
|
||||
return this.jwtService.sign({ sub: userId });
|
||||
}
|
||||
|
||||
verifyToken(token: string) {
|
||||
return this.jwtService.verify<{ sub: string }>(token);
|
||||
private async createRefreshToken(userId: string): Promise<string> {
|
||||
const raw = crypto.randomBytes(40).toString("hex");
|
||||
const tokenHash = this.hashToken(raw);
|
||||
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 86400 * 1000);
|
||||
await this.prisma.refreshToken.create({ data: { userId, tokenHash, expiresAt } });
|
||||
return raw;
|
||||
}
|
||||
|
||||
private hashToken(token: string): string {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
private hashPassword(password: string): string {
|
||||
const salt = crypto.randomBytes(16).toString("hex");
|
||||
const hash = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex");
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
private verifyPassword(password: string, stored: string): boolean {
|
||||
const [salt, hash] = stored.split(":");
|
||||
if (!salt || !hash) return false;
|
||||
const computed = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex");
|
||||
return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(computed));
|
||||
}
|
||||
}
|
||||
|
||||
6
src/auth/dto/forgot-password.dto.ts
Normal file
6
src/auth/dto/forgot-password.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { IsEmail } from "class-validator";
|
||||
|
||||
export class ForgotPasswordDto {
|
||||
@IsEmail({}, { message: "Invalid email address." })
|
||||
email!: string;
|
||||
}
|
||||
@ -1,4 +1,14 @@
|
||||
export type LoginDto = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
import { IsEmail, IsOptional, IsString, MinLength } from "class-validator";
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail({}, { message: "Invalid email address." })
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1, { message: "Password is required." })
|
||||
password!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
totpToken?: string;
|
||||
}
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
export type RegisterDto = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
import { IsEmail, IsString, MinLength } from "class-validator";
|
||||
|
||||
export class RegisterDto {
|
||||
@IsEmail({}, { message: "Invalid email address." })
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8, { message: "Password must be at least 8 characters." })
|
||||
password!: string;
|
||||
}
|
||||
|
||||
10
src/auth/dto/reset-password.dto.ts
Normal file
10
src/auth/dto/reset-password.dto.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
export class ResetPasswordDto {
|
||||
@IsString()
|
||||
token!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8, { message: "Password must be at least 8 characters." })
|
||||
password!: string;
|
||||
}
|
||||
@ -1,11 +1,13 @@
|
||||
export type UpdateProfileDto = {
|
||||
fullName?: string;
|
||||
phone?: string;
|
||||
companyName?: string;
|
||||
addressLine1?: string;
|
||||
addressLine2?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
};
|
||||
import { IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@IsOptional() @IsString() @MaxLength(200) fullName?: string;
|
||||
@IsOptional() @IsString() @MaxLength(30) phone?: string;
|
||||
@IsOptional() @IsString() @MaxLength(200) companyName?: string;
|
||||
@IsOptional() @IsString() @MaxLength(300) addressLine1?: string;
|
||||
@IsOptional() @IsString() @MaxLength(300) addressLine2?: string;
|
||||
@IsOptional() @IsString() @MaxLength(100) city?: string;
|
||||
@IsOptional() @IsString() @MaxLength(100) state?: string;
|
||||
@IsOptional() @IsString() @MaxLength(20) postalCode?: string;
|
||||
@IsOptional() @IsString() @MaxLength(100) country?: string;
|
||||
}
|
||||
|
||||
27
src/auth/twofa/two-factor.controller.ts
Normal file
27
src/auth/twofa/two-factor.controller.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { Body, Controller, Delete, Post } from "@nestjs/common";
|
||||
import { ok } from "../../common/response";
|
||||
import { TwoFactorService } from "./two-factor.service";
|
||||
import { CurrentUser } from "../../common/decorators/current-user.decorator";
|
||||
|
||||
@Controller("auth/2fa")
|
||||
export class TwoFactorController {
|
||||
constructor(private readonly twoFactorService: TwoFactorService) {}
|
||||
|
||||
@Post("generate")
|
||||
async generate(@CurrentUser() userId: string) {
|
||||
const data = await this.twoFactorService.generateSecret(userId);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("enable")
|
||||
async enable(@CurrentUser() userId: string, @Body("token") token: string) {
|
||||
const data = await this.twoFactorService.enableTwoFactor(userId, token);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Delete("disable")
|
||||
async disable(@CurrentUser() userId: string, @Body("token") token: string) {
|
||||
const data = await this.twoFactorService.disableTwoFactor(userId, token);
|
||||
return ok(data);
|
||||
}
|
||||
}
|
||||
10
src/auth/twofa/two-factor.module.ts
Normal file
10
src/auth/twofa/two-factor.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TwoFactorController } from "./two-factor.controller";
|
||||
import { TwoFactorService } from "./two-factor.service";
|
||||
|
||||
@Module({
|
||||
controllers: [TwoFactorController],
|
||||
providers: [TwoFactorService],
|
||||
exports: [TwoFactorService],
|
||||
})
|
||||
export class TwoFactorModule {}
|
||||
96
src/auth/twofa/two-factor.service.ts
Normal file
96
src/auth/twofa/two-factor.service.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import * as speakeasy from "speakeasy";
|
||||
import * as QRCode from "qrcode";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { EncryptionService } from "../../common/encryption.service";
|
||||
|
||||
@Injectable()
|
||||
export class TwoFactorService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly encryption: EncryptionService,
|
||||
) {}
|
||||
|
||||
async generateSecret(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true, twoFactorEnabled: true },
|
||||
});
|
||||
if (!user) throw new BadRequestException("User not found.");
|
||||
if (user.twoFactorEnabled) {
|
||||
throw new BadRequestException("2FA is already enabled.");
|
||||
}
|
||||
|
||||
const secret = speakeasy.generateSecret({ name: `LedgerOne:${user.email}`, length: 20 });
|
||||
const otpAuthUrl = secret.otpauth_url ?? "";
|
||||
const qrCodeDataUrl = await QRCode.toDataURL(otpAuthUrl);
|
||||
|
||||
// Store encrypted secret temporarily (not yet enabled)
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { twoFactorSecret: this.encryption.encrypt(secret.base32) },
|
||||
});
|
||||
|
||||
return { qrCode: qrCodeDataUrl, otpAuthUrl };
|
||||
}
|
||||
|
||||
async enableTwoFactor(userId: string, token: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { twoFactorSecret: true, twoFactorEnabled: true },
|
||||
});
|
||||
if (!user?.twoFactorSecret) {
|
||||
throw new BadRequestException("Please generate a 2FA secret first.");
|
||||
}
|
||||
if (user.twoFactorEnabled) {
|
||||
throw new BadRequestException("2FA is already enabled.");
|
||||
}
|
||||
|
||||
const secret = this.encryption.decrypt(user.twoFactorSecret);
|
||||
const isValid = speakeasy.totp.verify({ secret, encoding: "base32", token, window: 1 });
|
||||
if (!isValid) {
|
||||
throw new BadRequestException("Invalid TOTP token.");
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { twoFactorEnabled: true },
|
||||
});
|
||||
await this.prisma.auditLog.create({
|
||||
data: { userId, action: "auth.2fa.enabled", metadata: {} },
|
||||
});
|
||||
|
||||
return { message: "2FA enabled successfully." };
|
||||
}
|
||||
|
||||
async disableTwoFactor(userId: string, token: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { twoFactorSecret: true, twoFactorEnabled: true },
|
||||
});
|
||||
if (!user?.twoFactorEnabled || !user.twoFactorSecret) {
|
||||
throw new BadRequestException("2FA is not enabled.");
|
||||
}
|
||||
|
||||
const secret = this.encryption.decrypt(user.twoFactorSecret);
|
||||
const isValid = speakeasy.totp.verify({ secret, encoding: "base32", token, window: 1 });
|
||||
if (!isValid) {
|
||||
throw new BadRequestException("Invalid TOTP token.");
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { twoFactorEnabled: false, twoFactorSecret: null },
|
||||
});
|
||||
await this.prisma.auditLog.create({
|
||||
data: { userId, action: "auth.2fa.disabled", metadata: {} },
|
||||
});
|
||||
|
||||
return { message: "2FA disabled successfully." };
|
||||
}
|
||||
|
||||
verifyToken(encryptedSecret: string, token: string): boolean {
|
||||
const secret = this.encryption.decrypt(encryptedSecret);
|
||||
return speakeasy.totp.verify({ secret, encoding: "base32", token, window: 1 });
|
||||
}
|
||||
}
|
||||
9
src/common/common.module.ts
Normal file
9
src/common/common.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { EncryptionService } from "./encryption.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [EncryptionService],
|
||||
exports: [EncryptionService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
9
src/common/decorators/current-user.decorator.ts
Normal file
9
src/common/decorators/current-user.decorator.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): string => {
|
||||
const request = ctx.switchToHttp().getRequest<Request & { user: { sub: string } }>();
|
||||
return request.user?.sub ?? "";
|
||||
},
|
||||
);
|
||||
4
src/common/decorators/public.decorator.ts
Normal file
4
src/common/decorators/public.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
import { IS_PUBLIC_KEY } from "../guards/jwt-auth.guard";
|
||||
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
37
src/common/encryption.service.ts
Normal file
37
src/common/encryption.service.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import * as crypto from "crypto";
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 16;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
@Injectable()
|
||||
export class EncryptionService {
|
||||
private readonly key: Buffer;
|
||||
|
||||
constructor() {
|
||||
const hexKey = process.env.ENCRYPTION_KEY;
|
||||
if (!hexKey || hexKey.length !== 64) {
|
||||
throw new Error("ENCRYPTION_KEY must be a 64-char hex string (32 bytes).");
|
||||
}
|
||||
this.key = Buffer.from(hexKey, "hex");
|
||||
}
|
||||
|
||||
encrypt(plaintext: string): string {
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, this.key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, tag, encrypted]).toString("base64");
|
||||
}
|
||||
|
||||
decrypt(ciphertext: string): string {
|
||||
const buf = Buffer.from(ciphertext, "base64");
|
||||
const iv = buf.subarray(0, IV_LENGTH);
|
||||
const tag = buf.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
||||
const encrypted = buf.subarray(IV_LENGTH + TAG_LENGTH);
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, this.key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return decipher.update(encrypted) + decipher.final("utf8");
|
||||
}
|
||||
}
|
||||
53
src/common/guards/jwt-auth.guard.ts
Normal file
53
src/common/guards/jwt-auth.guard.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { Request } from "express";
|
||||
|
||||
export const IS_PUBLIC_KEY = "isPublic";
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly reflector: Reflector,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const token = this.extractToken(request);
|
||||
if (!token) {
|
||||
throw new UnauthorizedException("Missing bearer token.");
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = this.jwtService.verify<{ sub: string }>(token, {
|
||||
secret: process.env.JWT_SECRET,
|
||||
});
|
||||
(request as Request & { user: { sub: string } }).user = payload;
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException("Invalid or expired token.");
|
||||
}
|
||||
}
|
||||
|
||||
private extractToken(request: Request): string | null {
|
||||
const auth = request.headers.authorization;
|
||||
if (auth?.startsWith("Bearer ")) {
|
||||
return auth.slice(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
56
src/common/sentry.filter.ts
Normal file
56
src/common/sentry.filter.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import { Request, Response } from "express";
|
||||
|
||||
@Catch()
|
||||
export class SentryExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger("ExceptionFilter");
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
const status =
|
||||
exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
// Only report 5xx errors to Sentry
|
||||
if (status >= 500 && process.env.SENTRY_DSN) {
|
||||
Sentry.captureException(exception, {
|
||||
extra: {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
userId: (request as Request & { user?: { sub: string } }).user?.sub,
|
||||
},
|
||||
});
|
||||
} else if (status >= 500) {
|
||||
this.logger.error(exception);
|
||||
}
|
||||
|
||||
const message =
|
||||
exception instanceof HttpException
|
||||
? exception.getResponse()
|
||||
: "Internal server error";
|
||||
|
||||
response.status(status).json({
|
||||
data: null,
|
||||
error: {
|
||||
statusCode: status,
|
||||
message:
|
||||
typeof message === "string"
|
||||
? message
|
||||
: (message as { message?: string }).message ?? "Internal server error",
|
||||
},
|
||||
meta: { timestamp: new Date().toISOString(), version: "1.0" },
|
||||
});
|
||||
}
|
||||
}
|
||||
45
src/config/env.validation.ts
Normal file
45
src/config/env.validation.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import * as Joi from "joi";
|
||||
|
||||
export const envValidationSchema = Joi.object({
|
||||
DATABASE_URL: Joi.string().required(),
|
||||
JWT_SECRET: Joi.string().min(32).required(),
|
||||
JWT_REFRESH_SECRET: Joi.string().min(32).required(),
|
||||
ENCRYPTION_KEY: Joi.string().length(64).required(),
|
||||
|
||||
PLAID_CLIENT_ID: Joi.string().required(),
|
||||
PLAID_SECRET: Joi.string().required(),
|
||||
PLAID_ENV: Joi.string().valid("sandbox", "development", "production").default("sandbox"),
|
||||
PLAID_PRODUCTS: Joi.string().default("transactions"),
|
||||
PLAID_COUNTRY_CODES: Joi.string().default("US"),
|
||||
PLAID_REDIRECT_URI: Joi.string().uri().optional().allow(""),
|
||||
|
||||
SUPABASE_URL: Joi.string().optional().allow(""),
|
||||
SUPABASE_SERVICE_KEY: Joi.string().optional().allow(""),
|
||||
SUPABASE_ANON_KEY: Joi.string().optional().allow(""),
|
||||
|
||||
STRIPE_SECRET_KEY: Joi.string().optional().allow(""),
|
||||
STRIPE_WEBHOOK_SECRET: Joi.string().optional().allow(""),
|
||||
STRIPE_PRICE_PRO: Joi.string().optional().allow(""),
|
||||
STRIPE_PRICE_ELITE: Joi.string().optional().allow(""),
|
||||
|
||||
SMTP_HOST: Joi.string().optional().allow(""),
|
||||
SMTP_PORT: Joi.number().default(587),
|
||||
SMTP_USER: Joi.string().optional().allow(""),
|
||||
SMTP_PASS: Joi.string().optional().allow(""),
|
||||
SMTP_FROM: Joi.string().default("noreply@ledgerone.app"),
|
||||
|
||||
APP_URL: Joi.string().uri().default("http://localhost:3052"),
|
||||
PORT: Joi.number().default(3051),
|
||||
NODE_ENV: Joi.string().valid("development", "production", "test").default("development"),
|
||||
|
||||
CORS_ORIGIN: Joi.string().default("http://localhost:3052"),
|
||||
|
||||
AUTO_SYNC_ENABLED: Joi.boolean().default(true),
|
||||
AUTO_SYNC_INTERVAL_MINUTES: Joi.number().default(15),
|
||||
|
||||
SENTRY_DSN: Joi.string().optional().allow(""),
|
||||
|
||||
GOOGLE_CLIENT_ID: Joi.string().optional().allow(""),
|
||||
GOOGLE_CLIENT_SECRET: Joi.string().optional().allow(""),
|
||||
GOOGLE_REDIRECT_URI: Joi.string().uri().optional().allow(""),
|
||||
});
|
||||
9
src/email/email.module.ts
Normal file
9
src/email/email.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { EmailService } from "./email.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [EmailService],
|
||||
exports: [EmailService],
|
||||
})
|
||||
export class EmailModule {}
|
||||
80
src/email/email.service.ts
Normal file
80
src/email/email.service.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import * as nodemailer from "nodemailer";
|
||||
import type { Transporter } from "nodemailer";
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
private readonly logger = new Logger(EmailService.name);
|
||||
private readonly transporter: Transporter;
|
||||
private readonly from: string;
|
||||
private readonly appUrl: string;
|
||||
|
||||
constructor() {
|
||||
this.from = process.env.SMTP_FROM ?? "noreply@ledgerone.app";
|
||||
this.appUrl = process.env.APP_URL ?? "http://localhost:3052";
|
||||
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
if (smtpHost) {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: Number(process.env.SMTP_PORT ?? 587),
|
||||
secure: Number(process.env.SMTP_PORT ?? 587) === 465,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Dev mode: log emails instead of sending
|
||||
this.transporter = nodemailer.createTransport({ jsonTransport: true });
|
||||
}
|
||||
}
|
||||
|
||||
async sendVerificationEmail(email: string, token: string): Promise<void> {
|
||||
const url = `${this.appUrl}/verify-email?token=${token}`;
|
||||
try {
|
||||
const info = await this.transporter.sendMail({
|
||||
from: this.from,
|
||||
to: email,
|
||||
subject: "Verify your LedgerOne account",
|
||||
html: `
|
||||
<h2>Welcome to LedgerOne!</h2>
|
||||
<p>Please verify your email address by clicking the link below:</p>
|
||||
<p><a href="${url}" style="background:#316263;color:#B6FF3B;padding:12px 24px;text-decoration:none;border-radius:6px;display:inline-block;">Verify Email</a></p>
|
||||
<p>Or copy this link: ${url}</p>
|
||||
<p>This link expires in 24 hours.</p>
|
||||
`,
|
||||
});
|
||||
if (!process.env.SMTP_HOST) {
|
||||
this.logger.log(`[DEV] Verification email for ${email}: ${url}`);
|
||||
this.logger.debug(JSON.stringify(info));
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to send verification email to ${email}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
async sendPasswordResetEmail(email: string, token: string): Promise<void> {
|
||||
const url = `${this.appUrl}/reset-password?token=${token}`;
|
||||
try {
|
||||
const info = await this.transporter.sendMail({
|
||||
from: this.from,
|
||||
to: email,
|
||||
subject: "Reset your LedgerOne password",
|
||||
html: `
|
||||
<h2>Password Reset Request</h2>
|
||||
<p>Click the button below to reset your password. This link expires in 1 hour.</p>
|
||||
<p><a href="${url}" style="background:#316263;color:#B6FF3B;padding:12px 24px;text-decoration:none;border-radius:6px;display:inline-block;">Reset Password</a></p>
|
||||
<p>Or copy this link: ${url}</p>
|
||||
<p>If you did not request this, you can safely ignore this email.</p>
|
||||
`,
|
||||
});
|
||||
if (!process.env.SMTP_HOST) {
|
||||
this.logger.log(`[DEV] Password reset email for ${email}: ${url}`);
|
||||
this.logger.debug(JSON.stringify(info));
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to send password reset email to ${email}`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,20 +1,21 @@
|
||||
import { Controller, Get, Post, Query } from "@nestjs/common";
|
||||
import { ok } from "../common/response";
|
||||
import { ExportsService } from "./exports.service";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
|
||||
@Controller("exports")
|
||||
export class ExportsController {
|
||||
constructor(private readonly exportsService: ExportsService) {}
|
||||
|
||||
@Get("csv")
|
||||
async exportCsv(@Query("user_id") userId?: string, @Query() query?: Record<string, string>) {
|
||||
const data = await this.exportsService.exportCsv(userId, query ?? {});
|
||||
async exportCsv(@CurrentUser() userId: string, @Query() query: Record<string, string>) {
|
||||
const data = await this.exportsService.exportCsv(userId, query);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("sheets")
|
||||
async exportSheets() {
|
||||
const data = await this.exportsService.exportSheets();
|
||||
async exportSheets(@CurrentUser() userId: string, @Query() query: Record<string, string>) {
|
||||
const data = await this.exportsService.exportSheets(userId, query);
|
||||
return ok(data);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import { google } from "googleapis";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class ExportsService {
|
||||
private readonly logger = new Logger(ExportsService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private toCsv(rows: Array<Record<string, string>>) {
|
||||
const headers = Object.keys(rows[0] ?? {});
|
||||
if (!rows.length) return "";
|
||||
const headers = Object.keys(rows[0]);
|
||||
const escape = (value: string) => `"${value.replace(/"/g, '""')}"`;
|
||||
const lines = [headers.join(",")];
|
||||
for (const row of rows) {
|
||||
@ -15,59 +19,45 @@ export class ExportsService {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async exportCsv(userId?: string, filters: Record<string, string> = {}) {
|
||||
if (!userId) {
|
||||
return { status: "missing_user", csv: "" };
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
account: { userId }
|
||||
};
|
||||
private async getTransactions(
|
||||
userId: string,
|
||||
filters: Record<string, string>,
|
||||
limit = 1000,
|
||||
) {
|
||||
const where: Record<string, unknown> = { account: { userId } };
|
||||
if (filters.start_date || filters.end_date) {
|
||||
where.date = {
|
||||
gte: filters.start_date ? new Date(filters.start_date) : undefined,
|
||||
lte: filters.end_date ? new Date(filters.end_date) : undefined
|
||||
lte: filters.end_date ? new Date(filters.end_date) : undefined,
|
||||
};
|
||||
}
|
||||
if (filters.min_amount || filters.max_amount) {
|
||||
const minAmount = filters.min_amount ? Number(filters.min_amount) : undefined;
|
||||
const maxAmount = filters.max_amount ? Number(filters.max_amount) : undefined;
|
||||
where.amount = {
|
||||
gte: Number.isNaN(minAmount ?? Number.NaN) ? undefined : minAmount,
|
||||
lte: Number.isNaN(maxAmount ?? Number.NaN) ? undefined : maxAmount
|
||||
gte: filters.min_amount ? Number(filters.min_amount) : undefined,
|
||||
lte: filters.max_amount ? Number(filters.max_amount) : undefined,
|
||||
};
|
||||
}
|
||||
if (filters.category) {
|
||||
where.derived = {
|
||||
is: {
|
||||
userCategory: {
|
||||
contains: filters.category,
|
||||
mode: "insensitive"
|
||||
}
|
||||
}
|
||||
is: { userCategory: { contains: filters.category, mode: "insensitive" } },
|
||||
};
|
||||
}
|
||||
if (filters.source) {
|
||||
where.source = {
|
||||
contains: filters.source,
|
||||
mode: "insensitive"
|
||||
};
|
||||
where.source = { contains: filters.source, mode: "insensitive" };
|
||||
}
|
||||
if (filters.include_hidden !== "true") {
|
||||
where.OR = [
|
||||
{ derived: null },
|
||||
{ derived: { isHidden: false } }
|
||||
];
|
||||
where.OR = [{ derived: null }, { derived: { isHidden: false } }];
|
||||
}
|
||||
|
||||
const transactions = await this.prisma.transactionRaw.findMany({
|
||||
return this.prisma.transactionRaw.findMany({
|
||||
where,
|
||||
include: { derived: true },
|
||||
orderBy: { date: "desc" },
|
||||
take: 1000
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
const rows = transactions.map((tx) => ({
|
||||
private toRows(transactions: Awaited<ReturnType<typeof this.getTransactions>>) {
|
||||
return transactions.map((tx) => ({
|
||||
id: tx.id,
|
||||
date: tx.date.toISOString().slice(0, 10),
|
||||
description: tx.description,
|
||||
@ -75,25 +65,104 @@ export class ExportsService {
|
||||
category: tx.derived?.userCategory ?? "",
|
||||
notes: tx.derived?.userNotes ?? "",
|
||||
hidden: tx.derived?.isHidden ? "true" : "false",
|
||||
source: tx.source
|
||||
source: tx.source,
|
||||
}));
|
||||
}
|
||||
|
||||
async exportCsv(userId: string, filters: Record<string, string> = {}) {
|
||||
const transactions = await this.getTransactions(userId, filters);
|
||||
const rows = this.toRows(transactions);
|
||||
const csv = this.toCsv(rows);
|
||||
|
||||
await this.prisma.exportLog.create({
|
||||
data: {
|
||||
userId,
|
||||
filters,
|
||||
rowCount: rows.length
|
||||
}
|
||||
data: { userId, filters, rowCount: rows.length },
|
||||
});
|
||||
|
||||
return { status: "ready", csv, rowCount: rows.length };
|
||||
}
|
||||
|
||||
exportSheets() {
|
||||
async exportSheets(userId: string, filters: Record<string, string> = {}) {
|
||||
// Get the user's Google connection
|
||||
const gc = await this.prisma.googleConnection.findUnique({ where: { userId } });
|
||||
if (!gc || !gc.isConnected) {
|
||||
throw new BadRequestException(
|
||||
"Google account not connected. Please connect via /api/google/connect.",
|
||||
);
|
||||
}
|
||||
|
||||
const oauth2Client = new google.auth.OAuth2(
|
||||
process.env.GOOGLE_CLIENT_ID,
|
||||
process.env.GOOGLE_CLIENT_SECRET,
|
||||
);
|
||||
oauth2Client.setCredentials({
|
||||
access_token: gc.accessToken,
|
||||
refresh_token: gc.refreshToken,
|
||||
});
|
||||
|
||||
// Refresh the access token if needed
|
||||
const { credentials } = await oauth2Client.refreshAccessToken();
|
||||
await this.prisma.googleConnection.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
accessToken: credentials.access_token ?? gc.accessToken,
|
||||
lastSyncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
oauth2Client.setCredentials(credentials);
|
||||
|
||||
const sheets = google.sheets({ version: "v4", auth: oauth2Client });
|
||||
const transactions = await this.getTransactions(userId, filters);
|
||||
const rows = this.toRows(transactions);
|
||||
|
||||
const sheetTitle = `LedgerOne Export ${new Date().toISOString().slice(0, 10)}`;
|
||||
|
||||
let spreadsheetId = gc.spreadsheetId;
|
||||
if (!spreadsheetId) {
|
||||
// Create a new spreadsheet
|
||||
const spreadsheet = await sheets.spreadsheets.create({
|
||||
requestBody: { properties: { title: "LedgerOne" } },
|
||||
});
|
||||
spreadsheetId = spreadsheet.data.spreadsheetId!;
|
||||
await this.prisma.googleConnection.update({
|
||||
where: { userId },
|
||||
data: { spreadsheetId },
|
||||
});
|
||||
}
|
||||
|
||||
// Add a new sheet tab
|
||||
await sheets.spreadsheets.batchUpdate({
|
||||
spreadsheetId,
|
||||
requestBody: {
|
||||
requests: [{ addSheet: { properties: { title: sheetTitle } } }],
|
||||
},
|
||||
});
|
||||
|
||||
// Build values: header + data rows
|
||||
const headers = rows.length ? Object.keys(rows[0]) : ["id", "date", "description", "amount", "category", "notes", "hidden", "source"];
|
||||
const values = [
|
||||
headers,
|
||||
...rows.map((row) => headers.map((h) => row[h as keyof typeof row] ?? "")),
|
||||
];
|
||||
|
||||
await sheets.spreadsheets.values.update({
|
||||
spreadsheetId,
|
||||
range: `'${sheetTitle}'!A1`,
|
||||
valueInputOption: "RAW",
|
||||
requestBody: { values },
|
||||
});
|
||||
|
||||
await this.prisma.exportLog.create({
|
||||
data: { userId, filters: { ...filters, destination: "google_sheets" }, rowCount: rows.length },
|
||||
});
|
||||
|
||||
this.logger.log(`Exported ${rows.length} rows to Google Sheets for user ${userId}`);
|
||||
|
||||
return {
|
||||
status: "queued",
|
||||
status: "exported",
|
||||
rowCount: rows.length,
|
||||
spreadsheetId,
|
||||
sheetTitle,
|
||||
url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
33
src/google/google.controller.ts
Normal file
33
src/google/google.controller.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Post } from "@nestjs/common";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { GoogleService } from "./google.service";
|
||||
|
||||
@Controller("google")
|
||||
export class GoogleController {
|
||||
constructor(private readonly googleService: GoogleService) {}
|
||||
|
||||
/** Returns the Google OAuth URL — frontend redirects the user to it. */
|
||||
@Get("connect")
|
||||
getAuthUrl(@CurrentUser() userId: string) {
|
||||
return this.googleService.getAuthUrl(userId);
|
||||
}
|
||||
|
||||
/** Exchanges the OAuth code for tokens and saves the connection. */
|
||||
@Post("exchange")
|
||||
@HttpCode(200)
|
||||
exchange(@CurrentUser() userId: string, @Body() body: { code: string }) {
|
||||
return this.googleService.exchangeCode(userId, body.code);
|
||||
}
|
||||
|
||||
/** Removes the stored Google connection. */
|
||||
@Delete("disconnect")
|
||||
disconnect(@CurrentUser() userId: string) {
|
||||
return this.googleService.disconnect(userId);
|
||||
}
|
||||
|
||||
/** Returns whether the user has a connected Google account. */
|
||||
@Get("status")
|
||||
status(@CurrentUser() userId: string) {
|
||||
return this.googleService.getStatus(userId);
|
||||
}
|
||||
}
|
||||
11
src/google/google.module.ts
Normal file
11
src/google/google.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { GoogleController } from "./google.controller";
|
||||
import { GoogleService } from "./google.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [GoogleController],
|
||||
providers: [GoogleService],
|
||||
})
|
||||
export class GoogleModule {}
|
||||
94
src/google/google.service.ts
Normal file
94
src/google/google.service.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import { google } from "googleapis";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
const SCOPES = [
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/drive.file",
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class GoogleService {
|
||||
private readonly logger = new Logger(GoogleService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private createClient() {
|
||||
return new google.auth.OAuth2(
|
||||
process.env.GOOGLE_CLIENT_ID,
|
||||
process.env.GOOGLE_CLIENT_SECRET,
|
||||
process.env.GOOGLE_REDIRECT_URI,
|
||||
);
|
||||
}
|
||||
|
||||
async getAuthUrl(userId: string) {
|
||||
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
|
||||
throw new BadRequestException("Google OAuth is not configured on this server.");
|
||||
}
|
||||
const client = this.createClient();
|
||||
const authUrl = client.generateAuthUrl({
|
||||
access_type: "offline",
|
||||
scope: SCOPES,
|
||||
prompt: "consent", // always request a refresh_token
|
||||
state: userId, // passed back in callback to identify the user
|
||||
});
|
||||
return { authUrl };
|
||||
}
|
||||
|
||||
async exchangeCode(userId: string, code: string) {
|
||||
const client = this.createClient();
|
||||
let tokens: Awaited<ReturnType<typeof client.getToken>>["tokens"];
|
||||
try {
|
||||
const { tokens: t } = await client.getToken(code);
|
||||
tokens = t;
|
||||
} catch {
|
||||
throw new BadRequestException("Invalid or expired authorization code.");
|
||||
}
|
||||
|
||||
if (!tokens.refresh_token) {
|
||||
throw new BadRequestException(
|
||||
"No refresh token received. Please disconnect and try again.",
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch the Google account email
|
||||
client.setCredentials(tokens);
|
||||
const oauth2 = google.oauth2({ version: "v2", auth: client });
|
||||
const { data } = await oauth2.userinfo.get();
|
||||
const googleEmail = data.email ?? "";
|
||||
|
||||
await this.prisma.googleConnection.upsert({
|
||||
where: { userId },
|
||||
update: {
|
||||
googleEmail,
|
||||
refreshToken: tokens.refresh_token,
|
||||
accessToken: tokens.access_token ?? null,
|
||||
isConnected: true,
|
||||
connectedAt: new Date(),
|
||||
spreadsheetId: null, // reset so a new spreadsheet is created on next export
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
googleEmail,
|
||||
refreshToken: tokens.refresh_token,
|
||||
accessToken: tokens.access_token ?? null,
|
||||
isConnected: true,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Google account connected for user ${userId}: ${googleEmail}`);
|
||||
return { connected: true, googleEmail };
|
||||
}
|
||||
|
||||
async disconnect(userId: string) {
|
||||
await this.prisma.googleConnection.deleteMany({ where: { userId } });
|
||||
return { disconnected: true };
|
||||
}
|
||||
|
||||
async getStatus(userId: string) {
|
||||
const gc = await this.prisma.googleConnection.findUnique({ where: { userId } });
|
||||
if (!gc || !gc.isConnected) return { connected: false };
|
||||
return { connected: true, googleEmail: gc.googleEmail, connectedAt: gc.connectedAt };
|
||||
}
|
||||
}
|
||||
83
src/main.ts
83
src/main.ts
@ -1,16 +1,85 @@
|
||||
import "dotenv/config";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import helmet from "helmet";
|
||||
import { AppModule } from "./app.module";
|
||||
import { SentryExceptionFilter } from "./common/sentry.filter";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix("api");
|
||||
app.enableCors({
|
||||
origin: true,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization"]
|
||||
// ─── Sentry initialization (before app creation) ──────────────────────────
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
environment: process.env.NODE_ENV ?? "development",
|
||||
});
|
||||
}
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
bufferLogs: true,
|
||||
rawBody: true, // Required for Stripe webhook signature verification
|
||||
});
|
||||
await app.listen(3051);
|
||||
|
||||
// ─── Security headers ─────────────────────────────────────────────────────
|
||||
app.use(
|
||||
helmet({
|
||||
crossOriginEmbedderPolicy: false,
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"], // Swagger UI needs inline scripts
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", "data:", "https:"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// ─── CORS ─────────────────────────────────────────────────────────────────
|
||||
const corsOrigin = process.env.CORS_ORIGIN ?? "http://localhost:3052";
|
||||
app.enableCors({
|
||||
origin: corsOrigin.split(",").map((o) => o.trim()),
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization"],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// ─── Global prefix ────────────────────────────────────────────────────────
|
||||
app.setGlobalPrefix("api");
|
||||
|
||||
// ─── Global validation pipe ───────────────────────────────────────────────
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: { enableImplicitConversion: true },
|
||||
}),
|
||||
);
|
||||
|
||||
// ─── Swagger / OpenAPI ────────────────────────────────────────────────────
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle("LedgerOne API")
|
||||
.setDescription("Personal finance & bookkeeping SaaS API")
|
||||
.setVersion("1.0")
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup("api/docs", app, document);
|
||||
}
|
||||
|
||||
// ─── Global exception filter (Sentry + structured error response) ─────────
|
||||
app.useGlobalFilters(new SentryExceptionFilter());
|
||||
|
||||
// ─── Use Pino as the logger ────────────────────────────────────────────────
|
||||
app.useLogger(app.get(Logger));
|
||||
|
||||
const port = process.env.PORT ?? 3051;
|
||||
await app.listen(port);
|
||||
app.get(Logger).log(`LedgerOne backend running on port ${port}`, "Bootstrap");
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
@ -1,23 +1,24 @@
|
||||
import { Body, Controller, Post } from "@nestjs/common";
|
||||
import { ok } from "../common/response";
|
||||
import { PlaidService } from "./plaid.service";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
|
||||
@Controller("plaid")
|
||||
export class PlaidController {
|
||||
constructor(private readonly plaidService: PlaidService) {}
|
||||
|
||||
@Post("link-token")
|
||||
async createLinkToken() {
|
||||
const data = await this.plaidService.createLinkToken();
|
||||
async createLinkToken(@CurrentUser() userId: string) {
|
||||
const data = await this.plaidService.createLinkToken(userId);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("exchange")
|
||||
async exchange(@Body() payload: { publicToken: string; userId: string }) {
|
||||
const data = await this.plaidService.exchangePublicTokenForUser(
|
||||
payload.userId,
|
||||
payload.publicToken
|
||||
);
|
||||
async exchange(
|
||||
@CurrentUser() userId: string,
|
||||
@Body() payload: { publicToken: string },
|
||||
) {
|
||||
const data = await this.plaidService.exchangePublicTokenForUser(userId, payload.publicToken);
|
||||
return ok(data);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,17 +4,21 @@ import {
|
||||
CountryCode,
|
||||
PlaidApi,
|
||||
PlaidEnvironments,
|
||||
Products
|
||||
Products,
|
||||
} from "plaid";
|
||||
import * as crypto from "crypto";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { EncryptionService } from "../common/encryption.service";
|
||||
|
||||
@Injectable()
|
||||
export class PlaidService {
|
||||
private readonly client: PlaidApi;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly encryption: EncryptionService,
|
||||
) {
|
||||
const env = (process.env.PLAID_ENV ?? "sandbox") as keyof typeof PlaidEnvironments;
|
||||
const clientId = this.requireEnv("PLAID_CLIENT_ID");
|
||||
const secret = this.requireEnv("PLAID_SECRET");
|
||||
@ -25,14 +29,14 @@ export class PlaidService {
|
||||
headers: {
|
||||
"PLAID-CLIENT-ID": clientId,
|
||||
"PLAID-SECRET": secret,
|
||||
"Plaid-Version": "2020-09-14"
|
||||
}
|
||||
}
|
||||
"Plaid-Version": "2020-09-14",
|
||||
},
|
||||
},
|
||||
});
|
||||
this.client = new PlaidApi(config);
|
||||
}
|
||||
|
||||
async createLinkToken() {
|
||||
async createLinkToken(userId: string) {
|
||||
const products = (process.env.PLAID_PRODUCTS ?? "transactions")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
@ -45,19 +49,17 @@ export class PlaidService {
|
||||
|
||||
try {
|
||||
const response = await this.client.linkTokenCreate({
|
||||
user: {
|
||||
client_user_id: crypto.randomUUID()
|
||||
},
|
||||
user: { client_user_id: userId },
|
||||
client_name: "LedgerOne",
|
||||
products,
|
||||
country_codes: countryCodes,
|
||||
language: "en",
|
||||
redirect_uri: redirectUri || undefined
|
||||
redirect_uri: redirectUri || undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
linkToken: response.data.link_token,
|
||||
expiration: response.data.expiration
|
||||
expiration: response.data.expiration,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { error_message?: string } } };
|
||||
@ -69,12 +71,17 @@ export class PlaidService {
|
||||
|
||||
async exchangePublicTokenForUser(userId: string, publicToken: string) {
|
||||
const exchange = await this.client.itemPublicTokenExchange({
|
||||
public_token: publicToken
|
||||
public_token: publicToken,
|
||||
});
|
||||
const accessToken = exchange.data.access_token;
|
||||
const rawAccessToken = exchange.data.access_token;
|
||||
const itemId = exchange.data.item_id;
|
||||
|
||||
const accountsResponse = await this.client.accountsGet({ access_token: accessToken });
|
||||
// Encrypt before storing
|
||||
const encryptedToken = this.encryption.encrypt(rawAccessToken);
|
||||
|
||||
const accountsResponse = await this.client.accountsGet({
|
||||
access_token: rawAccessToken,
|
||||
});
|
||||
const institutionId = accountsResponse.data.item?.institution_id;
|
||||
const institutionName = institutionId
|
||||
? await this.getInstitutionName(institutionId)
|
||||
@ -87,49 +94,53 @@ export class PlaidService {
|
||||
institutionName,
|
||||
accountType: account.subtype ?? account.type,
|
||||
mask: account.mask ?? null,
|
||||
plaidAccessToken: accessToken,
|
||||
plaidAccessToken: encryptedToken,
|
||||
plaidItemId: itemId,
|
||||
currentBalance: account.balances.current ?? null,
|
||||
availableBalance: account.balances.available ?? null,
|
||||
isoCurrencyCode: account.balances.iso_currency_code ?? null,
|
||||
lastBalanceSync: new Date(),
|
||||
userId
|
||||
userId,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
institutionName,
|
||||
accountType: account.subtype ?? account.type,
|
||||
mask: account.mask ?? null,
|
||||
plaidAccessToken: accessToken,
|
||||
plaidAccessToken: encryptedToken,
|
||||
plaidItemId: itemId,
|
||||
plaidAccountId: account.account_id,
|
||||
currentBalance: account.balances.current ?? null,
|
||||
availableBalance: account.balances.available ?? null,
|
||||
isoCurrencyCode: account.balances.iso_currency_code ?? null,
|
||||
lastBalanceSync: new Date(),
|
||||
isActive: true
|
||||
}
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
itemId,
|
||||
accountCount: accountsResponse.data.accounts.length
|
||||
accountCount: accountsResponse.data.accounts.length,
|
||||
};
|
||||
}
|
||||
|
||||
async syncBalancesForUser(userId: string) {
|
||||
const accounts = await this.prisma.account.findMany({
|
||||
where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } }
|
||||
where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } },
|
||||
});
|
||||
const tokens = Array.from(new Set(accounts.map((acct) => acct.plaidAccessToken))).filter(
|
||||
Boolean
|
||||
) as string[];
|
||||
|
||||
// Deduplicate by decrypted token
|
||||
const tokenMap = new Map<string, string>();
|
||||
for (const acct of accounts) {
|
||||
if (!acct.plaidAccessToken) continue;
|
||||
const raw = this.encryption.decrypt(acct.plaidAccessToken);
|
||||
tokenMap.set(acct.plaidAccessToken, raw);
|
||||
}
|
||||
|
||||
let updated = 0;
|
||||
for (const token of tokens) {
|
||||
const response = await this.client.accountsBalanceGet({ access_token: token });
|
||||
for (const [, rawToken] of tokenMap) {
|
||||
const response = await this.client.accountsBalanceGet({ access_token: rawToken });
|
||||
for (const account of response.data.accounts) {
|
||||
const record = await this.prisma.account.updateMany({
|
||||
where: { plaidAccountId: account.account_id, userId },
|
||||
@ -137,8 +148,8 @@ export class PlaidService {
|
||||
currentBalance: account.balances.current ?? null,
|
||||
availableBalance: account.balances.available ?? null,
|
||||
isoCurrencyCode: account.balances.iso_currency_code ?? null,
|
||||
lastBalanceSync: new Date()
|
||||
}
|
||||
lastBalanceSync: new Date(),
|
||||
},
|
||||
});
|
||||
updated += record.count;
|
||||
}
|
||||
@ -148,32 +159,31 @@ export class PlaidService {
|
||||
|
||||
async syncTransactionsForUser(userId: string, startDate: string, endDate: string) {
|
||||
const accounts = await this.prisma.account.findMany({
|
||||
where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } }
|
||||
where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } },
|
||||
});
|
||||
|
||||
// Build map: raw decrypted token → plaidAccountIds
|
||||
const tokenMap = new Map<string, string[]>();
|
||||
for (const account of accounts) {
|
||||
if (!account.plaidAccessToken || !account.plaidAccountId) {
|
||||
continue;
|
||||
}
|
||||
const list = tokenMap.get(account.plaidAccessToken) ?? [];
|
||||
if (!account.plaidAccessToken || !account.plaidAccountId) continue;
|
||||
const raw = this.encryption.decrypt(account.plaidAccessToken);
|
||||
const list = tokenMap.get(raw) ?? [];
|
||||
list.push(account.plaidAccountId);
|
||||
tokenMap.set(account.plaidAccessToken, list);
|
||||
tokenMap.set(raw, list);
|
||||
}
|
||||
|
||||
let created = 0;
|
||||
for (const [token] of tokenMap) {
|
||||
for (const [rawToken] of tokenMap) {
|
||||
const response = await this.client.transactionsGet({
|
||||
access_token: token,
|
||||
access_token: rawToken,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
options: { count: 500, offset: 0 }
|
||||
options: { count: 500, offset: 0 },
|
||||
});
|
||||
|
||||
for (const tx of response.data.transactions) {
|
||||
const account = accounts.find((acct) => acct.plaidAccountId === tx.account_id);
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
if (!account) continue;
|
||||
const rawPayload = tx as unknown as Prisma.InputJsonValue;
|
||||
await this.prisma.transactionRaw.upsert({
|
||||
where: { bankTransactionId: tx.transaction_id },
|
||||
@ -184,7 +194,7 @@ export class PlaidService {
|
||||
description: tx.name ?? "Plaid transaction",
|
||||
rawPayload,
|
||||
source: "plaid",
|
||||
ingestedAt: new Date()
|
||||
ingestedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
accountId: account.id,
|
||||
@ -194,8 +204,8 @@ export class PlaidService {
|
||||
description: tx.name ?? "Plaid transaction",
|
||||
rawPayload,
|
||||
ingestedAt: new Date(),
|
||||
source: "plaid"
|
||||
}
|
||||
source: "plaid",
|
||||
},
|
||||
});
|
||||
created += 1;
|
||||
}
|
||||
@ -216,7 +226,7 @@ export class PlaidService {
|
||||
try {
|
||||
const response = await this.client.institutionsGetById({
|
||||
institution_id: institutionId,
|
||||
country_codes: ["US" as CountryCode]
|
||||
country_codes: ["US" as CountryCode],
|
||||
});
|
||||
return response.data.institution.name ?? "Plaid institution";
|
||||
} catch {
|
||||
|
||||
@ -1,37 +1,59 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Query } from "@nestjs/common";
|
||||
import { Body, Controller, Get, Param, Post, Put } from "@nestjs/common";
|
||||
import { ok } from "../common/response";
|
||||
import { RulesService } from "./rules.service";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
|
||||
@Controller("rules")
|
||||
export class RulesController {
|
||||
constructor(private readonly rulesService: RulesService) {}
|
||||
|
||||
@Get()
|
||||
async list(@Query("user_id") userId?: string) {
|
||||
async list(@CurrentUser() userId: string) {
|
||||
const data = await this.rulesService.list(userId);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() payload: Record<string, unknown>) {
|
||||
const data = await this.rulesService.create(payload as never);
|
||||
async create(
|
||||
@CurrentUser() userId: string,
|
||||
@Body()
|
||||
payload: {
|
||||
name: string;
|
||||
priority?: number;
|
||||
conditions: Record<string, unknown>;
|
||||
actions: Record<string, unknown>;
|
||||
isActive?: boolean;
|
||||
},
|
||||
) {
|
||||
const data = await this.rulesService.create(userId, payload);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Put(":id")
|
||||
async update(@Param("id") id: string, @Body() payload: Record<string, unknown>) {
|
||||
const data = await this.rulesService.update(id, payload as never);
|
||||
async update(
|
||||
@CurrentUser() userId: string,
|
||||
@Param("id") id: string,
|
||||
@Body()
|
||||
payload: {
|
||||
name?: string;
|
||||
priority?: number;
|
||||
conditions?: Record<string, unknown>;
|
||||
actions?: Record<string, unknown>;
|
||||
isActive?: boolean;
|
||||
},
|
||||
) {
|
||||
const data = await this.rulesService.update(userId, id, payload);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post(":id/execute")
|
||||
async execute(@Param("id") id: string) {
|
||||
const data = await this.rulesService.execute(id);
|
||||
async execute(@CurrentUser() userId: string, @Param("id") id: string) {
|
||||
const data = await this.rulesService.execute(userId, id);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Get("suggestions")
|
||||
async suggestions(@Query("user_id") userId?: string) {
|
||||
async suggestions(@CurrentUser() userId: string) {
|
||||
const data = await this.rulesService.suggest(userId);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@ -1,161 +1,145 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class RulesService {
|
||||
constructor(private readonly storage: StorageService) {}
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(userId?: string) {
|
||||
if (!userId) {
|
||||
return [];
|
||||
}
|
||||
const snapshot = await this.storage.load();
|
||||
return snapshot.rules
|
||||
.filter((rule) => rule.userId === userId)
|
||||
.sort((a, b) => a.priority - b.priority);
|
||||
async list(userId: string) {
|
||||
return this.prisma.rule.findMany({
|
||||
where: { userId, isActive: true },
|
||||
orderBy: { priority: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
async create(payload: Record<string, unknown>) {
|
||||
const snapshot = await this.storage.load();
|
||||
const now = this.storage.now();
|
||||
const rule = {
|
||||
id: this.storage.createId(),
|
||||
userId: String(payload.userId ?? ""),
|
||||
name: String(payload.name ?? "Untitled rule"),
|
||||
priority: Number(payload.priority ?? snapshot.rules.length + 1),
|
||||
conditions: (payload.conditions as Record<string, unknown>) ?? {},
|
||||
actions: (payload.actions as Record<string, unknown>) ?? {},
|
||||
isActive: payload.isActive !== false,
|
||||
createdAt: now
|
||||
};
|
||||
snapshot.rules.push(rule);
|
||||
await this.storage.save(snapshot);
|
||||
return rule;
|
||||
async create(
|
||||
userId: string,
|
||||
payload: {
|
||||
name: string;
|
||||
priority?: number;
|
||||
conditions: Record<string, unknown>;
|
||||
actions: Record<string, unknown>;
|
||||
isActive?: boolean;
|
||||
},
|
||||
) {
|
||||
const count = await this.prisma.rule.count({ where: { userId } });
|
||||
return this.prisma.rule.create({
|
||||
data: {
|
||||
userId,
|
||||
name: payload.name ?? "Untitled rule",
|
||||
priority: payload.priority ?? count + 1,
|
||||
conditions: payload.conditions as Prisma.InputJsonValue,
|
||||
actions: payload.actions as Prisma.InputJsonValue,
|
||||
isActive: payload.isActive !== false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, payload: Record<string, unknown>) {
|
||||
const snapshot = await this.storage.load();
|
||||
const index = snapshot.rules.findIndex((rule) => rule.id === id);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
const existing = snapshot.rules[index];
|
||||
const next = {
|
||||
...existing,
|
||||
name: typeof payload.name === "string" ? payload.name : existing.name,
|
||||
priority: typeof payload.priority === "number" ? payload.priority : existing.priority,
|
||||
conditions:
|
||||
typeof payload.conditions === "object" && payload.conditions !== null
|
||||
? (payload.conditions as Record<string, unknown>)
|
||||
: existing.conditions,
|
||||
actions:
|
||||
typeof payload.actions === "object" && payload.actions !== null
|
||||
? (payload.actions as Record<string, unknown>)
|
||||
: existing.actions,
|
||||
isActive: typeof payload.isActive === "boolean" ? payload.isActive : existing.isActive
|
||||
};
|
||||
snapshot.rules[index] = next;
|
||||
await this.storage.save(snapshot);
|
||||
return next;
|
||||
async update(
|
||||
userId: string,
|
||||
id: string,
|
||||
payload: {
|
||||
name?: string;
|
||||
priority?: number;
|
||||
conditions?: Record<string, unknown>;
|
||||
actions?: Record<string, unknown>;
|
||||
isActive?: boolean;
|
||||
},
|
||||
) {
|
||||
const existing = await this.prisma.rule.findFirst({ where: { id, userId } });
|
||||
if (!existing) throw new BadRequestException("Rule not found.");
|
||||
return this.prisma.rule.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(payload.name !== undefined && { name: payload.name }),
|
||||
...(payload.priority !== undefined && { priority: payload.priority }),
|
||||
...(payload.conditions !== undefined && { conditions: payload.conditions as Prisma.InputJsonValue }),
|
||||
...(payload.actions !== undefined && { actions: payload.actions as Prisma.InputJsonValue }),
|
||||
...(payload.isActive !== undefined && { isActive: payload.isActive }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private matchesRule(rule: { conditions: Record<string, unknown> }, tx: { description: string; amount: number }) {
|
||||
const conditions = rule.conditions as Record<string, unknown>;
|
||||
private matchesRule(
|
||||
conditions: Record<string, unknown>,
|
||||
tx: { description: string; amount: number | string },
|
||||
): boolean {
|
||||
const textContains = typeof conditions.textContains === "string" ? conditions.textContains : "";
|
||||
const amountGreater =
|
||||
typeof conditions.amountGreaterThan === "number" ? conditions.amountGreaterThan : null;
|
||||
const amountLess =
|
||||
typeof conditions.amountLessThan === "number" ? conditions.amountLessThan : null;
|
||||
const amountGt = typeof conditions.amountGreaterThan === "number" ? conditions.amountGreaterThan : null;
|
||||
const amountLt = typeof conditions.amountLessThan === "number" ? conditions.amountLessThan : null;
|
||||
|
||||
const description = tx.description.toLowerCase();
|
||||
if (textContains && !description.includes(textContains.toLowerCase())) {
|
||||
if (textContains && !tx.description.toLowerCase().includes(textContains.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
const amount = Number(tx.amount);
|
||||
if (amountGreater !== null && amount <= amountGreater) {
|
||||
return false;
|
||||
}
|
||||
if (amountLess !== null && amount >= amountLess) {
|
||||
return false;
|
||||
}
|
||||
if (amountGt !== null && amount <= amountGt) return false;
|
||||
if (amountLt !== null && amount >= amountLt) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async execute(id: string) {
|
||||
const snapshot = await this.storage.load();
|
||||
const rule = snapshot.rules.find((item) => item.id === id);
|
||||
if (!rule || !rule.isActive) {
|
||||
return { id, status: "skipped" };
|
||||
}
|
||||
async execute(userId: string, id: string) {
|
||||
const rule = await this.prisma.rule.findFirst({ where: { id, userId } });
|
||||
if (!rule || !rule.isActive) return { id, status: "skipped" };
|
||||
|
||||
const userAccounts = snapshot.accounts.filter((acct) => acct.userId === rule.userId);
|
||||
const accountIds = new Set(userAccounts.map((acct) => acct.id));
|
||||
const transactions = snapshot.transactionsRaw.filter((tx) => accountIds.has(tx.accountId));
|
||||
const conditions = rule.conditions as Record<string, unknown>;
|
||||
const actions = rule.actions as Record<string, unknown>;
|
||||
|
||||
const transactions = await this.prisma.transactionRaw.findMany({
|
||||
where: { account: { userId } },
|
||||
include: { derived: true },
|
||||
});
|
||||
|
||||
let applied = 0;
|
||||
for (const tx of transactions) {
|
||||
if (!this.matchesRule(rule, tx)) {
|
||||
if (!this.matchesRule(conditions, { description: tx.description, amount: Number(tx.amount) })) {
|
||||
continue;
|
||||
}
|
||||
const actions = rule.actions as Record<string, unknown>;
|
||||
const existingIndex = snapshot.transactionsDerived.findIndex(
|
||||
(item) => item.rawTransactionId === tx.id
|
||||
);
|
||||
const derived = {
|
||||
id:
|
||||
existingIndex >= 0
|
||||
? snapshot.transactionsDerived[existingIndex].id
|
||||
: this.storage.createId(),
|
||||
rawTransactionId: tx.id,
|
||||
userCategory:
|
||||
typeof actions.setCategory === "string" ? actions.setCategory : undefined,
|
||||
userNotes:
|
||||
existingIndex >= 0 ? snapshot.transactionsDerived[existingIndex].userNotes : undefined,
|
||||
isHidden: typeof actions.setHidden === "boolean" ? actions.setHidden : false,
|
||||
modifiedAt: this.storage.now(),
|
||||
modifiedBy: "rule"
|
||||
};
|
||||
if (existingIndex >= 0) {
|
||||
snapshot.transactionsDerived[existingIndex] = derived;
|
||||
} else {
|
||||
snapshot.transactionsDerived.push(derived);
|
||||
}
|
||||
|
||||
snapshot.ruleExecutions.push({
|
||||
id: this.storage.createId(),
|
||||
ruleId: rule.id,
|
||||
transactionId: tx.id,
|
||||
executedAt: this.storage.now(),
|
||||
result: { applied: true }
|
||||
await this.prisma.transactionDerived.upsert({
|
||||
where: { rawTransactionId: tx.id },
|
||||
update: {
|
||||
userCategory: typeof actions.setCategory === "string" ? actions.setCategory : tx.derived?.userCategory ?? null,
|
||||
isHidden: typeof actions.setHidden === "boolean" ? actions.setHidden : tx.derived?.isHidden ?? false,
|
||||
modifiedAt: new Date(),
|
||||
modifiedBy: "rule",
|
||||
},
|
||||
create: {
|
||||
rawTransactionId: tx.id,
|
||||
userCategory: typeof actions.setCategory === "string" ? actions.setCategory : null,
|
||||
isHidden: typeof actions.setHidden === "boolean" ? actions.setHidden : false,
|
||||
modifiedAt: new Date(),
|
||||
modifiedBy: "rule",
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.ruleExecution.create({
|
||||
data: {
|
||||
ruleId: rule.id,
|
||||
transactionId: tx.id,
|
||||
result: { applied: true } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
applied += 1;
|
||||
}
|
||||
|
||||
await this.storage.save(snapshot);
|
||||
return { id: rule.id, status: "completed", applied };
|
||||
}
|
||||
|
||||
async suggest(userId?: string) {
|
||||
if (!userId) {
|
||||
return [];
|
||||
}
|
||||
const snapshot = await this.storage.load();
|
||||
const userAccounts = snapshot.accounts.filter((acct) => acct.userId === userId);
|
||||
const accountIds = new Set(userAccounts.map((acct) => acct.id));
|
||||
const derived = snapshot.transactionsDerived
|
||||
.filter((item) => {
|
||||
const raw = snapshot.transactionsRaw.find((tx) => tx.id === item.rawTransactionId);
|
||||
return raw && accountIds.has(raw.accountId) && item.userCategory;
|
||||
})
|
||||
.slice(0, 200);
|
||||
async suggest(userId: string) {
|
||||
const derived = await this.prisma.transactionDerived.findMany({
|
||||
where: {
|
||||
raw: { account: { userId } },
|
||||
userCategory: { not: null },
|
||||
},
|
||||
include: { raw: { select: { description: true } } },
|
||||
take: 200,
|
||||
});
|
||||
|
||||
const bucket = new Map<string, { category: string; count: number }>();
|
||||
for (const item of derived) {
|
||||
const raw = snapshot.transactionsRaw.find((tx) => tx.id === item.rawTransactionId);
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
const key = raw.description.toLowerCase();
|
||||
const key = item.raw.description.toLowerCase();
|
||||
const category = item.userCategory ?? "Uncategorized";
|
||||
const entry = bucket.get(key) ?? { category, count: 0 };
|
||||
entry.count += 1;
|
||||
@ -170,7 +154,7 @@ export class RulesService {
|
||||
name: `Auto: ${value.category}`,
|
||||
conditions: { textContains: description },
|
||||
actions: { setCategory: value.category },
|
||||
confidence: Math.min(0.95, 0.5 + value.count * 0.1)
|
||||
confidence: Math.min(0.95, 0.5 + value.count * 0.1),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
56
src/stripe/stripe.controller.ts
Normal file
56
src/stripe/stripe.controller.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
Post,
|
||||
RawBodyRequest,
|
||||
Req,
|
||||
} from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
import { ok } from "../common/response";
|
||||
import { StripeService } from "./stripe.service";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { Public } from "../common/decorators/public.decorator";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Controller("billing")
|
||||
export class StripeController {
|
||||
constructor(
|
||||
private readonly stripeService: StripeService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Get("subscription")
|
||||
async getSubscription(@CurrentUser() userId: string) {
|
||||
const data = await this.stripeService.getSubscription(userId);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("checkout")
|
||||
async checkout(@CurrentUser() userId: string, @Body("priceId") priceId: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true },
|
||||
});
|
||||
if (!user) return ok({ error: "User not found" });
|
||||
const data = await this.stripeService.createCheckoutSession(userId, user.email, priceId);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("portal")
|
||||
async portal(@CurrentUser() userId: string) {
|
||||
const data = await this.stripeService.createPortalSession(userId);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("webhook")
|
||||
async webhook(
|
||||
@Req() req: RawBodyRequest<Request>,
|
||||
@Headers("stripe-signature") signature: string,
|
||||
) {
|
||||
const data = await this.stripeService.handleWebhook(req.rawBody!, signature);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
11
src/stripe/stripe.module.ts
Normal file
11
src/stripe/stripe.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { StripeController } from "./stripe.controller";
|
||||
import { StripeService } from "./stripe.service";
|
||||
import { SubscriptionGuard } from "./subscription.guard";
|
||||
|
||||
@Module({
|
||||
controllers: [StripeController],
|
||||
providers: [StripeService, SubscriptionGuard],
|
||||
exports: [StripeService, SubscriptionGuard],
|
||||
})
|
||||
export class StripeModule {}
|
||||
128
src/stripe/stripe.service.ts
Normal file
128
src/stripe/stripe.service.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import Stripe from "stripe";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
export const PLAN_LIMITS: Record<string, { accounts: number; exports: number }> = {
|
||||
free: { accounts: 2, exports: 5 },
|
||||
pro: { accounts: 10, exports: 100 },
|
||||
elite: { accounts: -1, exports: -1 }, // -1 = unlimited
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class StripeService {
|
||||
private readonly stripe: Stripe;
|
||||
private readonly logger = new Logger(StripeService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {
|
||||
const key = process.env.STRIPE_SECRET_KEY;
|
||||
// Allow empty key in dev mode — operations will fail gracefully when called
|
||||
this.stripe = new Stripe(key || "sk_test_placeholder", { apiVersion: "2026-02-25.clover" });
|
||||
}
|
||||
|
||||
async getOrCreateCustomer(userId: string, email: string): Promise<string> {
|
||||
const sub = await this.prisma.subscription.findUnique({ where: { userId } });
|
||||
if (sub?.stripeCustomerId) return sub.stripeCustomerId;
|
||||
|
||||
const customer = await this.stripe.customers.create({ email, metadata: { userId } });
|
||||
await this.prisma.subscription.upsert({
|
||||
where: { userId },
|
||||
update: { stripeCustomerId: customer.id },
|
||||
create: { userId, plan: "free", stripeCustomerId: customer.id },
|
||||
});
|
||||
return customer.id;
|
||||
}
|
||||
|
||||
async createCheckoutSession(userId: string, email: string, priceId: string) {
|
||||
const customerId = await this.getOrCreateCustomer(userId, email);
|
||||
const session = await this.stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
payment_method_types: ["card"],
|
||||
mode: "subscription",
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: `${process.env.APP_URL}/settings/billing?success=true`,
|
||||
cancel_url: `${process.env.APP_URL}/settings/billing?cancelled=true`,
|
||||
metadata: { userId },
|
||||
});
|
||||
return { url: session.url };
|
||||
}
|
||||
|
||||
async createPortalSession(userId: string) {
|
||||
const sub = await this.prisma.subscription.findUnique({ where: { userId } });
|
||||
if (!sub?.stripeCustomerId) {
|
||||
throw new BadRequestException("No Stripe customer found. Please upgrade first.");
|
||||
}
|
||||
const session = await this.stripe.billingPortal.sessions.create({
|
||||
customer: sub.stripeCustomerId,
|
||||
return_url: `${process.env.APP_URL}/settings/billing`,
|
||||
});
|
||||
return { url: session.url };
|
||||
}
|
||||
|
||||
async getSubscription(userId: string) {
|
||||
const sub = await this.prisma.subscription.findUnique({ where: { userId } });
|
||||
return sub ?? { userId, plan: "free" };
|
||||
}
|
||||
|
||||
async handleWebhook(rawBody: Buffer, signature: string) {
|
||||
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!secret) throw new Error("STRIPE_WEBHOOK_SECRET is required.");
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = this.stripe.webhooks.constructEvent(rawBody, signature, secret);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Webhook signature verification failed: ${err}`);
|
||||
throw new BadRequestException("Invalid webhook signature.");
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case "customer.subscription.created":
|
||||
case "customer.subscription.updated": {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
await this.syncSubscription(subscription);
|
||||
break;
|
||||
}
|
||||
case "customer.subscription.deleted": {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
const customerId = subscription.customer as string;
|
||||
const sub = await this.prisma.subscription.findFirst({
|
||||
where: { stripeCustomerId: customerId },
|
||||
});
|
||||
if (sub) {
|
||||
await this.prisma.subscription.update({
|
||||
where: { userId: sub.userId },
|
||||
data: { plan: "free", stripeSubId: null, currentPeriodEnd: null, cancelAtPeriodEnd: false },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
this.logger.debug(`Unhandled Stripe event: ${event.type}`);
|
||||
}
|
||||
|
||||
return { received: true };
|
||||
}
|
||||
|
||||
private async syncSubscription(subscription: Stripe.Subscription) {
|
||||
const customerId = subscription.customer as string;
|
||||
const sub = await this.prisma.subscription.findFirst({
|
||||
where: { stripeCustomerId: customerId },
|
||||
});
|
||||
if (!sub) return;
|
||||
|
||||
const priceId = subscription.items.data[0]?.price.id;
|
||||
let plan = "free";
|
||||
if (priceId === process.env.STRIPE_PRICE_PRO) plan = "pro";
|
||||
else if (priceId === process.env.STRIPE_PRICE_ELITE) plan = "elite";
|
||||
|
||||
await this.prisma.subscription.update({
|
||||
where: { userId: sub.userId },
|
||||
data: {
|
||||
plan,
|
||||
stripeSubId: subscription.id,
|
||||
currentPeriodEnd: new Date(subscription.billing_cycle_anchor * 1000),
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
46
src/stripe/subscription.guard.ts
Normal file
46
src/stripe/subscription.guard.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
} from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { Request } from "express";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
export const REQUIRED_PLAN_KEY = "requiredPlan";
|
||||
export const RequiredPlan = (plan: "pro" | "elite") =>
|
||||
SetMetadata(REQUIRED_PLAN_KEY, plan);
|
||||
|
||||
const PLAN_RANK: Record<string, number> = { free: 0, pro: 1, elite: 2 };
|
||||
|
||||
@Injectable()
|
||||
export class SubscriptionGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const required = this.reflector.getAllAndOverride<string>(REQUIRED_PLAN_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!required) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request & { user?: { sub: string } }>();
|
||||
const userId = request.user?.sub;
|
||||
if (!userId) throw new ForbiddenException("Authentication required.");
|
||||
|
||||
const sub = await this.prisma.subscription.findUnique({ where: { userId } });
|
||||
const currentPlan = sub?.plan ?? "free";
|
||||
const currentRank = PLAN_RANK[currentPlan] ?? 0;
|
||||
const requiredRank = PLAN_RANK[required] ?? 0;
|
||||
|
||||
if (currentRank < requiredRank) {
|
||||
throw new ForbiddenException(`This feature requires a ${required} plan.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,14 @@
|
||||
export type CreateTaxReturnDto = {
|
||||
userId: string;
|
||||
taxYear: number;
|
||||
filingType: "individual" | "business";
|
||||
jurisdictions: string[];
|
||||
};
|
||||
import { IsArray, IsIn, IsInt, IsString } from "class-validator";
|
||||
|
||||
export class CreateTaxReturnDto {
|
||||
@IsInt()
|
||||
taxYear!: number;
|
||||
|
||||
@IsString()
|
||||
@IsIn(["individual", "business"])
|
||||
filingType!: "individual" | "business";
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
jurisdictions!: string[];
|
||||
}
|
||||
|
||||
@ -1,4 +1,12 @@
|
||||
export type UpdateTaxReturnDto = {
|
||||
import { IsIn, IsObject, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class UpdateTaxReturnDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(["draft", "ready", "exported"])
|
||||
status?: "draft" | "ready" | "exported";
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
summary?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,43 +1,49 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common";
|
||||
import { Body, Controller, Get, Param, Patch, Post } from "@nestjs/common";
|
||||
import { ok } from "../common/response";
|
||||
import { CreateTaxReturnDto } from "./dto/create-return.dto";
|
||||
import { UpdateTaxReturnDto } from "./dto/update-return.dto";
|
||||
import { TaxService } from "./tax.service";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
|
||||
@Controller("tax")
|
||||
export class TaxController {
|
||||
constructor(private readonly taxService: TaxService) {}
|
||||
|
||||
@Get("returns")
|
||||
async listReturns(@Query("user_id") userId?: string) {
|
||||
async listReturns(@CurrentUser() userId: string) {
|
||||
const data = await this.taxService.listReturns(userId);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("returns")
|
||||
async createReturn(@Body() payload: CreateTaxReturnDto) {
|
||||
const data = await this.taxService.createReturn(payload);
|
||||
async createReturn(@CurrentUser() userId: string, @Body() payload: CreateTaxReturnDto) {
|
||||
const data = await this.taxService.createReturn(userId, payload);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Patch("returns/:id")
|
||||
async updateReturn(@Param("id") id: string, @Body() payload: UpdateTaxReturnDto) {
|
||||
const data = await this.taxService.updateReturn(id, payload);
|
||||
async updateReturn(
|
||||
@CurrentUser() userId: string,
|
||||
@Param("id") id: string,
|
||||
@Body() payload: UpdateTaxReturnDto,
|
||||
) {
|
||||
const data = await this.taxService.updateReturn(userId, id, payload);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("returns/:id/documents")
|
||||
async addDocument(
|
||||
@CurrentUser() userId: string,
|
||||
@Param("id") id: string,
|
||||
@Body() payload: { docType: string; metadata: Record<string, unknown> }
|
||||
@Body() payload: { docType: string; metadata?: Record<string, unknown> },
|
||||
) {
|
||||
const data = await this.taxService.addDocument(id, payload.docType, payload.metadata ?? {});
|
||||
const data = await this.taxService.addDocument(userId, id, payload.docType, payload.metadata ?? {});
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("returns/:id/export")
|
||||
async exportReturn(@Param("id") id: string) {
|
||||
const data = await this.taxService.exportReturn(id);
|
||||
async exportReturn(@CurrentUser() userId: string, @Param("id") id: string) {
|
||||
const data = await this.taxService.exportReturn(userId, id);
|
||||
return ok(data);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,6 @@ import { TaxService } from "./tax.service";
|
||||
|
||||
@Module({
|
||||
controllers: [TaxController],
|
||||
providers: [TaxService]
|
||||
providers: [TaxService],
|
||||
})
|
||||
export class TaxModule {}
|
||||
|
||||
@ -1,84 +1,75 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreateTaxReturnDto } from "./dto/create-return.dto";
|
||||
import { UpdateTaxReturnDto } from "./dto/update-return.dto";
|
||||
|
||||
@Injectable()
|
||||
export class TaxService {
|
||||
constructor(private readonly storage: StorageService) {}
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listReturns(userId?: string) {
|
||||
const snapshot = await this.storage.load();
|
||||
return userId
|
||||
? snapshot.taxReturns.filter((ret) => ret.userId === userId)
|
||||
: [];
|
||||
async listReturns(userId: string) {
|
||||
return this.prisma.taxReturn.findMany({
|
||||
where: { userId },
|
||||
include: { documents: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
async createReturn(payload: CreateTaxReturnDto) {
|
||||
const snapshot = await this.storage.load();
|
||||
const now = this.storage.now();
|
||||
const next = {
|
||||
id: this.storage.createId(),
|
||||
userId: payload.userId,
|
||||
taxYear: payload.taxYear,
|
||||
filingType: payload.filingType,
|
||||
jurisdictions: payload.jurisdictions,
|
||||
status: "draft" as const,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
summary: {}
|
||||
};
|
||||
snapshot.taxReturns.push(next);
|
||||
await this.storage.save(snapshot);
|
||||
return next;
|
||||
async createReturn(userId: string, payload: CreateTaxReturnDto) {
|
||||
return this.prisma.taxReturn.create({
|
||||
data: {
|
||||
userId,
|
||||
taxYear: payload.taxYear,
|
||||
filingType: payload.filingType,
|
||||
jurisdictions: payload.jurisdictions as Prisma.InputJsonValue,
|
||||
status: "draft",
|
||||
summary: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateReturn(id: string, payload: UpdateTaxReturnDto) {
|
||||
const snapshot = await this.storage.load();
|
||||
const index = snapshot.taxReturns.findIndex((ret) => ret.id === id);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
const existing = snapshot.taxReturns[index];
|
||||
const next = {
|
||||
...existing,
|
||||
status: payload.status ?? existing.status,
|
||||
summary: payload.summary ?? existing.summary,
|
||||
updatedAt: this.storage.now()
|
||||
};
|
||||
snapshot.taxReturns[index] = next;
|
||||
await this.storage.save(snapshot);
|
||||
return next;
|
||||
async updateReturn(userId: string, id: string, payload: UpdateTaxReturnDto) {
|
||||
const existing = await this.prisma.taxReturn.findFirst({ where: { id, userId } });
|
||||
if (!existing) throw new BadRequestException("Tax return not found.");
|
||||
return this.prisma.taxReturn.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(payload.status !== undefined && { status: payload.status }),
|
||||
...(payload.summary !== undefined && { summary: payload.summary as Prisma.InputJsonValue }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async addDocument(returnId: string, docType: string, metadata: Record<string, unknown>) {
|
||||
const snapshot = await this.storage.load();
|
||||
const doc = {
|
||||
id: this.storage.createId(),
|
||||
taxReturnId: returnId,
|
||||
docType,
|
||||
metadata,
|
||||
createdAt: this.storage.now()
|
||||
};
|
||||
snapshot.taxDocuments.push(doc);
|
||||
await this.storage.save(snapshot);
|
||||
return doc;
|
||||
async addDocument(
|
||||
userId: string,
|
||||
returnId: string,
|
||||
docType: string,
|
||||
metadata: Record<string, unknown>,
|
||||
) {
|
||||
const taxReturn = await this.prisma.taxReturn.findFirst({ where: { id: returnId, userId } });
|
||||
if (!taxReturn) throw new BadRequestException("Tax return not found.");
|
||||
return this.prisma.taxDocument.create({
|
||||
data: {
|
||||
taxReturnId: returnId,
|
||||
docType,
|
||||
metadata: metadata as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async exportReturn(id: string) {
|
||||
const snapshot = await this.storage.load();
|
||||
const taxReturn = snapshot.taxReturns.find((ret) => ret.id === id);
|
||||
if (!taxReturn) {
|
||||
return null;
|
||||
}
|
||||
const docs = snapshot.taxDocuments.filter((doc) => doc.taxReturnId === id);
|
||||
const payload = {
|
||||
return: taxReturn,
|
||||
documents: docs
|
||||
};
|
||||
taxReturn.status = "exported";
|
||||
taxReturn.updatedAt = this.storage.now();
|
||||
await this.storage.save(snapshot);
|
||||
return payload;
|
||||
async exportReturn(userId: string, id: string) {
|
||||
const taxReturn = await this.prisma.taxReturn.findFirst({
|
||||
where: { id, userId },
|
||||
include: { documents: true },
|
||||
});
|
||||
if (!taxReturn) throw new BadRequestException("Tax return not found.");
|
||||
|
||||
await this.prisma.taxReturn.update({
|
||||
where: { id },
|
||||
data: { status: "exported" },
|
||||
});
|
||||
|
||||
return { return: { ...taxReturn, status: "exported" }, documents: taxReturn.documents };
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,28 @@
|
||||
export type CreateManualTransactionDto = {
|
||||
userId: string;
|
||||
import { IsBoolean, IsNumber, IsOptional, IsString, IsDateString } from "class-validator";
|
||||
|
||||
export class CreateManualTransactionDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
accountId?: string;
|
||||
date: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
|
||||
@IsDateString()
|
||||
date!: string;
|
||||
|
||||
@IsString()
|
||||
description!: string;
|
||||
|
||||
@IsNumber()
|
||||
amount!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
hidden?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,86 +1,128 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { ok } from "../common/response";
|
||||
import { UpdateDerivedDto } from "./dto/update-derived.dto";
|
||||
import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto";
|
||||
import { TransactionsService } from "./transactions.service";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
|
||||
@Controller("transactions")
|
||||
export class TransactionsController {
|
||||
constructor(private readonly transactionsService: TransactionsService) {}
|
||||
|
||||
@Get()
|
||||
async list(@Query() query: Record<string, string>) {
|
||||
const data = await this.transactionsService.list({
|
||||
userId: query.user_id,
|
||||
startDate: query.start_date,
|
||||
endDate: query.end_date,
|
||||
accountId: query.account_id,
|
||||
minAmount: query.min_amount,
|
||||
maxAmount: query.max_amount,
|
||||
category: query.category,
|
||||
source: query.source,
|
||||
search: query.search,
|
||||
includeHidden: query.include_hidden
|
||||
async list(
|
||||
@CurrentUser() userId: string,
|
||||
@Query("start_date") startDate?: string,
|
||||
@Query("end_date") endDate?: string,
|
||||
@Query("account_id") accountId?: string,
|
||||
@Query("min_amount") minAmount?: string,
|
||||
@Query("max_amount") maxAmount?: string,
|
||||
@Query("category") category?: string,
|
||||
@Query("source") source?: string,
|
||||
@Query("search") search?: string,
|
||||
@Query("include_hidden") includeHidden?: string,
|
||||
@Query("page") page = 1,
|
||||
@Query("limit") limit = 50,
|
||||
) {
|
||||
const data = await this.transactionsService.list(userId, {
|
||||
startDate,
|
||||
endDate,
|
||||
accountId,
|
||||
minAmount,
|
||||
maxAmount,
|
||||
category,
|
||||
source,
|
||||
search,
|
||||
includeHidden,
|
||||
page: +page,
|
||||
limit: +limit,
|
||||
});
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("import")
|
||||
async importCsv() {
|
||||
const data = await this.transactionsService.importCsv();
|
||||
@UseInterceptors(FileInterceptor("file", { limits: { fileSize: 5 * 1024 * 1024 } }))
|
||||
async importCsv(
|
||||
@CurrentUser() userId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
const data = await this.transactionsService.importCsv(userId, file);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("sync")
|
||||
async sync(@Body() payload: { userId: string; startDate?: string; endDate?: string }) {
|
||||
const endDate = payload.endDate ?? new Date().toISOString().slice(0, 10);
|
||||
const startDate =
|
||||
payload.startDate ??
|
||||
new Date(new Date().setDate(new Date(endDate).getDate() - 30))
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const data = await this.transactionsService.sync(payload.userId, startDate, endDate);
|
||||
async sync(
|
||||
@CurrentUser() userId: string,
|
||||
@Body("startDate") startDate?: string,
|
||||
@Body("endDate") endDate?: string,
|
||||
) {
|
||||
const end = endDate ?? new Date().toISOString().slice(0, 10);
|
||||
const start =
|
||||
startDate ??
|
||||
new Date(new Date().setDate(new Date(end).getDate() - 30)).toISOString().slice(0, 10);
|
||||
const data = await this.transactionsService.sync(userId, start, end);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("manual")
|
||||
async manual(@Body() payload: CreateManualTransactionDto) {
|
||||
const data = await this.transactionsService.createManualTransaction(payload);
|
||||
async manual(
|
||||
@CurrentUser() userId: string,
|
||||
@Body() payload: CreateManualTransactionDto,
|
||||
) {
|
||||
const data = await this.transactionsService.createManualTransaction(userId, payload);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Get("summary")
|
||||
async summary(@Query() query: Record<string, string>) {
|
||||
const endDate = query.end_date ?? new Date().toISOString().slice(0, 10);
|
||||
const startDate =
|
||||
query.start_date ??
|
||||
new Date(new Date().setDate(new Date(endDate).getDate() - 30))
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const data = await this.transactionsService.summary(query.user_id ?? "", startDate, endDate);
|
||||
async summary(
|
||||
@CurrentUser() userId: string,
|
||||
@Query("start_date") startDate?: string,
|
||||
@Query("end_date") endDate?: string,
|
||||
) {
|
||||
const end = endDate ?? new Date().toISOString().slice(0, 10);
|
||||
const start =
|
||||
startDate ??
|
||||
new Date(new Date().setDate(new Date(end).getDate() - 30)).toISOString().slice(0, 10);
|
||||
const data = await this.transactionsService.summary(userId, start, end);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Get("cashflow")
|
||||
async cashflow(@Query() query: Record<string, string>) {
|
||||
const months = query.months ? Number(query.months) : 6;
|
||||
const data = await this.transactionsService.cashflow(query.user_id ?? "", months);
|
||||
async cashflow(
|
||||
@CurrentUser() userId: string,
|
||||
@Query("months") months = 6,
|
||||
) {
|
||||
const data = await this.transactionsService.cashflow(userId, +months);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Get("merchants")
|
||||
async merchants(@Query() query: Record<string, string>) {
|
||||
const limit = query.limit ? Number(query.limit) : 6;
|
||||
const data = await this.transactionsService.merchantInsights(
|
||||
query.user_id ?? "",
|
||||
limit
|
||||
);
|
||||
async merchants(
|
||||
@CurrentUser() userId: string,
|
||||
@Query("limit") limit = 6,
|
||||
) {
|
||||
const data = await this.transactionsService.merchantInsights(userId, +limit);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Patch(":id/derived")
|
||||
async updateDerived(@Param("id") id: string, @Body() payload: UpdateDerivedDto) {
|
||||
const data = await this.transactionsService.updateDerived(id, payload);
|
||||
async updateDerived(
|
||||
@CurrentUser() userId: string,
|
||||
@Param("id") id: string,
|
||||
@Body() payload: UpdateDerivedDto,
|
||||
) {
|
||||
const data = await this.transactionsService.updateDerived(userId, id, payload);
|
||||
return ok(data);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,96 +1,147 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import * as crypto from "crypto";
|
||||
import { parse } from "csv-parse/sync";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { PlaidService } from "../plaid/plaid.service";
|
||||
import { UpdateDerivedDto } from "./dto/update-derived.dto";
|
||||
import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto";
|
||||
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
|
||||
// ─── Bank CSV format auto-detection ──────────────────────────────────────────
|
||||
type ParsedRow = { date: string; description: string; amount: number };
|
||||
|
||||
function detectAndParse(buffer: Buffer): ParsedRow[] {
|
||||
const text = buffer.toString("utf8").trim();
|
||||
const rows: Record<string, string>[] = parse(text, {
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
trim: true,
|
||||
bom: true,
|
||||
});
|
||||
|
||||
if (!rows.length) return [];
|
||||
const headers = Object.keys(rows[0]).map((h) => h.toLowerCase());
|
||||
|
||||
// Chase format: Transaction Date, Description, Amount
|
||||
if (headers.includes("transaction date") && headers.includes("description") && headers.includes("amount")) {
|
||||
return rows.map((r) => ({
|
||||
date: r["Transaction Date"] ?? r["transaction date"],
|
||||
description: r["Description"] ?? r["description"],
|
||||
amount: parseFloat(r["Amount"] ?? r["amount"] ?? "0"),
|
||||
})).filter((r) => r.date && r.description);
|
||||
}
|
||||
|
||||
// Bank of America format: Date, Description, Amount, Running Bal.
|
||||
if (headers.includes("date") && headers.includes("description") && headers.includes("amount") && headers.some((h) => h.includes("running"))) {
|
||||
return rows.map((r) => ({
|
||||
date: r["Date"] ?? r["date"],
|
||||
description: r["Description"] ?? r["description"],
|
||||
amount: parseFloat((r["Amount"] ?? r["amount"] ?? "0").replace(/,/g, "")),
|
||||
})).filter((r) => r.date && r.description);
|
||||
}
|
||||
|
||||
// Wells Fargo format: 5 unnamed columns — Date, Amount, *, *, Description
|
||||
if (headers.length >= 5 && (headers[0] === "" || /^[0-9]/.test(rows[0][Object.keys(rows[0])[0]] ?? ""))) {
|
||||
const keys = Object.keys(rows[0]);
|
||||
return rows.map((r) => ({
|
||||
date: r[keys[0]],
|
||||
description: r[keys[4]] ?? r[keys[3]],
|
||||
amount: parseFloat((r[keys[1]] ?? "0").replace(/,/g, "")),
|
||||
})).filter((r) => r.date && r.description);
|
||||
}
|
||||
|
||||
// Generic: look for date, amount, description columns
|
||||
const dateKey = Object.keys(rows[0]).find((k) => /date/i.test(k));
|
||||
const amountKey = Object.keys(rows[0]).find((k) => /amount/i.test(k));
|
||||
const descKey = Object.keys(rows[0]).find((k) => /desc|memo|narr|payee/i.test(k));
|
||||
|
||||
if (dateKey && amountKey && descKey) {
|
||||
return rows.map((r) => ({
|
||||
date: r[dateKey],
|
||||
description: r[descKey],
|
||||
amount: parseFloat((r[amountKey] ?? "0").replace(/[^0-9.-]/g, "")),
|
||||
})).filter((r) => r.date && r.description);
|
||||
}
|
||||
|
||||
throw new BadRequestException("Unrecognized CSV format. Supported: Chase, Bank of America, Wells Fargo, or generic (date/amount/description columns).");
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransactionsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly plaidService: PlaidService
|
||||
private readonly plaidService: PlaidService,
|
||||
) {}
|
||||
|
||||
async list(filters: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
accountId?: string;
|
||||
userId?: string;
|
||||
minAmount?: string;
|
||||
maxAmount?: string;
|
||||
category?: string;
|
||||
source?: string;
|
||||
search?: string;
|
||||
includeHidden?: string;
|
||||
}) {
|
||||
async list(
|
||||
userId: string,
|
||||
filters: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
accountId?: string;
|
||||
minAmount?: string;
|
||||
maxAmount?: string;
|
||||
category?: string;
|
||||
source?: string;
|
||||
search?: 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: Record<string, unknown> = {
|
||||
date: { gte: start, lte: end }
|
||||
const where: Prisma.TransactionRawWhereInput = {
|
||||
account: { userId },
|
||||
date: { gte: start, lte: end },
|
||||
};
|
||||
|
||||
if (filters.minAmount || filters.maxAmount) {
|
||||
const minAmount = filters.minAmount ? Number(filters.minAmount) : undefined;
|
||||
const maxAmount = filters.maxAmount ? Number(filters.maxAmount) : undefined;
|
||||
where.amount = {
|
||||
gte: Number.isNaN(minAmount ?? Number.NaN) ? undefined : minAmount,
|
||||
lte: Number.isNaN(maxAmount ?? Number.NaN) ? undefined : 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.category) {
|
||||
where.derived = {
|
||||
is: {
|
||||
userCategory: {
|
||||
contains: filters.category,
|
||||
mode: "insensitive"
|
||||
}
|
||||
}
|
||||
};
|
||||
where.derived = { is: { userCategory: { contains: filters.category, mode: "insensitive" } } };
|
||||
}
|
||||
|
||||
if (filters.source) {
|
||||
where.source = {
|
||||
contains: filters.source,
|
||||
mode: "insensitive"
|
||||
};
|
||||
where.source = { contains: filters.source, mode: "insensitive" };
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
where.description = {
|
||||
contains: filters.search,
|
||||
mode: "insensitive"
|
||||
};
|
||||
where.description = { contains: filters.search, mode: "insensitive" };
|
||||
}
|
||||
|
||||
if (filters.accountId) {
|
||||
where.accountId = filters.accountId;
|
||||
}
|
||||
|
||||
if (filters.userId) {
|
||||
where.account = { userId: filters.userId };
|
||||
}
|
||||
|
||||
if (filters.includeHidden !== "true") {
|
||||
where.OR = [
|
||||
{ derived: null },
|
||||
{ derived: { isHidden: false } }
|
||||
];
|
||||
where.OR = [{ derived: null }, { derived: { isHidden: false } }];
|
||||
}
|
||||
|
||||
const rows = await this.prisma.transactionRaw.findMany({
|
||||
where,
|
||||
include: { derived: true },
|
||||
orderBy: { date: "desc" },
|
||||
take: 100
|
||||
});
|
||||
const take = Math.min(filters.limit ?? 50, MAX_PAGE_SIZE);
|
||||
const skip = ((filters.page ?? 1) - 1) * take;
|
||||
|
||||
return rows.map((row) => ({
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.transactionRaw.findMany({
|
||||
where,
|
||||
include: { derived: true },
|
||||
orderBy: { date: "desc" },
|
||||
take,
|
||||
skip,
|
||||
}),
|
||||
this.prisma.transactionRaw.count({ where }),
|
||||
]);
|
||||
|
||||
const transactions = rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.description,
|
||||
amount: Number(row.amount).toFixed(2),
|
||||
@ -98,25 +149,87 @@ export class TransactionsService {
|
||||
note: row.derived?.userNotes ?? "",
|
||||
status: row.derived?.modifiedBy ?? "raw",
|
||||
hidden: row.derived?.isHidden ?? false,
|
||||
date: row.date.toISOString().slice(0, 10)
|
||||
date: row.date.toISOString().slice(0, 10),
|
||||
source: row.source,
|
||||
accountId: row.accountId,
|
||||
}));
|
||||
|
||||
return { transactions, total, page: filters.page ?? 1, limit: take };
|
||||
}
|
||||
|
||||
async importCsv() {
|
||||
return { status: "queued" };
|
||||
}
|
||||
async importCsv(userId: string, file: Express.Multer.File) {
|
||||
if (!file?.buffer) {
|
||||
throw new BadRequestException("No file uploaded.");
|
||||
}
|
||||
if (!file.originalname.toLowerCase().endsWith(".csv")) {
|
||||
throw new BadRequestException("File must be a CSV.");
|
||||
}
|
||||
|
||||
async createManualTransaction(payload: CreateManualTransactionDto) {
|
||||
const account = payload.accountId
|
||||
? await this.prisma.account.findFirst({
|
||||
where: { id: payload.accountId, userId: payload.userId }
|
||||
})
|
||||
: await this.prisma.account.findFirst({
|
||||
where: { userId: payload.userId }
|
||||
const rows = detectAndParse(file.buffer);
|
||||
if (!rows.length) {
|
||||
throw new BadRequestException("CSV file is empty or could not be parsed.");
|
||||
}
|
||||
|
||||
// Find or create a manual import account for this user
|
||||
let account = await this.prisma.account.findFirst({
|
||||
where: { userId, institutionName: "CSV Import", plaidAccessToken: null },
|
||||
});
|
||||
if (!account) {
|
||||
account = await this.prisma.account.create({
|
||||
data: {
|
||||
userId,
|
||||
institutionName: "CSV Import",
|
||||
accountType: "checking",
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const dateObj = new Date(row.date);
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const bankTransactionId = `csv_${crypto.createHash("sha256")
|
||||
.update(`${userId}:${row.date}:${row.description}:${row.amount}`)
|
||||
.digest("hex")
|
||||
.slice(0, 16)}`;
|
||||
|
||||
try {
|
||||
await this.prisma.transactionRaw.upsert({
|
||||
where: { bankTransactionId },
|
||||
update: {},
|
||||
create: {
|
||||
accountId: account.id,
|
||||
bankTransactionId,
|
||||
date: dateObj,
|
||||
amount: row.amount,
|
||||
description: row.description,
|
||||
rawPayload: row as unknown as Prisma.InputJsonValue,
|
||||
ingestedAt: new Date(),
|
||||
source: "csv",
|
||||
},
|
||||
});
|
||||
imported++;
|
||||
} catch {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, skipped, total: rows.length };
|
||||
}
|
||||
|
||||
async createManualTransaction(userId: string, payload: CreateManualTransactionDto) {
|
||||
const account = payload.accountId
|
||||
? await this.prisma.account.findFirst({ where: { id: payload.accountId, userId } })
|
||||
: await this.prisma.account.findFirst({ where: { userId } });
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
throw new BadRequestException("No account found for user.");
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
@ -127,10 +240,10 @@ export class TransactionsService {
|
||||
date: new Date(payload.date),
|
||||
amount: payload.amount,
|
||||
description: payload.description,
|
||||
rawPayload: payload as Prisma.InputJsonValue,
|
||||
rawPayload: payload as unknown as Prisma.InputJsonValue,
|
||||
ingestedAt: new Date(),
|
||||
source: "manual"
|
||||
}
|
||||
source: "manual",
|
||||
},
|
||||
});
|
||||
|
||||
if (payload.category || payload.note || payload.hidden) {
|
||||
@ -141,15 +254,21 @@ export class TransactionsService {
|
||||
userNotes: payload.note ?? null,
|
||||
isHidden: payload.hidden ?? false,
|
||||
modifiedAt: new Date(),
|
||||
modifiedBy: "user"
|
||||
}
|
||||
modifiedBy: "user",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return raw;
|
||||
return { id: raw.id };
|
||||
}
|
||||
|
||||
async updateDerived(id: string, payload: UpdateDerivedDto) {
|
||||
async updateDerived(userId: string, id: string, payload: UpdateDerivedDto) {
|
||||
// Ensure the transaction belongs to the user
|
||||
const tx = await this.prisma.transactionRaw.findFirst({
|
||||
where: { id, account: { userId } },
|
||||
});
|
||||
if (!tx) throw new BadRequestException("Transaction not found.");
|
||||
|
||||
return this.prisma.transactionDerived.upsert({
|
||||
where: { rawTransactionId: id },
|
||||
update: {
|
||||
@ -157,7 +276,7 @@ export class TransactionsService {
|
||||
userNotes: payload.userNotes,
|
||||
isHidden: payload.isHidden ?? false,
|
||||
modifiedAt: new Date(),
|
||||
modifiedBy: "user"
|
||||
modifiedBy: "user",
|
||||
},
|
||||
create: {
|
||||
rawTransactionId: id,
|
||||
@ -165,8 +284,8 @@ export class TransactionsService {
|
||||
userNotes: payload.userNotes,
|
||||
isHidden: payload.isHidden ?? false,
|
||||
modifiedAt: new Date(),
|
||||
modifiedBy: "user"
|
||||
}
|
||||
modifiedBy: "user",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -178,25 +297,25 @@ export class TransactionsService {
|
||||
const rows = await this.prisma.transactionRaw.findMany({
|
||||
where: {
|
||||
account: { userId },
|
||||
date: { gte: new Date(startDate), lte: new Date(endDate) }
|
||||
}
|
||||
date: { gte: new Date(startDate), lte: new Date(endDate) },
|
||||
},
|
||||
});
|
||||
|
||||
const total = rows.reduce((sum, row) => sum + Number(row.amount), 0);
|
||||
const income = rows.reduce(
|
||||
(sum, row) => sum + (Number(row.amount) < 0 ? Math.abs(Number(row.amount)) : 0),
|
||||
0
|
||||
0,
|
||||
);
|
||||
const expense = rows.reduce(
|
||||
(sum, row) => sum + (Number(row.amount) > 0 ? Number(row.amount) : 0),
|
||||
0
|
||||
0,
|
||||
);
|
||||
return {
|
||||
total: total.toFixed(2),
|
||||
count: rows.length,
|
||||
income: income.toFixed(2),
|
||||
expense: expense.toFixed(2),
|
||||
net: (income - expense).toFixed(2)
|
||||
net: (income - expense).toFixed(2),
|
||||
};
|
||||
}
|
||||
|
||||
@ -204,7 +323,7 @@ export class TransactionsService {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - (months - 1), 1);
|
||||
const rows = await this.prisma.transactionRaw.findMany({
|
||||
where: { account: { userId }, date: { gte: start, lte: now } }
|
||||
where: { account: { userId }, date: { gte: start, lte: now } },
|
||||
});
|
||||
|
||||
const buckets = new Map<string, { income: number; expense: number }>();
|
||||
@ -217,49 +336,43 @@ export class TransactionsService {
|
||||
for (const row of rows) {
|
||||
const key = `${row.date.getFullYear()}-${String(row.date.getMonth() + 1).padStart(2, "0")}`;
|
||||
const bucket = buckets.get(key);
|
||||
if (!bucket) {
|
||||
continue;
|
||||
}
|
||||
if (!bucket) continue;
|
||||
const amount = Number(row.amount);
|
||||
if (amount < 0) {
|
||||
bucket.income += Math.abs(amount);
|
||||
} else {
|
||||
bucket.expense += amount;
|
||||
}
|
||||
if (amount < 0) bucket.income += Math.abs(amount);
|
||||
else bucket.expense += amount;
|
||||
}
|
||||
|
||||
return Array.from(buckets.entries()).map(([month, value]) => ({
|
||||
month,
|
||||
income: value.income.toFixed(2),
|
||||
expense: value.expense.toFixed(2),
|
||||
net: (value.income - value.expense).toFixed(2)
|
||||
net: (value.income - value.expense).toFixed(2),
|
||||
}));
|
||||
}
|
||||
|
||||
async merchantInsights(userId: string, limit = 6) {
|
||||
const capped = Math.min(limit, MAX_PAGE_SIZE);
|
||||
const rows = await this.prisma.transactionRaw.findMany({
|
||||
where: { account: { userId } }
|
||||
where: { account: { userId } },
|
||||
select: { description: true, amount: true },
|
||||
});
|
||||
const bucket = new Map<string, { total: number; count: number }>();
|
||||
for (const row of rows) {
|
||||
const merchant = row.description;
|
||||
const amount = Number(row.amount);
|
||||
if (amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
const entry = bucket.get(merchant) ?? { total: 0, count: 0 };
|
||||
if (amount <= 0) continue;
|
||||
const entry = bucket.get(row.description) ?? { total: 0, count: 0 };
|
||||
entry.total += amount;
|
||||
entry.count += 1;
|
||||
bucket.set(merchant, entry);
|
||||
bucket.set(row.description, entry);
|
||||
}
|
||||
|
||||
return Array.from(bucket.entries())
|
||||
.sort((a, b) => b[1].total - a[1].total)
|
||||
.slice(0, limit)
|
||||
.slice(0, capped)
|
||||
.map(([merchant, value]) => ({
|
||||
merchant,
|
||||
total: value.total.toFixed(2),
|
||||
count: value.count
|
||||
count: value.count,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
322
write-2fa-speakeasy.mjs
Normal file
322
write-2fa-speakeasy.mjs
Normal file
@ -0,0 +1,322 @@
|
||||
import { writeFileSync } from "fs";
|
||||
|
||||
writeFileSync("src/auth/twofa/two-factor.service.ts", `import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import * as speakeasy from "speakeasy";
|
||||
import * as QRCode from "qrcode";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { EncryptionService } from "../../common/encryption.service";
|
||||
|
||||
@Injectable()
|
||||
export class TwoFactorService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly encryption: EncryptionService,
|
||||
) {}
|
||||
|
||||
async generateSecret(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true, twoFactorEnabled: true },
|
||||
});
|
||||
if (!user) throw new BadRequestException("User not found.");
|
||||
if (user.twoFactorEnabled) {
|
||||
throw new BadRequestException("2FA is already enabled.");
|
||||
}
|
||||
|
||||
const secret = speakeasy.generateSecret({ name: \`LedgerOne:\${user.email}\`, length: 20 });
|
||||
const otpAuthUrl = secret.otpauth_url ?? "";
|
||||
const qrCodeDataUrl = await QRCode.toDataURL(otpAuthUrl);
|
||||
|
||||
// Store encrypted secret temporarily (not yet enabled)
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { twoFactorSecret: this.encryption.encrypt(secret.base32) },
|
||||
});
|
||||
|
||||
return { qrCode: qrCodeDataUrl, otpAuthUrl };
|
||||
}
|
||||
|
||||
async enableTwoFactor(userId: string, token: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { twoFactorSecret: true, twoFactorEnabled: true },
|
||||
});
|
||||
if (!user?.twoFactorSecret) {
|
||||
throw new BadRequestException("Please generate a 2FA secret first.");
|
||||
}
|
||||
if (user.twoFactorEnabled) {
|
||||
throw new BadRequestException("2FA is already enabled.");
|
||||
}
|
||||
|
||||
const secret = this.encryption.decrypt(user.twoFactorSecret);
|
||||
const isValid = speakeasy.totp.verify({ secret, encoding: "base32", token, window: 1 });
|
||||
if (!isValid) {
|
||||
throw new BadRequestException("Invalid TOTP token.");
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { twoFactorEnabled: true },
|
||||
});
|
||||
await this.prisma.auditLog.create({
|
||||
data: { userId, action: "auth.2fa.enabled", metadata: {} },
|
||||
});
|
||||
|
||||
return { message: "2FA enabled successfully." };
|
||||
}
|
||||
|
||||
async disableTwoFactor(userId: string, token: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { twoFactorSecret: true, twoFactorEnabled: true },
|
||||
});
|
||||
if (!user?.twoFactorEnabled || !user.twoFactorSecret) {
|
||||
throw new BadRequestException("2FA is not enabled.");
|
||||
}
|
||||
|
||||
const secret = this.encryption.decrypt(user.twoFactorSecret);
|
||||
const isValid = speakeasy.totp.verify({ secret, encoding: "base32", token, window: 1 });
|
||||
if (!isValid) {
|
||||
throw new BadRequestException("Invalid TOTP token.");
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { twoFactorEnabled: false, twoFactorSecret: null },
|
||||
});
|
||||
await this.prisma.auditLog.create({
|
||||
data: { userId, action: "auth.2fa.disabled", metadata: {} },
|
||||
});
|
||||
|
||||
return { message: "2FA disabled successfully." };
|
||||
}
|
||||
|
||||
verifyToken(encryptedSecret: string, token: string): boolean {
|
||||
const secret = this.encryption.decrypt(encryptedSecret);
|
||||
return speakeasy.totp.verify({ secret, encoding: "base32", token, window: 1 });
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
// Update auth.service.ts to use speakeasy instead of otplib
|
||||
// (The login method needs to use speakeasy for 2FA verification)
|
||||
// We need to update auth.service.ts to use speakeasy
|
||||
|
||||
writeFileSync("src/auth/auth.service.ts", `import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import * as crypto from "crypto";
|
||||
import * as speakeasy from "speakeasy";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { EmailService } from "../email/email.service";
|
||||
import { EncryptionService } from "../common/encryption.service";
|
||||
import { LoginDto } from "./dto/login.dto";
|
||||
import { RegisterDto } from "./dto/register.dto";
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
|
||||
import { ResetPasswordDto } from "./dto/reset-password.dto";
|
||||
|
||||
const VERIFY_TOKEN_TTL_HOURS = 24;
|
||||
const RESET_TOKEN_TTL_HOURS = 1;
|
||||
const REFRESH_TOKEN_TTL_DAYS = 30;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly encryption: EncryptionService,
|
||||
) {}
|
||||
|
||||
async register(payload: RegisterDto) {
|
||||
const email = payload.email.toLowerCase().trim();
|
||||
const existing = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (existing) throw new BadRequestException("Email already registered.");
|
||||
|
||||
const passwordHash = this.hashPassword(payload.password);
|
||||
const user = await this.prisma.user.create({ data: { email, passwordHash } });
|
||||
await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.register", metadata: { email } } });
|
||||
|
||||
const verifyToken = crypto.randomBytes(32).toString("hex");
|
||||
const expiresAt = new Date(Date.now() + VERIFY_TOKEN_TTL_HOURS * 3600 * 1000);
|
||||
await this.prisma.emailVerificationToken.upsert({
|
||||
where: { userId: user.id },
|
||||
update: { token: verifyToken, expiresAt },
|
||||
create: { userId: user.id, token: verifyToken, expiresAt },
|
||||
});
|
||||
await this.emailService.sendVerificationEmail(email, verifyToken);
|
||||
|
||||
const accessToken = this.signAccessToken(user.id);
|
||||
const refreshToken = await this.createRefreshToken(user.id);
|
||||
return {
|
||||
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
|
||||
accessToken,
|
||||
refreshToken,
|
||||
message: "Registration successful. Please verify your email.",
|
||||
};
|
||||
}
|
||||
|
||||
async login(payload: LoginDto) {
|
||||
const email = payload.email.toLowerCase().trim();
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user || !this.verifyPassword(payload.password, user.passwordHash)) {
|
||||
throw new UnauthorizedException("Invalid credentials.");
|
||||
}
|
||||
|
||||
// ── 2FA enforcement ──────────────────────────────────────────────────────
|
||||
if (user.twoFactorEnabled && user.twoFactorSecret) {
|
||||
if (!payload.totpToken) {
|
||||
return { requiresTwoFactor: true, accessToken: null, refreshToken: null };
|
||||
}
|
||||
const secret = this.encryption.decrypt(user.twoFactorSecret);
|
||||
const isValid = speakeasy.totp.verify({ secret, encoding: "base32", token: payload.totpToken, window: 1 });
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException("Invalid TOTP code.");
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.login", metadata: { email } } });
|
||||
const accessToken = this.signAccessToken(user.id);
|
||||
const refreshToken = await this.createRefreshToken(user.id);
|
||||
return {
|
||||
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyEmail(token: string) {
|
||||
const record = await this.prisma.emailVerificationToken.findUnique({ where: { token } });
|
||||
if (!record || record.expiresAt < new Date()) {
|
||||
throw new BadRequestException("Invalid or expired verification token.");
|
||||
}
|
||||
await this.prisma.user.update({ where: { id: record.userId }, data: { emailVerified: true } });
|
||||
await this.prisma.emailVerificationToken.delete({ where: { token } });
|
||||
return { message: "Email verified successfully." };
|
||||
}
|
||||
|
||||
async refreshAccessToken(rawRefreshToken: string) {
|
||||
const tokenHash = this.hashToken(rawRefreshToken);
|
||||
const record = await this.prisma.refreshToken.findUnique({ where: { tokenHash } });
|
||||
if (!record || record.revokedAt || record.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException("Invalid or expired refresh token.");
|
||||
}
|
||||
await this.prisma.refreshToken.update({ where: { id: record.id }, data: { revokedAt: new Date() } });
|
||||
const accessToken = this.signAccessToken(record.userId);
|
||||
const refreshToken = await this.createRefreshToken(record.userId);
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async logout(rawRefreshToken: string) {
|
||||
const tokenHash = this.hashToken(rawRefreshToken);
|
||||
await this.prisma.refreshToken.updateMany({
|
||||
where: { tokenHash, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return { message: "Logged out." };
|
||||
}
|
||||
|
||||
async forgotPassword(payload: ForgotPasswordDto) {
|
||||
const email = payload.email.toLowerCase().trim();
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user) return { message: "If that email exists, a reset link has been sent." };
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_HOURS * 3600 * 1000);
|
||||
await this.prisma.passwordResetToken.create({ data: { userId: user.id, token, expiresAt } });
|
||||
await this.emailService.sendPasswordResetEmail(email, token);
|
||||
return { message: "If that email exists, a reset link has been sent." };
|
||||
}
|
||||
|
||||
async resetPassword(payload: ResetPasswordDto) {
|
||||
const record = await this.prisma.passwordResetToken.findUnique({ where: { token: payload.token } });
|
||||
if (!record || record.usedAt || record.expiresAt < new Date()) {
|
||||
throw new BadRequestException("Invalid or expired reset token.");
|
||||
}
|
||||
const passwordHash = this.hashPassword(payload.password);
|
||||
await this.prisma.user.update({ where: { id: record.userId }, data: { passwordHash } });
|
||||
await this.prisma.passwordResetToken.update({ where: { id: record.id }, data: { usedAt: new Date() } });
|
||||
await this.prisma.refreshToken.updateMany({
|
||||
where: { userId: record.userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return { message: "Password reset successfully. Please log in." };
|
||||
}
|
||||
|
||||
async updateProfile(userId: string, payload: UpdateProfileDto) {
|
||||
const data: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
const trimmed = (value as string | undefined)?.trim();
|
||||
if (trimmed) data[key] = trimmed;
|
||||
}
|
||||
if (!Object.keys(data).length) throw new BadRequestException("No profile fields provided.");
|
||||
const user = await this.prisma.user.update({ where: { id: userId }, data });
|
||||
await this.prisma.auditLog.create({
|
||||
data: { userId: user.id, action: "auth.profile.update", metadata: { updatedFields: Object.keys(data) } },
|
||||
});
|
||||
return {
|
||||
user: {
|
||||
id: user.id, email: user.email, fullName: user.fullName, phone: user.phone,
|
||||
companyName: user.companyName, addressLine1: user.addressLine1,
|
||||
addressLine2: user.addressLine2, city: user.city, state: user.state,
|
||||
postalCode: user.postalCode, country: user.country,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getProfile(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException("User not found.");
|
||||
return {
|
||||
user: {
|
||||
id: user.id, email: user.email, fullName: user.fullName, phone: user.phone,
|
||||
companyName: user.companyName, addressLine1: user.addressLine1,
|
||||
addressLine2: user.addressLine2, city: user.city, state: user.state,
|
||||
postalCode: user.postalCode, country: user.country,
|
||||
emailVerified: user.emailVerified,
|
||||
twoFactorEnabled: user.twoFactorEnabled,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
verifyToken(token: string): { sub: string } {
|
||||
return this.jwtService.verify<{ sub: string }>(token);
|
||||
}
|
||||
|
||||
private signAccessToken(userId: string): string {
|
||||
return this.jwtService.sign({ sub: userId });
|
||||
}
|
||||
|
||||
private async createRefreshToken(userId: string): Promise<string> {
|
||||
const raw = crypto.randomBytes(40).toString("hex");
|
||||
const tokenHash = this.hashToken(raw);
|
||||
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 86400 * 1000);
|
||||
await this.prisma.refreshToken.create({ data: { userId, tokenHash, expiresAt } });
|
||||
return raw;
|
||||
}
|
||||
|
||||
private hashToken(token: string): string {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
private hashPassword(password: string): string {
|
||||
const salt = crypto.randomBytes(16).toString("hex");
|
||||
const hash = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex");
|
||||
return \`\${salt}:\${hash}\`;
|
||||
}
|
||||
|
||||
private verifyPassword(password: string, stored: string): boolean {
|
||||
const [salt, hash] = stored.split(":");
|
||||
if (!salt || !hash) return false;
|
||||
const computed = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex");
|
||||
return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(computed));
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
console.log("2FA service (speakeasy) + auth.service.ts written");
|
||||
150
write-2fa.mjs
Normal file
150
write-2fa.mjs
Normal file
@ -0,0 +1,150 @@
|
||||
import { writeFileSync, mkdirSync } from "fs";
|
||||
|
||||
mkdirSync("src/auth/twofa", { recursive: true });
|
||||
|
||||
// ─── two-factor.service.ts ───────────────────────────────────────────────────
|
||||
writeFileSync("src/auth/twofa/two-factor.service.ts", `import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { authenticator } from "otplib";
|
||||
import * as QRCode from "qrcode";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { EncryptionService } from "../../common/encryption.service";
|
||||
|
||||
@Injectable()
|
||||
export class TwoFactorService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly encryption: EncryptionService,
|
||||
) {}
|
||||
|
||||
async generateSecret(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true, twoFactorEnabled: true },
|
||||
});
|
||||
if (!user) throw new BadRequestException("User not found.");
|
||||
if (user.twoFactorEnabled) {
|
||||
throw new BadRequestException("2FA is already enabled.");
|
||||
}
|
||||
|
||||
const secret = authenticator.generateSecret();
|
||||
const otpAuthUrl = authenticator.keyuri(user.email, "LedgerOne", secret);
|
||||
const qrCodeDataUrl = await QRCode.toDataURL(otpAuthUrl);
|
||||
|
||||
// Store encrypted secret temporarily (not yet enabled)
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { twoFactorSecret: this.encryption.encrypt(secret) },
|
||||
});
|
||||
|
||||
return { qrCode: qrCodeDataUrl, otpAuthUrl };
|
||||
}
|
||||
|
||||
async enableTwoFactor(userId: string, token: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { twoFactorSecret: true, twoFactorEnabled: true },
|
||||
});
|
||||
if (!user?.twoFactorSecret) {
|
||||
throw new BadRequestException("Please generate a 2FA secret first.");
|
||||
}
|
||||
if (user.twoFactorEnabled) {
|
||||
throw new BadRequestException("2FA is already enabled.");
|
||||
}
|
||||
|
||||
const secret = this.encryption.decrypt(user.twoFactorSecret);
|
||||
const isValid = authenticator.verify({ token, secret });
|
||||
if (!isValid) {
|
||||
throw new BadRequestException("Invalid TOTP token.");
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { twoFactorEnabled: true },
|
||||
});
|
||||
await this.prisma.auditLog.create({
|
||||
data: { userId, action: "auth.2fa.enabled", metadata: {} },
|
||||
});
|
||||
|
||||
return { message: "2FA enabled successfully." };
|
||||
}
|
||||
|
||||
async disableTwoFactor(userId: string, token: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { twoFactorSecret: true, twoFactorEnabled: true },
|
||||
});
|
||||
if (!user?.twoFactorEnabled || !user.twoFactorSecret) {
|
||||
throw new BadRequestException("2FA is not enabled.");
|
||||
}
|
||||
|
||||
const secret = this.encryption.decrypt(user.twoFactorSecret);
|
||||
const isValid = authenticator.verify({ token, secret });
|
||||
if (!isValid) {
|
||||
throw new BadRequestException("Invalid TOTP token.");
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { twoFactorEnabled: false, twoFactorSecret: null },
|
||||
});
|
||||
await this.prisma.auditLog.create({
|
||||
data: { userId, action: "auth.2fa.disabled", metadata: {} },
|
||||
});
|
||||
|
||||
return { message: "2FA disabled successfully." };
|
||||
}
|
||||
|
||||
verifyToken(secret: string, token: string): boolean {
|
||||
return authenticator.verify({ token, secret });
|
||||
}
|
||||
|
||||
decryptSecret(encryptedSecret: string): string {
|
||||
return this.encryption.decrypt(encryptedSecret);
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── two-factor.controller.ts ────────────────────────────────────────────────
|
||||
writeFileSync("src/auth/twofa/two-factor.controller.ts", `import { Body, Controller, Delete, Post } from "@nestjs/common";
|
||||
import { ok } from "../../common/response";
|
||||
import { TwoFactorService } from "./two-factor.service";
|
||||
import { CurrentUser } from "../../common/decorators/current-user.decorator";
|
||||
|
||||
@Controller("auth/2fa")
|
||||
export class TwoFactorController {
|
||||
constructor(private readonly twoFactorService: TwoFactorService) {}
|
||||
|
||||
@Post("generate")
|
||||
async generate(@CurrentUser() userId: string) {
|
||||
const data = await this.twoFactorService.generateSecret(userId);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("enable")
|
||||
async enable(@CurrentUser() userId: string, @Body("token") token: string) {
|
||||
const data = await this.twoFactorService.enableTwoFactor(userId, token);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Delete("disable")
|
||||
async disable(@CurrentUser() userId: string, @Body("token") token: string) {
|
||||
const data = await this.twoFactorService.disableTwoFactor(userId, token);
|
||||
return ok(data);
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── two-factor.module.ts ────────────────────────────────────────────────────
|
||||
writeFileSync("src/auth/twofa/two-factor.module.ts", `import { Module } from "@nestjs/common";
|
||||
import { TwoFactorController } from "./two-factor.controller";
|
||||
import { TwoFactorService } from "./two-factor.service";
|
||||
|
||||
@Module({
|
||||
controllers: [TwoFactorController],
|
||||
providers: [TwoFactorService],
|
||||
exports: [TwoFactorService],
|
||||
})
|
||||
export class TwoFactorModule {}
|
||||
`);
|
||||
|
||||
console.log("2FA files written");
|
||||
295
write-files.mjs
Normal file
295
write-files.mjs
Normal file
@ -0,0 +1,295 @@
|
||||
import { writeFileSync } from "fs";
|
||||
|
||||
// ─── auth.service.ts ─────────────────────────────────────────────────────────
|
||||
writeFileSync("src/auth/auth.service.ts", `import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import * as crypto from "crypto";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { EmailService } from "../email/email.service";
|
||||
import { LoginDto } from "./dto/login.dto";
|
||||
import { RegisterDto } from "./dto/register.dto";
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
|
||||
import { ResetPasswordDto } from "./dto/reset-password.dto";
|
||||
|
||||
const VERIFY_TOKEN_TTL_HOURS = 24;
|
||||
const RESET_TOKEN_TTL_HOURS = 1;
|
||||
const REFRESH_TOKEN_TTL_DAYS = 30;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly emailService: EmailService,
|
||||
) {}
|
||||
|
||||
async register(payload: RegisterDto) {
|
||||
const email = payload.email.toLowerCase().trim();
|
||||
const existing = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (existing) throw new BadRequestException("Email already registered.");
|
||||
|
||||
const passwordHash = this.hashPassword(payload.password);
|
||||
const user = await this.prisma.user.create({ data: { email, passwordHash } });
|
||||
await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.register", metadata: { email } } });
|
||||
|
||||
const verifyToken = crypto.randomBytes(32).toString("hex");
|
||||
const expiresAt = new Date(Date.now() + VERIFY_TOKEN_TTL_HOURS * 3600 * 1000);
|
||||
await this.prisma.emailVerificationToken.upsert({
|
||||
where: { userId: user.id },
|
||||
update: { token: verifyToken, expiresAt },
|
||||
create: { userId: user.id, token: verifyToken, expiresAt },
|
||||
});
|
||||
await this.emailService.sendVerificationEmail(email, verifyToken);
|
||||
|
||||
const accessToken = this.signAccessToken(user.id);
|
||||
const refreshToken = await this.createRefreshToken(user.id);
|
||||
return {
|
||||
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
|
||||
accessToken,
|
||||
refreshToken,
|
||||
message: "Registration successful. Please verify your email.",
|
||||
};
|
||||
}
|
||||
|
||||
async login(payload: LoginDto) {
|
||||
const email = payload.email.toLowerCase().trim();
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user || !this.verifyPassword(payload.password, user.passwordHash)) {
|
||||
throw new UnauthorizedException("Invalid credentials.");
|
||||
}
|
||||
await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.login", metadata: { email } } });
|
||||
const accessToken = this.signAccessToken(user.id);
|
||||
const refreshToken = await this.createRefreshToken(user.id);
|
||||
return {
|
||||
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyEmail(token: string) {
|
||||
const record = await this.prisma.emailVerificationToken.findUnique({ where: { token } });
|
||||
if (!record || record.expiresAt < new Date()) {
|
||||
throw new BadRequestException("Invalid or expired verification token.");
|
||||
}
|
||||
await this.prisma.user.update({ where: { id: record.userId }, data: { emailVerified: true } });
|
||||
await this.prisma.emailVerificationToken.delete({ where: { token } });
|
||||
return { message: "Email verified successfully." };
|
||||
}
|
||||
|
||||
async refreshAccessToken(rawRefreshToken: string) {
|
||||
const tokenHash = this.hashToken(rawRefreshToken);
|
||||
const record = await this.prisma.refreshToken.findUnique({ where: { tokenHash } });
|
||||
if (!record || record.revokedAt || record.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException("Invalid or expired refresh token.");
|
||||
}
|
||||
await this.prisma.refreshToken.update({ where: { id: record.id }, data: { revokedAt: new Date() } });
|
||||
const accessToken = this.signAccessToken(record.userId);
|
||||
const refreshToken = await this.createRefreshToken(record.userId);
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async logout(rawRefreshToken: string) {
|
||||
const tokenHash = this.hashToken(rawRefreshToken);
|
||||
await this.prisma.refreshToken.updateMany({
|
||||
where: { tokenHash, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return { message: "Logged out." };
|
||||
}
|
||||
|
||||
async forgotPassword(payload: ForgotPasswordDto) {
|
||||
const email = payload.email.toLowerCase().trim();
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user) return { message: "If that email exists, a reset link has been sent." };
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_HOURS * 3600 * 1000);
|
||||
await this.prisma.passwordResetToken.create({ data: { userId: user.id, token, expiresAt } });
|
||||
await this.emailService.sendPasswordResetEmail(email, token);
|
||||
return { message: "If that email exists, a reset link has been sent." };
|
||||
}
|
||||
|
||||
async resetPassword(payload: ResetPasswordDto) {
|
||||
const record = await this.prisma.passwordResetToken.findUnique({ where: { token: payload.token } });
|
||||
if (!record || record.usedAt || record.expiresAt < new Date()) {
|
||||
throw new BadRequestException("Invalid or expired reset token.");
|
||||
}
|
||||
const passwordHash = this.hashPassword(payload.password);
|
||||
await this.prisma.user.update({ where: { id: record.userId }, data: { passwordHash } });
|
||||
await this.prisma.passwordResetToken.update({ where: { id: record.id }, data: { usedAt: new Date() } });
|
||||
await this.prisma.refreshToken.updateMany({
|
||||
where: { userId: record.userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return { message: "Password reset successfully. Please log in." };
|
||||
}
|
||||
|
||||
async updateProfile(userId: string, payload: UpdateProfileDto) {
|
||||
const data: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
const trimmed = (value as string | undefined)?.trim();
|
||||
if (trimmed) data[key] = trimmed;
|
||||
}
|
||||
if (!Object.keys(data).length) throw new BadRequestException("No profile fields provided.");
|
||||
const user = await this.prisma.user.update({ where: { id: userId }, data });
|
||||
await this.prisma.auditLog.create({
|
||||
data: { userId: user.id, action: "auth.profile.update", metadata: { updatedFields: Object.keys(data) } },
|
||||
});
|
||||
return {
|
||||
user: {
|
||||
id: user.id, email: user.email, fullName: user.fullName, phone: user.phone,
|
||||
companyName: user.companyName, addressLine1: user.addressLine1,
|
||||
addressLine2: user.addressLine2, city: user.city, state: user.state,
|
||||
postalCode: user.postalCode, country: user.country,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getProfile(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException("User not found.");
|
||||
return {
|
||||
user: {
|
||||
id: user.id, email: user.email, fullName: user.fullName, phone: user.phone,
|
||||
companyName: user.companyName, addressLine1: user.addressLine1,
|
||||
addressLine2: user.addressLine2, city: user.city, state: user.state,
|
||||
postalCode: user.postalCode, country: user.country,
|
||||
emailVerified: user.emailVerified, createdAt: user.createdAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
verifyToken(token: string): { sub: string } {
|
||||
return this.jwtService.verify<{ sub: string }>(token);
|
||||
}
|
||||
|
||||
private signAccessToken(userId: string): string {
|
||||
return this.jwtService.sign({ sub: userId });
|
||||
}
|
||||
|
||||
private async createRefreshToken(userId: string): Promise<string> {
|
||||
const raw = crypto.randomBytes(40).toString("hex");
|
||||
const tokenHash = this.hashToken(raw);
|
||||
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 86400 * 1000);
|
||||
await this.prisma.refreshToken.create({ data: { userId, tokenHash, expiresAt } });
|
||||
return raw;
|
||||
}
|
||||
|
||||
private hashToken(token: string): string {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
private hashPassword(password: string): string {
|
||||
const salt = crypto.randomBytes(16).toString("hex");
|
||||
const hash = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex");
|
||||
return \`\${salt}:\${hash}\`;
|
||||
}
|
||||
|
||||
private verifyPassword(password: string, stored: string): boolean {
|
||||
const [salt, hash] = stored.split(":");
|
||||
if (!salt || !hash) return false;
|
||||
const computed = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex");
|
||||
return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(computed));
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── auth.controller.ts ──────────────────────────────────────────────────────
|
||||
writeFileSync("src/auth/auth.controller.ts", `import { Body, Controller, Get, Post, Patch, Query, UseGuards } from "@nestjs/common";
|
||||
import { ok } from "../common/response";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { LoginDto } from "./dto/login.dto";
|
||||
import { RegisterDto } from "./dto/register.dto";
|
||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
|
||||
import { ResetPasswordDto } from "./dto/reset-password.dto";
|
||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { Public } from "../common/decorators/public.decorator";
|
||||
|
||||
@Controller("auth")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post("register")
|
||||
async register(@Body() payload: RegisterDto) {
|
||||
return ok(await this.authService.register(payload));
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("login")
|
||||
async login(@Body() payload: LoginDto) {
|
||||
return ok(await this.authService.login(payload));
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get("verify-email")
|
||||
async verifyEmail(@Query("token") token: string) {
|
||||
return ok(await this.authService.verifyEmail(token));
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("refresh")
|
||||
async refresh(@Body("refreshToken") refreshToken: string) {
|
||||
return ok(await this.authService.refreshAccessToken(refreshToken));
|
||||
}
|
||||
|
||||
@Post("logout")
|
||||
async logout(@Body("refreshToken") refreshToken: string) {
|
||||
return ok(await this.authService.logout(refreshToken));
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("forgot-password")
|
||||
async forgotPassword(@Body() payload: ForgotPasswordDto) {
|
||||
return ok(await this.authService.forgotPassword(payload));
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("reset-password")
|
||||
async resetPassword(@Body() payload: ResetPasswordDto) {
|
||||
return ok(await this.authService.resetPassword(payload));
|
||||
}
|
||||
|
||||
@Get("me")
|
||||
async me(@CurrentUser() userId: string) {
|
||||
return ok(await this.authService.getProfile(userId));
|
||||
}
|
||||
|
||||
@Patch("profile")
|
||||
async updateProfile(@CurrentUser() userId: string, @Body() payload: UpdateProfileDto) {
|
||||
return ok(await this.authService.updateProfile(userId, payload));
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── auth.module.ts ──────────────────────────────────────────────────────────
|
||||
writeFileSync("src/auth/auth.module.ts", `import { Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET,
|
||||
signOptions: { expiresIn: "15m" },
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtAuthGuard],
|
||||
exports: [AuthService, JwtModule, JwtAuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
`);
|
||||
|
||||
console.log("auth files written");
|
||||
414
write-rules-tax.mjs
Normal file
414
write-rules-tax.mjs
Normal file
@ -0,0 +1,414 @@
|
||||
import { writeFileSync } from "fs";
|
||||
|
||||
// ─── rules.service.ts (Prisma) ───────────────────────────────────────────────
|
||||
writeFileSync("src/rules/rules.service.ts", `import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class RulesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(userId: string) {
|
||||
return this.prisma.rule.findMany({
|
||||
where: { userId, isActive: true },
|
||||
orderBy: { priority: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
payload: {
|
||||
name: string;
|
||||
priority?: number;
|
||||
conditions: Record<string, unknown>;
|
||||
actions: Record<string, unknown>;
|
||||
isActive?: boolean;
|
||||
},
|
||||
) {
|
||||
const count = await this.prisma.rule.count({ where: { userId } });
|
||||
return this.prisma.rule.create({
|
||||
data: {
|
||||
userId,
|
||||
name: payload.name ?? "Untitled rule",
|
||||
priority: payload.priority ?? count + 1,
|
||||
conditions: payload.conditions as Prisma.InputJsonValue,
|
||||
actions: payload.actions as Prisma.InputJsonValue,
|
||||
isActive: payload.isActive !== false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
userId: string,
|
||||
id: string,
|
||||
payload: {
|
||||
name?: string;
|
||||
priority?: number;
|
||||
conditions?: Record<string, unknown>;
|
||||
actions?: Record<string, unknown>;
|
||||
isActive?: boolean;
|
||||
},
|
||||
) {
|
||||
const existing = await this.prisma.rule.findFirst({ where: { id, userId } });
|
||||
if (!existing) throw new BadRequestException("Rule not found.");
|
||||
return this.prisma.rule.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(payload.name !== undefined && { name: payload.name }),
|
||||
...(payload.priority !== undefined && { priority: payload.priority }),
|
||||
...(payload.conditions !== undefined && { conditions: payload.conditions as Prisma.InputJsonValue }),
|
||||
...(payload.actions !== undefined && { actions: payload.actions as Prisma.InputJsonValue }),
|
||||
...(payload.isActive !== undefined && { isActive: payload.isActive }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private matchesRule(
|
||||
conditions: Record<string, unknown>,
|
||||
tx: { description: string; amount: number | string },
|
||||
): boolean {
|
||||
const textContains = typeof conditions.textContains === "string" ? conditions.textContains : "";
|
||||
const amountGt = typeof conditions.amountGreaterThan === "number" ? conditions.amountGreaterThan : null;
|
||||
const amountLt = typeof conditions.amountLessThan === "number" ? conditions.amountLessThan : null;
|
||||
|
||||
if (textContains && !tx.description.toLowerCase().includes(textContains.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
const amount = Number(tx.amount);
|
||||
if (amountGt !== null && amount <= amountGt) return false;
|
||||
if (amountLt !== null && amount >= amountLt) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async execute(userId: string, id: string) {
|
||||
const rule = await this.prisma.rule.findFirst({ where: { id, userId } });
|
||||
if (!rule || !rule.isActive) return { id, status: "skipped" };
|
||||
|
||||
const conditions = rule.conditions as Record<string, unknown>;
|
||||
const actions = rule.actions as Record<string, unknown>;
|
||||
|
||||
const transactions = await this.prisma.transactionRaw.findMany({
|
||||
where: { account: { userId } },
|
||||
include: { derived: true },
|
||||
});
|
||||
|
||||
let applied = 0;
|
||||
for (const tx of transactions) {
|
||||
if (!this.matchesRule(conditions, { description: tx.description, amount: tx.amount })) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.prisma.transactionDerived.upsert({
|
||||
where: { rawTransactionId: tx.id },
|
||||
update: {
|
||||
userCategory: typeof actions.setCategory === "string" ? actions.setCategory : tx.derived?.userCategory ?? null,
|
||||
isHidden: typeof actions.setHidden === "boolean" ? actions.setHidden : tx.derived?.isHidden ?? false,
|
||||
modifiedAt: new Date(),
|
||||
modifiedBy: "rule",
|
||||
},
|
||||
create: {
|
||||
rawTransactionId: tx.id,
|
||||
userCategory: typeof actions.setCategory === "string" ? actions.setCategory : null,
|
||||
isHidden: typeof actions.setHidden === "boolean" ? actions.setHidden : false,
|
||||
modifiedAt: new Date(),
|
||||
modifiedBy: "rule",
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.ruleExecution.create({
|
||||
data: {
|
||||
ruleId: rule.id,
|
||||
transactionId: tx.id,
|
||||
result: { applied: true } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
applied += 1;
|
||||
}
|
||||
|
||||
return { id: rule.id, status: "completed", applied };
|
||||
}
|
||||
|
||||
async suggest(userId: string) {
|
||||
const derived = await this.prisma.transactionDerived.findMany({
|
||||
where: {
|
||||
raw: { account: { userId } },
|
||||
userCategory: { not: null },
|
||||
},
|
||||
include: { raw: { select: { description: true } } },
|
||||
take: 200,
|
||||
});
|
||||
|
||||
const bucket = new Map<string, { category: string; count: number }>();
|
||||
for (const item of derived) {
|
||||
const key = item.raw.description.toLowerCase();
|
||||
const category = item.userCategory ?? "Uncategorized";
|
||||
const entry = bucket.get(key) ?? { category, count: 0 };
|
||||
entry.count += 1;
|
||||
bucket.set(key, entry);
|
||||
}
|
||||
|
||||
return Array.from(bucket.entries())
|
||||
.filter(([, value]) => value.count >= 2)
|
||||
.slice(0, 5)
|
||||
.map(([description, value], index) => ({
|
||||
id: \`suggestion_\${index + 1}\`,
|
||||
name: \`Auto: \${value.category}\`,
|
||||
conditions: { textContains: description },
|
||||
actions: { setCategory: value.category },
|
||||
confidence: Math.min(0.95, 0.5 + value.count * 0.1),
|
||||
}));
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── rules.controller.ts ─────────────────────────────────────────────────────
|
||||
writeFileSync("src/rules/rules.controller.ts", `import { Body, Controller, Get, Param, Post, Put } from "@nestjs/common";
|
||||
import { ok } from "../common/response";
|
||||
import { RulesService } from "./rules.service";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
|
||||
@Controller("rules")
|
||||
export class RulesController {
|
||||
constructor(private readonly rulesService: RulesService) {}
|
||||
|
||||
@Get()
|
||||
async list(@CurrentUser() userId: string) {
|
||||
const data = await this.rulesService.list(userId);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@CurrentUser() userId: string,
|
||||
@Body()
|
||||
payload: {
|
||||
name: string;
|
||||
priority?: number;
|
||||
conditions: Record<string, unknown>;
|
||||
actions: Record<string, unknown>;
|
||||
isActive?: boolean;
|
||||
},
|
||||
) {
|
||||
const data = await this.rulesService.create(userId, payload);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Put(":id")
|
||||
async update(
|
||||
@CurrentUser() userId: string,
|
||||
@Param("id") id: string,
|
||||
@Body()
|
||||
payload: {
|
||||
name?: string;
|
||||
priority?: number;
|
||||
conditions?: Record<string, unknown>;
|
||||
actions?: Record<string, unknown>;
|
||||
isActive?: boolean;
|
||||
},
|
||||
) {
|
||||
const data = await this.rulesService.update(userId, id, payload);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post(":id/execute")
|
||||
async execute(@CurrentUser() userId: string, @Param("id") id: string) {
|
||||
const data = await this.rulesService.execute(userId, id);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Get("suggestions")
|
||||
async suggestions(@CurrentUser() userId: string) {
|
||||
const data = await this.rulesService.suggest(userId);
|
||||
return ok(data);
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── rules.module.ts ─────────────────────────────────────────────────────────
|
||||
writeFileSync("src/rules/rules.module.ts", `import { Module } from "@nestjs/common";
|
||||
import { RulesController } from "./rules.controller";
|
||||
import { RulesService } from "./rules.service";
|
||||
|
||||
@Module({
|
||||
controllers: [RulesController],
|
||||
providers: [RulesService],
|
||||
})
|
||||
export class RulesModule {}
|
||||
`);
|
||||
|
||||
// ─── tax.service.ts (Prisma) ─────────────────────────────────────────────────
|
||||
writeFileSync("src/tax/tax.service.ts", `import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreateTaxReturnDto } from "./dto/create-return.dto";
|
||||
import { UpdateTaxReturnDto } from "./dto/update-return.dto";
|
||||
|
||||
@Injectable()
|
||||
export class TaxService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listReturns(userId: string) {
|
||||
return this.prisma.taxReturn.findMany({
|
||||
where: { userId },
|
||||
include: { documents: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
async createReturn(userId: string, payload: CreateTaxReturnDto) {
|
||||
return this.prisma.taxReturn.create({
|
||||
data: {
|
||||
userId,
|
||||
taxYear: payload.taxYear,
|
||||
filingType: payload.filingType,
|
||||
jurisdictions: payload.jurisdictions as Prisma.InputJsonValue,
|
||||
status: "draft",
|
||||
summary: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateReturn(userId: string, id: string, payload: UpdateTaxReturnDto) {
|
||||
const existing = await this.prisma.taxReturn.findFirst({ where: { id, userId } });
|
||||
if (!existing) throw new BadRequestException("Tax return not found.");
|
||||
return this.prisma.taxReturn.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(payload.status !== undefined && { status: payload.status }),
|
||||
...(payload.summary !== undefined && { summary: payload.summary as Prisma.InputJsonValue }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async addDocument(
|
||||
userId: string,
|
||||
returnId: string,
|
||||
docType: string,
|
||||
metadata: Record<string, unknown>,
|
||||
) {
|
||||
const taxReturn = await this.prisma.taxReturn.findFirst({ where: { id: returnId, userId } });
|
||||
if (!taxReturn) throw new BadRequestException("Tax return not found.");
|
||||
return this.prisma.taxDocument.create({
|
||||
data: {
|
||||
taxReturnId: returnId,
|
||||
docType,
|
||||
metadata: metadata as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async exportReturn(userId: string, id: string) {
|
||||
const taxReturn = await this.prisma.taxReturn.findFirst({
|
||||
where: { id, userId },
|
||||
include: { documents: true },
|
||||
});
|
||||
if (!taxReturn) throw new BadRequestException("Tax return not found.");
|
||||
|
||||
await this.prisma.taxReturn.update({
|
||||
where: { id },
|
||||
data: { status: "exported" },
|
||||
});
|
||||
|
||||
return { return: { ...taxReturn, status: "exported" }, documents: taxReturn.documents };
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── tax.controller.ts ───────────────────────────────────────────────────────
|
||||
writeFileSync("src/tax/tax.controller.ts", `import { Body, Controller, Get, Param, Patch, Post } from "@nestjs/common";
|
||||
import { ok } from "../common/response";
|
||||
import { CreateTaxReturnDto } from "./dto/create-return.dto";
|
||||
import { UpdateTaxReturnDto } from "./dto/update-return.dto";
|
||||
import { TaxService } from "./tax.service";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
|
||||
@Controller("tax")
|
||||
export class TaxController {
|
||||
constructor(private readonly taxService: TaxService) {}
|
||||
|
||||
@Get("returns")
|
||||
async listReturns(@CurrentUser() userId: string) {
|
||||
const data = await this.taxService.listReturns(userId);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("returns")
|
||||
async createReturn(@CurrentUser() userId: string, @Body() payload: CreateTaxReturnDto) {
|
||||
const data = await this.taxService.createReturn(userId, payload);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Patch("returns/:id")
|
||||
async updateReturn(
|
||||
@CurrentUser() userId: string,
|
||||
@Param("id") id: string,
|
||||
@Body() payload: UpdateTaxReturnDto,
|
||||
) {
|
||||
const data = await this.taxService.updateReturn(userId, id, payload);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("returns/:id/documents")
|
||||
async addDocument(
|
||||
@CurrentUser() userId: string,
|
||||
@Param("id") id: string,
|
||||
@Body() payload: { docType: string; metadata?: Record<string, unknown> },
|
||||
) {
|
||||
const data = await this.taxService.addDocument(userId, id, payload.docType, payload.metadata ?? {});
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("returns/:id/export")
|
||||
async exportReturn(@CurrentUser() userId: string, @Param("id") id: string) {
|
||||
const data = await this.taxService.exportReturn(userId, id);
|
||||
return ok(data);
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── tax.module.ts ───────────────────────────────────────────────────────────
|
||||
writeFileSync("src/tax/tax.module.ts", `import { Module } from "@nestjs/common";
|
||||
import { TaxController } from "./tax.controller";
|
||||
import { TaxService } from "./tax.service";
|
||||
|
||||
@Module({
|
||||
controllers: [TaxController],
|
||||
providers: [TaxService],
|
||||
})
|
||||
export class TaxModule {}
|
||||
`);
|
||||
|
||||
// ─── tax DTOs (updated to class-validator) ───────────────────────────────────
|
||||
writeFileSync("src/tax/dto/create-return.dto.ts", `import { IsArray, IsIn, IsInt, IsString } from "class-validator";
|
||||
|
||||
export class CreateTaxReturnDto {
|
||||
@IsInt()
|
||||
taxYear!: number;
|
||||
|
||||
@IsString()
|
||||
@IsIn(["individual", "business"])
|
||||
filingType!: "individual" | "business";
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
jurisdictions!: string[];
|
||||
}
|
||||
`);
|
||||
|
||||
writeFileSync("src/tax/dto/update-return.dto.ts", `import { IsIn, IsObject, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class UpdateTaxReturnDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(["draft", "ready", "exported"])
|
||||
status?: "draft" | "ready" | "exported";
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
summary?: Record<string, unknown>;
|
||||
}
|
||||
`);
|
||||
|
||||
console.log("rules + tax files written");
|
||||
429
write-stripe-sheets.mjs
Normal file
429
write-stripe-sheets.mjs
Normal file
@ -0,0 +1,429 @@
|
||||
import { writeFileSync, mkdirSync } from "fs";
|
||||
|
||||
mkdirSync("src/stripe", { recursive: true });
|
||||
|
||||
// ─── stripe.service.ts ───────────────────────────────────────────────────────
|
||||
writeFileSync("src/stripe/stripe.service.ts", `import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import Stripe from "stripe";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
export const PLAN_LIMITS: Record<string, { accounts: number; exports: number }> = {
|
||||
free: { accounts: 2, exports: 5 },
|
||||
pro: { accounts: 10, exports: 100 },
|
||||
elite: { accounts: -1, exports: -1 }, // -1 = unlimited
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class StripeService {
|
||||
private readonly stripe: Stripe;
|
||||
private readonly logger = new Logger(StripeService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {
|
||||
const key = process.env.STRIPE_SECRET_KEY;
|
||||
if (!key) throw new Error("STRIPE_SECRET_KEY is required.");
|
||||
this.stripe = new Stripe(key, { apiVersion: "2025-01-27.acacia" });
|
||||
}
|
||||
|
||||
async getOrCreateCustomer(userId: string, email: string): Promise<string> {
|
||||
const sub = await this.prisma.subscription.findUnique({ where: { userId } });
|
||||
if (sub?.stripeCustomerId) return sub.stripeCustomerId;
|
||||
|
||||
const customer = await this.stripe.customers.create({ email, metadata: { userId } });
|
||||
await this.prisma.subscription.upsert({
|
||||
where: { userId },
|
||||
update: { stripeCustomerId: customer.id },
|
||||
create: { userId, plan: "free", stripeCustomerId: customer.id },
|
||||
});
|
||||
return customer.id;
|
||||
}
|
||||
|
||||
async createCheckoutSession(userId: string, email: string, priceId: string) {
|
||||
const customerId = await this.getOrCreateCustomer(userId, email);
|
||||
const session = await this.stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
payment_method_types: ["card"],
|
||||
mode: "subscription",
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: \`\${process.env.APP_URL}/settings/billing?success=true\`,
|
||||
cancel_url: \`\${process.env.APP_URL}/settings/billing?cancelled=true\`,
|
||||
metadata: { userId },
|
||||
});
|
||||
return { url: session.url };
|
||||
}
|
||||
|
||||
async createPortalSession(userId: string) {
|
||||
const sub = await this.prisma.subscription.findUnique({ where: { userId } });
|
||||
if (!sub?.stripeCustomerId) {
|
||||
throw new BadRequestException("No Stripe customer found. Please upgrade first.");
|
||||
}
|
||||
const session = await this.stripe.billingPortal.sessions.create({
|
||||
customer: sub.stripeCustomerId,
|
||||
return_url: \`\${process.env.APP_URL}/settings/billing\`,
|
||||
});
|
||||
return { url: session.url };
|
||||
}
|
||||
|
||||
async getSubscription(userId: string) {
|
||||
const sub = await this.prisma.subscription.findUnique({ where: { userId } });
|
||||
return sub ?? { userId, plan: "free" };
|
||||
}
|
||||
|
||||
async handleWebhook(rawBody: Buffer, signature: string) {
|
||||
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!secret) throw new Error("STRIPE_WEBHOOK_SECRET is required.");
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = this.stripe.webhooks.constructEvent(rawBody, signature, secret);
|
||||
} catch (err) {
|
||||
this.logger.warn(\`Webhook signature verification failed: \${err}\`);
|
||||
throw new BadRequestException("Invalid webhook signature.");
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case "customer.subscription.created":
|
||||
case "customer.subscription.updated": {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
await this.syncSubscription(subscription);
|
||||
break;
|
||||
}
|
||||
case "customer.subscription.deleted": {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
const customerId = subscription.customer as string;
|
||||
const sub = await this.prisma.subscription.findFirst({
|
||||
where: { stripeCustomerId: customerId },
|
||||
});
|
||||
if (sub) {
|
||||
await this.prisma.subscription.update({
|
||||
where: { userId: sub.userId },
|
||||
data: { plan: "free", stripeSubId: null, currentPeriodEnd: null, cancelAtPeriodEnd: false },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
this.logger.debug(\`Unhandled Stripe event: \${event.type}\`);
|
||||
}
|
||||
|
||||
return { received: true };
|
||||
}
|
||||
|
||||
private async syncSubscription(subscription: Stripe.Subscription) {
|
||||
const customerId = subscription.customer as string;
|
||||
const sub = await this.prisma.subscription.findFirst({
|
||||
where: { stripeCustomerId: customerId },
|
||||
});
|
||||
if (!sub) return;
|
||||
|
||||
const priceId = subscription.items.data[0]?.price.id;
|
||||
let plan = "free";
|
||||
if (priceId === process.env.STRIPE_PRICE_PRO) plan = "pro";
|
||||
else if (priceId === process.env.STRIPE_PRICE_ELITE) plan = "elite";
|
||||
|
||||
await this.prisma.subscription.update({
|
||||
where: { userId: sub.userId },
|
||||
data: {
|
||||
plan,
|
||||
stripeSubId: subscription.id,
|
||||
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── stripe.controller.ts ────────────────────────────────────────────────────
|
||||
writeFileSync("src/stripe/stripe.controller.ts", `import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
Post,
|
||||
RawBodyRequest,
|
||||
Req,
|
||||
} from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
import { ok } from "../common/response";
|
||||
import { StripeService } from "./stripe.service";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { Public } from "../common/decorators/public.decorator";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Controller("billing")
|
||||
export class StripeController {
|
||||
constructor(
|
||||
private readonly stripeService: StripeService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Get("subscription")
|
||||
async getSubscription(@CurrentUser() userId: string) {
|
||||
const data = await this.stripeService.getSubscription(userId);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("checkout")
|
||||
async checkout(@CurrentUser() userId: string, @Body("priceId") priceId: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true },
|
||||
});
|
||||
if (!user) return ok({ error: "User not found" });
|
||||
const data = await this.stripeService.createCheckoutSession(userId, user.email, priceId);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("portal")
|
||||
async portal(@CurrentUser() userId: string) {
|
||||
const data = await this.stripeService.createPortalSession(userId);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post("webhook")
|
||||
async webhook(
|
||||
@Req() req: RawBodyRequest<Request>,
|
||||
@Headers("stripe-signature") signature: string,
|
||||
) {
|
||||
const data = await this.stripeService.handleWebhook(req.rawBody!, signature);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── subscription.guard.ts ───────────────────────────────────────────────────
|
||||
writeFileSync("src/stripe/subscription.guard.ts", `import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
} from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { Request } from "express";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
export const REQUIRED_PLAN_KEY = "requiredPlan";
|
||||
export const RequiredPlan = (plan: "pro" | "elite") =>
|
||||
SetMetadata(REQUIRED_PLAN_KEY, plan);
|
||||
|
||||
const PLAN_RANK: Record<string, number> = { free: 0, pro: 1, elite: 2 };
|
||||
|
||||
@Injectable()
|
||||
export class SubscriptionGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const required = this.reflector.getAllAndOverride<string>(REQUIRED_PLAN_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!required) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request & { user?: { sub: string } }>();
|
||||
const userId = request.user?.sub;
|
||||
if (!userId) throw new ForbiddenException("Authentication required.");
|
||||
|
||||
const sub = await this.prisma.subscription.findUnique({ where: { userId } });
|
||||
const currentPlan = sub?.plan ?? "free";
|
||||
const currentRank = PLAN_RANK[currentPlan] ?? 0;
|
||||
const requiredRank = PLAN_RANK[required] ?? 0;
|
||||
|
||||
if (currentRank < requiredRank) {
|
||||
throw new ForbiddenException(\`This feature requires a \${required} plan.\`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── stripe.module.ts ────────────────────────────────────────────────────────
|
||||
writeFileSync("src/stripe/stripe.module.ts", `import { Module } from "@nestjs/common";
|
||||
import { StripeController } from "./stripe.controller";
|
||||
import { StripeService } from "./stripe.service";
|
||||
import { SubscriptionGuard } from "./subscription.guard";
|
||||
|
||||
@Module({
|
||||
controllers: [StripeController],
|
||||
providers: [StripeService, SubscriptionGuard],
|
||||
exports: [StripeService, SubscriptionGuard],
|
||||
})
|
||||
export class StripeModule {}
|
||||
`);
|
||||
|
||||
// ─── exports.service.ts (with real Google Sheets) ────────────────────────────
|
||||
writeFileSync("src/exports/exports.service.ts", `import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import { google } from "googleapis";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class ExportsService {
|
||||
private readonly logger = new Logger(ExportsService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private toCsv(rows: Array<Record<string, string>>) {
|
||||
if (!rows.length) return "";
|
||||
const headers = Object.keys(rows[0]);
|
||||
const escape = (value: string) => \`"\${value.replace(/"/g, '""')}"\`;
|
||||
const lines = [headers.join(",")];
|
||||
for (const row of rows) {
|
||||
lines.push(headers.map((key) => escape(row[key] ?? "")).join(","));
|
||||
}
|
||||
return lines.join("\\n");
|
||||
}
|
||||
|
||||
private async getTransactions(
|
||||
userId: string,
|
||||
filters: Record<string, string>,
|
||||
limit = 1000,
|
||||
) {
|
||||
const where: Record<string, unknown> = { account: { userId } };
|
||||
if (filters.start_date || filters.end_date) {
|
||||
where.date = {
|
||||
gte: filters.start_date ? new Date(filters.start_date) : undefined,
|
||||
lte: filters.end_date ? new Date(filters.end_date) : undefined,
|
||||
};
|
||||
}
|
||||
if (filters.min_amount || filters.max_amount) {
|
||||
where.amount = {
|
||||
gte: filters.min_amount ? Number(filters.min_amount) : undefined,
|
||||
lte: filters.max_amount ? Number(filters.max_amount) : undefined,
|
||||
};
|
||||
}
|
||||
if (filters.category) {
|
||||
where.derived = {
|
||||
is: { userCategory: { contains: filters.category, mode: "insensitive" } },
|
||||
};
|
||||
}
|
||||
if (filters.source) {
|
||||
where.source = { contains: filters.source, mode: "insensitive" };
|
||||
}
|
||||
if (filters.include_hidden !== "true") {
|
||||
where.OR = [{ derived: null }, { derived: { isHidden: false } }];
|
||||
}
|
||||
return this.prisma.transactionRaw.findMany({
|
||||
where,
|
||||
include: { derived: true },
|
||||
orderBy: { date: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
private toRows(transactions: Awaited<ReturnType<typeof this.getTransactions>>) {
|
||||
return transactions.map((tx) => ({
|
||||
id: tx.id,
|
||||
date: tx.date.toISOString().slice(0, 10),
|
||||
description: tx.description,
|
||||
amount: Number(tx.amount).toFixed(2),
|
||||
category: tx.derived?.userCategory ?? "",
|
||||
notes: tx.derived?.userNotes ?? "",
|
||||
hidden: tx.derived?.isHidden ? "true" : "false",
|
||||
source: tx.source,
|
||||
}));
|
||||
}
|
||||
|
||||
async exportCsv(userId: string, filters: Record<string, string> = {}) {
|
||||
const transactions = await this.getTransactions(userId, filters);
|
||||
const rows = this.toRows(transactions);
|
||||
const csv = this.toCsv(rows);
|
||||
|
||||
await this.prisma.exportLog.create({
|
||||
data: { userId, filters, rowCount: rows.length },
|
||||
});
|
||||
|
||||
return { status: "ready", csv, rowCount: rows.length };
|
||||
}
|
||||
|
||||
async exportSheets(userId: string, filters: Record<string, string> = {}) {
|
||||
// Get the user's Google connection
|
||||
const gc = await this.prisma.googleConnection.findUnique({ where: { userId } });
|
||||
if (!gc || !gc.isConnected) {
|
||||
throw new BadRequestException(
|
||||
"Google account not connected. Please connect via /api/google/connect.",
|
||||
);
|
||||
}
|
||||
|
||||
const oauth2Client = new google.auth.OAuth2(
|
||||
process.env.GOOGLE_CLIENT_ID,
|
||||
process.env.GOOGLE_CLIENT_SECRET,
|
||||
);
|
||||
oauth2Client.setCredentials({
|
||||
access_token: gc.accessToken,
|
||||
refresh_token: gc.refreshToken,
|
||||
});
|
||||
|
||||
// Refresh the access token if needed
|
||||
const { credentials } = await oauth2Client.refreshAccessToken();
|
||||
await this.prisma.googleConnection.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
accessToken: credentials.access_token ?? gc.accessToken,
|
||||
lastSyncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
oauth2Client.setCredentials(credentials);
|
||||
|
||||
const sheets = google.sheets({ version: "v4", auth: oauth2Client });
|
||||
const transactions = await this.getTransactions(userId, filters);
|
||||
const rows = this.toRows(transactions);
|
||||
|
||||
const sheetTitle = \`LedgerOne Export \${new Date().toISOString().slice(0, 10)}\`;
|
||||
|
||||
let spreadsheetId = gc.spreadsheetId;
|
||||
if (!spreadsheetId) {
|
||||
// Create a new spreadsheet
|
||||
const spreadsheet = await sheets.spreadsheets.create({
|
||||
requestBody: { properties: { title: "LedgerOne" } },
|
||||
});
|
||||
spreadsheetId = spreadsheet.data.spreadsheetId!;
|
||||
await this.prisma.googleConnection.update({
|
||||
where: { userId },
|
||||
data: { spreadsheetId },
|
||||
});
|
||||
}
|
||||
|
||||
// Add a new sheet tab
|
||||
await sheets.spreadsheets.batchUpdate({
|
||||
spreadsheetId,
|
||||
requestBody: {
|
||||
requests: [{ addSheet: { properties: { title: sheetTitle } } }],
|
||||
},
|
||||
});
|
||||
|
||||
// Build values: header + data rows
|
||||
const headers = rows.length ? Object.keys(rows[0]) : ["id", "date", "description", "amount", "category", "notes", "hidden", "source"];
|
||||
const values = [
|
||||
headers,
|
||||
...rows.map((row) => headers.map((h) => row[h as keyof typeof row] ?? "")),
|
||||
];
|
||||
|
||||
await sheets.spreadsheets.values.update({
|
||||
spreadsheetId,
|
||||
range: \`'\${sheetTitle}'!A1\`,
|
||||
valueInputOption: "RAW",
|
||||
requestBody: { values },
|
||||
});
|
||||
|
||||
await this.prisma.exportLog.create({
|
||||
data: { userId, filters: { ...filters, destination: "google_sheets" }, rowCount: rows.length },
|
||||
});
|
||||
|
||||
this.logger.log(\`Exported \${rows.length} rows to Google Sheets for user \${userId}\`);
|
||||
|
||||
return {
|
||||
status: "exported",
|
||||
rowCount: rows.length,
|
||||
spreadsheetId,
|
||||
sheetTitle,
|
||||
url: \`https://docs.google.com/spreadsheets/d/\${spreadsheetId}\`,
|
||||
};
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
console.log("stripe + sheets files written");
|
||||
546
write-transactions.mjs
Normal file
546
write-transactions.mjs
Normal file
@ -0,0 +1,546 @@
|
||||
import { writeFileSync } from "fs";
|
||||
|
||||
// ─── transactions.service.ts ─────────────────────────────────────────────────
|
||||
writeFileSync("src/transactions/transactions.service.ts", `import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import * as crypto from "crypto";
|
||||
import { parse } from "csv-parse/sync";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { PlaidService } from "../plaid/plaid.service";
|
||||
import { UpdateDerivedDto } from "./dto/update-derived.dto";
|
||||
import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto";
|
||||
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
|
||||
// ─── Bank CSV format auto-detection ──────────────────────────────────────────
|
||||
type ParsedRow = { date: string; description: string; amount: number };
|
||||
|
||||
function detectAndParse(buffer: Buffer): ParsedRow[] {
|
||||
const text = buffer.toString("utf8").trim();
|
||||
const rows: Record<string, string>[] = parse(text, {
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
trim: true,
|
||||
bom: true,
|
||||
});
|
||||
|
||||
if (!rows.length) return [];
|
||||
const headers = Object.keys(rows[0]).map((h) => h.toLowerCase());
|
||||
|
||||
// Chase format: Transaction Date, Description, Amount
|
||||
if (headers.includes("transaction date") && headers.includes("description") && headers.includes("amount")) {
|
||||
return rows.map((r) => ({
|
||||
date: r["Transaction Date"] ?? r["transaction date"],
|
||||
description: r["Description"] ?? r["description"],
|
||||
amount: parseFloat(r["Amount"] ?? r["amount"] ?? "0"),
|
||||
})).filter((r) => r.date && r.description);
|
||||
}
|
||||
|
||||
// Bank of America format: Date, Description, Amount, Running Bal.
|
||||
if (headers.includes("date") && headers.includes("description") && headers.includes("amount") && headers.some((h) => h.includes("running"))) {
|
||||
return rows.map((r) => ({
|
||||
date: r["Date"] ?? r["date"],
|
||||
description: r["Description"] ?? r["description"],
|
||||
amount: parseFloat((r["Amount"] ?? r["amount"] ?? "0").replace(/,/g, "")),
|
||||
})).filter((r) => r.date && r.description);
|
||||
}
|
||||
|
||||
// Wells Fargo format: 5 unnamed columns — Date, Amount, *, *, Description
|
||||
if (headers.length >= 5 && (headers[0] === "" || /^[0-9]/.test(rows[0][Object.keys(rows[0])[0]] ?? ""))) {
|
||||
const keys = Object.keys(rows[0]);
|
||||
return rows.map((r) => ({
|
||||
date: r[keys[0]],
|
||||
description: r[keys[4]] ?? r[keys[3]],
|
||||
amount: parseFloat((r[keys[1]] ?? "0").replace(/,/g, "")),
|
||||
})).filter((r) => r.date && r.description);
|
||||
}
|
||||
|
||||
// Generic: look for date, amount, description columns
|
||||
const dateKey = Object.keys(rows[0]).find((k) => /date/i.test(k));
|
||||
const amountKey = Object.keys(rows[0]).find((k) => /amount/i.test(k));
|
||||
const descKey = Object.keys(rows[0]).find((k) => /desc|memo|narr|payee/i.test(k));
|
||||
|
||||
if (dateKey && amountKey && descKey) {
|
||||
return rows.map((r) => ({
|
||||
date: r[dateKey],
|
||||
description: r[descKey],
|
||||
amount: parseFloat((r[amountKey] ?? "0").replace(/[^0-9.-]/g, "")),
|
||||
})).filter((r) => r.date && r.description);
|
||||
}
|
||||
|
||||
throw new BadRequestException("Unrecognized CSV format. Supported: Chase, Bank of America, Wells Fargo, or generic (date/amount/description columns).");
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransactionsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly plaidService: PlaidService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
userId: string,
|
||||
filters: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
accountId?: string;
|
||||
minAmount?: string;
|
||||
maxAmount?: string;
|
||||
category?: string;
|
||||
source?: string;
|
||||
search?: 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.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.category) {
|
||||
where.derived = { is: { userCategory: { contains: filters.category, mode: "insensitive" } } };
|
||||
}
|
||||
|
||||
if (filters.source) {
|
||||
where.source = { contains: filters.source, mode: "insensitive" };
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
where.description = { contains: filters.search, mode: "insensitive" };
|
||||
}
|
||||
|
||||
if (filters.accountId) {
|
||||
where.accountId = filters.accountId;
|
||||
}
|
||||
|
||||
if (filters.includeHidden !== "true") {
|
||||
where.OR = [{ derived: null }, { derived: { isHidden: false } }];
|
||||
}
|
||||
|
||||
const take = Math.min(filters.limit ?? 50, MAX_PAGE_SIZE);
|
||||
const skip = ((filters.page ?? 1) - 1) * take;
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.transactionRaw.findMany({
|
||||
where,
|
||||
include: { derived: true },
|
||||
orderBy: { date: "desc" },
|
||||
take,
|
||||
skip,
|
||||
}),
|
||||
this.prisma.transactionRaw.count({ where }),
|
||||
]);
|
||||
|
||||
const transactions = rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.description,
|
||||
amount: Number(row.amount).toFixed(2),
|
||||
category: row.derived?.userCategory ?? "Uncategorized",
|
||||
note: row.derived?.userNotes ?? "",
|
||||
status: row.derived?.modifiedBy ?? "raw",
|
||||
hidden: row.derived?.isHidden ?? false,
|
||||
date: row.date.toISOString().slice(0, 10),
|
||||
source: row.source,
|
||||
accountId: row.accountId,
|
||||
}));
|
||||
|
||||
return { transactions, total, page: filters.page ?? 1, limit: take };
|
||||
}
|
||||
|
||||
async importCsv(userId: string, file: Express.Multer.File) {
|
||||
if (!file?.buffer) {
|
||||
throw new BadRequestException("No file uploaded.");
|
||||
}
|
||||
if (!file.originalname.toLowerCase().endsWith(".csv")) {
|
||||
throw new BadRequestException("File must be a CSV.");
|
||||
}
|
||||
|
||||
const rows = detectAndParse(file.buffer);
|
||||
if (!rows.length) {
|
||||
throw new BadRequestException("CSV file is empty or could not be parsed.");
|
||||
}
|
||||
|
||||
// Find or create a manual import account for this user
|
||||
let account = await this.prisma.account.findFirst({
|
||||
where: { userId, institutionName: "CSV Import", plaidAccessToken: null },
|
||||
});
|
||||
if (!account) {
|
||||
account = await this.prisma.account.create({
|
||||
data: {
|
||||
userId,
|
||||
institutionName: "CSV Import",
|
||||
accountType: "checking",
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const dateObj = new Date(row.date);
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const bankTransactionId = \`csv_\${crypto.createHash("sha256")
|
||||
.update(\`\${userId}:\${row.date}:\${row.description}:\${row.amount}\`)
|
||||
.digest("hex")
|
||||
.slice(0, 16)}\`;
|
||||
|
||||
try {
|
||||
await this.prisma.transactionRaw.upsert({
|
||||
where: { bankTransactionId },
|
||||
update: {},
|
||||
create: {
|
||||
accountId: account.id,
|
||||
bankTransactionId,
|
||||
date: dateObj,
|
||||
amount: row.amount,
|
||||
description: row.description,
|
||||
rawPayload: row as unknown as Prisma.InputJsonValue,
|
||||
ingestedAt: new Date(),
|
||||
source: "csv",
|
||||
},
|
||||
});
|
||||
imported++;
|
||||
} catch {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, skipped, total: rows.length };
|
||||
}
|
||||
|
||||
async createManualTransaction(userId: string, payload: CreateManualTransactionDto) {
|
||||
const account = payload.accountId
|
||||
? await this.prisma.account.findFirst({ where: { id: payload.accountId, userId } })
|
||||
: await this.prisma.account.findFirst({ where: { userId } });
|
||||
|
||||
if (!account) {
|
||||
throw new BadRequestException("No account found for user.");
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const raw = await this.prisma.transactionRaw.create({
|
||||
data: {
|
||||
accountId: account.id,
|
||||
bankTransactionId: \`manual_\${id}\`,
|
||||
date: new Date(payload.date),
|
||||
amount: payload.amount,
|
||||
description: payload.description,
|
||||
rawPayload: payload as unknown as Prisma.InputJsonValue,
|
||||
ingestedAt: new Date(),
|
||||
source: "manual",
|
||||
},
|
||||
});
|
||||
|
||||
if (payload.category || payload.note || payload.hidden) {
|
||||
await this.prisma.transactionDerived.create({
|
||||
data: {
|
||||
rawTransactionId: raw.id,
|
||||
userCategory: payload.category ?? null,
|
||||
userNotes: payload.note ?? null,
|
||||
isHidden: payload.hidden ?? false,
|
||||
modifiedAt: new Date(),
|
||||
modifiedBy: "user",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { id: raw.id };
|
||||
}
|
||||
|
||||
async updateDerived(userId: string, id: string, payload: UpdateDerivedDto) {
|
||||
// Ensure the transaction belongs to the user
|
||||
const tx = await this.prisma.transactionRaw.findFirst({
|
||||
where: { id, account: { userId } },
|
||||
});
|
||||
if (!tx) throw new BadRequestException("Transaction not found.");
|
||||
|
||||
return this.prisma.transactionDerived.upsert({
|
||||
where: { rawTransactionId: id },
|
||||
update: {
|
||||
userCategory: payload.userCategory,
|
||||
userNotes: payload.userNotes,
|
||||
isHidden: payload.isHidden ?? false,
|
||||
modifiedAt: new Date(),
|
||||
modifiedBy: "user",
|
||||
},
|
||||
create: {
|
||||
rawTransactionId: id,
|
||||
userCategory: payload.userCategory,
|
||||
userNotes: payload.userNotes,
|
||||
isHidden: payload.isHidden ?? false,
|
||||
modifiedAt: new Date(),
|
||||
modifiedBy: "user",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async sync(userId: string, startDate: string, endDate: string) {
|
||||
return this.plaidService.syncTransactionsForUser(userId, startDate, endDate);
|
||||
}
|
||||
|
||||
async summary(userId: string, startDate: string, endDate: string) {
|
||||
const rows = await this.prisma.transactionRaw.findMany({
|
||||
where: {
|
||||
account: { userId },
|
||||
date: { gte: new Date(startDate), lte: new Date(endDate) },
|
||||
},
|
||||
});
|
||||
|
||||
const total = rows.reduce((sum, row) => sum + Number(row.amount), 0);
|
||||
const income = rows.reduce(
|
||||
(sum, row) => sum + (Number(row.amount) < 0 ? Math.abs(Number(row.amount)) : 0),
|
||||
0,
|
||||
);
|
||||
const expense = rows.reduce(
|
||||
(sum, row) => sum + (Number(row.amount) > 0 ? Number(row.amount) : 0),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
total: total.toFixed(2),
|
||||
count: rows.length,
|
||||
income: income.toFixed(2),
|
||||
expense: expense.toFixed(2),
|
||||
net: (income - expense).toFixed(2),
|
||||
};
|
||||
}
|
||||
|
||||
async cashflow(userId: string, months = 6) {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - (months - 1), 1);
|
||||
const rows = await this.prisma.transactionRaw.findMany({
|
||||
where: { account: { userId }, date: { gte: start, lte: now } },
|
||||
});
|
||||
|
||||
const buckets = new Map<string, { income: number; expense: number }>();
|
||||
for (let i = 0; i < months; i += 1) {
|
||||
const date = new Date(now.getFullYear(), now.getMonth() - (months - 1) + i, 1);
|
||||
const key = \`\${date.getFullYear()}-\${String(date.getMonth() + 1).padStart(2, "0")}\`;
|
||||
buckets.set(key, { income: 0, expense: 0 });
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const key = \`\${row.date.getFullYear()}-\${String(row.date.getMonth() + 1).padStart(2, "0")}\`;
|
||||
const bucket = buckets.get(key);
|
||||
if (!bucket) continue;
|
||||
const amount = Number(row.amount);
|
||||
if (amount < 0) bucket.income += Math.abs(amount);
|
||||
else bucket.expense += amount;
|
||||
}
|
||||
|
||||
return Array.from(buckets.entries()).map(([month, value]) => ({
|
||||
month,
|
||||
income: value.income.toFixed(2),
|
||||
expense: value.expense.toFixed(2),
|
||||
net: (value.income - value.expense).toFixed(2),
|
||||
}));
|
||||
}
|
||||
|
||||
async merchantInsights(userId: string, limit = 6) {
|
||||
const capped = Math.min(limit, MAX_PAGE_SIZE);
|
||||
const rows = await this.prisma.transactionRaw.findMany({
|
||||
where: { account: { userId } },
|
||||
select: { description: true, amount: true },
|
||||
});
|
||||
const bucket = new Map<string, { total: number; count: number }>();
|
||||
for (const row of rows) {
|
||||
const amount = Number(row.amount);
|
||||
if (amount <= 0) continue;
|
||||
const entry = bucket.get(row.description) ?? { total: 0, count: 0 };
|
||||
entry.total += amount;
|
||||
entry.count += 1;
|
||||
bucket.set(row.description, entry);
|
||||
}
|
||||
|
||||
return Array.from(bucket.entries())
|
||||
.sort((a, b) => b[1].total - a[1].total)
|
||||
.slice(0, capped)
|
||||
.map(([merchant, value]) => ({
|
||||
merchant,
|
||||
total: value.total.toFixed(2),
|
||||
count: value.count,
|
||||
}));
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── transactions.controller.ts ──────────────────────────────────────────────
|
||||
writeFileSync("src/transactions/transactions.controller.ts", `import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { ok } from "../common/response";
|
||||
import { UpdateDerivedDto } from "./dto/update-derived.dto";
|
||||
import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto";
|
||||
import { TransactionsService } from "./transactions.service";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
|
||||
@Controller("transactions")
|
||||
export class TransactionsController {
|
||||
constructor(private readonly transactionsService: TransactionsService) {}
|
||||
|
||||
@Get()
|
||||
async list(
|
||||
@CurrentUser() userId: string,
|
||||
@Query("start_date") startDate?: string,
|
||||
@Query("end_date") endDate?: string,
|
||||
@Query("account_id") accountId?: string,
|
||||
@Query("min_amount") minAmount?: string,
|
||||
@Query("max_amount") maxAmount?: string,
|
||||
@Query("category") category?: string,
|
||||
@Query("source") source?: string,
|
||||
@Query("search") search?: string,
|
||||
@Query("include_hidden") includeHidden?: string,
|
||||
@Query("page") page = 1,
|
||||
@Query("limit") limit = 50,
|
||||
) {
|
||||
const data = await this.transactionsService.list(userId, {
|
||||
startDate,
|
||||
endDate,
|
||||
accountId,
|
||||
minAmount,
|
||||
maxAmount,
|
||||
category,
|
||||
source,
|
||||
search,
|
||||
includeHidden,
|
||||
page: +page,
|
||||
limit: +limit,
|
||||
});
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("import")
|
||||
@UseInterceptors(FileInterceptor("file", { limits: { fileSize: 5 * 1024 * 1024 } }))
|
||||
async importCsv(
|
||||
@CurrentUser() userId: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
) {
|
||||
const data = await this.transactionsService.importCsv(userId, file);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("sync")
|
||||
async sync(
|
||||
@CurrentUser() userId: string,
|
||||
@Body("startDate") startDate?: string,
|
||||
@Body("endDate") endDate?: string,
|
||||
) {
|
||||
const end = endDate ?? new Date().toISOString().slice(0, 10);
|
||||
const start =
|
||||
startDate ??
|
||||
new Date(new Date().setDate(new Date(end).getDate() - 30)).toISOString().slice(0, 10);
|
||||
const data = await this.transactionsService.sync(userId, start, end);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Post("manual")
|
||||
async manual(
|
||||
@CurrentUser() userId: string,
|
||||
@Body() payload: CreateManualTransactionDto,
|
||||
) {
|
||||
const data = await this.transactionsService.createManualTransaction(userId, payload);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Get("summary")
|
||||
async summary(
|
||||
@CurrentUser() userId: string,
|
||||
@Query("start_date") startDate?: string,
|
||||
@Query("end_date") endDate?: string,
|
||||
) {
|
||||
const end = endDate ?? new Date().toISOString().slice(0, 10);
|
||||
const start =
|
||||
startDate ??
|
||||
new Date(new Date().setDate(new Date(end).getDate() - 30)).toISOString().slice(0, 10);
|
||||
const data = await this.transactionsService.summary(userId, start, end);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Get("cashflow")
|
||||
async cashflow(
|
||||
@CurrentUser() userId: string,
|
||||
@Query("months") months = 6,
|
||||
) {
|
||||
const data = await this.transactionsService.cashflow(userId, +months);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Get("merchants")
|
||||
async merchants(
|
||||
@CurrentUser() userId: string,
|
||||
@Query("limit") limit = 6,
|
||||
) {
|
||||
const data = await this.transactionsService.merchantInsights(userId, +limit);
|
||||
return ok(data);
|
||||
}
|
||||
|
||||
@Patch(":id/derived")
|
||||
async updateDerived(
|
||||
@CurrentUser() userId: string,
|
||||
@Param("id") id: string,
|
||||
@Body() payload: UpdateDerivedDto,
|
||||
) {
|
||||
const data = await this.transactionsService.updateDerived(userId, id, payload);
|
||||
return ok(data);
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
// ─── create-manual-transaction.dto.ts ────────────────────────────────────────
|
||||
writeFileSync("src/transactions/dto/create-manual-transaction.dto.ts", `import { IsBoolean, IsNumber, IsOptional, IsString, IsDateString } from "class-validator";
|
||||
|
||||
export class CreateManualTransactionDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
accountId?: string;
|
||||
|
||||
@IsDateString()
|
||||
date!: string;
|
||||
|
||||
@IsString()
|
||||
description!: string;
|
||||
|
||||
@IsNumber()
|
||||
amount!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
hidden?: boolean;
|
||||
}
|
||||
`);
|
||||
|
||||
console.log("transactions files written");
|
||||
Loading…
x
Reference in New Issue
Block a user