chore: initialize NestJS project with Docker and environment configuration

Add project scaffolding and development infrastructure:
- Add environment configuration files (.env.development, .env.example) with database, JWT, security, AI integration, logging, and CORS settings
- Add .gitignore to exclude build artifacts, logs, IDE files, and environment variables
- Add .prettierrc with single quotes and trailing commas configuration
- Add multi-stage Dockerfile with development, build, and production stages
- Ad
This commit is contained in:
Заид Омар Медхат 2025-12-13 15:45:08 +05:00
commit 33602d0fe9
108 changed files with 19954 additions and 0 deletions

44
.env.development Normal file
View File

@ -0,0 +1,44 @@
# Application
NODE_ENV=development
PORT=3000
FRONTEND_URL=http://localhost:5173
# Database
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=finance_user
DB_PASSWORD=dev_password_123
DB_NAME=finance_app
# JWT Security
JWT_SECRET=dev_jwt_secret_key_minimum_32_characters_long_here
JWT_REFRESH_SECRET=dev_refresh_secret_key_minimum_32_characters_long
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d
# Cookie Settings
COOKIE_DOMAIN=localhost
COOKIE_SECURE=false
# Rate Limiting
RATE_LIMIT_WINDOW=15
RATE_LIMIT_MAX=100
LOGIN_RATE_LIMIT_MAX=5
# Security
BCRYPT_SALT_ROUNDS=12
MAX_LOGIN_ATTEMPTS=10
LOCKOUT_DURATION_MINUTES=30
# AI Integration (Phase 2)
DEEPSEEK_API_KEY=
OPENROUTER_API_KEY=
AI_SERVICE_URL=http://localhost:8000
AI_ENABLED=false
# Logging
LOG_LEVEL=debug
LOG_FORMAT=pretty
# CORS
CORS_ORIGINS=http://localhost:5173,http://localhost:3001

44
.env.example Normal file
View File

@ -0,0 +1,44 @@
# Application
NODE_ENV=development
PORT=3000
FRONTEND_URL=http://localhost:5173
# Database
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=finance_user
DB_PASSWORD=secure_password_here
DB_NAME=finance_app
# JWT Security
JWT_SECRET=your_jwt_secret_key_here_minimum_32_characters_long
JWT_REFRESH_SECRET=your_refresh_secret_key_here_minimum_32_characters_long
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d
# Cookie Settings
COOKIE_DOMAIN=localhost
COOKIE_SECURE=false
# Rate Limiting
RATE_LIMIT_WINDOW=15
RATE_LIMIT_MAX=100
LOGIN_RATE_LIMIT_MAX=5
# Security
BCRYPT_SALT_ROUNDS=12
MAX_LOGIN_ATTEMPTS=10
LOCKOUT_DURATION_MINUTES=30
# AI Integration (Phase 2 - DeepSeek via OpenRouter)
DEEPSEEK_API_KEY=
OPENROUTER_API_KEY=
AI_SERVICE_URL=http://localhost:8000
AI_ENABLED=false
# Logging
LOG_LEVEL=debug
LOG_FORMAT=pretty
# CORS
CORS_ORIGINS=http://localhost:5173,http://localhost:3001

56
.gitignore vendored Normal file
View File

@ -0,0 +1,56 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

4
.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

82
Dockerfile Normal file
View File

@ -0,0 +1,82 @@
# ============================================
# Development Stage
# ============================================
FROM node:18-alpine AS development
WORKDIR /app
# Install dependencies for native modules
RUN apk add --no-cache python3 make g++
# Copy package files
COPY package*.json ./
# Install all dependencies (including devDependencies)
RUN npm ci
# Copy source code
COPY . .
# Expose port
EXPOSE 3000
# Start in development mode
CMD ["npm", "run", "start:dev"]
# ============================================
# Build Stage
# ============================================
FROM node:18-alpine AS build
WORKDIR /app
# Install dependencies for native modules
RUN apk add --no-cache python3 make g++
# Copy package files
COPY package*.json ./
# Install all dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Prune dev dependencies
RUN npm prune --production
# ============================================
# Production Stage
# ============================================
FROM node:18-alpine AS production
WORKDIR /app
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
adduser -S nestjs -u 1001
# Copy built application from build stage
COPY --from=build --chown=nestjs:nodejs /app/dist ./dist
COPY --from=build --chown=nestjs:nodejs /app/node_modules ./node_modules
COPY --from=build --chown=nestjs:nodejs /app/package*.json ./
# Set environment variables
ENV NODE_ENV=production
ENV PORT=3000
# Switch to non-root user
USER nestjs
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
# Start the application
CMD ["node", "dist/main.js"]

168
README.md Normal file
View File

@ -0,0 +1,168 @@
# Finance Backend API
API для управления личными финансами с поддержкой русской локализации.
## Возможности
- **Аутентификация**: JWT токены в HTTP-only cookies, refresh token rotation
- **Транзакции**: CRUD операции, фильтрация, импорт, аналитика
- **Категории**: Стандартные русские категории, пользовательские категории
- **Бюджеты**: Правило 50/30/20, отслеживание расходов по категориям
- **Финансовые цели**: Создание целей, отслеживание прогресса, автонакопления
- **Рекомендации**: AI-powered персонализированные советы
- **Аналитика**: Тренды, разбивка по категориям, финансовое здоровье
## Технологии
- **Framework**: NestJS 10.x
- **Database**: PostgreSQL + TypeORM
- **Auth**: Passport.js + JWT
- **Validation**: class-validator
- **Documentation**: Swagger/OpenAPI
- **Containerization**: Docker
## Быстрый старт
### Требования
- Node.js 18+
- Docker & Docker Compose
- PostgreSQL 15+ (или через Docker)
### Установка
```bash
# Клонирование репозитория
git clone <repository-url>
cd finance-backend
# Установка зависимостей
npm install
# Копирование переменных окружения
cp .env.example .env.development
```
### Запуск с Docker
```bash
# Запуск PostgreSQL
docker compose up -d postgres
# Запуск в режиме разработки
npm run start:dev
```
### Запуск без Docker
1. Установите PostgreSQL локально
2. Создайте базу данных `finance_app`
3. Обновите `.env.development` с вашими настройками
4. Запустите приложение:
```bash
npm run start:dev
```
## API Endpoints
После запуска доступны:
- **API**: http://localhost:3000/api/v1
- **Swagger Docs**: http://localhost:3000/api/docs
- **Health Check**: http://localhost:3000/health
### Основные эндпоинты
| Модуль | Путь | Описание |
|--------|------|----------|
| Auth | `/api/v1/auth/*` | Регистрация, вход, токены |
| Transactions | `/api/v1/transactions/*` | CRUD транзакций |
| Categories | `/api/v1/categories/*` | Управление категориями |
| Budgets | `/api/v1/budgets/*` | Бюджеты 50/30/20 |
| Goals | `/api/v1/goals/*` | Финансовые цели |
| Recommendations | `/api/v1/recommendations/*` | AI рекомендации |
| Analytics | `/api/v1/analytics/*` | Аналитика и отчеты |
## Структура проекта
```
src/
├── common/ # Общие утилиты, декораторы, фильтры
│ ├── constants/ # Константы и сообщения об ошибках
│ ├── decorators/ # Кастомные декораторы
│ ├── filters/ # Exception filters
│ ├── guards/ # Auth guards
│ ├── interceptors/ # Response interceptors
│ └── utils/ # Утилиты (дата, валюта, безопасность)
├── config/ # Конфигурация приложения
├── database/ # TypeORM конфигурация и миграции
└── modules/ # Функциональные модули
├── auth/ # Аутентификация
├── categories/ # Категории
├── transactions/ # Транзакции
├── budgets/ # Бюджеты
├── goals/ # Цели
├── recommendations/ # Рекомендации
├── analytics/ # Аналитика
└── ai/ # AI сервис (placeholder)
```
## Скрипты
```bash
# Разработка
npm run start:dev # Запуск с hot-reload
# Сборка
npm run build # Сборка проекта
npm run start:prod # Запуск production
# Тестирование
npm run test # Unit тесты
npm run test:e2e # E2E тесты
npm run test:cov # Coverage отчет
# База данных
npm run migration:generate # Генерация миграции
npm run migration:run # Применение миграций
npm run seed # Заполнение начальными данными
# Линтинг
npm run lint # Проверка кода
npm run format # Форматирование
```
## Переменные окружения
| Переменная | Описание | По умолчанию |
|------------|----------|--------------|
| `PORT` | Порт сервера | 3000 |
| `NODE_ENV` | Окружение | development |
| `DB_HOST` | Хост PostgreSQL | localhost |
| `DB_PORT` | Порт PostgreSQL | 5432 |
| `DB_USERNAME` | Пользователь БД | finance_user |
| `DB_PASSWORD` | Пароль БД | - |
| `DB_NAME` | Имя базы данных | finance_app |
| `JWT_ACCESS_SECRET` | Секрет access токена | - |
| `JWT_REFRESH_SECRET` | Секрет refresh токена | - |
| `JWT_ACCESS_EXPIRES` | Время жизни access | 15m |
| `JWT_REFRESH_EXPIRES` | Время жизни refresh | 7d |
## Безопасность
- JWT токены хранятся в HTTP-only cookies
- Refresh token rotation при каждом обновлении
- Rate limiting на все эндпоинты
- Bcrypt для хеширования паролей (12 раундов)
- Блокировка аккаунта после 10 неудачных попыток входа
- Аудит логи для критических операций
## Локализация
Все сообщения об ошибках и стандартные категории на русском языке.
Поддержка рублей (RUB) и московского часового пояса (UTC+3).
## License
MIT

51
docker-compose.prod.yml Normal file
View File

@ -0,0 +1,51 @@
version: '3.8'
services:
postgres:
image: postgres:14-alpine
container_name: finance_postgres_prod
environment:
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
volumes:
- postgres_data_prod:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_NAME}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- finance_network_prod
restart: unless-stopped
app:
build:
context: .
dockerfile: Dockerfile
target: production
container_name: finance_app_prod
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DB_HOST=postgres
- DB_PORT=5432
- DB_USERNAME=${DB_USERNAME}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME}
- JWT_SECRET=${JWT_SECRET}
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
depends_on:
postgres:
condition: service_healthy
networks:
- finance_network_prod
restart: unless-stopped
volumes:
postgres_data_prod:
networks:
finance_network_prod:
driver: bridge

54
docker-compose.yml Normal file
View File

@ -0,0 +1,54 @@
version: '3.8'
services:
postgres:
image: postgres:14-alpine
container_name: finance_postgres
environment:
POSTGRES_USER: finance_user
POSTGRES_PASSWORD: dev_password_123
POSTGRES_DB: finance_app
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U finance_user -d finance_app"]
interval: 10s
timeout: 5s
retries: 5
networks:
- finance_network
app:
build:
context: .
dockerfile: Dockerfile
target: development
container_name: finance_app
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- DB_HOST=postgres
- DB_PORT=5432
- DB_USERNAME=finance_user
- DB_PASSWORD=dev_password_123
- DB_NAME=finance_app
volumes:
- .:/app
- /app/node_modules
depends_on:
postgres:
condition: service_healthy
networks:
- finance_network
command: npm run start:dev
volumes:
postgres_data:
networks:
finance_network:
driver: bridge

9
docker/postgres/init.sql Normal file
View File

@ -0,0 +1,9 @@
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- Set timezone to Moscow
SET timezone = 'Europe/Moscow';
-- Grant privileges
GRANT ALL PRIVILEGES ON DATABASE finance_app TO finance_user;

34
eslint.config.mjs Normal file
View File

@ -0,0 +1,34 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn'
},
},
);

8
nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

12686
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

105
package.json Normal file
View File

@ -0,0 +1,105 @@
{
"name": "finance-backend",
"version": "1.0.0",
"description": "Personal Finance Management API with Russian localization",
"author": "Finance App Team",
"private": true,
"license": "MIT",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js",
"migration:generate": "npm run typeorm -- migration:generate -d src/database/data-source.ts",
"migration:run": "npm run typeorm -- migration:run -d src/database/data-source.ts",
"migration:revert": "npm run typeorm -- migration:revert -d src/database/data-source.ts",
"seed": "ts-node -r tsconfig-paths/register src/database/seeds/run-seed.ts"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.3.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/swagger": "^11.2.3",
"@nestjs/throttler": "^5.1.2",
"@nestjs/typeorm": "^10.0.2",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie-parser": "^1.4.6",
"date-fns": "^2.30.0",
"date-fns-tz": "^2.0.0",
"dotenv": "^16.3.1",
"helmet": "^7.1.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.11.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20",
"uuid": "^9.0.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.14",
"@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.3.0",
"@swc/cli": "^0.5.0",
"@swc/core": "^1.7.0",
"@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.7",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.14",
"@types/node": "^20.11.0",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"@types/uuid": "^9.0.8",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.2",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^@/(.*)$": "<rootDir>/$1",
"^@common/(.*)$": "<rootDir>/common/$1",
"^@modules/(.*)$": "<rootDir>/modules/$1",
"^@config/(.*)$": "<rootDir>/config/$1"
}
}
}

View File

@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

12
src/app.controller.ts Normal file
View File

@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

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

@ -0,0 +1,105 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD, APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
// Config
import { appConfig, databaseConfig, jwtConfig, securityConfig, aiConfig } from './config';
// Common
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
// Modules
import { AuthModule } from './modules/auth/auth.module';
import { CategoriesModule } from './modules/categories/categories.module';
import { TransactionsModule } from './modules/transactions/transactions.module';
import { BudgetsModule } from './modules/budgets/budgets.module';
import { GoalsModule } from './modules/goals/goals.module';
import { RecommendationsModule } from './modules/recommendations/recommendations.module';
import { AnalyticsModule } from './modules/analytics/analytics.module';
import { AiModule } from './modules/ai/ai.module';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@Module({
imports: [
// Configuration
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, databaseConfig, jwtConfig, securityConfig, aiConfig],
envFilePath: ['.env.development', '.env'],
}),
// Database
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
type: 'postgres',
host: configService.get('database.host'),
port: configService.get('database.port'),
username: configService.get('database.username'),
password: configService.get('database.password'),
database: configService.get('database.database'),
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: configService.get('database.synchronize'),
logging: configService.get('database.logging'),
}),
inject: [ConfigService],
}),
// Rate Limiting
ThrottlerModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
throttlers: [{
ttl: (configService.get<number>('security.rateLimitWindow') || 15) * 1000,
limit: configService.get<number>('security.rateLimitMax') || 100,
}],
}),
inject: [ConfigService],
}),
// Feature Modules
AuthModule,
CategoriesModule,
TransactionsModule,
BudgetsModule,
GoalsModule,
RecommendationsModule,
AnalyticsModule,
AiModule,
],
controllers: [AppController],
providers: [
AppService,
// Global Guards
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
// Global Filters
{
provide: APP_FILTER,
useClass: AllExceptionsFilter,
},
// Global Interceptors
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor,
},
{
provide: APP_INTERCEPTOR,
useClass: TransformInterceptor,
},
],
})
export class AppModule {}

8
src/app.service.ts Normal file
View File

@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View File

@ -0,0 +1,296 @@
/**
* Default Russian categories for the 50/30/20 budget rule
* Стандартные категории для правила 50/30/20
*/
export enum TransactionType {
INCOME = 'INCOME',
EXPENSE = 'EXPENSE',
}
export enum BudgetGroupType {
ESSENTIAL = 'ESSENTIAL', // 50% - Необходимое
PERSONAL = 'PERSONAL', // 30% - Личное
SAVINGS = 'SAVINGS', // 20% - Накопления
}
export enum PaymentMethod {
CASH = 'CASH',
CARD = 'CARD',
BANK_TRANSFER = 'BANK_TRANSFER',
}
export enum GoalType {
APARTMENT = 'APARTMENT', // Квартира
CAR = 'CAR', // Машина
VACATION = 'VACATION', // Отпуск
EDUCATION = 'EDUCATION', // Образование
WEDDING = 'WEDDING', // Свадьба
EMERGENCY = 'EMERGENCY', // Резервный фонд
OTHER = 'OTHER', // Другое
}
export interface DefaultCategory {
nameRu: string;
nameEn: string;
type: TransactionType;
groupType: BudgetGroupType | null;
icon: string;
color: string;
}
export const DEFAULT_EXPENSE_CATEGORIES: DefaultCategory[] = [
// ESSENTIAL (50%) - Необходимое
{
nameRu: 'Жилье',
nameEn: 'Housing',
type: TransactionType.EXPENSE,
groupType: BudgetGroupType.ESSENTIAL,
icon: 'home',
color: '#4CAF50',
},
{
nameRu: 'Коммуналка',
nameEn: 'Utilities',
type: TransactionType.EXPENSE,
groupType: BudgetGroupType.ESSENTIAL,
icon: 'flash',
color: '#FF9800',
},
{
nameRu: 'Продукты',
nameEn: 'Groceries',
type: TransactionType.EXPENSE,
groupType: BudgetGroupType.ESSENTIAL,
icon: 'cart',
color: '#8BC34A',
},
{
nameRu: 'Транспорт',
nameEn: 'Transport',
type: TransactionType.EXPENSE,
groupType: BudgetGroupType.ESSENTIAL,
icon: 'car',
color: '#2196F3',
},
{
nameRu: 'Медицина',
nameEn: 'Healthcare',
type: TransactionType.EXPENSE,
groupType: BudgetGroupType.ESSENTIAL,
icon: 'medical',
color: '#F44336',
},
{
nameRu: 'Связь и интернет',
nameEn: 'Phone & Internet',
type: TransactionType.EXPENSE,
groupType: BudgetGroupType.ESSENTIAL,
icon: 'phone',
color: '#9C27B0',
},
{
nameRu: 'Страхование',
nameEn: 'Insurance',
type: TransactionType.EXPENSE,
groupType: BudgetGroupType.ESSENTIAL,
icon: 'shield',
color: '#607D8B',
},
// PERSONAL (30%) - Личное
{
nameRu: 'Развлечения',
nameEn: 'Entertainment',
type: TransactionType.EXPENSE,
groupType: BudgetGroupType.PERSONAL,
icon: 'game',
color: '#E91E63',
},
{
nameRu: 'Рестораны и кафе',
nameEn: 'Restaurants',
type: TransactionType.EXPENSE,
groupType: BudgetGroupType.PERSONAL,
icon: 'restaurant',
color: '#FF5722',
},
{
nameRu: 'Шоппинг',
nameEn: 'Shopping',
type: TransactionType.EXPENSE,
groupType: BudgetGroupType.PERSONAL,
icon: 'bag',
color: '#9C27B0',
},
{
nameRu: 'Хобби',
nameEn: 'Hobbies',
type: TransactionType.EXPENSE,
groupType: BudgetGroupType.PERSONAL,
icon: 'palette',
color: '#3F51B5',
},
{
nameRu: 'Спорт и фитнес',
nameEn: 'Sports & Fitness',
type: TransactionType.EXPENSE,
groupType: BudgetGroupType.PERSONAL,
icon: 'fitness',
color: '#00BCD4',
},
{
nameRu: 'Красота и уход',
nameEn: 'Beauty & Care',
type: TransactionType.EXPENSE,
groupType: BudgetGroupType.PERSONAL,
icon: 'sparkles',
color: '#E91E63',
},
{
nameRu: 'Подписки',
nameEn: 'Subscriptions',
type: TransactionType.EXPENSE,
groupType: BudgetGroupType.PERSONAL,
icon: 'card',
color: '#673AB7',
},
{
nameRu: 'Подарки',
nameEn: 'Gifts',
type: TransactionType.EXPENSE,
groupType: BudgetGroupType.PERSONAL,
icon: 'gift',
color: '#F44336',
},
{
nameRu: 'Путешествия',
nameEn: 'Travel',
type: TransactionType.EXPENSE,
groupType: BudgetGroupType.PERSONAL,
icon: 'airplane',
color: '#00BCD4',
},
// SAVINGS (20%) - Накопления
{
nameRu: 'Накопления',
nameEn: 'Savings',
type: TransactionType.EXPENSE,
groupType: BudgetGroupType.SAVINGS,
icon: 'wallet',
color: '#4CAF50',
},
{
nameRu: 'Инвестиции',
nameEn: 'Investments',
type: TransactionType.EXPENSE,
groupType: BudgetGroupType.SAVINGS,
icon: 'trending-up',
color: '#2196F3',
},
{
nameRu: 'Погашение долгов',
nameEn: 'Debt Payment',
type: TransactionType.EXPENSE,
groupType: BudgetGroupType.SAVINGS,
icon: 'cash',
color: '#FF9800',
},
{
nameRu: 'Резервный фонд',
nameEn: 'Emergency Fund',
type: TransactionType.EXPENSE,
groupType: BudgetGroupType.SAVINGS,
icon: 'shield-checkmark',
color: '#009688',
},
];
export const DEFAULT_INCOME_CATEGORIES: DefaultCategory[] = [
{
nameRu: 'Зарплата',
nameEn: 'Salary',
type: TransactionType.INCOME,
groupType: null,
icon: 'briefcase',
color: '#4CAF50',
},
{
nameRu: 'Премия',
nameEn: 'Bonus',
type: TransactionType.INCOME,
groupType: null,
icon: 'star',
color: '#FFC107',
},
{
nameRu: 'Подработка',
nameEn: 'Side Income',
type: TransactionType.INCOME,
groupType: null,
icon: 'laptop',
color: '#2196F3',
},
{
nameRu: 'Фриланс',
nameEn: 'Freelance',
type: TransactionType.INCOME,
groupType: null,
icon: 'code',
color: '#9C27B0',
},
{
nameRu: 'Дивиденды',
nameEn: 'Dividends',
type: TransactionType.INCOME,
groupType: null,
icon: 'pie-chart',
color: '#00BCD4',
},
{
nameRu: 'Проценты по вкладам',
nameEn: 'Interest',
type: TransactionType.INCOME,
groupType: null,
icon: 'percent',
color: '#8BC34A',
},
{
nameRu: 'Аренда',
nameEn: 'Rental Income',
type: TransactionType.INCOME,
groupType: null,
icon: 'home',
color: '#FF9800',
},
{
nameRu: 'Возврат налогов',
nameEn: 'Tax Refund',
type: TransactionType.INCOME,
groupType: null,
icon: 'receipt',
color: '#607D8B',
},
{
nameRu: 'Подарки',
nameEn: 'Gifts Received',
type: TransactionType.INCOME,
groupType: null,
icon: 'gift',
color: '#E91E63',
},
{
nameRu: 'Другое',
nameEn: 'Other',
type: TransactionType.INCOME,
groupType: null,
icon: 'ellipsis-horizontal',
color: '#9E9E9E',
},
];
export const ALL_DEFAULT_CATEGORIES = [
...DEFAULT_EXPENSE_CATEGORIES,
...DEFAULT_INCOME_CATEGORIES,
];

View File

@ -0,0 +1,62 @@
/**
* Russian error messages for the application
* Сообщения об ошибках на русском языке
*/
export const ErrorMessages = {
// Authentication / Аутентификация
INVALID_CREDENTIALS: 'Неверный email или пароль',
ACCESS_DENIED: 'Доступ запрещен',
SESSION_EXPIRED: 'Сессия истекла, войдите снова',
TOKEN_INVALID: 'Недействительный токен',
TOKEN_EXPIRED: 'Токен истек',
ACCOUNT_LOCKED: 'Аккаунт заблокирован. Попробуйте позже',
TOO_MANY_REQUESTS: 'Слишком много запросов, попробуйте позже',
EMAIL_NOT_VERIFIED: 'Email не подтвержден',
// User / Пользователь
USER_NOT_FOUND: 'Пользователь не найден',
USER_ALREADY_EXISTS: 'Пользователь с таким email уже существует',
PHONE_ALREADY_EXISTS: 'Пользователь с таким телефоном уже существует',
// Validation / Валидация
INVALID_DATA: 'Неверные данные',
INVALID_EMAIL: 'Некорректный формат email',
INVALID_PHONE: 'Некорректный формат телефона',
INVALID_DATE: 'Некорректная дата',
INVALID_AMOUNT: 'Некорректная сумма',
// Password / Пароль
PASSWORD_TOO_SHORT: 'Пароль должен содержать минимум 12 символов',
PASSWORD_TOO_WEAK: 'Пароль должен содержать заглавные и строчные буквы, цифры и спецсимволы',
PASSWORDS_DO_NOT_MATCH: 'Пароли не совпадают',
// Transaction / Транзакция
TRANSACTION_NOT_FOUND: 'Транзакция не найдена',
AMOUNT_MUST_BE_POSITIVE: 'Сумма должна быть положительным числом',
AMOUNT_TOO_LARGE: 'Сумма превышает допустимый лимит',
INSUFFICIENT_FUNDS: 'Недостаточно средств',
// Category / Категория
CATEGORY_NOT_FOUND: 'Категория не найдена',
CATEGORY_ALREADY_EXISTS: 'Категория с таким названием уже существует',
CANNOT_DELETE_DEFAULT_CATEGORY: 'Нельзя удалить стандартную категорию',
// Budget / Бюджет
BUDGET_NOT_FOUND: 'Бюджет не найден',
BUDGET_LIMIT_EXCEEDED: 'Превышен лимит бюджета',
BUDGET_ALREADY_EXISTS: 'Бюджет на этот месяц уже существует',
// Goal / Цель
GOAL_NOT_FOUND: 'Финансовая цель не найдена',
GOAL_ALREADY_COMPLETED: 'Цель уже достигнута',
TARGET_AMOUNT_INVALID: 'Целевая сумма должна быть положительной',
// General / Общие
INTERNAL_SERVER_ERROR: 'Внутренняя ошибка сервера',
NOT_FOUND: 'Ресурс не найден',
BAD_REQUEST: 'Некорректный запрос',
FORBIDDEN: 'Действие запрещено',
CONFLICT: 'Конфликт данных',
} as const;
export type ErrorMessageKey = keyof typeof ErrorMessages;

View File

@ -0,0 +1,2 @@
export * from './error-messages';
export * from './categories';

View File

@ -0,0 +1,3 @@
export * from './public.decorator';
export * from './user.decorator';
export * from './roles.decorator';

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@ -0,0 +1,9 @@
import { SetMetadata } from '@nestjs/common';
export enum Role {
USER = 'user',
ADMIN = 'admin',
}
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

View File

@ -0,0 +1,21 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface JwtPayload {
sub: string;
email: string;
iat?: number;
exp?: number;
}
export const CurrentUser = createParamDecorator(
(data: keyof JwtPayload | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as JwtPayload;
if (!user) {
return null;
}
return data ? user[data] : user;
},
);

View File

@ -0,0 +1,46 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Внутренняя ошибка сервера';
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
message = typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as any).message || message;
} else if (exception instanceof Error) {
// Log the actual error for debugging
this.logger.error(
`Unhandled exception: ${exception.message}`,
exception.stack,
);
}
const errorResponse = {
statusCode: status,
message,
timestamp: new Date().toISOString(),
path: request.url,
};
response.status(status).json(errorResponse);
}
}

View File

@ -0,0 +1,74 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
interface ErrorResponse {
statusCode: number;
message: string;
errors?: Array<{ field: string; message: string }>;
timestamp: string;
path: string;
}
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const exceptionResponse = exception.getResponse();
let message = 'Внутренняя ошибка сервера';
let errors: Array<{ field: string; message: string }> | undefined;
if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
} else if (typeof exceptionResponse === 'object') {
const responseObj = exceptionResponse as any;
message = responseObj.message || message;
// Handle class-validator errors
if (Array.isArray(responseObj.message)) {
errors = responseObj.message.map((msg: string) => {
const parts = msg.split(' ');
const field = parts[0] || 'unknown';
return { field, message: msg };
});
message = 'Ошибка валидации данных';
}
}
const errorResponse: ErrorResponse = {
statusCode: status,
message,
timestamp: new Date().toISOString(),
path: request.url,
};
if (errors) {
errorResponse.errors = errors;
}
// Log error (mask sensitive data)
this.logger.error(
`HTTP ${status} Error: ${message}`,
{
path: request.url,
method: request.method,
// Don't log sensitive data
userId: (request as any).user?.sub || 'anonymous',
},
);
response.status(status).json(errorResponse);
}
}

View File

@ -0,0 +1,2 @@
export * from './http-exception.filter';
export * from './all-exceptions.filter';

View File

@ -0,0 +1,2 @@
export * from './jwt-auth.guard';
export * from './roles.guard';

View File

@ -0,0 +1,38 @@
import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { ErrorMessages } from '../constants/error-messages';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
handleRequest(err: any, user: any, info: any) {
if (err || !user) {
if (info?.name === 'TokenExpiredError') {
throw new UnauthorizedException(ErrorMessages.TOKEN_EXPIRED);
}
if (info?.name === 'JsonWebTokenError') {
throw new UnauthorizedException(ErrorMessages.TOKEN_INVALID);
}
throw new UnauthorizedException(ErrorMessages.ACCESS_DENIED);
}
return user;
}
}

View File

@ -0,0 +1,34 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY, Role } from '../decorators/roles.decorator';
import { ErrorMessages } from '../constants/error-messages';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user || !user.role) {
throw new ForbiddenException(ErrorMessages.ACCESS_DENIED);
}
const hasRole = requiredRoles.some((role) => user.role === role);
if (!hasRole) {
throw new ForbiddenException(ErrorMessages.ACCESS_DENIED);
}
return true;
}
}

View File

@ -0,0 +1,2 @@
export * from './transform.interceptor';
export * from './logging.interceptor';

View File

@ -0,0 +1,45 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Request } from 'express';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest<Request>();
const { method, url, ip } = request;
const userAgent = request.get('user-agent') || '';
const userId = (request as any).user?.sub || 'anonymous';
const now = Date.now();
return next.handle().pipe(
tap({
next: () => {
const response = context.switchToHttp().getResponse();
const { statusCode } = response;
const contentLength = response.get('content-length') || 0;
const duration = Date.now() - now;
this.logger.log(
`${method} ${url} ${statusCode} ${contentLength} - ${duration}ms - ${userId} - ${ip} - ${userAgent}`,
);
},
error: (error) => {
const duration = Date.now() - now;
this.logger.error(
`${method} ${url} ERROR - ${duration}ms - ${userId} - ${ip} - ${error.message}`,
);
},
}),
);
}
}

View File

@ -0,0 +1,27 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface ApiResponse<T> {
success: boolean;
data: T;
timestamp: string;
}
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data) => ({
success: true,
data,
timestamp: new Date().toISOString(),
})),
);
}
}

View File

@ -0,0 +1,108 @@
/**
* Currency utilities for Russian Rubles (RUB)
*/
/**
* Format amount in Russian rubles
*/
export function formatRubles(amount: number, showSymbol: boolean = true): string {
const formatted = new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
return showSymbol ? `${formatted}` : formatted;
}
/**
* Parse Russian formatted number string to number
*/
export function parseRubles(value: string): number {
// Remove currency symbol and spaces
const cleaned = value.replace(/[₽\s]/g, '').replace(',', '.');
return parseFloat(cleaned) || 0;
}
/**
* Round to 2 decimal places (kopeks)
*/
export function roundToKopeks(amount: number): number {
return Math.round(amount * 100) / 100;
}
/**
* Validate amount is positive and within reasonable limits
*/
export function validateAmount(amount: number): { valid: boolean; error?: string } {
if (typeof amount !== 'number' || isNaN(amount)) {
return { valid: false, error: 'Сумма должна быть числом' };
}
if (amount <= 0) {
return { valid: false, error: 'Сумма должна быть положительным числом' };
}
// Max 1 billion rubles per transaction
if (amount > 1_000_000_000) {
return { valid: false, error: 'Сумма превышает допустимый лимит' };
}
return { valid: true };
}
/**
* Calculate percentage
*/
export function calculatePercentage(part: number, total: number): number {
if (total === 0) return 0;
return roundToKopeks((part / total) * 100);
}
/**
* Format percentage in Russian style
*/
export function formatPercentage(value: number): string {
return `${value.toFixed(1).replace('.', ',')}%`;
}
/**
* 50/30/20 budget allocation
*/
export interface BudgetAllocation {
essentials: number; // 50%
personal: number; // 30%
savings: number; // 20%
}
/**
* Calculate 50/30/20 budget allocation from income
*/
export function calculateBudgetAllocation(income: number): BudgetAllocation {
return {
essentials: roundToKopeks(income * 0.5),
personal: roundToKopeks(income * 0.3),
savings: roundToKopeks(income * 0.2),
};
}
/**
* Calculate custom budget allocation
*/
export function calculateCustomAllocation(
income: number,
essentialsPercent: number,
personalPercent: number,
savingsPercent: number,
): BudgetAllocation {
const total = essentialsPercent + personalPercent + savingsPercent;
if (total !== 100) {
throw new Error('Сумма процентов должна равняться 100');
}
return {
essentials: roundToKopeks(income * (essentialsPercent / 100)),
personal: roundToKopeks(income * (personalPercent / 100)),
savings: roundToKopeks(income * (savingsPercent / 100)),
};
}

View File

@ -0,0 +1,82 @@
import { format, startOfMonth, endOfMonth, startOfYear, endOfYear, parseISO } from 'date-fns';
import { utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz';
const MOSCOW_TIMEZONE = 'Europe/Moscow';
/**
* Convert a date to Moscow timezone
*/
export function toMoscowTime(date: Date | string): Date {
const d = typeof date === 'string' ? parseISO(date) : date;
return utcToZonedTime(d, MOSCOW_TIMEZONE);
}
/**
* Convert a Moscow time to UTC
*/
export function fromMoscowTime(date: Date | string): Date {
const d = typeof date === 'string' ? parseISO(date) : date;
return zonedTimeToUtc(d, MOSCOW_TIMEZONE);
}
/**
* Format date in Russian locale format
*/
export function formatDateRu(date: Date | string, formatStr: string = 'dd.MM.yyyy'): string {
const d = typeof date === 'string' ? parseISO(date) : date;
return format(d, formatStr);
}
/**
* Get start of month in Moscow timezone
*/
export function getMonthStartMoscow(date: Date = new Date()): Date {
const moscowDate = toMoscowTime(date);
return startOfMonth(moscowDate);
}
/**
* Get end of month in Moscow timezone
*/
export function getMonthEndMoscow(date: Date = new Date()): Date {
const moscowDate = toMoscowTime(date);
return endOfMonth(moscowDate);
}
/**
* Get start of year in Moscow timezone
*/
export function getYearStartMoscow(date: Date = new Date()): Date {
const moscowDate = toMoscowTime(date);
return startOfYear(moscowDate);
}
/**
* Get end of year in Moscow timezone
*/
export function getYearEndMoscow(date: Date = new Date()): Date {
const moscowDate = toMoscowTime(date);
return endOfYear(moscowDate);
}
/**
* Get current Moscow time
*/
export function nowMoscow(): Date {
return toMoscowTime(new Date());
}
/**
* Russian month names
*/
export const RUSSIAN_MONTHS = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь',
];
/**
* Get Russian month name
*/
export function getRussianMonthName(month: number): string {
return RUSSIAN_MONTHS[month] || '';
}

View File

@ -0,0 +1,4 @@
export * from './date.utils';
export * from './currency.utils';
export * from './security.utils';
export * from './validation.utils';

View File

@ -0,0 +1,121 @@
import * as bcrypt from 'bcrypt';
import { createHash, randomBytes } from 'crypto';
/**
* Hash a password using bcrypt
*/
export async function hashPassword(password: string, saltRounds: number = 12): Promise<string> {
return bcrypt.hash(password, saltRounds);
}
/**
* Compare password with hash
*/
export async function comparePassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
/**
* Generate a secure random token
*/
export function generateSecureToken(length: number = 32): string {
return randomBytes(length).toString('hex');
}
/**
* Hash a token for storage (using SHA-256)
*/
export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
/**
* Validate password strength
*/
export interface PasswordValidationResult {
valid: boolean;
errors: string[];
}
export function validatePasswordStrength(password: string): PasswordValidationResult {
const errors: string[] = [];
if (password.length < 12) {
errors.push('Пароль должен содержать минимум 12 символов');
}
if (!/[a-z]/.test(password)) {
errors.push('Пароль должен содержать строчные буквы');
}
if (!/[A-Z]/.test(password)) {
errors.push('Пароль должен содержать заглавные буквы');
}
if (!/\d/.test(password)) {
errors.push('Пароль должен содержать цифры');
}
if (!/[@$!%*?&]/.test(password)) {
errors.push('Пароль должен содержать специальные символы (@$!%*?&)');
}
return {
valid: errors.length === 0,
errors,
};
}
/**
* Mask sensitive data for logging
*/
export function maskEmail(email: string): string {
const [local, domain] = email.split('@');
if (!local || !domain) return '***@***.***';
const maskedLocal = local.length > 2
? `${local[0]}***${local[local.length - 1]}`
: '***';
return `${maskedLocal}@${domain}`;
}
/**
* Mask phone number for logging
*/
export function maskPhone(phone: string): string {
if (phone.length < 4) return '****';
return `${phone.slice(0, 2)}****${phone.slice(-2)}`;
}
/**
* Sanitize user input to prevent XSS
*/
export function sanitizeInput(input: string): string {
return input
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
.replace(/\//g, '&#x2F;');
}
/**
* Validate Russian phone number format
*/
export function validateRussianPhone(phone: string): boolean {
// Russian phone: +7XXXXXXXXXX or 8XXXXXXXXXX
const cleanPhone = phone.replace(/[\s\-\(\)]/g, '');
return /^(\+7|8)\d{10}$/.test(cleanPhone);
}
/**
* Normalize Russian phone number to +7 format
*/
export function normalizeRussianPhone(phone: string): string {
const cleanPhone = phone.replace(/[\s\-\(\)]/g, '');
if (cleanPhone.startsWith('8')) {
return '+7' + cleanPhone.slice(1);
}
return cleanPhone;
}

View File

@ -0,0 +1,123 @@
import { BadRequestException } from '@nestjs/common';
import { ErrorMessages } from '../constants/error-messages';
/**
* Validate UUID format
*/
export function isValidUUID(id: string): boolean {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidRegex.test(id);
}
/**
* Validate and throw if UUID is invalid
*/
export function validateUUID(id: string, fieldName: string = 'ID'): void {
if (!isValidUUID(id)) {
throw new BadRequestException(`Некорректный формат ${fieldName}`);
}
}
/**
* Validate email format
*/
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* Validate date string format (YYYY-MM-DD)
*/
export function isValidDateString(dateStr: string): boolean {
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(dateStr)) return false;
const date = new Date(dateStr);
return !isNaN(date.getTime());
}
/**
* Validate pagination parameters
*/
export interface PaginationParams {
page: number;
limit: number;
}
export function validatePagination(page?: number, limit?: number): PaginationParams {
const validPage = Math.max(1, page || 1);
const validLimit = Math.min(100, Math.max(1, limit || 20));
return { page: validPage, limit: validLimit };
}
/**
* Calculate pagination offset
*/
export function calculateOffset(page: number, limit: number): number {
return (page - 1) * limit;
}
/**
* Create pagination response metadata
*/
export interface PaginationMeta {
page: number;
limit: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
}
export function createPaginationMeta(
page: number,
limit: number,
total: number,
): PaginationMeta {
const totalPages = Math.ceil(total / limit);
return {
page,
limit,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1,
};
}
/**
* Validate date range
*/
export function validateDateRange(startDate: string, endDate: string): void {
if (!isValidDateString(startDate)) {
throw new BadRequestException('Некорректная дата начала');
}
if (!isValidDateString(endDate)) {
throw new BadRequestException('Некорректная дата окончания');
}
if (new Date(startDate) > new Date(endDate)) {
throw new BadRequestException('Дата начала не может быть позже даты окончания');
}
}
/**
* Validate transaction amount
*/
export function validateTransactionAmount(amount: number): void {
if (typeof amount !== 'number' || isNaN(amount)) {
throw new BadRequestException(ErrorMessages.INVALID_AMOUNT);
}
if (amount <= 0) {
throw new BadRequestException(ErrorMessages.AMOUNT_MUST_BE_POSITIVE);
}
if (amount > 1_000_000_000) {
throw new BadRequestException(ErrorMessages.AMOUNT_TOO_LARGE);
}
}

8
src/config/ai.config.ts Normal file
View File

@ -0,0 +1,8 @@
import { registerAs } from '@nestjs/config';
export default registerAs('ai', () => ({
deepseekApiKey: process.env.DEEPSEEK_API_KEY || '',
openrouterApiKey: process.env.OPENROUTER_API_KEY || '',
serviceUrl: process.env.AI_SERVICE_URL || 'http://localhost:8000',
enabled: process.env.AI_ENABLED === 'true',
}));

10
src/config/app.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { registerAs } from '@nestjs/config';
export default registerAs('app', () => ({
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '3000', 10),
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
corsOrigins: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:5173'],
logLevel: process.env.LOG_LEVEL || 'debug',
logFormat: process.env.LOG_FORMAT || 'pretty',
}));

View File

@ -0,0 +1,11 @@
import { registerAs } from '@nestjs/config';
export default registerAs('database', () => ({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
username: process.env.DB_USERNAME || 'finance_user',
password: process.env.DB_PASSWORD || 'dev_password_123',
database: process.env.DB_NAME || 'finance_app',
synchronize: process.env.NODE_ENV === 'development',
logging: process.env.NODE_ENV === 'development',
}));

5
src/config/index.ts Normal file
View File

@ -0,0 +1,5 @@
export { default as appConfig } from './app.config';
export { default as databaseConfig } from './database.config';
export { default as jwtConfig } from './jwt.config';
export { default as securityConfig } from './security.config';
export { default as aiConfig } from './ai.config';

10
src/config/jwt.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { registerAs } from '@nestjs/config';
export default registerAs('jwt', () => ({
secret: process.env.JWT_SECRET || 'dev_jwt_secret_key_minimum_32_characters_long_here',
refreshSecret: process.env.JWT_REFRESH_SECRET || 'dev_refresh_secret_key_minimum_32_characters_long',
accessExpiry: process.env.JWT_ACCESS_EXPIRY || '15m',
refreshExpiry: process.env.JWT_REFRESH_EXPIRY || '7d',
cookieDomain: process.env.COOKIE_DOMAIN || 'localhost',
cookieSecure: process.env.COOKIE_SECURE === 'true',
}));

View File

@ -0,0 +1,10 @@
import { registerAs } from '@nestjs/config';
export default registerAs('security', () => ({
bcryptSaltRounds: parseInt(process.env.BCRYPT_SALT_ROUNDS || '12', 10),
maxLoginAttempts: parseInt(process.env.MAX_LOGIN_ATTEMPTS || '10', 10),
lockoutDurationMinutes: parseInt(process.env.LOCKOUT_DURATION_MINUTES || '30', 10),
rateLimitWindow: parseInt(process.env.RATE_LIMIT_WINDOW || '15', 10),
rateLimitMax: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
loginRateLimitMax: parseInt(process.env.LOGIN_RATE_LIMIT_MAX || '5', 10),
}));

View File

@ -0,0 +1,21 @@
import { DataSource, DataSourceOptions } from 'typeorm';
import * as dotenv from 'dotenv';
dotenv.config({ path: '.env.development' });
export const dataSourceOptions: DataSourceOptions = {
type: 'postgres',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
username: process.env.DB_USERNAME || 'finance_user',
password: process.env.DB_PASSWORD || 'dev_password_123',
database: process.env.DB_NAME || 'finance_app',
entities: ['src/**/*.entity{.ts,.js}'],
migrations: ['src/database/migrations/*{.ts,.js}'],
synchronize: false,
logging: process.env.NODE_ENV === 'development',
};
const dataSource = new DataSource(dataSourceOptions);
export default dataSource;

View File

@ -0,0 +1,30 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from '../../app.module';
import { CategoriesService } from '../../modules/categories/categories.service';
import { Logger } from '@nestjs/common';
async function runSeed() {
const logger = new Logger('Seed');
logger.log('Starting database seeding...');
const app = await NestFactory.createApplicationContext(AppModule);
try {
// Seed default categories
const categoriesService = app.get(CategoriesService);
await categoriesService.seedDefaultCategories();
logger.log('✅ Default categories seeded successfully');
logger.log('🎉 Database seeding completed!');
} catch (error) {
logger.error('❌ Seeding failed:', error);
throw error;
} finally {
await app.close();
}
}
runSeed()
.then(() => process.exit(0))
.catch(() => process.exit(1));

102
src/main.ts Normal file
View File

@ -0,0 +1,102 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import * as cookieParser from 'cookie-parser';
import helmet from 'helmet';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
const port = configService.get<number>('app.port') || 3000;
const frontendUrl = configService.get<string>('app.frontendUrl') || 'http://localhost:5173';
const corsOrigins = configService.get<string[]>('app.corsOrigins') || [frontendUrl];
// Security
app.use(helmet());
app.use(cookieParser());
// CORS
app.enableCors({
origin: corsOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
});
// Global Validation Pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
// API Prefix
app.setGlobalPrefix('api/v1');
// Swagger Documentation
const swaggerConfig = new DocumentBuilder()
.setTitle('Finance App API')
.setDescription(`
## API для управления личными финансами
### Основные возможности:
- **Аутентификация**: Регистрация, вход, JWT токены в HTTP-only cookies
- **Транзакции**: CRUD операции, фильтрация, аналитика
- **Категории**: Стандартные и пользовательские категории
- **Бюджеты**: Правило 50/30/20, отслеживание расходов
- **AI рекомендации**: Персонализированные советы (Phase 2)
### Безопасность:
- JWT токены в HTTP-only cookies
- Rate limiting
- Защита от XSS и CSRF
### Локализация:
- Все сообщения об ошибках на русском языке
- Поддержка рублей (RUB)
- Московский часовой пояс (UTC+3)
`)
.setVersion('1.0')
.addTag('Аутентификация', 'Регистрация, вход, управление сессиями')
.addTag('Транзакции', 'Управление доходами и расходами')
.addTag('Категории', 'Категории транзакций')
.addTag('Бюджеты (50/30/20)', 'Планирование бюджета по правилу 50/30/20')
.addBearerAuth()
.addCookieAuth('access_token')
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document, {
swaggerOptions: {
persistAuthorization: true,
tagsSorter: 'alpha',
operationsSorter: 'alpha',
},
customSiteTitle: 'Finance App API - Документация',
});
// Health check endpoint
app.getHttpAdapter().get('/health', (req, res) => {
res.status(200).json({
status: 'ok',
timestamp: new Date().toISOString(),
});
});
await app.listen(port);
logger.log(`🚀 Application is running on: http://localhost:${port}`);
logger.log(`📚 Swagger documentation: http://localhost:${port}/api/docs`);
logger.log(`🏥 Health check: http://localhost:${port}/health`);
}
bootstrap();

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { AiService } from './ai.service';
@Module({
providers: [AiService],
exports: [AiService],
})
export class AiModule {}

View File

@ -0,0 +1,341 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
/**
* AI Service Placeholder for DeepSeek Integration via OpenRouter
* This service provides mock implementations that will be replaced with real API calls in Phase 2
*/
export interface TransactionCategorizationRequest {
description: string;
amount: number;
date: string;
}
export interface TransactionCategorizationResponse {
suggestedCategoryId: string;
suggestedCategoryName: string;
confidence: number;
reasoning: string;
}
export interface SpendingAnalysisRequest {
userId: string;
startDate: string;
endDate: string;
transactions: Array<{
amount: number;
category: string;
date: string;
}>;
}
export interface SpendingAnalysisResponse {
patterns: Array<{
pattern: string;
description: string;
impact: 'positive' | 'negative' | 'neutral';
}>;
insights: string[];
recommendations: string[];
}
export interface FinancialRecommendation {
id: string;
type: 'SAVING' | 'SPENDING' | 'INVESTMENT' | 'TAX' | 'DEBT';
titleRu: string;
descriptionRu: string;
priorityScore: number;
confidenceScore: number;
actionData?: Record<string, any>;
}
export interface ForecastRequest {
userId: string;
historicalData: Array<{
month: string;
income: number;
expenses: number;
}>;
monthsToForecast: number;
}
export interface ForecastResponse {
forecasts: Array<{
month: string;
predictedIncome: number;
predictedExpenses: number;
confidence: number;
}>;
trends: {
incomeGrowth: number;
expenseGrowth: number;
savingsRate: number;
};
}
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
private readonly isEnabled: boolean;
constructor(private configService: ConfigService) {
this.isEnabled = this.configService.get<boolean>('ai.enabled') || false;
if (!this.isEnabled) {
this.logger.warn('AI Service is disabled. Using mock implementations.');
}
}
/**
* Categorize a transaction using AI
* PLACEHOLDER: Returns mock data until DeepSeek integration
*/
async categorizeTransaction(
request: TransactionCategorizationRequest,
): Promise<TransactionCategorizationResponse> {
this.logger.debug(`Categorizing transaction: ${request.description}`);
if (!this.isEnabled) {
return this.mockCategorizeTransaction(request);
}
// TODO: Implement DeepSeek API call via OpenRouter
// const response = await this.callDeepSeek('categorize', request);
return this.mockCategorizeTransaction(request);
}
/**
* Analyze spending patterns
* PLACEHOLDER: Returns mock data until DeepSeek integration
*/
async analyzeSpending(
request: SpendingAnalysisRequest,
): Promise<SpendingAnalysisResponse> {
this.logger.debug(`Analyzing spending for user: ${request.userId}`);
if (!this.isEnabled) {
return this.mockAnalyzeSpending(request);
}
// TODO: Implement DeepSeek API call via OpenRouter
return this.mockAnalyzeSpending(request);
}
/**
* Generate personalized recommendations
* PLACEHOLDER: Returns mock data until DeepSeek integration
*/
async generateRecommendations(
userId: string,
context: {
monthlyIncome: number;
totalExpenses: number;
savingsRate: number;
topCategories: string[];
},
): Promise<FinancialRecommendation[]> {
this.logger.debug(`Generating recommendations for user: ${userId}`);
if (!this.isEnabled) {
return this.mockGenerateRecommendations(context);
}
// TODO: Implement DeepSeek API call via OpenRouter
return this.mockGenerateRecommendations(context);
}
/**
* Forecast future finances
* PLACEHOLDER: Returns mock data until DeepSeek integration
*/
async forecastFinances(request: ForecastRequest): Promise<ForecastResponse> {
this.logger.debug(`Forecasting finances for user: ${request.userId}`);
if (!this.isEnabled) {
return this.mockForecastFinances(request);
}
// TODO: Implement DeepSeek API call via OpenRouter
return this.mockForecastFinances(request);
}
// ============================================
// Mock Implementations (Phase 1)
// ============================================
private mockCategorizeTransaction(
request: TransactionCategorizationRequest,
): TransactionCategorizationResponse {
const description = request.description.toLowerCase();
// Simple keyword-based categorization
const categoryMap: Record<string, { id: string; name: string }> = {
'продукты': { id: 'groceries', name: 'Продукты' },
'пятерочка': { id: 'groceries', name: 'Продукты' },
'магнит': { id: 'groceries', name: 'Продукты' },
'такси': { id: 'transport', name: 'Транспорт' },
'яндекс': { id: 'transport', name: 'Транспорт' },
'метро': { id: 'transport', name: 'Транспорт' },
'ресторан': { id: 'restaurants', name: 'Рестораны и кафе' },
'кафе': { id: 'restaurants', name: 'Рестораны и кафе' },
'аптека': { id: 'healthcare', name: 'Медицина' },
'жкх': { id: 'utilities', name: 'Коммуналка' },
'электричество': { id: 'utilities', name: 'Коммуналка' },
};
for (const [keyword, category] of Object.entries(categoryMap)) {
if (description.includes(keyword)) {
return {
suggestedCategoryId: category.id,
suggestedCategoryName: category.name,
confidence: 0.85,
reasoning: `Ключевое слово "${keyword}" найдено в описании`,
};
}
}
return {
suggestedCategoryId: 'other',
suggestedCategoryName: 'Другое',
confidence: 0.3,
reasoning: 'Не удалось определить категорию по описанию',
};
}
private mockAnalyzeSpending(
request: SpendingAnalysisRequest,
): SpendingAnalysisResponse {
return {
patterns: [
{
pattern: 'weekend_spending',
description: 'Повышенные траты в выходные дни',
impact: 'negative',
},
{
pattern: 'regular_savings',
description: 'Регулярные переводы на накопления',
impact: 'positive',
},
],
insights: [
'Ваши расходы на рестораны выросли на 15% по сравнению с прошлым месяцем',
'Вы экономите в среднем 18% от дохода',
'Основные траты приходятся на продукты и транспорт',
],
recommendations: [
'Рассмотрите возможность готовить дома чаще для экономии на ресторанах',
'Увеличьте автоматические переводы на накопления до 20%',
'Используйте карту с кэшбэком для регулярных покупок',
],
};
}
private mockGenerateRecommendations(context: {
monthlyIncome: number;
totalExpenses: number;
savingsRate: number;
topCategories: string[];
}): FinancialRecommendation[] {
const recommendations: FinancialRecommendation[] = [];
// Savings recommendation
if (context.savingsRate < 20) {
recommendations.push({
id: 'increase-savings',
type: 'SAVING',
titleRu: 'Увеличьте накопления',
descriptionRu: `Ваша текущая норма сбережений ${context.savingsRate.toFixed(1)}%. Рекомендуем увеличить до 20% согласно правилу 50/30/20.`,
priorityScore: 0.9,
confidenceScore: 0.95,
actionData: {
targetSavingsRate: 20,
monthlySavingsTarget: context.monthlyIncome * 0.2,
},
});
}
// Tax deduction recommendation
recommendations.push({
id: 'tax-deduction',
type: 'TAX',
titleRu: 'Налоговый вычет',
descriptionRu: 'Проверьте возможность получения налогового вычета за медицинские услуги или образование (3-НДФЛ).',
priorityScore: 0.7,
confidenceScore: 0.8,
});
// Emergency fund recommendation
recommendations.push({
id: 'emergency-fund',
type: 'SAVING',
titleRu: 'Резервный фонд',
descriptionRu: 'Рекомендуем создать резервный фонд в размере 3-6 месячных расходов.',
priorityScore: 0.85,
confidenceScore: 0.9,
actionData: {
targetAmount: context.totalExpenses * 6,
},
});
return recommendations;
}
private mockForecastFinances(request: ForecastRequest): ForecastResponse {
const lastData = request.historicalData[request.historicalData.length - 1];
const forecasts: ForecastResponse['forecasts'] = [];
let currentIncome = lastData?.income || 100000;
let currentExpenses = lastData?.expenses || 80000;
for (let i = 1; i <= request.monthsToForecast; i++) {
const month = new Date();
month.setMonth(month.getMonth() + i);
// Simple linear projection with some variance
currentIncome *= 1.02; // 2% growth
currentExpenses *= 1.015; // 1.5% growth
forecasts.push({
month: month.toISOString().slice(0, 7),
predictedIncome: Math.round(currentIncome),
predictedExpenses: Math.round(currentExpenses),
confidence: Math.max(0.5, 0.9 - i * 0.05), // Confidence decreases over time
});
}
return {
forecasts,
trends: {
incomeGrowth: 2.0,
expenseGrowth: 1.5,
savingsRate: ((currentIncome - currentExpenses) / currentIncome) * 100,
},
};
}
// ============================================
// Future DeepSeek Integration Methods
// ============================================
/**
* Call DeepSeek API via OpenRouter
* TODO: Implement in Phase 2
*/
// private async callDeepSeek(endpoint: string, data: any): Promise<any> {
// const apiKey = this.configService.get<string>('ai.openrouterApiKey');
// const serviceUrl = this.configService.get<string>('ai.serviceUrl');
//
// const response = await fetch(`${serviceUrl}/${endpoint}`, {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// 'Authorization': `Bearer ${apiKey}`,
// },
// body: JSON.stringify(data),
// });
//
// return response.json();
// }
}

View File

@ -0,0 +1,96 @@
import {
Controller,
Get,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { AnalyticsService } from './analytics.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser, JwtPayload } from '../../common/decorators/user.decorator';
@ApiTags('Аналитика')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('analytics')
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
@Get('overview')
@ApiOperation({ summary: 'Обзор за месяц' })
@ApiQuery({ name: 'month', required: false, example: '2024-01', description: 'Месяц в формате YYYY-MM' })
@ApiResponse({ status: 200, description: 'Обзор за месяц' })
async getMonthlyOverview(
@CurrentUser() user: JwtPayload,
@Query('month') month?: string,
) {
const date = month ? new Date(month + '-01') : new Date();
return this.analyticsService.getMonthlyOverview(user.sub, date);
}
@Get('trends')
@ApiOperation({ summary: 'Тренды расходов' })
@ApiQuery({ name: 'months', required: false, example: 6 })
@ApiResponse({ status: 200, description: 'Тренды расходов по месяцам' })
async getSpendingTrends(
@CurrentUser() user: JwtPayload,
@Query('months') months?: number,
) {
return this.analyticsService.getSpendingTrends(user.sub, months || 6);
}
@Get('categories')
@ApiOperation({ summary: 'Разбивка по категориям' })
@ApiQuery({ name: 'startDate', required: true, example: '2024-01-01' })
@ApiQuery({ name: 'endDate', required: true, example: '2024-01-31' })
@ApiQuery({ name: 'type', enum: ['INCOME', 'EXPENSE'], required: false })
@ApiResponse({ status: 200, description: 'Разбивка расходов по категориям' })
async getCategoryBreakdown(
@CurrentUser() user: JwtPayload,
@Query('startDate') startDate: string,
@Query('endDate') endDate: string,
@Query('type') type?: 'INCOME' | 'EXPENSE',
) {
return this.analyticsService.getCategoryBreakdown(
user.sub,
new Date(startDate),
new Date(endDate),
type || 'EXPENSE',
);
}
@Get('income-vs-expenses')
@ApiOperation({ summary: 'Сравнение доходов и расходов' })
@ApiQuery({ name: 'months', required: false, example: 12 })
@ApiResponse({ status: 200, description: 'Сравнение по месяцам' })
async getIncomeVsExpenses(
@CurrentUser() user: JwtPayload,
@Query('months') months?: number,
) {
return this.analyticsService.getIncomeVsExpenses(user.sub, months || 12);
}
@Get('health')
@ApiOperation({ summary: 'Оценка финансового здоровья' })
@ApiResponse({ status: 200, description: 'Оценка и рекомендации' })
async getFinancialHealth(@CurrentUser() user: JwtPayload) {
return this.analyticsService.getFinancialHealth(user.sub);
}
@Get('yearly')
@ApiOperation({ summary: 'Годовой отчет' })
@ApiQuery({ name: 'year', required: false, example: 2024 })
@ApiResponse({ status: 200, description: 'Годовая сводка' })
async getYearlySummary(
@CurrentUser() user: JwtPayload,
@Query('year') year?: number,
) {
return this.analyticsService.getYearlySummary(user.sub, year || new Date().getFullYear());
}
}

View File

@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AnalyticsController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
import { Transaction } from '../transactions/entities/transaction.entity';
import { Category } from '../categories/entities/category.entity';
import { Budget } from '../budgets/entities/budget.entity';
import { Goal } from '../goals/entities/goal.entity';
@Module({
imports: [
TypeOrmModule.forFeature([Transaction, Category, Budget, Goal]),
],
controllers: [AnalyticsController],
providers: [AnalyticsService],
exports: [AnalyticsService],
})
export class AnalyticsModule {}

View File

@ -0,0 +1,478 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between } from 'typeorm';
import { Transaction } from '../transactions/entities/transaction.entity';
import { Category } from '../categories/entities/category.entity';
import { Budget } from '../budgets/entities/budget.entity';
import { Goal } from '../goals/entities/goal.entity';
import { startOfMonth, endOfMonth, subMonths, format, startOfYear, endOfYear } from 'date-fns';
import { formatRubles } from '../../common/utils/currency.utils';
export interface MonthlyOverview {
month: string;
totalIncome: number;
totalExpenses: number;
netSavings: number;
savingsRate: number;
topCategories: Array<{
categoryId: string;
categoryName: string;
amount: number;
percentage: number;
}>;
}
export interface SpendingTrend {
period: string;
amount: number;
change: number;
changePercent: number;
}
export interface CategoryBreakdown {
categoryId: string;
categoryName: string;
categoryIcon: string;
categoryColor: string;
amount: number;
percentage: number;
transactionCount: number;
averageTransaction: number;
}
export interface FinancialHealth {
score: number;
grade: 'A' | 'B' | 'C' | 'D' | 'F';
factors: Array<{
name: string;
score: number;
description: string;
recommendation?: string;
}>;
}
@Injectable()
export class AnalyticsService {
constructor(
@InjectRepository(Transaction)
private transactionRepository: Repository<Transaction>,
@InjectRepository(Category)
private categoryRepository: Repository<Category>,
@InjectRepository(Budget)
private budgetRepository: Repository<Budget>,
@InjectRepository(Goal)
private goalRepository: Repository<Goal>,
) {}
/**
* Get monthly overview for a specific month
*/
async getMonthlyOverview(userId: string, date: Date = new Date()): Promise<MonthlyOverview> {
const monthStart = startOfMonth(date);
const monthEnd = endOfMonth(date);
const transactions = await this.transactionRepository.find({
where: {
userId,
transactionDate: Between(monthStart, monthEnd),
},
relations: ['category'],
});
const totalIncome = transactions
.filter(t => t.type === 'INCOME')
.reduce((sum, t) => sum + Number(t.amount), 0);
const totalExpenses = transactions
.filter(t => t.type === 'EXPENSE')
.reduce((sum, t) => sum + Number(t.amount), 0);
const netSavings = totalIncome - totalExpenses;
const savingsRate = totalIncome > 0 ? (netSavings / totalIncome) * 100 : 0;
// Calculate top spending categories
const categorySpending: Record<string, { name: string; amount: number }> = {};
transactions
.filter(t => t.type === 'EXPENSE')
.forEach(t => {
const catId = t.categoryId || 'other';
const catName = t.category?.nameRu || 'Другое';
if (!categorySpending[catId]) {
categorySpending[catId] = { name: catName, amount: 0 };
}
categorySpending[catId].amount += Number(t.amount);
});
const topCategories = Object.entries(categorySpending)
.map(([categoryId, data]) => ({
categoryId,
categoryName: data.name,
amount: data.amount,
percentage: totalExpenses > 0 ? (data.amount / totalExpenses) * 100 : 0,
}))
.sort((a, b) => b.amount - a.amount)
.slice(0, 5);
return {
month: format(date, 'yyyy-MM'),
totalIncome,
totalExpenses,
netSavings,
savingsRate,
topCategories,
};
}
/**
* Get spending trends over multiple months
*/
async getSpendingTrends(userId: string, months: number = 6): Promise<SpendingTrend[]> {
const trends: SpendingTrend[] = [];
let previousAmount = 0;
for (let i = months - 1; i >= 0; i--) {
const date = subMonths(new Date(), i);
const monthStart = startOfMonth(date);
const monthEnd = endOfMonth(date);
const transactions = await this.transactionRepository.find({
where: {
userId,
type: 'EXPENSE',
transactionDate: Between(monthStart, monthEnd),
},
});
const amount = transactions.reduce((sum, t) => sum + Number(t.amount), 0);
const change = previousAmount > 0 ? amount - previousAmount : 0;
const changePercent = previousAmount > 0 ? (change / previousAmount) * 100 : 0;
trends.push({
period: format(date, 'yyyy-MM'),
amount,
change,
changePercent,
});
previousAmount = amount;
}
return trends;
}
/**
* Get detailed category breakdown
*/
async getCategoryBreakdown(
userId: string,
startDate: Date,
endDate: Date,
type: 'INCOME' | 'EXPENSE' = 'EXPENSE',
): Promise<CategoryBreakdown[]> {
const transactions = await this.transactionRepository.find({
where: {
userId,
type,
transactionDate: Between(startDate, endDate),
},
relations: ['category'],
});
const totalAmount = transactions.reduce((sum, t) => sum + Number(t.amount), 0);
const categoryData: Record<string, {
name: string;
icon: string;
color: string;
amount: number;
count: number;
}> = {};
transactions.forEach(t => {
const catId = t.categoryId || 'other';
if (!categoryData[catId]) {
categoryData[catId] = {
name: t.category?.nameRu || 'Другое',
icon: t.category?.icon || 'help-circle',
color: t.category?.color || '#9E9E9E',
amount: 0,
count: 0,
};
}
categoryData[catId].amount += Number(t.amount);
categoryData[catId].count += 1;
});
return Object.entries(categoryData)
.map(([categoryId, data]) => ({
categoryId,
categoryName: data.name,
categoryIcon: data.icon,
categoryColor: data.color,
amount: data.amount,
percentage: totalAmount > 0 ? (data.amount / totalAmount) * 100 : 0,
transactionCount: data.count,
averageTransaction: data.count > 0 ? data.amount / data.count : 0,
}))
.sort((a, b) => b.amount - a.amount);
}
/**
* Get income vs expenses comparison
*/
async getIncomeVsExpenses(userId: string, months: number = 12): Promise<Array<{
period: string;
income: number;
expenses: number;
savings: number;
}>> {
const result: Array<{
period: string;
income: number;
expenses: number;
savings: number;
}> = [];
for (let i = months - 1; i >= 0; i--) {
const date = subMonths(new Date(), i);
const monthStart = startOfMonth(date);
const monthEnd = endOfMonth(date);
const transactions = await this.transactionRepository.find({
where: {
userId,
transactionDate: Between(monthStart, monthEnd),
},
});
const income = transactions
.filter(t => t.type === 'INCOME')
.reduce((sum, t) => sum + Number(t.amount), 0);
const expenses = transactions
.filter(t => t.type === 'EXPENSE')
.reduce((sum, t) => sum + Number(t.amount), 0);
result.push({
period: format(date, 'yyyy-MM'),
income,
expenses,
savings: income - expenses,
});
}
return result;
}
/**
* Calculate financial health score
*/
async getFinancialHealth(userId: string): Promise<FinancialHealth> {
const factors: FinancialHealth['factors'] = [];
let totalScore = 0;
// Get last 3 months data
const threeMonthsAgo = subMonths(new Date(), 3);
const transactions = await this.transactionRepository.find({
where: {
userId,
transactionDate: Between(threeMonthsAgo, new Date()),
},
});
const totalIncome = transactions
.filter(t => t.type === 'INCOME')
.reduce((sum, t) => sum + Number(t.amount), 0);
const totalExpenses = transactions
.filter(t => t.type === 'EXPENSE')
.reduce((sum, t) => sum + Number(t.amount), 0);
// Factor 1: Savings Rate (target: 20%+)
const savingsRate = totalIncome > 0 ? ((totalIncome - totalExpenses) / totalIncome) * 100 : 0;
const savingsScore = Math.min(100, (savingsRate / 20) * 100);
factors.push({
name: 'Норма сбережений',
score: savingsScore,
description: `Ваша норма сбережений: ${savingsRate.toFixed(1)}%`,
recommendation: savingsRate < 20 ? 'Рекомендуем увеличить сбережения до 20% от дохода' : undefined,
});
totalScore += savingsScore * 0.3;
// Factor 2: Budget Adherence
const currentBudget = await this.budgetRepository.findOne({
where: { userId, month: startOfMonth(new Date()) },
});
let budgetScore = 50; // Default if no budget
if (currentBudget) {
const budgetUsage = (currentBudget.totalSpent / Number(currentBudget.totalIncome)) * 100;
budgetScore = budgetUsage <= 100 ? 100 - Math.max(0, budgetUsage - 80) : 0;
}
factors.push({
name: 'Соблюдение бюджета',
score: budgetScore,
description: currentBudget ? `Использовано ${((currentBudget.totalSpent / Number(currentBudget.totalIncome)) * 100).toFixed(0)}% бюджета` : 'Бюджет не установлен',
recommendation: !currentBudget ? 'Создайте бюджет для лучшего контроля расходов' : undefined,
});
totalScore += budgetScore * 0.25;
// Factor 3: Goal Progress
const goals = await this.goalRepository.find({
where: { userId, status: 'ACTIVE' as any },
});
let goalScore = 50;
if (goals.length > 0) {
const avgProgress = goals.reduce((sum, g) => sum + g.progressPercent, 0) / goals.length;
goalScore = avgProgress;
}
factors.push({
name: 'Прогресс по целям',
score: goalScore,
description: goals.length > 0 ? `${goals.length} активных целей` : 'Нет активных целей',
recommendation: goals.length === 0 ? 'Создайте финансовые цели для мотивации' : undefined,
});
totalScore += goalScore * 0.2;
// Factor 4: Expense Consistency
const monthlyExpenses: number[] = [];
for (let i = 0; i < 3; i++) {
const date = subMonths(new Date(), i);
const monthStart = startOfMonth(date);
const monthEnd = endOfMonth(date);
const monthTransactions = transactions.filter(t => {
const tDate = new Date(t.transactionDate);
return t.type === 'EXPENSE' && tDate >= monthStart && tDate <= monthEnd;
});
monthlyExpenses.push(monthTransactions.reduce((sum, t) => sum + Number(t.amount), 0));
}
const avgExpense = monthlyExpenses.reduce((a, b) => a + b, 0) / monthlyExpenses.length;
const variance = monthlyExpenses.reduce((sum, e) => sum + Math.pow(e - avgExpense, 2), 0) / monthlyExpenses.length;
const stdDev = Math.sqrt(variance);
const consistencyScore = avgExpense > 0 ? Math.max(0, 100 - (stdDev / avgExpense) * 100) : 50;
factors.push({
name: 'Стабильность расходов',
score: consistencyScore,
description: `Отклонение расходов: ${((stdDev / avgExpense) * 100).toFixed(0)}%`,
recommendation: consistencyScore < 70 ? 'Старайтесь поддерживать стабильный уровень расходов' : undefined,
});
totalScore += consistencyScore * 0.25;
// Calculate grade
let grade: FinancialHealth['grade'];
if (totalScore >= 90) grade = 'A';
else if (totalScore >= 80) grade = 'B';
else if (totalScore >= 70) grade = 'C';
else if (totalScore >= 60) grade = 'D';
else grade = 'F';
return {
score: Math.round(totalScore),
grade,
factors,
};
}
/**
* Get yearly summary
*/
async getYearlySummary(userId: string, year: number = new Date().getFullYear()): Promise<{
year: number;
totalIncome: number;
totalExpenses: number;
totalSavings: number;
averageMonthlyIncome: number;
averageMonthlyExpenses: number;
bestMonth: { month: string; savings: number };
worstMonth: { month: string; savings: number };
topExpenseCategories: Array<{ name: string; amount: number; percentage: number }>;
}> {
const yearStart = startOfYear(new Date(year, 0, 1));
const yearEnd = endOfYear(new Date(year, 0, 1));
const transactions = await this.transactionRepository.find({
where: {
userId,
transactionDate: Between(yearStart, yearEnd),
},
relations: ['category'],
});
const totalIncome = transactions
.filter(t => t.type === 'INCOME')
.reduce((sum, t) => sum + Number(t.amount), 0);
const totalExpenses = transactions
.filter(t => t.type === 'EXPENSE')
.reduce((sum, t) => sum + Number(t.amount), 0);
// Calculate monthly data
const monthlyData: Record<string, { income: number; expenses: number }> = {};
transactions.forEach(t => {
const month = format(new Date(t.transactionDate), 'yyyy-MM');
if (!monthlyData[month]) {
monthlyData[month] = { income: 0, expenses: 0 };
}
if (t.type === 'INCOME') {
monthlyData[month].income += Number(t.amount);
} else {
monthlyData[month].expenses += Number(t.amount);
}
});
const months = Object.entries(monthlyData);
const monthCount = months.length || 1;
// Find best and worst months
let bestMonth = { month: '', savings: -Infinity };
let worstMonth = { month: '', savings: Infinity };
months.forEach(([month, data]) => {
const savings = data.income - data.expenses;
if (savings > bestMonth.savings) {
bestMonth = { month, savings };
}
if (savings < worstMonth.savings) {
worstMonth = { month, savings };
}
});
// Top expense categories
const categoryExpenses: Record<string, { name: string; amount: number }> = {};
transactions
.filter(t => t.type === 'EXPENSE')
.forEach(t => {
const catName = t.category?.nameRu || 'Другое';
if (!categoryExpenses[catName]) {
categoryExpenses[catName] = { name: catName, amount: 0 };
}
categoryExpenses[catName].amount += Number(t.amount);
});
const topExpenseCategories = Object.values(categoryExpenses)
.map(cat => ({
name: cat.name,
amount: cat.amount,
percentage: totalExpenses > 0 ? (cat.amount / totalExpenses) * 100 : 0,
}))
.sort((a, b) => b.amount - a.amount)
.slice(0, 10);
return {
year,
totalIncome,
totalExpenses,
totalSavings: totalIncome - totalExpenses,
averageMonthlyIncome: totalIncome / monthCount,
averageMonthlyExpenses: totalExpenses / monthCount,
bestMonth: bestMonth.month ? bestMonth : { month: 'N/A', savings: 0 },
worstMonth: worstMonth.month ? worstMonth : { month: 'N/A', savings: 0 },
topExpenseCategories,
};
}
}

View File

@ -0,0 +1,360 @@
import {
Controller,
Post,
Get,
Put,
Body,
Req,
Res,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBody,
ApiBearerAuth,
ApiCookieAuth,
} from '@nestjs/swagger';
import { Request, Response } from 'express';
import { AuthService } from './auth.service';
import { RegisterDto, LoginDto, UpdateProfileDto, ChangePasswordDto, AuthResponseDto, LogoutResponseDto } from './dto';
import { Public } from '../../common/decorators/public.decorator';
import { CurrentUser, JwtPayload } from '../../common/decorators/user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { ConfigService } from '@nestjs/config';
@ApiTags('Аутентификация')
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly configService: ConfigService,
) {}
@Public()
@Post('register')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Регистрация нового пользователя' })
@ApiBody({ type: RegisterDto })
@ApiResponse({
status: 201,
description: 'Пользователь успешно зарегистрирован',
type: AuthResponseDto,
})
@ApiResponse({ status: 400, description: 'Некорректные данные' })
@ApiResponse({ status: 409, description: 'Пользователь уже существует' })
async register(
@Body() dto: RegisterDto,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<AuthResponseDto> {
const metadata = {
ipAddress: req.ip,
userAgent: req.get('user-agent'),
};
const user = await this.authService.register(dto, metadata);
const { tokens } = await this.authService.login(
{ email: dto.email, password: dto.password },
metadata,
);
this.setTokenCookies(res, tokens.accessToken, tokens.refreshToken);
return {
userId: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
tokens: {
accessToken: tokens.accessToken,
expiresIn: tokens.expiresIn,
},
};
}
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Вход в систему' })
@ApiBody({ type: LoginDto })
@ApiResponse({
status: 200,
description: 'Успешный вход',
type: AuthResponseDto,
})
@ApiResponse({ status: 401, description: 'Неверный email или пароль' })
async login(
@Body() dto: LoginDto,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<AuthResponseDto> {
const metadata = {
ipAddress: req.ip,
userAgent: req.get('user-agent'),
};
const { user, tokens } = await this.authService.login(dto, metadata);
this.setTokenCookies(res, tokens.accessToken, tokens.refreshToken);
return {
userId: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
tokens: {
accessToken: tokens.accessToken,
expiresIn: tokens.expiresIn,
},
};
}
@Public()
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Обновление токена доступа' })
@ApiCookieAuth()
@ApiResponse({
status: 200,
description: 'Токен успешно обновлен',
})
@ApiResponse({ status: 401, description: 'Недействительный refresh токен' })
async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
const refreshToken = req.cookies?.refresh_token;
if (!refreshToken) {
res.status(HttpStatus.UNAUTHORIZED).json({
statusCode: 401,
message: 'Refresh токен не найден',
});
return;
}
// Decode token to get user ID
const decoded = this.decodeToken(refreshToken);
if (!decoded) {
res.status(HttpStatus.UNAUTHORIZED).json({
statusCode: 401,
message: 'Недействительный токен',
});
return;
}
const metadata = {
ipAddress: req.ip,
userAgent: req.get('user-agent'),
};
const tokens = await this.authService.refreshTokens(
decoded.sub,
refreshToken,
metadata,
);
this.setTokenCookies(res, tokens.accessToken, tokens.refreshToken);
return {
accessToken: tokens.accessToken,
expiresIn: tokens.expiresIn,
};
}
@UseGuards(JwtAuthGuard)
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'Выход из системы' })
@ApiResponse({
status: 200,
description: 'Успешный выход',
type: LogoutResponseDto,
})
async logout(
@CurrentUser() user: JwtPayload,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<LogoutResponseDto> {
const refreshToken = req.cookies?.refresh_token;
const metadata = {
ipAddress: req.ip,
userAgent: req.get('user-agent'),
};
await this.authService.logout(user.sub, refreshToken, metadata);
this.clearTokenCookies(res);
return { message: 'Выход выполнен успешно' };
}
@UseGuards(JwtAuthGuard)
@Post('logout-all')
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'Выход со всех устройств' })
@ApiResponse({
status: 200,
description: 'Успешный выход со всех устройств',
type: LogoutResponseDto,
})
async logoutAll(
@CurrentUser() user: JwtPayload,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<LogoutResponseDto> {
const metadata = {
ipAddress: req.ip,
userAgent: req.get('user-agent'),
};
await this.authService.logoutAll(user.sub, metadata);
this.clearTokenCookies(res);
return { message: 'Выход со всех устройств выполнен успешно' };
}
@UseGuards(JwtAuthGuard)
@Get('profile')
@ApiBearerAuth()
@ApiOperation({ summary: 'Получение профиля пользователя' })
@ApiResponse({
status: 200,
description: 'Профиль пользователя',
})
async getProfile(@CurrentUser() user: JwtPayload) {
const profile = await this.authService.getProfile(user.sub);
return {
id: profile.id,
email: profile.email,
phone: profile.phone,
firstName: profile.firstName,
lastName: profile.lastName,
currency: profile.currency,
language: profile.language,
timezone: profile.timezone,
monthlyIncome: profile.monthlyIncome,
isEmailVerified: profile.isEmailVerified,
isPhoneVerified: profile.isPhoneVerified,
createdAt: profile.createdAt,
};
}
@UseGuards(JwtAuthGuard)
@Put('profile')
@ApiBearerAuth()
@ApiOperation({ summary: 'Обновление профиля пользователя' })
@ApiBody({ type: UpdateProfileDto })
@ApiResponse({
status: 200,
description: 'Профиль успешно обновлен',
})
async updateProfile(
@CurrentUser() user: JwtPayload,
@Body() dto: UpdateProfileDto,
@Req() req: Request,
) {
const metadata = {
ipAddress: req.ip,
userAgent: req.get('user-agent'),
};
const profile = await this.authService.updateProfile(user.sub, dto, metadata);
return {
id: profile.id,
email: profile.email,
phone: profile.phone,
firstName: profile.firstName,
lastName: profile.lastName,
currency: profile.currency,
language: profile.language,
timezone: profile.timezone,
monthlyIncome: profile.monthlyIncome,
};
}
@UseGuards(JwtAuthGuard)
@Post('change-password')
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'Изменение пароля' })
@ApiBody({ type: ChangePasswordDto })
@ApiResponse({
status: 200,
description: 'Пароль успешно изменен',
})
@ApiResponse({ status: 400, description: 'Неверный текущий пароль' })
async changePassword(
@CurrentUser() user: JwtPayload,
@Body() dto: ChangePasswordDto,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
const metadata = {
ipAddress: req.ip,
userAgent: req.get('user-agent'),
};
await this.authService.changePassword(user.sub, dto, metadata);
this.clearTokenCookies(res);
return { message: 'Пароль успешно изменен. Войдите снова.' };
}
/**
* Set HTTP-only cookies for tokens
*/
private setTokenCookies(res: Response, accessToken: string, refreshToken: string): void {
const isProduction = this.configService.get('app.nodeEnv') === 'production';
const cookieDomain = this.configService.get<string>('jwt.cookieDomain');
const commonOptions = {
httpOnly: true,
secure: isProduction,
sameSite: 'strict' as const,
domain: cookieDomain,
};
res.cookie('access_token', accessToken, {
...commonOptions,
maxAge: 15 * 60 * 1000, // 15 minutes
});
res.cookie('refresh_token', refreshToken, {
...commonOptions,
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/auth', // Only send to auth endpoints
});
}
/**
* Clear token cookies
*/
private clearTokenCookies(res: Response): void {
const cookieDomain = this.configService.get<string>('jwt.cookieDomain');
res.clearCookie('access_token', { domain: cookieDomain });
res.clearCookie('refresh_token', { domain: cookieDomain, path: '/auth' });
}
/**
* Decode JWT token without verification
*/
private decodeToken(token: string): JwtPayload | null {
try {
return this.configService.get('jwt.refreshSecret')
? (JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()) as JwtPayload)
: null;
} catch {
return null;
}
}
}

View File

@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { User, RefreshToken, AuditLog } from './entities';
import { JwtStrategy, JwtRefreshStrategy } from './strategies';
@Module({
imports: [
TypeOrmModule.forFeature([User, RefreshToken, AuditLog]),
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('jwt.secret'),
signOptions: {
expiresIn: configService.get<string>('jwt.accessExpiry') || '15m',
},
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, JwtRefreshStrategy],
exports: [AuthService, JwtStrategy],
})
export class AuthModule {}

View File

@ -0,0 +1,440 @@
import {
Injectable,
UnauthorizedException,
ConflictException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, MoreThan } from 'typeorm';
import { User } from './entities/user.entity';
import { RefreshToken } from './entities/refresh-token.entity';
import { AuditLog } from './entities/audit-log.entity';
import { RegisterDto, LoginDto, UpdateProfileDto, ChangePasswordDto } from './dto';
import { ErrorMessages } from '../../common/constants/error-messages';
import {
hashPassword,
comparePassword,
generateSecureToken,
hashToken,
normalizeRussianPhone,
} from '../../common/utils/security.utils';
interface TokenPair {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
interface RequestMetadata {
ipAddress?: string;
userAgent?: string;
}
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
@InjectRepository(RefreshToken)
private refreshTokenRepository: Repository<RefreshToken>,
@InjectRepository(AuditLog)
private auditLogRepository: Repository<AuditLog>,
private jwtService: JwtService,
private configService: ConfigService,
) {}
/**
* Register a new user
*/
async register(dto: RegisterDto, metadata: RequestMetadata): Promise<User> {
// Check if user exists
const existingUser = await this.userRepository.findOne({
where: { email: dto.email.toLowerCase() },
});
if (existingUser) {
throw new ConflictException(ErrorMessages.USER_ALREADY_EXISTS);
}
// Check phone uniqueness if provided
if (dto.phone) {
const normalizedPhone = normalizeRussianPhone(dto.phone);
const existingPhone = await this.userRepository.findOne({
where: { phone: normalizedPhone },
});
if (existingPhone) {
throw new ConflictException(ErrorMessages.PHONE_ALREADY_EXISTS);
}
}
// Hash password
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds') || 12;
const passwordHash = await hashPassword(dto.password, saltRounds);
// Create user
const user = this.userRepository.create({
email: dto.email.toLowerCase(),
passwordHash,
firstName: dto.firstName,
lastName: dto.lastName,
phone: dto.phone ? normalizeRussianPhone(dto.phone) : undefined,
});
const savedUser = await this.userRepository.save(user);
// Audit log
await this.createAuditLog({
userId: savedUser.id,
action: 'REGISTER',
entityType: 'USER',
entityId: savedUser.id,
newValues: { email: savedUser.email },
...metadata,
});
this.logger.log(`User registered: ${savedUser.email}`);
return savedUser;
}
/**
* Login user and return tokens
*/
async login(dto: LoginDto, metadata: RequestMetadata): Promise<{ user: User; tokens: TokenPair }> {
const user = await this.userRepository.findOne({
where: { email: dto.email.toLowerCase() },
});
if (!user) {
throw new UnauthorizedException(ErrorMessages.INVALID_CREDENTIALS);
}
// Check if account is locked
if (user.isLocked()) {
throw new UnauthorizedException(ErrorMessages.ACCOUNT_LOCKED);
}
// Verify password
const isPasswordValid = await comparePassword(dto.password, user.passwordHash);
if (!isPasswordValid) {
await this.handleFailedLogin(user);
throw new UnauthorizedException(ErrorMessages.INVALID_CREDENTIALS);
}
// Reset failed attempts on successful login
if (user.failedLoginAttempts > 0) {
user.failedLoginAttempts = 0;
user.lockedUntil = null as any;
}
user.lastLoginAt = new Date();
await this.userRepository.save(user);
// Generate tokens
const tokens = await this.generateTokens(user, metadata);
// Audit log
await this.createAuditLog({
userId: user.id,
action: 'LOGIN',
entityType: 'USER',
entityId: user.id,
...metadata,
});
this.logger.log(`User logged in: ${user.email}`);
return { user, tokens };
}
/**
* Refresh access token
*/
async refreshTokens(
userId: string,
refreshToken: string,
metadata: RequestMetadata,
): Promise<TokenPair> {
const tokenHash = hashToken(refreshToken);
const storedToken = await this.refreshTokenRepository.findOne({
where: {
userId,
tokenHash,
isRevoked: false,
expiresAt: MoreThan(new Date()),
},
});
if (!storedToken) {
// Potential token reuse attack - revoke all user tokens
await this.revokeAllUserTokens(userId);
throw new UnauthorizedException(ErrorMessages.TOKEN_INVALID);
}
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user || !user.isActive) {
throw new UnauthorizedException(ErrorMessages.ACCESS_DENIED);
}
// Revoke old token
storedToken.isRevoked = true;
await this.refreshTokenRepository.save(storedToken);
// Generate new tokens
const tokens = await this.generateTokens(user, metadata);
// Link old token to new one (for token reuse detection)
storedToken.replacedByTokenHash = hashToken(tokens.refreshToken);
await this.refreshTokenRepository.save(storedToken);
return tokens;
}
/**
* Logout user
*/
async logout(userId: string, refreshToken: string, metadata: RequestMetadata): Promise<void> {
if (refreshToken) {
const tokenHash = hashToken(refreshToken);
await this.refreshTokenRepository.update(
{ userId, tokenHash },
{ isRevoked: true },
);
}
await this.createAuditLog({
userId,
action: 'LOGOUT',
entityType: 'USER',
entityId: userId,
...metadata,
});
this.logger.log(`User logged out: ${userId}`);
}
/**
* Logout from all devices
*/
async logoutAll(userId: string, metadata: RequestMetadata): Promise<void> {
await this.revokeAllUserTokens(userId);
await this.createAuditLog({
userId,
action: 'LOGOUT_ALL',
entityType: 'USER',
entityId: userId,
...metadata,
});
this.logger.log(`User logged out from all devices: ${userId}`);
}
/**
* Get user profile
*/
async getProfile(userId: string): Promise<User> {
const user = await this.userRepository.findOne({
where: { id: userId },
});
if (!user) {
throw new UnauthorizedException(ErrorMessages.USER_NOT_FOUND);
}
return user;
}
/**
* Update user profile
*/
async updateProfile(
userId: string,
dto: UpdateProfileDto,
metadata: RequestMetadata,
): Promise<User> {
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new UnauthorizedException(ErrorMessages.USER_NOT_FOUND);
}
const oldValues = { ...user };
// Check phone uniqueness if changing
if (dto.phone && dto.phone !== user.phone) {
const normalizedPhone = normalizeRussianPhone(dto.phone);
const existingPhone = await this.userRepository.findOne({
where: { phone: normalizedPhone },
});
if (existingPhone && existingPhone.id !== userId) {
throw new ConflictException(ErrorMessages.PHONE_ALREADY_EXISTS);
}
user.phone = normalizedPhone;
}
// Update fields
if (dto.firstName !== undefined) user.firstName = dto.firstName;
if (dto.lastName !== undefined) user.lastName = dto.lastName;
if (dto.monthlyIncome !== undefined) user.monthlyIncome = dto.monthlyIncome;
if (dto.timezone !== undefined) user.timezone = dto.timezone;
if (dto.language !== undefined) user.language = dto.language;
const savedUser = await this.userRepository.save(user);
await this.createAuditLog({
userId,
action: 'UPDATE_PROFILE',
entityType: 'USER',
entityId: userId,
oldValues: { firstName: oldValues.firstName, lastName: oldValues.lastName },
newValues: { firstName: savedUser.firstName, lastName: savedUser.lastName },
...metadata,
});
return savedUser;
}
/**
* Change password
*/
async changePassword(
userId: string,
dto: ChangePasswordDto,
metadata: RequestMetadata,
): Promise<void> {
if (dto.newPassword !== dto.confirmPassword) {
throw new BadRequestException(ErrorMessages.PASSWORDS_DO_NOT_MATCH);
}
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new UnauthorizedException(ErrorMessages.USER_NOT_FOUND);
}
const isCurrentPasswordValid = await comparePassword(dto.currentPassword, user.passwordHash);
if (!isCurrentPasswordValid) {
throw new BadRequestException(ErrorMessages.INVALID_CREDENTIALS);
}
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds') || 12;
user.passwordHash = await hashPassword(dto.newPassword, saltRounds);
await this.userRepository.save(user);
// Revoke all refresh tokens (force re-login on all devices)
await this.revokeAllUserTokens(userId);
await this.createAuditLog({
userId,
action: 'CHANGE_PASSWORD',
entityType: 'USER',
entityId: userId,
...metadata,
});
this.logger.log(`Password changed for user: ${userId}`);
}
/**
* Generate access and refresh tokens
*/
private async generateTokens(user: User, metadata: RequestMetadata): Promise<TokenPair> {
const tokenId = generateSecureToken(16);
const accessPayload = {
sub: user.id,
email: user.email,
};
const refreshPayload = {
sub: user.id,
email: user.email,
tokenId,
};
const accessToken = this.jwtService.sign(accessPayload, {
secret: this.configService.get<string>('jwt.secret'),
expiresIn: this.configService.get<string>('jwt.accessExpiry') || '15m',
});
const refreshToken = this.jwtService.sign(refreshPayload, {
secret: this.configService.get<string>('jwt.refreshSecret'),
expiresIn: this.configService.get<string>('jwt.refreshExpiry') || '7d',
});
// Store refresh token hash
const refreshTokenEntity = this.refreshTokenRepository.create({
userId: user.id,
tokenHash: hashToken(refreshToken),
userAgent: metadata.userAgent,
ipAddress: metadata.ipAddress,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
});
await this.refreshTokenRepository.save(refreshTokenEntity);
return {
accessToken,
refreshToken,
expiresIn: 900, // 15 minutes in seconds
};
}
/**
* Handle failed login attempt
*/
private async handleFailedLogin(user: User): Promise<void> {
const maxAttempts = this.configService.get<number>('security.maxLoginAttempts') || 10;
const lockoutMinutes = this.configService.get<number>('security.lockoutDurationMinutes') || 30;
user.failedLoginAttempts += 1;
if (user.failedLoginAttempts >= maxAttempts) {
user.lockedUntil = new Date(Date.now() + lockoutMinutes * 60 * 1000);
this.logger.warn(`Account locked due to failed attempts: ${user.email}`);
}
await this.userRepository.save(user);
}
/**
* Revoke all refresh tokens for a user
*/
private async revokeAllUserTokens(userId: string): Promise<void> {
await this.refreshTokenRepository.update(
{ userId, isRevoked: false },
{ isRevoked: true },
);
}
/**
* Create audit log entry
*/
private async createAuditLog(data: {
userId: string;
action: string;
entityType: string;
entityId?: string;
oldValues?: Record<string, any>;
newValues?: Record<string, any>;
ipAddress?: string;
userAgent?: string;
}): Promise<void> {
const auditLog = this.auditLogRepository.create(data);
await this.auditLogRepository.save(auditLog);
}
}

View File

@ -0,0 +1,32 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MinLength, MaxLength, Matches } from 'class-validator';
export class ChangePasswordDto {
@ApiProperty({
description: 'Текущий пароль',
example: 'OldPass123!',
})
@IsString({ message: 'Текущий пароль должен быть строкой' })
@MinLength(1, { message: 'Текущий пароль не может быть пустым' })
currentPassword: string;
@ApiProperty({
description: 'Новый пароль (минимум 12 символов)',
example: 'NewSecurePass123!',
})
@IsString({ message: 'Новый пароль должен быть строкой' })
@MinLength(12, { message: 'Новый пароль должен содержать минимум 12 символов' })
@MaxLength(128, { message: 'Новый пароль не должен превышать 128 символов' })
@Matches(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
{ message: 'Новый пароль должен содержать заглавные и строчные буквы, цифры и спецсимволы' },
)
newPassword: string;
@ApiProperty({
description: 'Подтверждение нового пароля',
example: 'NewSecurePass123!',
})
@IsString({ message: 'Подтверждение пароля должно быть строкой' })
confirmPassword: string;
}

View File

@ -0,0 +1,5 @@
export * from './register.dto';
export * from './login.dto';
export * from './tokens.dto';
export * from './update-profile.dto';
export * from './change-password.dto';

View File

@ -0,0 +1,19 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString, MinLength } from 'class-validator';
export class LoginDto {
@ApiProperty({
description: 'Email пользователя',
example: 'user@example.com',
})
@IsEmail({}, { message: 'Некорректный формат email' })
email: string;
@ApiProperty({
description: 'Пароль пользователя',
example: 'SecurePass123!',
})
@IsString({ message: 'Пароль должен быть строкой' })
@MinLength(1, { message: 'Пароль не может быть пустым' })
password: string;
}

View File

@ -0,0 +1,60 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsEmail,
IsString,
MinLength,
MaxLength,
Matches,
IsOptional,
IsPhoneNumber,
} from 'class-validator';
export class RegisterDto {
@ApiProperty({
description: 'Email пользователя',
example: 'user@example.com',
})
@IsEmail({}, { message: 'Некорректный формат email' })
@MaxLength(255, { message: 'Email не должен превышать 255 символов' })
email: string;
@ApiProperty({
description: 'Пароль (минимум 12 символов, заглавные и строчные буквы, цифры, спецсимволы)',
example: 'SecurePass123!',
minLength: 12,
})
@IsString({ message: 'Пароль должен быть строкой' })
@MinLength(12, { message: 'Пароль должен содержать минимум 12 символов' })
@MaxLength(128, { message: 'Пароль не должен превышать 128 символов' })
@Matches(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
{ message: 'Пароль должен содержать заглавные и строчные буквы, цифры и спецсимволы (@$!%*?&)' },
)
password: string;
@ApiPropertyOptional({
description: 'Имя пользователя',
example: 'Иван',
})
@IsOptional()
@IsString({ message: 'Имя должно быть строкой' })
@MaxLength(100, { message: 'Имя не должно превышать 100 символов' })
firstName?: string;
@ApiPropertyOptional({
description: 'Фамилия пользователя',
example: 'Иванов',
})
@IsOptional()
@IsString({ message: 'Фамилия должна быть строкой' })
@MaxLength(100, { message: 'Фамилия не должна превышать 100 символов' })
lastName?: string;
@ApiPropertyOptional({
description: 'Номер телефона в формате +7XXXXXXXXXX',
example: '+79991234567',
})
@IsOptional()
@Matches(/^(\+7|8)\d{10}$/, { message: 'Некорректный формат телефона. Используйте формат +7XXXXXXXXXX' })
phone?: string;
}

View File

@ -0,0 +1,71 @@
import { ApiProperty } from '@nestjs/swagger';
export class TokensDto {
@ApiProperty({
description: 'Access токен для авторизации запросов',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
})
accessToken: string;
@ApiProperty({
description: 'Время истечения access токена в секундах',
example: 900,
})
expiresIn: number;
}
export class AuthResponseDto {
@ApiProperty({
description: 'ID пользователя',
example: '123e4567-e89b-12d3-a456-426614174000',
})
userId: string;
@ApiProperty({
description: 'Email пользователя',
example: 'user@example.com',
})
email: string;
@ApiProperty({
description: 'Имя пользователя',
example: 'Иван',
nullable: true,
})
firstName: string | null;
@ApiProperty({
description: 'Фамилия пользователя',
example: 'Иванов',
nullable: true,
})
lastName: string | null;
@ApiProperty({
description: 'Токены доступа',
type: TokensDto,
})
tokens: TokensDto;
}
export class RefreshTokenResponseDto {
@ApiProperty({
description: 'Новый access токен',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
})
accessToken: string;
@ApiProperty({
description: 'Время истечения в секундах',
example: 900,
})
expiresIn: number;
}
export class LogoutResponseDto {
@ApiProperty({
description: 'Сообщение об успешном выходе',
example: 'Выход выполнен успешно',
})
message: string;
}

View File

@ -0,0 +1,65 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
MaxLength,
IsOptional,
Matches,
IsNumber,
Min,
Max,
} from 'class-validator';
export class UpdateProfileDto {
@ApiPropertyOptional({
description: 'Имя пользователя',
example: 'Иван',
})
@IsOptional()
@IsString({ message: 'Имя должно быть строкой' })
@MaxLength(100, { message: 'Имя не должно превышать 100 символов' })
firstName?: string;
@ApiPropertyOptional({
description: 'Фамилия пользователя',
example: 'Иванов',
})
@IsOptional()
@IsString({ message: 'Фамилия должна быть строкой' })
@MaxLength(100, { message: 'Фамилия не должна превышать 100 символов' })
lastName?: string;
@ApiPropertyOptional({
description: 'Номер телефона в формате +7XXXXXXXXXX',
example: '+79991234567',
})
@IsOptional()
@Matches(/^(\+7|8)\d{10}$/, { message: 'Некорректный формат телефона' })
phone?: string;
@ApiPropertyOptional({
description: 'Ежемесячный доход в рублях',
example: 150000,
})
@IsOptional()
@IsNumber({}, { message: 'Доход должен быть числом' })
@Min(0, { message: 'Доход не может быть отрицательным' })
@Max(1000000000, { message: 'Доход превышает допустимый лимит' })
monthlyIncome?: number;
@ApiPropertyOptional({
description: 'Часовой пояс',
example: 'Europe/Moscow',
})
@IsOptional()
@IsString({ message: 'Часовой пояс должен быть строкой' })
timezone?: string;
@ApiPropertyOptional({
description: 'Язык интерфейса',
example: 'ru',
})
@IsOptional()
@IsString({ message: 'Язык должен быть строкой' })
@Matches(/^(ru|en)$/, { message: 'Поддерживаемые языки: ru, en' })
language?: string;
}

View File

@ -0,0 +1,50 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from './user.entity';
@Entity('audit_logs')
export class AuditLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId: string;
@Column({ length: 50 })
action: string;
@Column({ name: 'entity_type', length: 50 })
entityType: string;
@Column({ name: 'entity_id', type: 'uuid', nullable: true })
entityId: string;
@Column({ name: 'old_values', type: 'jsonb', nullable: true })
oldValues: Record<string, any>;
@Column({ name: 'new_values', type: 'jsonb', nullable: true })
newValues: Record<string, any>;
@Column({ name: 'ip_address', type: 'inet', nullable: true })
ipAddress: string;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent: string;
@Index()
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
// Relations
@ManyToOne(() => User, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'user_id' })
user: User;
}

View File

@ -0,0 +1,3 @@
export * from './user.entity';
export * from './refresh-token.entity';
export * from './audit-log.entity';

View File

@ -0,0 +1,56 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { User } from './user.entity';
@Entity('refresh_tokens')
export class RefreshToken {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({ name: 'token_hash', length: 255 })
tokenHash: string;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent: string;
@Column({ name: 'ip_address', type: 'inet', nullable: true })
ipAddress: string;
@Index()
@Column({ name: 'expires_at', type: 'timestamp' })
expiresAt: Date;
@Column({ name: 'is_revoked', default: false })
isRevoked: boolean;
@Column({ name: 'replaced_by_token_hash', length: 255, nullable: true })
replacedByTokenHash: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
// Relations
@ManyToOne(() => User, (user) => user.refreshTokens, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
// Helper methods
isExpired(): boolean {
return new Date() > this.expiresAt;
}
isValid(): boolean {
return !this.isRevoked && !this.isExpired();
}
}

View File

@ -0,0 +1,95 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
OneToMany,
Index,
} from 'typeorm';
import { Exclude } from 'class-transformer';
import { RefreshToken } from './refresh-token.entity';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ unique: true, length: 255 })
email: string;
@Index()
@Column({ unique: true, length: 20, nullable: true })
phone: string;
@Exclude()
@Column({ name: 'password_hash', length: 255 })
passwordHash: string;
@Column({ name: 'first_name', length: 100, nullable: true })
firstName: string;
@Column({ name: 'last_name', length: 100, nullable: true })
lastName: string;
@Column({ length: 3, default: 'RUB' })
currency: string;
@Column({ length: 10, default: 'ru' })
language: string;
@Column({ length: 50, default: 'Europe/Moscow' })
timezone: string;
// Security fields
@Column({ name: 'is_email_verified', default: false })
isEmailVerified: boolean;
@Column({ name: 'is_phone_verified', default: false })
isPhoneVerified: boolean;
@Column({ name: 'is_active', default: true })
isActive: boolean;
@Column({ name: 'failed_login_attempts', default: 0 })
failedLoginAttempts: number;
@Column({ name: 'locked_until', type: 'timestamp', nullable: true })
lockedUntil: Date;
@Column({ name: 'last_login_at', type: 'timestamp', nullable: true })
lastLoginAt: Date;
// Financial preferences
@Column({ name: 'monthly_income', type: 'decimal', precision: 15, scale: 2, default: 0 })
monthlyIncome: number;
@Column({ name: 'financial_goals', type: 'jsonb', nullable: true })
financialGoals: Record<string, any>;
// Timestamps
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@DeleteDateColumn({ name: 'deleted_at' })
deletedAt: Date;
// Relations
@OneToMany(() => RefreshToken, (token) => token.user)
refreshTokens: RefreshToken[];
// Helper methods
get fullName(): string {
return [this.firstName, this.lastName].filter(Boolean).join(' ');
}
isLocked(): boolean {
if (!this.lockedUntil) return false;
return new Date() < this.lockedUntil;
}
}

View File

@ -0,0 +1,2 @@
export * from './jwt.strategy';
export * from './jwt-refresh.strategy';

View File

@ -0,0 +1,44 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Request } from 'express';
import { ErrorMessages } from '../../../common/constants/error-messages';
export interface RefreshTokenPayload {
sub: string;
email: string;
tokenId: string;
iat?: number;
exp?: number;
}
@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
constructor(private configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
// Extract from cookie
(request: Request) => {
return request?.cookies?.refresh_token;
},
]),
ignoreExpiration: false,
secretOrKey: configService.get<string>('jwt.refreshSecret'),
passReqToCallback: true,
});
}
async validate(request: Request, payload: RefreshTokenPayload): Promise<RefreshTokenPayload & { refreshToken: string }> {
const refreshToken = request?.cookies?.refresh_token;
if (!refreshToken) {
throw new UnauthorizedException(ErrorMessages.TOKEN_INVALID);
}
return {
...payload,
refreshToken,
};
}
}

View File

@ -0,0 +1,59 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Request } from 'express';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../entities/user.entity';
import { ErrorMessages } from '../../../common/constants/error-messages';
export interface JwtPayload {
sub: string;
email: string;
iat?: number;
exp?: number;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private configService: ConfigService,
@InjectRepository(User)
private userRepository: Repository<User>,
) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
// First try to extract from cookie
(request: Request) => {
return request?.cookies?.access_token;
},
// Fallback to Authorization header
ExtractJwt.fromAuthHeaderAsBearerToken(),
]),
ignoreExpiration: false,
secretOrKey: configService.get<string>('jwt.secret'),
});
}
async validate(payload: JwtPayload): Promise<JwtPayload> {
const user = await this.userRepository.findOne({
where: { id: payload.sub },
select: ['id', 'email', 'isActive', 'lockedUntil'],
});
if (!user) {
throw new UnauthorizedException(ErrorMessages.USER_NOT_FOUND);
}
if (!user.isActive) {
throw new UnauthorizedException(ErrorMessages.ACCESS_DENIED);
}
if (user.isLocked()) {
throw new UnauthorizedException(ErrorMessages.ACCOUNT_LOCKED);
}
return payload;
}
}

View File

@ -0,0 +1,104 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Query,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { BudgetsService } from './budgets.service';
import { CreateBudgetDto, UpdateBudgetDto } from './dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser, JwtPayload } from '../../common/decorators/user.decorator';
@ApiTags('Бюджеты (50/30/20)')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('budgets')
export class BudgetsController {
constructor(private readonly budgetsService: BudgetsService) {}
@Get()
@ApiOperation({ summary: 'Получение всех бюджетов пользователя' })
@ApiResponse({ status: 200, description: 'Список бюджетов' })
async findAll(@CurrentUser() user: JwtPayload) {
return this.budgetsService.findAll(user.sub);
}
@Get('current')
@ApiOperation({ summary: 'Получение бюджета текущего месяца' })
@ApiResponse({ status: 200, description: 'Бюджет текущего месяца' })
async findCurrent(@CurrentUser() user: JwtPayload) {
return this.budgetsService.findCurrent(user.sub);
}
@Get('month')
@ApiOperation({ summary: 'Получение бюджета за конкретный месяц' })
@ApiQuery({ name: 'month', example: '2024-01-01', description: 'Первый день месяца' })
@ApiResponse({ status: 200, description: 'Бюджет' })
@ApiResponse({ status: 404, description: 'Бюджет не найден' })
async findByMonth(
@CurrentUser() user: JwtPayload,
@Query('month') month: string,
) {
return this.budgetsService.findByMonth(user.sub, month);
}
@Get('progress')
@ApiOperation({ summary: 'Получение прогресса по бюджету' })
@ApiQuery({ name: 'month', example: '2024-01-01' })
@ApiResponse({ status: 200, description: 'Прогресс по категориям 50/30/20' })
async getProgress(
@CurrentUser() user: JwtPayload,
@Query('month') month: string,
) {
return this.budgetsService.getProgress(user.sub, month);
}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Создание бюджета на месяц' })
@ApiResponse({ status: 201, description: 'Бюджет создан' })
@ApiResponse({ status: 409, description: 'Бюджет на этот месяц уже существует' })
async create(
@CurrentUser() user: JwtPayload,
@Body() dto: CreateBudgetDto,
) {
return this.budgetsService.create(user.sub, dto);
}
@Put()
@ApiOperation({ summary: 'Обновление бюджета' })
@ApiQuery({ name: 'month', example: '2024-01-01' })
@ApiResponse({ status: 200, description: 'Бюджет обновлен' })
async update(
@CurrentUser() user: JwtPayload,
@Query('month') month: string,
@Body() dto: UpdateBudgetDto,
) {
return this.budgetsService.update(user.sub, month, dto);
}
@Delete()
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Удаление бюджета' })
@ApiQuery({ name: 'month', example: '2024-01-01' })
@ApiResponse({ status: 204, description: 'Бюджет удален' })
async remove(
@CurrentUser() user: JwtPayload,
@Query('month') month: string,
) {
await this.budgetsService.remove(user.sub, month);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BudgetsController } from './budgets.controller';
import { BudgetsService } from './budgets.service';
import { Budget } from './entities/budget.entity';
@Module({
imports: [TypeOrmModule.forFeature([Budget])],
controllers: [BudgetsController],
providers: [BudgetsService],
exports: [BudgetsService],
})
export class BudgetsModule {}

View File

@ -0,0 +1,213 @@
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Budget } from './entities/budget.entity';
import { CreateBudgetDto, UpdateBudgetDto } from './dto';
import { ErrorMessages } from '../../common/constants/error-messages';
import { calculateBudgetAllocation, calculateCustomAllocation } from '../../common/utils/currency.utils';
import { startOfMonth, format } from 'date-fns';
@Injectable()
export class BudgetsService {
constructor(
@InjectRepository(Budget)
private budgetRepository: Repository<Budget>,
) {}
/**
* Get all budgets for a user
*/
async findAll(userId: string): Promise<Budget[]> {
return this.budgetRepository.find({
where: { userId },
order: { month: 'DESC' },
});
}
/**
* Get budget for a specific month
*/
async findByMonth(userId: string, month: string): Promise<Budget> {
const monthDate = startOfMonth(new Date(month));
const budget = await this.budgetRepository.findOne({
where: { userId, month: monthDate },
});
if (!budget) {
throw new NotFoundException(ErrorMessages.BUDGET_NOT_FOUND);
}
return budget;
}
/**
* Get current month's budget
*/
async findCurrent(userId: string): Promise<Budget | null> {
const currentMonth = startOfMonth(new Date());
return this.budgetRepository.findOne({
where: { userId, month: currentMonth },
});
}
/**
* Create a new budget
*/
async create(userId: string, dto: CreateBudgetDto): Promise<Budget> {
const monthDate = startOfMonth(new Date(dto.month));
// Check if budget already exists
const existing = await this.budgetRepository.findOne({
where: { userId, month: monthDate },
});
if (existing) {
throw new ConflictException(ErrorMessages.BUDGET_ALREADY_EXISTS);
}
// Calculate allocations
let allocation;
if (dto.essentialsPercent !== undefined || dto.personalPercent !== undefined || dto.savingsPercent !== undefined) {
const essentials = dto.essentialsPercent ?? 50;
const personal = dto.personalPercent ?? 30;
const savings = dto.savingsPercent ?? 20;
if (essentials + personal + savings !== 100) {
throw new BadRequestException('Сумма процентов должна равняться 100');
}
allocation = calculateCustomAllocation(dto.totalIncome, essentials, personal, savings);
} else {
allocation = calculateBudgetAllocation(dto.totalIncome);
}
const budget = this.budgetRepository.create({
userId,
month: monthDate,
totalIncome: dto.totalIncome,
essentialsLimit: allocation.essentials,
personalLimit: allocation.personal,
savingsLimit: allocation.savings,
customAllocations: dto.essentialsPercent !== undefined ? {
essentialsPercent: dto.essentialsPercent,
personalPercent: dto.personalPercent,
savingsPercent: dto.savingsPercent,
} : undefined,
});
return this.budgetRepository.save(budget);
}
/**
* Update a budget
*/
async update(userId: string, month: string, dto: UpdateBudgetDto): Promise<Budget> {
const budget = await this.findByMonth(userId, month);
if (dto.totalIncome !== undefined) {
budget.totalIncome = dto.totalIncome;
// Recalculate allocations
const essentials = dto.essentialsPercent ?? budget.customAllocations?.essentialsPercent ?? 50;
const personal = dto.personalPercent ?? budget.customAllocations?.personalPercent ?? 30;
const savings = dto.savingsPercent ?? budget.customAllocations?.savingsPercent ?? 20;
if (essentials + personal + savings !== 100) {
throw new BadRequestException('Сумма процентов должна равняться 100');
}
const allocation = calculateCustomAllocation(dto.totalIncome, essentials, personal, savings);
budget.essentialsLimit = allocation.essentials;
budget.personalLimit = allocation.personal;
budget.savingsLimit = allocation.savings;
if (dto.essentialsPercent !== undefined) {
budget.customAllocations = {
essentialsPercent: essentials,
personalPercent: personal,
savingsPercent: savings,
};
}
}
return this.budgetRepository.save(budget);
}
/**
* Update spent amounts (called when transactions are added)
*/
async updateSpent(
userId: string,
month: Date,
groupType: 'ESSENTIAL' | 'PERSONAL' | 'SAVINGS',
amount: number,
): Promise<void> {
const monthDate = startOfMonth(month);
const budget = await this.budgetRepository.findOne({
where: { userId, month: monthDate },
});
if (!budget) return;
switch (groupType) {
case 'ESSENTIAL':
budget.essentialsSpent = Number(budget.essentialsSpent) + amount;
break;
case 'PERSONAL':
budget.personalSpent = Number(budget.personalSpent) + amount;
break;
case 'SAVINGS':
budget.savingsSpent = Number(budget.savingsSpent) + amount;
break;
}
await this.budgetRepository.save(budget);
}
/**
* Get budget progress
*/
async getProgress(userId: string, month: string): Promise<{
essentials: { limit: number; spent: number; remaining: number; percentage: number };
personal: { limit: number; spent: number; remaining: number; percentage: number };
savings: { limit: number; spent: number; remaining: number; percentage: number };
total: { income: number; spent: number; remaining: number; percentage: number };
}> {
const budget = await this.findByMonth(userId, month);
const calcProgress = (limit: number, spent: number) => ({
limit: Number(limit),
spent: Number(spent),
remaining: Number(limit) - Number(spent),
percentage: limit > 0 ? (Number(spent) / Number(limit)) * 100 : 0,
});
return {
essentials: calcProgress(budget.essentialsLimit, budget.essentialsSpent),
personal: calcProgress(budget.personalLimit, budget.personalSpent),
savings: calcProgress(budget.savingsLimit, budget.savingsSpent),
total: {
income: Number(budget.totalIncome),
spent: budget.totalSpent,
remaining: budget.totalRemaining,
percentage: budget.totalIncome > 0 ? (budget.totalSpent / Number(budget.totalIncome)) * 100 : 0,
},
};
}
/**
* Delete a budget
*/
async remove(userId: string, month: string): Promise<void> {
const budget = await this.findByMonth(userId, month);
await this.budgetRepository.remove(budget);
}
}

View File

@ -0,0 +1,49 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsDateString, IsOptional, Min, Max } from 'class-validator';
export class CreateBudgetDto {
@ApiProperty({
description: 'Месяц бюджета (первый день месяца)',
example: '2024-01-01',
})
@IsDateString({}, { message: 'Некорректный формат даты' })
month: string;
@ApiProperty({
description: 'Общий доход за месяц',
example: 150000,
})
@IsNumber({}, { message: 'Доход должен быть числом' })
@Min(0, { message: 'Доход не может быть отрицательным' })
totalIncome: number;
@ApiPropertyOptional({
description: 'Процент на необходимое (по умолчанию 50%)',
example: 50,
})
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
essentialsPercent?: number;
@ApiPropertyOptional({
description: 'Процент на личное (по умолчанию 30%)',
example: 30,
})
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
personalPercent?: number;
@ApiPropertyOptional({
description: 'Процент на накопления (по умолчанию 20%)',
example: 20,
})
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
savingsPercent?: number;
}

View File

@ -0,0 +1,2 @@
export * from './create-budget.dto';
export * from './update-budget.dto';

View File

@ -0,0 +1,43 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsOptional, Min, Max } from 'class-validator';
export class UpdateBudgetDto {
@ApiPropertyOptional({
description: 'Общий доход за месяц',
example: 150000,
})
@IsOptional()
@IsNumber({}, { message: 'Доход должен быть числом' })
@Min(0, { message: 'Доход не может быть отрицательным' })
totalIncome?: number;
@ApiPropertyOptional({
description: 'Процент на необходимое',
example: 50,
})
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
essentialsPercent?: number;
@ApiPropertyOptional({
description: 'Процент на личное',
example: 30,
})
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
personalPercent?: number;
@ApiPropertyOptional({
description: 'Процент на накопления',
example: 20,
})
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
savingsPercent?: number;
}

View File

@ -0,0 +1,84 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
Index,
Unique,
} from 'typeorm';
import { User } from '../../auth/entities/user.entity';
@Entity('budgets')
@Unique(['userId', 'month'])
export class Budget {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Index()
@Column({ type: 'date' })
month: Date; // First day of month
@Column({ name: 'total_income', type: 'decimal', precision: 15, scale: 2, default: 0 })
totalIncome: number;
// 50/30/20 allocations
@Column({ name: 'essentials_limit', type: 'decimal', precision: 15, scale: 2, default: 0 })
essentialsLimit: number;
@Column({ name: 'essentials_spent', type: 'decimal', precision: 15, scale: 2, default: 0 })
essentialsSpent: number;
@Column({ name: 'personal_limit', type: 'decimal', precision: 15, scale: 2, default: 0 })
personalLimit: number;
@Column({ name: 'personal_spent', type: 'decimal', precision: 15, scale: 2, default: 0 })
personalSpent: number;
@Column({ name: 'savings_limit', type: 'decimal', precision: 15, scale: 2, default: 0 })
savingsLimit: number;
@Column({ name: 'savings_spent', type: 'decimal', precision: 15, scale: 2, default: 0 })
savingsSpent: number;
// Custom allocations (override default percentages)
@Column({ name: 'custom_allocations', type: 'jsonb', nullable: true })
customAllocations: {
essentialsPercent?: number;
personalPercent?: number;
savingsPercent?: number;
};
@Column({ name: 'is_active', default: true })
isActive: boolean;
// Relations
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
// Computed properties
get essentialsRemaining(): number {
return this.essentialsLimit - this.essentialsSpent;
}
get personalRemaining(): number {
return this.personalLimit - this.personalSpent;
}
get savingsRemaining(): number {
return this.savingsLimit - this.savingsSpent;
}
get totalSpent(): number {
return Number(this.essentialsSpent) + Number(this.personalSpent) + Number(this.savingsSpent);
}
get totalRemaining(): number {
return Number(this.totalIncome) - this.totalSpent;
}
}

View File

@ -0,0 +1 @@
export * from './budget.entity';

View File

@ -0,0 +1,127 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
ApiParam,
} from '@nestjs/swagger';
import { CategoriesService } from './categories.service';
import { CreateCategoryDto, UpdateCategoryDto } from './dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser, JwtPayload } from '../../common/decorators/user.decorator';
@ApiTags('Категории')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('categories')
export class CategoriesController {
constructor(private readonly categoriesService: CategoriesService) {}
@Get()
@ApiOperation({ summary: 'Получение списка категорий' })
@ApiQuery({
name: 'type',
required: false,
enum: ['INCOME', 'EXPENSE'],
description: 'Фильтр по типу категории',
})
@ApiResponse({
status: 200,
description: 'Список категорий',
})
async findAll(
@CurrentUser() user: JwtPayload,
@Query('type') type?: 'INCOME' | 'EXPENSE',
) {
return this.categoriesService.findAll(user.sub, type);
}
@Get('grouped')
@ApiOperation({ summary: 'Получение категорий, сгруппированных по типу бюджета' })
@ApiResponse({
status: 200,
description: 'Категории, сгруппированные по ESSENTIAL/PERSONAL/SAVINGS',
})
async findGrouped(@CurrentUser() user: JwtPayload) {
return this.categoriesService.findByBudgetGroup(user.sub);
}
@Get(':id')
@ApiOperation({ summary: 'Получение категории по ID' })
@ApiParam({ name: 'id', description: 'ID категории' })
@ApiResponse({
status: 200,
description: 'Категория',
})
@ApiResponse({ status: 404, description: 'Категория не найдена' })
async findOne(
@CurrentUser() user: JwtPayload,
@Param('id') id: string,
) {
return this.categoriesService.findOne(id, user.sub);
}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Создание пользовательской категории' })
@ApiResponse({
status: 201,
description: 'Категория создана',
})
@ApiResponse({ status: 400, description: 'Некорректные данные' })
@ApiResponse({ status: 409, description: 'Категория уже существует' })
async create(
@CurrentUser() user: JwtPayload,
@Body() dto: CreateCategoryDto,
) {
return this.categoriesService.create(user.sub, dto);
}
@Put(':id')
@ApiOperation({ summary: 'Обновление пользовательской категории' })
@ApiParam({ name: 'id', description: 'ID категории' })
@ApiResponse({
status: 200,
description: 'Категория обновлена',
})
@ApiResponse({ status: 403, description: 'Нельзя изменить стандартную категорию' })
@ApiResponse({ status: 404, description: 'Категория не найдена' })
async update(
@CurrentUser() user: JwtPayload,
@Param('id') id: string,
@Body() dto: UpdateCategoryDto,
) {
return this.categoriesService.update(id, user.sub, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Удаление пользовательской категории' })
@ApiParam({ name: 'id', description: 'ID категории' })
@ApiResponse({
status: 204,
description: 'Категория удалена',
})
@ApiResponse({ status: 403, description: 'Нельзя удалить стандартную категорию' })
@ApiResponse({ status: 404, description: 'Категория не найдена' })
async remove(
@CurrentUser() user: JwtPayload,
@Param('id') id: string,
) {
await this.categoriesService.remove(id, user.sub);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
import { Category } from './entities/category.entity';
@Module({
imports: [TypeOrmModule.forFeature([Category])],
controllers: [CategoriesController],
providers: [CategoriesService],
exports: [CategoriesService],
})
export class CategoriesModule {}

View File

@ -0,0 +1,178 @@
import {
Injectable,
NotFoundException,
ConflictException,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull } from 'typeorm';
import { Category } from './entities/category.entity';
import { CreateCategoryDto, UpdateCategoryDto } from './dto';
import { ErrorMessages } from '../../common/constants/error-messages';
import { ALL_DEFAULT_CATEGORIES } from '../../common/constants/categories';
@Injectable()
export class CategoriesService {
constructor(
@InjectRepository(Category)
private categoryRepository: Repository<Category>,
) {}
/**
* Get all categories for a user (including defaults)
*/
async findAll(userId: string, type?: 'INCOME' | 'EXPENSE'): Promise<Category[]> {
const whereConditions: any[] = [
{ userId: IsNull(), isDefault: true }, // Default categories
{ userId }, // User's custom categories
];
if (type) {
whereConditions[0].type = type;
whereConditions[1].type = type;
}
return this.categoryRepository.find({
where: whereConditions,
order: { isDefault: 'DESC', nameRu: 'ASC' },
});
}
/**
* Get a single category
*/
async findOne(id: string, userId: string): Promise<Category> {
const category = await this.categoryRepository.findOne({
where: [
{ id, userId: IsNull(), isDefault: true },
{ id, userId },
],
});
if (!category) {
throw new NotFoundException(ErrorMessages.CATEGORY_NOT_FOUND);
}
return category;
}
/**
* Create a custom category
*/
async create(userId: string, dto: CreateCategoryDto): Promise<Category> {
// Check for duplicate name
const existing = await this.categoryRepository.findOne({
where: [
{ nameRu: dto.nameRu, userId },
{ nameRu: dto.nameRu, userId: IsNull(), isDefault: true },
],
});
if (existing) {
throw new ConflictException(ErrorMessages.CATEGORY_ALREADY_EXISTS);
}
const category = this.categoryRepository.create({
...dto,
userId,
isDefault: false,
});
return this.categoryRepository.save(category);
}
/**
* Update a custom category
*/
async update(id: string, userId: string, dto: UpdateCategoryDto): Promise<Category> {
const category = await this.categoryRepository.findOne({
where: { id, userId },
});
if (!category) {
throw new NotFoundException(ErrorMessages.CATEGORY_NOT_FOUND);
}
if (category.isDefault) {
throw new ForbiddenException(ErrorMessages.CANNOT_DELETE_DEFAULT_CATEGORY);
}
// Check for duplicate name if changing
if (dto.nameRu && dto.nameRu !== category.nameRu) {
const existing = await this.categoryRepository.findOne({
where: [
{ nameRu: dto.nameRu, userId },
{ nameRu: dto.nameRu, userId: IsNull(), isDefault: true },
],
});
if (existing) {
throw new ConflictException(ErrorMessages.CATEGORY_ALREADY_EXISTS);
}
}
Object.assign(category, dto);
return this.categoryRepository.save(category);
}
/**
* Delete a custom category
*/
async remove(id: string, userId: string): Promise<void> {
const category = await this.categoryRepository.findOne({
where: { id, userId },
});
if (!category) {
throw new NotFoundException(ErrorMessages.CATEGORY_NOT_FOUND);
}
if (category.isDefault) {
throw new ForbiddenException(ErrorMessages.CANNOT_DELETE_DEFAULT_CATEGORY);
}
await this.categoryRepository.remove(category);
}
/**
* Seed default categories
*/
async seedDefaultCategories(): Promise<void> {
const existingCount = await this.categoryRepository.count({
where: { isDefault: true },
});
if (existingCount > 0) {
return; // Already seeded
}
const categories = ALL_DEFAULT_CATEGORIES.map((cat) =>
this.categoryRepository.create({
nameRu: cat.nameRu,
nameEn: cat.nameEn,
type: cat.type,
groupType: cat.groupType,
icon: cat.icon,
color: cat.color,
isDefault: true,
userId: null,
}),
);
await this.categoryRepository.save(categories);
}
/**
* Get categories grouped by budget type
*/
async findByBudgetGroup(userId: string): Promise<Record<string, Category[]>> {
const categories = await this.findAll(userId, 'EXPENSE');
return {
ESSENTIAL: categories.filter((c) => c.groupType === 'ESSENTIAL'),
PERSONAL: categories.filter((c) => c.groupType === 'PERSONAL'),
SAVINGS: categories.filter((c) => c.groupType === 'SAVINGS'),
UNCATEGORIZED: categories.filter((c) => !c.groupType),
};
}
}

View File

@ -0,0 +1,64 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsEnum, IsOptional, MaxLength, Matches } from 'class-validator';
import { TransactionType, BudgetGroupType } from '../../../common/constants/categories';
export class CreateCategoryDto {
@ApiProperty({
description: 'Название категории на русском',
example: 'Продукты',
})
@IsString({ message: 'Название должно быть строкой' })
@MaxLength(100, { message: 'Название не должно превышать 100 символов' })
nameRu: string;
@ApiPropertyOptional({
description: 'Название категории на английском',
example: 'Groceries',
})
@IsOptional()
@IsString({ message: 'Название должно быть строкой' })
@MaxLength(100, { message: 'Название не должно превышать 100 символов' })
nameEn?: string;
@ApiProperty({
description: 'Тип категории',
enum: TransactionType,
example: 'EXPENSE',
})
@IsEnum(TransactionType, { message: 'Тип должен быть INCOME или EXPENSE' })
type: TransactionType;
@ApiPropertyOptional({
description: 'Группа бюджета (для расходов)',
enum: BudgetGroupType,
example: 'ESSENTIAL',
})
@IsOptional()
@IsEnum(BudgetGroupType, { message: 'Группа должна быть ESSENTIAL, PERSONAL или SAVINGS' })
groupType?: BudgetGroupType;
@ApiPropertyOptional({
description: 'Иконка категории',
example: 'cart',
})
@IsOptional()
@IsString({ message: 'Иконка должна быть строкой' })
@MaxLength(50, { message: 'Название иконки не должно превышать 50 символов' })
icon?: string;
@ApiPropertyOptional({
description: 'Цвет категории в HEX формате',
example: '#4CAF50',
})
@IsOptional()
@Matches(/^#[0-9A-Fa-f]{6}$/, { message: 'Цвет должен быть в формате HEX (#RRGGBB)' })
color?: string;
@ApiPropertyOptional({
description: 'ID родительской категории',
example: '123e4567-e89b-12d3-a456-426614174000',
})
@IsOptional()
@IsString()
parentId?: string;
}

View File

@ -0,0 +1,2 @@
export * from './create-category.dto';
export * from './update-category.dto';

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateCategoryDto } from './create-category.dto';
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}

View File

@ -0,0 +1,57 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
OneToMany,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../auth/entities/user.entity';
@Entity('categories')
export class Category {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'name_ru', length: 100 })
nameRu: string;
@Column({ name: 'name_en', length: 100, nullable: true })
nameEn: string;
@Index()
@Column({ length: 10 })
type: 'INCOME' | 'EXPENSE';
@Column({ name: 'group_type', length: 20, nullable: true })
groupType: 'ESSENTIAL' | 'PERSONAL' | 'SAVINGS' | null;
@Column({ length: 50, nullable: true })
icon: string;
@Column({ length: 7, nullable: true })
color: string;
@Column({ name: 'is_default', default: true })
isDefault: boolean;
@Index()
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId: string | null;
@Column({ name: 'parent_id', type: 'uuid', nullable: true })
parentId: string | null;
// Relations
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@ManyToOne(() => Category, (category) => category.children)
@JoinColumn({ name: 'parent_id' })
parent: Category;
@OneToMany(() => Category, (category) => category.parent)
children: Category[];
}

View File

@ -0,0 +1 @@
export * from './category.entity';

View File

@ -0,0 +1,110 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsNumber,
IsEnum,
IsOptional,
IsDateString,
IsBoolean,
Min,
Max,
MaxLength,
Matches,
} from 'class-validator';
import { GoalPriority } from '../entities/goal.entity';
export class CreateGoalDto {
@ApiProperty({
description: 'Название цели',
example: 'Накопить на отпуск',
})
@IsString({ message: 'Название должно быть строкой' })
@MaxLength(200, { message: 'Название не должно превышать 200 символов' })
titleRu: string;
@ApiPropertyOptional({
description: 'Описание цели',
example: 'Поездка в Турцию на 2 недели',
})
@IsOptional()
@IsString({ message: 'Описание должно быть строкой' })
descriptionRu?: string;
@ApiProperty({
description: 'Целевая сумма в рублях',
example: 150000,
})
@IsNumber({}, { message: 'Сумма должна быть числом' })
@Min(1, { message: 'Сумма должна быть положительным числом' })
@Max(1000000000, { message: 'Сумма превышает допустимый лимит' })
targetAmount: number;
@ApiPropertyOptional({
description: 'Начальная сумма',
example: 10000,
})
@IsOptional()
@IsNumber({}, { message: 'Сумма должна быть числом' })
@Min(0, { message: 'Сумма не может быть отрицательной' })
currentAmount?: number;
@ApiPropertyOptional({
description: 'Целевая дата достижения (YYYY-MM-DD)',
example: '2024-12-31',
})
@IsOptional()
@IsDateString({}, { message: 'Некорректный формат даты' })
targetDate?: string;
@ApiPropertyOptional({
description: 'Приоритет цели',
enum: GoalPriority,
example: 'HIGH',
})
@IsOptional()
@IsEnum(GoalPriority, { message: 'Приоритет должен быть LOW, MEDIUM или HIGH' })
priority?: GoalPriority;
@ApiPropertyOptional({
description: 'Иконка цели',
example: 'plane',
})
@IsOptional()
@IsString()
@MaxLength(50)
icon?: string;
@ApiPropertyOptional({
description: 'Цвет в HEX формате',
example: '#4CAF50',
})
@IsOptional()
@Matches(/^#[0-9A-Fa-f]{6}$/, { message: 'Цвет должен быть в формате HEX' })
color?: string;
@ApiPropertyOptional({
description: 'Включить автоматическое накопление',
example: true,
})
@IsOptional()
@IsBoolean()
autoSaveEnabled?: boolean;
@ApiPropertyOptional({
description: 'Сумма автоматического накопления',
example: 5000,
})
@IsOptional()
@IsNumber()
@Min(0)
autoSaveAmount?: number;
@ApiPropertyOptional({
description: 'Частота автоматического накопления',
enum: ['DAILY', 'WEEKLY', 'MONTHLY'],
example: 'MONTHLY',
})
@IsOptional()
@IsEnum(['DAILY', 'WEEKLY', 'MONTHLY'])
autoSaveFrequency?: 'DAILY' | 'WEEKLY' | 'MONTHLY';
}

View File

@ -0,0 +1,2 @@
export * from './create-goal.dto';
export * from './update-goal.dto';

View File

@ -0,0 +1,131 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsNumber,
IsEnum,
IsOptional,
IsDateString,
IsBoolean,
Min,
Max,
MaxLength,
Matches,
} from 'class-validator';
import { GoalPriority, GoalStatus } from '../entities/goal.entity';
export class UpdateGoalDto {
@ApiPropertyOptional({
description: 'Название цели',
example: 'Накопить на отпуск',
})
@IsOptional()
@IsString({ message: 'Название должно быть строкой' })
@MaxLength(200, { message: 'Название не должно превышать 200 символов' })
titleRu?: string;
@ApiPropertyOptional({
description: 'Описание цели',
example: 'Поездка в Турцию на 2 недели',
})
@IsOptional()
@IsString({ message: 'Описание должно быть строкой' })
descriptionRu?: string;
@ApiPropertyOptional({
description: 'Целевая сумма в рублях',
example: 150000,
})
@IsOptional()
@IsNumber({}, { message: 'Сумма должна быть числом' })
@Min(1, { message: 'Сумма должна быть положительным числом' })
@Max(1000000000, { message: 'Сумма превышает допустимый лимит' })
targetAmount?: number;
@ApiPropertyOptional({
description: 'Целевая дата достижения (YYYY-MM-DD)',
example: '2024-12-31',
})
@IsOptional()
@IsDateString({}, { message: 'Некорректный формат даты' })
targetDate?: string;
@ApiPropertyOptional({
description: 'Статус цели',
enum: GoalStatus,
example: 'ACTIVE',
})
@IsOptional()
@IsEnum(GoalStatus, { message: 'Некорректный статус' })
status?: GoalStatus;
@ApiPropertyOptional({
description: 'Приоритет цели',
enum: GoalPriority,
example: 'HIGH',
})
@IsOptional()
@IsEnum(GoalPriority, { message: 'Приоритет должен быть LOW, MEDIUM или HIGH' })
priority?: GoalPriority;
@ApiPropertyOptional({
description: 'Иконка цели',
example: 'plane',
})
@IsOptional()
@IsString()
@MaxLength(50)
icon?: string;
@ApiPropertyOptional({
description: 'Цвет в HEX формате',
example: '#4CAF50',
})
@IsOptional()
@Matches(/^#[0-9A-Fa-f]{6}$/, { message: 'Цвет должен быть в формате HEX' })
color?: string;
@ApiPropertyOptional({
description: 'Включить автоматическое накопление',
example: true,
})
@IsOptional()
@IsBoolean()
autoSaveEnabled?: boolean;
@ApiPropertyOptional({
description: 'Сумма автоматического накопления',
example: 5000,
})
@IsOptional()
@IsNumber()
@Min(0)
autoSaveAmount?: number;
@ApiPropertyOptional({
description: 'Частота автоматического накопления',
enum: ['DAILY', 'WEEKLY', 'MONTHLY'],
example: 'MONTHLY',
})
@IsOptional()
@IsEnum(['DAILY', 'WEEKLY', 'MONTHLY'])
autoSaveFrequency?: 'DAILY' | 'WEEKLY' | 'MONTHLY';
}
export class AddFundsDto {
@ApiProperty({
description: 'Сумма для добавления',
example: 5000,
})
@IsNumber({}, { message: 'Сумма должна быть числом' })
@Min(0.01, { message: 'Сумма должна быть положительной' })
amount: number;
@ApiPropertyOptional({
description: 'Комментарий',
example: 'Зарплата за январь',
})
@IsOptional()
@IsString()
@MaxLength(200)
note?: string;
}

View File

@ -0,0 +1,118 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../auth/entities/user.entity';
export enum GoalStatus {
ACTIVE = 'ACTIVE',
COMPLETED = 'COMPLETED',
PAUSED = 'PAUSED',
CANCELLED = 'CANCELLED',
}
export enum GoalPriority {
LOW = 'LOW',
MEDIUM = 'MEDIUM',
HIGH = 'HIGH',
}
@Entity('goals')
export class Goal {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({ name: 'title_ru', length: 200 })
titleRu: string;
@Column({ name: 'description_ru', type: 'text', nullable: true })
descriptionRu: string;
@Column({ name: 'target_amount', type: 'decimal', precision: 15, scale: 2 })
targetAmount: number;
@Column({ name: 'current_amount', type: 'decimal', precision: 15, scale: 2, default: 0 })
currentAmount: number;
@Column({ length: 3, default: 'RUB' })
currency: string;
@Column({ name: 'target_date', type: 'date', nullable: true })
targetDate: Date;
@Column({ type: 'enum', enum: GoalStatus, default: GoalStatus.ACTIVE })
status: GoalStatus;
@Column({ type: 'enum', enum: GoalPriority, default: GoalPriority.MEDIUM })
priority: GoalPriority;
@Column({ length: 50, nullable: true })
icon: string;
@Column({ length: 7, nullable: true })
color: string;
// Auto-save settings
@Column({ name: 'auto_save_enabled', default: false })
autoSaveEnabled: boolean;
@Column({ name: 'auto_save_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
autoSaveAmount: number;
@Column({ name: 'auto_save_frequency', length: 20, nullable: true })
autoSaveFrequency: 'DAILY' | 'WEEKLY' | 'MONTHLY' | null;
// Timestamps
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@Column({ name: 'completed_at', type: 'timestamp', nullable: true })
completedAt: Date;
// Relations
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
// Computed properties
get progressPercent(): number {
if (Number(this.targetAmount) === 0) return 0;
return Math.min(100, (Number(this.currentAmount) / Number(this.targetAmount)) * 100);
}
get remainingAmount(): number {
return Math.max(0, Number(this.targetAmount) - Number(this.currentAmount));
}
get isCompleted(): boolean {
return Number(this.currentAmount) >= Number(this.targetAmount);
}
get daysRemaining(): number | null {
if (!this.targetDate) return null;
const today = new Date();
const target = new Date(this.targetDate);
const diffTime = target.getTime() - today.getTime();
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
get monthlyRequiredSaving(): number | null {
const days = this.daysRemaining;
if (days === null || days <= 0) return null;
const months = days / 30;
return this.remainingAmount / months;
}
}

View File

@ -0,0 +1 @@
export * from './goal.entity';

View File

@ -0,0 +1,135 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
HttpCode,
HttpStatus,
ParseUUIDPipe,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
ApiParam,
} from '@nestjs/swagger';
import { GoalsService } from './goals.service';
import { CreateGoalDto, UpdateGoalDto, AddFundsDto } from './dto';
import { GoalStatus } from './entities/goal.entity';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser, JwtPayload } from '../../common/decorators/user.decorator';
@ApiTags('Финансовые цели')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('goals')
export class GoalsController {
constructor(private readonly goalsService: GoalsService) {}
@Get()
@ApiOperation({ summary: 'Получение всех целей пользователя' })
@ApiQuery({ name: 'status', enum: GoalStatus, required: false })
@ApiResponse({ status: 200, description: 'Список целей' })
async findAll(
@CurrentUser() user: JwtPayload,
@Query('status') status?: GoalStatus,
) {
return this.goalsService.findAll(user.sub, status);
}
@Get('summary')
@ApiOperation({ summary: 'Получение сводки по целям' })
@ApiResponse({ status: 200, description: 'Сводка по целям' })
async getSummary(@CurrentUser() user: JwtPayload) {
return this.goalsService.getSummary(user.sub);
}
@Get('upcoming')
@ApiOperation({ summary: 'Получение целей с приближающимся дедлайном' })
@ApiQuery({ name: 'days', required: false, example: 30 })
@ApiResponse({ status: 200, description: 'Список целей с дедлайном' })
async getUpcoming(
@CurrentUser() user: JwtPayload,
@Query('days') days?: number,
) {
return this.goalsService.getUpcomingDeadlines(user.sub, days || 30);
}
@Get(':id')
@ApiOperation({ summary: 'Получение цели по ID' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiResponse({ status: 200, description: 'Цель' })
@ApiResponse({ status: 404, description: 'Цель не найдена' })
async findOne(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.goalsService.findOne(id, user.sub);
}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Создание новой цели' })
@ApiResponse({ status: 201, description: 'Цель создана' })
async create(
@CurrentUser() user: JwtPayload,
@Body() dto: CreateGoalDto,
) {
return this.goalsService.create(user.sub, dto);
}
@Put(':id')
@ApiOperation({ summary: 'Обновление цели' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiResponse({ status: 200, description: 'Цель обновлена' })
async update(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateGoalDto,
) {
return this.goalsService.update(id, user.sub, dto);
}
@Post(':id/add-funds')
@ApiOperation({ summary: 'Добавление средств к цели' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiResponse({ status: 200, description: 'Средства добавлены' })
async addFunds(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AddFundsDto,
) {
return this.goalsService.addFunds(id, user.sub, dto);
}
@Post(':id/withdraw')
@ApiOperation({ summary: 'Снятие средств с цели' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiResponse({ status: 200, description: 'Средства сняты' })
async withdrawFunds(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AddFundsDto,
) {
return this.goalsService.withdrawFunds(id, user.sub, dto.amount);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Удаление цели' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiResponse({ status: 204, description: 'Цель удалена' })
async remove(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
await this.goalsService.remove(id, user.sub);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { GoalsController } from './goals.controller';
import { GoalsService } from './goals.service';
import { Goal } from './entities/goal.entity';
@Module({
imports: [TypeOrmModule.forFeature([Goal])],
controllers: [GoalsController],
providers: [GoalsService],
exports: [GoalsService],
})
export class GoalsModule {}

View File

@ -0,0 +1,181 @@
import {
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Goal, GoalStatus } from './entities/goal.entity';
import { CreateGoalDto, UpdateGoalDto, AddFundsDto } from './dto';
import { ErrorMessages } from '../../common/constants/error-messages';
@Injectable()
export class GoalsService {
constructor(
@InjectRepository(Goal)
private goalRepository: Repository<Goal>,
) {}
/**
* Get all goals for a user
*/
async findAll(userId: string, status?: GoalStatus): Promise<Goal[]> {
const where: any = { userId };
if (status) where.status = status;
return this.goalRepository.find({
where,
order: { priority: 'DESC', createdAt: 'DESC' },
});
}
/**
* Get a single goal
*/
async findOne(id: string, userId: string): Promise<Goal> {
const goal = await this.goalRepository.findOne({
where: { id, userId },
});
if (!goal) {
throw new NotFoundException(ErrorMessages.GOAL_NOT_FOUND);
}
return goal;
}
/**
* Create a new goal
*/
async create(userId: string, dto: CreateGoalDto): Promise<Goal> {
const goal = this.goalRepository.create({
...dto,
userId,
targetDate: dto.targetDate ? new Date(dto.targetDate) : undefined,
});
return this.goalRepository.save(goal);
}
/**
* Update a goal
*/
async update(id: string, userId: string, dto: UpdateGoalDto): Promise<Goal> {
const goal = await this.findOne(id, userId);
if (dto.targetDate) {
goal.targetDate = new Date(dto.targetDate);
}
Object.assign(goal, {
...dto,
targetDate: dto.targetDate ? new Date(dto.targetDate) : goal.targetDate,
});
// Check if goal is completed
if (goal.isCompleted && goal.status === GoalStatus.ACTIVE) {
goal.status = GoalStatus.COMPLETED;
goal.completedAt = new Date();
}
return this.goalRepository.save(goal);
}
/**
* Add funds to a goal
*/
async addFunds(id: string, userId: string, dto: AddFundsDto): Promise<Goal> {
const goal = await this.findOne(id, userId);
if (goal.status !== GoalStatus.ACTIVE) {
throw new BadRequestException('Нельзя добавить средства к неактивной цели');
}
goal.currentAmount = Number(goal.currentAmount) + dto.amount;
// Check if goal is completed
if (goal.isCompleted) {
goal.status = GoalStatus.COMPLETED;
goal.completedAt = new Date();
}
return this.goalRepository.save(goal);
}
/**
* Withdraw funds from a goal
*/
async withdrawFunds(id: string, userId: string, amount: number): Promise<Goal> {
const goal = await this.findOne(id, userId);
if (amount > Number(goal.currentAmount)) {
throw new BadRequestException('Недостаточно средств для снятия');
}
goal.currentAmount = Number(goal.currentAmount) - amount;
// If was completed but now not, reactivate
if (goal.status === GoalStatus.COMPLETED && !goal.isCompleted) {
goal.status = GoalStatus.ACTIVE;
goal.completedAt = null as any;
}
return this.goalRepository.save(goal);
}
/**
* Delete a goal
*/
async remove(id: string, userId: string): Promise<void> {
const goal = await this.findOne(id, userId);
await this.goalRepository.remove(goal);
}
/**
* Get goals summary
*/
async getSummary(userId: string): Promise<{
totalGoals: number;
activeGoals: number;
completedGoals: number;
totalTargetAmount: number;
totalCurrentAmount: number;
overallProgress: number;
}> {
const goals = await this.goalRepository.find({ where: { userId } });
const activeGoals = goals.filter(g => g.status === GoalStatus.ACTIVE);
const completedGoals = goals.filter(g => g.status === GoalStatus.COMPLETED);
const totalTargetAmount = activeGoals.reduce((sum, g) => sum + Number(g.targetAmount), 0);
const totalCurrentAmount = activeGoals.reduce((sum, g) => sum + Number(g.currentAmount), 0);
return {
totalGoals: goals.length,
activeGoals: activeGoals.length,
completedGoals: completedGoals.length,
totalTargetAmount,
totalCurrentAmount,
overallProgress: totalTargetAmount > 0 ? (totalCurrentAmount / totalTargetAmount) * 100 : 0,
};
}
/**
* Get goals that are close to deadline
*/
async getUpcomingDeadlines(userId: string, daysAhead: number = 30): Promise<Goal[]> {
const goals = await this.goalRepository.find({
where: { userId, status: GoalStatus.ACTIVE },
});
const today = new Date();
const futureDate = new Date();
futureDate.setDate(today.getDate() + daysAhead);
return goals.filter(goal => {
if (!goal.targetDate) return false;
const targetDate = new Date(goal.targetDate);
return targetDate >= today && targetDate <= futureDate;
}).sort((a, b) => new Date(a.targetDate).getTime() - new Date(b.targetDate).getTime());
}
}

View File

@ -0,0 +1 @@
export * from './recommendation.entity';

View File

@ -0,0 +1,81 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../auth/entities/user.entity';
export enum RecommendationType {
SAVING = 'SAVING',
SPENDING = 'SPENDING',
INVESTMENT = 'INVESTMENT',
TAX = 'TAX',
DEBT = 'DEBT',
BUDGET = 'BUDGET',
GOAL = 'GOAL',
}
export enum RecommendationStatus {
NEW = 'NEW',
VIEWED = 'VIEWED',
APPLIED = 'APPLIED',
DISMISSED = 'DISMISSED',
}
@Entity('recommendations')
export class Recommendation {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({ type: 'enum', enum: RecommendationType })
type: RecommendationType;
@Column({ name: 'title_ru', length: 200 })
titleRu: string;
@Column({ name: 'description_ru', type: 'text' })
descriptionRu: string;
@Column({ name: 'action_text_ru', length: 200, nullable: true })
actionTextRu: string;
@Column({ name: 'priority_score', type: 'decimal', precision: 3, scale: 2, default: 0.5 })
priorityScore: number;
@Column({ name: 'confidence_score', type: 'decimal', precision: 3, scale: 2, default: 0.5 })
confidenceScore: number;
@Column({ name: 'potential_savings', type: 'decimal', precision: 15, scale: 2, nullable: true })
potentialSavings: number;
@Column({ type: 'enum', enum: RecommendationStatus, default: RecommendationStatus.NEW })
status: RecommendationStatus;
@Column({ name: 'action_data', type: 'jsonb', nullable: true })
actionData: Record<string, any>;
@Column({ name: 'expires_at', type: 'timestamp', nullable: true })
expiresAt: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@Column({ name: 'viewed_at', type: 'timestamp', nullable: true })
viewedAt: Date;
@Column({ name: 'applied_at', type: 'timestamp', nullable: true })
appliedAt: Date;
// Relations
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
}

View File

@ -0,0 +1,98 @@
import {
Controller,
Get,
Post,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
ApiParam,
} from '@nestjs/swagger';
import { RecommendationsService } from './recommendations.service';
import { RecommendationType } from './entities/recommendation.entity';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser, JwtPayload } from '../../common/decorators/user.decorator';
@ApiTags('Рекомендации')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('recommendations')
export class RecommendationsController {
constructor(private readonly recommendationsService: RecommendationsService) {}
@Get()
@ApiOperation({ summary: 'Получение активных рекомендаций' })
@ApiQuery({ name: 'type', enum: RecommendationType, required: false })
@ApiResponse({ status: 200, description: 'Список рекомендаций' })
async findAll(
@CurrentUser() user: JwtPayload,
@Query('type') type?: RecommendationType,
) {
return this.recommendationsService.findAll(user.sub, type);
}
@Get('stats')
@ApiOperation({ summary: 'Статистика по рекомендациям' })
@ApiResponse({ status: 200, description: 'Статистика' })
async getStats(@CurrentUser() user: JwtPayload) {
return this.recommendationsService.getStats(user.sub);
}
@Post('generate')
@ApiOperation({ summary: 'Генерация новых рекомендаций' })
@ApiResponse({ status: 200, description: 'Сгенерированные рекомендации' })
async generate(@CurrentUser() user: JwtPayload) {
return this.recommendationsService.generateRecommendations(user.sub);
}
@Get(':id')
@ApiOperation({ summary: 'Получение рекомендации по ID' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiResponse({ status: 200, description: 'Рекомендация' })
async findOne(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.recommendationsService.findOne(id, user.sub);
}
@Post(':id/view')
@ApiOperation({ summary: 'Отметить рекомендацию как просмотренную' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiResponse({ status: 200, description: 'Рекомендация обновлена' })
async markAsViewed(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.recommendationsService.markAsViewed(id, user.sub);
}
@Post(':id/apply')
@ApiOperation({ summary: 'Отметить рекомендацию как примененную' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiResponse({ status: 200, description: 'Рекомендация применена' })
async markAsApplied(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.recommendationsService.markAsApplied(id, user.sub);
}
@Post(':id/dismiss')
@ApiOperation({ summary: 'Отклонить рекомендацию' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiResponse({ status: 200, description: 'Рекомендация отклонена' })
async dismiss(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.recommendationsService.dismiss(id, user.sub);
}
}

View File

@ -0,0 +1,23 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { RecommendationsController } from './recommendations.controller';
import { RecommendationsService } from './recommendations.service';
import { Recommendation } from './entities/recommendation.entity';
import { AiModule } from '../ai/ai.module';
import { TransactionsModule } from '../transactions/transactions.module';
import { BudgetsModule } from '../budgets/budgets.module';
import { GoalsModule } from '../goals/goals.module';
@Module({
imports: [
TypeOrmModule.forFeature([Recommendation]),
AiModule,
forwardRef(() => TransactionsModule),
forwardRef(() => BudgetsModule),
forwardRef(() => GoalsModule),
],
controllers: [RecommendationsController],
providers: [RecommendationsService],
exports: [RecommendationsService],
})
export class RecommendationsModule {}

View File

@ -0,0 +1,282 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThan, MoreThan } from 'typeorm';
import { Recommendation, RecommendationType, RecommendationStatus } from './entities/recommendation.entity';
import { AiService } from '../ai/ai.service';
import { TransactionsService } from '../transactions/transactions.service';
import { BudgetsService } from '../budgets/budgets.service';
import { GoalsService } from '../goals/goals.service';
@Injectable()
export class RecommendationsService {
constructor(
@InjectRepository(Recommendation)
private recommendationRepository: Repository<Recommendation>,
private aiService: AiService,
private transactionsService: TransactionsService,
private budgetsService: BudgetsService,
private goalsService: GoalsService,
) {}
/**
* Get all active recommendations for a user
*/
async findAll(userId: string, type?: RecommendationType): Promise<Recommendation[]> {
const where: any = {
userId,
status: RecommendationStatus.NEW,
};
if (type) where.type = type;
return this.recommendationRepository.find({
where,
order: { priorityScore: 'DESC', createdAt: 'DESC' },
});
}
/**
* Get a single recommendation
*/
async findOne(id: string, userId: string): Promise<Recommendation> {
const recommendation = await this.recommendationRepository.findOne({
where: { id, userId },
});
if (!recommendation) {
throw new NotFoundException('Рекомендация не найдена');
}
return recommendation;
}
/**
* Mark recommendation as viewed
*/
async markAsViewed(id: string, userId: string): Promise<Recommendation> {
const recommendation = await this.findOne(id, userId);
if (recommendation.status === RecommendationStatus.NEW) {
recommendation.status = RecommendationStatus.VIEWED;
recommendation.viewedAt = new Date();
await this.recommendationRepository.save(recommendation);
}
return recommendation;
}
/**
* Mark recommendation as applied
*/
async markAsApplied(id: string, userId: string): Promise<Recommendation> {
const recommendation = await this.findOne(id, userId);
recommendation.status = RecommendationStatus.APPLIED;
recommendation.appliedAt = new Date();
return this.recommendationRepository.save(recommendation);
}
/**
* Dismiss a recommendation
*/
async dismiss(id: string, userId: string): Promise<Recommendation> {
const recommendation = await this.findOne(id, userId);
recommendation.status = RecommendationStatus.DISMISSED;
return this.recommendationRepository.save(recommendation);
}
/**
* Generate new recommendations for a user
*/
async generateRecommendations(userId: string): Promise<Recommendation[]> {
// Get user's financial data
const [transactions, currentBudget, goalsSummary] = await Promise.all([
this.transactionsService.findAll(userId, { page: 1, limit: 100 }),
this.budgetsService.findCurrent(userId),
this.goalsService.getSummary(userId),
]);
// Calculate context for AI
const totalExpenses = transactions.data
.filter(t => t.type === 'EXPENSE')
.reduce((sum, t) => sum + Number(t.amount), 0);
const totalIncome = transactions.data
.filter(t => t.type === 'INCOME')
.reduce((sum, t) => sum + Number(t.amount), 0);
const savingsRate = totalIncome > 0 ? ((totalIncome - totalExpenses) / totalIncome) * 100 : 0;
// Get top spending categories
const categorySpending: Record<string, number> = {};
transactions.data
.filter(t => t.type === 'EXPENSE')
.forEach(t => {
const catName = t.category?.nameRu || 'Другое';
categorySpending[catName] = (categorySpending[catName] || 0) + Number(t.amount);
});
const topCategories = Object.entries(categorySpending)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([name]) => name);
// Generate AI recommendations
const aiRecommendations = await this.aiService.generateRecommendations(userId, {
monthlyIncome: totalIncome,
totalExpenses,
savingsRate,
topCategories,
});
// Save recommendations to database
const recommendations: Recommendation[] = [];
for (const rec of aiRecommendations) {
// Check if similar recommendation already exists
const existing = await this.recommendationRepository.findOne({
where: {
userId,
type: rec.type as RecommendationType,
status: RecommendationStatus.NEW,
},
});
if (!existing) {
const recommendation = this.recommendationRepository.create({
userId,
type: rec.type as RecommendationType,
titleRu: rec.titleRu,
descriptionRu: rec.descriptionRu,
priorityScore: rec.priorityScore,
confidenceScore: rec.confidenceScore,
actionData: rec.actionData,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
});
recommendations.push(await this.recommendationRepository.save(recommendation));
}
}
// Add budget-based recommendations
if (currentBudget) {
const budgetRecommendations = this.generateBudgetRecommendations(currentBudget, userId);
for (const rec of budgetRecommendations) {
recommendations.push(await this.recommendationRepository.save(rec));
}
}
// Add goal-based recommendations
if (goalsSummary.activeGoals > 0) {
const goalRecommendations = this.generateGoalRecommendations(goalsSummary, userId);
for (const rec of goalRecommendations) {
recommendations.push(await this.recommendationRepository.save(rec));
}
}
return recommendations;
}
/**
* Generate budget-based recommendations
*/
private generateBudgetRecommendations(budget: any, userId: string): Recommendation[] {
const recommendations: Recommendation[] = [];
// Check if overspending on essentials
const essentialsPercent = (Number(budget.essentialsSpent) / Number(budget.essentialsLimit)) * 100;
if (essentialsPercent > 90) {
recommendations.push(this.recommendationRepository.create({
userId,
type: RecommendationType.BUDGET,
titleRu: 'Превышение лимита на необходимое',
descriptionRu: `Вы израсходовали ${essentialsPercent.toFixed(0)}% бюджета на необходимые расходы. Рекомендуем пересмотреть траты или увеличить лимит.`,
priorityScore: 0.9,
confidenceScore: 0.95,
potentialSavings: Number(budget.essentialsSpent) - Number(budget.essentialsLimit),
}));
}
// Check if underspending on savings
const savingsPercent = (Number(budget.savingsSpent) / Number(budget.savingsLimit)) * 100;
if (savingsPercent < 50 && new Date().getDate() > 15) {
recommendations.push(this.recommendationRepository.create({
userId,
type: RecommendationType.SAVING,
titleRu: 'Увеличьте накопления',
descriptionRu: `Вы накопили только ${savingsPercent.toFixed(0)}% от запланированного. До конца месяца осталось время - переведите средства на накопления.`,
priorityScore: 0.8,
confidenceScore: 0.9,
actionData: { targetAmount: Number(budget.savingsLimit) - Number(budget.savingsSpent) },
}));
}
return recommendations;
}
/**
* Generate goal-based recommendations
*/
private generateGoalRecommendations(summary: any, userId: string): Recommendation[] {
const recommendations: Recommendation[] = [];
if (summary.overallProgress < 30 && summary.activeGoals > 0) {
recommendations.push(this.recommendationRepository.create({
userId,
type: RecommendationType.GOAL,
titleRu: 'Ускорьте достижение целей',
descriptionRu: `Общий прогресс по вашим целям составляет ${summary.overallProgress.toFixed(0)}%. Рассмотрите возможность увеличения регулярных отчислений.`,
priorityScore: 0.7,
confidenceScore: 0.85,
actionData: {
currentProgress: summary.overallProgress,
activeGoals: summary.activeGoals,
},
}));
}
return recommendations;
}
/**
* Clean up expired recommendations
*/
async cleanupExpired(): Promise<number> {
const result = await this.recommendationRepository.delete({
expiresAt: LessThan(new Date()),
status: RecommendationStatus.NEW,
});
return result.affected || 0;
}
/**
* Get recommendation statistics
*/
async getStats(userId: string): Promise<{
total: number;
new: number;
viewed: number;
applied: number;
dismissed: number;
potentialSavings: number;
}> {
const recommendations = await this.recommendationRepository.find({
where: { userId },
});
return {
total: recommendations.length,
new: recommendations.filter(r => r.status === RecommendationStatus.NEW).length,
viewed: recommendations.filter(r => r.status === RecommendationStatus.VIEWED).length,
applied: recommendations.filter(r => r.status === RecommendationStatus.APPLIED).length,
dismissed: recommendations.filter(r => r.status === RecommendationStatus.DISMISSED).length,
potentialSavings: recommendations
.filter(r => r.status !== RecommendationStatus.DISMISSED && r.potentialSavings)
.reduce((sum, r) => sum + Number(r.potentialSavings || 0), 0),
};
}
}

View File

@ -0,0 +1,82 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsNumber,
IsEnum,
IsOptional,
IsDateString,
IsUUID,
IsUrl,
Min,
Max,
MaxLength,
} from 'class-validator';
import { TransactionType, PaymentMethod } from '../../../common/constants/categories';
export class CreateTransactionDto {
@ApiProperty({
description: 'Сумма транзакции в рублях',
example: 1500.50,
minimum: 0.01,
})
@IsNumber({}, { message: 'Сумма должна быть числом' })
@Min(0.01, { message: 'Сумма должна быть положительным числом' })
@Max(1000000000, { message: 'Сумма превышает допустимый лимит' })
amount: number;
@ApiProperty({
description: 'Тип транзакции',
enum: TransactionType,
example: 'EXPENSE',
})
@IsEnum(TransactionType, { message: 'Тип должен быть INCOME или EXPENSE' })
type: TransactionType;
@ApiProperty({
description: 'ID категории',
example: '123e4567-e89b-12d3-a456-426614174000',
})
@IsUUID('4', { message: 'Некорректный формат ID категории' })
categoryId: string;
@ApiProperty({
description: 'Дата транзакции в формате YYYY-MM-DD',
example: '2024-01-15',
})
@IsDateString({}, { message: 'Некорректный формат даты' })
transactionDate: string;
@ApiPropertyOptional({
description: 'Описание транзакции',
example: 'Покупка продуктов в Пятерочке',
})
@IsOptional()
@IsString({ message: 'Описание должно быть строкой' })
@MaxLength(500, { message: 'Описание не должно превышать 500 символов' })
description?: string;
@ApiPropertyOptional({
description: 'Способ оплаты',
enum: PaymentMethod,
example: 'CARD',
})
@IsOptional()
@IsEnum(PaymentMethod, { message: 'Способ оплаты должен быть CASH, CARD или BANK_TRANSFER' })
paymentMethod?: PaymentMethod;
@ApiPropertyOptional({
description: 'URL чека',
example: 'https://storage.example.com/receipts/123.jpg',
})
@IsOptional()
@IsUrl({}, { message: 'Некорректный URL чека' })
@MaxLength(500, { message: 'URL не должен превышать 500 символов' })
receiptUrl?: string;
@ApiPropertyOptional({
description: 'Является ли транзакция запланированной',
example: false,
})
@IsOptional()
isPlanned?: boolean;
}

View File

@ -0,0 +1,3 @@
export * from './create-transaction.dto';
export * from './update-transaction.dto';
export * from './query-transactions.dto';

View File

@ -0,0 +1,84 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsEnum, IsDateString, IsUUID, IsNumber, Min, Max } from 'class-validator';
import { Type } from 'class-transformer';
import { TransactionType, PaymentMethod } from '../../../common/constants/categories';
export class QueryTransactionsDto {
@ApiPropertyOptional({
description: 'Номер страницы',
example: 1,
default: 1,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({
description: 'Количество записей на странице',
example: 20,
default: 20,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(100)
limit?: number = 20;
@ApiPropertyOptional({
description: 'Фильтр по типу транзакции',
enum: TransactionType,
})
@IsOptional()
@IsEnum(TransactionType)
type?: TransactionType;
@ApiPropertyOptional({
description: 'Фильтр по категории',
example: '123e4567-e89b-12d3-a456-426614174000',
})
@IsOptional()
@IsUUID('4')
categoryId?: string;
@ApiPropertyOptional({
description: 'Фильтр по способу оплаты',
enum: PaymentMethod,
})
@IsOptional()
@IsEnum(PaymentMethod)
paymentMethod?: PaymentMethod;
@ApiPropertyOptional({
description: 'Дата начала периода (YYYY-MM-DD)',
example: '2024-01-01',
})
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional({
description: 'Дата окончания периода (YYYY-MM-DD)',
example: '2024-01-31',
})
@IsOptional()
@IsDateString()
endDate?: string;
@ApiPropertyOptional({
description: 'Поиск по описанию',
example: 'продукты',
})
@IsOptional()
search?: string;
@ApiPropertyOptional({
description: 'Сортировка',
enum: ['date_asc', 'date_desc', 'amount_asc', 'amount_desc'],
default: 'date_desc',
})
@IsOptional()
sort?: 'date_asc' | 'date_desc' | 'amount_asc' | 'amount_desc' = 'date_desc';
}

Some files were not shown because too many files have changed in this diff Show More