diff --git a/package-lock.json b/package-lock.json index a8c1272..df8716f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "passport-custom": "^1.1.1", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", - "pg": "^8.15.5", + "pg": "^8.15.6", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "starknet": "^6.24.1", @@ -64,6 +64,7 @@ "ts-loader": "^9.5.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", + "typeorm-ts-node-commonjs": "^0.3.20", "typescript": "^5.7.3", "typescript-eslint": "^8.20.0" } @@ -1932,11 +1933,14 @@ } }, "node_modules/@nestjs/common": { - "version": "11.0.12", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.0.12.tgz", - "integrity": "sha512-6PXxmDe2iYmb57xacnxzpW1NAxRZ7Gf+acMT7/hmRB/4KpZiFU/cNvLWwgbM2BL5QSzQulOwY6ny5bbKnPpB+A==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.0.tgz", + "integrity": "sha512-8MrajltjtIN6eW9cTpv+1IZogqz2Zsrc8YDt0LwQPUq8cSq0j50DETdQpPsNMeib+p9avkV41+NrzGk1z2o5Wg==", + "license": "MIT", "dependencies": { + "file-type": "20.4.1", "iterare": "1.2.1", + "load-esm": "1.0.2", "tslib": "2.8.1", "uid": "2.0.2" }, @@ -1959,6 +1963,54 @@ } } }, + "node_modules/@nestjs/common/node_modules/file-type": { + "version": "20.4.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-20.4.1.tgz", + "integrity": "sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.2.6", + "strtok3": "^10.2.0", + "token-types": "^6.0.0", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/@nestjs/common/node_modules/peek-readable": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-7.0.0.tgz", + "integrity": "sha512-nri2TO5JE3/mRryik9LlHFT53cgHfRK0Lt0BAZQXku/AW3E6XLt2GaY8siWi7dvW/m1z0ecn+J+bpDa9ZN3IsQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@nestjs/common/node_modules/strtok3": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.2.2.tgz", + "integrity": "sha512-Xt18+h4s7Z8xyZ0tmBoRmzxcop97R4BAh+dXouUDCYn+Em+1P3qpkUfI5ueWLT8ynC5hZ+q4iPEmGG1urvQGBg==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@nestjs/config": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.2.tgz", @@ -2475,11 +2527,28 @@ "node": ">=14.16" } }, + "node_modules/@tokenizer/inflate": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", + "integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fflate": "^0.8.2", + "token-types": "^6.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@tokenizer/token": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", - "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", - "dev": true + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==" }, "node_modules/@tsconfig/node10": { "version": "1.0.11", @@ -5626,6 +5695,12 @@ "tough-cookie": "^4.0.0" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -7654,6 +7729,25 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, + "node_modules/load-esm": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.2.tgz", + "integrity": "sha512-nVAvWk/jeyrWyXEAs84mpQCYccxRqgKY4OznLuJhJCa0XsPSfdOIr2zvBZEj3IHEHbX97jjscKRRV539bW0Gpw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "engines": { + "node": ">=13.2.0" + } + }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -8643,13 +8737,13 @@ "dev": true }, "node_modules/pg": { - "version": "8.15.5", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.15.5.tgz", - "integrity": "sha512-EpAhHFQc+aH9VfeffWIVC+XXk6lmAhS9W1FxtxcPXs94yxhrI1I6w/zkWfIOII/OkBv3Be04X3xMOj0kQ78l6w==", + "version": "8.15.6", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.15.6.tgz", + "integrity": "sha512-yvao7YI3GdmmrslNVsZgx9PfntfWrnXwtR+K/DjI0I/sTKif4Z623um+sjVZ1hk5670B+ODjvHDAckKdjmPTsg==", "license": "MIT", "dependencies": { "pg-connection-string": "^2.8.5", - "pg-pool": "^3.9.5", + "pg-pool": "^3.9.6", "pg-protocol": "^1.9.5", "pg-types": "^2.1.0", "pgpass": "1.x" @@ -8691,9 +8785,9 @@ } }, "node_modules/pg-pool": { - "version": "3.9.5", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.9.5.tgz", - "integrity": "sha512-DxyAlOgvUzRFpFAZjbCc8fUfG7BcETDHgepFPf724B0i08k9PAiZV1tkGGgQIL0jbMEuR9jW1YN7eX+WgXxCsQ==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.9.6.tgz", + "integrity": "sha512-rFen0G7adh1YmgvrmE5IPIqbb+IgEzENUm+tzm6MLLDSlPRoZVhzU1WdML9PV2W5GOdRA9qBKURlbt1OsXOsPw==", "license": "MIT", "peerDependencies": { "pg": ">=8.0" @@ -10470,7 +10564,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.0.0.tgz", "integrity": "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA==", - "dev": true, "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" @@ -10617,6 +10710,7 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, + "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -10854,6 +10948,16 @@ } } }, + "node_modules/typeorm-ts-node-commonjs": { + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/typeorm-ts-node-commonjs/-/typeorm-ts-node-commonjs-0.3.20.tgz", + "integrity": "sha512-lXjve7w7OcF3s5+dHnCsrBjUTukpVeiS0bDe5KDXWcDx8TyRW0GTTg9kjWgHzFgHgBIBBu4WGXM0iuGpEgaV9g==", + "dev": true, + "license": "UNLICENSED", + "bin": { + "typeorm-ts-node-commonjs": "wrapper.sh" + } + }, "node_modules/typeorm/node_modules/ansis": { "version": "3.17.0", "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz", @@ -11011,7 +11115,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.4.0.tgz", "integrity": "sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ==", - "dev": true, "engines": { "node": ">=18" }, diff --git a/package.json b/package.json index e179ed6..ace989f 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "passport-custom": "^1.1.1", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", - "pg": "^8.15.5", + "pg": "^8.15.6", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "starknet": "^6.24.1", @@ -78,6 +78,7 @@ "ts-loader": "^9.5.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", + "typeorm-ts-node-commonjs": "^0.3.20", "typescript": "^5.7.3", "typescript-eslint": "^8.20.0" }, diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 1240415..c6ce7ad 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -1,18 +1,31 @@ -import { registerAs } from '@nestjs/config'; +import dotenv from 'dotenv'; +dotenv.config(); -export default registerAs('app', () => ({ +// Helper to parse integers robustly +const toInt = (value: string | undefined, fallback: number): number => { + if (value === undefined) return fallback; + const num = parseInt(value, 10); + return Number.isNaN(num) ? fallback : num; +}; + +// Export a factory function +export default () => ({ env: process.env.NODE_ENV || 'development', - name: process.env.APP_NAME || 'MyApp', - port: parseInt(process.env.APP_PORT || '3000', 10), + name: process.env.APP_NAME || 'budget-chain-backend', + port: toInt(process.env.PORT, 3000), database: { host: process.env.DATABASE_HOST || 'localhost', - port: parseInt(process.env.DATABASE_PORT || '5432', 10), + port: toInt(process.env.DATABASE_PORT, 5432), user: process.env.DATABASE_USER || 'postgres', - password: process.env.DATABASE_PASSWORD || 'secret', - name: process.env.DATABASE_NAME || 'mydatabase', + password: process.env.DATABASE_PASSWORD || 'password', + name: process.env.DATABASE_NAME || 'budgetchain', }, jwt: { - secret: process.env.JWT_SECRET || 'default_jwt_secret', - expiresIn: process.env.JWT_EXPIRES_IN || '3600s', + secret: + process.env.JWT_SECRET || + (() => { + throw new Error('JWT_SECRET is not defined'); + })(), + expiresIn: process.env.JWT_EXPIRES_IN || '1h', }, -})); +}); diff --git a/src/config/data-source.ts b/src/config/data-source.ts new file mode 100644 index 0000000..1d7eb74 --- /dev/null +++ b/src/config/data-source.ts @@ -0,0 +1,45 @@ +// import { DataSource } from 'typeorm'; +// import { ConfigService } from '@nestjs/config'; + +// const configService = new ConfigService(); + +// export default new DataSource({ +// type: 'postgres', +// host: configService.get('database').host as string, +// port: configService.get('database').port as number, +// username: configService.get('database').user as string, +// password: configService.get('database').password as string, +// database: configService.get('database').name as string, +// entities: ['src/modules/**/*.entity.ts'], +// migrations: ['src/migrations/*.ts'], +// synchronize: false, // Use migrations instead of auto-sync +// }); + +import { DataSource } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; + +// Define the structure of the database configuration +interface DatabaseConfig { + host: string; + port: number; + user: string; + password: string; + name: string; +} + +const configService = new ConfigService(); + +// Retrieve the database configuration with a type assertion +const database = configService.get('database') as DatabaseConfig; + +export default new DataSource({ + type: 'postgres', + host: database.host, + port: database.port, + username: database.user, + password: database.password, + database: database.name, + entities: ['src/modules/**/*.entity.ts'], + migrations: ['src/migrations/*.ts'], + synchronize: false, // Use migrations instead of auto-sync +}); diff --git a/src/config/logging.service.ts b/src/config/logging.service.ts index 63badb3..1799dd6 100644 --- a/src/config/logging.service.ts +++ b/src/config/logging.service.ts @@ -8,47 +8,44 @@ export class LoggingService implements LoggerService { this.context = context; } - log(message: any, ...optionalParams: any[]) { + log(message: unknown, ...optionalParams: unknown[]) { this.printMessage('log', message, ...optionalParams); } - error(message: any, ...optionalParams: any[]) { + error(message: unknown, ...optionalParams: unknown[]) { this.printMessage('error', message, ...optionalParams); } - warn(message: any, ...optionalParams: any[]) { + warn(message: unknown, ...optionalParams: unknown[]) { this.printMessage('warn', message, ...optionalParams); } - debug(message: any, ...optionalParams: any[]) { + debug(message: unknown, ...optionalParams: unknown[]) { this.printMessage('debug', message, ...optionalParams); } - verbose(message: any, ...optionalParams: any[]) { + verbose(message: unknown, ...optionalParams: unknown[]) { this.printMessage('verbose', message, ...optionalParams); } - private printMessage(level: string, message: any, ...optionalParams: any[]) { + private printMessage( + level: 'log' | 'error' | 'warn' | 'debug' | 'verbose', + message: unknown, + ...optionalParams: unknown[] + ) { const timestamp = new Date().toISOString(); - let logMessage = `[${timestamp}] ${level.toUpperCase()} `; - - if (this.context) { - logMessage += `[${this.context}] `; - } - - logMessage += message; - - if (optionalParams.length > 0) { - logMessage += ` ${optionalParams.join(' ')}`; - } - - if (process.env.NODE_ENV === 'production') { - // In production, you might want to send logs to a centralized logging system - // (e.g., using Winston, Morgan, or a cloud logging service) - console.log(logMessage); // Or send to your logging service - } else { - // In development, you can simply print to the console - console.log(logMessage); - } + const contextTag = this.context ? `[${this.context}] ` : ''; + // ensure message is a string + const main = + typeof message === 'string' ? message : JSON.stringify(message, null, 2); + // turn params into strings too + const rest = optionalParams + .map(p => (typeof p === 'string' ? p : JSON.stringify(p, null, 2))) + .join(' '); + const payload = rest ? `${main} ${rest}` : main; + const output = `[${timestamp}] ${level.toUpperCase()} ${contextTag}${payload}`; + + // console.log only sees a single string, so no spread of any[] + console.log(output); } } diff --git a/src/modules/treasury/controllers/transaction.controller.ts b/src/modules/treasury/controllers/transaction.controller.ts new file mode 100644 index 0000000..d00ce48 --- /dev/null +++ b/src/modules/treasury/controllers/transaction.controller.ts @@ -0,0 +1,124 @@ +import { + Controller, + Post, + Get, + Put, + Delete, + Param, + Body, + Query, + UseGuards, + Request, +} from '@nestjs/common'; +import { TransactionService } from '../services/transaction.service'; +import { RolesGuard } from '../../../shared/guards/roles.guard'; +import { Roles } from '../../../shared/decorators/roles.decorator'; +import { UserRole, User } from '../../user/entities/user.entity'; + +// Import Metadata from transaction.service.ts +interface Metadata { + [key: string]: unknown; +} + +@Controller('transactions') +@UseGuards(RolesGuard) +export class TransactionController { + constructor(private readonly transactionService: TransactionService) {} + + @Post() + @Roles(UserRole.ADMIN) + async createTransaction( + @Request() req: { user: User }, + @Body() + body: { + date: Date; + description: string; + category: string; + ledgerEntries: { + accountId: number; + type: 'debit' | 'credit'; + amount: number; + }[]; + metadata?: Metadata; + } + ) { + return this.transactionService.createTransaction( + req.user, + body.date, + body.description, + body.category, + body.ledgerEntries, + body.metadata + ); + } + + @Put(':id') + @Roles(UserRole.ADMIN) + async updateTransaction( + @Param('id') id: number, + @Request() req: { user: User }, + @Body() + body: { + date?: Date; + description?: string; + category?: string; + ledgerEntries?: { + accountId: number; + type: 'debit' | 'credit'; + amount: number; + }[]; + metadata?: Metadata; + } + ) { + return this.transactionService.updateTransaction( + id, + req.user, + body.date, + body.description, + body.category, + body.ledgerEntries, + body.metadata + ); + } + + @Delete(':id') + @Roles(UserRole.ADMIN) + async deleteTransaction( + @Param('id') id: number, + @Request() req: { user: User } + ) { + await this.transactionService.deleteTransaction(id, req.user); + return { message: 'Transaction deleted successfully' }; + } + + @Get() + @Roles(UserRole.USER, UserRole.ADMIN) + async getTransactions( + @Query('startDate') startDate?: Date, + @Query('endDate') endDate?: Date, + @Query('category') category?: string, + @Query('accountId') accountId?: number, + @Query('description') description?: string, + @Query('page') page = 1, + @Query('limit') limit = 10 + ) { + const filters = { startDate, endDate, category, accountId, description }; + return this.transactionService.findTransactions(filters, page, limit); + } + + @Post('reconcile') + @Roles(UserRole.ADMIN) + async reconcileTransactions() { + await this.transactionService.reconcileTransactions(); + return { message: 'Reconciliation completed' }; + } + + @Get('report') + @Roles(UserRole.USER, UserRole.ADMIN) + async generateReport( + @Query('startDate') startDate: Date, + @Query('endDate') endDate: Date + ) { + return this.transactionService.generateReport(startDate, endDate); + } +} diff --git a/src/modules/treasury/entities/account.entity.ts b/src/modules/treasury/entities/account.entity.ts new file mode 100644 index 0000000..e6232ed --- /dev/null +++ b/src/modules/treasury/entities/account.entity.ts @@ -0,0 +1,13 @@ +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; + +@Entity('accounts') +export class Account { + @PrimaryGeneratedColumn() + id: number; + + @Column() + name: string; + + @Column('decimal', { precision: 15, scale: 2, default: 0 }) + balance: number; +} diff --git a/src/modules/treasury/entities/audit-log.entity.ts b/src/modules/treasury/entities/audit-log.entity.ts new file mode 100644 index 0000000..900e505 --- /dev/null +++ b/src/modules/treasury/entities/audit-log.entity.ts @@ -0,0 +1,32 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, +} from 'typeorm'; +import { User } from '../../user/entities/user.entity'; + +@Entity('audit_logs') +export class AuditLog { + @PrimaryGeneratedColumn() + id: number; + + @Column() + entityName: string; // e.g., 'Transaction' + + @Column() + entityId: number; + + @Column({ type: 'enum', enum: ['create', 'update', 'delete'] }) + action: 'create' | 'update' | 'delete'; + + @Column({ type: 'json' }) + changes: any; // Stores before/after values + + @ManyToOne(() => User) + user: User; + + @CreateDateColumn() + timestamp: Date; +} diff --git a/src/modules/treasury/entities/ledger-entry.entity.ts b/src/modules/treasury/entities/ledger-entry.entity.ts new file mode 100644 index 0000000..aeb664d --- /dev/null +++ b/src/modules/treasury/entities/ledger-entry.entity.ts @@ -0,0 +1,21 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm'; +import { Transaction } from './transaction.entity'; +import { Account } from './account.entity'; + +@Entity('ledger_entries') +export class LedgerEntry { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => Transaction, transaction => transaction.ledgerEntries) + transaction: Transaction; + + @ManyToOne(() => Account) + account: Account; + + @Column({ type: 'enum', enum: ['debit', 'credit'] }) + type: 'debit' | 'credit'; + + @Column('decimal', { precision: 15, scale: 2 }) + amount: number; +} diff --git a/src/modules/treasury/entities/transaction.entity.ts b/src/modules/treasury/entities/transaction.entity.ts new file mode 100644 index 0000000..f5e8f38 --- /dev/null +++ b/src/modules/treasury/entities/transaction.entity.ts @@ -0,0 +1,44 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + OneToMany, + CreateDateColumn, + Index, +} from 'typeorm'; +import { User } from '../../user/entities/user.entity'; +import { LedgerEntry } from './ledger-entry.entity'; + +@Entity('transactions') +@Index(['date', 'category']) // Optimize search and filtering +export class Transaction { + @PrimaryGeneratedColumn() + id: number; + + @Column() + date: Date; + + @Column() + description: string; + + @Column() + category: string; // e.g., 'income', 'expense', 'transfer' + + @ManyToOne(() => User) + createdBy: User; + + @CreateDateColumn() + createdAt: Date; + + @OneToMany(() => LedgerEntry, ledgerEntry => ledgerEntry.transaction, { + cascade: true, + }) + ledgerEntries: LedgerEntry[]; + + @Column({ default: false }) + reconciled: boolean; + + @Column({ type: 'json', nullable: true }) + metadata: any; // Flexible key-value pairs +} diff --git a/src/modules/treasury/services/transaction.service.ts b/src/modules/treasury/services/transaction.service.ts new file mode 100644 index 0000000..21ca050 --- /dev/null +++ b/src/modules/treasury/services/transaction.service.ts @@ -0,0 +1,276 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { Transaction } from '../entities/transaction.entity'; +import { LedgerEntry } from '../entities/ledger-entry.entity'; +import { AuditLog } from '../entities/audit-log.entity'; +import { Account } from '../entities/account.entity'; +import { User } from '../../user/entities/user.entity'; + +// Interface for metadata to replace `any` +interface Metadata { + [key: string]: unknown; +} + +// Interface for raw query results in generateReport +interface ReportRow { + category: string; + totalAmount: string; // Raw query returns string for numeric aggregates +} + +@Injectable() +export class TransactionService { + constructor( + @InjectRepository(Transaction) + private transactionRepository: Repository, + @InjectRepository(LedgerEntry) + private ledgerEntryRepository: Repository, + @InjectRepository(AuditLog) + private auditLogRepository: Repository, + @InjectRepository(Account) + private accountRepository: Repository + ) {} + + async createTransaction( + user: User, + date: Date, + description: string, + category: string, + ledgerEntriesData: { + accountId: number; + type: 'debit' | 'credit'; + amount: number; + }[], + metadata?: Metadata + ): Promise { + const debitTotal = ledgerEntriesData + .filter(e => e.type === 'debit') + .reduce((sum, e) => sum + e.amount, 0); + const creditTotal = ledgerEntriesData + .filter(e => e.type === 'credit') + .reduce((sum, e) => sum + e.amount, 0); + if (debitTotal !== creditTotal) { + throw new Error('Debits must equal credits for double-entry bookkeeping'); + } + + const transaction = this.transactionRepository.create({ + date, + description, + category, + createdBy: user, + metadata, + ledgerEntries: [], + }); + + for (const entryData of ledgerEntriesData) { + const account = await this.accountRepository.findOneOrFail({ + where: { id: entryData.accountId }, + }); + const ledgerEntry = this.ledgerEntryRepository.create({ + account, + type: entryData.type, + amount: entryData.amount, + }); + transaction.ledgerEntries.push(ledgerEntry); + } + + const savedTransaction = await this.transactionRepository.save(transaction); + await this.logAudit(user, 'Transaction', savedTransaction.id, 'create', { + metadata, + }); + return savedTransaction; + } + + async updateTransaction( + id: number, + user: User, + date?: Date, + description?: string, + category?: string, + ledgerEntriesData?: { + accountId: number; + type: 'debit' | 'credit'; + amount: number; + }[], + metadata?: Metadata + ): Promise { + const transaction = await this.transactionRepository.findOneOrFail({ + where: { id }, + relations: ['ledgerEntries', 'ledgerEntries.account'], + }); + const oldData = { ...transaction }; + + if (date) transaction.date = date; + if (description) transaction.description = description; + if (category) transaction.category = category; + if (metadata) transaction.metadata = metadata; + + if (ledgerEntriesData) { + const debitTotal = ledgerEntriesData + .filter(e => e.type === 'debit') + .reduce((sum, e) => sum + e.amount, 0); + const creditTotal = ledgerEntriesData + .filter(e => e.type === 'credit') + .reduce((sum, e) => sum + e.amount, 0); + if (debitTotal !== creditTotal) { + throw new Error( + 'Debits must equal credits for double-entry bookkeeping' + ); + } + + await this.ledgerEntryRepository.remove(transaction.ledgerEntries); + transaction.ledgerEntries = []; + for (const entryData of ledgerEntriesData) { + const account = await this.accountRepository.findOneOrFail({ + where: { id: entryData.accountId }, + }); + const ledgerEntry = this.ledgerEntryRepository.create({ + account, + type: entryData.type, + amount: entryData.amount, + }); + transaction.ledgerEntries.push(ledgerEntry); + } + } + + const updatedTransaction = + await this.transactionRepository.save(transaction); + await this.logAudit(user, 'Transaction', id, 'update', { + old: oldData, + new: updatedTransaction, + }); + return updatedTransaction; + } + + async deleteTransaction(id: number, user: User): Promise { + const transaction = await this.transactionRepository.findOneOrFail({ + where: { id }, + }); + await this.transactionRepository.remove(transaction); + await this.logAudit(user, 'Transaction', id, 'delete', { + deleted: transaction, + }); + } + + async findTransactions( + filters: { + startDate?: Date; + endDate?: Date; + category?: string; + accountId?: number; + description?: string; + }, + page: number = 1, + limit: number = 10 + ): Promise<{ transactions: Transaction[]; total: number }> { + const query = this.transactionRepository + .createQueryBuilder('transaction') + .leftJoinAndSelect('transaction.ledgerEntries', 'ledgerEntry') + .leftJoinAndSelect('ledgerEntry.account', 'account'); + + if (filters.startDate || filters.endDate) { + query.andWhere('transaction.date BETWEEN :startDate AND :endDate', { + startDate: filters.startDate || new Date(0), + endDate: filters.endDate || new Date(), + }); + } + if (filters.category) { + query.andWhere('transaction.category = :category', { + category: filters.category, + }); + } + if (filters.accountId) { + query.andWhere('ledgerEntry.accountId = :accountId', { + accountId: filters.accountId, + }); + } + if (filters.description) { + query.andWhere('transaction.description LIKE :description', { + description: `%${filters.description}%`, + }); + } + + const [transactions, total] = await query + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + + return { transactions, total }; + } + + async reconcileTransactions(): Promise { + const accounts = await this.accountRepository.find(); + for (const account of accounts) { + const ledgerEntries = await this.ledgerEntryRepository.find({ + where: { account: { id: account.id } }, + relations: ['transaction'], + }); + const debitSum = ledgerEntries + .filter(e => e.type === 'debit') + .reduce((sum, e) => sum + Number(e.amount), 0); + const creditSum = ledgerEntries + .filter(e => e.type === 'credit') + .reduce((sum, e) => sum + Number(e.amount), 0); + const calculatedBalance = creditSum - debitSum; + + if (calculatedBalance !== Number(account.balance)) { + const transactionIds = ledgerEntries.map(e => e.transaction.id); + await this.transactionRepository.update( + { id: In(transactionIds) }, + { reconciled: false } + ); + account.balance = calculatedBalance; + await this.accountRepository.save(account); // Update balance per Issue #19 integration + } else { + const transactionIds = ledgerEntries.map(e => e.transaction.id); + await this.transactionRepository.update( + { id: In(transactionIds) }, + { reconciled: true } + ); + } + } + } + + async generateReport( + startDate: Date, + endDate: Date + ): Promise<{ category: string; totalAmount: number }[]> { + const results = await this.transactionRepository + .createQueryBuilder('transaction') + .select('transaction.category', 'category') + .addSelect( + "SUM(CASE WHEN ledgerEntry.type = 'credit' THEN ledgerEntry.amount ELSE -ledgerEntry.amount END)", + 'totalAmount' + ) + .leftJoin('transaction.ledgerEntries', 'ledgerEntry') + .where('transaction.date BETWEEN :startDate AND :endDate', { + startDate, + endDate, + }) + .groupBy('transaction.category') + .getRawMany(); + + return results.map(row => ({ + category: row.category, + totalAmount: parseFloat(row.totalAmount), + })); + } + + private async logAudit( + user: User, + entityName: string, + entityId: number, + action: string, + changes: Record + ): Promise { + const auditLog = this.auditLogRepository.create({ + entityName, + entityId, + action, + changes, + user, + timestamp: new Date(), + } as AuditLog); + await this.auditLogRepository.save(auditLog); + } +} diff --git a/src/modules/treasury/treasury.module.ts b/src/modules/treasury/treasury.module.ts index fd7736e..6388fa8 100644 --- a/src/modules/treasury/treasury.module.ts +++ b/src/modules/treasury/treasury.module.ts @@ -1,95 +1,17 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; - -// Repositories -import { TreasuryRepositoryImpl } from '../repository/treasury.repository'; -import { AssetRepositoryImpl } from '../repository/asset.repository'; -import { TransactionRepositoryImpl } from '../repository/transaction.repository'; -import { BudgetRepositoryImpl } from '../repository/budget.repository'; -import { AllocationRepositoryImpl } from '../repository/allocation.repository'; -import { RiskAssessmentRepositoryImpl } from '../repository/rist_assessment.repository'; -import { AuditLogRepositoryImpl } from '../repository/audit_log.repository'; - -import { ConfigModule } from '../../config/config.module'; - -// Entities -import { Asset } from './entities/asset.entity'; -import { AssetTransaction } from './entities/asset-transaction.entity'; -import { Budget } from './entities/budget.entity'; -import { Allocation } from './entities/allocation.entity'; -import { AllocationTransaction } from './entities/allocation-transaction.entity'; -import { Treasury } from '../user/entities/treasury.entity'; -import { Transaction } from '../user/entities/transaction.entity'; -import { RiskAssessment } from '../user/entities/risk_assessment.entity'; -import { AuditLog } from '../user/entities/audit_log.entity'; - -// Services -import { TreasuryService } from './services/treasury.service'; -import { TreasuryAssetService } from './services/treasury-asset.service'; -import { TreasuryTransactionService } from './services/treasury-transaction.service'; -import { TreasuryBudgetService } from './services/treasury-budget.service'; -import { TreasuryAllocationService } from './services/treasury-allocation.service'; - -// Controllers -import { TreasuryController } from './controllers/treasury.controller'; -import { TreasuryAssetController } from './controllers/treasury-asset.controller'; -import { TreasuryTransactionController } from './controllers/treasury-transaction.controller'; -import { TreasuryBudgetController } from './controllers/treasury-budget.controller'; -import { TreasuryAllocationController } from './controllers/treasury-allocation.controller'; - -// Blockchain module for blockchain interactions -import { BlockchainModule } from '../blockchain/blockchain.module'; +import { Transaction } from './entities/transaction.entity'; // Updated path +import { LedgerEntry } from './entities/ledger-entry.entity'; // Added import +import { AuditLog } from './entities/audit-log.entity'; // Updated path +import { Account } from './entities/account.entity'; // Added import +import { TransactionService } from './services/transaction.service'; +import { TransactionController } from './controllers/transaction.controller'; @Module({ imports: [ - TypeOrmModule.forFeature([ - Asset, - AssetTransaction, - Budget, - Allocation, - AllocationTransaction, - Treasury, - Transaction, - RiskAssessment, - AuditLog, - ]), - ConfigModule, - BlockchainModule, - ], - controllers: [ - TreasuryController, - TreasuryAssetController, - TreasuryTransactionController, - TreasuryBudgetController, - TreasuryAllocationController, - ], - providers: [ - TreasuryService, - TreasuryAssetService, - TreasuryTransactionService, - TreasuryBudgetService, - TreasuryAllocationService, - TreasuryRepositoryImpl, - AssetRepositoryImpl, - TransactionRepositoryImpl, - BudgetRepositoryImpl, - AllocationRepositoryImpl, - RiskAssessmentRepositoryImpl, - AuditLogRepositoryImpl, - ], - exports: [ - TreasuryService, - TreasuryAssetService, - TreasuryTransactionService, - TreasuryBudgetService, - TreasuryAllocationService, - TreasuryRepositoryImpl, - AssetRepositoryImpl, - TransactionRepositoryImpl, - BudgetRepositoryImpl, - AllocationRepositoryImpl, - RiskAssessmentRepositoryImpl, - AuditLogRepositoryImpl, + TypeOrmModule.forFeature([Transaction, LedgerEntry, AuditLog, Account]), ], + providers: [TransactionService], + controllers: [TransactionController], }) -export class TreasuryModule {} +export class TreasuryModule {} \ No newline at end of file