first commit
This commit is contained in:
commit
d1a5a1d1a0
11
.env.example
Normal file
11
.env.example
Normal 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
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
29
data/storage.json
Normal file
29
data/storage.json
Normal 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
24
docs/diagrams.md
Normal 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
55
docs/feature-list.md
Normal 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 (W‑2/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
26
docs/test-cases.md
Normal 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
12
jest.config.ts
Normal 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
4
nest-cli.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
8312
package-lock.json
generated
Normal file
8312
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
package.json
Normal file
41
package.json
Normal 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
124
prisma/schema.prisma
Normal 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])
|
||||
}
|
||||
35
src/accounts/accounts.controller.ts
Normal file
35
src/accounts/accounts.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
src/accounts/accounts.module.ts
Normal file
11
src/accounts/accounts.module.ts
Normal 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 {}
|
||||
45
src/accounts/accounts.service.ts
Normal file
45
src/accounts/accounts.service.ts
Normal 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
27
src/app.module.ts
Normal 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 {}
|
||||
42
src/auth/auth.controller.ts
Normal file
42
src/auth/auth.controller.ts
Normal 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
16
src/auth/auth.module.ts
Normal 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
146
src/auth/auth.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
4
src/auth/dto/login.dto.ts
Normal file
4
src/auth/dto/login.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export type LoginDto = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
4
src/auth/dto/register.dto.ts
Normal file
4
src/auth/dto/register.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export type RegisterDto = {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
11
src/auth/dto/update-profile.dto.ts
Normal file
11
src/auth/dto/update-profile.dto.ts
Normal 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
21
src/common/response.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
20
src/exports/exports.controller.ts
Normal file
20
src/exports/exports.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
src/exports/exports.module.ts
Normal file
9
src/exports/exports.module.ts
Normal 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 {}
|
||||
99
src/exports/exports.service.ts
Normal file
99
src/exports/exports.service.ts
Normal 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
16
src/main.ts
Normal 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();
|
||||
23
src/plaid/plaid.controller.ts
Normal file
23
src/plaid/plaid.controller.ts
Normal 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
10
src/plaid/plaid.module.ts
Normal 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
226
src/plaid/plaid.service.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/prisma/prisma.module.ts
Normal file
9
src/prisma/prisma.module.ts
Normal 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 {}
|
||||
13
src/prisma/prisma.service.ts
Normal file
13
src/prisma/prisma.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
38
src/rules/rules.controller.ts
Normal file
38
src/rules/rules.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
src/rules/rules.module.ts
Normal file
9
src/rules/rules.module.ts
Normal 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
176
src/rules/rules.service.ts
Normal 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)
|
||||
}));
|
||||
}
|
||||
}
|
||||
9
src/storage/storage.module.ts
Normal file
9
src/storage/storage.module.ts
Normal 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 {}
|
||||
166
src/storage/storage.service.ts
Normal file
166
src/storage/storage.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
9
src/supabase/supabase.module.ts
Normal file
9
src/supabase/supabase.module.ts
Normal 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 {}
|
||||
33
src/supabase/supabase.service.ts
Normal file
33
src/supabase/supabase.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
6
src/tax/dto/create-return.dto.ts
Normal file
6
src/tax/dto/create-return.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export type CreateTaxReturnDto = {
|
||||
userId: string;
|
||||
taxYear: number;
|
||||
filingType: "individual" | "business";
|
||||
jurisdictions: string[];
|
||||
};
|
||||
4
src/tax/dto/update-return.dto.ts
Normal file
4
src/tax/dto/update-return.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export type UpdateTaxReturnDto = {
|
||||
status?: "draft" | "ready" | "exported";
|
||||
summary?: Record<string, unknown>;
|
||||
};
|
||||
43
src/tax/tax.controller.ts
Normal file
43
src/tax/tax.controller.ts
Normal 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
9
src/tax/tax.module.ts
Normal 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
84
src/tax/tax.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
54
src/transactions/auto-sync.service.ts
Normal file
54
src/transactions/auto-sync.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/transactions/dto/create-manual-transaction.dto.ts
Normal file
10
src/transactions/dto/create-manual-transaction.dto.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export type CreateManualTransactionDto = {
|
||||
userId: string;
|
||||
accountId?: string;
|
||||
date: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
category?: string;
|
||||
note?: string;
|
||||
hidden?: boolean;
|
||||
};
|
||||
5
src/transactions/dto/update-derived.dto.ts
Normal file
5
src/transactions/dto/update-derived.dto.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export type UpdateDerivedDto = {
|
||||
userCategory?: string;
|
||||
userNotes?: string;
|
||||
isHidden?: boolean;
|
||||
};
|
||||
86
src/transactions/transactions.controller.ts
Normal file
86
src/transactions/transactions.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
src/transactions/transactions.module.ts
Normal file
12
src/transactions/transactions.module.ts
Normal 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 {}
|
||||
265
src/transactions/transactions.service.ts
Normal file
265
src/transactions/transactions.service.ts
Normal 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
|
||||
}));
|
||||
}
|
||||
}
|
||||
39
test/exports.service.spec.ts
Normal file
39
test/exports.service.spec.ts
Normal 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 })
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
65
test/transactions.controller.spec.ts
Normal file
65
test/transactions.controller.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
92
test/transactions.service.spec.ts
Normal file
92
test/transactions.service.spec.ts
Normal 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
20
test/utils/mock-prisma.ts
Normal 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
4
tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||
}
|
||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal 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/**"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user