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