first commit

This commit is contained in:
Manesh 2026-02-24 21:45:18 +00:00
commit d1a5a1d1a0
55 changed files with 10686 additions and 0 deletions

11
.env.example Normal file
View File

@ -0,0 +1,11 @@
DATABASE_URL=postgresql://user:password@localhost:5432/ledgerone
JWT_SECRET=change_me
SUPABASE_URL=http://127.0.0.1:54321
SUPABASE_SERVICE_KEY=your_service_role_key
SUPABASE_ANON_KEY=your_anon_key
PLAID_CLIENT_ID=your_client_id
PLAID_SECRET=your_sandbox_secret
PLAID_ENV=sandbox
PLAID_PRODUCTS=transactions
PLAID_COUNTRY_CODES=US
PLAID_REDIRECT_URI=http://localhost:3001/app/connect

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
.env
*.log

29
data/storage.json Normal file
View File

@ -0,0 +1,29 @@
{
"users": [],
"accounts": [],
"transactionsRaw": [],
"transactionsDerived": [],
"rules": [
{
"id": "fff34fea-ff49-4d2e-ae0f-fdf4e35db82d",
"userId": "aee0b88d-8898-4174-9fcb-0435997421a6",
"name": "asdfasdfa",
"priority": 1,
"conditions": {
"textContains": "dinning",
"amountGreaterThan": 2
},
"actions": {
"setCategory": "dining",
"setHidden": false
},
"isActive": true,
"createdAt": "2026-01-21T03:26:36.913Z"
}
],
"ruleExecutions": [],
"exportLogs": [],
"auditLogs": [],
"taxReturns": [],
"taxDocuments": []
}

24
docs/diagrams.md Normal file
View File

@ -0,0 +1,24 @@
# LedgerOne Diagrams
## Entity Relationships (Mermaid)
```mermaid
erDiagram
USER ||--o{ ACCOUNT : owns
USER ||--o{ RULE : defines
USER ||--o{ EXPORT_LOG : exports
USER ||--o{ AUDIT_LOG : logs
ACCOUNT ||--o{ TRANSACTION_RAW : holds
TRANSACTION_RAW ||--o| TRANSACTION_DERIVED : overlays
RULE ||--o{ RULE_EXECUTION : runs
TRANSACTION_RAW ||--o{ RULE_EXECUTION : evaluates
```
## Transaction Flow (Mermaid)
```mermaid
flowchart TD
A[Plaid Transactions] --> B[Raw Transactions]
B --> C[Derived Layer]
D[Rules Engine] --> C
C --> E[Exports CSV]
C --> F[AI Suggestions]
```

55
docs/feature-list.md Normal file
View File

@ -0,0 +1,55 @@
# LedgerOne Feature List
## Implemented
### Backend (NestJS)
- API response wrapper (standardized `data/meta/error` format)
- Transactions API: list + derived update endpoints
- Rules API: list/create/update/execute + suggestion endpoint
- Exports API: CSV generation (raw + derived fields) + export logs
- Accounts API: list + link token stub
- Plaid module: link token + exchange stubs
- Tax module: returns CRUD + export package
- Auth module: register/login using JSON storage
- Storage layer: JSON file persistence (`data/storage.json`)
- Supabase module stub
- Auto-sync service stub
### Frontend (Next.js + Tailwind)
- Landing page UI
- Transactions page
- Rules page + AI suggestions panel
- Exports page
- Tax prep page with all-states selector + John Doe sample dataset
- Login + Register pages
- Next API proxy routes (auth, transactions, rules, exports, tax)
- Liquid / glass UI theme
### Docs & Tests
- Test cases list
- Diagrams (ERD + flows)
- Backend tests: transactions + exports
- Frontend tests: dashboard + transactions
## Partially Implemented / Stubs
- Plaid integration (link/exchange only; no sync)
- Supabase integration (module only)
- Auto-sync service (scaffold only)
- Rules engine: basic conditions/actions only (not full DSL)
- Exporting: JSON/CSV package only (no file storage)
## Not Yet Implemented
- Real database (Postgres) + migrations (Prisma disabled)
- Full CSV import pipeline + deduping
- Rule UI: create/edit/priority drag-drop
- Audit log UI
- MFA + session management
- Stripe billing + plans
- Email integration (Gmail/Postmark)
- Analytics (Plausible)
- Tax e-file provider integration
- Full tax intake flows (W2/1099/deductions/credits)
## Notes
- Current runtime uses JSON storage for dev simplicity.
- Backend endpoints are open (no auth guards enforced).

26
docs/test-cases.md Normal file
View File

@ -0,0 +1,26 @@
# LedgerOne Test Cases
## Auth
- Register new user with valid email/password returns token and user id.
- Register with existing email returns error.
- Login with wrong password returns error.
- Login with valid credentials returns token and user id.
## Transactions
- List transactions returns raw + derived fields for user.
- Update derived creates overlay without mutating raw.
- Update derived preserves raw transaction id and logs modifiedBy=user.
## Rules Engine
- Create rule with priority stores conditions/actions JSON.
- Execute rule applies category/hidden to derived layer only.
- Rule execution logs include rule id and transaction id.
- Suggestions return patterns when at least 2 matching edits exist.
## Exports
- Export CSV includes raw + derived fields for selected filters.
- Export log stored with filters + row count.
## Plaid (Stub)
- Link token endpoint returns token + expiration.
- Exchange endpoint returns access token placeholder.

12
jest.config.ts Normal file
View File

@ -0,0 +1,12 @@
import type { Config } from "jest";
const config: Config = {
preset: "ts-jest",
testEnvironment: "node",
roots: ["<rootDir>/src", "<rootDir>/test"],
testMatch: ["**/*.spec.ts"],
moduleFileExtensions: ["ts", "js", "json"],
clearMocks: true
};
export default config;

4
nest-cli.json Normal file
View File

@ -0,0 +1,4 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

8312
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
package.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "ledgerone",
"version": "0.1.0",
"description": "LedgerOne API (NestJS)",
"license": "UNLICENSED",
"private": true,
"scripts": {
"start": "node dist/main.js",
"start:dev": "nest start --watch",
"dev": "nest start --watch",
"build": "nest build",
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"@nestjs/common": "^10.3.4",
"@nestjs/core": "^10.3.4",
"@nestjs/platform-express": "^10.3.4",
"@nestjs/jwt": "^10.2.0",
"@prisma/client": "^5.18.0",
"@supabase/supabase-js": "^2.49.1",
"bcryptjs": "^2.4.3",
"dotenv": "^16.4.5",
"plaid": "^17.0.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.3.2",
"@nestjs/schematics": "^10.1.1",
"@nestjs/testing": "^10.3.4",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.20",
"@types/supertest": "^2.0.16",
"jest": "^29.7.0",
"prisma": "^5.18.0",
"supertest": "^7.0.0",
"ts-jest": "^29.1.2",
"typescript": "^5.3.3"
}
}

124
prisma/schema.prisma Normal file
View File

@ -0,0 +1,124 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
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
accounts Account[]
rules Rule[]
exports ExportLog[]
auditLogs AuditLog[]
}
model Account {
id String @id @default(uuid())
userId String
institutionName String
accountType String
mask String?
plaidAccessToken String?
plaidItemId String?
plaidAccountId String? @unique
currentBalance Decimal?
availableBalance Decimal?
isoCurrencyCode String?
lastBalanceSync DateTime?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
transactionsRaw TransactionRaw[]
}
model TransactionRaw {
id String @id @default(uuid())
accountId String
bankTransactionId String @unique
date DateTime
amount Decimal
description String
rawPayload Json
ingestedAt DateTime @default(now())
source String
account Account @relation(fields: [accountId], references: [id])
derived TransactionDerived?
ruleExecutions RuleExecution[]
}
model TransactionDerived {
id String @id @default(uuid())
rawTransactionId String @unique
userCategory String?
userNotes String?
isHidden Boolean @default(false)
modifiedAt DateTime @default(now())
modifiedBy String
raw TransactionRaw @relation(fields: [rawTransactionId], references: [id])
}
model Rule {
id String @id @default(uuid())
userId String
name String
priority Int
conditions Json
actions Json
isActive Boolean @default(true)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
executions RuleExecution[]
}
model RuleExecution {
id String @id @default(uuid())
ruleId String
transactionId String
executedAt DateTime @default(now())
result Json
rule Rule @relation(fields: [ruleId], references: [id])
transaction TransactionRaw @relation(fields: [transactionId], references: [id])
}
model ExportLog {
id String @id @default(uuid())
userId String
filters Json
rowCount Int
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
}
model AuditLog {
id String @id @default(uuid())
userId String
action String
metadata Json
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
}

View File

@ -0,0 +1,35 @@
import { Body, Controller, Get, Post, Query } from "@nestjs/common";
import { ok } from "../common/response";
import { AccountsService } from "./accounts.service";
@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);
return ok(data);
}
@Post("link")
async link() {
const data = await this.accountsService.createLinkToken();
return ok(data);
}
@Post("manual")
async manual(
@Body()
payload: { userId: string; institutionName: string; accountType: string; mask?: string }
) {
const data = await this.accountsService.createManualAccount(payload.userId, payload);
return ok(data);
}
@Post("balances")
async balances(@Body() payload: { userId: string }) {
const data = await this.accountsService.refreshBalances(payload.userId);
return ok(data);
}
}

View File

@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { PlaidModule } from "../plaid/plaid.module";
import { AccountsController } from "./accounts.controller";
import { AccountsService } from "./accounts.service";
@Module({
imports: [PlaidModule],
controllers: [AccountsController],
providers: [AccountsService]
})
export class AccountsModule {}

View File

@ -0,0 +1,45 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { PlaidService } from "../plaid/plaid.service";
@Injectable()
export class AccountsService {
constructor(
private readonly prisma: PrismaService,
private readonly plaidService: PlaidService
) {}
async list(userId?: string) {
if (!userId) {
return [];
}
return this.prisma.account.findMany({
where: { userId },
orderBy: { createdAt: "desc" }
});
}
createLinkToken() {
return { linkToken: "stub_link_token" };
}
async refreshBalances(userId: string) {
return this.plaidService.syncBalancesForUser(userId);
}
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
}
});
}
}

27
src/app.module.ts Normal file
View File

@ -0,0 +1,27 @@
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 { 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";
@Module({
imports: [
StorageModule,
PrismaModule,
SupabaseModule,
AuthModule,
PlaidModule,
TaxModule,
TransactionsModule,
AccountsModule,
RulesModule,
ExportsModule
]
})
export class AppModule {}

View File

@ -0,0 +1,42 @@
import { Body, Controller, Headers, Patch, Post, UnauthorizedException } 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";
@Controller("auth")
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post("register")
async register(@Body() payload: RegisterDto) {
const data = await this.authService.register(payload);
return ok(data);
}
@Post("login")
async login(@Body() payload: LoginDto) {
const data = await this.authService.login(payload);
return ok(data);
}
@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);
}
}

16
src/auth/auth.module.ts Normal file
View File

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

146
src/auth/auth.service.ts Normal file
View File

@ -0,0 +1,146 @@
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
import * as crypto from "crypto";
import { JwtService } from "@nestjs/jwt";
import { PrismaService } from "../prisma/prisma.service";
import { LoginDto } from "./dto/login.dto";
import { RegisterDto } from "./dto/register.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto";
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService
) {}
async register(payload: RegisterDto) {
const email = payload.email?.toLowerCase().trim();
if (!email || !payload.password) {
throw new BadRequestException("Email and password are required.");
}
const existing = await this.prisma.user.findUnique({ where: { email } });
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 token = this.signToken(user.id);
return { user: { id: user.id, email: user.email, fullName: user.fullName }, token };
}
async login(payload: LoginDto) {
const email = payload.email?.toLowerCase().trim();
if (!email || !payload.password) {
throw new BadRequestException("Email and password are required.");
}
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user) {
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 }
}
});
const token = this.signToken(user.id);
return { user: { id: user.id, email: user.email, fullName: user.fullName }, token };
}
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 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
}
};
}
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}`;
}
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));
}
private signToken(userId: string) {
return this.jwtService.sign({ sub: userId });
}
verifyToken(token: string) {
return this.jwtService.verify<{ sub: string }>(token);
}
}

View File

@ -0,0 +1,4 @@
export type LoginDto = {
email: string;
password: string;
};

View File

@ -0,0 +1,4 @@
export type RegisterDto = {
email: string;
password: string;
};

View File

@ -0,0 +1,11 @@
export type UpdateProfileDto = {
fullName?: string;
phone?: string;
companyName?: string;
addressLine1?: string;
addressLine2?: string;
city?: string;
state?: string;
postalCode?: string;
country?: string;
};

21
src/common/response.ts Normal file
View File

@ -0,0 +1,21 @@
export type ApiMeta = {
timestamp: string;
version: "v1";
};
export type ApiResponse<T> = {
data: T;
meta: ApiMeta;
error: null | { message: string; code?: string };
};
export function ok<T>(data: T): ApiResponse<T> {
return {
data,
meta: {
timestamp: new Date().toISOString(),
version: "v1",
},
error: null,
};
}

View File

@ -0,0 +1,20 @@
import { Controller, Get, Post, Query } from "@nestjs/common";
import { ok } from "../common/response";
import { ExportsService } from "./exports.service";
@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 ?? {});
return ok(data);
}
@Post("sheets")
async exportSheets() {
const data = await this.exportsService.exportSheets();
return ok(data);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { ExportsController } from "./exports.controller";
import { ExportsService } from "./exports.service";
@Module({
controllers: [ExportsController],
providers: [ExportsService],
})
export class ExportsModule {}

View File

@ -0,0 +1,99 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
@Injectable()
export class ExportsService {
constructor(private readonly prisma: PrismaService) {}
private toCsv(rows: Array<Record<string, string>>) {
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");
}
async exportCsv(userId?: string, filters: Record<string, string> = {}) {
if (!userId) {
return { status: "missing_user", csv: "" };
}
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) {
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
};
}
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 } }
];
}
const transactions = await this.prisma.transactionRaw.findMany({
where,
include: { derived: true },
orderBy: { date: "desc" },
take: 1000
});
const rows = 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
}));
const csv = this.toCsv(rows);
await this.prisma.exportLog.create({
data: {
userId,
filters,
rowCount: rows.length
}
});
return { status: "ready", csv, rowCount: rows.length };
}
exportSheets() {
return {
status: "queued",
};
}
}

16
src/main.ts Normal file
View File

@ -0,0 +1,16 @@
import "dotenv/config";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
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"]
});
await app.listen(3051);
}
bootstrap();

View File

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

10
src/plaid/plaid.module.ts Normal file
View File

@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { PlaidController } from "./plaid.controller";
import { PlaidService } from "./plaid.service";
@Module({
controllers: [PlaidController],
providers: [PlaidService],
exports: [PlaidService]
})
export class PlaidModule {}

226
src/plaid/plaid.service.ts Normal file
View File

@ -0,0 +1,226 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import {
Configuration,
CountryCode,
PlaidApi,
PlaidEnvironments,
Products
} from "plaid";
import * as crypto from "crypto";
import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
@Injectable()
export class PlaidService {
private readonly client: PlaidApi;
constructor(private readonly prisma: PrismaService) {
const env = (process.env.PLAID_ENV ?? "sandbox") as keyof typeof PlaidEnvironments;
const clientId = this.requireEnv("PLAID_CLIENT_ID");
const secret = this.requireEnv("PLAID_SECRET");
const config = new Configuration({
basePath: PlaidEnvironments[env] ?? PlaidEnvironments.sandbox,
baseOptions: {
headers: {
"PLAID-CLIENT-ID": clientId,
"PLAID-SECRET": secret,
"Plaid-Version": "2020-09-14"
}
}
});
this.client = new PlaidApi(config);
}
async createLinkToken() {
const products = (process.env.PLAID_PRODUCTS ?? "transactions")
.split(",")
.map((item) => item.trim())
.filter(Boolean) as Products[];
const countryCodes = (process.env.PLAID_COUNTRY_CODES ?? "US")
.split(",")
.map((item) => item.trim())
.filter(Boolean) as CountryCode[];
const redirectUri = process.env.PLAID_REDIRECT_URI?.trim();
try {
const response = await this.client.linkTokenCreate({
user: {
client_user_id: crypto.randomUUID()
},
client_name: "LedgerOne",
products,
country_codes: countryCodes,
language: "en",
redirect_uri: redirectUri || undefined
});
return {
linkToken: response.data.link_token,
expiration: response.data.expiration
};
} catch (error: unknown) {
const err = error as { response?: { data?: { error_message?: string } } };
const message =
err.response?.data?.error_message ?? "Plaid link token request failed.";
throw new BadRequestException(message);
}
}
async exchangePublicTokenForUser(userId: string, publicToken: string) {
const exchange = await this.client.itemPublicTokenExchange({
public_token: publicToken
});
const accessToken = exchange.data.access_token;
const itemId = exchange.data.item_id;
const accountsResponse = await this.client.accountsGet({ access_token: accessToken });
const institutionId = accountsResponse.data.item?.institution_id;
const institutionName = institutionId
? await this.getInstitutionName(institutionId)
: "Plaid institution";
for (const account of accountsResponse.data.accounts) {
await this.prisma.account.upsert({
where: { plaidAccountId: account.account_id },
update: {
institutionName,
accountType: account.subtype ?? account.type,
mask: account.mask ?? null,
plaidAccessToken: accessToken,
plaidItemId: itemId,
currentBalance: account.balances.current ?? null,
availableBalance: account.balances.available ?? null,
isoCurrencyCode: account.balances.iso_currency_code ?? null,
lastBalanceSync: new Date(),
userId
},
create: {
userId,
institutionName,
accountType: account.subtype ?? account.type,
mask: account.mask ?? null,
plaidAccessToken: accessToken,
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
}
});
}
return {
accessToken,
itemId,
accountCount: accountsResponse.data.accounts.length
};
}
async syncBalancesForUser(userId: string) {
const accounts = await this.prisma.account.findMany({
where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } }
});
const tokens = Array.from(new Set(accounts.map((acct) => acct.plaidAccessToken))).filter(
Boolean
) as string[];
let updated = 0;
for (const token of tokens) {
const response = await this.client.accountsBalanceGet({ access_token: token });
for (const account of response.data.accounts) {
const record = await this.prisma.account.updateMany({
where: { plaidAccountId: account.account_id, userId },
data: {
currentBalance: account.balances.current ?? null,
availableBalance: account.balances.available ?? null,
isoCurrencyCode: account.balances.iso_currency_code ?? null,
lastBalanceSync: new Date()
}
});
updated += record.count;
}
}
return { updated };
}
async syncTransactionsForUser(userId: string, startDate: string, endDate: string) {
const accounts = await this.prisma.account.findMany({
where: { userId, plaidAccessToken: { not: null }, plaidAccountId: { not: null } }
});
const tokenMap = new Map<string, string[]>();
for (const account of accounts) {
if (!account.plaidAccessToken || !account.plaidAccountId) {
continue;
}
const list = tokenMap.get(account.plaidAccessToken) ?? [];
list.push(account.plaidAccountId);
tokenMap.set(account.plaidAccessToken, list);
}
let created = 0;
for (const [token] of tokenMap) {
const response = await this.client.transactionsGet({
access_token: token,
start_date: startDate,
end_date: endDate,
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;
}
const rawPayload = tx as unknown as Prisma.InputJsonValue;
await this.prisma.transactionRaw.upsert({
where: { bankTransactionId: tx.transaction_id },
update: {
accountId: account.id,
date: new Date(tx.date),
amount: tx.amount,
description: tx.name ?? "Plaid transaction",
rawPayload,
source: "plaid",
ingestedAt: new Date()
},
create: {
accountId: account.id,
bankTransactionId: tx.transaction_id,
date: new Date(tx.date),
amount: tx.amount,
description: tx.name ?? "Plaid transaction",
rawPayload,
ingestedAt: new Date(),
source: "plaid"
}
});
created += 1;
}
}
return { created };
}
private requireEnv(name: string) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing ${name} environment variable.`);
}
return value;
}
private async getInstitutionName(institutionId: string) {
try {
const response = await this.client.institutionsGetById({
institution_id: institutionId,
country_codes: ["US" as CountryCode]
});
return response.data.institution.name ?? "Plaid institution";
} catch {
return "Plaid institution";
}
}
}

View File

@ -0,0 +1,9 @@
import { Global, Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service";
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService]
})
export class PrismaModule {}

View File

@ -0,0 +1,13 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View File

@ -0,0 +1,38 @@
import { Body, Controller, Get, Param, Post, Put, Query } from "@nestjs/common";
import { ok } from "../common/response";
import { RulesService } from "./rules.service";
@Controller("rules")
export class RulesController {
constructor(private readonly rulesService: RulesService) {}
@Get()
async list(@Query("user_id") 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);
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);
return ok(data);
}
@Post(":id/execute")
async execute(@Param("id") id: string) {
const data = await this.rulesService.execute(id);
return ok(data);
}
@Get("suggestions")
async suggestions(@Query("user_id") userId?: string) {
const data = await this.rulesService.suggest(userId);
return ok(data);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { RulesController } from "./rules.controller";
import { RulesService } from "./rules.service";
@Module({
controllers: [RulesController],
providers: [RulesService],
})
export class RulesModule {}

176
src/rules/rules.service.ts Normal file
View File

@ -0,0 +1,176 @@
import { Injectable } from "@nestjs/common";
import { StorageService } from "../storage/storage.service";
@Injectable()
export class RulesService {
constructor(private readonly storage: StorageService) {}
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 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 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;
}
private matchesRule(rule: { conditions: Record<string, unknown> }, tx: { description: string; amount: number }) {
const conditions = rule.conditions as Record<string, unknown>;
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 description = tx.description.toLowerCase();
if (textContains && !description.includes(textContains.toLowerCase())) {
return false;
}
const amount = Number(tx.amount);
if (amountGreater !== null && amount <= amountGreater) {
return false;
}
if (amountLess !== null && amount >= amountLess) {
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" };
}
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));
let applied = 0;
for (const tx of transactions) {
if (!this.matchesRule(rule, tx)) {
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 }
});
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);
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 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)
}));
}
}

View File

@ -0,0 +1,9 @@
import { Global, Module } from "@nestjs/common";
import { StorageService } from "./storage.service";
@Global()
@Module({
providers: [StorageService],
exports: [StorageService]
})
export class StorageModule {}

View File

@ -0,0 +1,166 @@
import { Injectable } from "@nestjs/common";
import { promises as fs } from "fs";
import * as path from "path";
import * as crypto from "crypto";
export type StoredUser = {
id: string;
email: string;
passwordHash: string;
createdAt: string;
updatedAt: string;
};
export type StoredAccount = {
id: string;
userId: string;
institutionName: string;
accountType: string;
mask?: string;
plaidAccessToken?: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
};
export type StoredTransactionRaw = {
id: string;
accountId: string;
bankTransactionId: string;
date: string;
amount: number;
description: string;
rawPayload: Record<string, unknown>;
ingestedAt: string;
source: string;
};
export type StoredTransactionDerived = {
id: string;
rawTransactionId: string;
userCategory?: string;
userNotes?: string;
isHidden: boolean;
modifiedAt: string;
modifiedBy: string;
};
export type StoredRule = {
id: string;
userId: string;
name: string;
priority: number;
conditions: Record<string, unknown>;
actions: Record<string, unknown>;
isActive: boolean;
createdAt: string;
};
export type StoredRuleExecution = {
id: string;
ruleId: string;
transactionId: string;
executedAt: string;
result: Record<string, unknown>;
};
export type StoredExportLog = {
id: string;
userId: string;
filters: Record<string, unknown>;
rowCount: number;
createdAt: string;
};
export type StoredAuditLog = {
id: string;
userId: string;
action: string;
metadata: Record<string, unknown>;
createdAt: string;
};
export type StorageSnapshot = {
users: StoredUser[];
accounts: StoredAccount[];
transactionsRaw: StoredTransactionRaw[];
transactionsDerived: StoredTransactionDerived[];
rules: StoredRule[];
ruleExecutions: StoredRuleExecution[];
exportLogs: StoredExportLog[];
auditLogs: StoredAuditLog[];
taxReturns: StoredTaxReturn[];
taxDocuments: StoredTaxDocument[];
};
export type StoredTaxReturn = {
id: string;
userId: string;
taxYear: number;
filingType: "individual" | "business";
jurisdictions: string[];
status: "draft" | "ready" | "exported";
createdAt: string;
updatedAt: string;
summary: Record<string, unknown>;
};
export type StoredTaxDocument = {
id: string;
taxReturnId: string;
docType: string;
metadata: Record<string, unknown>;
createdAt: string;
};
@Injectable()
export class StorageService {
private readonly filePath = path.resolve(process.cwd(), "data", "storage.json");
private writing = Promise.resolve();
private async ensureFile() {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
try {
await fs.access(this.filePath);
} catch {
const empty: StorageSnapshot = {
users: [],
accounts: [],
transactionsRaw: [],
transactionsDerived: [],
rules: [],
ruleExecutions: [],
exportLogs: [],
auditLogs: [],
taxReturns: [],
taxDocuments: []
};
await fs.writeFile(this.filePath, JSON.stringify(empty, null, 2), "utf8");
}
}
async load(): Promise<StorageSnapshot> {
await this.ensureFile();
const raw = await fs.readFile(this.filePath, "utf8");
return JSON.parse(raw) as StorageSnapshot;
}
async save(next: StorageSnapshot) {
await this.ensureFile();
this.writing = this.writing.then(() =>
fs.writeFile(this.filePath, JSON.stringify(next, null, 2), "utf8")
);
await this.writing;
}
createId() {
if (crypto.randomUUID) {
return crypto.randomUUID();
}
return crypto.randomBytes(16).toString("hex");
}
now() {
return new Date().toISOString();
}
}

View File

@ -0,0 +1,9 @@
import { Global, Module } from "@nestjs/common";
import { SupabaseService } from "./supabase.service";
@Global()
@Module({
providers: [SupabaseService],
exports: [SupabaseService]
})
export class SupabaseModule {}

View File

@ -0,0 +1,33 @@
import { Injectable } from "@nestjs/common";
import { createClient, SupabaseClient } from "@supabase/supabase-js";
@Injectable()
export class SupabaseService {
private readonly client: SupabaseClient;
constructor() {
const url = this.requireEnv("SUPABASE_URL");
const serviceKey =
process.env.SUPABASE_SERVICE_KEY ?? process.env.SUPABASE_ANON_KEY;
if (!serviceKey) {
throw new Error(
"Missing SUPABASE_SERVICE_KEY (preferred) or SUPABASE_ANON_KEY environment variable."
);
}
this.client = createClient(url, serviceKey, {
auth: { persistSession: false }
});
}
getClient() {
return this.client;
}
private requireEnv(name: string) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing ${name} environment variable.`);
}
return value;
}
}

View File

@ -0,0 +1,6 @@
export type CreateTaxReturnDto = {
userId: string;
taxYear: number;
filingType: "individual" | "business";
jurisdictions: string[];
};

View File

@ -0,0 +1,4 @@
export type UpdateTaxReturnDto = {
status?: "draft" | "ready" | "exported";
summary?: Record<string, unknown>;
};

43
src/tax/tax.controller.ts Normal file
View File

@ -0,0 +1,43 @@
import { Body, Controller, Get, Param, Patch, Post, Query } 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";
@Controller("tax")
export class TaxController {
constructor(private readonly taxService: TaxService) {}
@Get("returns")
async listReturns(@Query("user_id") 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);
return ok(data);
}
@Patch("returns/:id")
async updateReturn(@Param("id") id: string, @Body() payload: UpdateTaxReturnDto) {
const data = await this.taxService.updateReturn(id, payload);
return ok(data);
}
@Post("returns/:id/documents")
async addDocument(
@Param("id") id: string,
@Body() payload: { docType: string; metadata: Record<string, unknown> }
) {
const data = await this.taxService.addDocument(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);
return ok(data);
}
}

9
src/tax/tax.module.ts Normal file
View File

@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { TaxController } from "./tax.controller";
import { TaxService } from "./tax.service";
@Module({
controllers: [TaxController],
providers: [TaxService]
})
export class TaxModule {}

84
src/tax/tax.service.ts Normal file
View File

@ -0,0 +1,84 @@
import { Injectable } from "@nestjs/common";
import { StorageService } from "../storage/storage.service";
import { CreateTaxReturnDto } from "./dto/create-return.dto";
import { UpdateTaxReturnDto } from "./dto/update-return.dto";
@Injectable()
export class TaxService {
constructor(private readonly storage: StorageService) {}
async listReturns(userId?: string) {
const snapshot = await this.storage.load();
return userId
? snapshot.taxReturns.filter((ret) => ret.userId === userId)
: [];
}
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 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 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 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;
}
}

View File

@ -0,0 +1,54 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { PlaidService } from "../plaid/plaid.service";
@Injectable()
export class AutoSyncService implements OnModuleInit, OnModuleDestroy {
private intervalId: NodeJS.Timeout | null = null;
constructor(
private readonly prisma: PrismaService,
private readonly plaidService: PlaidService
) {}
onModuleInit() {
const enabled = (process.env.AUTO_SYNC_ENABLED ?? "true").toLowerCase() !== "false";
if (!enabled) {
return;
}
if (!process.env.PLAID_CLIENT_ID || !process.env.PLAID_SECRET) {
return;
}
const minutes = Number(process.env.AUTO_SYNC_INTERVAL_MINUTES ?? "15");
const interval = Number.isNaN(minutes) ? 15 : minutes;
this.intervalId = setInterval(() => {
this.run().catch(() => undefined);
}, interval * 60 * 1000);
this.run().catch(() => undefined);
}
onModuleDestroy() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
private async run() {
const accounts = await this.prisma.account.findMany({
where: { plaidAccessToken: { not: null }, plaidAccountId: { not: null } },
select: { userId: true }
});
const userIds = Array.from(new Set(accounts.map((acct) => acct.userId)));
if (!userIds.length) {
return;
}
const endDate = new Date().toISOString().slice(0, 10);
const startDate = new Date(new Date().setDate(new Date().getDate() - 7))
.toISOString()
.slice(0, 10);
for (const userId of userIds) {
await this.plaidService.syncTransactionsForUser(userId, startDate, endDate);
}
}
}

View File

@ -0,0 +1,10 @@
export type CreateManualTransactionDto = {
userId: string;
accountId?: string;
date: string;
description: string;
amount: number;
category?: string;
note?: string;
hidden?: boolean;
};

View File

@ -0,0 +1,5 @@
export type UpdateDerivedDto = {
userCategory?: string;
userNotes?: string;
isHidden?: boolean;
};

View File

@ -0,0 +1,86 @@
import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common";
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";
@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
});
return ok(data);
}
@Post("import")
async importCsv() {
const data = await this.transactionsService.importCsv();
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);
return ok(data);
}
@Post("manual")
async manual(@Body() payload: CreateManualTransactionDto) {
const data = await this.transactionsService.createManualTransaction(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);
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);
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
);
return ok(data);
}
@Patch(":id/derived")
async updateDerived(@Param("id") id: string, @Body() payload: UpdateDerivedDto) {
const data = await this.transactionsService.updateDerived(id, payload);
return ok(data);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { PlaidModule } from "../plaid/plaid.module";
import { TransactionsController } from "./transactions.controller";
import { TransactionsService } from "./transactions.service";
import { AutoSyncService } from "./auto-sync.service";
@Module({
imports: [PlaidModule],
controllers: [TransactionsController],
providers: [TransactionsService, AutoSyncService]
})
export class TransactionsModule {}

View File

@ -0,0 +1,265 @@
import { Injectable } from "@nestjs/common";
import * as crypto from "crypto";
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";
@Injectable()
export class TransactionsService {
constructor(
private readonly prisma: PrismaService,
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;
}) {
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 }
};
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
};
}
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.userId) {
where.account = { userId: filters.userId };
}
if (filters.includeHidden !== "true") {
where.OR = [
{ derived: null },
{ derived: { isHidden: false } }
];
}
const rows = await this.prisma.transactionRaw.findMany({
where,
include: { derived: true },
orderBy: { date: "desc" },
take: 100
});
return 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)
}));
}
async importCsv() {
return { status: "queued" };
}
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 }
});
if (!account) {
return null;
}
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 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 raw;
}
async updateDerived(id: string, payload: UpdateDerivedDto) {
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 rows = await this.prisma.transactionRaw.findMany({
where: { account: { userId } }
});
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 };
entry.total += amount;
entry.count += 1;
bucket.set(merchant, entry);
}
return Array.from(bucket.entries())
.sort((a, b) => b[1].total - a[1].total)
.slice(0, limit)
.map(([merchant, value]) => ({
merchant,
total: value.total.toFixed(2),
count: value.count
}));
}
}

View File

@ -0,0 +1,39 @@
import { ExportsService } from "../src/exports/exports.service";
import { createPrismaMock } from "./utils/mock-prisma";
describe("ExportsService", () => {
it("returns missing_user when user id is absent", async () => {
const prisma = createPrismaMock();
const service = new ExportsService(prisma as any);
const result = await service.exportCsv(undefined);
expect(result.status).toBe("missing_user");
expect(prisma.exportLog.create).not.toHaveBeenCalled();
});
it("exports csv with headers and rows", async () => {
const prisma = createPrismaMock();
prisma.transactionRaw.findMany.mockResolvedValue([
{
id: "tx_1",
date: new Date("2025-01-10"),
description: "Lunch",
amount: 20,
source: "manual",
derived: { userCategory: "Meals", userNotes: "Team lunch", isHidden: false }
}
]);
prisma.exportLog.create.mockResolvedValue({ id: "log_1" });
const service = new ExportsService(prisma as any);
const result = await service.exportCsv("user_1", { category: "Meals" });
expect(result.status).toBe("ready");
expect(result.rowCount).toBe(1);
expect(result.csv).toContain("id,date,description,amount,category,notes,hidden,source");
expect(prisma.exportLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ userId: "user_1", rowCount: 1 })
})
);
});
});

View File

@ -0,0 +1,65 @@
import { TransactionsController } from "../src/transactions/transactions.controller";
describe("TransactionsController", () => {
const createController = () => {
const service = {
list: jest.fn().mockResolvedValue([]),
sync: jest.fn().mockResolvedValue({ created: 0 }),
summary: jest.fn().mockResolvedValue({ total: "0.00", count: 0 }),
updateDerived: jest.fn().mockResolvedValue({}),
createManualTransaction: jest.fn().mockResolvedValue({ id: "tx_1" }),
cashflow: jest.fn().mockResolvedValue([]),
merchantInsights: jest.fn().mockResolvedValue([])
};
return { controller: new TransactionsController(service as any), service };
};
it("lists transactions", async () => {
const { controller, service } = createController();
const result = await controller.list({ user_id: "user_1" });
expect(service.list).toHaveBeenCalledWith(
expect.objectContaining({ userId: "user_1" })
);
expect(result.data).toEqual([]);
});
it("syncs transactions", async () => {
const { controller, service } = createController();
const result = await controller.sync({ userId: "user_1" });
expect(service.sync).toHaveBeenCalled();
expect(result.data).toEqual({ created: 0 });
});
it("returns summary", async () => {
const { controller, service } = createController();
const result = await controller.summary({ user_id: "user_1" });
expect(service.summary).toHaveBeenCalled();
expect(result.data.total).toBe("0.00");
});
it("creates manual transaction", async () => {
const { controller, service } = createController();
const result = await controller.manual({
userId: "user_1",
date: "2025-01-01",
description: "Manual",
amount: 10
});
expect(service.createManualTransaction).toHaveBeenCalled();
expect(result.data.id).toBe("tx_1");
});
it("returns cashflow", async () => {
const { controller, service } = createController();
const result = await controller.cashflow({ user_id: "user_1", months: "3" });
expect(service.cashflow).toHaveBeenCalledWith("user_1", 3);
expect(result.data).toEqual([]);
});
it("returns merchant insights", async () => {
const { controller, service } = createController();
const result = await controller.merchants({ user_id: "user_1", limit: "5" });
expect(service.merchantInsights).toHaveBeenCalledWith("user_1", 5);
expect(result.data).toEqual([]);
});
});

View File

@ -0,0 +1,92 @@
import { TransactionsService } from "../src/transactions/transactions.service";
import { createPrismaMock } from "./utils/mock-prisma";
const createService = () => {
const prisma = createPrismaMock();
const plaid = { syncTransactionsForUser: jest.fn() };
const service = new TransactionsService(prisma as any, plaid as any);
return { service, prisma, plaid };
};
describe("TransactionsService", () => {
it("calculates summary income/expense/net", async () => {
const { service, prisma } = createService();
prisma.transactionRaw.findMany.mockResolvedValue([
{ amount: -120.5, date: new Date("2025-01-01") },
{ amount: 40, date: new Date("2025-01-02") },
{ amount: -10, date: new Date("2025-01-03") }
]);
const result = await service.summary("user_1", "2025-01-01", "2025-01-31");
expect(result.total).toBe("-90.50");
expect(result.income).toBe("130.50");
expect(result.expense).toBe("40.00");
expect(result.net).toBe("90.50");
expect(result.count).toBe(3);
});
it("builds cashflow buckets for requested months", async () => {
const { service, prisma } = createService();
prisma.transactionRaw.findMany.mockResolvedValue([
{ amount: -200, date: new Date("2025-01-12") },
{ amount: 50, date: new Date("2025-02-03") }
]);
const result = await service.cashflow("user_1", 3);
expect(result).toHaveLength(3);
expect(result.some((row) => row.month.endsWith("-01"))).toBe(true);
});
it("returns merchant insights sorted by spend", async () => {
const { service, prisma } = createService();
prisma.transactionRaw.findMany.mockResolvedValue([
{ description: "Coffee Bar", amount: 12.5 },
{ description: "Coffee Bar", amount: 7.5 },
{ description: "Grocer", amount: 40 },
{ description: "Refund", amount: -10 }
]);
const result = await service.merchantInsights("user_1", 2);
expect(result[0].merchant).toBe("Grocer");
expect(result[0].total).toBe("40.00");
expect(result[1].merchant).toBe("Coffee Bar");
expect(result[1].count).toBe(2);
});
it("creates manual transaction and derived fields", async () => {
const { service, prisma } = createService();
prisma.account.findFirst.mockResolvedValue({ id: "acct_1", userId: "user_1" });
prisma.transactionRaw.create.mockResolvedValue({ id: "tx_1" });
prisma.transactionDerived.create.mockResolvedValue({ id: "derived_1" });
const result = await service.createManualTransaction({
userId: "user_1",
accountId: "acct_1",
date: "2025-01-15",
description: "Manual payment",
amount: 123.45,
category: "Operations",
note: "Test note",
hidden: false
});
expect(result).toEqual({ id: "tx_1" });
expect(prisma.transactionRaw.create).toHaveBeenCalled();
expect(prisma.transactionDerived.create).toHaveBeenCalled();
});
it("returns null when no account is available for manual transaction", async () => {
const { service, prisma } = createService();
prisma.account.findFirst.mockResolvedValue(null);
const result = await service.createManualTransaction({
userId: "user_1",
date: "2025-01-15",
description: "Manual payment",
amount: 10
});
expect(result).toBeNull();
expect(prisma.transactionRaw.create).not.toHaveBeenCalled();
});
});

20
test/utils/mock-prisma.ts Normal file
View File

@ -0,0 +1,20 @@
/// <reference types="jest" />
export const createPrismaMock = () => ({
account: {
findFirst: jest.fn(),
findMany: jest.fn()
},
transactionRaw: {
findMany: jest.fn(),
create: jest.fn(),
upsert: jest.fn()
},
transactionDerived: {
create: jest.fn(),
upsert: jest.fn()
},
exportLog: {
create: jest.fn()
}
});

4
tsconfig.build.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}

19
tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strict": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "frontend", "src/prisma/**"]
}