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:
commit
33602d0fe9
44
.env.development
Normal file
44
.env.development
Normal 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
44
.env.example
Normal 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
56
.gitignore
vendored
Normal 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
4
.prettierrc
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
82
Dockerfile
Normal file
82
Dockerfile
Normal 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
168
README.md
Normal 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
51
docker-compose.prod.yml
Normal 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
54
docker-compose.yml
Normal 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
9
docker/postgres/init.sql
Normal 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
34
eslint.config.mjs
Normal 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
8
nest-cli.json
Normal 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
12686
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
105
package.json
Normal file
105
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/app.controller.spec.ts
Normal file
22
src/app.controller.spec.ts
Normal 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
12
src/app.controller.ts
Normal 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
105
src/app.module.ts
Normal 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
8
src/app.service.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
296
src/common/constants/categories.ts
Normal file
296
src/common/constants/categories.ts
Normal 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,
|
||||
];
|
||||
62
src/common/constants/error-messages.ts
Normal file
62
src/common/constants/error-messages.ts
Normal 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;
|
||||
2
src/common/constants/index.ts
Normal file
2
src/common/constants/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './error-messages';
|
||||
export * from './categories';
|
||||
3
src/common/decorators/index.ts
Normal file
3
src/common/decorators/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './public.decorator';
|
||||
export * from './user.decorator';
|
||||
export * from './roles.decorator';
|
||||
4
src/common/decorators/public.decorator.ts
Normal file
4
src/common/decorators/public.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
9
src/common/decorators/roles.decorator.ts
Normal file
9
src/common/decorators/roles.decorator.ts
Normal 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);
|
||||
21
src/common/decorators/user.decorator.ts
Normal file
21
src/common/decorators/user.decorator.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
46
src/common/filters/all-exceptions.filter.ts
Normal file
46
src/common/filters/all-exceptions.filter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
74
src/common/filters/http-exception.filter.ts
Normal file
74
src/common/filters/http-exception.filter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
2
src/common/filters/index.ts
Normal file
2
src/common/filters/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './http-exception.filter';
|
||||
export * from './all-exceptions.filter';
|
||||
2
src/common/guards/index.ts
Normal file
2
src/common/guards/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './jwt-auth.guard';
|
||||
export * from './roles.guard';
|
||||
38
src/common/guards/jwt-auth.guard.ts
Normal file
38
src/common/guards/jwt-auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
34
src/common/guards/roles.guard.ts
Normal file
34
src/common/guards/roles.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
2
src/common/interceptors/index.ts
Normal file
2
src/common/interceptors/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './transform.interceptor';
|
||||
export * from './logging.interceptor';
|
||||
45
src/common/interceptors/logging.interceptor.ts
Normal file
45
src/common/interceptors/logging.interceptor.ts
Normal 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}`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
27
src/common/interceptors/transform.interceptor.ts
Normal file
27
src/common/interceptors/transform.interceptor.ts
Normal 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(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
108
src/common/utils/currency.utils.ts
Normal file
108
src/common/utils/currency.utils.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
82
src/common/utils/date.utils.ts
Normal file
82
src/common/utils/date.utils.ts
Normal 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] || '';
|
||||
}
|
||||
4
src/common/utils/index.ts
Normal file
4
src/common/utils/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './date.utils';
|
||||
export * from './currency.utils';
|
||||
export * from './security.utils';
|
||||
export * from './validation.utils';
|
||||
121
src/common/utils/security.utils.ts
Normal file
121
src/common/utils/security.utils.ts
Normal 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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/\//g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
123
src/common/utils/validation.utils.ts
Normal file
123
src/common/utils/validation.utils.ts
Normal 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
8
src/config/ai.config.ts
Normal 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
10
src/config/app.config.ts
Normal 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',
|
||||
}));
|
||||
11
src/config/database.config.ts
Normal file
11
src/config/database.config.ts
Normal 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
5
src/config/index.ts
Normal 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
10
src/config/jwt.config.ts
Normal 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',
|
||||
}));
|
||||
10
src/config/security.config.ts
Normal file
10
src/config/security.config.ts
Normal 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),
|
||||
}));
|
||||
21
src/database/data-source.ts
Normal file
21
src/database/data-source.ts
Normal 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;
|
||||
30
src/database/seeds/run-seed.ts
Normal file
30
src/database/seeds/run-seed.ts
Normal 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
102
src/main.ts
Normal 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();
|
||||
8
src/modules/ai/ai.module.ts
Normal file
8
src/modules/ai/ai.module.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AiService } from './ai.service';
|
||||
|
||||
@Module({
|
||||
providers: [AiService],
|
||||
exports: [AiService],
|
||||
})
|
||||
export class AiModule {}
|
||||
341
src/modules/ai/ai.service.ts
Normal file
341
src/modules/ai/ai.service.ts
Normal 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();
|
||||
// }
|
||||
}
|
||||
96
src/modules/analytics/analytics.controller.ts
Normal file
96
src/modules/analytics/analytics.controller.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
18
src/modules/analytics/analytics.module.ts
Normal file
18
src/modules/analytics/analytics.module.ts
Normal 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 {}
|
||||
478
src/modules/analytics/analytics.service.ts
Normal file
478
src/modules/analytics/analytics.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
360
src/modules/auth/auth.controller.ts
Normal file
360
src/modules/auth/auth.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/modules/auth/auth.module.ts
Normal file
30
src/modules/auth/auth.module.ts
Normal 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 {}
|
||||
440
src/modules/auth/auth.service.ts
Normal file
440
src/modules/auth/auth.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
32
src/modules/auth/dto/change-password.dto.ts
Normal file
32
src/modules/auth/dto/change-password.dto.ts
Normal 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;
|
||||
}
|
||||
5
src/modules/auth/dto/index.ts
Normal file
5
src/modules/auth/dto/index.ts
Normal 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';
|
||||
19
src/modules/auth/dto/login.dto.ts
Normal file
19
src/modules/auth/dto/login.dto.ts
Normal 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;
|
||||
}
|
||||
60
src/modules/auth/dto/register.dto.ts
Normal file
60
src/modules/auth/dto/register.dto.ts
Normal 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;
|
||||
}
|
||||
71
src/modules/auth/dto/tokens.dto.ts
Normal file
71
src/modules/auth/dto/tokens.dto.ts
Normal 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;
|
||||
}
|
||||
65
src/modules/auth/dto/update-profile.dto.ts
Normal file
65
src/modules/auth/dto/update-profile.dto.ts
Normal 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;
|
||||
}
|
||||
50
src/modules/auth/entities/audit-log.entity.ts
Normal file
50
src/modules/auth/entities/audit-log.entity.ts
Normal 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;
|
||||
}
|
||||
3
src/modules/auth/entities/index.ts
Normal file
3
src/modules/auth/entities/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './user.entity';
|
||||
export * from './refresh-token.entity';
|
||||
export * from './audit-log.entity';
|
||||
56
src/modules/auth/entities/refresh-token.entity.ts
Normal file
56
src/modules/auth/entities/refresh-token.entity.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
95
src/modules/auth/entities/user.entity.ts
Normal file
95
src/modules/auth/entities/user.entity.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
2
src/modules/auth/strategies/index.ts
Normal file
2
src/modules/auth/strategies/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './jwt.strategy';
|
||||
export * from './jwt-refresh.strategy';
|
||||
44
src/modules/auth/strategies/jwt-refresh.strategy.ts
Normal file
44
src/modules/auth/strategies/jwt-refresh.strategy.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
59
src/modules/auth/strategies/jwt.strategy.ts
Normal file
59
src/modules/auth/strategies/jwt.strategy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
104
src/modules/budgets/budgets.controller.ts
Normal file
104
src/modules/budgets/budgets.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
src/modules/budgets/budgets.module.ts
Normal file
13
src/modules/budgets/budgets.module.ts
Normal 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 {}
|
||||
213
src/modules/budgets/budgets.service.ts
Normal file
213
src/modules/budgets/budgets.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
49
src/modules/budgets/dto/create-budget.dto.ts
Normal file
49
src/modules/budgets/dto/create-budget.dto.ts
Normal 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;
|
||||
}
|
||||
2
src/modules/budgets/dto/index.ts
Normal file
2
src/modules/budgets/dto/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './create-budget.dto';
|
||||
export * from './update-budget.dto';
|
||||
43
src/modules/budgets/dto/update-budget.dto.ts
Normal file
43
src/modules/budgets/dto/update-budget.dto.ts
Normal 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;
|
||||
}
|
||||
84
src/modules/budgets/entities/budget.entity.ts
Normal file
84
src/modules/budgets/entities/budget.entity.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1
src/modules/budgets/entities/index.ts
Normal file
1
src/modules/budgets/entities/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './budget.entity';
|
||||
127
src/modules/categories/categories.controller.ts
Normal file
127
src/modules/categories/categories.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
src/modules/categories/categories.module.ts
Normal file
13
src/modules/categories/categories.module.ts
Normal 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 {}
|
||||
178
src/modules/categories/categories.service.ts
Normal file
178
src/modules/categories/categories.service.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
64
src/modules/categories/dto/create-category.dto.ts
Normal file
64
src/modules/categories/dto/create-category.dto.ts
Normal 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;
|
||||
}
|
||||
2
src/modules/categories/dto/index.ts
Normal file
2
src/modules/categories/dto/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './create-category.dto';
|
||||
export * from './update-category.dto';
|
||||
4
src/modules/categories/dto/update-category.dto.ts
Normal file
4
src/modules/categories/dto/update-category.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateCategoryDto } from './create-category.dto';
|
||||
|
||||
export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}
|
||||
57
src/modules/categories/entities/category.entity.ts
Normal file
57
src/modules/categories/entities/category.entity.ts
Normal 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[];
|
||||
}
|
||||
1
src/modules/categories/entities/index.ts
Normal file
1
src/modules/categories/entities/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './category.entity';
|
||||
110
src/modules/goals/dto/create-goal.dto.ts
Normal file
110
src/modules/goals/dto/create-goal.dto.ts
Normal 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';
|
||||
}
|
||||
2
src/modules/goals/dto/index.ts
Normal file
2
src/modules/goals/dto/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './create-goal.dto';
|
||||
export * from './update-goal.dto';
|
||||
131
src/modules/goals/dto/update-goal.dto.ts
Normal file
131
src/modules/goals/dto/update-goal.dto.ts
Normal 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;
|
||||
}
|
||||
118
src/modules/goals/entities/goal.entity.ts
Normal file
118
src/modules/goals/entities/goal.entity.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1
src/modules/goals/entities/index.ts
Normal file
1
src/modules/goals/entities/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './goal.entity';
|
||||
135
src/modules/goals/goals.controller.ts
Normal file
135
src/modules/goals/goals.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
src/modules/goals/goals.module.ts
Normal file
13
src/modules/goals/goals.module.ts
Normal 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 {}
|
||||
181
src/modules/goals/goals.service.ts
Normal file
181
src/modules/goals/goals.service.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
1
src/modules/recommendations/entities/index.ts
Normal file
1
src/modules/recommendations/entities/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './recommendation.entity';
|
||||
@ -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;
|
||||
}
|
||||
98
src/modules/recommendations/recommendations.controller.ts
Normal file
98
src/modules/recommendations/recommendations.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
23
src/modules/recommendations/recommendations.module.ts
Normal file
23
src/modules/recommendations/recommendations.module.ts
Normal 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 {}
|
||||
282
src/modules/recommendations/recommendations.service.ts
Normal file
282
src/modules/recommendations/recommendations.service.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
82
src/modules/transactions/dto/create-transaction.dto.ts
Normal file
82
src/modules/transactions/dto/create-transaction.dto.ts
Normal 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;
|
||||
}
|
||||
3
src/modules/transactions/dto/index.ts
Normal file
3
src/modules/transactions/dto/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './create-transaction.dto';
|
||||
export * from './update-transaction.dto';
|
||||
export * from './query-transactions.dto';
|
||||
84
src/modules/transactions/dto/query-transactions.dto.ts
Normal file
84
src/modules/transactions/dto/query-transactions.dto.ts
Normal 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
Loading…
Reference in New Issue
Block a user