implemented all the new changes

This commit is contained in:
metatroncubeswdev 2026-03-14 08:51:16 -04:00
parent d1a5a1d1a0
commit 21a8f093d1
57 changed files with 6997 additions and 756 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
dist
.env
*.log
coverage
.git

27
Dockerfile Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@ -14,23 +14,47 @@
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^10.3.4", "@nestjs/common": "^10.3.4",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^10.3.4", "@nestjs/core": "^10.3.4",
"@nestjs/platform-express": "^10.3.4",
"@nestjs/jwt": "^10.2.0", "@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", "@prisma/client": "^5.18.0",
"@sentry/node": "^10.40.0",
"@supabase/supabase-js": "^2.49.1", "@supabase/supabase-js": "^2.49.1",
"@types/qrcode": "^1.5.6",
"@types/speakeasy": "^2.0.10",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
"csv-parse": "^6.1.0",
"dotenv": "^16.4.5", "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", "plaid": "^17.0.0",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.1.13", "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": { "devDependencies": {
"@nestjs/cli": "^10.3.2", "@nestjs/cli": "^10.3.2",
"@nestjs/schematics": "^10.1.1", "@nestjs/schematics": "^10.1.1",
"@nestjs/testing": "^10.3.4", "@nestjs/testing": "^10.3.4",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/multer": "^2.0.0",
"@types/node": "^20.11.20", "@types/node": "^20.11.20",
"@types/nodemailer": "^7.0.11",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^2.0.16", "@types/supertest": "^2.0.16",
"jest": "^29.7.0", "jest": "^29.7.0",
"prisma": "^5.18.0", "prisma": "^5.18.0",

View File

@ -8,99 +8,108 @@ datasource db {
} }
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
email String @unique email String @unique
passwordHash String passwordHash String
fullName String? fullName String?
phone String? phone String?
companyName String? companyName String?
addressLine1 String? addressLine1 String?
addressLine2 String? addressLine2 String?
city String? city String?
state String? state String?
postalCode String? postalCode String?
country String? country String?
createdAt DateTime @default(now()) emailVerified Boolean @default(false)
updatedAt DateTime @updatedAt twoFactorEnabled Boolean @default(false)
twoFactorSecret String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[] accounts Account[]
rules Rule[] rules Rule[]
exports ExportLog[] exports ExportLog[]
auditLogs AuditLog[] auditLogs AuditLog[]
googleConnection GoogleConnection?
emailVerificationToken EmailVerificationToken?
passwordResetTokens PasswordResetToken[]
refreshTokens RefreshToken[]
subscription Subscription?
taxReturns TaxReturn[]
} }
model Account { model Account {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
institutionName String institutionName String
accountType String accountType String
mask String? mask String?
plaidAccessToken String? plaidAccessToken String?
plaidItemId String? plaidItemId String?
plaidAccountId String? @unique plaidAccountId String? @unique
currentBalance Decimal? currentBalance Decimal?
availableBalance Decimal? availableBalance Decimal?
isoCurrencyCode String? isoCurrencyCode String?
lastBalanceSync DateTime? lastBalanceSync DateTime?
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
transactionsRaw TransactionRaw[] transactionsRaw TransactionRaw[]
} }
model TransactionRaw { model TransactionRaw {
id String @id @default(uuid()) id String @id @default(uuid())
accountId String accountId String
bankTransactionId String @unique bankTransactionId String @unique
date DateTime date DateTime
amount Decimal amount Decimal
description String description String
rawPayload Json rawPayload Json
ingestedAt DateTime @default(now()) ingestedAt DateTime @default(now())
source String source String
account Account @relation(fields: [accountId], references: [id]) account Account @relation(fields: [accountId], references: [id])
derived TransactionDerived? derived TransactionDerived?
ruleExecutions RuleExecution[] ruleExecutions RuleExecution[]
} }
model TransactionDerived { model TransactionDerived {
id String @id @default(uuid()) id String @id @default(uuid())
rawTransactionId String @unique rawTransactionId String @unique
userCategory String? userCategory String?
userNotes String? userNotes String?
isHidden Boolean @default(false) isHidden Boolean @default(false)
modifiedAt DateTime @default(now()) modifiedAt DateTime @default(now())
modifiedBy String modifiedBy String
raw TransactionRaw @relation(fields: [rawTransactionId], references: [id]) raw TransactionRaw @relation(fields: [rawTransactionId], references: [id])
} }
model Rule { model Rule {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
name String name String
priority Int priority Int
conditions Json conditions Json
actions Json actions Json
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
executions RuleExecution[] executions RuleExecution[]
} }
model RuleExecution { model RuleExecution {
id String @id @default(uuid()) id String @id @default(uuid())
ruleId String ruleId String
transactionId String transactionId String
executedAt DateTime @default(now()) executedAt DateTime @default(now())
result Json result Json
rule Rule @relation(fields: [ruleId], references: [id]) rule Rule @relation(fields: [ruleId], references: [id])
transaction TransactionRaw @relation(fields: [transactionId], references: [id]) transaction TransactionRaw @relation(fields: [transactionId], references: [id])
} }
model ExportLog { model ExportLog {
@ -122,3 +131,90 @@ model AuditLog {
user User @relation(fields: [userId], references: [id]) 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
View 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());

View File

@ -1,35 +1,40 @@
import { Body, Controller, Get, Post, Query } from "@nestjs/common"; import { Body, Controller, Get, Post, Query } from "@nestjs/common";
import { ok } from "../common/response"; import { ok } from "../common/response";
import { AccountsService } from "./accounts.service"; import { AccountsService } from "./accounts.service";
import { CurrentUser } from "../common/decorators/current-user.decorator";
@Controller("accounts") @Controller("accounts")
export class AccountsController { export class AccountsController {
constructor(private readonly accountsService: AccountsService) {} constructor(private readonly accountsService: AccountsService) {}
@Get() @Get()
async list(@Query("user_id") userId?: string) { async list(
const data = await this.accountsService.list(userId); @CurrentUser() userId: string,
@Query("page") page = 1,
@Query("limit") limit = 20,
) {
const data = await this.accountsService.list(userId, +page, +limit);
return ok(data); return ok(data);
} }
@Post("link") @Post("link")
async link() { async link(@CurrentUser() userId: string) {
const data = await this.accountsService.createLinkToken(); const data = await this.accountsService.createLinkToken(userId);
return ok(data); return ok(data);
} }
@Post("manual") @Post("manual")
async manual( async manual(
@Body() @CurrentUser() userId: string,
payload: { userId: string; institutionName: string; accountType: string; mask?: 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); return ok(data);
} }
@Post("balances") @Post("balances")
async balances(@Body() payload: { userId: string }) { async balances(@CurrentUser() userId: string) {
const data = await this.accountsService.refreshBalances(payload.userId); const data = await this.accountsService.refreshBalances(userId);
return ok(data); return ok(data);
} }
} }

View File

@ -2,44 +2,71 @@ import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { PlaidService } from "../plaid/plaid.service"; import { PlaidService } from "../plaid/plaid.service";
const MAX_PAGE_SIZE = 100;
@Injectable() @Injectable()
export class AccountsService { export class AccountsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly plaidService: PlaidService private readonly plaidService: PlaidService,
) {} ) {}
async list(userId?: string) { async list(userId: string, page = 1, limit = 20) {
if (!userId) { const take = Math.min(limit, MAX_PAGE_SIZE);
return []; const skip = (page - 1) * take;
} const [accounts, total] = await Promise.all([
return this.prisma.account.findMany({ this.prisma.account.findMany({
where: { userId }, where: { userId, isActive: true },
orderBy: { createdAt: "desc" } 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() { async createLinkToken(userId: string) {
return { linkToken: "stub_link_token" }; return this.plaidService.createLinkToken(userId);
} }
async refreshBalances(userId: string) { async refreshBalances(userId: string) {
return this.plaidService.syncBalancesForUser(userId); return this.plaidService.syncBalancesForUser(userId);
} }
async createManualAccount(userId: string, payload: { async createManualAccount(
institutionName: string; userId: string,
accountType: string; payload: { institutionName: string; accountType: string; mask?: string },
mask?: string; ) {
}) {
return this.prisma.account.create({ return this.prisma.account.create({
data: { data: {
userId, userId,
institutionName: payload.institutionName, institutionName: payload.institutionName,
accountType: payload.accountType, accountType: payload.accountType,
mask: payload.mask ?? null, mask: payload.mask ?? null,
isActive: true isActive: true,
} },
select: {
id: true,
institutionName: true,
accountType: true,
mask: true,
isActive: true,
createdAt: true,
},
}); });
} }
} }

View File

@ -1,27 +1,80 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { AccountsModule } from "./accounts/accounts.module"; import { ConfigModule } from "@nestjs/config";
import { ExportsModule } from "./exports/exports.module"; import { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler";
import { RulesModule } from "./rules/rules.module"; import { APP_GUARD } from "@nestjs/core";
import { TransactionsModule } from "./transactions/transactions.module";
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 { AuthModule } from "./auth/auth.module";
import { PlaidModule } from "./plaid/plaid.module"; import { PlaidModule } from "./plaid/plaid.module";
import { StorageModule } from "./storage/storage.module";
import { TaxModule } from "./tax/tax.module"; import { TaxModule } from "./tax/tax.module";
import { SupabaseModule } from "./supabase/supabase.module"; import { TransactionsModule } from "./transactions/transactions.module";
import { PrismaModule } from "./prisma/prisma.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({ @Module({
imports: [ 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, PrismaModule,
StorageModule,
SupabaseModule, SupabaseModule,
EmailModule,
// ─── Feature modules ─────────────────────────────────────────────────────
AuthModule, AuthModule,
PlaidModule, PlaidModule,
TaxModule, TaxModule,
TransactionsModule, TransactionsModule,
AccountsModule, AccountsModule,
RulesModule, 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 {} export class AppModule {}

View File

@ -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 { ok } from "../common/response";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { LoginDto } from "./dto/login.dto"; import { LoginDto } from "./dto/login.dto";
import { RegisterDto } from "./dto/register.dto"; import { RegisterDto } from "./dto/register.dto";
import { UpdateProfileDto } from "./dto/update-profile.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") @Controller("auth")
@UseGuards(JwtAuthGuard)
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
@Public()
@Post("register") @Post("register")
async register(@Body() payload: RegisterDto) { async register(@Body() payload: RegisterDto) {
const data = await this.authService.register(payload); return ok(await this.authService.register(payload));
return ok(data);
} }
@Public()
@Post("login") @Post("login")
async login(@Body() payload: LoginDto) { async login(@Body() payload: LoginDto) {
const data = await this.authService.login(payload); return ok(await this.authService.login(payload));
return ok(data); }
@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") @Patch("profile")
async updateProfile( async updateProfile(@CurrentUser() userId: string, @Body() payload: UpdateProfileDto) {
@Headers("authorization") authorization: string | undefined, return ok(await this.authService.updateProfile(userId, payload));
@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);
} }
} }

View File

@ -2,15 +2,17 @@ import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt"; import { JwtModule } from "@nestjs/jwt";
import { AuthController } from "./auth.controller"; import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
@Module({ @Module({
imports: [ imports: [
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET ?? "change_me", secret: process.env.JWT_SECRET,
signOptions: { expiresIn: "7d" } signOptions: { expiresIn: "15m" },
}) }),
], ],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService] providers: [AuthService, JwtAuthGuard],
exports: [AuthService, JwtModule, JwtAuthGuard],
}) })
export class AuthModule {} export class AuthModule {}

View File

@ -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 crypto from "crypto";
import * as speakeasy from "speakeasy";
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
import { PrismaService } from "../prisma/prisma.service"; 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 { LoginDto } from "./dto/login.dto";
import { RegisterDto } from "./dto/register.dto"; import { RegisterDto } from "./dto/register.dto";
import { UpdateProfileDto } from "./dto/update-profile.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() @Injectable()
export class AuthService { export class AuthService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly jwtService: JwtService private readonly jwtService: JwtService,
private readonly emailService: EmailService,
private readonly encryption: EncryptionService,
) {} ) {}
async register(payload: RegisterDto) { async register(payload: RegisterDto) {
const email = payload.email?.toLowerCase().trim(); const email = payload.email.toLowerCase().trim();
if (!email || !payload.password) {
throw new BadRequestException("Email and password are required.");
}
const existing = await this.prisma.user.findUnique({ where: { email } }); const existing = await this.prisma.user.findUnique({ where: { email } });
if (existing) { if (existing) throw new BadRequestException("Email already registered.");
throw new BadRequestException("Email already registered.");
}
const passwordHash = this.hashPassword(payload.password); const passwordHash = this.hashPassword(payload.password);
const user = await this.prisma.user.create({ const user = await this.prisma.user.create({ data: { email, passwordHash } });
data: { await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.register", metadata: { email } } });
email,
passwordHash,
fullName: null,
phone: null
}
});
await this.prisma.auditLog.create({
data: {
userId: user.id,
action: "auth.register",
metadata: { email }
}
});
const token = this.signToken(user.id); const verifyToken = crypto.randomBytes(32).toString("hex");
return { user: { id: user.id, email: user.email, fullName: user.fullName }, token }; 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) { async login(payload: LoginDto) {
const email = payload.email?.toLowerCase().trim(); const email = payload.email.toLowerCase().trim();
if (!email || !payload.password) {
throw new BadRequestException("Email and password are required.");
}
const user = await this.prisma.user.findUnique({ where: { email } }); const user = await this.prisma.user.findUnique({ where: { email } });
if (!user) { if (!user || !this.verifyPassword(payload.password, user.passwordHash)) {
throw new UnauthorizedException("Invalid credentials."); throw new UnauthorizedException("Invalid credentials.");
} }
if (!this.verifyPassword(payload.password, user.passwordHash)) { // ── 2FA enforcement ──────────────────────────────────────────────────────
throw new UnauthorizedException("Invalid credentials."); if (user.twoFactorEnabled && user.twoFactorSecret) {
} if (!payload.totpToken) {
return { requiresTwoFactor: true, accessToken: null, refreshToken: null };
await this.prisma.auditLog.create({
data: {
userId: user.id,
action: "auth.login",
metadata: { email }
} }
}); 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); await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.login", metadata: { email } } });
return { user: { id: user.id, email: user.email, fullName: user.fullName }, token }; 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) { async updateProfile(userId: string, payload: UpdateProfileDto) {
const data: UpdateProfileDto = { const data: Record<string, string> = {};
fullName: payload.fullName?.trim(), for (const [key, value] of Object.entries(payload)) {
phone: payload.phone?.trim(), const trimmed = (value as string | undefined)?.trim();
companyName: payload.companyName?.trim(), if (trimmed) data[key] = trimmed;
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 user = await this.prisma.user.update({ if (!Object.keys(data).length) throw new BadRequestException("No profile fields provided.");
where: { id: userId }, const user = await this.prisma.user.update({ where: { id: userId }, data });
data
});
await this.prisma.auditLog.create({ await this.prisma.auditLog.create({
data: { data: { userId: user.id, action: "auth.profile.update", metadata: { updatedFields: Object.keys(data) } },
userId: user.id,
action: "auth.profile.update",
metadata: { updatedFields: Object.keys(data) }
}
}); });
return { return {
user: { user: {
id: user.id, id: user.id, email: user.email, fullName: user.fullName, phone: user.phone,
email: user.email, companyName: user.companyName, addressLine1: user.addressLine1,
fullName: user.fullName, addressLine2: user.addressLine2, city: user.city, state: user.state,
phone: user.phone, postalCode: user.postalCode, country: user.country,
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) { async getProfile(userId: string) {
const salt = crypto.randomBytes(16).toString("hex"); const user = await this.prisma.user.findUnique({ where: { id: userId } });
const hash = crypto.pbkdf2Sync(password, salt, 100000, 64, "sha512").toString("hex"); if (!user) throw new NotFoundException("User not found.");
return `${salt}:${hash}`; 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) { verifyToken(token: string): { sub: string } {
const [salt, hash] = stored.split(":"); return this.jwtService.verify<{ sub: string }>(token);
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));
} }
private signToken(userId: string) { private signAccessToken(userId: string): string {
return this.jwtService.sign({ sub: userId }); return this.jwtService.sign({ sub: userId });
} }
verifyToken(token: string) { private async createRefreshToken(userId: string): Promise<string> {
return this.jwtService.verify<{ sub: string }>(token); 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));
} }
} }

View File

@ -0,0 +1,6 @@
import { IsEmail } from "class-validator";
export class ForgotPasswordDto {
@IsEmail({}, { message: "Invalid email address." })
email!: string;
}

View File

@ -1,4 +1,14 @@
export type LoginDto = { import { IsEmail, IsOptional, IsString, MinLength } from "class-validator";
email: string;
password: string; export class LoginDto {
}; @IsEmail({}, { message: "Invalid email address." })
email!: string;
@IsString()
@MinLength(1, { message: "Password is required." })
password!: string;
@IsOptional()
@IsString()
totpToken?: string;
}

View File

@ -1,4 +1,10 @@
export type RegisterDto = { import { IsEmail, IsString, MinLength } from "class-validator";
email: string;
password: string; export class RegisterDto {
}; @IsEmail({}, { message: "Invalid email address." })
email!: string;
@IsString()
@MinLength(8, { message: "Password must be at least 8 characters." })
password!: string;
}

View 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;
}

View File

@ -1,11 +1,13 @@
export type UpdateProfileDto = { import { IsOptional, IsString, MaxLength } from "class-validator";
fullName?: string;
phone?: string; export class UpdateProfileDto {
companyName?: string; @IsOptional() @IsString() @MaxLength(200) fullName?: string;
addressLine1?: string; @IsOptional() @IsString() @MaxLength(30) phone?: string;
addressLine2?: string; @IsOptional() @IsString() @MaxLength(200) companyName?: string;
city?: string; @IsOptional() @IsString() @MaxLength(300) addressLine1?: string;
state?: string; @IsOptional() @IsString() @MaxLength(300) addressLine2?: string;
postalCode?: string; @IsOptional() @IsString() @MaxLength(100) city?: string;
country?: string; @IsOptional() @IsString() @MaxLength(100) state?: string;
}; @IsOptional() @IsString() @MaxLength(20) postalCode?: string;
@IsOptional() @IsString() @MaxLength(100) country?: string;
}

View 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);
}
}

View 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 {}

View 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 });
}
}

View 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 {}

View 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 ?? "";
},
);

View 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);

View 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");
}
}

View 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;
}
}

View 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" },
});
}
}

View 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(""),
});

View 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 {}

View 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);
}
}
}

View File

@ -1,20 +1,21 @@
import { Controller, Get, Post, Query } from "@nestjs/common"; import { Controller, Get, Post, Query } from "@nestjs/common";
import { ok } from "../common/response"; import { ok } from "../common/response";
import { ExportsService } from "./exports.service"; import { ExportsService } from "./exports.service";
import { CurrentUser } from "../common/decorators/current-user.decorator";
@Controller("exports") @Controller("exports")
export class ExportsController { export class ExportsController {
constructor(private readonly exportsService: ExportsService) {} constructor(private readonly exportsService: ExportsService) {}
@Get("csv") @Get("csv")
async exportCsv(@Query("user_id") userId?: string, @Query() query?: Record<string, string>) { async exportCsv(@CurrentUser() userId: string, @Query() query: Record<string, string>) {
const data = await this.exportsService.exportCsv(userId, query ?? {}); const data = await this.exportsService.exportCsv(userId, query);
return ok(data); return ok(data);
} }
@Post("sheets") @Post("sheets")
async exportSheets() { async exportSheets(@CurrentUser() userId: string, @Query() query: Record<string, string>) {
const data = await this.exportsService.exportSheets(); const data = await this.exportsService.exportSheets(userId, query);
return ok(data); return ok(data);
} }
} }

View File

@ -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"; import { PrismaService } from "../prisma/prisma.service";
@Injectable() @Injectable()
export class ExportsService { export class ExportsService {
private readonly logger = new Logger(ExportsService.name);
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
private toCsv(rows: Array<Record<string, string>>) { 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 escape = (value: string) => `"${value.replace(/"/g, '""')}"`;
const lines = [headers.join(",")]; const lines = [headers.join(",")];
for (const row of rows) { for (const row of rows) {
@ -15,59 +19,45 @@ export class ExportsService {
return lines.join("\n"); return lines.join("\n");
} }
async exportCsv(userId?: string, filters: Record<string, string> = {}) { private async getTransactions(
if (!userId) { userId: string,
return { status: "missing_user", csv: "" }; filters: Record<string, string>,
} limit = 1000,
) {
const where: Record<string, unknown> = { const where: Record<string, unknown> = { account: { userId } };
account: { userId }
};
if (filters.start_date || filters.end_date) { if (filters.start_date || filters.end_date) {
where.date = { where.date = {
gte: filters.start_date ? new Date(filters.start_date) : undefined, 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) { 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 = { where.amount = {
gte: Number.isNaN(minAmount ?? Number.NaN) ? undefined : minAmount, gte: filters.min_amount ? Number(filters.min_amount) : undefined,
lte: Number.isNaN(maxAmount ?? Number.NaN) ? undefined : maxAmount lte: filters.max_amount ? Number(filters.max_amount) : undefined,
}; };
} }
if (filters.category) { if (filters.category) {
where.derived = { where.derived = {
is: { is: { userCategory: { contains: filters.category, mode: "insensitive" } },
userCategory: {
contains: filters.category,
mode: "insensitive"
}
}
}; };
} }
if (filters.source) { if (filters.source) {
where.source = { where.source = { contains: filters.source, mode: "insensitive" };
contains: filters.source,
mode: "insensitive"
};
} }
if (filters.include_hidden !== "true") { if (filters.include_hidden !== "true") {
where.OR = [ where.OR = [{ derived: null }, { derived: { isHidden: false } }];
{ derived: null },
{ derived: { isHidden: false } }
];
} }
return this.prisma.transactionRaw.findMany({
const transactions = await this.prisma.transactionRaw.findMany({
where, where,
include: { derived: true }, include: { derived: true },
orderBy: { date: "desc" }, 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, id: tx.id,
date: tx.date.toISOString().slice(0, 10), date: tx.date.toISOString().slice(0, 10),
description: tx.description, description: tx.description,
@ -75,25 +65,104 @@ export class ExportsService {
category: tx.derived?.userCategory ?? "", category: tx.derived?.userCategory ?? "",
notes: tx.derived?.userNotes ?? "", notes: tx.derived?.userNotes ?? "",
hidden: tx.derived?.isHidden ? "true" : "false", 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); const csv = this.toCsv(rows);
await this.prisma.exportLog.create({ await this.prisma.exportLog.create({
data: { data: { userId, filters, rowCount: rows.length },
userId,
filters,
rowCount: rows.length
}
}); });
return { status: "ready", csv, 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 { return {
status: "queued", status: "exported",
rowCount: rows.length,
spreadsheetId,
sheetTitle,
url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
}; };
} }
} }

View 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);
}
}

View 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 {}

View 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 };
}
}

View File

@ -1,16 +1,85 @@
import "dotenv/config"; import "dotenv/config";
import * as Sentry from "@sentry/node";
import { NestFactory } from "@nestjs/core"; 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 { AppModule } from "./app.module";
import { SentryExceptionFilter } from "./common/sentry.filter";
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); // ─── Sentry initialization (before app creation) ──────────────────────────
app.setGlobalPrefix("api"); if (process.env.SENTRY_DSN) {
app.enableCors({ Sentry.init({
origin: true, dsn: process.env.SENTRY_DSN,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], environment: process.env.NODE_ENV ?? "development",
allowedHeaders: ["Content-Type", "Authorization"] });
}
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(); bootstrap();

View File

@ -1,23 +1,24 @@
import { Body, Controller, Post } from "@nestjs/common"; import { Body, Controller, Post } from "@nestjs/common";
import { ok } from "../common/response"; import { ok } from "../common/response";
import { PlaidService } from "./plaid.service"; import { PlaidService } from "./plaid.service";
import { CurrentUser } from "../common/decorators/current-user.decorator";
@Controller("plaid") @Controller("plaid")
export class PlaidController { export class PlaidController {
constructor(private readonly plaidService: PlaidService) {} constructor(private readonly plaidService: PlaidService) {}
@Post("link-token") @Post("link-token")
async createLinkToken() { async createLinkToken(@CurrentUser() userId: string) {
const data = await this.plaidService.createLinkToken(); const data = await this.plaidService.createLinkToken(userId);
return ok(data); return ok(data);
} }
@Post("exchange") @Post("exchange")
async exchange(@Body() payload: { publicToken: string; userId: string }) { async exchange(
const data = await this.plaidService.exchangePublicTokenForUser( @CurrentUser() userId: string,
payload.userId, @Body() payload: { publicToken: string },
payload.publicToken ) {
); const data = await this.plaidService.exchangePublicTokenForUser(userId, payload.publicToken);
return ok(data); return ok(data);
} }
} }

View File

@ -4,17 +4,21 @@ import {
CountryCode, CountryCode,
PlaidApi, PlaidApi,
PlaidEnvironments, PlaidEnvironments,
Products Products,
} from "plaid"; } from "plaid";
import * as crypto from "crypto"; import * as crypto from "crypto";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { EncryptionService } from "../common/encryption.service";
@Injectable() @Injectable()
export class PlaidService { export class PlaidService {
private readonly client: PlaidApi; 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 env = (process.env.PLAID_ENV ?? "sandbox") as keyof typeof PlaidEnvironments;
const clientId = this.requireEnv("PLAID_CLIENT_ID"); const clientId = this.requireEnv("PLAID_CLIENT_ID");
const secret = this.requireEnv("PLAID_SECRET"); const secret = this.requireEnv("PLAID_SECRET");
@ -25,14 +29,14 @@ export class PlaidService {
headers: { headers: {
"PLAID-CLIENT-ID": clientId, "PLAID-CLIENT-ID": clientId,
"PLAID-SECRET": secret, "PLAID-SECRET": secret,
"Plaid-Version": "2020-09-14" "Plaid-Version": "2020-09-14",
} },
} },
}); });
this.client = new PlaidApi(config); this.client = new PlaidApi(config);
} }
async createLinkToken() { async createLinkToken(userId: string) {
const products = (process.env.PLAID_PRODUCTS ?? "transactions") const products = (process.env.PLAID_PRODUCTS ?? "transactions")
.split(",") .split(",")
.map((item) => item.trim()) .map((item) => item.trim())
@ -45,19 +49,17 @@ export class PlaidService {
try { try {
const response = await this.client.linkTokenCreate({ const response = await this.client.linkTokenCreate({
user: { user: { client_user_id: userId },
client_user_id: crypto.randomUUID()
},
client_name: "LedgerOne", client_name: "LedgerOne",
products, products,
country_codes: countryCodes, country_codes: countryCodes,
language: "en", language: "en",
redirect_uri: redirectUri || undefined redirect_uri: redirectUri || undefined,
}); });
return { return {
linkToken: response.data.link_token, linkToken: response.data.link_token,
expiration: response.data.expiration expiration: response.data.expiration,
}; };
} catch (error: unknown) { } catch (error: unknown) {
const err = error as { response?: { data?: { error_message?: string } } }; const err = error as { response?: { data?: { error_message?: string } } };
@ -69,12 +71,17 @@ export class PlaidService {
async exchangePublicTokenForUser(userId: string, publicToken: string) { async exchangePublicTokenForUser(userId: string, publicToken: string) {
const exchange = await this.client.itemPublicTokenExchange({ 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 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 institutionId = accountsResponse.data.item?.institution_id;
const institutionName = institutionId const institutionName = institutionId
? await this.getInstitutionName(institutionId) ? await this.getInstitutionName(institutionId)
@ -87,49 +94,53 @@ export class PlaidService {
institutionName, institutionName,
accountType: account.subtype ?? account.type, accountType: account.subtype ?? account.type,
mask: account.mask ?? null, mask: account.mask ?? null,
plaidAccessToken: accessToken, plaidAccessToken: encryptedToken,
plaidItemId: itemId, plaidItemId: itemId,
currentBalance: account.balances.current ?? null, currentBalance: account.balances.current ?? null,
availableBalance: account.balances.available ?? null, availableBalance: account.balances.available ?? null,
isoCurrencyCode: account.balances.iso_currency_code ?? null, isoCurrencyCode: account.balances.iso_currency_code ?? null,
lastBalanceSync: new Date(), lastBalanceSync: new Date(),
userId userId,
}, },
create: { create: {
userId, userId,
institutionName, institutionName,
accountType: account.subtype ?? account.type, accountType: account.subtype ?? account.type,
mask: account.mask ?? null, mask: account.mask ?? null,
plaidAccessToken: accessToken, plaidAccessToken: encryptedToken,
plaidItemId: itemId, plaidItemId: itemId,
plaidAccountId: account.account_id, plaidAccountId: account.account_id,
currentBalance: account.balances.current ?? null, currentBalance: account.balances.current ?? null,
availableBalance: account.balances.available ?? null, availableBalance: account.balances.available ?? null,
isoCurrencyCode: account.balances.iso_currency_code ?? null, isoCurrencyCode: account.balances.iso_currency_code ?? null,
lastBalanceSync: new Date(), lastBalanceSync: new Date(),
isActive: true isActive: true,
} },
}); });
} }
return { return {
accessToken,
itemId, itemId,
accountCount: accountsResponse.data.accounts.length accountCount: accountsResponse.data.accounts.length,
}; };
} }
async syncBalancesForUser(userId: string) { async syncBalancesForUser(userId: string) {
const accounts = await this.prisma.account.findMany({ 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 // Deduplicate by decrypted token
) as string[]; 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; let updated = 0;
for (const token of tokens) { for (const [, rawToken] of tokenMap) {
const response = await this.client.accountsBalanceGet({ access_token: token }); const response = await this.client.accountsBalanceGet({ access_token: rawToken });
for (const account of response.data.accounts) { for (const account of response.data.accounts) {
const record = await this.prisma.account.updateMany({ const record = await this.prisma.account.updateMany({
where: { plaidAccountId: account.account_id, userId }, where: { plaidAccountId: account.account_id, userId },
@ -137,8 +148,8 @@ export class PlaidService {
currentBalance: account.balances.current ?? null, currentBalance: account.balances.current ?? null,
availableBalance: account.balances.available ?? null, availableBalance: account.balances.available ?? null,
isoCurrencyCode: account.balances.iso_currency_code ?? null, isoCurrencyCode: account.balances.iso_currency_code ?? null,
lastBalanceSync: new Date() lastBalanceSync: new Date(),
} },
}); });
updated += record.count; updated += record.count;
} }
@ -148,32 +159,31 @@ export class PlaidService {
async syncTransactionsForUser(userId: string, startDate: string, endDate: string) { async syncTransactionsForUser(userId: string, startDate: string, endDate: string) {
const accounts = await this.prisma.account.findMany({ 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[]>(); const tokenMap = new Map<string, string[]>();
for (const account of accounts) { for (const account of accounts) {
if (!account.plaidAccessToken || !account.plaidAccountId) { if (!account.plaidAccessToken || !account.plaidAccountId) continue;
continue; const raw = this.encryption.decrypt(account.plaidAccessToken);
} const list = tokenMap.get(raw) ?? [];
const list = tokenMap.get(account.plaidAccessToken) ?? [];
list.push(account.plaidAccountId); list.push(account.plaidAccountId);
tokenMap.set(account.plaidAccessToken, list); tokenMap.set(raw, list);
} }
let created = 0; let created = 0;
for (const [token] of tokenMap) { for (const [rawToken] of tokenMap) {
const response = await this.client.transactionsGet({ const response = await this.client.transactionsGet({
access_token: token, access_token: rawToken,
start_date: startDate, start_date: startDate,
end_date: endDate, end_date: endDate,
options: { count: 500, offset: 0 } options: { count: 500, offset: 0 },
}); });
for (const tx of response.data.transactions) { for (const tx of response.data.transactions) {
const account = accounts.find((acct) => acct.plaidAccountId === tx.account_id); const account = accounts.find((acct) => acct.plaidAccountId === tx.account_id);
if (!account) { if (!account) continue;
continue;
}
const rawPayload = tx as unknown as Prisma.InputJsonValue; const rawPayload = tx as unknown as Prisma.InputJsonValue;
await this.prisma.transactionRaw.upsert({ await this.prisma.transactionRaw.upsert({
where: { bankTransactionId: tx.transaction_id }, where: { bankTransactionId: tx.transaction_id },
@ -184,7 +194,7 @@ export class PlaidService {
description: tx.name ?? "Plaid transaction", description: tx.name ?? "Plaid transaction",
rawPayload, rawPayload,
source: "plaid", source: "plaid",
ingestedAt: new Date() ingestedAt: new Date(),
}, },
create: { create: {
accountId: account.id, accountId: account.id,
@ -194,8 +204,8 @@ export class PlaidService {
description: tx.name ?? "Plaid transaction", description: tx.name ?? "Plaid transaction",
rawPayload, rawPayload,
ingestedAt: new Date(), ingestedAt: new Date(),
source: "plaid" source: "plaid",
} },
}); });
created += 1; created += 1;
} }
@ -216,7 +226,7 @@ export class PlaidService {
try { try {
const response = await this.client.institutionsGetById({ const response = await this.client.institutionsGetById({
institution_id: institutionId, institution_id: institutionId,
country_codes: ["US" as CountryCode] country_codes: ["US" as CountryCode],
}); });
return response.data.institution.name ?? "Plaid institution"; return response.data.institution.name ?? "Plaid institution";
} catch { } catch {

View File

@ -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 { ok } from "../common/response";
import { RulesService } from "./rules.service"; import { RulesService } from "./rules.service";
import { CurrentUser } from "../common/decorators/current-user.decorator";
@Controller("rules") @Controller("rules")
export class RulesController { export class RulesController {
constructor(private readonly rulesService: RulesService) {} constructor(private readonly rulesService: RulesService) {}
@Get() @Get()
async list(@Query("user_id") userId?: string) { async list(@CurrentUser() userId: string) {
const data = await this.rulesService.list(userId); const data = await this.rulesService.list(userId);
return ok(data); return ok(data);
} }
@Post() @Post()
async create(@Body() payload: Record<string, unknown>) { async create(
const data = await this.rulesService.create(payload as never); @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); return ok(data);
} }
@Put(":id") @Put(":id")
async update(@Param("id") id: string, @Body() payload: Record<string, unknown>) { async update(
const data = await this.rulesService.update(id, payload as never); @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); return ok(data);
} }
@Post(":id/execute") @Post(":id/execute")
async execute(@Param("id") id: string) { async execute(@CurrentUser() userId: string, @Param("id") id: string) {
const data = await this.rulesService.execute(id); const data = await this.rulesService.execute(userId, id);
return ok(data); return ok(data);
} }
@Get("suggestions") @Get("suggestions")
async suggestions(@Query("user_id") userId?: string) { async suggestions(@CurrentUser() userId: string) {
const data = await this.rulesService.suggest(userId); const data = await this.rulesService.suggest(userId);
return ok(data); return ok(data);
} }

View File

@ -1,161 +1,145 @@
import { Injectable } from "@nestjs/common"; import { BadRequestException, Injectable } from "@nestjs/common";
import { StorageService } from "../storage/storage.service"; import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
@Injectable() @Injectable()
export class RulesService { export class RulesService {
constructor(private readonly storage: StorageService) {} constructor(private readonly prisma: PrismaService) {}
async list(userId?: string) { async list(userId: string) {
if (!userId) { return this.prisma.rule.findMany({
return []; where: { userId, isActive: true },
} orderBy: { priority: "asc" },
const snapshot = await this.storage.load(); });
return snapshot.rules
.filter((rule) => rule.userId === userId)
.sort((a, b) => a.priority - b.priority);
} }
async create(payload: Record<string, unknown>) { async create(
const snapshot = await this.storage.load(); userId: string,
const now = this.storage.now(); payload: {
const rule = { name: string;
id: this.storage.createId(), priority?: number;
userId: String(payload.userId ?? ""), conditions: Record<string, unknown>;
name: String(payload.name ?? "Untitled rule"), actions: Record<string, unknown>;
priority: Number(payload.priority ?? snapshot.rules.length + 1), isActive?: boolean;
conditions: (payload.conditions as Record<string, unknown>) ?? {}, },
actions: (payload.actions as Record<string, unknown>) ?? {}, ) {
isActive: payload.isActive !== false, const count = await this.prisma.rule.count({ where: { userId } });
createdAt: now return this.prisma.rule.create({
}; data: {
snapshot.rules.push(rule); userId,
await this.storage.save(snapshot); name: payload.name ?? "Untitled rule",
return 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>) { async update(
const snapshot = await this.storage.load(); userId: string,
const index = snapshot.rules.findIndex((rule) => rule.id === id); id: string,
if (index === -1) { payload: {
return null; name?: string;
} priority?: number;
const existing = snapshot.rules[index]; conditions?: Record<string, unknown>;
const next = { actions?: Record<string, unknown>;
...existing, isActive?: boolean;
name: typeof payload.name === "string" ? payload.name : existing.name, },
priority: typeof payload.priority === "number" ? payload.priority : existing.priority, ) {
conditions: const existing = await this.prisma.rule.findFirst({ where: { id, userId } });
typeof payload.conditions === "object" && payload.conditions !== null if (!existing) throw new BadRequestException("Rule not found.");
? (payload.conditions as Record<string, unknown>) return this.prisma.rule.update({
: existing.conditions, where: { id },
actions: data: {
typeof payload.actions === "object" && payload.actions !== null ...(payload.name !== undefined && { name: payload.name }),
? (payload.actions as Record<string, unknown>) ...(payload.priority !== undefined && { priority: payload.priority }),
: existing.actions, ...(payload.conditions !== undefined && { conditions: payload.conditions as Prisma.InputJsonValue }),
isActive: typeof payload.isActive === "boolean" ? payload.isActive : existing.isActive ...(payload.actions !== undefined && { actions: payload.actions as Prisma.InputJsonValue }),
}; ...(payload.isActive !== undefined && { isActive: payload.isActive }),
snapshot.rules[index] = next; },
await this.storage.save(snapshot); });
return next;
} }
private matchesRule(rule: { conditions: Record<string, unknown> }, tx: { description: string; amount: number }) { private matchesRule(
const conditions = rule.conditions as Record<string, unknown>; conditions: Record<string, unknown>,
tx: { description: string; amount: number | string },
): boolean {
const textContains = typeof conditions.textContains === "string" ? conditions.textContains : ""; const textContains = typeof conditions.textContains === "string" ? conditions.textContains : "";
const amountGreater = const amountGt = typeof conditions.amountGreaterThan === "number" ? conditions.amountGreaterThan : null;
typeof conditions.amountGreaterThan === "number" ? conditions.amountGreaterThan : null; const amountLt = typeof conditions.amountLessThan === "number" ? conditions.amountLessThan : null;
const amountLess =
typeof conditions.amountLessThan === "number" ? conditions.amountLessThan : null;
const description = tx.description.toLowerCase(); if (textContains && !tx.description.toLowerCase().includes(textContains.toLowerCase())) {
if (textContains && !description.includes(textContains.toLowerCase())) {
return false; return false;
} }
const amount = Number(tx.amount); const amount = Number(tx.amount);
if (amountGreater !== null && amount <= amountGreater) { if (amountGt !== null && amount <= amountGt) return false;
return false; if (amountLt !== null && amount >= amountLt) return false;
}
if (amountLess !== null && amount >= amountLess) {
return false;
}
return true; return true;
} }
async execute(id: string) { async execute(userId: string, id: string) {
const snapshot = await this.storage.load(); const rule = await this.prisma.rule.findFirst({ where: { id, userId } });
const rule = snapshot.rules.find((item) => item.id === id); if (!rule || !rule.isActive) return { id, status: "skipped" };
if (!rule || !rule.isActive) {
return { id, status: "skipped" };
}
const userAccounts = snapshot.accounts.filter((acct) => acct.userId === rule.userId); const conditions = rule.conditions as Record<string, unknown>;
const accountIds = new Set(userAccounts.map((acct) => acct.id)); const actions = rule.actions as Record<string, unknown>;
const transactions = snapshot.transactionsRaw.filter((tx) => accountIds.has(tx.accountId));
const transactions = await this.prisma.transactionRaw.findMany({
where: { account: { userId } },
include: { derived: true },
});
let applied = 0; let applied = 0;
for (const tx of transactions) { for (const tx of transactions) {
if (!this.matchesRule(rule, tx)) { if (!this.matchesRule(conditions, { description: tx.description, amount: Number(tx.amount) })) {
continue; 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({ await this.prisma.transactionDerived.upsert({
id: this.storage.createId(), where: { rawTransactionId: tx.id },
ruleId: rule.id, update: {
transactionId: tx.id, userCategory: typeof actions.setCategory === "string" ? actions.setCategory : tx.derived?.userCategory ?? null,
executedAt: this.storage.now(), isHidden: typeof actions.setHidden === "boolean" ? actions.setHidden : tx.derived?.isHidden ?? false,
result: { applied: true } 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; applied += 1;
} }
await this.storage.save(snapshot);
return { id: rule.id, status: "completed", applied }; return { id: rule.id, status: "completed", applied };
} }
async suggest(userId?: string) { async suggest(userId: string) {
if (!userId) { const derived = await this.prisma.transactionDerived.findMany({
return []; where: {
} raw: { account: { userId } },
const snapshot = await this.storage.load(); userCategory: { not: null },
const userAccounts = snapshot.accounts.filter((acct) => acct.userId === userId); },
const accountIds = new Set(userAccounts.map((acct) => acct.id)); include: { raw: { select: { description: true } } },
const derived = snapshot.transactionsDerived take: 200,
.filter((item) => { });
const raw = snapshot.transactionsRaw.find((tx) => tx.id === item.rawTransactionId);
return raw && accountIds.has(raw.accountId) && item.userCategory;
})
.slice(0, 200);
const bucket = new Map<string, { category: string; count: number }>(); const bucket = new Map<string, { category: string; count: number }>();
for (const item of derived) { for (const item of derived) {
const raw = snapshot.transactionsRaw.find((tx) => tx.id === item.rawTransactionId); const key = item.raw.description.toLowerCase();
if (!raw) {
continue;
}
const key = raw.description.toLowerCase();
const category = item.userCategory ?? "Uncategorized"; const category = item.userCategory ?? "Uncategorized";
const entry = bucket.get(key) ?? { category, count: 0 }; const entry = bucket.get(key) ?? { category, count: 0 };
entry.count += 1; entry.count += 1;
@ -170,7 +154,7 @@ export class RulesService {
name: `Auto: ${value.category}`, name: `Auto: ${value.category}`,
conditions: { textContains: description }, conditions: { textContains: description },
actions: { setCategory: value.category }, 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),
})); }));
} }
} }

View 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;
}
}

View 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 {}

View 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,
},
});
}
}

View 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;
}
}

View File

@ -1,6 +1,14 @@
export type CreateTaxReturnDto = { import { IsArray, IsIn, IsInt, IsString } from "class-validator";
userId: string;
taxYear: number; export class CreateTaxReturnDto {
filingType: "individual" | "business"; @IsInt()
jurisdictions: string[]; taxYear!: number;
};
@IsString()
@IsIn(["individual", "business"])
filingType!: "individual" | "business";
@IsArray()
@IsString({ each: true })
jurisdictions!: string[];
}

View File

@ -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"; status?: "draft" | "ready" | "exported";
@IsOptional()
@IsObject()
summary?: Record<string, unknown>; summary?: Record<string, unknown>;
}; }

View File

@ -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 { ok } from "../common/response";
import { CreateTaxReturnDto } from "./dto/create-return.dto"; import { CreateTaxReturnDto } from "./dto/create-return.dto";
import { UpdateTaxReturnDto } from "./dto/update-return.dto"; import { UpdateTaxReturnDto } from "./dto/update-return.dto";
import { TaxService } from "./tax.service"; import { TaxService } from "./tax.service";
import { CurrentUser } from "../common/decorators/current-user.decorator";
@Controller("tax") @Controller("tax")
export class TaxController { export class TaxController {
constructor(private readonly taxService: TaxService) {} constructor(private readonly taxService: TaxService) {}
@Get("returns") @Get("returns")
async listReturns(@Query("user_id") userId?: string) { async listReturns(@CurrentUser() userId: string) {
const data = await this.taxService.listReturns(userId); const data = await this.taxService.listReturns(userId);
return ok(data); return ok(data);
} }
@Post("returns") @Post("returns")
async createReturn(@Body() payload: CreateTaxReturnDto) { async createReturn(@CurrentUser() userId: string, @Body() payload: CreateTaxReturnDto) {
const data = await this.taxService.createReturn(payload); const data = await this.taxService.createReturn(userId, payload);
return ok(data); return ok(data);
} }
@Patch("returns/:id") @Patch("returns/:id")
async updateReturn(@Param("id") id: string, @Body() payload: UpdateTaxReturnDto) { async updateReturn(
const data = await this.taxService.updateReturn(id, payload); @CurrentUser() userId: string,
@Param("id") id: string,
@Body() payload: UpdateTaxReturnDto,
) {
const data = await this.taxService.updateReturn(userId, id, payload);
return ok(data); return ok(data);
} }
@Post("returns/:id/documents") @Post("returns/:id/documents")
async addDocument( async addDocument(
@CurrentUser() userId: string,
@Param("id") id: 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); return ok(data);
} }
@Post("returns/:id/export") @Post("returns/:id/export")
async exportReturn(@Param("id") id: string) { async exportReturn(@CurrentUser() userId: string, @Param("id") id: string) {
const data = await this.taxService.exportReturn(id); const data = await this.taxService.exportReturn(userId, id);
return ok(data); return ok(data);
} }
} }

View File

@ -4,6 +4,6 @@ import { TaxService } from "./tax.service";
@Module({ @Module({
controllers: [TaxController], controllers: [TaxController],
providers: [TaxService] providers: [TaxService],
}) })
export class TaxModule {} export class TaxModule {}

View File

@ -1,84 +1,75 @@
import { Injectable } from "@nestjs/common"; import { BadRequestException, Injectable } from "@nestjs/common";
import { StorageService } from "../storage/storage.service"; import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { CreateTaxReturnDto } from "./dto/create-return.dto"; import { CreateTaxReturnDto } from "./dto/create-return.dto";
import { UpdateTaxReturnDto } from "./dto/update-return.dto"; import { UpdateTaxReturnDto } from "./dto/update-return.dto";
@Injectable() @Injectable()
export class TaxService { export class TaxService {
constructor(private readonly storage: StorageService) {} constructor(private readonly prisma: PrismaService) {}
async listReturns(userId?: string) { async listReturns(userId: string) {
const snapshot = await this.storage.load(); return this.prisma.taxReturn.findMany({
return userId where: { userId },
? snapshot.taxReturns.filter((ret) => ret.userId === userId) include: { documents: true },
: []; orderBy: { createdAt: "desc" },
});
} }
async createReturn(payload: CreateTaxReturnDto) { async createReturn(userId: string, payload: CreateTaxReturnDto) {
const snapshot = await this.storage.load(); return this.prisma.taxReturn.create({
const now = this.storage.now(); data: {
const next = { userId,
id: this.storage.createId(), taxYear: payload.taxYear,
userId: payload.userId, filingType: payload.filingType,
taxYear: payload.taxYear, jurisdictions: payload.jurisdictions as Prisma.InputJsonValue,
filingType: payload.filingType, status: "draft",
jurisdictions: payload.jurisdictions, summary: {},
status: "draft" as const, },
createdAt: now, });
updatedAt: now,
summary: {}
};
snapshot.taxReturns.push(next);
await this.storage.save(snapshot);
return next;
} }
async updateReturn(id: string, payload: UpdateTaxReturnDto) { async updateReturn(userId: string, id: string, payload: UpdateTaxReturnDto) {
const snapshot = await this.storage.load(); const existing = await this.prisma.taxReturn.findFirst({ where: { id, userId } });
const index = snapshot.taxReturns.findIndex((ret) => ret.id === id); if (!existing) throw new BadRequestException("Tax return not found.");
if (index === -1) { return this.prisma.taxReturn.update({
return null; where: { id },
} data: {
const existing = snapshot.taxReturns[index]; ...(payload.status !== undefined && { status: payload.status }),
const next = { ...(payload.summary !== undefined && { summary: payload.summary as Prisma.InputJsonValue }),
...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 addDocument(returnId: string, docType: string, metadata: Record<string, unknown>) { async addDocument(
const snapshot = await this.storage.load(); userId: string,
const doc = { returnId: string,
id: this.storage.createId(), docType: string,
taxReturnId: returnId, metadata: Record<string, unknown>,
docType, ) {
metadata, const taxReturn = await this.prisma.taxReturn.findFirst({ where: { id: returnId, userId } });
createdAt: this.storage.now() if (!taxReturn) throw new BadRequestException("Tax return not found.");
}; return this.prisma.taxDocument.create({
snapshot.taxDocuments.push(doc); data: {
await this.storage.save(snapshot); taxReturnId: returnId,
return doc; docType,
metadata: metadata as Prisma.InputJsonValue,
},
});
} }
async exportReturn(id: string) { async exportReturn(userId: string, id: string) {
const snapshot = await this.storage.load(); const taxReturn = await this.prisma.taxReturn.findFirst({
const taxReturn = snapshot.taxReturns.find((ret) => ret.id === id); where: { id, userId },
if (!taxReturn) { include: { documents: true },
return null; });
} if (!taxReturn) throw new BadRequestException("Tax return not found.");
const docs = snapshot.taxDocuments.filter((doc) => doc.taxReturnId === id);
const payload = { await this.prisma.taxReturn.update({
return: taxReturn, where: { id },
documents: docs data: { status: "exported" },
}; });
taxReturn.status = "exported";
taxReturn.updatedAt = this.storage.now(); return { return: { ...taxReturn, status: "exported" }, documents: taxReturn.documents };
await this.storage.save(snapshot);
return payload;
} }
} }

View File

@ -1,10 +1,28 @@
export type CreateManualTransactionDto = { import { IsBoolean, IsNumber, IsOptional, IsString, IsDateString } from "class-validator";
userId: string;
export class CreateManualTransactionDto {
@IsOptional()
@IsString()
accountId?: string; accountId?: string;
date: string;
description: string; @IsDateString()
amount: number; date!: string;
@IsString()
description!: string;
@IsNumber()
amount!: number;
@IsOptional()
@IsString()
category?: string; category?: string;
@IsOptional()
@IsString()
note?: string; note?: string;
@IsOptional()
@IsBoolean()
hidden?: boolean; hidden?: boolean;
}; }

View File

@ -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 { ok } from "../common/response";
import { UpdateDerivedDto } from "./dto/update-derived.dto"; import { UpdateDerivedDto } from "./dto/update-derived.dto";
import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto"; import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto";
import { TransactionsService } from "./transactions.service"; import { TransactionsService } from "./transactions.service";
import { CurrentUser } from "../common/decorators/current-user.decorator";
@Controller("transactions") @Controller("transactions")
export class TransactionsController { export class TransactionsController {
constructor(private readonly transactionsService: TransactionsService) {} constructor(private readonly transactionsService: TransactionsService) {}
@Get() @Get()
async list(@Query() query: Record<string, string>) { async list(
const data = await this.transactionsService.list({ @CurrentUser() userId: string,
userId: query.user_id, @Query("start_date") startDate?: string,
startDate: query.start_date, @Query("end_date") endDate?: string,
endDate: query.end_date, @Query("account_id") accountId?: string,
accountId: query.account_id, @Query("min_amount") minAmount?: string,
minAmount: query.min_amount, @Query("max_amount") maxAmount?: string,
maxAmount: query.max_amount, @Query("category") category?: string,
category: query.category, @Query("source") source?: string,
source: query.source, @Query("search") search?: string,
search: query.search, @Query("include_hidden") includeHidden?: string,
includeHidden: query.include_hidden @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); return ok(data);
} }
@Post("import") @Post("import")
async importCsv() { @UseInterceptors(FileInterceptor("file", { limits: { fileSize: 5 * 1024 * 1024 } }))
const data = await this.transactionsService.importCsv(); async importCsv(
@CurrentUser() userId: string,
@UploadedFile() file: Express.Multer.File,
) {
const data = await this.transactionsService.importCsv(userId, file);
return ok(data); return ok(data);
} }
@Post("sync") @Post("sync")
async sync(@Body() payload: { userId: string; startDate?: string; endDate?: string }) { async sync(
const endDate = payload.endDate ?? new Date().toISOString().slice(0, 10); @CurrentUser() userId: string,
const startDate = @Body("startDate") startDate?: string,
payload.startDate ?? @Body("endDate") endDate?: string,
new Date(new Date().setDate(new Date(endDate).getDate() - 30)) ) {
.toISOString() const end = endDate ?? new Date().toISOString().slice(0, 10);
.slice(0, 10); const start =
const data = await this.transactionsService.sync(payload.userId, startDate, endDate); 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); return ok(data);
} }
@Post("manual") @Post("manual")
async manual(@Body() payload: CreateManualTransactionDto) { async manual(
const data = await this.transactionsService.createManualTransaction(payload); @CurrentUser() userId: string,
@Body() payload: CreateManualTransactionDto,
) {
const data = await this.transactionsService.createManualTransaction(userId, payload);
return ok(data); return ok(data);
} }
@Get("summary") @Get("summary")
async summary(@Query() query: Record<string, string>) { async summary(
const endDate = query.end_date ?? new Date().toISOString().slice(0, 10); @CurrentUser() userId: string,
const startDate = @Query("start_date") startDate?: string,
query.start_date ?? @Query("end_date") endDate?: string,
new Date(new Date().setDate(new Date(endDate).getDate() - 30)) ) {
.toISOString() const end = endDate ?? new Date().toISOString().slice(0, 10);
.slice(0, 10); const start =
const data = await this.transactionsService.summary(query.user_id ?? "", startDate, endDate); 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); return ok(data);
} }
@Get("cashflow") @Get("cashflow")
async cashflow(@Query() query: Record<string, string>) { async cashflow(
const months = query.months ? Number(query.months) : 6; @CurrentUser() userId: string,
const data = await this.transactionsService.cashflow(query.user_id ?? "", months); @Query("months") months = 6,
) {
const data = await this.transactionsService.cashflow(userId, +months);
return ok(data); return ok(data);
} }
@Get("merchants") @Get("merchants")
async merchants(@Query() query: Record<string, string>) { async merchants(
const limit = query.limit ? Number(query.limit) : 6; @CurrentUser() userId: string,
const data = await this.transactionsService.merchantInsights( @Query("limit") limit = 6,
query.user_id ?? "", ) {
limit const data = await this.transactionsService.merchantInsights(userId, +limit);
);
return ok(data); return ok(data);
} }
@Patch(":id/derived") @Patch(":id/derived")
async updateDerived(@Param("id") id: string, @Body() payload: UpdateDerivedDto) { async updateDerived(
const data = await this.transactionsService.updateDerived(id, payload); @CurrentUser() userId: string,
@Param("id") id: string,
@Body() payload: UpdateDerivedDto,
) {
const data = await this.transactionsService.updateDerived(userId, id, payload);
return ok(data); return ok(data);
} }
} }

View File

@ -1,96 +1,147 @@
import { Injectable } from "@nestjs/common"; import { BadRequestException, Injectable } from "@nestjs/common";
import * as crypto from "crypto"; import * as crypto from "crypto";
import { parse } from "csv-parse/sync";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { PlaidService } from "../plaid/plaid.service"; import { PlaidService } from "../plaid/plaid.service";
import { UpdateDerivedDto } from "./dto/update-derived.dto"; import { UpdateDerivedDto } from "./dto/update-derived.dto";
import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto"; import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto";
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() @Injectable()
export class TransactionsService { export class TransactionsService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly plaidService: PlaidService private readonly plaidService: PlaidService,
) {} ) {}
async list(filters: { async list(
startDate?: string; userId: string,
endDate?: string; filters: {
accountId?: string; startDate?: string;
userId?: string; endDate?: string;
minAmount?: string; accountId?: string;
maxAmount?: string; minAmount?: string;
category?: string; maxAmount?: string;
source?: string; category?: string;
search?: string; source?: string;
includeHidden?: string; search?: string;
}) { includeHidden?: string;
page?: number;
limit?: number;
},
) {
const end = filters.endDate ? new Date(filters.endDate) : new Date(); const end = filters.endDate ? new Date(filters.endDate) : new Date();
const start = filters.startDate const start = filters.startDate
? new Date(filters.startDate) ? new Date(filters.startDate)
: new Date(new Date().setDate(end.getDate() - 30)); : new Date(new Date().setDate(end.getDate() - 30));
const where: Record<string, unknown> = { const where: Prisma.TransactionRawWhereInput = {
date: { gte: start, lte: end } account: { userId },
date: { gte: start, lte: end },
}; };
if (filters.minAmount || filters.maxAmount) { if (filters.minAmount || filters.maxAmount) {
const minAmount = filters.minAmount ? Number(filters.minAmount) : undefined; const min = filters.minAmount ? parseFloat(filters.minAmount) : undefined;
const maxAmount = filters.maxAmount ? Number(filters.maxAmount) : undefined; const max = filters.maxAmount ? parseFloat(filters.maxAmount) : undefined;
where.amount = { where.amount = { gte: min, lte: max };
gte: Number.isNaN(minAmount ?? Number.NaN) ? undefined : minAmount,
lte: Number.isNaN(maxAmount ?? Number.NaN) ? undefined : maxAmount
};
} }
if (filters.category) { if (filters.category) {
where.derived = { where.derived = { is: { userCategory: { contains: filters.category, mode: "insensitive" } } };
is: {
userCategory: {
contains: filters.category,
mode: "insensitive"
}
}
};
} }
if (filters.source) { if (filters.source) {
where.source = { where.source = { contains: filters.source, mode: "insensitive" };
contains: filters.source,
mode: "insensitive"
};
} }
if (filters.search) { if (filters.search) {
where.description = { where.description = { contains: filters.search, mode: "insensitive" };
contains: filters.search,
mode: "insensitive"
};
} }
if (filters.accountId) { if (filters.accountId) {
where.accountId = filters.accountId; where.accountId = filters.accountId;
} }
if (filters.userId) {
where.account = { userId: filters.userId };
}
if (filters.includeHidden !== "true") { if (filters.includeHidden !== "true") {
where.OR = [ where.OR = [{ derived: null }, { derived: { isHidden: false } }];
{ derived: null },
{ derived: { isHidden: false } }
];
} }
const rows = await this.prisma.transactionRaw.findMany({ const take = Math.min(filters.limit ?? 50, MAX_PAGE_SIZE);
where, const skip = ((filters.page ?? 1) - 1) * take;
include: { derived: true },
orderBy: { date: "desc" },
take: 100
});
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, id: row.id,
name: row.description, name: row.description,
amount: Number(row.amount).toFixed(2), amount: Number(row.amount).toFixed(2),
@ -98,25 +149,87 @@ export class TransactionsService {
note: row.derived?.userNotes ?? "", note: row.derived?.userNotes ?? "",
status: row.derived?.modifiedBy ?? "raw", status: row.derived?.modifiedBy ?? "raw",
hidden: row.derived?.isHidden ?? false, 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() { async importCsv(userId: string, file: Express.Multer.File) {
return { status: "queued" }; 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 rows = detectAndParse(file.buffer);
const account = payload.accountId if (!rows.length) {
? await this.prisma.account.findFirst({ throw new BadRequestException("CSV file is empty or could not be parsed.");
where: { id: payload.accountId, userId: payload.userId } }
})
: await this.prisma.account.findFirst({ // Find or create a manual import account for this user
where: { userId: payload.userId } 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) { if (!account) {
return null; throw new BadRequestException("No account found for user.");
} }
const id = crypto.randomUUID(); const id = crypto.randomUUID();
@ -127,10 +240,10 @@ export class TransactionsService {
date: new Date(payload.date), date: new Date(payload.date),
amount: payload.amount, amount: payload.amount,
description: payload.description, description: payload.description,
rawPayload: payload as Prisma.InputJsonValue, rawPayload: payload as unknown as Prisma.InputJsonValue,
ingestedAt: new Date(), ingestedAt: new Date(),
source: "manual" source: "manual",
} },
}); });
if (payload.category || payload.note || payload.hidden) { if (payload.category || payload.note || payload.hidden) {
@ -141,15 +254,21 @@ export class TransactionsService {
userNotes: payload.note ?? null, userNotes: payload.note ?? null,
isHidden: payload.hidden ?? false, isHidden: payload.hidden ?? false,
modifiedAt: new Date(), 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({ return this.prisma.transactionDerived.upsert({
where: { rawTransactionId: id }, where: { rawTransactionId: id },
update: { update: {
@ -157,7 +276,7 @@ export class TransactionsService {
userNotes: payload.userNotes, userNotes: payload.userNotes,
isHidden: payload.isHidden ?? false, isHidden: payload.isHidden ?? false,
modifiedAt: new Date(), modifiedAt: new Date(),
modifiedBy: "user" modifiedBy: "user",
}, },
create: { create: {
rawTransactionId: id, rawTransactionId: id,
@ -165,8 +284,8 @@ export class TransactionsService {
userNotes: payload.userNotes, userNotes: payload.userNotes,
isHidden: payload.isHidden ?? false, isHidden: payload.isHidden ?? false,
modifiedAt: new Date(), modifiedAt: new Date(),
modifiedBy: "user" modifiedBy: "user",
} },
}); });
} }
@ -178,25 +297,25 @@ export class TransactionsService {
const rows = await this.prisma.transactionRaw.findMany({ const rows = await this.prisma.transactionRaw.findMany({
where: { where: {
account: { userId }, 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 total = rows.reduce((sum, row) => sum + Number(row.amount), 0);
const income = rows.reduce( const income = rows.reduce(
(sum, row) => sum + (Number(row.amount) < 0 ? Math.abs(Number(row.amount)) : 0), (sum, row) => sum + (Number(row.amount) < 0 ? Math.abs(Number(row.amount)) : 0),
0 0,
); );
const expense = rows.reduce( const expense = rows.reduce(
(sum, row) => sum + (Number(row.amount) > 0 ? Number(row.amount) : 0), (sum, row) => sum + (Number(row.amount) > 0 ? Number(row.amount) : 0),
0 0,
); );
return { return {
total: total.toFixed(2), total: total.toFixed(2),
count: rows.length, count: rows.length,
income: income.toFixed(2), income: income.toFixed(2),
expense: expense.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 now = new Date();
const start = new Date(now.getFullYear(), now.getMonth() - (months - 1), 1); const start = new Date(now.getFullYear(), now.getMonth() - (months - 1), 1);
const rows = await this.prisma.transactionRaw.findMany({ 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 }>(); const buckets = new Map<string, { income: number; expense: number }>();
@ -217,49 +336,43 @@ export class TransactionsService {
for (const row of rows) { for (const row of rows) {
const key = `${row.date.getFullYear()}-${String(row.date.getMonth() + 1).padStart(2, "0")}`; const key = `${row.date.getFullYear()}-${String(row.date.getMonth() + 1).padStart(2, "0")}`;
const bucket = buckets.get(key); const bucket = buckets.get(key);
if (!bucket) { if (!bucket) continue;
continue;
}
const amount = Number(row.amount); const amount = Number(row.amount);
if (amount < 0) { if (amount < 0) bucket.income += Math.abs(amount);
bucket.income += Math.abs(amount); else bucket.expense += amount;
} else {
bucket.expense += amount;
}
} }
return Array.from(buckets.entries()).map(([month, value]) => ({ return Array.from(buckets.entries()).map(([month, value]) => ({
month, month,
income: value.income.toFixed(2), income: value.income.toFixed(2),
expense: value.expense.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) { async merchantInsights(userId: string, limit = 6) {
const capped = Math.min(limit, MAX_PAGE_SIZE);
const rows = await this.prisma.transactionRaw.findMany({ 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 }>(); const bucket = new Map<string, { total: number; count: number }>();
for (const row of rows) { for (const row of rows) {
const merchant = row.description;
const amount = Number(row.amount); const amount = Number(row.amount);
if (amount <= 0) { if (amount <= 0) continue;
continue; const entry = bucket.get(row.description) ?? { total: 0, count: 0 };
}
const entry = bucket.get(merchant) ?? { total: 0, count: 0 };
entry.total += amount; entry.total += amount;
entry.count += 1; entry.count += 1;
bucket.set(merchant, entry); bucket.set(row.description, entry);
} }
return Array.from(bucket.entries()) return Array.from(bucket.entries())
.sort((a, b) => b[1].total - a[1].total) .sort((a, b) => b[1].total - a[1].total)
.slice(0, limit) .slice(0, capped)
.map(([merchant, value]) => ({ .map(([merchant, value]) => ({
merchant, merchant,
total: value.total.toFixed(2), total: value.total.toFixed(2),
count: value.count count: value.count,
})); }));
} }
} }

322
write-2fa-speakeasy.mjs Normal file
View 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
View 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
View 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
View 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
View 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
View 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");