Compare commits
10 Commits
96f0b6ba80
...
640632ca7e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
640632ca7e | ||
| 221878529f | |||
|
|
3cc47bf56b | ||
|
|
0cc8178164 | ||
|
|
4ec325208d | ||
|
|
86fa5720ca | ||
|
|
786d7049b9 | ||
|
|
eaa4d1c23b | ||
|
|
ecf2dcf569 | ||
|
|
1d21861615 |
15
.env
Normal file
15
.env
Normal file
@ -0,0 +1,15 @@
|
||||
DB_USERNAME=finance_user
|
||||
DB_PASSWORD=SecurePassword123!
|
||||
DB_NAME=finance_app
|
||||
|
||||
|
||||
JWT_SECRET=your_jwt_secret_key_here_minimum_32_characters_long
|
||||
JWT_REFRESH_SECRET=your_refresh_secret_key_here_minimum_32_characters_long
|
||||
|
||||
|
||||
COOKIE_DOMAIN=ai-assistant-bot.xyz
|
||||
COOKIE_SECURE=true
|
||||
|
||||
|
||||
FRONTEND_URL=https://your-frontend.ai-assistant-bot.xyz
|
||||
CORS_ORIGINS=https://your-frontend.ai-assistant-bot.xyz
|
||||
@ -32,9 +32,13 @@ LOCKOUT_DURATION_MINUTES=30
|
||||
|
||||
# AI Integration (Phase 2)
|
||||
DEEPSEEK_API_KEY=
|
||||
OPENROUTER_API_KEY=
|
||||
AI_SERVICE_URL=http://localhost:8000
|
||||
AI_ENABLED=false
|
||||
AI_ENABLED=true
|
||||
|
||||
OPENROUTER_API_KEY=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
|
||||
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
OPENROUTER_MODEL=openai/gpt-oss-20b:free
|
||||
OPENROUTER_TIMEOUT_MS=20000
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=debug
|
||||
|
||||
@ -33,6 +33,9 @@ LOCKOUT_DURATION_MINUTES=30
|
||||
# AI Integration (Phase 2 - DeepSeek via OpenRouter)
|
||||
DEEPSEEK_API_KEY=
|
||||
OPENROUTER_API_KEY=
|
||||
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
OPENROUTER_MODEL=openai/gpt-oss-120b:free
|
||||
OPENROUTER_TIMEOUT_MS=20000
|
||||
AI_SERVICE_URL=http://localhost:8000
|
||||
AI_ENABLED=false
|
||||
|
||||
|
||||
61
.gitea/workflows/deploy-production.yml
Normal file
61
.gitea/workflows/deploy-production.yml
Normal file
@ -0,0 +1,61 @@
|
||||
name: Deploy Production
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy_production:
|
||||
name: Deploy to Production
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: production
|
||||
url: https://api-finance.ai-assistant-bot.xyz
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install SSH and rsync
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends openssh-client rsync
|
||||
|
||||
- name: Configure SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" | tr -d '\r' > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Ensure remote directory exists
|
||||
run: |
|
||||
ssh -o StrictHostKeyChecking=yes "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
|
||||
"mkdir -p /opt/apps/api-finance"
|
||||
|
||||
- name: Sync repository to server
|
||||
run: |
|
||||
rsync -az --delete \
|
||||
--exclude='.git' \
|
||||
--exclude='.env' \
|
||||
--exclude='.env.*' \
|
||||
--exclude='node_modules' \
|
||||
--exclude='coverage' \
|
||||
--exclude='dist' \
|
||||
./ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/opt/apps/api-finance/"
|
||||
|
||||
- name: Rebuild and restart Docker Compose
|
||||
run: |
|
||||
ssh -o StrictHostKeyChecking=yes "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" "
|
||||
set -e
|
||||
cd /opt/apps/api-finance
|
||||
docker compose -f docker-compose.server.yml pull
|
||||
docker compose -f docker-compose.server.yml up -d --build
|
||||
docker image prune -f
|
||||
"
|
||||
|
||||
- name: Optional: Check service health
|
||||
run: |
|
||||
ssh -o StrictHostKeyChecking=yes "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" "
|
||||
set -e
|
||||
curl --fail --silent --show-error https://api-finance.ai-assistant-bot.xyz/ || exit 1
|
||||
"
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -36,11 +36,11 @@ lerna-debug.log*
|
||||
!.vscode/extensions.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
# .env
|
||||
# .env.development.local
|
||||
# .env.test.local
|
||||
# .env.production.local
|
||||
# .env.local
|
||||
|
||||
# temp directory
|
||||
.temp
|
||||
|
||||
@ -1,28 +1,6 @@
|
||||
stages:
|
||||
- build
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
|
||||
IMAGE_TAG_LATEST: $CI_REGISTRY_IMAGE:latest
|
||||
|
||||
build_and_push:
|
||||
stage: build
|
||||
image: docker:29
|
||||
services:
|
||||
- name: docker:29-dind
|
||||
command: ["--tls=false"]
|
||||
variables:
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
DOCKER_HOST: tcp://docker:2375
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
script:
|
||||
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
|
||||
- docker build --target production -t "$IMAGE_TAG" -t "$IMAGE_TAG_LATEST" .
|
||||
- docker push "$IMAGE_TAG"
|
||||
- docker push "$IMAGE_TAG_LATEST"
|
||||
|
||||
deploy_production:
|
||||
stage: deploy
|
||||
image: alpine:3.20
|
||||
@ -30,7 +8,7 @@ deploy_production:
|
||||
name: production
|
||||
url: https://api-finance.ai-assistant-bot.xyz
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
||||
when: manual
|
||||
allow_failure: false
|
||||
before_script:
|
||||
@ -41,14 +19,5 @@ deploy_production:
|
||||
- ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
|
||||
script:
|
||||
- ssh "$DEPLOY_USER@$DEPLOY_HOST" "mkdir -p /opt/apps/api-finance"
|
||||
- rsync -az --delete \
|
||||
--exclude='.git' \
|
||||
--exclude='.env' \
|
||||
--exclude='.env.*' \
|
||||
--exclude='node_modules' \
|
||||
--exclude='coverage' \
|
||||
--exclude='dist' \
|
||||
./ "$DEPLOY_USER@$DEPLOY_HOST:/opt/apps/api-finance/"
|
||||
- ssh "$DEPLOY_USER@$DEPLOY_HOST" "docker login -u '$CI_REGISTRY_USER' -p '$CI_REGISTRY_PASSWORD' '$CI_REGISTRY'"
|
||||
- ssh "$DEPLOY_USER@$DEPLOY_HOST" "cd /opt/apps/api-finance && APP_IMAGE='$IMAGE_TAG' docker compose -f docker-compose.server.yml pull"
|
||||
- ssh "$DEPLOY_USER@$DEPLOY_HOST" "cd /opt/apps/api-finance && APP_IMAGE='$IMAGE_TAG' docker compose -f docker-compose.server.yml up -d"
|
||||
- rsync -az --delete --exclude='.git' --exclude='.env' --exclude='.env.*' --exclude='node_modules' --exclude='coverage' --exclude='dist' ./ "$DEPLOY_USER@$DEPLOY_HOST:/opt/apps/api-finance/"
|
||||
- ssh "$DEPLOY_USER@$DEPLOY_HOST" "cd /opt/apps/api-finance && docker compose -f docker-compose.server.yml up -d --build"
|
||||
|
||||
@ -63,6 +63,7 @@ RUN addgroup -g 1001 -S nodejs && \
|
||||
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 ./
|
||||
COPY --from=build --chown=nestjs:nodejs /app/scripts ./scripts
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
@ -78,5 +79,5 @@ EXPOSE 3000
|
||||
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"]
|
||||
# Start the application with migrations
|
||||
CMD ["sh", "-c", "node scripts/run-migrations.js && node dist/main.js"]
|
||||
|
||||
@ -1,48 +1,28 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14-alpine
|
||||
container_name: api_finance_postgres
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USERNAME}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
volumes:
|
||||
- api_finance_postgres_data:/var/lib/postgresql/data
|
||||
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_NAME}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- api_finance_internal
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
image: ${APP_IMAGE}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: production
|
||||
container_name: api_finance_app
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DB_HOST: postgres
|
||||
DB_HOST: shared_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}
|
||||
FRONTEND_URL: ${FRONTEND_URL}
|
||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN}
|
||||
COOKIE_SECURE: ${COOKIE_SECURE}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS}
|
||||
DB_USERNAME: finance_user
|
||||
DB_PASSWORD: SecurePassword123
|
||||
DB_NAME: finance_app
|
||||
JWT_SECRET: your_jwt_secret_key_here_minimum_32_characters_long
|
||||
JWT_REFRESH_SECRET: your_refresh_secret_key_here_minimum_32_characters_long
|
||||
FRONTEND_URL: https://your-frontend.ai-assistant-bot.xyz
|
||||
COOKIE_DOMAIN: ai-assistant-bot.xyz
|
||||
COOKIE_SECURE: "true"
|
||||
CORS_ORIGINS: https://your-frontend.ai-assistant-bot.xyz
|
||||
PORT: 3000
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- proxy
|
||||
- api_finance_internal
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
@ -52,11 +32,6 @@ services:
|
||||
- traefik.http.routers.api-finance.tls.certresolver=le
|
||||
- traefik.http.services.api-finance.loadbalancer.server.port=3000
|
||||
|
||||
volumes:
|
||||
api_finance_postgres_data:
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
api_finance_internal:
|
||||
driver: bridge
|
||||
|
||||
43
package-lock.json
generated
43
package-lock.json
generated
@ -18,6 +18,7 @@
|
||||
"@nestjs/swagger": "^7.4.0",
|
||||
"@nestjs/throttler": "^5.1.2",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
"axios": "^1.6.8",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
@ -4640,7 +4641,6 @@
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
@ -4658,6 +4658,17 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/b4a": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz",
|
||||
@ -5490,7 +5501,6 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
@ -5874,7 +5884,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
@ -6124,7 +6133,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@ -6924,6 +6932,26 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@ -7029,7 +7057,6 @@
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
@ -10162,6 +10189,12 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
"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",
|
||||
"migration:run:prod": "node scripts/run-migrations.js",
|
||||
"seed": "ts-node -r tsconfig-paths/register src/database/seeds/run-seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
@ -34,6 +35,7 @@
|
||||
"@nestjs/swagger": "^7.4.0",
|
||||
"@nestjs/throttler": "^5.1.2",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
"axios": "^1.6.8",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
|
||||
41
scripts/run-migrations.js
Normal file
41
scripts/run-migrations.js
Normal file
@ -0,0 +1,41 @@
|
||||
const { DataSource } = require('typeorm');
|
||||
const path = require('path');
|
||||
|
||||
const 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',
|
||||
migrations: [path.join(__dirname, '../dist/database/migrations/*.js')],
|
||||
logging: true,
|
||||
};
|
||||
|
||||
async function runMigrations() {
|
||||
console.log('🔄 Running database migrations...');
|
||||
|
||||
const dataSource = new DataSource(dataSourceOptions);
|
||||
|
||||
try {
|
||||
await dataSource.initialize();
|
||||
console.log('📦 Database connection established');
|
||||
|
||||
const migrations = await dataSource.runMigrations({ transaction: 'all' });
|
||||
|
||||
if (migrations.length === 0) {
|
||||
console.log('✅ No pending migrations');
|
||||
} else {
|
||||
console.log(`✅ Successfully ran ${migrations.length} migration(s):`);
|
||||
migrations.forEach((m) => console.log(` - ${m.name}`));
|
||||
}
|
||||
|
||||
await dataSource.destroy();
|
||||
console.log('🔌 Database connection closed');
|
||||
} catch (error) {
|
||||
console.error('❌ Migration failed:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runMigrations();
|
||||
@ -17,13 +17,18 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
if (response.headersSent) {
|
||||
return;
|
||||
}
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = 'Внутренняя ошибка сервера';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
message = typeof exceptionResponse === 'string'
|
||||
message =
|
||||
typeof exceptionResponse === 'string'
|
||||
? exceptionResponse
|
||||
: (exceptionResponse as any).message || message;
|
||||
} else if (exception instanceof Error) {
|
||||
|
||||
@ -3,6 +3,13 @@ import { registerAs } from '@nestjs/config';
|
||||
export default registerAs('ai', () => ({
|
||||
deepseekApiKey: process.env.DEEPSEEK_API_KEY || '',
|
||||
openrouterApiKey: process.env.OPENROUTER_API_KEY || '',
|
||||
openrouterBaseUrl:
|
||||
process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1',
|
||||
openrouterModel: process.env.OPENROUTER_MODEL || 'openai/gpt-oss-120b:free',
|
||||
openrouterTimeoutMs: parseInt(
|
||||
process.env.OPENROUTER_TIMEOUT_MS || '20000',
|
||||
10,
|
||||
),
|
||||
serviceUrl: process.env.AI_SERVICE_URL || 'http://localhost:8000',
|
||||
enabled: process.env.AI_ENABLED === 'true',
|
||||
}));
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||
import * as dotenv from 'dotenv';
|
||||
|
||||
dotenv.config({ path: '.env' });
|
||||
dotenv.config({ path: '.env.development' });
|
||||
|
||||
export const dataSourceOptions: DataSourceOptions = {
|
||||
@ -10,8 +11,8 @@ export const dataSourceOptions: DataSourceOptions = {
|
||||
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}'],
|
||||
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
|
||||
migrations: [__dirname + '/migrations/*{.ts,.js}'],
|
||||
synchronize: false,
|
||||
logging: process.env.NODE_ENV === 'development',
|
||||
};
|
||||
|
||||
305
src/database/migrations/1734256800000-InitialSchema.ts
Normal file
305
src/database/migrations/1734256800000-InitialSchema.ts
Normal file
@ -0,0 +1,305 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class InitialSchema1734256800000 implements MigrationInterface {
|
||||
name = 'InitialSchema1734256800000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE "public"."transaction_type_enum" AS ENUM('INCOME', 'EXPENSE')
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE "public"."budget_group_type_enum" AS ENUM('ESSENTIAL', 'PERSONAL', 'SAVINGS')
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE "public"."payment_method_enum" AS ENUM('CASH', 'CARD', 'BANK_TRANSFER')
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE "public"."goal_status_enum" AS ENUM('ACTIVE', 'COMPLETED', 'PAUSED', 'CANCELLED')
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE "public"."goal_priority_enum" AS ENUM('LOW', 'MEDIUM', 'HIGH')
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE "public"."goal_auto_save_frequency_enum" AS ENUM('DAILY', 'WEEKLY', 'MONTHLY')
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE "public"."recommendation_type_enum" AS ENUM('SAVING', 'SPENDING', 'INVESTMENT', 'TAX', 'DEBT', 'BUDGET', 'GOAL')
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE "public"."recommendation_status_enum" AS ENUM('NEW', 'VIEWED', 'APPLIED', 'DISMISSED')
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "users" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"email" character varying(255) NOT NULL,
|
||||
"phone" character varying(20),
|
||||
"password_hash" character varying(255) NOT NULL,
|
||||
"first_name" character varying(100),
|
||||
"last_name" character varying(100),
|
||||
"currency" character varying(3) NOT NULL DEFAULT 'RUB',
|
||||
"language" character varying(10) NOT NULL DEFAULT 'ru',
|
||||
"timezone" character varying(50) NOT NULL DEFAULT 'Europe/Moscow',
|
||||
"is_email_verified" boolean NOT NULL DEFAULT false,
|
||||
"is_phone_verified" boolean NOT NULL DEFAULT false,
|
||||
"is_active" boolean NOT NULL DEFAULT true,
|
||||
"failed_login_attempts" integer NOT NULL DEFAULT 0,
|
||||
"locked_until" TIMESTAMP,
|
||||
"last_login_at" TIMESTAMP,
|
||||
"monthly_income" numeric(15,2) NOT NULL DEFAULT 0,
|
||||
"financial_goals" jsonb,
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"deleted_at" TIMESTAMP,
|
||||
CONSTRAINT "UQ_users_email" UNIQUE ("email"),
|
||||
CONSTRAINT "UQ_users_phone" UNIQUE ("phone"),
|
||||
CONSTRAINT "PK_users" PRIMARY KEY ("id")
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_users_email" ON "users" ("email")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_users_phone" ON "users" ("phone")`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "refresh_tokens" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"user_id" uuid NOT NULL,
|
||||
"token_hash" character varying(255) NOT NULL,
|
||||
"user_agent" text,
|
||||
"ip_address" inet,
|
||||
"expires_at" TIMESTAMP NOT NULL,
|
||||
"is_revoked" boolean NOT NULL DEFAULT false,
|
||||
"replaced_by_token_hash" character varying(255),
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_refresh_tokens" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_refresh_tokens_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_refresh_tokens_user_id" ON "refresh_tokens" ("user_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_refresh_tokens_expires_at" ON "refresh_tokens" ("expires_at")`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "audit_logs" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"user_id" uuid,
|
||||
"action" character varying(50) NOT NULL,
|
||||
"entity_type" character varying(50) NOT NULL,
|
||||
"entity_id" uuid,
|
||||
"old_values" jsonb,
|
||||
"new_values" jsonb,
|
||||
"ip_address" inet,
|
||||
"user_agent" text,
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_audit_logs" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_audit_logs_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_audit_logs_user_id" ON "audit_logs" ("user_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_audit_logs_created_at" ON "audit_logs" ("created_at")`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "categories" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"name_ru" character varying(100) NOT NULL,
|
||||
"name_en" character varying(100),
|
||||
"type" "public"."transaction_type_enum" NOT NULL,
|
||||
"group_type" "public"."budget_group_type_enum",
|
||||
"icon" character varying(50),
|
||||
"color" character varying(7),
|
||||
"is_default" boolean NOT NULL DEFAULT true,
|
||||
"user_id" uuid,
|
||||
"parent_id" uuid,
|
||||
CONSTRAINT "PK_categories" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_categories_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_categories_parent" FOREIGN KEY ("parent_id") REFERENCES "categories"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_categories_type" ON "categories" ("type")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_categories_user_id" ON "categories" ("user_id")`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "transactions" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"user_id" uuid NOT NULL,
|
||||
"amount" numeric(15,2) NOT NULL,
|
||||
"currency" character varying(3) NOT NULL DEFAULT 'RUB',
|
||||
"type" "public"."transaction_type_enum" NOT NULL,
|
||||
"category_id" uuid,
|
||||
"description" text,
|
||||
"transaction_date" date NOT NULL,
|
||||
"payment_method" "public"."payment_method_enum",
|
||||
"receipt_url" character varying(500),
|
||||
"receipt_processed" boolean NOT NULL DEFAULT false,
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"is_recurring" boolean NOT NULL DEFAULT false,
|
||||
"recurring_pattern" jsonb,
|
||||
"is_planned" boolean NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"deleted_at" TIMESTAMP,
|
||||
CONSTRAINT "CHK_transactions_amount" CHECK ("amount" > 0),
|
||||
CONSTRAINT "PK_transactions" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_transactions_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_transactions_category" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_transactions_user_id" ON "transactions" ("user_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_transactions_type" ON "transactions" ("type")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_transactions_category_id" ON "transactions" ("category_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_transactions_transaction_date" ON "transactions" ("transaction_date")`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "budgets" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"user_id" uuid NOT NULL,
|
||||
"month" date NOT NULL,
|
||||
"total_income" numeric(15,2) NOT NULL DEFAULT 0,
|
||||
"essentials_limit" numeric(15,2) NOT NULL DEFAULT 0,
|
||||
"essentials_spent" numeric(15,2) NOT NULL DEFAULT 0,
|
||||
"personal_limit" numeric(15,2) NOT NULL DEFAULT 0,
|
||||
"personal_spent" numeric(15,2) NOT NULL DEFAULT 0,
|
||||
"savings_limit" numeric(15,2) NOT NULL DEFAULT 0,
|
||||
"savings_spent" numeric(15,2) NOT NULL DEFAULT 0,
|
||||
"custom_allocations" jsonb,
|
||||
"is_active" boolean NOT NULL DEFAULT true,
|
||||
CONSTRAINT "UQ_budgets_user_month" UNIQUE ("user_id", "month"),
|
||||
CONSTRAINT "PK_budgets" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_budgets_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_budgets_user_id" ON "budgets" ("user_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_budgets_month" ON "budgets" ("month")`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "goals" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"user_id" uuid NOT NULL,
|
||||
"title_ru" character varying(200) NOT NULL,
|
||||
"description_ru" text,
|
||||
"target_amount" numeric(15,2) NOT NULL,
|
||||
"current_amount" numeric(15,2) NOT NULL DEFAULT 0,
|
||||
"currency" character varying(3) NOT NULL DEFAULT 'RUB',
|
||||
"target_date" date,
|
||||
"status" "public"."goal_status_enum" NOT NULL DEFAULT 'ACTIVE',
|
||||
"priority" "public"."goal_priority_enum" NOT NULL DEFAULT 'MEDIUM',
|
||||
"icon" character varying(50),
|
||||
"color" character varying(7),
|
||||
"auto_save_enabled" boolean NOT NULL DEFAULT false,
|
||||
"auto_save_amount" numeric(15,2),
|
||||
"auto_save_frequency" "public"."goal_auto_save_frequency_enum",
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"completed_at" TIMESTAMP,
|
||||
CONSTRAINT "PK_goals" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_goals_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_goals_user_id" ON "goals" ("user_id")`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "recommendations" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"user_id" uuid NOT NULL,
|
||||
"type" "public"."recommendation_type_enum" NOT NULL,
|
||||
"title_ru" character varying(200) NOT NULL,
|
||||
"description_ru" text NOT NULL,
|
||||
"action_text_ru" character varying(200),
|
||||
"priority_score" numeric(3,2) NOT NULL DEFAULT 0.5,
|
||||
"confidence_score" numeric(3,2) NOT NULL DEFAULT 0.5,
|
||||
"potential_savings" numeric(15,2),
|
||||
"status" "public"."recommendation_status_enum" NOT NULL DEFAULT 'NEW',
|
||||
"action_data" jsonb,
|
||||
"expires_at" TIMESTAMP,
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"viewed_at" TIMESTAMP,
|
||||
"applied_at" TIMESTAMP,
|
||||
CONSTRAINT "PK_recommendations" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_recommendations_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_recommendations_user_id" ON "recommendations" ("user_id")`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "recommendations"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "goals"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "budgets"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "transactions"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "categories"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "audit_logs"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "refresh_tokens"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "users"`);
|
||||
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS "public"."recommendation_status_enum"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS "public"."recommendation_type_enum"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS "public"."goal_auto_save_frequency_enum"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS "public"."goal_priority_enum"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TYPE IF EXISTS "public"."goal_status_enum"`);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS "public"."payment_method_enum"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS "public"."budget_group_type_enum"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS "public"."transaction_type_enum"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
/**
|
||||
* AI Service Placeholder for DeepSeek Integration via OpenRouter
|
||||
@ -17,6 +18,7 @@ export interface TransactionCategorizationResponse {
|
||||
suggestedCategoryName: string;
|
||||
confidence: number;
|
||||
reasoning: string;
|
||||
source?: 'openrouter' | 'mock';
|
||||
}
|
||||
|
||||
export interface SpendingAnalysisRequest {
|
||||
@ -38,18 +40,56 @@ export interface SpendingAnalysisResponse {
|
||||
}>;
|
||||
insights: string[];
|
||||
recommendations: string[];
|
||||
source?: 'openrouter' | 'mock';
|
||||
}
|
||||
|
||||
export interface FinancialRecommendation {
|
||||
id: string;
|
||||
type: 'SAVING' | 'SPENDING' | 'INVESTMENT' | 'TAX' | 'DEBT';
|
||||
type:
|
||||
| 'SAVING'
|
||||
| 'SPENDING'
|
||||
| 'INVESTMENT'
|
||||
| 'TAX'
|
||||
| 'DEBT'
|
||||
| 'BUDGET'
|
||||
| 'GOAL';
|
||||
titleRu: string;
|
||||
descriptionRu: string;
|
||||
priorityScore: number;
|
||||
confidenceScore: number;
|
||||
actionData?: Record<string, any>;
|
||||
source?: 'openrouter' | 'mock';
|
||||
}
|
||||
|
||||
export interface AnalyticsNarrativeRequest {
|
||||
period: string;
|
||||
totals: {
|
||||
income: number;
|
||||
expenses: number;
|
||||
netSavings: number;
|
||||
savingsRate: number;
|
||||
};
|
||||
topCategories: Array<{ name: string; amount: number }>;
|
||||
}
|
||||
|
||||
export interface AnalyticsNarrativeResponse {
|
||||
summaryRu: string;
|
||||
insightsRu: string[];
|
||||
actionsRu: string[];
|
||||
source?: 'openrouter' | 'mock';
|
||||
}
|
||||
|
||||
type OpenRouterChatMessage = {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
};
|
||||
|
||||
type OpenRouterChatCompletionResponse = {
|
||||
choices?: Array<{
|
||||
message?: { content?: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
export interface ForecastRequest {
|
||||
userId: string;
|
||||
historicalData: Array<{
|
||||
@ -78,13 +118,45 @@ export interface ForecastResponse {
|
||||
export class AiService {
|
||||
private readonly logger = new Logger(AiService.name);
|
||||
private readonly isEnabled: boolean;
|
||||
private readonly http: AxiosInstance;
|
||||
private readonly openrouterApiKey: string;
|
||||
private readonly openrouterBaseUrl: string;
|
||||
private readonly openrouterModel: string;
|
||||
private readonly openrouterTimeoutMs: number;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.isEnabled = this.configService.get<boolean>('ai.enabled') || false;
|
||||
this.openrouterApiKey =
|
||||
this.configService.get<string>('ai.openrouterApiKey') || '';
|
||||
this.openrouterBaseUrl =
|
||||
this.configService.get<string>('ai.openrouterBaseUrl') ||
|
||||
'https://openrouter.ai/api/v1';
|
||||
this.openrouterModel =
|
||||
this.configService.get<string>('ai.openrouterModel') ||
|
||||
'openai/gpt-oss-120b:free';
|
||||
this.openrouterTimeoutMs =
|
||||
this.configService.get<number>('ai.openrouterTimeoutMs') || 20000;
|
||||
|
||||
if (!this.isEnabled) {
|
||||
this.logger.warn('AI Service is disabled. Using mock implementations.');
|
||||
const enabledFlag = this.configService.get<boolean>('ai.enabled') || false;
|
||||
this.isEnabled = enabledFlag && !!this.openrouterApiKey;
|
||||
|
||||
if (!enabledFlag) {
|
||||
this.logger.warn(
|
||||
'AI Service is disabled (AI_ENABLED=false). Using mock implementations.',
|
||||
);
|
||||
} else if (!this.openrouterApiKey) {
|
||||
this.logger.warn(
|
||||
'AI Service enabled but OPENROUTER_API_KEY is missing. Using mock implementations.',
|
||||
);
|
||||
}
|
||||
|
||||
this.http = axios.create({
|
||||
baseURL: this.openrouterBaseUrl,
|
||||
timeout: this.openrouterTimeoutMs,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.openrouterApiKey}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -100,10 +172,39 @@ export class AiService {
|
||||
return this.mockCategorizeTransaction(request);
|
||||
}
|
||||
|
||||
// TODO: Implement DeepSeek API call via OpenRouter
|
||||
// const response = await this.callDeepSeek('categorize', request);
|
||||
try {
|
||||
const messages: OpenRouterChatMessage[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'Ты финансовый ассистент. Подбери категорию транзакции. Верни ОДНУ строку валидного JSON (без markdown, без пояснений, без переносов строк). ВАЖНО: внутри строк обязательно экранируй кавычки и переносы (используй \\" и \\n). Не ставь запятых после последнего поля. Держи строки короткими (до 200 символов). Формат строго: {"suggestedCategoryName":"...","confidence":0.0,"reasoning":"..."}',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: JSON.stringify(request),
|
||||
},
|
||||
];
|
||||
|
||||
const json = await this.callOpenRouterJson<{
|
||||
suggestedCategoryName: string;
|
||||
confidence: number;
|
||||
reasoning: string;
|
||||
}>(messages);
|
||||
|
||||
return {
|
||||
suggestedCategoryId: 'unknown',
|
||||
suggestedCategoryName: json.suggestedCategoryName || 'Другое',
|
||||
confidence: this.clamp01(json.confidence ?? 0.5),
|
||||
reasoning: json.reasoning || 'AI suggested category',
|
||||
source: 'openrouter',
|
||||
};
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`OpenRouter categorizeTransaction failed, using mock. Reason: ${(e as Error)?.message}`,
|
||||
);
|
||||
return this.mockCategorizeTransaction(request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze spending patterns
|
||||
@ -118,9 +219,40 @@ export class AiService {
|
||||
return this.mockAnalyzeSpending(request);
|
||||
}
|
||||
|
||||
// TODO: Implement DeepSeek API call via OpenRouter
|
||||
try {
|
||||
const safePayload = {
|
||||
userId: request.userId,
|
||||
startDate: request.startDate,
|
||||
endDate: request.endDate,
|
||||
transactions: request.transactions.slice(0, 200),
|
||||
};
|
||||
|
||||
const messages: OpenRouterChatMessage[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'Ты финансовый аналитик. Проанализируй траты и верни ОДНУ строку валидного JSON (без markdown, без пояснений, без переносов строк). ВАЖНО: экранируй кавычки и переносы внутри строк (\\" и \\n), без лишних запятых. Держи массивы короткими (patterns<=5, insights<=5, recommendations<=5), строки до 200 символов. Формат строго: {"patterns":[{"pattern":"...","description":"...","impact":"positive|negative|neutral"}],"insights":["..."],"recommendations":["..."]}',
|
||||
},
|
||||
{ role: 'user', content: JSON.stringify(safePayload) },
|
||||
];
|
||||
|
||||
const json =
|
||||
await this.callOpenRouterJson<SpendingAnalysisResponse>(messages);
|
||||
return {
|
||||
patterns: Array.isArray(json.patterns) ? json.patterns : [],
|
||||
insights: Array.isArray(json.insights) ? json.insights : [],
|
||||
recommendations: Array.isArray(json.recommendations)
|
||||
? json.recommendations
|
||||
: [],
|
||||
source: 'openrouter',
|
||||
};
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`OpenRouter analyzeSpending failed, using mock. Reason: ${(e as Error)?.message}`,
|
||||
);
|
||||
return this.mockAnalyzeSpending(request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate personalized recommendations
|
||||
@ -141,9 +273,50 @@ export class AiService {
|
||||
return this.mockGenerateRecommendations(context);
|
||||
}
|
||||
|
||||
// TODO: Implement DeepSeek API call via OpenRouter
|
||||
try {
|
||||
const messages: OpenRouterChatMessage[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'Ты персональный финансовый ассистент. Сгенерируй 3-5 рекомендаций на русском. Верни ОДНУ строку валидного JSON (без markdown, без пояснений, без переносов строк). ВАЖНО: экранируй кавычки и переносы внутри строк (\\" и \\n), без лишних запятых. titleRu<=120 символов, descriptionRu<=300 символов. priorityScore/confidenceScore: 0.0..1.0. actionData должен быть объектом (может быть пустым {}). Формат строго: {"recommendations":[{"id":"...","type":"SAVING|SPENDING|INVESTMENT|TAX|DEBT|BUDGET|GOAL","titleRu":"...","descriptionRu":"...","priorityScore":0.0,"confidenceScore":0.0,"actionData":{}}]}',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: JSON.stringify({ userId, context }),
|
||||
},
|
||||
];
|
||||
|
||||
const json = await this.callOpenRouterJson<{
|
||||
recommendations: FinancialRecommendation[];
|
||||
}>(messages);
|
||||
const recs = Array.isArray(json.recommendations)
|
||||
? json.recommendations
|
||||
: [];
|
||||
|
||||
const normalized = recs
|
||||
.slice(0, 8)
|
||||
.map((r) => ({
|
||||
id: r.id || `ai-${Date.now()}`,
|
||||
type: r.type,
|
||||
titleRu: r.titleRu,
|
||||
descriptionRu: r.descriptionRu,
|
||||
priorityScore: this.clamp01(Number(r.priorityScore ?? 0.5)),
|
||||
confidenceScore: this.clamp01(Number(r.confidenceScore ?? 0.5)),
|
||||
actionData: r.actionData,
|
||||
source: 'openrouter' as const,
|
||||
}))
|
||||
.filter((r) => !!r.titleRu && !!r.descriptionRu);
|
||||
|
||||
return normalized.length > 0
|
||||
? normalized
|
||||
: this.mockGenerateRecommendations(context);
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`OpenRouter generateRecommendations failed, using mock. Reason: ${(e as Error)?.message}`,
|
||||
);
|
||||
return this.mockGenerateRecommendations(context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forecast future finances
|
||||
@ -156,10 +329,238 @@ export class AiService {
|
||||
return this.mockForecastFinances(request);
|
||||
}
|
||||
|
||||
// TODO: Implement DeepSeek API call via OpenRouter
|
||||
// Forecasting can be expensive; keep mock fallback for now.
|
||||
return this.mockForecastFinances(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an AI narrative for analytics dashboards (C option)
|
||||
* Returns mock-like output when AI is disabled or fails.
|
||||
*/
|
||||
async generateAnalyticsNarrative(
|
||||
request: AnalyticsNarrativeRequest,
|
||||
): Promise<AnalyticsNarrativeResponse> {
|
||||
if (!this.isEnabled) {
|
||||
return {
|
||||
summaryRu: 'Краткий обзор недоступен (AI отключен).',
|
||||
insightsRu: [],
|
||||
actionsRu: [],
|
||||
source: 'mock',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const safePayload = {
|
||||
...request,
|
||||
topCategories: request.topCategories.slice(0, 8),
|
||||
};
|
||||
|
||||
const messages: OpenRouterChatMessage[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'Ты финансовый аналитик. Сформируй краткий обзор по метрикам. Верни ОДНУ строку валидного JSON (без markdown, без пояснений, без переносов строк). ВАЖНО: экранируй кавычки и переносы внутри строк (\\" и \\n), без лишних запятых. summaryRu<=300 символов, insightsRu<=5, actionsRu<=5, строки до 200 символов. Формат строго: {"summaryRu":"...","insightsRu":["..."],"actionsRu":["..."]}',
|
||||
},
|
||||
{ role: 'user', content: JSON.stringify(safePayload) },
|
||||
];
|
||||
|
||||
const json =
|
||||
await this.callOpenRouterJson<AnalyticsNarrativeResponse>(messages);
|
||||
|
||||
return {
|
||||
summaryRu: json.summaryRu || 'Обзор недоступен',
|
||||
insightsRu: Array.isArray(json.insightsRu) ? json.insightsRu : [],
|
||||
actionsRu: Array.isArray(json.actionsRu) ? json.actionsRu : [],
|
||||
source: 'openrouter',
|
||||
};
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`OpenRouter generateAnalyticsNarrative failed. Reason: ${(e as Error)?.message}`,
|
||||
);
|
||||
return {
|
||||
summaryRu: 'Не удалось получить AI-обзор. Попробуйте позже.',
|
||||
insightsRu: [],
|
||||
actionsRu: [],
|
||||
source: 'mock',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private clamp01(value: number): number {
|
||||
if (Number.isNaN(value)) return 0.5;
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
private async callOpenRouterJson<T>(
|
||||
messages: OpenRouterChatMessage[],
|
||||
): Promise<T> {
|
||||
const model = this.openrouterModel;
|
||||
|
||||
const maxAttempts = 2;
|
||||
let lastError: unknown;
|
||||
|
||||
let repairHintAdded = false;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const attemptMessages = repairHintAdded
|
||||
? [
|
||||
...messages,
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
'Твой предыдущий ответ был НЕВАЛИДНЫМ JSON (ошибка парсинга). Верни снова ответ ТОЛЬКО как валидный JSON (без markdown, без пояснений, без лишнего текста).',
|
||||
},
|
||||
]
|
||||
: messages;
|
||||
|
||||
// Keep response size controlled (reduce truncation risk for strict JSON)
|
||||
const payload = {
|
||||
model,
|
||||
messages: attemptMessages,
|
||||
temperature: 0,
|
||||
max_tokens: 500,
|
||||
// Some OpenRouter providers support OpenAI-style JSON enforcement.
|
||||
// If unsupported, it should be ignored; we still validate/repair/fallback.
|
||||
response_format: { type: 'json_object' },
|
||||
};
|
||||
|
||||
const resp = await this.http.post<OpenRouterChatCompletionResponse>(
|
||||
'/chat/completions',
|
||||
payload,
|
||||
{
|
||||
headers: {
|
||||
'HTTP-Referer': 'http://localhost',
|
||||
'X-Title': 'Finance App',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const content = resp.data?.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error('OpenRouter response content is empty');
|
||||
}
|
||||
|
||||
const extracted = this.extractJson(content);
|
||||
return JSON.parse(extracted) as T;
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
const anyErr = e as any;
|
||||
const msg = anyErr?.message || String(e);
|
||||
const status = anyErr?.response?.status as number | undefined;
|
||||
|
||||
this.logger.warn(
|
||||
`OpenRouter attempt ${attempt}/${maxAttempts} failed${status ? ` (status ${status})` : ''}: ${msg}`,
|
||||
);
|
||||
|
||||
// If this isn't the last attempt, wait a bit before retrying.
|
||||
if (attempt < maxAttempts) {
|
||||
let delayMs = 500 * Math.pow(2, attempt - 1); // 500ms, 1000ms...
|
||||
|
||||
// If model returned invalid JSON, retry once with a strict repair hint.
|
||||
if (
|
||||
!repairHintAdded &&
|
||||
/\bJSON\b/i.test(msg) &&
|
||||
/position|unterminated|unexpected|parse/i.test(msg)
|
||||
) {
|
||||
repairHintAdded = true;
|
||||
}
|
||||
|
||||
// If rate limited, honor Retry-After header when possible.
|
||||
if (status === 429) {
|
||||
const retryAfterHeader = anyErr?.response?.headers?.['retry-after'];
|
||||
const retryAfterSec = retryAfterHeader
|
||||
? Number(retryAfterHeader)
|
||||
: NaN;
|
||||
if (!Number.isNaN(retryAfterSec) && retryAfterSec > 0) {
|
||||
delayMs = Math.min(30000, retryAfterSec * 1000);
|
||||
} else {
|
||||
delayMs = Math.min(30000, 2000 * attempt);
|
||||
}
|
||||
}
|
||||
|
||||
await this.sleep(delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
private async sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private extractJson(text: string): string {
|
||||
// Some models may wrap JSON in text; attempt to extract first JSON object/array.
|
||||
const firstBrace = text.indexOf('{');
|
||||
const firstBracket = text.indexOf('[');
|
||||
|
||||
let start = -1;
|
||||
if (firstBrace === -1) start = firstBracket;
|
||||
else if (firstBracket === -1) start = firstBrace;
|
||||
else start = Math.min(firstBrace, firstBracket);
|
||||
|
||||
if (start === -1) {
|
||||
throw new Error('No JSON found in model output');
|
||||
}
|
||||
|
||||
const candidate = text
|
||||
.slice(start)
|
||||
.trim()
|
||||
.replace(/```[a-z]*\s*/gi, '')
|
||||
.replace(/```\s*$/g, '')
|
||||
.trim();
|
||||
|
||||
return this.extractBalancedJson(candidate);
|
||||
}
|
||||
|
||||
private extractBalancedJson(candidate: string): string {
|
||||
const startChar = candidate[0];
|
||||
const endChar = startChar === '{' ? '}' : startChar === '[' ? ']' : '';
|
||||
if (!endChar) {
|
||||
throw new Error('JSON must start with { or [');
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
|
||||
for (let i = 0; i < candidate.length; i++) {
|
||||
const ch = candidate[i];
|
||||
|
||||
if (inString) {
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === startChar) depth++;
|
||||
if (ch === endChar) depth--;
|
||||
|
||||
if (depth === 0) {
|
||||
return candidate.slice(0, i + 1).trim();
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't find a balanced end, return full candidate (JSON.parse will fail with a useful error)
|
||||
return candidate.trim();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Mock Implementations (Phase 1)
|
||||
// ============================================
|
||||
@ -171,17 +572,17 @@ export class AiService {
|
||||
|
||||
// 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: 'Коммуналка' },
|
||||
продукты: { 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)) {
|
||||
@ -191,6 +592,7 @@ export class AiService {
|
||||
suggestedCategoryName: category.name,
|
||||
confidence: 0.85,
|
||||
reasoning: `Ключевое слово "${keyword}" найдено в описании`,
|
||||
source: 'mock',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -200,6 +602,7 @@ export class AiService {
|
||||
suggestedCategoryName: 'Другое',
|
||||
confidence: 0.3,
|
||||
reasoning: 'Не удалось определить категорию по описанию',
|
||||
source: 'mock',
|
||||
};
|
||||
}
|
||||
|
||||
@ -229,6 +632,7 @@ export class AiService {
|
||||
'Увеличьте автоматические переводы на накопления до 20%',
|
||||
'Используйте карту с кэшбэком для регулярных покупок',
|
||||
],
|
||||
source: 'mock',
|
||||
};
|
||||
}
|
||||
|
||||
@ -253,6 +657,7 @@ export class AiService {
|
||||
targetSavingsRate: 20,
|
||||
monthlySavingsTarget: context.monthlyIncome * 0.2,
|
||||
},
|
||||
source: 'mock',
|
||||
});
|
||||
}
|
||||
|
||||
@ -261,9 +666,11 @@ export class AiService {
|
||||
id: 'tax-deduction',
|
||||
type: 'TAX',
|
||||
titleRu: 'Налоговый вычет',
|
||||
descriptionRu: 'Проверьте возможность получения налогового вычета за медицинские услуги или образование (3-НДФЛ).',
|
||||
descriptionRu:
|
||||
'Проверьте возможность получения налогового вычета за медицинские услуги или образование (3-НДФЛ).',
|
||||
priorityScore: 0.7,
|
||||
confidenceScore: 0.8,
|
||||
source: 'mock',
|
||||
});
|
||||
|
||||
// Emergency fund recommendation
|
||||
@ -271,12 +678,14 @@ export class AiService {
|
||||
id: 'emergency-fund',
|
||||
type: 'SAVING',
|
||||
titleRu: 'Резервный фонд',
|
||||
descriptionRu: 'Рекомендуем создать резервный фонд в размере 3-6 месячных расходов.',
|
||||
descriptionRu:
|
||||
'Рекомендуем создать резервный фонд в размере 3-6 месячных расходов.',
|
||||
priorityScore: 0.85,
|
||||
confidenceScore: 0.9,
|
||||
actionData: {
|
||||
targetAmount: context.totalExpenses * 6,
|
||||
},
|
||||
source: 'mock',
|
||||
});
|
||||
|
||||
return recommendations;
|
||||
|
||||
@ -1,19 +1,18 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiOkResponse,
|
||||
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';
|
||||
import {
|
||||
CurrentUser,
|
||||
JwtPayload,
|
||||
} from '../../common/decorators/user.decorator';
|
||||
import { TransactionType } from '../../common/constants/categories';
|
||||
|
||||
@ApiTags('Аналитика')
|
||||
@ -25,8 +24,32 @@ export class AnalyticsController {
|
||||
|
||||
@Get('overview')
|
||||
@ApiOperation({ summary: 'Обзор за месяц' })
|
||||
@ApiQuery({ name: 'month', required: false, example: '2024-01', description: 'Месяц в формате YYYY-MM' })
|
||||
@ApiResponse({ status: 200, description: 'Обзор за месяц' })
|
||||
@ApiQuery({
|
||||
name: 'month',
|
||||
required: false,
|
||||
example: '2024-01',
|
||||
description: 'Месяц в формате YYYY-MM',
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: 'Обзор за месяц',
|
||||
schema: {
|
||||
example: {
|
||||
month: '2024-01',
|
||||
totalIncome: 120000,
|
||||
totalExpenses: 85000,
|
||||
netSavings: 35000,
|
||||
savingsRate: 29.17,
|
||||
topCategories: [
|
||||
{
|
||||
categoryId: 'uuid',
|
||||
categoryName: 'Продукты',
|
||||
amount: 22000,
|
||||
percentage: 25.88,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
async getMonthlyOverview(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Query('month') month?: string,
|
||||
@ -35,10 +58,45 @@ export class AnalyticsController {
|
||||
return this.analyticsService.getMonthlyOverview(user.sub, date);
|
||||
}
|
||||
|
||||
@Get('narrative')
|
||||
@ApiOperation({ summary: 'AI обзор и рекомендации за месяц' })
|
||||
@ApiQuery({
|
||||
name: 'month',
|
||||
required: false,
|
||||
example: '2024-01',
|
||||
description: 'Месяц в формате YYYY-MM',
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: 'AI обзор за месяц',
|
||||
schema: {
|
||||
example: {
|
||||
summaryRu: 'В этом месяце вы сэкономили 29% дохода.',
|
||||
insightsRu: ['Самая крупная категория расходов — Продукты.'],
|
||||
actionsRu: ['Установите лимит на рестораны на следующий месяц.'],
|
||||
source: 'openrouter',
|
||||
},
|
||||
},
|
||||
})
|
||||
async getMonthlyNarrative(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Query('month') month?: string,
|
||||
) {
|
||||
const date = month ? new Date(month + '-01') : new Date();
|
||||
return this.analyticsService.getMonthlyNarrative(user.sub, date);
|
||||
}
|
||||
|
||||
@Get('trends')
|
||||
@ApiOperation({ summary: 'Тренды расходов' })
|
||||
@ApiQuery({ name: 'months', required: false, example: 6 })
|
||||
@ApiResponse({ status: 200, description: 'Тренды расходов по месяцам' })
|
||||
@ApiOkResponse({
|
||||
description: 'Тренды расходов по месяцам',
|
||||
schema: {
|
||||
example: [
|
||||
{ period: '2024-01', amount: 85000, change: 0, changePercent: 0 },
|
||||
{ period: '2024-02', amount: 90000, change: 5000, changePercent: 5.88 },
|
||||
],
|
||||
},
|
||||
})
|
||||
async getSpendingTrends(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Query('months') months?: number,
|
||||
@ -51,7 +109,23 @@ export class AnalyticsController {
|
||||
@ApiQuery({ name: 'startDate', required: true, example: '2024-01-01' })
|
||||
@ApiQuery({ name: 'endDate', required: true, example: '2024-01-31' })
|
||||
@ApiQuery({ name: 'type', enum: TransactionType, required: false })
|
||||
@ApiResponse({ status: 200, description: 'Разбивка расходов по категориям' })
|
||||
@ApiOkResponse({
|
||||
description: 'Разбивка расходов по категориям',
|
||||
schema: {
|
||||
example: [
|
||||
{
|
||||
categoryId: 'uuid',
|
||||
categoryName: 'Продукты',
|
||||
categoryIcon: 'shopping-cart',
|
||||
categoryColor: '#4CAF50',
|
||||
amount: 22000,
|
||||
percentage: 25.88,
|
||||
transactionCount: 12,
|
||||
averageTransaction: 1833.33,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
async getCategoryBreakdown(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Query('startDate') startDate: string,
|
||||
@ -69,7 +143,15 @@ export class AnalyticsController {
|
||||
@Get('income-vs-expenses')
|
||||
@ApiOperation({ summary: 'Сравнение доходов и расходов' })
|
||||
@ApiQuery({ name: 'months', required: false, example: 12 })
|
||||
@ApiResponse({ status: 200, description: 'Сравнение по месяцам' })
|
||||
@ApiOkResponse({
|
||||
description: 'Сравнение по месяцам',
|
||||
schema: {
|
||||
example: [
|
||||
{ month: '2024-01', income: 120000, expenses: 85000, balance: 35000 },
|
||||
{ month: '2024-02', income: 120000, expenses: 90000, balance: 30000 },
|
||||
],
|
||||
},
|
||||
})
|
||||
async getIncomeVsExpenses(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Query('months') months?: number,
|
||||
@ -79,7 +161,23 @@ export class AnalyticsController {
|
||||
|
||||
@Get('health')
|
||||
@ApiOperation({ summary: 'Оценка финансового здоровья' })
|
||||
@ApiResponse({ status: 200, description: 'Оценка и рекомендации' })
|
||||
@ApiOkResponse({
|
||||
description: 'Оценка и рекомендации',
|
||||
schema: {
|
||||
example: {
|
||||
score: 78,
|
||||
grade: 'B',
|
||||
factors: [
|
||||
{
|
||||
name: 'Норма сбережений',
|
||||
score: 75,
|
||||
description: 'Вы сохраняете около 20% дохода.',
|
||||
recommendation: 'Попробуйте увеличить до 25% в следующем месяце.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
async getFinancialHealth(@CurrentUser() user: JwtPayload) {
|
||||
return this.analyticsService.getFinancialHealth(user.sub);
|
||||
}
|
||||
@ -87,11 +185,26 @@ export class AnalyticsController {
|
||||
@Get('yearly')
|
||||
@ApiOperation({ summary: 'Годовой отчет' })
|
||||
@ApiQuery({ name: 'year', required: false, example: 2024 })
|
||||
@ApiResponse({ status: 200, description: 'Годовая сводка' })
|
||||
@ApiOkResponse({
|
||||
description: 'Годовая сводка',
|
||||
schema: {
|
||||
example: {
|
||||
year: 2024,
|
||||
totalIncome: 1440000,
|
||||
totalExpenses: 1020000,
|
||||
netSavings: 420000,
|
||||
savingsRate: 29.17,
|
||||
topExpenseCategories: [{ categoryName: 'Продукты', amount: 240000 }],
|
||||
},
|
||||
},
|
||||
})
|
||||
async getYearlySummary(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Query('year') year?: number,
|
||||
) {
|
||||
return this.analyticsService.getYearlySummary(user.sub, year || new Date().getFullYear());
|
||||
return this.analyticsService.getYearlySummary(
|
||||
user.sub,
|
||||
year || new Date().getFullYear(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,10 +6,12 @@ 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 { AiModule } from '../ai/ai.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Transaction, Category, Budget, Goal]),
|
||||
AiModule,
|
||||
],
|
||||
controllers: [AnalyticsController],
|
||||
providers: [AnalyticsService],
|
||||
|
||||
@ -5,9 +5,17 @@ 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 {
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
subMonths,
|
||||
format,
|
||||
startOfYear,
|
||||
endOfYear,
|
||||
} from 'date-fns';
|
||||
import { formatRubles } from '../../common/utils/currency.utils';
|
||||
import { TransactionType } from '../../common/constants/categories';
|
||||
import { AiService } from '../ai/ai.service';
|
||||
|
||||
export interface MonthlyOverview {
|
||||
month: string;
|
||||
@ -63,12 +71,16 @@ export class AnalyticsService {
|
||||
private budgetRepository: Repository<Budget>,
|
||||
@InjectRepository(Goal)
|
||||
private goalRepository: Repository<Goal>,
|
||||
private aiService: AiService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get monthly overview for a specific month
|
||||
*/
|
||||
async getMonthlyOverview(userId: string, date: Date = new Date()): Promise<MonthlyOverview> {
|
||||
async getMonthlyOverview(
|
||||
userId: string,
|
||||
date: Date = new Date(),
|
||||
): Promise<MonthlyOverview> {
|
||||
const monthStart = startOfMonth(date);
|
||||
const monthEnd = endOfMonth(date);
|
||||
|
||||
@ -81,21 +93,22 @@ export class AnalyticsService {
|
||||
});
|
||||
|
||||
const totalIncome = transactions
|
||||
.filter(t => t.type === TransactionType.INCOME)
|
||||
.filter((t) => t.type === TransactionType.INCOME)
|
||||
.reduce((sum, t) => sum + Number(t.amount), 0);
|
||||
|
||||
const totalExpenses = transactions
|
||||
.filter(t => t.type === TransactionType.EXPENSE)
|
||||
.filter((t) => t.type === TransactionType.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 }> = {};
|
||||
const categorySpending: Record<string, { name: string; amount: number }> =
|
||||
{};
|
||||
transactions
|
||||
.filter(t => t.type === TransactionType.EXPENSE)
|
||||
.forEach(t => {
|
||||
.filter((t) => t.type === TransactionType.EXPENSE)
|
||||
.forEach((t) => {
|
||||
const catId = t.categoryId || 'other';
|
||||
const catName = t.category?.nameRu || 'Другое';
|
||||
if (!categorySpending[catId]) {
|
||||
@ -124,10 +137,31 @@ export class AnalyticsService {
|
||||
};
|
||||
}
|
||||
|
||||
async getMonthlyNarrative(userId: string, date: Date = new Date()) {
|
||||
const overview = await this.getMonthlyOverview(userId, date);
|
||||
|
||||
return this.aiService.generateAnalyticsNarrative({
|
||||
period: overview.month,
|
||||
totals: {
|
||||
income: overview.totalIncome,
|
||||
expenses: overview.totalExpenses,
|
||||
netSavings: overview.netSavings,
|
||||
savingsRate: overview.savingsRate,
|
||||
},
|
||||
topCategories: overview.topCategories.map((c) => ({
|
||||
name: c.categoryName,
|
||||
amount: c.amount,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get spending trends over multiple months
|
||||
*/
|
||||
async getSpendingTrends(userId: string, months: number = 6): Promise<SpendingTrend[]> {
|
||||
async getSpendingTrends(
|
||||
userId: string,
|
||||
months: number = 6,
|
||||
): Promise<SpendingTrend[]> {
|
||||
const trends: SpendingTrend[] = [];
|
||||
let previousAmount = 0;
|
||||
|
||||
@ -146,7 +180,8 @@ export class AnalyticsService {
|
||||
|
||||
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;
|
||||
const changePercent =
|
||||
previousAmount > 0 ? (change / previousAmount) * 100 : 0;
|
||||
|
||||
trends.push({
|
||||
period: format(date, 'yyyy-MM'),
|
||||
@ -179,17 +214,23 @@ export class AnalyticsService {
|
||||
relations: ['category'],
|
||||
});
|
||||
|
||||
const totalAmount = transactions.reduce((sum, t) => sum + Number(t.amount), 0);
|
||||
const totalAmount = transactions.reduce(
|
||||
(sum, t) => sum + Number(t.amount),
|
||||
0,
|
||||
);
|
||||
|
||||
const categoryData: Record<string, {
|
||||
const categoryData: Record<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
amount: number;
|
||||
count: number;
|
||||
}> = {};
|
||||
}
|
||||
> = {};
|
||||
|
||||
transactions.forEach(t => {
|
||||
transactions.forEach((t) => {
|
||||
const catId = t.categoryId || 'other';
|
||||
if (!categoryData[catId]) {
|
||||
categoryData[catId] = {
|
||||
@ -221,12 +262,17 @@ export class AnalyticsService {
|
||||
/**
|
||||
* Get income vs expenses comparison
|
||||
*/
|
||||
async getIncomeVsExpenses(userId: string, months: number = 12): Promise<Array<{
|
||||
async getIncomeVsExpenses(
|
||||
userId: string,
|
||||
months: number = 12,
|
||||
): Promise<
|
||||
Array<{
|
||||
period: string;
|
||||
income: number;
|
||||
expenses: number;
|
||||
savings: number;
|
||||
}>> {
|
||||
}>
|
||||
> {
|
||||
const result: Array<{
|
||||
period: string;
|
||||
income: number;
|
||||
@ -247,11 +293,11 @@ export class AnalyticsService {
|
||||
});
|
||||
|
||||
const income = transactions
|
||||
.filter(t => t.type === TransactionType.INCOME)
|
||||
.filter((t) => t.type === TransactionType.INCOME)
|
||||
.reduce((sum, t) => sum + Number(t.amount), 0);
|
||||
|
||||
const expenses = transactions
|
||||
.filter(t => t.type === TransactionType.EXPENSE)
|
||||
.filter((t) => t.type === TransactionType.EXPENSE)
|
||||
.reduce((sum, t) => sum + Number(t.amount), 0);
|
||||
|
||||
result.push({
|
||||
@ -282,21 +328,25 @@ export class AnalyticsService {
|
||||
});
|
||||
|
||||
const totalIncome = transactions
|
||||
.filter(t => t.type === 'INCOME')
|
||||
.filter((t) => t.type === 'INCOME')
|
||||
.reduce((sum, t) => sum + Number(t.amount), 0);
|
||||
|
||||
const totalExpenses = transactions
|
||||
.filter(t => t.type === 'EXPENSE')
|
||||
.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 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,
|
||||
recommendation:
|
||||
savingsRate < 20
|
||||
? 'Рекомендуем увеличить сбережения до 20% от дохода'
|
||||
: undefined,
|
||||
});
|
||||
totalScore += savingsScore * 0.3;
|
||||
|
||||
@ -307,14 +357,20 @@ export class AnalyticsService {
|
||||
|
||||
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;
|
||||
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,
|
||||
description: currentBudget
|
||||
? `Использовано ${((currentBudget.totalSpent / Number(currentBudget.totalIncome)) * 100).toFixed(0)}% бюджета`
|
||||
: 'Бюджет не установлен',
|
||||
recommendation: !currentBudget
|
||||
? 'Создайте бюджет для лучшего контроля расходов'
|
||||
: undefined,
|
||||
});
|
||||
totalScore += budgetScore * 0.25;
|
||||
|
||||
@ -325,14 +381,21 @@ export class AnalyticsService {
|
||||
|
||||
let goalScore = 50;
|
||||
if (goals.length > 0) {
|
||||
const avgProgress = goals.reduce((sum, g) => sum + g.progressPercent, 0) / goals.length;
|
||||
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,
|
||||
description:
|
||||
goals.length > 0
|
||||
? `${goals.length} активных целей`
|
||||
: 'Нет активных целей',
|
||||
recommendation:
|
||||
goals.length === 0
|
||||
? 'Создайте финансовые цели для мотивации'
|
||||
: undefined,
|
||||
});
|
||||
totalScore += goalScore * 0.2;
|
||||
|
||||
@ -343,24 +406,37 @@ export class AnalyticsService {
|
||||
const monthStart = startOfMonth(date);
|
||||
const monthEnd = endOfMonth(date);
|
||||
|
||||
const monthTransactions = transactions.filter(t => {
|
||||
const monthTransactions = transactions.filter((t) => {
|
||||
const tDate = new Date(t.transactionDate);
|
||||
return t.type === TransactionType.EXPENSE && tDate >= monthStart && tDate <= monthEnd;
|
||||
return (
|
||||
t.type === TransactionType.EXPENSE &&
|
||||
tDate >= monthStart &&
|
||||
tDate <= monthEnd
|
||||
);
|
||||
});
|
||||
|
||||
monthlyExpenses.push(monthTransactions.reduce((sum, t) => sum + Number(t.amount), 0));
|
||||
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 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;
|
||||
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,
|
||||
recommendation:
|
||||
consistencyScore < 70
|
||||
? 'Старайтесь поддерживать стабильный уровень расходов'
|
||||
: undefined,
|
||||
});
|
||||
totalScore += consistencyScore * 0.25;
|
||||
|
||||
@ -382,7 +458,10 @@ export class AnalyticsService {
|
||||
/**
|
||||
* Get yearly summary
|
||||
*/
|
||||
async getYearlySummary(userId: string, year: number = new Date().getFullYear()): Promise<{
|
||||
async getYearlySummary(
|
||||
userId: string,
|
||||
year: number = new Date().getFullYear(),
|
||||
): Promise<{
|
||||
year: number;
|
||||
totalIncome: number;
|
||||
totalExpenses: number;
|
||||
@ -391,7 +470,11 @@ export class AnalyticsService {
|
||||
averageMonthlyExpenses: number;
|
||||
bestMonth: { month: string; savings: number };
|
||||
worstMonth: { month: string; savings: number };
|
||||
topExpenseCategories: Array<{ name: string; amount: number; percentage: 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));
|
||||
@ -405,16 +488,17 @@ export class AnalyticsService {
|
||||
});
|
||||
|
||||
const totalIncome = transactions
|
||||
.filter(t => t.type === TransactionType.INCOME)
|
||||
.filter((t) => t.type === TransactionType.INCOME)
|
||||
.reduce((sum, t) => sum + Number(t.amount), 0);
|
||||
|
||||
const totalExpenses = transactions
|
||||
.filter(t => t.type === TransactionType.EXPENSE)
|
||||
.filter((t) => t.type === TransactionType.EXPENSE)
|
||||
.reduce((sum, t) => sum + Number(t.amount), 0);
|
||||
|
||||
// Calculate monthly data
|
||||
const monthlyData: Record<string, { income: number; expenses: number }> = {};
|
||||
transactions.forEach(t => {
|
||||
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 };
|
||||
@ -444,10 +528,11 @@ export class AnalyticsService {
|
||||
});
|
||||
|
||||
// Top expense categories
|
||||
const categoryExpenses: Record<string, { name: string; amount: number }> = {};
|
||||
const categoryExpenses: Record<string, { name: string; amount: number }> =
|
||||
{};
|
||||
transactions
|
||||
.filter(t => t.type === TransactionType.EXPENSE)
|
||||
.forEach(t => {
|
||||
.filter((t) => t.type === TransactionType.EXPENSE)
|
||||
.forEach((t) => {
|
||||
const catName = t.category?.nameRu || 'Другое';
|
||||
if (!categoryExpenses[catName]) {
|
||||
categoryExpenses[catName] = { name: catName, amount: 0 };
|
||||
@ -456,7 +541,7 @@ export class AnalyticsService {
|
||||
});
|
||||
|
||||
const topExpenseCategories = Object.values(categoryExpenses)
|
||||
.map(cat => ({
|
||||
.map((cat) => ({
|
||||
name: cat.name,
|
||||
amount: cat.amount,
|
||||
percentage: totalExpenses > 0 ? (cat.amount / totalExpenses) * 100 : 0,
|
||||
|
||||
@ -9,20 +9,33 @@ import {
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiOkResponse,
|
||||
ApiCreatedResponse,
|
||||
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 {
|
||||
RegisterDto,
|
||||
LoginDto,
|
||||
UpdateProfileDto,
|
||||
ChangePasswordDto,
|
||||
AuthResponseDto,
|
||||
LogoutResponseDto,
|
||||
} from './dto';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { CurrentUser, JwtPayload } from '../../common/decorators/user.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
JwtPayload,
|
||||
} from '../../common/decorators/user.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@ -39,10 +52,21 @@ export class AuthController {
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Регистрация нового пользователя' })
|
||||
@ApiBody({ type: RegisterDto })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
@ApiCreatedResponse({
|
||||
description: 'Пользователь успешно зарегистрирован',
|
||||
type: AuthResponseDto,
|
||||
schema: {
|
||||
example: {
|
||||
userId: 'uuid',
|
||||
email: 'user@example.com',
|
||||
firstName: 'Omar',
|
||||
lastName: 'Zaid',
|
||||
tokens: {
|
||||
accessToken: 'jwt-access-token',
|
||||
expiresIn: 900,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: 'Некорректные данные' })
|
||||
@ApiResponse({ status: 409, description: 'Пользователь уже существует' })
|
||||
@ -81,10 +105,21 @@ export class AuthController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Вход в систему' })
|
||||
@ApiBody({ type: LoginDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@ApiOkResponse({
|
||||
description: 'Успешный вход',
|
||||
type: AuthResponseDto,
|
||||
schema: {
|
||||
example: {
|
||||
userId: 'uuid',
|
||||
email: 'user@example.com',
|
||||
firstName: 'Omar',
|
||||
lastName: 'Zaid',
|
||||
tokens: {
|
||||
accessToken: 'jwt-access-token',
|
||||
expiresIn: 900,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Неверный email или пароль' })
|
||||
async login(
|
||||
@ -118,9 +153,14 @@ export class AuthController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Обновление токена доступа' })
|
||||
@ApiCookieAuth()
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@ApiOkResponse({
|
||||
description: 'Токен успешно обновлен',
|
||||
schema: {
|
||||
example: {
|
||||
accessToken: 'jwt-access-token',
|
||||
expiresIn: 900,
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Недействительный refresh токен' })
|
||||
async refresh(
|
||||
@ -130,21 +170,13 @@ export class AuthController {
|
||||
const refreshToken = req.cookies?.refresh_token;
|
||||
|
||||
if (!refreshToken) {
|
||||
res.status(HttpStatus.UNAUTHORIZED).json({
|
||||
statusCode: 401,
|
||||
message: 'Refresh токен не найден',
|
||||
});
|
||||
return;
|
||||
throw new UnauthorizedException('Refresh токен не найден');
|
||||
}
|
||||
|
||||
// Decode token to get user ID
|
||||
const decoded = this.decodeToken(refreshToken);
|
||||
if (!decoded) {
|
||||
res.status(HttpStatus.UNAUTHORIZED).json({
|
||||
statusCode: 401,
|
||||
message: 'Недействительный токен',
|
||||
});
|
||||
return;
|
||||
throw new UnauthorizedException('Недействительный токен');
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
@ -171,10 +203,14 @@ export class AuthController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Выход из системы' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@ApiOkResponse({
|
||||
description: 'Успешный выход',
|
||||
type: LogoutResponseDto,
|
||||
schema: {
|
||||
example: {
|
||||
message: 'Выход выполнен успешно',
|
||||
},
|
||||
},
|
||||
})
|
||||
async logout(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@ -199,10 +235,14 @@ export class AuthController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Выход со всех устройств' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@ApiOkResponse({
|
||||
description: 'Успешный выход со всех устройств',
|
||||
type: LogoutResponseDto,
|
||||
schema: {
|
||||
example: {
|
||||
message: 'Выход выполнен успешно',
|
||||
},
|
||||
},
|
||||
})
|
||||
async logoutAll(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@ -266,7 +306,11 @@ export class AuthController {
|
||||
userAgent: req.get('user-agent'),
|
||||
};
|
||||
|
||||
const profile = await this.authService.updateProfile(user.sub, dto, metadata);
|
||||
const profile = await this.authService.updateProfile(
|
||||
user.sub,
|
||||
dto,
|
||||
metadata,
|
||||
);
|
||||
return {
|
||||
id: profile.id,
|
||||
email: profile.email,
|
||||
@ -312,7 +356,11 @@ export class AuthController {
|
||||
/**
|
||||
* Set HTTP-only cookies for tokens
|
||||
*/
|
||||
private setTokenCookies(res: Response, accessToken: string, refreshToken: string): void {
|
||||
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');
|
||||
|
||||
@ -331,7 +379,7 @@ export class AuthController {
|
||||
res.cookie('refresh_token', refreshToken, {
|
||||
...commonOptions,
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
path: '/auth', // Only send to auth endpoints
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
@ -342,7 +390,7 @@ export class AuthController {
|
||||
const cookieDomain = this.configService.get<string>('jwt.cookieDomain');
|
||||
|
||||
res.clearCookie('access_token', { domain: cookieDomain });
|
||||
res.clearCookie('refresh_token', { domain: cookieDomain, path: '/auth' });
|
||||
res.clearCookie('refresh_token', { domain: cookieDomain, path: '/' });
|
||||
}
|
||||
|
||||
/**
|
||||
@ -351,7 +399,9 @@ export class AuthController {
|
||||
private decodeToken(token: string): JwtPayload | null {
|
||||
try {
|
||||
return this.configService.get('jwt.refreshSecret')
|
||||
? (JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()) as JwtPayload)
|
||||
? (JSON.parse(
|
||||
Buffer.from(token.split('.')[1], 'base64').toString(),
|
||||
) as JwtPayload)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@ -14,13 +14,18 @@ import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiOkResponse,
|
||||
ApiCreatedResponse,
|
||||
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';
|
||||
import {
|
||||
CurrentUser,
|
||||
JwtPayload,
|
||||
} from '../../common/decorators/user.decorator';
|
||||
|
||||
@ApiTags('Бюджеты (50/30/20)')
|
||||
@ApiBearerAuth()
|
||||
@ -31,22 +36,73 @@ export class BudgetsController {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Получение всех бюджетов пользователя' })
|
||||
@ApiResponse({ status: 200, description: 'Список бюджетов' })
|
||||
@ApiOkResponse({
|
||||
description: 'Список бюджетов',
|
||||
schema: {
|
||||
example: [
|
||||
{
|
||||
id: 'uuid',
|
||||
month: '2024-01-01',
|
||||
totalIncome: 120000,
|
||||
essentialsLimit: 60000,
|
||||
personalLimit: 36000,
|
||||
savingsLimit: 24000,
|
||||
essentialsSpent: 42000,
|
||||
personalSpent: 28000,
|
||||
savingsSpent: 15000,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
async findAll(@CurrentUser() user: JwtPayload) {
|
||||
return this.budgetsService.findAll(user.sub);
|
||||
}
|
||||
|
||||
@Get('current')
|
||||
@ApiOperation({ summary: 'Получение бюджета текущего месяца' })
|
||||
@ApiResponse({ status: 200, description: 'Бюджет текущего месяца' })
|
||||
@ApiOkResponse({
|
||||
description: 'Бюджет текущего месяца',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'uuid',
|
||||
month: '2024-01-01',
|
||||
totalIncome: 120000,
|
||||
essentialsLimit: 60000,
|
||||
personalLimit: 36000,
|
||||
savingsLimit: 24000,
|
||||
essentialsSpent: 42000,
|
||||
personalSpent: 28000,
|
||||
savingsSpent: 15000,
|
||||
},
|
||||
},
|
||||
})
|
||||
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: 'Бюджет' })
|
||||
@ApiQuery({
|
||||
name: 'month',
|
||||
example: '2024-01-01',
|
||||
description: 'Первый день месяца',
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: 'Бюджет',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'uuid',
|
||||
month: '2024-01-01',
|
||||
totalIncome: 120000,
|
||||
essentialsLimit: 60000,
|
||||
personalLimit: 36000,
|
||||
savingsLimit: 24000,
|
||||
essentialsSpent: 42000,
|
||||
personalSpent: 28000,
|
||||
savingsSpent: 15000,
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Бюджет не найден' })
|
||||
async findByMonth(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@ -58,7 +114,17 @@ export class BudgetsController {
|
||||
@Get('progress')
|
||||
@ApiOperation({ summary: 'Получение прогресса по бюджету' })
|
||||
@ApiQuery({ name: 'month', example: '2024-01-01' })
|
||||
@ApiResponse({ status: 200, description: 'Прогресс по категориям 50/30/20' })
|
||||
@ApiOkResponse({
|
||||
description: 'Прогресс по категориям 50/30/20',
|
||||
schema: {
|
||||
example: {
|
||||
month: '2024-01-01',
|
||||
essentials: { limit: 60000, spent: 42000, percent: 70 },
|
||||
personal: { limit: 36000, spent: 28000, percent: 77.78 },
|
||||
savings: { limit: 24000, spent: 15000, percent: 62.5 },
|
||||
},
|
||||
},
|
||||
})
|
||||
async getProgress(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Query('month') month: string,
|
||||
@ -69,19 +135,43 @@ export class BudgetsController {
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Создание бюджета на месяц' })
|
||||
@ApiResponse({ status: 201, description: 'Бюджет создан' })
|
||||
@ApiResponse({ status: 409, description: 'Бюджет на этот месяц уже существует' })
|
||||
async create(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: CreateBudgetDto,
|
||||
) {
|
||||
@ApiCreatedResponse({
|
||||
description: 'Бюджет создан',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'uuid',
|
||||
month: '2024-01-01',
|
||||
totalIncome: 120000,
|
||||
essentialsLimit: 60000,
|
||||
personalLimit: 36000,
|
||||
savingsLimit: 24000,
|
||||
},
|
||||
},
|
||||
})
|
||||
@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: 'Бюджет обновлен' })
|
||||
@ApiOkResponse({
|
||||
description: 'Бюджет обновлен',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'uuid',
|
||||
month: '2024-01-01',
|
||||
totalIncome: 120000,
|
||||
essentialsLimit: 65000,
|
||||
personalLimit: 35000,
|
||||
savingsLimit: 20000,
|
||||
},
|
||||
},
|
||||
})
|
||||
async update(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Query('month') month: string,
|
||||
@ -94,11 +184,14 @@ export class BudgetsController {
|
||||
@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,
|
||||
) {
|
||||
@ApiResponse({
|
||||
status: 204,
|
||||
description: 'Бюджет удален',
|
||||
schema: {
|
||||
example: null,
|
||||
},
|
||||
})
|
||||
async remove(@CurrentUser() user: JwtPayload, @Query('month') month: string) {
|
||||
await this.budgetsService.remove(user.sub, month);
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,8 @@ import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiOkResponse,
|
||||
ApiCreatedResponse,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
ApiParam,
|
||||
@ -22,7 +24,10 @@ import {
|
||||
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';
|
||||
import {
|
||||
CurrentUser,
|
||||
JwtPayload,
|
||||
} from '../../common/decorators/user.decorator';
|
||||
|
||||
@ApiTags('Категории')
|
||||
@ApiBearerAuth()
|
||||
@ -39,9 +44,22 @@ export class CategoriesController {
|
||||
enum: ['INCOME', 'EXPENSE'],
|
||||
description: 'Фильтр по типу категории',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@ApiOkResponse({
|
||||
description: 'Список категорий',
|
||||
schema: {
|
||||
example: [
|
||||
{
|
||||
id: 'uuid',
|
||||
nameRu: 'Продукты',
|
||||
nameEn: 'Groceries',
|
||||
type: 'EXPENSE',
|
||||
icon: 'shopping-cart',
|
||||
color: '#4CAF50',
|
||||
groupType: 'ESSENTIAL',
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
async findAll(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@ -51,10 +69,39 @@ export class CategoriesController {
|
||||
}
|
||||
|
||||
@Get('grouped')
|
||||
@ApiOperation({ summary: 'Получение категорий, сгруппированных по типу бюджета' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@ApiOperation({
|
||||
summary: 'Получение категорий, сгруппированных по типу бюджета',
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: 'Категории, сгруппированные по ESSENTIAL/PERSONAL/SAVINGS',
|
||||
schema: {
|
||||
example: {
|
||||
ESSENTIAL: [
|
||||
{
|
||||
id: 'uuid',
|
||||
nameRu: 'Продукты',
|
||||
type: 'EXPENSE',
|
||||
groupType: 'ESSENTIAL',
|
||||
},
|
||||
],
|
||||
PERSONAL: [
|
||||
{
|
||||
id: 'uuid',
|
||||
nameRu: 'Развлечения',
|
||||
type: 'EXPENSE',
|
||||
groupType: 'PERSONAL',
|
||||
},
|
||||
],
|
||||
SAVINGS: [
|
||||
{
|
||||
id: 'uuid',
|
||||
nameRu: 'Накопления',
|
||||
type: 'EXPENSE',
|
||||
groupType: 'SAVINGS',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
async findGrouped(@CurrentUser() user: JwtPayload) {
|
||||
return this.categoriesService.findByBudgetGroup(user.sub);
|
||||
@ -63,24 +110,43 @@ export class CategoriesController {
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Получение категории по ID' })
|
||||
@ApiParam({ name: 'id', description: 'ID категории' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@ApiOkResponse({
|
||||
description: 'Категория',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'uuid',
|
||||
nameRu: 'Продукты',
|
||||
nameEn: 'Groceries',
|
||||
type: 'EXPENSE',
|
||||
icon: 'shopping-cart',
|
||||
color: '#4CAF50',
|
||||
groupType: 'ESSENTIAL',
|
||||
isDefault: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Категория не найдена' })
|
||||
async findOne(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
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,
|
||||
@ApiCreatedResponse({
|
||||
description: 'Категория создана',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'uuid',
|
||||
nameRu: 'Кофе',
|
||||
nameEn: 'Coffee',
|
||||
type: 'EXPENSE',
|
||||
icon: 'coffee',
|
||||
color: '#795548',
|
||||
groupType: 'PERSONAL',
|
||||
isDefault: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: 'Некорректные данные' })
|
||||
@ApiResponse({ status: 409, description: 'Категория уже существует' })
|
||||
@ -94,11 +160,23 @@ export class CategoriesController {
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Обновление пользовательской категории' })
|
||||
@ApiParam({ name: 'id', description: 'ID категории' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@ApiOkResponse({
|
||||
description: 'Категория обновлена',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'uuid',
|
||||
nameRu: 'Кофе (обновлено)',
|
||||
type: 'EXPENSE',
|
||||
icon: 'coffee',
|
||||
color: '#795548',
|
||||
groupType: 'PERSONAL',
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 403,
|
||||
description: 'Нельзя изменить стандартную категорию',
|
||||
})
|
||||
@ApiResponse({ status: 403, description: 'Нельзя изменить стандартную категорию' })
|
||||
@ApiResponse({ status: 404, description: 'Категория не найдена' })
|
||||
async update(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@ -116,12 +194,12 @@ export class CategoriesController {
|
||||
status: 204,
|
||||
description: 'Категория удалена',
|
||||
})
|
||||
@ApiResponse({ status: 403, description: 'Нельзя удалить стандартную категорию' })
|
||||
@ApiResponse({
|
||||
status: 403,
|
||||
description: 'Нельзя удалить стандартную категорию',
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Категория не найдена' })
|
||||
async remove(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
async remove(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
|
||||
await this.categoriesService.remove(id, user.sub);
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,8 @@ import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiOkResponse,
|
||||
ApiCreatedResponse,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
ApiParam,
|
||||
@ -24,7 +26,10 @@ 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';
|
||||
import {
|
||||
CurrentUser,
|
||||
JwtPayload,
|
||||
} from '../../common/decorators/user.decorator';
|
||||
|
||||
@ApiTags('Финансовые цели')
|
||||
@ApiBearerAuth()
|
||||
@ -36,7 +41,25 @@ export class GoalsController {
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Получение всех целей пользователя' })
|
||||
@ApiQuery({ name: 'status', enum: GoalStatus, required: false })
|
||||
@ApiResponse({ status: 200, description: 'Список целей' })
|
||||
@ApiOkResponse({
|
||||
description: 'Список целей',
|
||||
schema: {
|
||||
example: [
|
||||
{
|
||||
id: 'uuid',
|
||||
userId: 'uuid',
|
||||
name: 'Подушка безопасности',
|
||||
targetAmount: 300000,
|
||||
currentAmount: 120000,
|
||||
status: 'ACTIVE',
|
||||
targetDate: '2024-12-31T00:00:00.000Z',
|
||||
autoSaveEnabled: true,
|
||||
autoSaveAmount: 10000,
|
||||
autoSaveFrequency: 'MONTHLY',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
async findAll(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Query('status') status?: GoalStatus,
|
||||
@ -46,7 +69,19 @@ export class GoalsController {
|
||||
|
||||
@Get('summary')
|
||||
@ApiOperation({ summary: 'Получение сводки по целям' })
|
||||
@ApiResponse({ status: 200, description: 'Сводка по целям' })
|
||||
@ApiOkResponse({
|
||||
description: 'Сводка по целям',
|
||||
schema: {
|
||||
example: {
|
||||
totalGoals: 3,
|
||||
activeGoals: 2,
|
||||
completedGoals: 1,
|
||||
totalTargetAmount: 600000,
|
||||
totalCurrentAmount: 240000,
|
||||
overallProgress: 40,
|
||||
},
|
||||
},
|
||||
})
|
||||
async getSummary(@CurrentUser() user: JwtPayload) {
|
||||
return this.goalsService.getSummary(user.sub);
|
||||
}
|
||||
@ -54,7 +89,21 @@ export class GoalsController {
|
||||
@Get('upcoming')
|
||||
@ApiOperation({ summary: 'Получение целей с приближающимся дедлайном' })
|
||||
@ApiQuery({ name: 'days', required: false, example: 30 })
|
||||
@ApiResponse({ status: 200, description: 'Список целей с дедлайном' })
|
||||
@ApiOkResponse({
|
||||
description: 'Список целей с дедлайном',
|
||||
schema: {
|
||||
example: [
|
||||
{
|
||||
id: 'uuid',
|
||||
name: 'Отпуск',
|
||||
targetAmount: 150000,
|
||||
currentAmount: 50000,
|
||||
targetDate: '2024-03-01T00:00:00.000Z',
|
||||
daysLeft: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
async getUpcoming(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Query('days') days?: number,
|
||||
@ -65,7 +114,23 @@ export class GoalsController {
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Получение цели по ID' })
|
||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||
@ApiResponse({ status: 200, description: 'Цель' })
|
||||
@ApiOkResponse({
|
||||
description: 'Цель',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'uuid',
|
||||
userId: 'uuid',
|
||||
name: 'Подушка безопасности',
|
||||
targetAmount: 300000,
|
||||
currentAmount: 120000,
|
||||
status: 'ACTIVE',
|
||||
targetDate: '2024-12-31T00:00:00.000Z',
|
||||
autoSaveEnabled: true,
|
||||
autoSaveAmount: 10000,
|
||||
autoSaveFrequency: 'MONTHLY',
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Цель не найдена' })
|
||||
async findOne(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@ -77,18 +142,42 @@ export class GoalsController {
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Создание новой цели' })
|
||||
@ApiResponse({ status: 201, description: 'Цель создана' })
|
||||
async create(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: CreateGoalDto,
|
||||
) {
|
||||
@ApiCreatedResponse({
|
||||
description: 'Цель создана',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'uuid',
|
||||
userId: 'uuid',
|
||||
name: 'Подушка безопасности',
|
||||
targetAmount: 300000,
|
||||
currentAmount: 0,
|
||||
status: 'ACTIVE',
|
||||
targetDate: '2024-12-31T00:00:00.000Z',
|
||||
autoSaveEnabled: true,
|
||||
autoSaveAmount: 10000,
|
||||
autoSaveFrequency: 'MONTHLY',
|
||||
},
|
||||
},
|
||||
})
|
||||
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: 'Цель обновлена' })
|
||||
@ApiOkResponse({
|
||||
description: 'Цель обновлена',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'uuid',
|
||||
name: 'Подушка безопасности (обновлено)',
|
||||
targetAmount: 350000,
|
||||
currentAmount: 120000,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
},
|
||||
})
|
||||
async update(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@ -100,7 +189,16 @@ export class GoalsController {
|
||||
@Post(':id/add-funds')
|
||||
@ApiOperation({ summary: 'Добавление средств к цели' })
|
||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||
@ApiResponse({ status: 200, description: 'Средства добавлены' })
|
||||
@ApiOkResponse({
|
||||
description: 'Средства добавлены',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'uuid',
|
||||
currentAmount: 130000,
|
||||
message: 'Средства добавлены',
|
||||
},
|
||||
},
|
||||
})
|
||||
async addFunds(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@ -112,7 +210,16 @@ export class GoalsController {
|
||||
@Post(':id/withdraw')
|
||||
@ApiOperation({ summary: 'Снятие средств с цели' })
|
||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||
@ApiResponse({ status: 200, description: 'Средства сняты' })
|
||||
@ApiOkResponse({
|
||||
description: 'Средства сняты',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'uuid',
|
||||
currentAmount: 110000,
|
||||
message: 'Средства сняты',
|
||||
},
|
||||
},
|
||||
})
|
||||
async withdrawFunds(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
|
||||
@ -26,6 +26,11 @@ export enum RecommendationStatus {
|
||||
DISMISSED = 'DISMISSED',
|
||||
}
|
||||
|
||||
export enum RecommendationSource {
|
||||
OPENROUTER = 'openrouter',
|
||||
MOCK = 'mock',
|
||||
}
|
||||
|
||||
@Entity('recommendations')
|
||||
export class Recommendation {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
@ -47,16 +52,45 @@ export class Recommendation {
|
||||
@Column({ name: 'action_text_ru', length: 200, nullable: true })
|
||||
actionTextRu: string;
|
||||
|
||||
@Column({ name: 'priority_score', type: 'decimal', precision: 3, scale: 2, default: 0.5 })
|
||||
@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 })
|
||||
@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 })
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: RecommendationSource,
|
||||
default: RecommendationSource.MOCK,
|
||||
})
|
||||
source: RecommendationSource;
|
||||
|
||||
@Column({
|
||||
name: 'potential_savings',
|
||||
type: 'decimal',
|
||||
precision: 15,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
potentialSavings: number;
|
||||
|
||||
@Column({ type: 'enum', enum: RecommendationStatus, default: RecommendationStatus.NEW })
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: RecommendationStatus,
|
||||
default: RecommendationStatus.NEW,
|
||||
})
|
||||
status: RecommendationStatus;
|
||||
|
||||
@Column({ name: 'action_data', type: 'jsonb', nullable: true })
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiOkResponse,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
ApiParam,
|
||||
@ -18,19 +19,46 @@ import {
|
||||
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';
|
||||
import {
|
||||
CurrentUser,
|
||||
JwtPayload,
|
||||
} from '../../common/decorators/user.decorator';
|
||||
|
||||
@ApiTags('Рекомендации')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('recommendations')
|
||||
export class RecommendationsController {
|
||||
constructor(private readonly recommendationsService: RecommendationsService) {}
|
||||
constructor(
|
||||
private readonly recommendationsService: RecommendationsService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Получение активных рекомендаций' })
|
||||
@ApiQuery({ name: 'type', enum: RecommendationType, required: false })
|
||||
@ApiResponse({ status: 200, description: 'Список рекомендаций' })
|
||||
@ApiOkResponse({
|
||||
description: 'Список рекомендаций',
|
||||
schema: {
|
||||
example: [
|
||||
{
|
||||
id: 'uuid',
|
||||
userId: 'uuid',
|
||||
type: 'SAVING',
|
||||
titleRu: 'Увеличьте накопления',
|
||||
descriptionRu: 'Рекомендуем увеличить норму сбережений до 20%.',
|
||||
priorityScore: 0.9,
|
||||
confidenceScore: 0.95,
|
||||
source: 'openrouter',
|
||||
status: 'NEW',
|
||||
actionData: { targetSavingsRate: 20 },
|
||||
expiresAt: '2024-02-15T00:00:00.000Z',
|
||||
createdAt: '2024-01-15T00:00:00.000Z',
|
||||
viewedAt: null,
|
||||
appliedAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
async findAll(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Query('type') type?: RecommendationType,
|
||||
@ -40,14 +68,45 @@ export class RecommendationsController {
|
||||
|
||||
@Get('stats')
|
||||
@ApiOperation({ summary: 'Статистика по рекомендациям' })
|
||||
@ApiResponse({ status: 200, description: 'Статистика' })
|
||||
@ApiOkResponse({
|
||||
description: 'Статистика',
|
||||
schema: {
|
||||
example: {
|
||||
total: 10,
|
||||
new: 4,
|
||||
viewed: 3,
|
||||
applied: 2,
|
||||
dismissed: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
async getStats(@CurrentUser() user: JwtPayload) {
|
||||
return this.recommendationsService.getStats(user.sub);
|
||||
}
|
||||
|
||||
@Post('generate')
|
||||
@ApiOperation({ summary: 'Генерация новых рекомендаций' })
|
||||
@ApiResponse({ status: 200, description: 'Сгенерированные рекомендации' })
|
||||
@ApiOkResponse({
|
||||
description: 'Сгенерированные рекомендации',
|
||||
schema: {
|
||||
example: [
|
||||
{
|
||||
id: 'uuid',
|
||||
userId: 'uuid',
|
||||
type: 'BUDGET',
|
||||
titleRu: 'Превышение лимита на необходимое',
|
||||
descriptionRu: 'Вы израсходовали 95% бюджета на необходимые расходы.',
|
||||
priorityScore: 0.9,
|
||||
confidenceScore: 0.95,
|
||||
source: 'mock',
|
||||
status: 'NEW',
|
||||
actionData: null,
|
||||
expiresAt: '2024-02-15T00:00:00.000Z',
|
||||
createdAt: '2024-01-15T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
async generate(@CurrentUser() user: JwtPayload) {
|
||||
return this.recommendationsService.generateRecommendations(user.sub);
|
||||
}
|
||||
@ -55,7 +114,24 @@ export class RecommendationsController {
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Получение рекомендации по ID' })
|
||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||
@ApiResponse({ status: 200, description: 'Рекомендация' })
|
||||
@ApiOkResponse({
|
||||
description: 'Рекомендация',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'uuid',
|
||||
userId: 'uuid',
|
||||
type: 'SAVING',
|
||||
titleRu: 'Резервный фонд',
|
||||
descriptionRu: 'Создайте резервный фонд 3–6 месячных расходов.',
|
||||
priorityScore: 0.85,
|
||||
confidenceScore: 0.9,
|
||||
source: 'mock',
|
||||
status: 'NEW',
|
||||
actionData: { targetAmount: 480000 },
|
||||
createdAt: '2024-01-15T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
})
|
||||
async findOne(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@ -66,7 +142,16 @@ export class RecommendationsController {
|
||||
@Post(':id/view')
|
||||
@ApiOperation({ summary: 'Отметить рекомендацию как просмотренную' })
|
||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||
@ApiResponse({ status: 200, description: 'Рекомендация обновлена' })
|
||||
@ApiOkResponse({
|
||||
description: 'Рекомендация обновлена',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'uuid',
|
||||
status: 'VIEWED',
|
||||
viewedAt: '2024-01-15T10:00:00.000Z',
|
||||
},
|
||||
},
|
||||
})
|
||||
async markAsViewed(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@ -77,7 +162,16 @@ export class RecommendationsController {
|
||||
@Post(':id/apply')
|
||||
@ApiOperation({ summary: 'Отметить рекомендацию как примененную' })
|
||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||
@ApiResponse({ status: 200, description: 'Рекомендация применена' })
|
||||
@ApiOkResponse({
|
||||
description: 'Рекомендация применена',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'uuid',
|
||||
status: 'APPLIED',
|
||||
appliedAt: '2024-01-15T10:00:00.000Z',
|
||||
},
|
||||
},
|
||||
})
|
||||
async markAsApplied(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@ -88,7 +182,15 @@ export class RecommendationsController {
|
||||
@Post(':id/dismiss')
|
||||
@ApiOperation({ summary: 'Отклонить рекомендацию' })
|
||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||
@ApiResponse({ status: 200, description: 'Рекомендация отклонена' })
|
||||
@ApiOkResponse({
|
||||
description: 'Рекомендация отклонена',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'uuid',
|
||||
status: 'DISMISSED',
|
||||
},
|
||||
},
|
||||
})
|
||||
async dismiss(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
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 {
|
||||
Recommendation,
|
||||
RecommendationType,
|
||||
RecommendationStatus,
|
||||
RecommendationSource,
|
||||
} from './entities/recommendation.entity';
|
||||
import { AiService } from '../ai/ai.service';
|
||||
import { TransactionsService } from '../transactions/transactions.service';
|
||||
import { BudgetsService } from '../budgets/budgets.service';
|
||||
@ -21,7 +26,10 @@ export class RecommendationsService {
|
||||
/**
|
||||
* Get all active recommendations for a user
|
||||
*/
|
||||
async findAll(userId: string, type?: RecommendationType): Promise<Recommendation[]> {
|
||||
async findAll(
|
||||
userId: string,
|
||||
type?: RecommendationType,
|
||||
): Promise<Recommendation[]> {
|
||||
const where: any = {
|
||||
userId,
|
||||
status: RecommendationStatus.NEW,
|
||||
@ -101,22 +109,24 @@ export class RecommendationsService {
|
||||
|
||||
// Calculate context for AI
|
||||
const totalExpenses = transactions.data
|
||||
.filter(t => t.type === 'EXPENSE')
|
||||
.filter((t) => t.type === 'EXPENSE')
|
||||
.reduce((sum, t) => sum + Number(t.amount), 0);
|
||||
|
||||
const totalIncome = transactions.data
|
||||
.filter(t => t.type === 'INCOME')
|
||||
.filter((t) => t.type === 'INCOME')
|
||||
.reduce((sum, t) => sum + Number(t.amount), 0);
|
||||
|
||||
const savingsRate = totalIncome > 0 ? ((totalIncome - totalExpenses) / totalIncome) * 100 : 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 => {
|
||||
.filter((t) => t.type === 'EXPENSE')
|
||||
.forEach((t) => {
|
||||
const catName = t.category?.nameRu || 'Другое';
|
||||
categorySpending[catName] = (categorySpending[catName] || 0) + Number(t.amount);
|
||||
categorySpending[catName] =
|
||||
(categorySpending[catName] || 0) + Number(t.amount);
|
||||
});
|
||||
|
||||
const topCategories = Object.entries(categorySpending)
|
||||
@ -125,12 +135,15 @@ export class RecommendationsService {
|
||||
.map(([name]) => name);
|
||||
|
||||
// Generate AI recommendations
|
||||
const aiRecommendations = await this.aiService.generateRecommendations(userId, {
|
||||
const aiRecommendations = await this.aiService.generateRecommendations(
|
||||
userId,
|
||||
{
|
||||
monthlyIncome: totalIncome,
|
||||
totalExpenses,
|
||||
savingsRate,
|
||||
topCategories,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Save recommendations to database
|
||||
const recommendations: Recommendation[] = [];
|
||||
@ -153,17 +166,26 @@ export class RecommendationsService {
|
||||
descriptionRu: rec.descriptionRu,
|
||||
priorityScore: rec.priorityScore,
|
||||
confidenceScore: rec.confidenceScore,
|
||||
source:
|
||||
rec.source === 'openrouter'
|
||||
? RecommendationSource.OPENROUTER
|
||||
: RecommendationSource.MOCK,
|
||||
actionData: rec.actionData,
|
||||
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
|
||||
});
|
||||
|
||||
recommendations.push(await this.recommendationRepository.save(recommendation));
|
||||
recommendations.push(
|
||||
await this.recommendationRepository.save(recommendation),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add budget-based recommendations
|
||||
if (currentBudget) {
|
||||
const budgetRecommendations = this.generateBudgetRecommendations(currentBudget, userId);
|
||||
const budgetRecommendations = this.generateBudgetRecommendations(
|
||||
currentBudget,
|
||||
userId,
|
||||
);
|
||||
for (const rec of budgetRecommendations) {
|
||||
recommendations.push(await this.recommendationRepository.save(rec));
|
||||
}
|
||||
@ -171,7 +193,10 @@ export class RecommendationsService {
|
||||
|
||||
// Add goal-based recommendations
|
||||
if (goalsSummary.activeGoals > 0) {
|
||||
const goalRecommendations = this.generateGoalRecommendations(goalsSummary, userId);
|
||||
const goalRecommendations = this.generateGoalRecommendations(
|
||||
goalsSummary,
|
||||
userId,
|
||||
);
|
||||
for (const rec of goalRecommendations) {
|
||||
recommendations.push(await this.recommendationRepository.save(rec));
|
||||
}
|
||||
@ -183,35 +208,50 @@ export class RecommendationsService {
|
||||
/**
|
||||
* Generate budget-based recommendations
|
||||
*/
|
||||
private generateBudgetRecommendations(budget: any, userId: string): Recommendation[] {
|
||||
private generateBudgetRecommendations(
|
||||
budget: any,
|
||||
userId: string,
|
||||
): Recommendation[] {
|
||||
const recommendations: Recommendation[] = [];
|
||||
|
||||
// Check if overspending on essentials
|
||||
const essentialsPercent = (Number(budget.essentialsSpent) / Number(budget.essentialsLimit)) * 100;
|
||||
const essentialsPercent =
|
||||
(Number(budget.essentialsSpent) / Number(budget.essentialsLimit)) * 100;
|
||||
if (essentialsPercent > 90) {
|
||||
recommendations.push(this.recommendationRepository.create({
|
||||
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),
|
||||
}));
|
||||
source: RecommendationSource.MOCK,
|
||||
potentialSavings:
|
||||
Number(budget.essentialsSpent) - Number(budget.essentialsLimit),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Check if underspending on savings
|
||||
const savingsPercent = (Number(budget.savingsSpent) / Number(budget.savingsLimit)) * 100;
|
||||
const savingsPercent =
|
||||
(Number(budget.savingsSpent) / Number(budget.savingsLimit)) * 100;
|
||||
if (savingsPercent < 50 && new Date().getDate() > 15) {
|
||||
recommendations.push(this.recommendationRepository.create({
|
||||
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) },
|
||||
}));
|
||||
source: RecommendationSource.MOCK,
|
||||
actionData: {
|
||||
targetAmount:
|
||||
Number(budget.savingsLimit) - Number(budget.savingsSpent),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
@ -220,22 +260,28 @@ export class RecommendationsService {
|
||||
/**
|
||||
* Generate goal-based recommendations
|
||||
*/
|
||||
private generateGoalRecommendations(summary: any, userId: string): Recommendation[] {
|
||||
private generateGoalRecommendations(
|
||||
summary: any,
|
||||
userId: string,
|
||||
): Recommendation[] {
|
||||
const recommendations: Recommendation[] = [];
|
||||
|
||||
if (summary.overallProgress < 30 && summary.activeGoals > 0) {
|
||||
recommendations.push(this.recommendationRepository.create({
|
||||
recommendations.push(
|
||||
this.recommendationRepository.create({
|
||||
userId,
|
||||
type: RecommendationType.GOAL,
|
||||
titleRu: 'Ускорьте достижение целей',
|
||||
descriptionRu: `Общий прогресс по вашим целям составляет ${summary.overallProgress.toFixed(0)}%. Рассмотрите возможность увеличения регулярных отчислений.`,
|
||||
priorityScore: 0.7,
|
||||
confidenceScore: 0.85,
|
||||
source: RecommendationSource.MOCK,
|
||||
actionData: {
|
||||
currentProgress: summary.overallProgress,
|
||||
activeGoals: summary.activeGoals,
|
||||
},
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
@ -270,12 +316,22 @@ export class RecommendationsService {
|
||||
|
||||
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,
|
||||
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)
|
||||
.filter(
|
||||
(r) =>
|
||||
r.status !== RecommendationStatus.DISMISSED && r.potentialSavings,
|
||||
)
|
||||
.reduce((sum, r) => sum + Number(r.potentialSavings || 0), 0),
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from './create-transaction.dto';
|
||||
export * from './update-transaction.dto';
|
||||
export * from './query-transactions.dto';
|
||||
export * from './suggest-category.dto';
|
||||
|
||||
33
src/modules/transactions/dto/suggest-category.dto.ts
Normal file
33
src/modules/transactions/dto/suggest-category.dto.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsDateString,
|
||||
IsNumber,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class SuggestCategoryDto {
|
||||
@ApiProperty({
|
||||
description: 'Описание транзакции',
|
||||
example: 'Покупка продуктов в Пятерочке',
|
||||
})
|
||||
@IsString({ message: 'Описание должно быть строкой' })
|
||||
@MaxLength(500, { message: 'Описание не должно превышать 500 символов' })
|
||||
description: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Сумма транзакции',
|
||||
example: 1500,
|
||||
})
|
||||
@IsNumber({}, { message: 'Сумма должна быть числом' })
|
||||
@Min(0.01, { message: 'Сумма должна быть положительным числом' })
|
||||
amount: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Дата транзакции (YYYY-MM-DD)',
|
||||
example: '2024-01-15',
|
||||
})
|
||||
@IsDateString({}, { message: 'Некорректный формат даты' })
|
||||
date: string;
|
||||
}
|
||||
@ -15,27 +15,56 @@ import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiOkResponse,
|
||||
ApiCreatedResponse,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { TransactionsService } from './transactions.service';
|
||||
import { CreateTransactionDto, UpdateTransactionDto, QueryTransactionsDto } from './dto';
|
||||
import {
|
||||
CreateTransactionDto,
|
||||
UpdateTransactionDto,
|
||||
QueryTransactionsDto,
|
||||
SuggestCategoryDto,
|
||||
} from './dto';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser, JwtPayload } from '../../common/decorators/user.decorator';
|
||||
import {
|
||||
CurrentUser,
|
||||
JwtPayload,
|
||||
} from '../../common/decorators/user.decorator';
|
||||
import { AiService } from '../ai/ai.service';
|
||||
|
||||
@ApiTags('Транзакции')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('transactions')
|
||||
export class TransactionsController {
|
||||
constructor(private readonly transactionsService: TransactionsService) {}
|
||||
constructor(
|
||||
private readonly transactionsService: TransactionsService,
|
||||
private readonly aiService: AiService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Получение списка транзакций' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@ApiOkResponse({
|
||||
description: 'Список транзакций с пагинацией',
|
||||
schema: {
|
||||
example: {
|
||||
data: [
|
||||
{
|
||||
id: 'uuid',
|
||||
amount: 1500.5,
|
||||
type: 'EXPENSE',
|
||||
categoryId: 'uuid',
|
||||
transactionDate: '2024-01-15T00:00:00.000Z',
|
||||
description: 'Покупка продуктов в Пятерочке',
|
||||
paymentMethod: 'CARD',
|
||||
},
|
||||
],
|
||||
meta: { page: 1, limit: 20, total: 1, totalPages: 1 },
|
||||
},
|
||||
},
|
||||
})
|
||||
async findAll(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@ -48,9 +77,16 @@ export class TransactionsController {
|
||||
@ApiOperation({ summary: 'Получение сводки по транзакциям за период' })
|
||||
@ApiQuery({ name: 'startDate', required: true, example: '2024-01-01' })
|
||||
@ApiQuery({ name: 'endDate', required: true, example: '2024-01-31' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@ApiOkResponse({
|
||||
description: 'Сводка: доходы, расходы, баланс',
|
||||
schema: {
|
||||
example: {
|
||||
totalIncome: 120000,
|
||||
totalExpense: 85000,
|
||||
balance: 35000,
|
||||
transactionCount: 42,
|
||||
},
|
||||
},
|
||||
})
|
||||
async getSummary(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@ -64,39 +100,69 @@ export class TransactionsController {
|
||||
@ApiOperation({ summary: 'Получение расходов по категориям' })
|
||||
@ApiQuery({ name: 'startDate', required: true, example: '2024-01-01' })
|
||||
@ApiQuery({ name: 'endDate', required: true, example: '2024-01-31' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@ApiOkResponse({
|
||||
description: 'Расходы, сгруппированные по категориям',
|
||||
schema: {
|
||||
example: [
|
||||
{
|
||||
categoryId: 'uuid',
|
||||
categoryName: 'Продукты',
|
||||
total: 22000,
|
||||
percentage: 25.88,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
async getByCategory(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Query('startDate') startDate: string,
|
||||
@Query('endDate') endDate: string,
|
||||
) {
|
||||
return this.transactionsService.getSpendingByCategory(user.sub, startDate, endDate);
|
||||
return this.transactionsService.getSpendingByCategory(
|
||||
user.sub,
|
||||
startDate,
|
||||
endDate,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Получение транзакции по ID' })
|
||||
@ApiParam({ name: 'id', description: 'ID транзакции' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@ApiOkResponse({
|
||||
description: 'Транзакция',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'uuid',
|
||||
amount: 1500.5,
|
||||
type: 'EXPENSE',
|
||||
categoryId: 'uuid',
|
||||
transactionDate: '2024-01-15T00:00:00.000Z',
|
||||
description: 'Покупка продуктов в Пятерочке',
|
||||
paymentMethod: 'CARD',
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Транзакция не найдена' })
|
||||
async findOne(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
async findOne(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
|
||||
return this.transactionsService.findOne(id, user.sub);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Создание транзакции' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
@ApiCreatedResponse({
|
||||
description: 'Транзакция создана',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'uuid',
|
||||
amount: 1500.5,
|
||||
type: 'EXPENSE',
|
||||
categoryId: 'uuid',
|
||||
transactionDate: '2024-01-15T00:00:00.000Z',
|
||||
description: 'Покупка продуктов в Пятерочке',
|
||||
paymentMethod: 'CARD',
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: 'Некорректные данные' })
|
||||
async create(
|
||||
@ -106,12 +172,51 @@ export class TransactionsController {
|
||||
return this.transactionsService.create(user.sub, dto);
|
||||
}
|
||||
|
||||
@Post('suggest-category')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'AI подсказка категории по описанию транзакции' })
|
||||
@ApiOkResponse({
|
||||
description: 'Подсказка категории',
|
||||
schema: {
|
||||
example: {
|
||||
suggestedCategoryId: 'unknown',
|
||||
suggestedCategoryName: 'Продукты',
|
||||
confidence: 0.82,
|
||||
reasoning:
|
||||
'В описании обнаружены ключевые слова, связанные с продуктами.',
|
||||
source: 'openrouter',
|
||||
},
|
||||
},
|
||||
})
|
||||
async suggestCategory(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: SuggestCategoryDto,
|
||||
) {
|
||||
return this.aiService.categorizeTransaction({
|
||||
description: dto.description,
|
||||
amount: dto.amount,
|
||||
date: dto.date,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('bulk')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Массовое создание транзакций (импорт)' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
@ApiCreatedResponse({
|
||||
description: 'Транзакции созданы',
|
||||
schema: {
|
||||
example: [
|
||||
{
|
||||
id: 'uuid',
|
||||
amount: 1500.5,
|
||||
type: 'EXPENSE',
|
||||
categoryId: 'uuid',
|
||||
transactionDate: '2024-01-15T00:00:00.000Z',
|
||||
description: 'Покупка продуктов',
|
||||
paymentMethod: 'CARD',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
async bulkCreate(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@ -123,9 +228,19 @@ export class TransactionsController {
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Обновление транзакции' })
|
||||
@ApiParam({ name: 'id', description: 'ID транзакции' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@ApiOkResponse({
|
||||
description: 'Транзакция обновлена',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'uuid',
|
||||
amount: 1600.0,
|
||||
type: 'EXPENSE',
|
||||
categoryId: 'uuid',
|
||||
transactionDate: '2024-01-15T00:00:00.000Z',
|
||||
description: 'Покупка продуктов (обновлено)',
|
||||
paymentMethod: 'CARD',
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Транзакция не найдена' })
|
||||
async update(
|
||||
@ -145,10 +260,7 @@ export class TransactionsController {
|
||||
description: 'Транзакция удалена',
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Транзакция не найдена' })
|
||||
async remove(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
async remove(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
|
||||
await this.transactionsService.remove(id, user.sub);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TransactionsController } from './transactions.controller';
|
||||
import { TransactionsService } from './transactions.service';
|
||||
import { Transaction } from './entities/transaction.entity';
|
||||
import { AiModule } from '../ai/ai.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Transaction])],
|
||||
imports: [TypeOrmModule.forFeature([Transaction]), AiModule],
|
||||
controllers: [TransactionsController],
|
||||
providers: [TransactionsService],
|
||||
exports: [TransactionsService],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user