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": {
"@nestjs/common": "^10.3.4",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^10.3.4",
"@nestjs/platform-express": "^10.3.4",
"@nestjs/jwt": "^10.2.0",
"@nestjs/platform-express": "^10.3.4",
"@nestjs/swagger": "^11.2.6",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^5.18.0",
"@sentry/node": "^10.40.0",
"@supabase/supabase-js": "^2.49.1",
"@types/qrcode": "^1.5.6",
"@types/speakeasy": "^2.0.10",
"bcryptjs": "^2.4.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
"csv-parse": "^6.1.0",
"dotenv": "^16.4.5",
"googleapis": "^171.4.0",
"helmet": "^8.1.0",
"joi": "^18.0.2",
"nestjs-pino": "^4.6.0",
"nodemailer": "^8.0.1",
"otplib": "^13.3.0",
"pino-http": "^11.0.0",
"pino-pretty": "^13.1.3",
"plaid": "^17.0.0",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"speakeasy": "^2.0.0",
"stripe": "^20.4.0",
"swagger-ui-express": "^5.0.1"
},
"devDependencies": {
"@nestjs/cli": "^10.3.2",
"@nestjs/schematics": "^10.1.1",
"@nestjs/testing": "^10.3.4",
"@types/jest": "^29.5.12",
"@types/multer": "^2.0.0",
"@types/node": "^20.11.20",
"@types/nodemailer": "^7.0.11",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^2.0.16",
"jest": "^29.7.0",
"prisma": "^5.18.0",

View File

@ -8,99 +8,108 @@ datasource db {
}
model User {
id String @id @default(uuid())
email String @unique
passwordHash String
fullName String?
phone String?
companyName String?
addressLine1 String?
addressLine2 String?
city String?
state String?
postalCode String?
country String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
email String @unique
passwordHash String
fullName String?
phone String?
companyName String?
addressLine1 String?
addressLine2 String?
city String?
state String?
postalCode String?
country String?
emailVerified Boolean @default(false)
twoFactorEnabled Boolean @default(false)
twoFactorSecret String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
rules Rule[]
exports ExportLog[]
auditLogs AuditLog[]
accounts Account[]
rules Rule[]
exports ExportLog[]
auditLogs AuditLog[]
googleConnection GoogleConnection?
emailVerificationToken EmailVerificationToken?
passwordResetTokens PasswordResetToken[]
refreshTokens RefreshToken[]
subscription Subscription?
taxReturns TaxReturn[]
}
model Account {
id String @id @default(uuid())
id String @id @default(uuid())
userId String
institutionName String
accountType String
mask String?
plaidAccessToken String?
plaidItemId String?
plaidAccountId String? @unique
plaidAccountId String? @unique
currentBalance Decimal?
availableBalance Decimal?
isoCurrencyCode String?
lastBalanceSync DateTime?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
user User @relation(fields: [userId], references: [id])
transactionsRaw TransactionRaw[]
}
model TransactionRaw {
id String @id @default(uuid())
id String @id @default(uuid())
accountId String
bankTransactionId String @unique
bankTransactionId String @unique
date DateTime
amount Decimal
description String
rawPayload Json
ingestedAt DateTime @default(now())
ingestedAt DateTime @default(now())
source String
account Account @relation(fields: [accountId], references: [id])
account Account @relation(fields: [accountId], references: [id])
derived TransactionDerived?
ruleExecutions RuleExecution[]
}
model TransactionDerived {
id String @id @default(uuid())
rawTransactionId String @unique
id String @id @default(uuid())
rawTransactionId String @unique
userCategory String?
userNotes String?
isHidden Boolean @default(false)
modifiedAt DateTime @default(now())
isHidden Boolean @default(false)
modifiedAt DateTime @default(now())
modifiedBy String
raw TransactionRaw @relation(fields: [rawTransactionId], references: [id])
}
model Rule {
id String @id @default(uuid())
id String @id @default(uuid())
userId String
name String
priority Int
conditions Json
actions Json
isActive Boolean @default(true)
createdAt DateTime @default(now())
isActive Boolean @default(true)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
user User @relation(fields: [userId], references: [id])
executions RuleExecution[]
}
model RuleExecution {
id String @id @default(uuid())
id String @id @default(uuid())
ruleId String
transactionId String
executedAt DateTime @default(now())
executedAt DateTime @default(now())
result Json
rule Rule @relation(fields: [ruleId], references: [id])
transaction TransactionRaw @relation(fields: [transactionId], references: [id])
rule Rule @relation(fields: [ruleId], references: [id])
transaction TransactionRaw @relation(fields: [transactionId], references: [id])
}
model ExportLog {
@ -122,3 +131,90 @@ model AuditLog {
user User @relation(fields: [userId], references: [id])
}
model GoogleConnection {
id String @id @default(uuid())
userId String @unique
googleEmail String
refreshToken String
accessToken String?
spreadsheetId String?
isConnected Boolean @default(true)
connectedAt DateTime @default(now())
lastSyncedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model EmailVerificationToken {
id String @id @default(uuid())
userId String @unique
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model PasswordResetToken {
id String @id @default(uuid())
userId String
token String @unique
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model RefreshToken {
id String @id @default(uuid())
userId String
tokenHash String @unique
expiresAt DateTime
revokedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Subscription {
id String @id @default(uuid())
userId String @unique
plan String @default("free")
stripeCustomerId String?
stripeSubId String?
currentPeriodEnd DateTime?
cancelAtPeriodEnd Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model TaxReturn {
id String @id @default(uuid())
userId String
taxYear Int
filingType String
jurisdictions Json
status String @default("draft")
summary Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
documents TaxDocument[]
}
model TaxDocument {
id String @id @default(uuid())
taxReturnId String
docType String
metadata Json @default("{}")
createdAt DateTime @default(now())
taxReturn TaxReturn @relation(fields: [taxReturnId], references: [id], onDelete: Cascade)
}

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

View File

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

View File

@ -1,27 +1,80 @@
import { Module } from "@nestjs/common";
import { AccountsModule } from "./accounts/accounts.module";
import { ExportsModule } from "./exports/exports.module";
import { RulesModule } from "./rules/rules.module";
import { TransactionsModule } from "./transactions/transactions.module";
import { ConfigModule } from "@nestjs/config";
import { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler";
import { APP_GUARD } from "@nestjs/core";
import { envValidationSchema } from "./config/env.validation";
import { CommonModule } from "./common/common.module";
import { PrismaModule } from "./prisma/prisma.module";
import { StorageModule } from "./storage/storage.module";
import { SupabaseModule } from "./supabase/supabase.module";
import { EmailModule } from "./email/email.module";
import { AuthModule } from "./auth/auth.module";
import { PlaidModule } from "./plaid/plaid.module";
import { StorageModule } from "./storage/storage.module";
import { TaxModule } from "./tax/tax.module";
import { SupabaseModule } from "./supabase/supabase.module";
import { PrismaModule } from "./prisma/prisma.module";
import { TransactionsModule } from "./transactions/transactions.module";
import { AccountsModule } from "./accounts/accounts.module";
import { RulesModule } from "./rules/rules.module";
import { ExportsModule } from "./exports/exports.module";
import { StripeModule } from "./stripe/stripe.module";
import { TwoFactorModule } from "./auth/twofa/two-factor.module";
import { GoogleModule } from "./google/google.module";
import { LoggerModule } from "nestjs-pino";
import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
@Module({
imports: [
StorageModule,
// ─── Env validation ──────────────────────────────────────────────────────
ConfigModule.forRoot({
isGlobal: true,
validationSchema: envValidationSchema,
validationOptions: { allowUnknown: true, abortEarly: false },
}),
// ─── Rate limiting: 100 requests / 60 seconds per IP ─────────────────────
ThrottlerModule.forRoot([
{
name: "default",
ttl: 60_000,
limit: 100,
},
]),
// ─── Structured logging (Pino) ────────────────────────────────────────────
LoggerModule.forRoot({
pinoHttp: {
level: process.env.NODE_ENV === "production" ? "info" : "debug",
transport: process.env.NODE_ENV !== "production"
? { target: "pino-pretty", options: { colorize: true, singleLine: true } }
: undefined,
redact: ["req.headers.authorization", "req.headers.cookie"],
},
}),
// ─── Core infrastructure ─────────────────────────────────────────────────
CommonModule,
PrismaModule,
StorageModule,
SupabaseModule,
EmailModule,
// ─── Feature modules ─────────────────────────────────────────────────────
AuthModule,
PlaidModule,
TaxModule,
TransactionsModule,
AccountsModule,
RulesModule,
ExportsModule
]
ExportsModule,
StripeModule,
TwoFactorModule,
GoogleModule,
],
providers: [
// Apply rate limiting globally
{ provide: APP_GUARD, useClass: ThrottlerGuard },
// Apply JWT auth globally (routes decorated with @Public() are exempt)
{ provide: APP_GUARD, useClass: JwtAuthGuard },
],
})
export class AppModule {}

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 { AuthService } from "./auth.service";
import { LoginDto } from "./dto/login.dto";
import { RegisterDto } from "./dto/register.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto";
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
import { ResetPasswordDto } from "./dto/reset-password.dto";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { Public } from "../common/decorators/public.decorator";
@Controller("auth")
@UseGuards(JwtAuthGuard)
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Post("register")
async register(@Body() payload: RegisterDto) {
const data = await this.authService.register(payload);
return ok(data);
return ok(await this.authService.register(payload));
}
@Public()
@Post("login")
async login(@Body() payload: LoginDto) {
const data = await this.authService.login(payload);
return ok(data);
return ok(await this.authService.login(payload));
}
@Public()
@Get("verify-email")
async verifyEmail(@Query("token") token: string) {
return ok(await this.authService.verifyEmail(token));
}
@Public()
@Post("refresh")
async refresh(@Body("refreshToken") refreshToken: string) {
return ok(await this.authService.refreshAccessToken(refreshToken));
}
@Post("logout")
async logout(@Body("refreshToken") refreshToken: string) {
return ok(await this.authService.logout(refreshToken));
}
@Public()
@Post("forgot-password")
async forgotPassword(@Body() payload: ForgotPasswordDto) {
return ok(await this.authService.forgotPassword(payload));
}
@Public()
@Post("reset-password")
async resetPassword(@Body() payload: ResetPasswordDto) {
return ok(await this.authService.resetPassword(payload));
}
@Get("me")
async me(@CurrentUser() userId: string) {
return ok(await this.authService.getProfile(userId));
}
@Patch("profile")
async updateProfile(
@Headers("authorization") authorization: string | undefined,
@Body() payload: UpdateProfileDto
) {
const token = authorization?.startsWith("Bearer ")
? authorization.slice("Bearer ".length)
: "";
if (!token) {
throw new UnauthorizedException("Missing bearer token.");
}
const decoded = this.authService.verifyToken(token);
if (!decoded?.sub) {
throw new UnauthorizedException("Invalid token.");
}
const data = await this.authService.updateProfile(decoded.sub, payload);
return ok(data);
async updateProfile(@CurrentUser() userId: string, @Body() payload: UpdateProfileDto) {
return ok(await this.authService.updateProfile(userId, payload));
}
}

View File

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

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 speakeasy from "speakeasy";
import { JwtService } from "@nestjs/jwt";
import { PrismaService } from "../prisma/prisma.service";
import { EmailService } from "../email/email.service";
import { EncryptionService } from "../common/encryption.service";
import { LoginDto } from "./dto/login.dto";
import { RegisterDto } from "./dto/register.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto";
import { ForgotPasswordDto } from "./dto/forgot-password.dto";
import { ResetPasswordDto } from "./dto/reset-password.dto";
const VERIFY_TOKEN_TTL_HOURS = 24;
const RESET_TOKEN_TTL_HOURS = 1;
const REFRESH_TOKEN_TTL_DAYS = 30;
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService
private readonly jwtService: JwtService,
private readonly emailService: EmailService,
private readonly encryption: EncryptionService,
) {}
async register(payload: RegisterDto) {
const email = payload.email?.toLowerCase().trim();
if (!email || !payload.password) {
throw new BadRequestException("Email and password are required.");
}
const email = payload.email.toLowerCase().trim();
const existing = await this.prisma.user.findUnique({ where: { email } });
if (existing) {
throw new BadRequestException("Email already registered.");
}
if (existing) throw new BadRequestException("Email already registered.");
const passwordHash = this.hashPassword(payload.password);
const user = await this.prisma.user.create({
data: {
email,
passwordHash,
fullName: null,
phone: null
}
});
await this.prisma.auditLog.create({
data: {
userId: user.id,
action: "auth.register",
metadata: { email }
}
});
const user = await this.prisma.user.create({ data: { email, passwordHash } });
await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.register", metadata: { email } } });
const token = this.signToken(user.id);
return { user: { id: user.id, email: user.email, fullName: user.fullName }, token };
const verifyToken = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + VERIFY_TOKEN_TTL_HOURS * 3600 * 1000);
await this.prisma.emailVerificationToken.upsert({
where: { userId: user.id },
update: { token: verifyToken, expiresAt },
create: { userId: user.id, token: verifyToken, expiresAt },
});
await this.emailService.sendVerificationEmail(email, verifyToken);
const accessToken = this.signAccessToken(user.id);
const refreshToken = await this.createRefreshToken(user.id);
return {
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
accessToken,
refreshToken,
message: "Registration successful. Please verify your email.",
};
}
async login(payload: LoginDto) {
const email = payload.email?.toLowerCase().trim();
if (!email || !payload.password) {
throw new BadRequestException("Email and password are required.");
}
const email = payload.email.toLowerCase().trim();
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user) {
if (!user || !this.verifyPassword(payload.password, user.passwordHash)) {
throw new UnauthorizedException("Invalid credentials.");
}
if (!this.verifyPassword(payload.password, user.passwordHash)) {
throw new UnauthorizedException("Invalid credentials.");
}
await this.prisma.auditLog.create({
data: {
userId: user.id,
action: "auth.login",
metadata: { email }
// ── 2FA enforcement ──────────────────────────────────────────────────────
if (user.twoFactorEnabled && user.twoFactorSecret) {
if (!payload.totpToken) {
return { requiresTwoFactor: true, accessToken: null, refreshToken: null };
}
});
const secret = this.encryption.decrypt(user.twoFactorSecret);
const isValid = speakeasy.totp.verify({ secret, encoding: "base32", token: payload.totpToken, window: 1 });
if (!isValid) {
throw new UnauthorizedException("Invalid TOTP code.");
}
}
const token = this.signToken(user.id);
return { user: { id: user.id, email: user.email, fullName: user.fullName }, token };
await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.login", metadata: { email } } });
const accessToken = this.signAccessToken(user.id);
const refreshToken = await this.createRefreshToken(user.id);
return {
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
accessToken,
refreshToken,
};
}
async verifyEmail(token: string) {
const record = await this.prisma.emailVerificationToken.findUnique({ where: { token } });
if (!record || record.expiresAt < new Date()) {
throw new BadRequestException("Invalid or expired verification token.");
}
await this.prisma.user.update({ where: { id: record.userId }, data: { emailVerified: true } });
await this.prisma.emailVerificationToken.delete({ where: { token } });
return { message: "Email verified successfully." };
}
async refreshAccessToken(rawRefreshToken: string) {
const tokenHash = this.hashToken(rawRefreshToken);
const record = await this.prisma.refreshToken.findUnique({ where: { tokenHash } });
if (!record || record.revokedAt || record.expiresAt < new Date()) {
throw new UnauthorizedException("Invalid or expired refresh token.");
}
await this.prisma.refreshToken.update({ where: { id: record.id }, data: { revokedAt: new Date() } });
const accessToken = this.signAccessToken(record.userId);
const refreshToken = await this.createRefreshToken(record.userId);
return { accessToken, refreshToken };
}
async logout(rawRefreshToken: string) {
const tokenHash = this.hashToken(rawRefreshToken);
await this.prisma.refreshToken.updateMany({
where: { tokenHash, revokedAt: null },
data: { revokedAt: new Date() },
});
return { message: "Logged out." };
}
async forgotPassword(payload: ForgotPasswordDto) {
const email = payload.email.toLowerCase().trim();
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user) return { message: "If that email exists, a reset link has been sent." };
const token = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_HOURS * 3600 * 1000);
await this.prisma.passwordResetToken.create({ data: { userId: user.id, token, expiresAt } });
await this.emailService.sendPasswordResetEmail(email, token);
return { message: "If that email exists, a reset link has been sent." };
}
async resetPassword(payload: ResetPasswordDto) {
const record = await this.prisma.passwordResetToken.findUnique({ where: { token: payload.token } });
if (!record || record.usedAt || record.expiresAt < new Date()) {
throw new BadRequestException("Invalid or expired reset token.");
}
const passwordHash = this.hashPassword(payload.password);
await this.prisma.user.update({ where: { id: record.userId }, data: { passwordHash } });
await this.prisma.passwordResetToken.update({ where: { id: record.id }, data: { usedAt: new Date() } });
await this.prisma.refreshToken.updateMany({
where: { userId: record.userId, revokedAt: null },
data: { revokedAt: new Date() },
});
return { message: "Password reset successfully. Please log in." };
}
async updateProfile(userId: string, payload: UpdateProfileDto) {
const data: UpdateProfileDto = {
fullName: payload.fullName?.trim(),
phone: payload.phone?.trim(),
companyName: payload.companyName?.trim(),
addressLine1: payload.addressLine1?.trim(),
addressLine2: payload.addressLine2?.trim(),
city: payload.city?.trim(),
state: payload.state?.trim(),
postalCode: payload.postalCode?.trim(),
country: payload.country?.trim()
};
Object.keys(data).forEach((key) => {
const value = data[key as keyof UpdateProfileDto];
if (value === undefined || value === "") {
delete data[key as keyof UpdateProfileDto];
}
});
if (Object.keys(data).length === 0) {
throw new BadRequestException("No profile fields provided.");
const data: Record<string, string> = {};
for (const [key, value] of Object.entries(payload)) {
const trimmed = (value as string | undefined)?.trim();
if (trimmed) data[key] = trimmed;
}
const user = await this.prisma.user.update({
where: { id: userId },
data
});
if (!Object.keys(data).length) throw new BadRequestException("No profile fields provided.");
const user = await this.prisma.user.update({ where: { id: userId }, data });
await this.prisma.auditLog.create({
data: {
userId: user.id,
action: "auth.profile.update",
metadata: { updatedFields: Object.keys(data) }
}
data: { userId: user.id, action: "auth.profile.update", metadata: { updatedFields: Object.keys(data) } },
});
return {
user: {
id: user.id,
email: user.email,
fullName: user.fullName,
phone: user.phone,
companyName: user.companyName,
addressLine1: user.addressLine1,
addressLine2: user.addressLine2,
city: user.city,
state: user.state,
postalCode: user.postalCode,
country: user.country
}
id: user.id, email: user.email, fullName: user.fullName, phone: user.phone,
companyName: user.companyName, addressLine1: user.addressLine1,
addressLine2: user.addressLine2, city: user.city, state: user.state,
postalCode: user.postalCode, country: user.country,
},
};
}
private hashPassword(password: string) {
const salt = crypto.randomBytes(16).toString("hex");
const hash = crypto.pbkdf2Sync(password, salt, 100000, 64, "sha512").toString("hex");
return `${salt}:${hash}`;
async getProfile(userId: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new NotFoundException("User not found.");
return {
user: {
id: user.id, email: user.email, fullName: user.fullName, phone: user.phone,
companyName: user.companyName, addressLine1: user.addressLine1,
addressLine2: user.addressLine2, city: user.city, state: user.state,
postalCode: user.postalCode, country: user.country,
emailVerified: user.emailVerified,
twoFactorEnabled: user.twoFactorEnabled,
createdAt: user.createdAt,
},
};
}
private verifyPassword(password: string, stored: string) {
const [salt, hash] = stored.split(":");
if (!salt || !hash) {
return false;
}
const computed = crypto.pbkdf2Sync(password, salt, 100000, 64, "sha512").toString("hex");
return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(computed));
verifyToken(token: string): { sub: string } {
return this.jwtService.verify<{ sub: string }>(token);
}
private signToken(userId: string) {
private signAccessToken(userId: string): string {
return this.jwtService.sign({ sub: userId });
}
verifyToken(token: string) {
return this.jwtService.verify<{ sub: string }>(token);
private async createRefreshToken(userId: string): Promise<string> {
const raw = crypto.randomBytes(40).toString("hex");
const tokenHash = this.hashToken(raw);
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 86400 * 1000);
await this.prisma.refreshToken.create({ data: { userId, tokenHash, expiresAt } });
return raw;
}
private hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
private hashPassword(password: string): string {
const salt = crypto.randomBytes(16).toString("hex");
const hash = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex");
return `${salt}:${hash}`;
}
private verifyPassword(password: string, stored: string): boolean {
const [salt, hash] = stored.split(":");
if (!salt || !hash) return false;
const computed = crypto.pbkdf2Sync(password, salt, 100_000, 64, "sha512").toString("hex");
return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(computed));
}
}

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 = {
email: string;
password: string;
};
import { IsEmail, IsOptional, IsString, MinLength } from "class-validator";
export class LoginDto {
@IsEmail({}, { message: "Invalid email address." })
email!: string;
@IsString()
@MinLength(1, { message: "Password is required." })
password!: string;
@IsOptional()
@IsString()
totpToken?: string;
}

View File

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

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 = {
fullName?: string;
phone?: string;
companyName?: string;
addressLine1?: string;
addressLine2?: string;
city?: string;
state?: string;
postalCode?: string;
country?: string;
};
import { IsOptional, IsString, MaxLength } from "class-validator";
export class UpdateProfileDto {
@IsOptional() @IsString() @MaxLength(200) fullName?: string;
@IsOptional() @IsString() @MaxLength(30) phone?: string;
@IsOptional() @IsString() @MaxLength(200) companyName?: string;
@IsOptional() @IsString() @MaxLength(300) addressLine1?: string;
@IsOptional() @IsString() @MaxLength(300) addressLine2?: string;
@IsOptional() @IsString() @MaxLength(100) city?: string;
@IsOptional() @IsString() @MaxLength(100) state?: string;
@IsOptional() @IsString() @MaxLength(20) postalCode?: string;
@IsOptional() @IsString() @MaxLength(100) country?: string;
}

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

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";
@Injectable()
export class ExportsService {
private readonly logger = new Logger(ExportsService.name);
constructor(private readonly prisma: PrismaService) {}
private toCsv(rows: Array<Record<string, string>>) {
const headers = Object.keys(rows[0] ?? {});
if (!rows.length) return "";
const headers = Object.keys(rows[0]);
const escape = (value: string) => `"${value.replace(/"/g, '""')}"`;
const lines = [headers.join(",")];
for (const row of rows) {
@ -15,59 +19,45 @@ export class ExportsService {
return lines.join("\n");
}
async exportCsv(userId?: string, filters: Record<string, string> = {}) {
if (!userId) {
return { status: "missing_user", csv: "" };
}
const where: Record<string, unknown> = {
account: { userId }
};
private async getTransactions(
userId: string,
filters: Record<string, string>,
limit = 1000,
) {
const where: Record<string, unknown> = { account: { userId } };
if (filters.start_date || filters.end_date) {
where.date = {
gte: filters.start_date ? new Date(filters.start_date) : undefined,
lte: filters.end_date ? new Date(filters.end_date) : undefined
lte: filters.end_date ? new Date(filters.end_date) : undefined,
};
}
if (filters.min_amount || filters.max_amount) {
const minAmount = filters.min_amount ? Number(filters.min_amount) : undefined;
const maxAmount = filters.max_amount ? Number(filters.max_amount) : undefined;
where.amount = {
gte: Number.isNaN(minAmount ?? Number.NaN) ? undefined : minAmount,
lte: Number.isNaN(maxAmount ?? Number.NaN) ? undefined : maxAmount
gte: filters.min_amount ? Number(filters.min_amount) : undefined,
lte: filters.max_amount ? Number(filters.max_amount) : undefined,
};
}
if (filters.category) {
where.derived = {
is: {
userCategory: {
contains: filters.category,
mode: "insensitive"
}
}
is: { userCategory: { contains: filters.category, mode: "insensitive" } },
};
}
if (filters.source) {
where.source = {
contains: filters.source,
mode: "insensitive"
};
where.source = { contains: filters.source, mode: "insensitive" };
}
if (filters.include_hidden !== "true") {
where.OR = [
{ derived: null },
{ derived: { isHidden: false } }
];
where.OR = [{ derived: null }, { derived: { isHidden: false } }];
}
const transactions = await this.prisma.transactionRaw.findMany({
return this.prisma.transactionRaw.findMany({
where,
include: { derived: true },
orderBy: { date: "desc" },
take: 1000
take: limit,
});
}
const rows = transactions.map((tx) => ({
private toRows(transactions: Awaited<ReturnType<typeof this.getTransactions>>) {
return transactions.map((tx) => ({
id: tx.id,
date: tx.date.toISOString().slice(0, 10),
description: tx.description,
@ -75,25 +65,104 @@ export class ExportsService {
category: tx.derived?.userCategory ?? "",
notes: tx.derived?.userNotes ?? "",
hidden: tx.derived?.isHidden ? "true" : "false",
source: tx.source
source: tx.source,
}));
}
async exportCsv(userId: string, filters: Record<string, string> = {}) {
const transactions = await this.getTransactions(userId, filters);
const rows = this.toRows(transactions);
const csv = this.toCsv(rows);
await this.prisma.exportLog.create({
data: {
userId,
filters,
rowCount: rows.length
}
data: { userId, filters, rowCount: rows.length },
});
return { status: "ready", csv, rowCount: rows.length };
}
exportSheets() {
async exportSheets(userId: string, filters: Record<string, string> = {}) {
// Get the user's Google connection
const gc = await this.prisma.googleConnection.findUnique({ where: { userId } });
if (!gc || !gc.isConnected) {
throw new BadRequestException(
"Google account not connected. Please connect via /api/google/connect.",
);
}
const oauth2Client = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
);
oauth2Client.setCredentials({
access_token: gc.accessToken,
refresh_token: gc.refreshToken,
});
// Refresh the access token if needed
const { credentials } = await oauth2Client.refreshAccessToken();
await this.prisma.googleConnection.update({
where: { userId },
data: {
accessToken: credentials.access_token ?? gc.accessToken,
lastSyncedAt: new Date(),
},
});
oauth2Client.setCredentials(credentials);
const sheets = google.sheets({ version: "v4", auth: oauth2Client });
const transactions = await this.getTransactions(userId, filters);
const rows = this.toRows(transactions);
const sheetTitle = `LedgerOne Export ${new Date().toISOString().slice(0, 10)}`;
let spreadsheetId = gc.spreadsheetId;
if (!spreadsheetId) {
// Create a new spreadsheet
const spreadsheet = await sheets.spreadsheets.create({
requestBody: { properties: { title: "LedgerOne" } },
});
spreadsheetId = spreadsheet.data.spreadsheetId!;
await this.prisma.googleConnection.update({
where: { userId },
data: { spreadsheetId },
});
}
// Add a new sheet tab
await sheets.spreadsheets.batchUpdate({
spreadsheetId,
requestBody: {
requests: [{ addSheet: { properties: { title: sheetTitle } } }],
},
});
// Build values: header + data rows
const headers = rows.length ? Object.keys(rows[0]) : ["id", "date", "description", "amount", "category", "notes", "hidden", "source"];
const values = [
headers,
...rows.map((row) => headers.map((h) => row[h as keyof typeof row] ?? "")),
];
await sheets.spreadsheets.values.update({
spreadsheetId,
range: `'${sheetTitle}'!A1`,
valueInputOption: "RAW",
requestBody: { values },
});
await this.prisma.exportLog.create({
data: { userId, filters: { ...filters, destination: "google_sheets" }, rowCount: rows.length },
});
this.logger.log(`Exported ${rows.length} rows to Google Sheets for user ${userId}`);
return {
status: "queued",
status: "exported",
rowCount: rows.length,
spreadsheetId,
sheetTitle,
url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
};
}
}

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 * as Sentry from "@sentry/node";
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import { Logger } from "nestjs-pino";
import helmet from "helmet";
import { AppModule } from "./app.module";
import { SentryExceptionFilter } from "./common/sentry.filter";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix("api");
app.enableCors({
origin: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"]
// ─── Sentry initialization (before app creation) ──────────────────────────
if (process.env.SENTRY_DSN) {
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV ?? "development",
});
}
const app = await NestFactory.create(AppModule, {
bufferLogs: true,
rawBody: true, // Required for Stripe webhook signature verification
});
await app.listen(3051);
// ─── Security headers ─────────────────────────────────────────────────────
app.use(
helmet({
crossOriginEmbedderPolicy: false,
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"], // Swagger UI needs inline scripts
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
}),
);
// ─── CORS ─────────────────────────────────────────────────────────────────
const corsOrigin = process.env.CORS_ORIGIN ?? "http://localhost:3052";
app.enableCors({
origin: corsOrigin.split(",").map((o) => o.trim()),
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
credentials: true,
});
// ─── Global prefix ────────────────────────────────────────────────────────
app.setGlobalPrefix("api");
// ─── Global validation pipe ───────────────────────────────────────────────
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);
// ─── Swagger / OpenAPI ────────────────────────────────────────────────────
if (process.env.NODE_ENV !== "production") {
const config = new DocumentBuilder()
.setTitle("LedgerOne API")
.setDescription("Personal finance & bookkeeping SaaS API")
.setVersion("1.0")
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("api/docs", app, document);
}
// ─── Global exception filter (Sentry + structured error response) ─────────
app.useGlobalFilters(new SentryExceptionFilter());
// ─── Use Pino as the logger ────────────────────────────────────────────────
app.useLogger(app.get(Logger));
const port = process.env.PORT ?? 3051;
await app.listen(port);
app.get(Logger).log(`LedgerOne backend running on port ${port}`, "Bootstrap");
}
bootstrap();

View File

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

View File

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

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

View File

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

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 = {
userId: string;
taxYear: number;
filingType: "individual" | "business";
jurisdictions: string[];
};
import { IsArray, IsIn, IsInt, IsString } from "class-validator";
export class CreateTaxReturnDto {
@IsInt()
taxYear!: number;
@IsString()
@IsIn(["individual", "business"])
filingType!: "individual" | "business";
@IsArray()
@IsString({ each: true })
jurisdictions!: string[];
}

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

View File

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

View File

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

View File

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

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

View File

@ -1,96 +1,147 @@
import { Injectable } from "@nestjs/common";
import { BadRequestException, Injectable } from "@nestjs/common";
import * as crypto from "crypto";
import { parse } from "csv-parse/sync";
import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { PlaidService } from "../plaid/plaid.service";
import { UpdateDerivedDto } from "./dto/update-derived.dto";
import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto";
const MAX_PAGE_SIZE = 100;
// ─── Bank CSV format auto-detection ──────────────────────────────────────────
type ParsedRow = { date: string; description: string; amount: number };
function detectAndParse(buffer: Buffer): ParsedRow[] {
const text = buffer.toString("utf8").trim();
const rows: Record<string, string>[] = parse(text, {
columns: true,
skip_empty_lines: true,
trim: true,
bom: true,
});
if (!rows.length) return [];
const headers = Object.keys(rows[0]).map((h) => h.toLowerCase());
// Chase format: Transaction Date, Description, Amount
if (headers.includes("transaction date") && headers.includes("description") && headers.includes("amount")) {
return rows.map((r) => ({
date: r["Transaction Date"] ?? r["transaction date"],
description: r["Description"] ?? r["description"],
amount: parseFloat(r["Amount"] ?? r["amount"] ?? "0"),
})).filter((r) => r.date && r.description);
}
// Bank of America format: Date, Description, Amount, Running Bal.
if (headers.includes("date") && headers.includes("description") && headers.includes("amount") && headers.some((h) => h.includes("running"))) {
return rows.map((r) => ({
date: r["Date"] ?? r["date"],
description: r["Description"] ?? r["description"],
amount: parseFloat((r["Amount"] ?? r["amount"] ?? "0").replace(/,/g, "")),
})).filter((r) => r.date && r.description);
}
// Wells Fargo format: 5 unnamed columns — Date, Amount, *, *, Description
if (headers.length >= 5 && (headers[0] === "" || /^[0-9]/.test(rows[0][Object.keys(rows[0])[0]] ?? ""))) {
const keys = Object.keys(rows[0]);
return rows.map((r) => ({
date: r[keys[0]],
description: r[keys[4]] ?? r[keys[3]],
amount: parseFloat((r[keys[1]] ?? "0").replace(/,/g, "")),
})).filter((r) => r.date && r.description);
}
// Generic: look for date, amount, description columns
const dateKey = Object.keys(rows[0]).find((k) => /date/i.test(k));
const amountKey = Object.keys(rows[0]).find((k) => /amount/i.test(k));
const descKey = Object.keys(rows[0]).find((k) => /desc|memo|narr|payee/i.test(k));
if (dateKey && amountKey && descKey) {
return rows.map((r) => ({
date: r[dateKey],
description: r[descKey],
amount: parseFloat((r[amountKey] ?? "0").replace(/[^0-9.-]/g, "")),
})).filter((r) => r.date && r.description);
}
throw new BadRequestException("Unrecognized CSV format. Supported: Chase, Bank of America, Wells Fargo, or generic (date/amount/description columns).");
}
@Injectable()
export class TransactionsService {
constructor(
private readonly prisma: PrismaService,
private readonly plaidService: PlaidService
private readonly plaidService: PlaidService,
) {}
async list(filters: {
startDate?: string;
endDate?: string;
accountId?: string;
userId?: string;
minAmount?: string;
maxAmount?: string;
category?: string;
source?: string;
search?: string;
includeHidden?: string;
}) {
async list(
userId: string,
filters: {
startDate?: string;
endDate?: string;
accountId?: string;
minAmount?: string;
maxAmount?: string;
category?: string;
source?: string;
search?: string;
includeHidden?: string;
page?: number;
limit?: number;
},
) {
const end = filters.endDate ? new Date(filters.endDate) : new Date();
const start = filters.startDate
? new Date(filters.startDate)
: new Date(new Date().setDate(end.getDate() - 30));
const where: Record<string, unknown> = {
date: { gte: start, lte: end }
const where: Prisma.TransactionRawWhereInput = {
account: { userId },
date: { gte: start, lte: end },
};
if (filters.minAmount || filters.maxAmount) {
const minAmount = filters.minAmount ? Number(filters.minAmount) : undefined;
const maxAmount = filters.maxAmount ? Number(filters.maxAmount) : undefined;
where.amount = {
gte: Number.isNaN(minAmount ?? Number.NaN) ? undefined : minAmount,
lte: Number.isNaN(maxAmount ?? Number.NaN) ? undefined : maxAmount
};
const min = filters.minAmount ? parseFloat(filters.minAmount) : undefined;
const max = filters.maxAmount ? parseFloat(filters.maxAmount) : undefined;
where.amount = { gte: min, lte: max };
}
if (filters.category) {
where.derived = {
is: {
userCategory: {
contains: filters.category,
mode: "insensitive"
}
}
};
where.derived = { is: { userCategory: { contains: filters.category, mode: "insensitive" } } };
}
if (filters.source) {
where.source = {
contains: filters.source,
mode: "insensitive"
};
where.source = { contains: filters.source, mode: "insensitive" };
}
if (filters.search) {
where.description = {
contains: filters.search,
mode: "insensitive"
};
where.description = { contains: filters.search, mode: "insensitive" };
}
if (filters.accountId) {
where.accountId = filters.accountId;
}
if (filters.userId) {
where.account = { userId: filters.userId };
}
if (filters.includeHidden !== "true") {
where.OR = [
{ derived: null },
{ derived: { isHidden: false } }
];
where.OR = [{ derived: null }, { derived: { isHidden: false } }];
}
const rows = await this.prisma.transactionRaw.findMany({
where,
include: { derived: true },
orderBy: { date: "desc" },
take: 100
});
const take = Math.min(filters.limit ?? 50, MAX_PAGE_SIZE);
const skip = ((filters.page ?? 1) - 1) * take;
return rows.map((row) => ({
const [rows, total] = await Promise.all([
this.prisma.transactionRaw.findMany({
where,
include: { derived: true },
orderBy: { date: "desc" },
take,
skip,
}),
this.prisma.transactionRaw.count({ where }),
]);
const transactions = rows.map((row) => ({
id: row.id,
name: row.description,
amount: Number(row.amount).toFixed(2),
@ -98,25 +149,87 @@ export class TransactionsService {
note: row.derived?.userNotes ?? "",
status: row.derived?.modifiedBy ?? "raw",
hidden: row.derived?.isHidden ?? false,
date: row.date.toISOString().slice(0, 10)
date: row.date.toISOString().slice(0, 10),
source: row.source,
accountId: row.accountId,
}));
return { transactions, total, page: filters.page ?? 1, limit: take };
}
async importCsv() {
return { status: "queued" };
}
async importCsv(userId: string, file: Express.Multer.File) {
if (!file?.buffer) {
throw new BadRequestException("No file uploaded.");
}
if (!file.originalname.toLowerCase().endsWith(".csv")) {
throw new BadRequestException("File must be a CSV.");
}
async createManualTransaction(payload: CreateManualTransactionDto) {
const account = payload.accountId
? await this.prisma.account.findFirst({
where: { id: payload.accountId, userId: payload.userId }
})
: await this.prisma.account.findFirst({
where: { userId: payload.userId }
const rows = detectAndParse(file.buffer);
if (!rows.length) {
throw new BadRequestException("CSV file is empty or could not be parsed.");
}
// Find or create a manual import account for this user
let account = await this.prisma.account.findFirst({
where: { userId, institutionName: "CSV Import", plaidAccessToken: null },
});
if (!account) {
account = await this.prisma.account.create({
data: {
userId,
institutionName: "CSV Import",
accountType: "checking",
isActive: true,
},
});
}
let imported = 0;
let skipped = 0;
for (const row of rows) {
const dateObj = new Date(row.date);
if (isNaN(dateObj.getTime())) {
skipped++;
continue;
}
const bankTransactionId = `csv_${crypto.createHash("sha256")
.update(`${userId}:${row.date}:${row.description}:${row.amount}`)
.digest("hex")
.slice(0, 16)}`;
try {
await this.prisma.transactionRaw.upsert({
where: { bankTransactionId },
update: {},
create: {
accountId: account.id,
bankTransactionId,
date: dateObj,
amount: row.amount,
description: row.description,
rawPayload: row as unknown as Prisma.InputJsonValue,
ingestedAt: new Date(),
source: "csv",
},
});
imported++;
} catch {
skipped++;
}
}
return { imported, skipped, total: rows.length };
}
async createManualTransaction(userId: string, payload: CreateManualTransactionDto) {
const account = payload.accountId
? await this.prisma.account.findFirst({ where: { id: payload.accountId, userId } })
: await this.prisma.account.findFirst({ where: { userId } });
if (!account) {
return null;
throw new BadRequestException("No account found for user.");
}
const id = crypto.randomUUID();
@ -127,10 +240,10 @@ export class TransactionsService {
date: new Date(payload.date),
amount: payload.amount,
description: payload.description,
rawPayload: payload as Prisma.InputJsonValue,
rawPayload: payload as unknown as Prisma.InputJsonValue,
ingestedAt: new Date(),
source: "manual"
}
source: "manual",
},
});
if (payload.category || payload.note || payload.hidden) {
@ -141,15 +254,21 @@ export class TransactionsService {
userNotes: payload.note ?? null,
isHidden: payload.hidden ?? false,
modifiedAt: new Date(),
modifiedBy: "user"
}
modifiedBy: "user",
},
});
}
return raw;
return { id: raw.id };
}
async updateDerived(id: string, payload: UpdateDerivedDto) {
async updateDerived(userId: string, id: string, payload: UpdateDerivedDto) {
// Ensure the transaction belongs to the user
const tx = await this.prisma.transactionRaw.findFirst({
where: { id, account: { userId } },
});
if (!tx) throw new BadRequestException("Transaction not found.");
return this.prisma.transactionDerived.upsert({
where: { rawTransactionId: id },
update: {
@ -157,7 +276,7 @@ export class TransactionsService {
userNotes: payload.userNotes,
isHidden: payload.isHidden ?? false,
modifiedAt: new Date(),
modifiedBy: "user"
modifiedBy: "user",
},
create: {
rawTransactionId: id,
@ -165,8 +284,8 @@ export class TransactionsService {
userNotes: payload.userNotes,
isHidden: payload.isHidden ?? false,
modifiedAt: new Date(),
modifiedBy: "user"
}
modifiedBy: "user",
},
});
}
@ -178,25 +297,25 @@ export class TransactionsService {
const rows = await this.prisma.transactionRaw.findMany({
where: {
account: { userId },
date: { gte: new Date(startDate), lte: new Date(endDate) }
}
date: { gte: new Date(startDate), lte: new Date(endDate) },
},
});
const total = rows.reduce((sum, row) => sum + Number(row.amount), 0);
const income = rows.reduce(
(sum, row) => sum + (Number(row.amount) < 0 ? Math.abs(Number(row.amount)) : 0),
0
0,
);
const expense = rows.reduce(
(sum, row) => sum + (Number(row.amount) > 0 ? Number(row.amount) : 0),
0
0,
);
return {
total: total.toFixed(2),
count: rows.length,
income: income.toFixed(2),
expense: expense.toFixed(2),
net: (income - expense).toFixed(2)
net: (income - expense).toFixed(2),
};
}
@ -204,7 +323,7 @@ export class TransactionsService {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth() - (months - 1), 1);
const rows = await this.prisma.transactionRaw.findMany({
where: { account: { userId }, date: { gte: start, lte: now } }
where: { account: { userId }, date: { gte: start, lte: now } },
});
const buckets = new Map<string, { income: number; expense: number }>();
@ -217,49 +336,43 @@ export class TransactionsService {
for (const row of rows) {
const key = `${row.date.getFullYear()}-${String(row.date.getMonth() + 1).padStart(2, "0")}`;
const bucket = buckets.get(key);
if (!bucket) {
continue;
}
if (!bucket) continue;
const amount = Number(row.amount);
if (amount < 0) {
bucket.income += Math.abs(amount);
} else {
bucket.expense += amount;
}
if (amount < 0) bucket.income += Math.abs(amount);
else bucket.expense += amount;
}
return Array.from(buckets.entries()).map(([month, value]) => ({
month,
income: value.income.toFixed(2),
expense: value.expense.toFixed(2),
net: (value.income - value.expense).toFixed(2)
net: (value.income - value.expense).toFixed(2),
}));
}
async merchantInsights(userId: string, limit = 6) {
const capped = Math.min(limit, MAX_PAGE_SIZE);
const rows = await this.prisma.transactionRaw.findMany({
where: { account: { userId } }
where: { account: { userId } },
select: { description: true, amount: true },
});
const bucket = new Map<string, { total: number; count: number }>();
for (const row of rows) {
const merchant = row.description;
const amount = Number(row.amount);
if (amount <= 0) {
continue;
}
const entry = bucket.get(merchant) ?? { total: 0, count: 0 };
if (amount <= 0) continue;
const entry = bucket.get(row.description) ?? { total: 0, count: 0 };
entry.total += amount;
entry.count += 1;
bucket.set(merchant, entry);
bucket.set(row.description, entry);
}
return Array.from(bucket.entries())
.sort((a, b) => b[1].total - a[1].total)
.slice(0, limit)
.slice(0, capped)
.map(([merchant, value]) => ({
merchant,
total: value.total.toFixed(2),
count: value.count
count: value.count,
}));
}
}

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