Compare commits

...

10 Commits

Author SHA1 Message Date
Заид Омар Медхат
640632ca7e init 2025-12-25 23:33:02 +05:00
221878529f f 2025-12-15 12:11:52 +05:00
Заид Омар Медхат
3cc47bf56b f 2025-12-14 00:56:12 +05:00
Заид Омар Медхат
0cc8178164 f 2025-12-14 00:50:27 +05:00
Заид Омар Медхат
4ec325208d f 2025-12-14 00:42:24 +05:00
Заид Омар Медхат
86fa5720ca f 2025-12-14 00:34:12 +05:00
Заид Омар Медхат
786d7049b9 f 2025-12-14 00:29:53 +05:00
Заид Омар Медхат
eaa4d1c23b f 2025-12-14 00:19:34 +05:00
Заид Омар Медхат
ecf2dcf569 f 2025-12-14 00:15:00 +05:00
Заид Омар Медхат
1d21861615 f 2025-12-14 00:13:50 +05:00
30 changed files with 2066 additions and 368 deletions

15
.env Normal file
View 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

View File

@ -32,9 +32,13 @@ LOCKOUT_DURATION_MINUTES=30
# AI Integration (Phase 2) # AI Integration (Phase 2)
DEEPSEEK_API_KEY= DEEPSEEK_API_KEY=
OPENROUTER_API_KEY=
AI_SERVICE_URL=http://localhost:8000 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 # Logging
LOG_LEVEL=debug LOG_LEVEL=debug

View File

@ -33,6 +33,9 @@ LOCKOUT_DURATION_MINUTES=30
# AI Integration (Phase 2 - DeepSeek via OpenRouter) # AI Integration (Phase 2 - DeepSeek via OpenRouter)
DEEPSEEK_API_KEY= DEEPSEEK_API_KEY=
OPENROUTER_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_SERVICE_URL=http://localhost:8000
AI_ENABLED=false AI_ENABLED=false

View 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
View File

@ -36,11 +36,11 @@ lerna-debug.log*
!.vscode/extensions.json !.vscode/extensions.json
# dotenv environment variable files # dotenv environment variable files
.env # .env
.env.development.local # .env.development.local
.env.test.local # .env.test.local
.env.production.local # .env.production.local
.env.local # .env.local
# temp directory # temp directory
.temp .temp

View File

@ -1,28 +1,6 @@
stages: stages:
- build
- deploy - 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: deploy_production:
stage: deploy stage: deploy
image: alpine:3.20 image: alpine:3.20
@ -30,7 +8,7 @@ deploy_production:
name: production name: production
url: https://api-finance.ai-assistant-bot.xyz url: https://api-finance.ai-assistant-bot.xyz
rules: rules:
- if: '$CI_COMMIT_BRANCH == "main"' - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
when: manual when: manual
allow_failure: false allow_failure: false
before_script: before_script:
@ -41,14 +19,5 @@ deploy_production:
- ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts - ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
script: script:
- ssh "$DEPLOY_USER@$DEPLOY_HOST" "mkdir -p /opt/apps/api-finance" - ssh "$DEPLOY_USER@$DEPLOY_HOST" "mkdir -p /opt/apps/api-finance"
- rsync -az --delete \ - rsync -az --delete --exclude='.git' --exclude='.env' --exclude='.env.*' --exclude='node_modules' --exclude='coverage' --exclude='dist' ./ "$DEPLOY_USER@$DEPLOY_HOST:/opt/apps/api-finance/"
--exclude='.git' \ - ssh "$DEPLOY_USER@$DEPLOY_HOST" "cd /opt/apps/api-finance && docker compose -f docker-compose.server.yml up -d --build"
--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"

View File

@ -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/dist ./dist
COPY --from=build --chown=nestjs:nodejs /app/node_modules ./node_modules 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/package*.json ./
COPY --from=build --chown=nestjs:nodejs /app/scripts ./scripts
# Set environment variables # Set environment variables
ENV NODE_ENV=production ENV NODE_ENV=production
@ -78,5 +79,5 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ 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))" CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
# Start the application # Start the application with migrations
CMD ["node", "dist/main.js"] CMD ["sh", "-c", "node scripts/run-migrations.js && node dist/main.js"]

View File

@ -1,48 +1,28 @@
version: '3.8' version: '3.8'
services: 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: app:
image: ${APP_IMAGE} build:
context: .
dockerfile: Dockerfile
target: production
container_name: api_finance_app container_name: api_finance_app
environment: environment:
NODE_ENV: production NODE_ENV: production
DB_HOST: postgres DB_HOST: shared_postgres
DB_PORT: 5432 DB_PORT: 5432
DB_USERNAME: ${DB_USERNAME} DB_USERNAME: finance_user
DB_PASSWORD: ${DB_PASSWORD} DB_PASSWORD: SecurePassword123
DB_NAME: ${DB_NAME} DB_NAME: finance_app
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: your_jwt_secret_key_here_minimum_32_characters_long
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET} JWT_REFRESH_SECRET: your_refresh_secret_key_here_minimum_32_characters_long
FRONTEND_URL: ${FRONTEND_URL} FRONTEND_URL: https://your-frontend.ai-assistant-bot.xyz
COOKIE_DOMAIN: ${COOKIE_DOMAIN} COOKIE_DOMAIN: ai-assistant-bot.xyz
COOKIE_SECURE: ${COOKIE_SECURE} COOKIE_SECURE: "true"
CORS_ORIGINS: ${CORS_ORIGINS} CORS_ORIGINS: https://your-frontend.ai-assistant-bot.xyz
PORT: 3000 PORT: 3000
depends_on:
postgres:
condition: service_healthy
networks: networks:
- proxy - proxy
- api_finance_internal
restart: unless-stopped restart: unless-stopped
labels: labels:
- traefik.enable=true - traefik.enable=true
@ -52,11 +32,6 @@ services:
- traefik.http.routers.api-finance.tls.certresolver=le - traefik.http.routers.api-finance.tls.certresolver=le
- traefik.http.services.api-finance.loadbalancer.server.port=3000 - traefik.http.services.api-finance.loadbalancer.server.port=3000
volumes:
api_finance_postgres_data:
networks: networks:
proxy: proxy:
external: true external: true
api_finance_internal:
driver: bridge

43
package-lock.json generated
View File

@ -18,6 +18,7 @@
"@nestjs/swagger": "^7.4.0", "@nestjs/swagger": "^7.4.0",
"@nestjs/throttler": "^5.1.2", "@nestjs/throttler": "^5.1.2",
"@nestjs/typeorm": "^10.0.2", "@nestjs/typeorm": "^10.0.2",
"axios": "^1.6.8",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
@ -4640,7 +4641,6 @@
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/available-typed-arrays": { "node_modules/available-typed-arrays": {
@ -4658,6 +4658,17 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/b4a": {
"version": "1.7.3", "version": "1.7.3",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz",
@ -5490,7 +5501,6 @@
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"delayed-stream": "~1.0.0" "delayed-stream": "~1.0.0"
@ -5874,7 +5884,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.4.0" "node": ">=0.4.0"
@ -6124,7 +6133,6 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@ -6924,6 +6932,26 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@ -7029,7 +7057,6 @@
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
@ -10162,6 +10189,12 @@
"node": ">= 0.10" "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": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View File

@ -22,6 +22,7 @@
"migration:generate": "npm run typeorm -- migration:generate -d src/database/data-source.ts", "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: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: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" "seed": "ts-node -r tsconfig-paths/register src/database/seeds/run-seed.ts"
}, },
"dependencies": { "dependencies": {
@ -34,6 +35,7 @@
"@nestjs/swagger": "^7.4.0", "@nestjs/swagger": "^7.4.0",
"@nestjs/throttler": "^5.1.2", "@nestjs/throttler": "^5.1.2",
"@nestjs/typeorm": "^10.0.2", "@nestjs/typeorm": "^10.0.2",
"axios": "^1.6.8",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",

41
scripts/run-migrations.js Normal file
View 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();

View File

@ -17,15 +17,20 @@ export class AllExceptionsFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>(); const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>(); const request = ctx.getRequest<Request>();
if (response.headersSent) {
return;
}
let status = HttpStatus.INTERNAL_SERVER_ERROR; let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Внутренняя ошибка сервера'; let message = 'Внутренняя ошибка сервера';
if (exception instanceof HttpException) { if (exception instanceof HttpException) {
status = exception.getStatus(); status = exception.getStatus();
const exceptionResponse = exception.getResponse(); const exceptionResponse = exception.getResponse();
message = typeof exceptionResponse === 'string' message =
? exceptionResponse typeof exceptionResponse === 'string'
: (exceptionResponse as any).message || message; ? exceptionResponse
: (exceptionResponse as any).message || message;
} else if (exception instanceof Error) { } else if (exception instanceof Error) {
// Log the actual error for debugging // Log the actual error for debugging
this.logger.error( this.logger.error(

View File

@ -3,6 +3,13 @@ import { registerAs } from '@nestjs/config';
export default registerAs('ai', () => ({ export default registerAs('ai', () => ({
deepseekApiKey: process.env.DEEPSEEK_API_KEY || '', deepseekApiKey: process.env.DEEPSEEK_API_KEY || '',
openrouterApiKey: process.env.OPENROUTER_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', serviceUrl: process.env.AI_SERVICE_URL || 'http://localhost:8000',
enabled: process.env.AI_ENABLED === 'true', enabled: process.env.AI_ENABLED === 'true',
})); }));

View File

@ -1,6 +1,7 @@
import { DataSource, DataSourceOptions } from 'typeorm'; import { DataSource, DataSourceOptions } from 'typeorm';
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
dotenv.config({ path: '.env' });
dotenv.config({ path: '.env.development' }); dotenv.config({ path: '.env.development' });
export const dataSourceOptions: DataSourceOptions = { export const dataSourceOptions: DataSourceOptions = {
@ -10,8 +11,8 @@ export const dataSourceOptions: DataSourceOptions = {
username: process.env.DB_USERNAME || 'finance_user', username: process.env.DB_USERNAME || 'finance_user',
password: process.env.DB_PASSWORD || 'dev_password_123', password: process.env.DB_PASSWORD || 'dev_password_123',
database: process.env.DB_NAME || 'finance_app', database: process.env.DB_NAME || 'finance_app',
entities: ['src/**/*.entity{.ts,.js}'], entities: [__dirname + '/../**/*.entity{.ts,.js}'],
migrations: ['src/database/migrations/*{.ts,.js}'], migrations: [__dirname + '/migrations/*{.ts,.js}'],
synchronize: false, synchronize: false,
logging: process.env.NODE_ENV === 'development', logging: process.env.NODE_ENV === 'development',
}; };

View 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"`,
);
}
}

View File

@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
/** /**
* AI Service Placeholder for DeepSeek Integration via OpenRouter * AI Service Placeholder for DeepSeek Integration via OpenRouter
@ -17,6 +18,7 @@ export interface TransactionCategorizationResponse {
suggestedCategoryName: string; suggestedCategoryName: string;
confidence: number; confidence: number;
reasoning: string; reasoning: string;
source?: 'openrouter' | 'mock';
} }
export interface SpendingAnalysisRequest { export interface SpendingAnalysisRequest {
@ -38,18 +40,56 @@ export interface SpendingAnalysisResponse {
}>; }>;
insights: string[]; insights: string[];
recommendations: string[]; recommendations: string[];
source?: 'openrouter' | 'mock';
} }
export interface FinancialRecommendation { export interface FinancialRecommendation {
id: string; id: string;
type: 'SAVING' | 'SPENDING' | 'INVESTMENT' | 'TAX' | 'DEBT'; type:
| 'SAVING'
| 'SPENDING'
| 'INVESTMENT'
| 'TAX'
| 'DEBT'
| 'BUDGET'
| 'GOAL';
titleRu: string; titleRu: string;
descriptionRu: string; descriptionRu: string;
priorityScore: number; priorityScore: number;
confidenceScore: number; confidenceScore: number;
actionData?: Record<string, any>; 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 { export interface ForecastRequest {
userId: string; userId: string;
historicalData: Array<{ historicalData: Array<{
@ -78,13 +118,45 @@ export interface ForecastResponse {
export class AiService { export class AiService {
private readonly logger = new Logger(AiService.name); private readonly logger = new Logger(AiService.name);
private readonly isEnabled: boolean; 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) { constructor(private configService: ConfigService) {
this.isEnabled = this.configService.get<boolean>('ai.enabled') || false; this.openrouterApiKey =
this.configService.get<string>('ai.openrouterApiKey') || '';
if (!this.isEnabled) { this.openrouterBaseUrl =
this.logger.warn('AI Service is disabled. Using mock implementations.'); 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;
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,9 +172,38 @@ export class AiService {
return this.mockCategorizeTransaction(request); return this.mockCategorizeTransaction(request);
} }
// TODO: Implement DeepSeek API call via OpenRouter try {
// const response = await this.callDeepSeek('categorize', request); const messages: OpenRouterChatMessage[] = [
return this.mockCategorizeTransaction(request); {
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);
}
} }
/** /**
@ -118,8 +219,39 @@ export class AiService {
return this.mockAnalyzeSpending(request); return this.mockAnalyzeSpending(request);
} }
// TODO: Implement DeepSeek API call via OpenRouter try {
return this.mockAnalyzeSpending(request); 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);
}
} }
/** /**
@ -141,8 +273,49 @@ export class AiService {
return this.mockGenerateRecommendations(context); return this.mockGenerateRecommendations(context);
} }
// TODO: Implement DeepSeek API call via OpenRouter try {
return this.mockGenerateRecommendations(context); 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);
}
} }
/** /**
@ -156,10 +329,238 @@ export class AiService {
return this.mockForecastFinances(request); return this.mockForecastFinances(request);
} }
// TODO: Implement DeepSeek API call via OpenRouter // Forecasting can be expensive; keep mock fallback for now.
return this.mockForecastFinances(request); 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) // Mock Implementations (Phase 1)
// ============================================ // ============================================
@ -171,17 +572,17 @@ export class AiService {
// Simple keyword-based categorization // Simple keyword-based categorization
const categoryMap: Record<string, { id: string; name: string }> = { const categoryMap: Record<string, { id: string; name: string }> = {
'продукты': { id: 'groceries', name: 'Продукты' }, продукты: { id: 'groceries', name: 'Продукты' },
'пятерочка': { id: 'groceries', name: 'Продукты' }, пятерочка: { id: 'groceries', name: 'Продукты' },
'магнит': { id: 'groceries', name: 'Продукты' }, магнит: { id: 'groceries', name: 'Продукты' },
'такси': { id: 'transport', name: 'Транспорт' }, такси: { id: 'transport', name: 'Транспорт' },
'яндекс': { id: 'transport', name: 'Транспорт' }, яндекс: { id: 'transport', name: 'Транспорт' },
'метро': { id: 'transport', name: 'Транспорт' }, метро: { id: 'transport', name: 'Транспорт' },
'ресторан': { id: 'restaurants', name: 'Рестораны и кафе' }, ресторан: { id: 'restaurants', name: 'Рестораны и кафе' },
'кафе': { id: 'restaurants', name: 'Рестораны и кафе' }, кафе: { id: 'restaurants', name: 'Рестораны и кафе' },
'аптека': { id: 'healthcare', name: 'Медицина' }, аптека: { id: 'healthcare', name: 'Медицина' },
'жкх': { id: 'utilities', name: 'Коммуналка' }, жкх: { id: 'utilities', name: 'Коммуналка' },
'электричество': { id: 'utilities', name: 'Коммуналка' }, электричество: { id: 'utilities', name: 'Коммуналка' },
}; };
for (const [keyword, category] of Object.entries(categoryMap)) { for (const [keyword, category] of Object.entries(categoryMap)) {
@ -191,6 +592,7 @@ export class AiService {
suggestedCategoryName: category.name, suggestedCategoryName: category.name,
confidence: 0.85, confidence: 0.85,
reasoning: `Ключевое слово "${keyword}" найдено в описании`, reasoning: `Ключевое слово "${keyword}" найдено в описании`,
source: 'mock',
}; };
} }
} }
@ -200,6 +602,7 @@ export class AiService {
suggestedCategoryName: 'Другое', suggestedCategoryName: 'Другое',
confidence: 0.3, confidence: 0.3,
reasoning: 'Не удалось определить категорию по описанию', reasoning: 'Не удалось определить категорию по описанию',
source: 'mock',
}; };
} }
@ -229,6 +632,7 @@ export class AiService {
'Увеличьте автоматические переводы на накопления до 20%', 'Увеличьте автоматические переводы на накопления до 20%',
'Используйте карту с кэшбэком для регулярных покупок', 'Используйте карту с кэшбэком для регулярных покупок',
], ],
source: 'mock',
}; };
} }
@ -253,6 +657,7 @@ export class AiService {
targetSavingsRate: 20, targetSavingsRate: 20,
monthlySavingsTarget: context.monthlyIncome * 0.2, monthlySavingsTarget: context.monthlyIncome * 0.2,
}, },
source: 'mock',
}); });
} }
@ -261,9 +666,11 @@ export class AiService {
id: 'tax-deduction', id: 'tax-deduction',
type: 'TAX', type: 'TAX',
titleRu: 'Налоговый вычет', titleRu: 'Налоговый вычет',
descriptionRu: 'Проверьте возможность получения налогового вычета за медицинские услуги или образование (3-НДФЛ).', descriptionRu:
'Проверьте возможность получения налогового вычета за медицинские услуги или образование (3-НДФЛ).',
priorityScore: 0.7, priorityScore: 0.7,
confidenceScore: 0.8, confidenceScore: 0.8,
source: 'mock',
}); });
// Emergency fund recommendation // Emergency fund recommendation
@ -271,12 +678,14 @@ export class AiService {
id: 'emergency-fund', id: 'emergency-fund',
type: 'SAVING', type: 'SAVING',
titleRu: 'Резервный фонд', titleRu: 'Резервный фонд',
descriptionRu: 'Рекомендуем создать резервный фонд в размере 3-6 месячных расходов.', descriptionRu:
'Рекомендуем создать резервный фонд в размере 3-6 месячных расходов.',
priorityScore: 0.85, priorityScore: 0.85,
confidenceScore: 0.9, confidenceScore: 0.9,
actionData: { actionData: {
targetAmount: context.totalExpenses * 6, targetAmount: context.totalExpenses * 6,
}, },
source: 'mock',
}); });
return recommendations; return recommendations;

View File

@ -1,19 +1,18 @@
import { import { Controller, Get, Query, UseGuards } from '@nestjs/common';
Controller,
Get,
Query,
UseGuards,
} from '@nestjs/common';
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiOkResponse,
ApiBearerAuth, ApiBearerAuth,
ApiQuery, ApiQuery,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { AnalyticsService } from './analytics.service'; import { AnalyticsService } from './analytics.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; 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'; import { TransactionType } from '../../common/constants/categories';
@ApiTags('Аналитика') @ApiTags('Аналитика')
@ -25,8 +24,32 @@ export class AnalyticsController {
@Get('overview') @Get('overview')
@ApiOperation({ summary: 'Обзор за месяц' }) @ApiOperation({ summary: 'Обзор за месяц' })
@ApiQuery({ name: 'month', required: false, example: '2024-01', description: 'Месяц в формате YYYY-MM' }) @ApiQuery({
@ApiResponse({ status: 200, description: 'Обзор за месяц' }) 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( async getMonthlyOverview(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@Query('month') month?: string, @Query('month') month?: string,
@ -35,10 +58,45 @@ export class AnalyticsController {
return this.analyticsService.getMonthlyOverview(user.sub, date); 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') @Get('trends')
@ApiOperation({ summary: 'Тренды расходов' }) @ApiOperation({ summary: 'Тренды расходов' })
@ApiQuery({ name: 'months', required: false, example: 6 }) @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( async getSpendingTrends(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@Query('months') months?: number, @Query('months') months?: number,
@ -51,7 +109,23 @@ export class AnalyticsController {
@ApiQuery({ name: 'startDate', required: true, example: '2024-01-01' }) @ApiQuery({ name: 'startDate', required: true, example: '2024-01-01' })
@ApiQuery({ name: 'endDate', required: true, example: '2024-01-31' }) @ApiQuery({ name: 'endDate', required: true, example: '2024-01-31' })
@ApiQuery({ name: 'type', enum: TransactionType, required: false }) @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( async getCategoryBreakdown(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@Query('startDate') startDate: string, @Query('startDate') startDate: string,
@ -69,7 +143,15 @@ export class AnalyticsController {
@Get('income-vs-expenses') @Get('income-vs-expenses')
@ApiOperation({ summary: 'Сравнение доходов и расходов' }) @ApiOperation({ summary: 'Сравнение доходов и расходов' })
@ApiQuery({ name: 'months', required: false, example: 12 }) @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( async getIncomeVsExpenses(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@Query('months') months?: number, @Query('months') months?: number,
@ -79,7 +161,23 @@ export class AnalyticsController {
@Get('health') @Get('health')
@ApiOperation({ summary: 'Оценка финансового здоровья' }) @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) { async getFinancialHealth(@CurrentUser() user: JwtPayload) {
return this.analyticsService.getFinancialHealth(user.sub); return this.analyticsService.getFinancialHealth(user.sub);
} }
@ -87,11 +185,26 @@ export class AnalyticsController {
@Get('yearly') @Get('yearly')
@ApiOperation({ summary: 'Годовой отчет' }) @ApiOperation({ summary: 'Годовой отчет' })
@ApiQuery({ name: 'year', required: false, example: 2024 }) @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( async getYearlySummary(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@Query('year') year?: number, @Query('year') year?: number,
) { ) {
return this.analyticsService.getYearlySummary(user.sub, year || new Date().getFullYear()); return this.analyticsService.getYearlySummary(
user.sub,
year || new Date().getFullYear(),
);
} }
} }

View File

@ -6,10 +6,12 @@ import { Transaction } from '../transactions/entities/transaction.entity';
import { Category } from '../categories/entities/category.entity'; import { Category } from '../categories/entities/category.entity';
import { Budget } from '../budgets/entities/budget.entity'; import { Budget } from '../budgets/entities/budget.entity';
import { Goal } from '../goals/entities/goal.entity'; import { Goal } from '../goals/entities/goal.entity';
import { AiModule } from '../ai/ai.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Transaction, Category, Budget, Goal]), TypeOrmModule.forFeature([Transaction, Category, Budget, Goal]),
AiModule,
], ],
controllers: [AnalyticsController], controllers: [AnalyticsController],
providers: [AnalyticsService], providers: [AnalyticsService],

View File

@ -5,9 +5,17 @@ import { Transaction } from '../transactions/entities/transaction.entity';
import { Category } from '../categories/entities/category.entity'; import { Category } from '../categories/entities/category.entity';
import { Budget } from '../budgets/entities/budget.entity'; import { Budget } from '../budgets/entities/budget.entity';
import { Goal } from '../goals/entities/goal.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 { formatRubles } from '../../common/utils/currency.utils';
import { TransactionType } from '../../common/constants/categories'; import { TransactionType } from '../../common/constants/categories';
import { AiService } from '../ai/ai.service';
export interface MonthlyOverview { export interface MonthlyOverview {
month: string; month: string;
@ -63,12 +71,16 @@ export class AnalyticsService {
private budgetRepository: Repository<Budget>, private budgetRepository: Repository<Budget>,
@InjectRepository(Goal) @InjectRepository(Goal)
private goalRepository: Repository<Goal>, private goalRepository: Repository<Goal>,
private aiService: AiService,
) {} ) {}
/** /**
* Get monthly overview for a specific month * 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 monthStart = startOfMonth(date);
const monthEnd = endOfMonth(date); const monthEnd = endOfMonth(date);
@ -81,21 +93,22 @@ export class AnalyticsService {
}); });
const totalIncome = transactions const totalIncome = transactions
.filter(t => t.type === TransactionType.INCOME) .filter((t) => t.type === TransactionType.INCOME)
.reduce((sum, t) => sum + Number(t.amount), 0); .reduce((sum, t) => sum + Number(t.amount), 0);
const totalExpenses = transactions const totalExpenses = transactions
.filter(t => t.type === TransactionType.EXPENSE) .filter((t) => t.type === TransactionType.EXPENSE)
.reduce((sum, t) => sum + Number(t.amount), 0); .reduce((sum, t) => sum + Number(t.amount), 0);
const netSavings = totalIncome - totalExpenses; const netSavings = totalIncome - totalExpenses;
const savingsRate = totalIncome > 0 ? (netSavings / totalIncome) * 100 : 0; const savingsRate = totalIncome > 0 ? (netSavings / totalIncome) * 100 : 0;
// Calculate top spending categories // Calculate top spending categories
const categorySpending: Record<string, { name: string; amount: number }> = {}; const categorySpending: Record<string, { name: string; amount: number }> =
{};
transactions transactions
.filter(t => t.type === TransactionType.EXPENSE) .filter((t) => t.type === TransactionType.EXPENSE)
.forEach(t => { .forEach((t) => {
const catId = t.categoryId || 'other'; const catId = t.categoryId || 'other';
const catName = t.category?.nameRu || 'Другое'; const catName = t.category?.nameRu || 'Другое';
if (!categorySpending[catId]) { 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 * 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[] = []; const trends: SpendingTrend[] = [];
let previousAmount = 0; let previousAmount = 0;
@ -146,7 +180,8 @@ export class AnalyticsService {
const amount = transactions.reduce((sum, t) => sum + Number(t.amount), 0); const amount = transactions.reduce((sum, t) => sum + Number(t.amount), 0);
const change = previousAmount > 0 ? amount - previousAmount : 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({ trends.push({
period: format(date, 'yyyy-MM'), period: format(date, 'yyyy-MM'),
@ -179,17 +214,23 @@ export class AnalyticsService {
relations: ['category'], 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<
name: string; string,
icon: string; {
color: string; name: string;
amount: number; icon: string;
count: number; color: string;
}> = {}; amount: number;
count: number;
}
> = {};
transactions.forEach(t => { transactions.forEach((t) => {
const catId = t.categoryId || 'other'; const catId = t.categoryId || 'other';
if (!categoryData[catId]) { if (!categoryData[catId]) {
categoryData[catId] = { categoryData[catId] = {
@ -221,12 +262,17 @@ export class AnalyticsService {
/** /**
* Get income vs expenses comparison * Get income vs expenses comparison
*/ */
async getIncomeVsExpenses(userId: string, months: number = 12): Promise<Array<{ async getIncomeVsExpenses(
period: string; userId: string,
income: number; months: number = 12,
expenses: number; ): Promise<
savings: number; Array<{
}>> { period: string;
income: number;
expenses: number;
savings: number;
}>
> {
const result: Array<{ const result: Array<{
period: string; period: string;
income: number; income: number;
@ -247,11 +293,11 @@ export class AnalyticsService {
}); });
const income = transactions const income = transactions
.filter(t => t.type === TransactionType.INCOME) .filter((t) => t.type === TransactionType.INCOME)
.reduce((sum, t) => sum + Number(t.amount), 0); .reduce((sum, t) => sum + Number(t.amount), 0);
const expenses = transactions const expenses = transactions
.filter(t => t.type === TransactionType.EXPENSE) .filter((t) => t.type === TransactionType.EXPENSE)
.reduce((sum, t) => sum + Number(t.amount), 0); .reduce((sum, t) => sum + Number(t.amount), 0);
result.push({ result.push({
@ -282,21 +328,25 @@ export class AnalyticsService {
}); });
const totalIncome = transactions const totalIncome = transactions
.filter(t => t.type === 'INCOME') .filter((t) => t.type === 'INCOME')
.reduce((sum, t) => sum + Number(t.amount), 0); .reduce((sum, t) => sum + Number(t.amount), 0);
const totalExpenses = transactions const totalExpenses = transactions
.filter(t => t.type === 'EXPENSE') .filter((t) => t.type === 'EXPENSE')
.reduce((sum, t) => sum + Number(t.amount), 0); .reduce((sum, t) => sum + Number(t.amount), 0);
// Factor 1: Savings Rate (target: 20%+) // 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); const savingsScore = Math.min(100, (savingsRate / 20) * 100);
factors.push({ factors.push({
name: 'Норма сбережений', name: 'Норма сбережений',
score: savingsScore, score: savingsScore,
description: `Ваша норма сбережений: ${savingsRate.toFixed(1)}%`, description: `Ваша норма сбережений: ${savingsRate.toFixed(1)}%`,
recommendation: savingsRate < 20 ? 'Рекомендуем увеличить сбережения до 20% от дохода' : undefined, recommendation:
savingsRate < 20
? 'Рекомендуем увеличить сбережения до 20% от дохода'
: undefined,
}); });
totalScore += savingsScore * 0.3; totalScore += savingsScore * 0.3;
@ -307,14 +357,20 @@ export class AnalyticsService {
let budgetScore = 50; // Default if no budget let budgetScore = 50; // Default if no budget
if (currentBudget) { if (currentBudget) {
const budgetUsage = (currentBudget.totalSpent / Number(currentBudget.totalIncome)) * 100; const budgetUsage =
budgetScore = budgetUsage <= 100 ? 100 - Math.max(0, budgetUsage - 80) : 0; (currentBudget.totalSpent / Number(currentBudget.totalIncome)) * 100;
budgetScore =
budgetUsage <= 100 ? 100 - Math.max(0, budgetUsage - 80) : 0;
} }
factors.push({ factors.push({
name: 'Соблюдение бюджета', name: 'Соблюдение бюджета',
score: budgetScore, score: budgetScore,
description: currentBudget ? `Использовано ${((currentBudget.totalSpent / Number(currentBudget.totalIncome)) * 100).toFixed(0)}% бюджета` : 'Бюджет не установлен', description: currentBudget
recommendation: !currentBudget ? 'Создайте бюджет для лучшего контроля расходов' : undefined, ? `Использовано ${((currentBudget.totalSpent / Number(currentBudget.totalIncome)) * 100).toFixed(0)}% бюджета`
: 'Бюджет не установлен',
recommendation: !currentBudget
? 'Создайте бюджет для лучшего контроля расходов'
: undefined,
}); });
totalScore += budgetScore * 0.25; totalScore += budgetScore * 0.25;
@ -325,14 +381,21 @@ export class AnalyticsService {
let goalScore = 50; let goalScore = 50;
if (goals.length > 0) { 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; goalScore = avgProgress;
} }
factors.push({ factors.push({
name: 'Прогресс по целям', name: 'Прогресс по целям',
score: goalScore, score: goalScore,
description: goals.length > 0 ? `${goals.length} активных целей` : 'Нет активных целей', description:
recommendation: goals.length === 0 ? 'Создайте финансовые цели для мотивации' : undefined, goals.length > 0
? `${goals.length} активных целей`
: 'Нет активных целей',
recommendation:
goals.length === 0
? 'Создайте финансовые цели для мотивации'
: undefined,
}); });
totalScore += goalScore * 0.2; totalScore += goalScore * 0.2;
@ -343,24 +406,37 @@ export class AnalyticsService {
const monthStart = startOfMonth(date); const monthStart = startOfMonth(date);
const monthEnd = endOfMonth(date); const monthEnd = endOfMonth(date);
const monthTransactions = transactions.filter(t => { const monthTransactions = transactions.filter((t) => {
const tDate = new Date(t.transactionDate); 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 avgExpense =
const variance = monthlyExpenses.reduce((sum, e) => sum + Math.pow(e - avgExpense, 2), 0) / monthlyExpenses.length; 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 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({ factors.push({
name: 'Стабильность расходов', name: 'Стабильность расходов',
score: consistencyScore, score: consistencyScore,
description: `Отклонение расходов: ${((stdDev / avgExpense) * 100).toFixed(0)}%`, description: `Отклонение расходов: ${((stdDev / avgExpense) * 100).toFixed(0)}%`,
recommendation: consistencyScore < 70 ? 'Старайтесь поддерживать стабильный уровень расходов' : undefined, recommendation:
consistencyScore < 70
? 'Старайтесь поддерживать стабильный уровень расходов'
: undefined,
}); });
totalScore += consistencyScore * 0.25; totalScore += consistencyScore * 0.25;
@ -382,7 +458,10 @@ export class AnalyticsService {
/** /**
* Get yearly summary * 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; year: number;
totalIncome: number; totalIncome: number;
totalExpenses: number; totalExpenses: number;
@ -391,7 +470,11 @@ export class AnalyticsService {
averageMonthlyExpenses: number; averageMonthlyExpenses: number;
bestMonth: { month: string; savings: number }; bestMonth: { month: string; savings: number };
worstMonth: { 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 yearStart = startOfYear(new Date(year, 0, 1));
const yearEnd = endOfYear(new Date(year, 0, 1)); const yearEnd = endOfYear(new Date(year, 0, 1));
@ -405,16 +488,17 @@ export class AnalyticsService {
}); });
const totalIncome = transactions const totalIncome = transactions
.filter(t => t.type === TransactionType.INCOME) .filter((t) => t.type === TransactionType.INCOME)
.reduce((sum, t) => sum + Number(t.amount), 0); .reduce((sum, t) => sum + Number(t.amount), 0);
const totalExpenses = transactions const totalExpenses = transactions
.filter(t => t.type === TransactionType.EXPENSE) .filter((t) => t.type === TransactionType.EXPENSE)
.reduce((sum, t) => sum + Number(t.amount), 0); .reduce((sum, t) => sum + Number(t.amount), 0);
// Calculate monthly data // Calculate monthly data
const monthlyData: Record<string, { income: number; expenses: number }> = {}; const monthlyData: Record<string, { income: number; expenses: number }> =
transactions.forEach(t => { {};
transactions.forEach((t) => {
const month = format(new Date(t.transactionDate), 'yyyy-MM'); const month = format(new Date(t.transactionDate), 'yyyy-MM');
if (!monthlyData[month]) { if (!monthlyData[month]) {
monthlyData[month] = { income: 0, expenses: 0 }; monthlyData[month] = { income: 0, expenses: 0 };
@ -444,10 +528,11 @@ export class AnalyticsService {
}); });
// Top expense categories // Top expense categories
const categoryExpenses: Record<string, { name: string; amount: number }> = {}; const categoryExpenses: Record<string, { name: string; amount: number }> =
{};
transactions transactions
.filter(t => t.type === TransactionType.EXPENSE) .filter((t) => t.type === TransactionType.EXPENSE)
.forEach(t => { .forEach((t) => {
const catName = t.category?.nameRu || 'Другое'; const catName = t.category?.nameRu || 'Другое';
if (!categoryExpenses[catName]) { if (!categoryExpenses[catName]) {
categoryExpenses[catName] = { name: catName, amount: 0 }; categoryExpenses[catName] = { name: catName, amount: 0 };
@ -456,7 +541,7 @@ export class AnalyticsService {
}); });
const topExpenseCategories = Object.values(categoryExpenses) const topExpenseCategories = Object.values(categoryExpenses)
.map(cat => ({ .map((cat) => ({
name: cat.name, name: cat.name,
amount: cat.amount, amount: cat.amount,
percentage: totalExpenses > 0 ? (cat.amount / totalExpenses) * 100 : 0, percentage: totalExpenses > 0 ? (cat.amount / totalExpenses) * 100 : 0,

View File

@ -9,20 +9,33 @@ import {
UseGuards, UseGuards,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiOkResponse,
ApiCreatedResponse,
ApiBody, ApiBody,
ApiBearerAuth, ApiBearerAuth,
ApiCookieAuth, ApiCookieAuth,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { AuthService } from './auth.service'; 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 { 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 { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
@ -39,10 +52,21 @@ export class AuthController {
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Регистрация нового пользователя' }) @ApiOperation({ summary: 'Регистрация нового пользователя' })
@ApiBody({ type: RegisterDto }) @ApiBody({ type: RegisterDto })
@ApiResponse({ @ApiCreatedResponse({
status: 201,
description: 'Пользователь успешно зарегистрирован', description: 'Пользователь успешно зарегистрирован',
type: AuthResponseDto, 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: 400, description: 'Некорректные данные' })
@ApiResponse({ status: 409, description: 'Пользователь уже существует' }) @ApiResponse({ status: 409, description: 'Пользователь уже существует' })
@ -81,10 +105,21 @@ export class AuthController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Вход в систему' }) @ApiOperation({ summary: 'Вход в систему' })
@ApiBody({ type: LoginDto }) @ApiBody({ type: LoginDto })
@ApiResponse({ @ApiOkResponse({
status: 200,
description: 'Успешный вход', description: 'Успешный вход',
type: AuthResponseDto, 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 или пароль' }) @ApiResponse({ status: 401, description: 'Неверный email или пароль' })
async login( async login(
@ -118,9 +153,14 @@ export class AuthController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Обновление токена доступа' }) @ApiOperation({ summary: 'Обновление токена доступа' })
@ApiCookieAuth() @ApiCookieAuth()
@ApiResponse({ @ApiOkResponse({
status: 200,
description: 'Токен успешно обновлен', description: 'Токен успешно обновлен',
schema: {
example: {
accessToken: 'jwt-access-token',
expiresIn: 900,
},
},
}) })
@ApiResponse({ status: 401, description: 'Недействительный refresh токен' }) @ApiResponse({ status: 401, description: 'Недействительный refresh токен' })
async refresh( async refresh(
@ -130,21 +170,13 @@ export class AuthController {
const refreshToken = req.cookies?.refresh_token; const refreshToken = req.cookies?.refresh_token;
if (!refreshToken) { if (!refreshToken) {
res.status(HttpStatus.UNAUTHORIZED).json({ throw new UnauthorizedException('Refresh токен не найден');
statusCode: 401,
message: 'Refresh токен не найден',
});
return;
} }
// Decode token to get user ID // Decode token to get user ID
const decoded = this.decodeToken(refreshToken); const decoded = this.decodeToken(refreshToken);
if (!decoded) { if (!decoded) {
res.status(HttpStatus.UNAUTHORIZED).json({ throw new UnauthorizedException('Недействительный токен');
statusCode: 401,
message: 'Недействительный токен',
});
return;
} }
const metadata = { const metadata = {
@ -171,10 +203,14 @@ export class AuthController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: 'Выход из системы' }) @ApiOperation({ summary: 'Выход из системы' })
@ApiResponse({ @ApiOkResponse({
status: 200,
description: 'Успешный выход', description: 'Успешный выход',
type: LogoutResponseDto, type: LogoutResponseDto,
schema: {
example: {
message: 'Выход выполнен успешно',
},
},
}) })
async logout( async logout(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@ -199,10 +235,14 @@ export class AuthController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: 'Выход со всех устройств' }) @ApiOperation({ summary: 'Выход со всех устройств' })
@ApiResponse({ @ApiOkResponse({
status: 200,
description: 'Успешный выход со всех устройств', description: 'Успешный выход со всех устройств',
type: LogoutResponseDto, type: LogoutResponseDto,
schema: {
example: {
message: 'Выход выполнен успешно',
},
},
}) })
async logoutAll( async logoutAll(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@ -266,7 +306,11 @@ export class AuthController {
userAgent: req.get('user-agent'), 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 { return {
id: profile.id, id: profile.id,
email: profile.email, email: profile.email,
@ -312,7 +356,11 @@ export class AuthController {
/** /**
* Set HTTP-only cookies for tokens * 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 isProduction = this.configService.get('app.nodeEnv') === 'production';
const cookieDomain = this.configService.get<string>('jwt.cookieDomain'); const cookieDomain = this.configService.get<string>('jwt.cookieDomain');
@ -331,7 +379,7 @@ export class AuthController {
res.cookie('refresh_token', refreshToken, { res.cookie('refresh_token', refreshToken, {
...commonOptions, ...commonOptions,
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days 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'); const cookieDomain = this.configService.get<string>('jwt.cookieDomain');
res.clearCookie('access_token', { domain: 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 { private decodeToken(token: string): JwtPayload | null {
try { try {
return this.configService.get('jwt.refreshSecret') 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; : null;
} catch { } catch {
return null; return null;

View File

@ -14,13 +14,18 @@ import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiOkResponse,
ApiCreatedResponse,
ApiBearerAuth, ApiBearerAuth,
ApiQuery, ApiQuery,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { BudgetsService } from './budgets.service'; import { BudgetsService } from './budgets.service';
import { CreateBudgetDto, UpdateBudgetDto } from './dto'; import { CreateBudgetDto, UpdateBudgetDto } from './dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; 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)') @ApiTags('Бюджеты (50/30/20)')
@ApiBearerAuth() @ApiBearerAuth()
@ -31,22 +36,73 @@ export class BudgetsController {
@Get() @Get()
@ApiOperation({ summary: 'Получение всех бюджетов пользователя' }) @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) { async findAll(@CurrentUser() user: JwtPayload) {
return this.budgetsService.findAll(user.sub); return this.budgetsService.findAll(user.sub);
} }
@Get('current') @Get('current')
@ApiOperation({ summary: 'Получение бюджета текущего месяца' }) @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) { async findCurrent(@CurrentUser() user: JwtPayload) {
return this.budgetsService.findCurrent(user.sub); return this.budgetsService.findCurrent(user.sub);
} }
@Get('month') @Get('month')
@ApiOperation({ summary: 'Получение бюджета за конкретный месяц' }) @ApiOperation({ summary: 'Получение бюджета за конкретный месяц' })
@ApiQuery({ name: 'month', example: '2024-01-01', description: 'Первый день месяца' }) @ApiQuery({
@ApiResponse({ status: 200, description: 'Бюджет' }) 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: 'Бюджет не найден' }) @ApiResponse({ status: 404, description: 'Бюджет не найден' })
async findByMonth( async findByMonth(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@ -58,7 +114,17 @@ export class BudgetsController {
@Get('progress') @Get('progress')
@ApiOperation({ summary: 'Получение прогресса по бюджету' }) @ApiOperation({ summary: 'Получение прогресса по бюджету' })
@ApiQuery({ name: 'month', example: '2024-01-01' }) @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( async getProgress(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@Query('month') month: string, @Query('month') month: string,
@ -69,19 +135,43 @@ export class BudgetsController {
@Post() @Post()
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Создание бюджета на месяц' }) @ApiOperation({ summary: 'Создание бюджета на месяц' })
@ApiResponse({ status: 201, description: 'Бюджет создан' }) @ApiCreatedResponse({
@ApiResponse({ status: 409, description: 'Бюджет на этот месяц уже существует' }) description: 'Бюджет создан',
async create( schema: {
@CurrentUser() user: JwtPayload, example: {
@Body() dto: CreateBudgetDto, 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); return this.budgetsService.create(user.sub, dto);
} }
@Put() @Put()
@ApiOperation({ summary: 'Обновление бюджета' }) @ApiOperation({ summary: 'Обновление бюджета' })
@ApiQuery({ name: 'month', example: '2024-01-01' }) @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( async update(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@Query('month') month: string, @Query('month') month: string,
@ -94,11 +184,14 @@ export class BudgetsController {
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Удаление бюджета' }) @ApiOperation({ summary: 'Удаление бюджета' })
@ApiQuery({ name: 'month', example: '2024-01-01' }) @ApiQuery({ name: 'month', example: '2024-01-01' })
@ApiResponse({ status: 204, description: 'Бюджет удален' }) @ApiResponse({
async remove( status: 204,
@CurrentUser() user: JwtPayload, description: 'Бюджет удален',
@Query('month') month: string, schema: {
) { example: null,
},
})
async remove(@CurrentUser() user: JwtPayload, @Query('month') month: string) {
await this.budgetsService.remove(user.sub, month); await this.budgetsService.remove(user.sub, month);
} }
} }

View File

@ -15,6 +15,8 @@ import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiOkResponse,
ApiCreatedResponse,
ApiBearerAuth, ApiBearerAuth,
ApiQuery, ApiQuery,
ApiParam, ApiParam,
@ -22,7 +24,10 @@ import {
import { CategoriesService } from './categories.service'; import { CategoriesService } from './categories.service';
import { CreateCategoryDto, UpdateCategoryDto } from './dto'; import { CreateCategoryDto, UpdateCategoryDto } from './dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; 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('Категории') @ApiTags('Категории')
@ApiBearerAuth() @ApiBearerAuth()
@ -39,9 +44,22 @@ export class CategoriesController {
enum: ['INCOME', 'EXPENSE'], enum: ['INCOME', 'EXPENSE'],
description: 'Фильтр по типу категории', description: 'Фильтр по типу категории',
}) })
@ApiResponse({ @ApiOkResponse({
status: 200,
description: 'Список категорий', description: 'Список категорий',
schema: {
example: [
{
id: 'uuid',
nameRu: 'Продукты',
nameEn: 'Groceries',
type: 'EXPENSE',
icon: 'shopping-cart',
color: '#4CAF50',
groupType: 'ESSENTIAL',
isDefault: true,
},
],
},
}) })
async findAll( async findAll(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@ -51,10 +69,39 @@ export class CategoriesController {
} }
@Get('grouped') @Get('grouped')
@ApiOperation({ summary: 'Получение категорий, сгруппированных по типу бюджета' }) @ApiOperation({
@ApiResponse({ summary: 'Получение категорий, сгруппированных по типу бюджета',
status: 200, })
@ApiOkResponse({
description: 'Категории, сгруппированные по ESSENTIAL/PERSONAL/SAVINGS', 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) { async findGrouped(@CurrentUser() user: JwtPayload) {
return this.categoriesService.findByBudgetGroup(user.sub); return this.categoriesService.findByBudgetGroup(user.sub);
@ -63,24 +110,43 @@ export class CategoriesController {
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Получение категории по ID' }) @ApiOperation({ summary: 'Получение категории по ID' })
@ApiParam({ name: 'id', description: 'ID категории' }) @ApiParam({ name: 'id', description: 'ID категории' })
@ApiResponse({ @ApiOkResponse({
status: 200,
description: 'Категория', description: 'Категория',
schema: {
example: {
id: 'uuid',
nameRu: 'Продукты',
nameEn: 'Groceries',
type: 'EXPENSE',
icon: 'shopping-cart',
color: '#4CAF50',
groupType: 'ESSENTIAL',
isDefault: true,
},
},
}) })
@ApiResponse({ status: 404, description: 'Категория не найдена' }) @ApiResponse({ status: 404, description: 'Категория не найдена' })
async findOne( async findOne(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
@CurrentUser() user: JwtPayload,
@Param('id') id: string,
) {
return this.categoriesService.findOne(id, user.sub); return this.categoriesService.findOne(id, user.sub);
} }
@Post() @Post()
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Создание пользовательской категории' }) @ApiOperation({ summary: 'Создание пользовательской категории' })
@ApiResponse({ @ApiCreatedResponse({
status: 201,
description: 'Категория создана', description: 'Категория создана',
schema: {
example: {
id: 'uuid',
nameRu: 'Кофе',
nameEn: 'Coffee',
type: 'EXPENSE',
icon: 'coffee',
color: '#795548',
groupType: 'PERSONAL',
isDefault: false,
},
},
}) })
@ApiResponse({ status: 400, description: 'Некорректные данные' }) @ApiResponse({ status: 400, description: 'Некорректные данные' })
@ApiResponse({ status: 409, description: 'Категория уже существует' }) @ApiResponse({ status: 409, description: 'Категория уже существует' })
@ -94,11 +160,23 @@ export class CategoriesController {
@Put(':id') @Put(':id')
@ApiOperation({ summary: 'Обновление пользовательской категории' }) @ApiOperation({ summary: 'Обновление пользовательской категории' })
@ApiParam({ name: 'id', description: 'ID категории' }) @ApiParam({ name: 'id', description: 'ID категории' })
@ApiResponse({ @ApiOkResponse({
status: 200,
description: 'Категория обновлена', 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: 'Категория не найдена' }) @ApiResponse({ status: 404, description: 'Категория не найдена' })
async update( async update(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@ -116,12 +194,12 @@ export class CategoriesController {
status: 204, status: 204,
description: 'Категория удалена', description: 'Категория удалена',
}) })
@ApiResponse({ status: 403, description: 'Нельзя удалить стандартную категорию' }) @ApiResponse({
status: 403,
description: 'Нельзя удалить стандартную категорию',
})
@ApiResponse({ status: 404, description: 'Категория не найдена' }) @ApiResponse({ status: 404, description: 'Категория не найдена' })
async remove( async remove(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
@CurrentUser() user: JwtPayload,
@Param('id') id: string,
) {
await this.categoriesService.remove(id, user.sub); await this.categoriesService.remove(id, user.sub);
} }
} }

View File

@ -16,6 +16,8 @@ import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiOkResponse,
ApiCreatedResponse,
ApiBearerAuth, ApiBearerAuth,
ApiQuery, ApiQuery,
ApiParam, ApiParam,
@ -24,7 +26,10 @@ import { GoalsService } from './goals.service';
import { CreateGoalDto, UpdateGoalDto, AddFundsDto } from './dto'; import { CreateGoalDto, UpdateGoalDto, AddFundsDto } from './dto';
import { GoalStatus } from './entities/goal.entity'; import { GoalStatus } from './entities/goal.entity';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; 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('Финансовые цели') @ApiTags('Финансовые цели')
@ApiBearerAuth() @ApiBearerAuth()
@ -36,7 +41,25 @@ export class GoalsController {
@Get() @Get()
@ApiOperation({ summary: 'Получение всех целей пользователя' }) @ApiOperation({ summary: 'Получение всех целей пользователя' })
@ApiQuery({ name: 'status', enum: GoalStatus, required: false }) @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( async findAll(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@Query('status') status?: GoalStatus, @Query('status') status?: GoalStatus,
@ -46,7 +69,19 @@ export class GoalsController {
@Get('summary') @Get('summary')
@ApiOperation({ 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) { async getSummary(@CurrentUser() user: JwtPayload) {
return this.goalsService.getSummary(user.sub); return this.goalsService.getSummary(user.sub);
} }
@ -54,7 +89,21 @@ export class GoalsController {
@Get('upcoming') @Get('upcoming')
@ApiOperation({ summary: 'Получение целей с приближающимся дедлайном' }) @ApiOperation({ summary: 'Получение целей с приближающимся дедлайном' })
@ApiQuery({ name: 'days', required: false, example: 30 }) @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( async getUpcoming(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@Query('days') days?: number, @Query('days') days?: number,
@ -65,7 +114,23 @@ export class GoalsController {
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Получение цели по ID' }) @ApiOperation({ summary: 'Получение цели по ID' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' }) @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: 'Цель не найдена' }) @ApiResponse({ status: 404, description: 'Цель не найдена' })
async findOne( async findOne(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@ -77,18 +142,42 @@ export class GoalsController {
@Post() @Post()
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Создание новой цели' }) @ApiOperation({ summary: 'Создание новой цели' })
@ApiResponse({ status: 201, description: 'Цель создана' }) @ApiCreatedResponse({
async create( description: 'Цель создана',
@CurrentUser() user: JwtPayload, schema: {
@Body() dto: CreateGoalDto, 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); return this.goalsService.create(user.sub, dto);
} }
@Put(':id') @Put(':id')
@ApiOperation({ summary: 'Обновление цели' }) @ApiOperation({ summary: 'Обновление цели' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' }) @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( async update(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@ -100,7 +189,16 @@ export class GoalsController {
@Post(':id/add-funds') @Post(':id/add-funds')
@ApiOperation({ summary: 'Добавление средств к цели' }) @ApiOperation({ summary: 'Добавление средств к цели' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' }) @ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiResponse({ status: 200, description: 'Средства добавлены' }) @ApiOkResponse({
description: 'Средства добавлены',
schema: {
example: {
id: 'uuid',
currentAmount: 130000,
message: 'Средства добавлены',
},
},
})
async addFunds( async addFunds(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@ -112,7 +210,16 @@ export class GoalsController {
@Post(':id/withdraw') @Post(':id/withdraw')
@ApiOperation({ summary: 'Снятие средств с цели' }) @ApiOperation({ summary: 'Снятие средств с цели' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' }) @ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiResponse({ status: 200, description: 'Средства сняты' }) @ApiOkResponse({
description: 'Средства сняты',
schema: {
example: {
id: 'uuid',
currentAmount: 110000,
message: 'Средства сняты',
},
},
})
async withdrawFunds( async withdrawFunds(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,

View File

@ -26,6 +26,11 @@ export enum RecommendationStatus {
DISMISSED = 'DISMISSED', DISMISSED = 'DISMISSED',
} }
export enum RecommendationSource {
OPENROUTER = 'openrouter',
MOCK = 'mock',
}
@Entity('recommendations') @Entity('recommendations')
export class Recommendation { export class Recommendation {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
@ -47,16 +52,45 @@ export class Recommendation {
@Column({ name: 'action_text_ru', length: 200, nullable: true }) @Column({ name: 'action_text_ru', length: 200, nullable: true })
actionTextRu: string; 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; 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; 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; potentialSavings: number;
@Column({ type: 'enum', enum: RecommendationStatus, default: RecommendationStatus.NEW }) @Column({
type: 'enum',
enum: RecommendationStatus,
default: RecommendationStatus.NEW,
})
status: RecommendationStatus; status: RecommendationStatus;
@Column({ name: 'action_data', type: 'jsonb', nullable: true }) @Column({ name: 'action_data', type: 'jsonb', nullable: true })

View File

@ -11,6 +11,7 @@ import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiOkResponse,
ApiBearerAuth, ApiBearerAuth,
ApiQuery, ApiQuery,
ApiParam, ApiParam,
@ -18,19 +19,46 @@ import {
import { RecommendationsService } from './recommendations.service'; import { RecommendationsService } from './recommendations.service';
import { RecommendationType } from './entities/recommendation.entity'; import { RecommendationType } from './entities/recommendation.entity';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; 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('Рекомендации') @ApiTags('Рекомендации')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('recommendations') @Controller('recommendations')
export class RecommendationsController { export class RecommendationsController {
constructor(private readonly recommendationsService: RecommendationsService) {} constructor(
private readonly recommendationsService: RecommendationsService,
) {}
@Get() @Get()
@ApiOperation({ summary: 'Получение активных рекомендаций' }) @ApiOperation({ summary: 'Получение активных рекомендаций' })
@ApiQuery({ name: 'type', enum: RecommendationType, required: false }) @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( async findAll(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@Query('type') type?: RecommendationType, @Query('type') type?: RecommendationType,
@ -40,14 +68,45 @@ export class RecommendationsController {
@Get('stats') @Get('stats')
@ApiOperation({ summary: 'Статистика по рекомендациям' }) @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) { async getStats(@CurrentUser() user: JwtPayload) {
return this.recommendationsService.getStats(user.sub); return this.recommendationsService.getStats(user.sub);
} }
@Post('generate') @Post('generate')
@ApiOperation({ summary: 'Генерация новых рекомендаций' }) @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) { async generate(@CurrentUser() user: JwtPayload) {
return this.recommendationsService.generateRecommendations(user.sub); return this.recommendationsService.generateRecommendations(user.sub);
} }
@ -55,7 +114,24 @@ export class RecommendationsController {
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Получение рекомендации по ID' }) @ApiOperation({ summary: 'Получение рекомендации по ID' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' }) @ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiResponse({ status: 200, description: 'Рекомендация' }) @ApiOkResponse({
description: 'Рекомендация',
schema: {
example: {
id: 'uuid',
userId: 'uuid',
type: 'SAVING',
titleRu: 'Резервный фонд',
descriptionRu: 'Создайте резервный фонд 36 месячных расходов.',
priorityScore: 0.85,
confidenceScore: 0.9,
source: 'mock',
status: 'NEW',
actionData: { targetAmount: 480000 },
createdAt: '2024-01-15T00:00:00.000Z',
},
},
})
async findOne( async findOne(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@ -66,7 +142,16 @@ export class RecommendationsController {
@Post(':id/view') @Post(':id/view')
@ApiOperation({ summary: 'Отметить рекомендацию как просмотренную' }) @ApiOperation({ summary: 'Отметить рекомендацию как просмотренную' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' }) @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( async markAsViewed(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@ -77,7 +162,16 @@ export class RecommendationsController {
@Post(':id/apply') @Post(':id/apply')
@ApiOperation({ summary: 'Отметить рекомендацию как примененную' }) @ApiOperation({ summary: 'Отметить рекомендацию как примененную' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' }) @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( async markAsApplied(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@ -88,7 +182,15 @@ export class RecommendationsController {
@Post(':id/dismiss') @Post(':id/dismiss')
@ApiOperation({ summary: 'Отклонить рекомендацию' }) @ApiOperation({ summary: 'Отклонить рекомендацию' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' }) @ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiResponse({ status: 200, description: 'Рекомендация отклонена' }) @ApiOkResponse({
description: 'Рекомендация отклонена',
schema: {
example: {
id: 'uuid',
status: 'DISMISSED',
},
},
})
async dismiss( async dismiss(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,

View File

@ -1,7 +1,12 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThan, MoreThan } from '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 { AiService } from '../ai/ai.service';
import { TransactionsService } from '../transactions/transactions.service'; import { TransactionsService } from '../transactions/transactions.service';
import { BudgetsService } from '../budgets/budgets.service'; import { BudgetsService } from '../budgets/budgets.service';
@ -21,7 +26,10 @@ export class RecommendationsService {
/** /**
* Get all active recommendations for a user * 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 = { const where: any = {
userId, userId,
status: RecommendationStatus.NEW, status: RecommendationStatus.NEW,
@ -55,7 +63,7 @@ export class RecommendationsService {
*/ */
async markAsViewed(id: string, userId: string): Promise<Recommendation> { async markAsViewed(id: string, userId: string): Promise<Recommendation> {
const recommendation = await this.findOne(id, userId); const recommendation = await this.findOne(id, userId);
if (recommendation.status === RecommendationStatus.NEW) { if (recommendation.status === RecommendationStatus.NEW) {
recommendation.status = RecommendationStatus.VIEWED; recommendation.status = RecommendationStatus.VIEWED;
recommendation.viewedAt = new Date(); recommendation.viewedAt = new Date();
@ -70,10 +78,10 @@ export class RecommendationsService {
*/ */
async markAsApplied(id: string, userId: string): Promise<Recommendation> { async markAsApplied(id: string, userId: string): Promise<Recommendation> {
const recommendation = await this.findOne(id, userId); const recommendation = await this.findOne(id, userId);
recommendation.status = RecommendationStatus.APPLIED; recommendation.status = RecommendationStatus.APPLIED;
recommendation.appliedAt = new Date(); recommendation.appliedAt = new Date();
return this.recommendationRepository.save(recommendation); return this.recommendationRepository.save(recommendation);
} }
@ -82,9 +90,9 @@ export class RecommendationsService {
*/ */
async dismiss(id: string, userId: string): Promise<Recommendation> { async dismiss(id: string, userId: string): Promise<Recommendation> {
const recommendation = await this.findOne(id, userId); const recommendation = await this.findOne(id, userId);
recommendation.status = RecommendationStatus.DISMISSED; recommendation.status = RecommendationStatus.DISMISSED;
return this.recommendationRepository.save(recommendation); return this.recommendationRepository.save(recommendation);
} }
@ -101,22 +109,24 @@ export class RecommendationsService {
// Calculate context for AI // Calculate context for AI
const totalExpenses = transactions.data const totalExpenses = transactions.data
.filter(t => t.type === 'EXPENSE') .filter((t) => t.type === 'EXPENSE')
.reduce((sum, t) => sum + Number(t.amount), 0); .reduce((sum, t) => sum + Number(t.amount), 0);
const totalIncome = transactions.data const totalIncome = transactions.data
.filter(t => t.type === 'INCOME') .filter((t) => t.type === 'INCOME')
.reduce((sum, t) => sum + Number(t.amount), 0); .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 // Get top spending categories
const categorySpending: Record<string, number> = {}; const categorySpending: Record<string, number> = {};
transactions.data transactions.data
.filter(t => t.type === 'EXPENSE') .filter((t) => t.type === 'EXPENSE')
.forEach(t => { .forEach((t) => {
const catName = t.category?.nameRu || 'Другое'; 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) const topCategories = Object.entries(categorySpending)
@ -125,12 +135,15 @@ export class RecommendationsService {
.map(([name]) => name); .map(([name]) => name);
// Generate AI recommendations // Generate AI recommendations
const aiRecommendations = await this.aiService.generateRecommendations(userId, { const aiRecommendations = await this.aiService.generateRecommendations(
monthlyIncome: totalIncome, userId,
totalExpenses, {
savingsRate, monthlyIncome: totalIncome,
topCategories, totalExpenses,
}); savingsRate,
topCategories,
},
);
// Save recommendations to database // Save recommendations to database
const recommendations: Recommendation[] = []; const recommendations: Recommendation[] = [];
@ -153,17 +166,26 @@ export class RecommendationsService {
descriptionRu: rec.descriptionRu, descriptionRu: rec.descriptionRu,
priorityScore: rec.priorityScore, priorityScore: rec.priorityScore,
confidenceScore: rec.confidenceScore, confidenceScore: rec.confidenceScore,
source:
rec.source === 'openrouter'
? RecommendationSource.OPENROUTER
: RecommendationSource.MOCK,
actionData: rec.actionData, actionData: rec.actionData,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days 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 // Add budget-based recommendations
if (currentBudget) { if (currentBudget) {
const budgetRecommendations = this.generateBudgetRecommendations(currentBudget, userId); const budgetRecommendations = this.generateBudgetRecommendations(
currentBudget,
userId,
);
for (const rec of budgetRecommendations) { for (const rec of budgetRecommendations) {
recommendations.push(await this.recommendationRepository.save(rec)); recommendations.push(await this.recommendationRepository.save(rec));
} }
@ -171,7 +193,10 @@ export class RecommendationsService {
// Add goal-based recommendations // Add goal-based recommendations
if (goalsSummary.activeGoals > 0) { if (goalsSummary.activeGoals > 0) {
const goalRecommendations = this.generateGoalRecommendations(goalsSummary, userId); const goalRecommendations = this.generateGoalRecommendations(
goalsSummary,
userId,
);
for (const rec of goalRecommendations) { for (const rec of goalRecommendations) {
recommendations.push(await this.recommendationRepository.save(rec)); recommendations.push(await this.recommendationRepository.save(rec));
} }
@ -183,35 +208,50 @@ export class RecommendationsService {
/** /**
* Generate budget-based recommendations * Generate budget-based recommendations
*/ */
private generateBudgetRecommendations(budget: any, userId: string): Recommendation[] { private generateBudgetRecommendations(
budget: any,
userId: string,
): Recommendation[] {
const recommendations: Recommendation[] = []; const recommendations: Recommendation[] = [];
// Check if overspending on essentials // 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) { if (essentialsPercent > 90) {
recommendations.push(this.recommendationRepository.create({ recommendations.push(
userId, this.recommendationRepository.create({
type: RecommendationType.BUDGET, userId,
titleRu: 'Превышение лимита на необходимое', type: RecommendationType.BUDGET,
descriptionRu: `Вы израсходовали ${essentialsPercent.toFixed(0)}% бюджета на необходимые расходы. Рекомендуем пересмотреть траты или увеличить лимит.`, titleRu: 'Превышение лимита на необходимое',
priorityScore: 0.9, descriptionRu: `Вы израсходовали ${essentialsPercent.toFixed(0)}% бюджета на необходимые расходы. Рекомендуем пересмотреть траты или увеличить лимит.`,
confidenceScore: 0.95, priorityScore: 0.9,
potentialSavings: Number(budget.essentialsSpent) - Number(budget.essentialsLimit), confidenceScore: 0.95,
})); source: RecommendationSource.MOCK,
potentialSavings:
Number(budget.essentialsSpent) - Number(budget.essentialsLimit),
}),
);
} }
// Check if underspending on savings // 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) { if (savingsPercent < 50 && new Date().getDate() > 15) {
recommendations.push(this.recommendationRepository.create({ recommendations.push(
userId, this.recommendationRepository.create({
type: RecommendationType.SAVING, userId,
titleRu: 'Увеличьте накопления', type: RecommendationType.SAVING,
descriptionRu: `Вы накопили только ${savingsPercent.toFixed(0)}% от запланированного. До конца месяца осталось время - переведите средства на накопления.`, titleRu: 'Увеличьте накопления',
priorityScore: 0.8, descriptionRu: `Вы накопили только ${savingsPercent.toFixed(0)}% от запланированного. До конца месяца осталось время - переведите средства на накопления.`,
confidenceScore: 0.9, priorityScore: 0.8,
actionData: { targetAmount: Number(budget.savingsLimit) - Number(budget.savingsSpent) }, confidenceScore: 0.9,
})); source: RecommendationSource.MOCK,
actionData: {
targetAmount:
Number(budget.savingsLimit) - Number(budget.savingsSpent),
},
}),
);
} }
return recommendations; return recommendations;
@ -220,22 +260,28 @@ export class RecommendationsService {
/** /**
* Generate goal-based recommendations * Generate goal-based recommendations
*/ */
private generateGoalRecommendations(summary: any, userId: string): Recommendation[] { private generateGoalRecommendations(
summary: any,
userId: string,
): Recommendation[] {
const recommendations: Recommendation[] = []; const recommendations: Recommendation[] = [];
if (summary.overallProgress < 30 && summary.activeGoals > 0) { if (summary.overallProgress < 30 && summary.activeGoals > 0) {
recommendations.push(this.recommendationRepository.create({ recommendations.push(
userId, this.recommendationRepository.create({
type: RecommendationType.GOAL, userId,
titleRu: 'Ускорьте достижение целей', type: RecommendationType.GOAL,
descriptionRu: `Общий прогресс по вашим целям составляет ${summary.overallProgress.toFixed(0)}%. Рассмотрите возможность увеличения регулярных отчислений.`, titleRu: 'Ускорьте достижение целей',
priorityScore: 0.7, descriptionRu: `Общий прогресс по вашим целям составляет ${summary.overallProgress.toFixed(0)}%. Рассмотрите возможность увеличения регулярных отчислений.`,
confidenceScore: 0.85, priorityScore: 0.7,
actionData: { confidenceScore: 0.85,
currentProgress: summary.overallProgress, source: RecommendationSource.MOCK,
activeGoals: summary.activeGoals, actionData: {
}, currentProgress: summary.overallProgress,
})); activeGoals: summary.activeGoals,
},
}),
);
} }
return recommendations; return recommendations;
@ -270,12 +316,22 @@ export class RecommendationsService {
return { return {
total: recommendations.length, total: recommendations.length,
new: recommendations.filter(r => r.status === RecommendationStatus.NEW).length, new: recommendations.filter((r) => r.status === RecommendationStatus.NEW)
viewed: recommendations.filter(r => r.status === RecommendationStatus.VIEWED).length, .length,
applied: recommendations.filter(r => r.status === RecommendationStatus.APPLIED).length, viewed: recommendations.filter(
dismissed: recommendations.filter(r => r.status === RecommendationStatus.DISMISSED).length, (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 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), .reduce((sum, r) => sum + Number(r.potentialSavings || 0), 0),
}; };
} }

View File

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

View 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;
}

View File

@ -15,27 +15,56 @@ import {
ApiTags, ApiTags,
ApiOperation, ApiOperation,
ApiResponse, ApiResponse,
ApiOkResponse,
ApiCreatedResponse,
ApiBearerAuth, ApiBearerAuth,
ApiParam, ApiParam,
ApiQuery, ApiQuery,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { TransactionsService } from './transactions.service'; 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 { 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('Транзакции') @ApiTags('Транзакции')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('transactions') @Controller('transactions')
export class TransactionsController { export class TransactionsController {
constructor(private readonly transactionsService: TransactionsService) {} constructor(
private readonly transactionsService: TransactionsService,
private readonly aiService: AiService,
) {}
@Get() @Get()
@ApiOperation({ summary: 'Получение списка транзакций' }) @ApiOperation({ summary: 'Получение списка транзакций' })
@ApiResponse({ @ApiOkResponse({
status: 200,
description: 'Список транзакций с пагинацией', 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( async findAll(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@ -48,9 +77,16 @@ export class TransactionsController {
@ApiOperation({ summary: 'Получение сводки по транзакциям за период' }) @ApiOperation({ summary: 'Получение сводки по транзакциям за период' })
@ApiQuery({ name: 'startDate', required: true, example: '2024-01-01' }) @ApiQuery({ name: 'startDate', required: true, example: '2024-01-01' })
@ApiQuery({ name: 'endDate', required: true, example: '2024-01-31' }) @ApiQuery({ name: 'endDate', required: true, example: '2024-01-31' })
@ApiResponse({ @ApiOkResponse({
status: 200,
description: 'Сводка: доходы, расходы, баланс', description: 'Сводка: доходы, расходы, баланс',
schema: {
example: {
totalIncome: 120000,
totalExpense: 85000,
balance: 35000,
transactionCount: 42,
},
},
}) })
async getSummary( async getSummary(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@ -64,39 +100,69 @@ export class TransactionsController {
@ApiOperation({ summary: 'Получение расходов по категориям' }) @ApiOperation({ summary: 'Получение расходов по категориям' })
@ApiQuery({ name: 'startDate', required: true, example: '2024-01-01' }) @ApiQuery({ name: 'startDate', required: true, example: '2024-01-01' })
@ApiQuery({ name: 'endDate', required: true, example: '2024-01-31' }) @ApiQuery({ name: 'endDate', required: true, example: '2024-01-31' })
@ApiResponse({ @ApiOkResponse({
status: 200,
description: 'Расходы, сгруппированные по категориям', description: 'Расходы, сгруппированные по категориям',
schema: {
example: [
{
categoryId: 'uuid',
categoryName: 'Продукты',
total: 22000,
percentage: 25.88,
},
],
},
}) })
async getByCategory( async getByCategory(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@Query('startDate') startDate: string, @Query('startDate') startDate: string,
@Query('endDate') endDate: string, @Query('endDate') endDate: string,
) { ) {
return this.transactionsService.getSpendingByCategory(user.sub, startDate, endDate); return this.transactionsService.getSpendingByCategory(
user.sub,
startDate,
endDate,
);
} }
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Получение транзакции по ID' }) @ApiOperation({ summary: 'Получение транзакции по ID' })
@ApiParam({ name: 'id', description: 'ID транзакции' }) @ApiParam({ name: 'id', description: 'ID транзакции' })
@ApiResponse({ @ApiOkResponse({
status: 200,
description: 'Транзакция', 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: 'Транзакция не найдена' }) @ApiResponse({ status: 404, description: 'Транзакция не найдена' })
async findOne( async findOne(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
@CurrentUser() user: JwtPayload,
@Param('id') id: string,
) {
return this.transactionsService.findOne(id, user.sub); return this.transactionsService.findOne(id, user.sub);
} }
@Post() @Post()
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Создание транзакции' }) @ApiOperation({ summary: 'Создание транзакции' })
@ApiResponse({ @ApiCreatedResponse({
status: 201,
description: 'Транзакция создана', 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: 'Некорректные данные' }) @ApiResponse({ status: 400, description: 'Некорректные данные' })
async create( async create(
@ -106,12 +172,51 @@ export class TransactionsController {
return this.transactionsService.create(user.sub, dto); 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') @Post('bulk')
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Массовое создание транзакций (импорт)' }) @ApiOperation({ summary: 'Массовое создание транзакций (импорт)' })
@ApiResponse({ @ApiCreatedResponse({
status: 201,
description: 'Транзакции созданы', description: 'Транзакции созданы',
schema: {
example: [
{
id: 'uuid',
amount: 1500.5,
type: 'EXPENSE',
categoryId: 'uuid',
transactionDate: '2024-01-15T00:00:00.000Z',
description: 'Покупка продуктов',
paymentMethod: 'CARD',
},
],
},
}) })
async bulkCreate( async bulkCreate(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@ -123,9 +228,19 @@ export class TransactionsController {
@Put(':id') @Put(':id')
@ApiOperation({ summary: 'Обновление транзакции' }) @ApiOperation({ summary: 'Обновление транзакции' })
@ApiParam({ name: 'id', description: 'ID транзакции' }) @ApiParam({ name: 'id', description: 'ID транзакции' })
@ApiResponse({ @ApiOkResponse({
status: 200,
description: 'Транзакция обновлена', 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: 'Транзакция не найдена' }) @ApiResponse({ status: 404, description: 'Транзакция не найдена' })
async update( async update(
@ -145,10 +260,7 @@ export class TransactionsController {
description: 'Транзакция удалена', description: 'Транзакция удалена',
}) })
@ApiResponse({ status: 404, description: 'Транзакция не найдена' }) @ApiResponse({ status: 404, description: 'Транзакция не найдена' })
async remove( async remove(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
@CurrentUser() user: JwtPayload,
@Param('id') id: string,
) {
await this.transactionsService.remove(id, user.sub); await this.transactionsService.remove(id, user.sub);
} }
} }

View File

@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { TransactionsController } from './transactions.controller'; import { TransactionsController } from './transactions.controller';
import { TransactionsService } from './transactions.service'; import { TransactionsService } from './transactions.service';
import { Transaction } from './entities/transaction.entity'; import { Transaction } from './entities/transaction.entity';
import { AiModule } from '../ai/ai.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Transaction])], imports: [TypeOrmModule.forFeature([Transaction]), AiModule],
controllers: [TransactionsController], controllers: [TransactionsController],
providers: [TransactionsService], providers: [TransactionsService],
exports: [TransactionsService], exports: [TransactionsService],