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)
DEEPSEEK_API_KEY=
OPENROUTER_API_KEY=
AI_SERVICE_URL=http://localhost:8000
AI_ENABLED=false
AI_ENABLED=true
OPENROUTER_API_KEY=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
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
OPENROUTER_MODEL=openai/gpt-oss-20b:free
OPENROUTER_TIMEOUT_MS=20000
# Logging
LOG_LEVEL=debug

View File

@ -33,6 +33,9 @@ LOCKOUT_DURATION_MINUTES=30
# AI Integration (Phase 2 - DeepSeek via OpenRouter)
DEEPSEEK_API_KEY=
OPENROUTER_API_KEY=
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
OPENROUTER_MODEL=openai/gpt-oss-120b:free
OPENROUTER_TIMEOUT_MS=20000
AI_SERVICE_URL=http://localhost:8000
AI_ENABLED=false

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
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# .env
# .env.development.local
# .env.test.local
# .env.production.local
# .env.local
# temp directory
.temp

View File

@ -1,28 +1,6 @@
stages:
- build
- deploy
variables:
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
IMAGE_TAG_LATEST: $CI_REGISTRY_IMAGE:latest
build_and_push:
stage: build
image: docker:29
services:
- name: docker:29-dind
command: ["--tls=false"]
variables:
DOCKER_TLS_CERTDIR: ""
DOCKER_HOST: tcp://docker:2375
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- docker build --target production -t "$IMAGE_TAG" -t "$IMAGE_TAG_LATEST" .
- docker push "$IMAGE_TAG"
- docker push "$IMAGE_TAG_LATEST"
deploy_production:
stage: deploy
image: alpine:3.20
@ -30,7 +8,7 @@ deploy_production:
name: production
url: https://api-finance.ai-assistant-bot.xyz
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
when: manual
allow_failure: false
before_script:
@ -41,14 +19,5 @@ deploy_production:
- ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
script:
- ssh "$DEPLOY_USER@$DEPLOY_HOST" "mkdir -p /opt/apps/api-finance"
- rsync -az --delete \
--exclude='.git' \
--exclude='.env' \
--exclude='.env.*' \
--exclude='node_modules' \
--exclude='coverage' \
--exclude='dist' \
./ "$DEPLOY_USER@$DEPLOY_HOST:/opt/apps/api-finance/"
- ssh "$DEPLOY_USER@$DEPLOY_HOST" "docker login -u '$CI_REGISTRY_USER' -p '$CI_REGISTRY_PASSWORD' '$CI_REGISTRY'"
- ssh "$DEPLOY_USER@$DEPLOY_HOST" "cd /opt/apps/api-finance && APP_IMAGE='$IMAGE_TAG' docker compose -f docker-compose.server.yml pull"
- ssh "$DEPLOY_USER@$DEPLOY_HOST" "cd /opt/apps/api-finance && APP_IMAGE='$IMAGE_TAG' docker compose -f docker-compose.server.yml up -d"
- rsync -az --delete --exclude='.git' --exclude='.env' --exclude='.env.*' --exclude='node_modules' --exclude='coverage' --exclude='dist' ./ "$DEPLOY_USER@$DEPLOY_HOST:/opt/apps/api-finance/"
- ssh "$DEPLOY_USER@$DEPLOY_HOST" "cd /opt/apps/api-finance && docker compose -f docker-compose.server.yml up -d --build"

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

View File

@ -1,48 +1,28 @@
version: '3.8'
services:
postgres:
image: postgres:14-alpine
container_name: api_finance_postgres
environment:
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
volumes:
- api_finance_postgres_data:/var/lib/postgresql/data
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_NAME}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- api_finance_internal
restart: unless-stopped
app:
image: ${APP_IMAGE}
build:
context: .
dockerfile: Dockerfile
target: production
container_name: api_finance_app
environment:
NODE_ENV: production
DB_HOST: postgres
DB_HOST: shared_postgres
DB_PORT: 5432
DB_USERNAME: ${DB_USERNAME}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME}
JWT_SECRET: ${JWT_SECRET}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
FRONTEND_URL: ${FRONTEND_URL}
COOKIE_DOMAIN: ${COOKIE_DOMAIN}
COOKIE_SECURE: ${COOKIE_SECURE}
CORS_ORIGINS: ${CORS_ORIGINS}
DB_USERNAME: finance_user
DB_PASSWORD: SecurePassword123
DB_NAME: finance_app
JWT_SECRET: your_jwt_secret_key_here_minimum_32_characters_long
JWT_REFRESH_SECRET: your_refresh_secret_key_here_minimum_32_characters_long
FRONTEND_URL: https://your-frontend.ai-assistant-bot.xyz
COOKIE_DOMAIN: ai-assistant-bot.xyz
COOKIE_SECURE: "true"
CORS_ORIGINS: https://your-frontend.ai-assistant-bot.xyz
PORT: 3000
depends_on:
postgres:
condition: service_healthy
networks:
- proxy
- api_finance_internal
restart: unless-stopped
labels:
- traefik.enable=true
@ -52,11 +32,6 @@ services:
- traefik.http.routers.api-finance.tls.certresolver=le
- traefik.http.services.api-finance.loadbalancer.server.port=3000
volumes:
api_finance_postgres_data:
networks:
proxy:
external: true
api_finance_internal:
driver: bridge

43
package-lock.json generated
View File

@ -18,6 +18,7 @@
"@nestjs/swagger": "^7.4.0",
"@nestjs/throttler": "^5.1.2",
"@nestjs/typeorm": "^10.0.2",
"axios": "^1.6.8",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
@ -4640,7 +4641,6 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/available-typed-arrays": {
@ -4658,6 +4658,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/b4a": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz",
@ -5490,7 +5501,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@ -5874,7 +5884,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@ -6124,7 +6133,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -6924,6 +6932,26 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@ -7029,7 +7057,6 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@ -10162,6 +10189,12 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View File

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

41
scripts/run-migrations.js Normal file
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 request = ctx.getRequest<Request>();
if (response.headersSent) {
return;
}
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Внутренняя ошибка сервера';
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
message = typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as any).message || message;
message =
typeof exceptionResponse === 'string'
? exceptionResponse
: (exceptionResponse as any).message || message;
} else if (exception instanceof Error) {
// Log the actual error for debugging
this.logger.error(

View File

@ -3,6 +3,13 @@ import { registerAs } from '@nestjs/config';
export default registerAs('ai', () => ({
deepseekApiKey: process.env.DEEPSEEK_API_KEY || '',
openrouterApiKey: process.env.OPENROUTER_API_KEY || '',
openrouterBaseUrl:
process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1',
openrouterModel: process.env.OPENROUTER_MODEL || 'openai/gpt-oss-120b:free',
openrouterTimeoutMs: parseInt(
process.env.OPENROUTER_TIMEOUT_MS || '20000',
10,
),
serviceUrl: process.env.AI_SERVICE_URL || 'http://localhost:8000',
enabled: process.env.AI_ENABLED === 'true',
}));

View File

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

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 { ConfigService } from '@nestjs/config';
import axios, { AxiosInstance } from 'axios';
/**
* AI Service Placeholder for DeepSeek Integration via OpenRouter
@ -17,6 +18,7 @@ export interface TransactionCategorizationResponse {
suggestedCategoryName: string;
confidence: number;
reasoning: string;
source?: 'openrouter' | 'mock';
}
export interface SpendingAnalysisRequest {
@ -38,18 +40,56 @@ export interface SpendingAnalysisResponse {
}>;
insights: string[];
recommendations: string[];
source?: 'openrouter' | 'mock';
}
export interface FinancialRecommendation {
id: string;
type: 'SAVING' | 'SPENDING' | 'INVESTMENT' | 'TAX' | 'DEBT';
type:
| 'SAVING'
| 'SPENDING'
| 'INVESTMENT'
| 'TAX'
| 'DEBT'
| 'BUDGET'
| 'GOAL';
titleRu: string;
descriptionRu: string;
priorityScore: number;
confidenceScore: number;
actionData?: Record<string, any>;
source?: 'openrouter' | 'mock';
}
export interface AnalyticsNarrativeRequest {
period: string;
totals: {
income: number;
expenses: number;
netSavings: number;
savingsRate: number;
};
topCategories: Array<{ name: string; amount: number }>;
}
export interface AnalyticsNarrativeResponse {
summaryRu: string;
insightsRu: string[];
actionsRu: string[];
source?: 'openrouter' | 'mock';
}
type OpenRouterChatMessage = {
role: 'system' | 'user' | 'assistant';
content: string;
};
type OpenRouterChatCompletionResponse = {
choices?: Array<{
message?: { content?: string };
}>;
};
export interface ForecastRequest {
userId: string;
historicalData: Array<{
@ -78,13 +118,45 @@ export interface ForecastResponse {
export class AiService {
private readonly logger = new Logger(AiService.name);
private readonly isEnabled: boolean;
private readonly http: AxiosInstance;
private readonly openrouterApiKey: string;
private readonly openrouterBaseUrl: string;
private readonly openrouterModel: string;
private readonly openrouterTimeoutMs: number;
constructor(private configService: ConfigService) {
this.isEnabled = this.configService.get<boolean>('ai.enabled') || false;
this.openrouterApiKey =
this.configService.get<string>('ai.openrouterApiKey') || '';
this.openrouterBaseUrl =
this.configService.get<string>('ai.openrouterBaseUrl') ||
'https://openrouter.ai/api/v1';
this.openrouterModel =
this.configService.get<string>('ai.openrouterModel') ||
'openai/gpt-oss-120b:free';
this.openrouterTimeoutMs =
this.configService.get<number>('ai.openrouterTimeoutMs') || 20000;
if (!this.isEnabled) {
this.logger.warn('AI Service is disabled. Using mock implementations.');
const enabledFlag = this.configService.get<boolean>('ai.enabled') || false;
this.isEnabled = enabledFlag && !!this.openrouterApiKey;
if (!enabledFlag) {
this.logger.warn(
'AI Service is disabled (AI_ENABLED=false). Using mock implementations.',
);
} else if (!this.openrouterApiKey) {
this.logger.warn(
'AI Service enabled but OPENROUTER_API_KEY is missing. Using mock implementations.',
);
}
this.http = axios.create({
baseURL: this.openrouterBaseUrl,
timeout: this.openrouterTimeoutMs,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.openrouterApiKey}`,
},
});
}
/**
@ -100,9 +172,38 @@ export class AiService {
return this.mockCategorizeTransaction(request);
}
// TODO: Implement DeepSeek API call via OpenRouter
// const response = await this.callDeepSeek('categorize', request);
return this.mockCategorizeTransaction(request);
try {
const messages: OpenRouterChatMessage[] = [
{
role: 'system',
content:
'Ты финансовый ассистент. Подбери категорию транзакции. Верни ОДНУ строку валидного JSON (без markdown, без пояснений, без переносов строк). ВАЖНО: внутри строк обязательно экранируй кавычки и переносы (используй \\" и \\n). Не ставь запятых после последнего поля. Держи строки короткими (до 200 символов). Формат строго: {"suggestedCategoryName":"...","confidence":0.0,"reasoning":"..."}',
},
{
role: 'user',
content: JSON.stringify(request),
},
];
const json = await this.callOpenRouterJson<{
suggestedCategoryName: string;
confidence: number;
reasoning: string;
}>(messages);
return {
suggestedCategoryId: 'unknown',
suggestedCategoryName: json.suggestedCategoryName || 'Другое',
confidence: this.clamp01(json.confidence ?? 0.5),
reasoning: json.reasoning || 'AI suggested category',
source: 'openrouter',
};
} catch (e) {
this.logger.warn(
`OpenRouter categorizeTransaction failed, using mock. Reason: ${(e as Error)?.message}`,
);
return this.mockCategorizeTransaction(request);
}
}
/**
@ -118,8 +219,39 @@ export class AiService {
return this.mockAnalyzeSpending(request);
}
// TODO: Implement DeepSeek API call via OpenRouter
return this.mockAnalyzeSpending(request);
try {
const safePayload = {
userId: request.userId,
startDate: request.startDate,
endDate: request.endDate,
transactions: request.transactions.slice(0, 200),
};
const messages: OpenRouterChatMessage[] = [
{
role: 'system',
content:
'Ты финансовый аналитик. Проанализируй траты и верни ОДНУ строку валидного JSON (без markdown, без пояснений, без переносов строк). ВАЖНО: экранируй кавычки и переносы внутри строк (\\" и \\n), без лишних запятых. Держи массивы короткими (patterns<=5, insights<=5, recommendations<=5), строки до 200 символов. Формат строго: {"patterns":[{"pattern":"...","description":"...","impact":"positive|negative|neutral"}],"insights":["..."],"recommendations":["..."]}',
},
{ role: 'user', content: JSON.stringify(safePayload) },
];
const json =
await this.callOpenRouterJson<SpendingAnalysisResponse>(messages);
return {
patterns: Array.isArray(json.patterns) ? json.patterns : [],
insights: Array.isArray(json.insights) ? json.insights : [],
recommendations: Array.isArray(json.recommendations)
? json.recommendations
: [],
source: 'openrouter',
};
} catch (e) {
this.logger.warn(
`OpenRouter analyzeSpending failed, using mock. Reason: ${(e as Error)?.message}`,
);
return this.mockAnalyzeSpending(request);
}
}
/**
@ -141,8 +273,49 @@ export class AiService {
return this.mockGenerateRecommendations(context);
}
// TODO: Implement DeepSeek API call via OpenRouter
return this.mockGenerateRecommendations(context);
try {
const messages: OpenRouterChatMessage[] = [
{
role: 'system',
content:
'Ты персональный финансовый ассистент. Сгенерируй 3-5 рекомендаций на русском. Верни ОДНУ строку валидного JSON (без markdown, без пояснений, без переносов строк). ВАЖНО: экранируй кавычки и переносы внутри строк (\\" и \\n), без лишних запятых. titleRu<=120 символов, descriptionRu<=300 символов. priorityScore/confidenceScore: 0.0..1.0. actionData должен быть объектом (может быть пустым {}). Формат строго: {"recommendations":[{"id":"...","type":"SAVING|SPENDING|INVESTMENT|TAX|DEBT|BUDGET|GOAL","titleRu":"...","descriptionRu":"...","priorityScore":0.0,"confidenceScore":0.0,"actionData":{}}]}',
},
{
role: 'user',
content: JSON.stringify({ userId, context }),
},
];
const json = await this.callOpenRouterJson<{
recommendations: FinancialRecommendation[];
}>(messages);
const recs = Array.isArray(json.recommendations)
? json.recommendations
: [];
const normalized = recs
.slice(0, 8)
.map((r) => ({
id: r.id || `ai-${Date.now()}`,
type: r.type,
titleRu: r.titleRu,
descriptionRu: r.descriptionRu,
priorityScore: this.clamp01(Number(r.priorityScore ?? 0.5)),
confidenceScore: this.clamp01(Number(r.confidenceScore ?? 0.5)),
actionData: r.actionData,
source: 'openrouter' as const,
}))
.filter((r) => !!r.titleRu && !!r.descriptionRu);
return normalized.length > 0
? normalized
: this.mockGenerateRecommendations(context);
} catch (e) {
this.logger.warn(
`OpenRouter generateRecommendations failed, using mock. Reason: ${(e as Error)?.message}`,
);
return this.mockGenerateRecommendations(context);
}
}
/**
@ -156,10 +329,238 @@ export class AiService {
return this.mockForecastFinances(request);
}
// TODO: Implement DeepSeek API call via OpenRouter
// Forecasting can be expensive; keep mock fallback for now.
return this.mockForecastFinances(request);
}
/**
* Generate an AI narrative for analytics dashboards (C option)
* Returns mock-like output when AI is disabled or fails.
*/
async generateAnalyticsNarrative(
request: AnalyticsNarrativeRequest,
): Promise<AnalyticsNarrativeResponse> {
if (!this.isEnabled) {
return {
summaryRu: 'Краткий обзор недоступен (AI отключен).',
insightsRu: [],
actionsRu: [],
source: 'mock',
};
}
try {
const safePayload = {
...request,
topCategories: request.topCategories.slice(0, 8),
};
const messages: OpenRouterChatMessage[] = [
{
role: 'system',
content:
'Ты финансовый аналитик. Сформируй краткий обзор по метрикам. Верни ОДНУ строку валидного JSON (без markdown, без пояснений, без переносов строк). ВАЖНО: экранируй кавычки и переносы внутри строк (\\" и \\n), без лишних запятых. summaryRu<=300 символов, insightsRu<=5, actionsRu<=5, строки до 200 символов. Формат строго: {"summaryRu":"...","insightsRu":["..."],"actionsRu":["..."]}',
},
{ role: 'user', content: JSON.stringify(safePayload) },
];
const json =
await this.callOpenRouterJson<AnalyticsNarrativeResponse>(messages);
return {
summaryRu: json.summaryRu || 'Обзор недоступен',
insightsRu: Array.isArray(json.insightsRu) ? json.insightsRu : [],
actionsRu: Array.isArray(json.actionsRu) ? json.actionsRu : [],
source: 'openrouter',
};
} catch (e) {
this.logger.warn(
`OpenRouter generateAnalyticsNarrative failed. Reason: ${(e as Error)?.message}`,
);
return {
summaryRu: 'Не удалось получить AI-обзор. Попробуйте позже.',
insightsRu: [],
actionsRu: [],
source: 'mock',
};
}
}
private clamp01(value: number): number {
if (Number.isNaN(value)) return 0.5;
return Math.max(0, Math.min(1, value));
}
private async callOpenRouterJson<T>(
messages: OpenRouterChatMessage[],
): Promise<T> {
const model = this.openrouterModel;
const maxAttempts = 2;
let lastError: unknown;
let repairHintAdded = false;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const attemptMessages = repairHintAdded
? [
...messages,
{
role: 'user',
content:
'Твой предыдущий ответ был НЕВАЛИДНЫМ JSON (ошибка парсинга). Верни снова ответ ТОЛЬКО как валидный JSON (без markdown, без пояснений, без лишнего текста).',
},
]
: messages;
// Keep response size controlled (reduce truncation risk for strict JSON)
const payload = {
model,
messages: attemptMessages,
temperature: 0,
max_tokens: 500,
// Some OpenRouter providers support OpenAI-style JSON enforcement.
// If unsupported, it should be ignored; we still validate/repair/fallback.
response_format: { type: 'json_object' },
};
const resp = await this.http.post<OpenRouterChatCompletionResponse>(
'/chat/completions',
payload,
{
headers: {
'HTTP-Referer': 'http://localhost',
'X-Title': 'Finance App',
},
},
);
const content = resp.data?.choices?.[0]?.message?.content;
if (!content) {
throw new Error('OpenRouter response content is empty');
}
const extracted = this.extractJson(content);
return JSON.parse(extracted) as T;
} catch (e) {
lastError = e;
const anyErr = e as any;
const msg = anyErr?.message || String(e);
const status = anyErr?.response?.status as number | undefined;
this.logger.warn(
`OpenRouter attempt ${attempt}/${maxAttempts} failed${status ? ` (status ${status})` : ''}: ${msg}`,
);
// If this isn't the last attempt, wait a bit before retrying.
if (attempt < maxAttempts) {
let delayMs = 500 * Math.pow(2, attempt - 1); // 500ms, 1000ms...
// If model returned invalid JSON, retry once with a strict repair hint.
if (
!repairHintAdded &&
/\bJSON\b/i.test(msg) &&
/position|unterminated|unexpected|parse/i.test(msg)
) {
repairHintAdded = true;
}
// If rate limited, honor Retry-After header when possible.
if (status === 429) {
const retryAfterHeader = anyErr?.response?.headers?.['retry-after'];
const retryAfterSec = retryAfterHeader
? Number(retryAfterHeader)
: NaN;
if (!Number.isNaN(retryAfterSec) && retryAfterSec > 0) {
delayMs = Math.min(30000, retryAfterSec * 1000);
} else {
delayMs = Math.min(30000, 2000 * attempt);
}
}
await this.sleep(delayMs);
}
}
}
throw lastError;
}
private async sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private extractJson(text: string): string {
// Some models may wrap JSON in text; attempt to extract first JSON object/array.
const firstBrace = text.indexOf('{');
const firstBracket = text.indexOf('[');
let start = -1;
if (firstBrace === -1) start = firstBracket;
else if (firstBracket === -1) start = firstBrace;
else start = Math.min(firstBrace, firstBracket);
if (start === -1) {
throw new Error('No JSON found in model output');
}
const candidate = text
.slice(start)
.trim()
.replace(/```[a-z]*\s*/gi, '')
.replace(/```\s*$/g, '')
.trim();
return this.extractBalancedJson(candidate);
}
private extractBalancedJson(candidate: string): string {
const startChar = candidate[0];
const endChar = startChar === '{' ? '}' : startChar === '[' ? ']' : '';
if (!endChar) {
throw new Error('JSON must start with { or [');
}
let depth = 0;
let inString = false;
let escape = false;
for (let i = 0; i < candidate.length; i++) {
const ch = candidate[i];
if (inString) {
if (escape) {
escape = false;
continue;
}
if (ch === '\\') {
escape = true;
continue;
}
if (ch === '"') {
inString = false;
}
continue;
}
if (ch === '"') {
inString = true;
continue;
}
if (ch === startChar) depth++;
if (ch === endChar) depth--;
if (depth === 0) {
return candidate.slice(0, i + 1).trim();
}
}
// If we couldn't find a balanced end, return full candidate (JSON.parse will fail with a useful error)
return candidate.trim();
}
// ============================================
// Mock Implementations (Phase 1)
// ============================================
@ -171,17 +572,17 @@ export class AiService {
// Simple keyword-based categorization
const categoryMap: Record<string, { id: string; name: string }> = {
'продукты': { id: 'groceries', name: 'Продукты' },
'пятерочка': { id: 'groceries', name: 'Продукты' },
'магнит': { id: 'groceries', name: 'Продукты' },
'такси': { id: 'transport', name: 'Транспорт' },
'яндекс': { id: 'transport', name: 'Транспорт' },
'метро': { id: 'transport', name: 'Транспорт' },
'ресторан': { id: 'restaurants', name: 'Рестораны и кафе' },
'кафе': { id: 'restaurants', name: 'Рестораны и кафе' },
'аптека': { id: 'healthcare', name: 'Медицина' },
'жкх': { id: 'utilities', name: 'Коммуналка' },
'электричество': { id: 'utilities', name: 'Коммуналка' },
продукты: { id: 'groceries', name: 'Продукты' },
пятерочка: { id: 'groceries', name: 'Продукты' },
магнит: { id: 'groceries', name: 'Продукты' },
такси: { id: 'transport', name: 'Транспорт' },
яндекс: { id: 'transport', name: 'Транспорт' },
метро: { id: 'transport', name: 'Транспорт' },
ресторан: { id: 'restaurants', name: 'Рестораны и кафе' },
кафе: { id: 'restaurants', name: 'Рестораны и кафе' },
аптека: { id: 'healthcare', name: 'Медицина' },
жкх: { id: 'utilities', name: 'Коммуналка' },
электричество: { id: 'utilities', name: 'Коммуналка' },
};
for (const [keyword, category] of Object.entries(categoryMap)) {
@ -191,6 +592,7 @@ export class AiService {
suggestedCategoryName: category.name,
confidence: 0.85,
reasoning: `Ключевое слово "${keyword}" найдено в описании`,
source: 'mock',
};
}
}
@ -200,6 +602,7 @@ export class AiService {
suggestedCategoryName: 'Другое',
confidence: 0.3,
reasoning: 'Не удалось определить категорию по описанию',
source: 'mock',
};
}
@ -229,6 +632,7 @@ export class AiService {
'Увеличьте автоматические переводы на накопления до 20%',
'Используйте карту с кэшбэком для регулярных покупок',
],
source: 'mock',
};
}
@ -253,6 +657,7 @@ export class AiService {
targetSavingsRate: 20,
monthlySavingsTarget: context.monthlyIncome * 0.2,
},
source: 'mock',
});
}
@ -261,9 +666,11 @@ export class AiService {
id: 'tax-deduction',
type: 'TAX',
titleRu: 'Налоговый вычет',
descriptionRu: 'Проверьте возможность получения налогового вычета за медицинские услуги или образование (3-НДФЛ).',
descriptionRu:
'Проверьте возможность получения налогового вычета за медицинские услуги или образование (3-НДФЛ).',
priorityScore: 0.7,
confidenceScore: 0.8,
source: 'mock',
});
// Emergency fund recommendation
@ -271,12 +678,14 @@ export class AiService {
id: 'emergency-fund',
type: 'SAVING',
titleRu: 'Резервный фонд',
descriptionRu: 'Рекомендуем создать резервный фонд в размере 3-6 месячных расходов.',
descriptionRu:
'Рекомендуем создать резервный фонд в размере 3-6 месячных расходов.',
priorityScore: 0.85,
confidenceScore: 0.9,
actionData: {
targetAmount: context.totalExpenses * 6,
},
source: 'mock',
});
return recommendations;

View File

@ -1,19 +1,18 @@
import {
Controller,
Get,
Query,
UseGuards,
} from '@nestjs/common';
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiOkResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { AnalyticsService } from './analytics.service';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser, JwtPayload } from '../../common/decorators/user.decorator';
import {
CurrentUser,
JwtPayload,
} from '../../common/decorators/user.decorator';
import { TransactionType } from '../../common/constants/categories';
@ApiTags('Аналитика')
@ -25,8 +24,32 @@ export class AnalyticsController {
@Get('overview')
@ApiOperation({ summary: 'Обзор за месяц' })
@ApiQuery({ name: 'month', required: false, example: '2024-01', description: 'Месяц в формате YYYY-MM' })
@ApiResponse({ status: 200, description: 'Обзор за месяц' })
@ApiQuery({
name: 'month',
required: false,
example: '2024-01',
description: 'Месяц в формате YYYY-MM',
})
@ApiOkResponse({
description: 'Обзор за месяц',
schema: {
example: {
month: '2024-01',
totalIncome: 120000,
totalExpenses: 85000,
netSavings: 35000,
savingsRate: 29.17,
topCategories: [
{
categoryId: 'uuid',
categoryName: 'Продукты',
amount: 22000,
percentage: 25.88,
},
],
},
},
})
async getMonthlyOverview(
@CurrentUser() user: JwtPayload,
@Query('month') month?: string,
@ -35,10 +58,45 @@ export class AnalyticsController {
return this.analyticsService.getMonthlyOverview(user.sub, date);
}
@Get('narrative')
@ApiOperation({ summary: 'AI обзор и рекомендации за месяц' })
@ApiQuery({
name: 'month',
required: false,
example: '2024-01',
description: 'Месяц в формате YYYY-MM',
})
@ApiOkResponse({
description: 'AI обзор за месяц',
schema: {
example: {
summaryRu: 'В этом месяце вы сэкономили 29% дохода.',
insightsRu: ['Самая крупная категория расходов — Продукты.'],
actionsRu: ['Установите лимит на рестораны на следующий месяц.'],
source: 'openrouter',
},
},
})
async getMonthlyNarrative(
@CurrentUser() user: JwtPayload,
@Query('month') month?: string,
) {
const date = month ? new Date(month + '-01') : new Date();
return this.analyticsService.getMonthlyNarrative(user.sub, date);
}
@Get('trends')
@ApiOperation({ summary: 'Тренды расходов' })
@ApiQuery({ name: 'months', required: false, example: 6 })
@ApiResponse({ status: 200, description: 'Тренды расходов по месяцам' })
@ApiOkResponse({
description: 'Тренды расходов по месяцам',
schema: {
example: [
{ period: '2024-01', amount: 85000, change: 0, changePercent: 0 },
{ period: '2024-02', amount: 90000, change: 5000, changePercent: 5.88 },
],
},
})
async getSpendingTrends(
@CurrentUser() user: JwtPayload,
@Query('months') months?: number,
@ -51,7 +109,23 @@ export class AnalyticsController {
@ApiQuery({ name: 'startDate', required: true, example: '2024-01-01' })
@ApiQuery({ name: 'endDate', required: true, example: '2024-01-31' })
@ApiQuery({ name: 'type', enum: TransactionType, required: false })
@ApiResponse({ status: 200, description: 'Разбивка расходов по категориям' })
@ApiOkResponse({
description: 'Разбивка расходов по категориям',
schema: {
example: [
{
categoryId: 'uuid',
categoryName: 'Продукты',
categoryIcon: 'shopping-cart',
categoryColor: '#4CAF50',
amount: 22000,
percentage: 25.88,
transactionCount: 12,
averageTransaction: 1833.33,
},
],
},
})
async getCategoryBreakdown(
@CurrentUser() user: JwtPayload,
@Query('startDate') startDate: string,
@ -69,7 +143,15 @@ export class AnalyticsController {
@Get('income-vs-expenses')
@ApiOperation({ summary: 'Сравнение доходов и расходов' })
@ApiQuery({ name: 'months', required: false, example: 12 })
@ApiResponse({ status: 200, description: 'Сравнение по месяцам' })
@ApiOkResponse({
description: 'Сравнение по месяцам',
schema: {
example: [
{ month: '2024-01', income: 120000, expenses: 85000, balance: 35000 },
{ month: '2024-02', income: 120000, expenses: 90000, balance: 30000 },
],
},
})
async getIncomeVsExpenses(
@CurrentUser() user: JwtPayload,
@Query('months') months?: number,
@ -79,7 +161,23 @@ export class AnalyticsController {
@Get('health')
@ApiOperation({ summary: 'Оценка финансового здоровья' })
@ApiResponse({ status: 200, description: 'Оценка и рекомендации' })
@ApiOkResponse({
description: 'Оценка и рекомендации',
schema: {
example: {
score: 78,
grade: 'B',
factors: [
{
name: 'Норма сбережений',
score: 75,
description: 'Вы сохраняете около 20% дохода.',
recommendation: 'Попробуйте увеличить до 25% в следующем месяце.',
},
],
},
},
})
async getFinancialHealth(@CurrentUser() user: JwtPayload) {
return this.analyticsService.getFinancialHealth(user.sub);
}
@ -87,11 +185,26 @@ export class AnalyticsController {
@Get('yearly')
@ApiOperation({ summary: 'Годовой отчет' })
@ApiQuery({ name: 'year', required: false, example: 2024 })
@ApiResponse({ status: 200, description: 'Годовая сводка' })
@ApiOkResponse({
description: 'Годовая сводка',
schema: {
example: {
year: 2024,
totalIncome: 1440000,
totalExpenses: 1020000,
netSavings: 420000,
savingsRate: 29.17,
topExpenseCategories: [{ categoryName: 'Продукты', amount: 240000 }],
},
},
})
async getYearlySummary(
@CurrentUser() user: JwtPayload,
@Query('year') year?: number,
) {
return this.analyticsService.getYearlySummary(user.sub, year || new Date().getFullYear());
return this.analyticsService.getYearlySummary(
user.sub,
year || new Date().getFullYear(),
);
}
}

View File

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

View File

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

View File

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

View File

@ -14,13 +14,18 @@ import {
ApiTags,
ApiOperation,
ApiResponse,
ApiOkResponse,
ApiCreatedResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { BudgetsService } from './budgets.service';
import { CreateBudgetDto, UpdateBudgetDto } from './dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser, JwtPayload } from '../../common/decorators/user.decorator';
import {
CurrentUser,
JwtPayload,
} from '../../common/decorators/user.decorator';
@ApiTags('Бюджеты (50/30/20)')
@ApiBearerAuth()
@ -31,22 +36,73 @@ export class BudgetsController {
@Get()
@ApiOperation({ summary: 'Получение всех бюджетов пользователя' })
@ApiResponse({ status: 200, description: 'Список бюджетов' })
@ApiOkResponse({
description: 'Список бюджетов',
schema: {
example: [
{
id: 'uuid',
month: '2024-01-01',
totalIncome: 120000,
essentialsLimit: 60000,
personalLimit: 36000,
savingsLimit: 24000,
essentialsSpent: 42000,
personalSpent: 28000,
savingsSpent: 15000,
},
],
},
})
async findAll(@CurrentUser() user: JwtPayload) {
return this.budgetsService.findAll(user.sub);
}
@Get('current')
@ApiOperation({ summary: 'Получение бюджета текущего месяца' })
@ApiResponse({ status: 200, description: 'Бюджет текущего месяца' })
@ApiOkResponse({
description: 'Бюджет текущего месяца',
schema: {
example: {
id: 'uuid',
month: '2024-01-01',
totalIncome: 120000,
essentialsLimit: 60000,
personalLimit: 36000,
savingsLimit: 24000,
essentialsSpent: 42000,
personalSpent: 28000,
savingsSpent: 15000,
},
},
})
async findCurrent(@CurrentUser() user: JwtPayload) {
return this.budgetsService.findCurrent(user.sub);
}
@Get('month')
@ApiOperation({ summary: 'Получение бюджета за конкретный месяц' })
@ApiQuery({ name: 'month', example: '2024-01-01', description: 'Первый день месяца' })
@ApiResponse({ status: 200, description: 'Бюджет' })
@ApiQuery({
name: 'month',
example: '2024-01-01',
description: 'Первый день месяца',
})
@ApiOkResponse({
description: 'Бюджет',
schema: {
example: {
id: 'uuid',
month: '2024-01-01',
totalIncome: 120000,
essentialsLimit: 60000,
personalLimit: 36000,
savingsLimit: 24000,
essentialsSpent: 42000,
personalSpent: 28000,
savingsSpent: 15000,
},
},
})
@ApiResponse({ status: 404, description: 'Бюджет не найден' })
async findByMonth(
@CurrentUser() user: JwtPayload,
@ -58,7 +114,17 @@ export class BudgetsController {
@Get('progress')
@ApiOperation({ summary: 'Получение прогресса по бюджету' })
@ApiQuery({ name: 'month', example: '2024-01-01' })
@ApiResponse({ status: 200, description: 'Прогресс по категориям 50/30/20' })
@ApiOkResponse({
description: 'Прогресс по категориям 50/30/20',
schema: {
example: {
month: '2024-01-01',
essentials: { limit: 60000, spent: 42000, percent: 70 },
personal: { limit: 36000, spent: 28000, percent: 77.78 },
savings: { limit: 24000, spent: 15000, percent: 62.5 },
},
},
})
async getProgress(
@CurrentUser() user: JwtPayload,
@Query('month') month: string,
@ -69,19 +135,43 @@ export class BudgetsController {
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Создание бюджета на месяц' })
@ApiResponse({ status: 201, description: 'Бюджет создан' })
@ApiResponse({ status: 409, description: 'Бюджет на этот месяц уже существует' })
async create(
@CurrentUser() user: JwtPayload,
@Body() dto: CreateBudgetDto,
) {
@ApiCreatedResponse({
description: 'Бюджет создан',
schema: {
example: {
id: 'uuid',
month: '2024-01-01',
totalIncome: 120000,
essentialsLimit: 60000,
personalLimit: 36000,
savingsLimit: 24000,
},
},
})
@ApiResponse({
status: 409,
description: 'Бюджет на этот месяц уже существует',
})
async create(@CurrentUser() user: JwtPayload, @Body() dto: CreateBudgetDto) {
return this.budgetsService.create(user.sub, dto);
}
@Put()
@ApiOperation({ summary: 'Обновление бюджета' })
@ApiQuery({ name: 'month', example: '2024-01-01' })
@ApiResponse({ status: 200, description: 'Бюджет обновлен' })
@ApiOkResponse({
description: 'Бюджет обновлен',
schema: {
example: {
id: 'uuid',
month: '2024-01-01',
totalIncome: 120000,
essentialsLimit: 65000,
personalLimit: 35000,
savingsLimit: 20000,
},
},
})
async update(
@CurrentUser() user: JwtPayload,
@Query('month') month: string,
@ -94,11 +184,14 @@ export class BudgetsController {
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Удаление бюджета' })
@ApiQuery({ name: 'month', example: '2024-01-01' })
@ApiResponse({ status: 204, description: 'Бюджет удален' })
async remove(
@CurrentUser() user: JwtPayload,
@Query('month') month: string,
) {
@ApiResponse({
status: 204,
description: 'Бюджет удален',
schema: {
example: null,
},
})
async remove(@CurrentUser() user: JwtPayload, @Query('month') month: string) {
await this.budgetsService.remove(user.sub, month);
}
}

View File

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

View File

@ -16,6 +16,8 @@ import {
ApiTags,
ApiOperation,
ApiResponse,
ApiOkResponse,
ApiCreatedResponse,
ApiBearerAuth,
ApiQuery,
ApiParam,
@ -24,7 +26,10 @@ import { GoalsService } from './goals.service';
import { CreateGoalDto, UpdateGoalDto, AddFundsDto } from './dto';
import { GoalStatus } from './entities/goal.entity';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser, JwtPayload } from '../../common/decorators/user.decorator';
import {
CurrentUser,
JwtPayload,
} from '../../common/decorators/user.decorator';
@ApiTags('Финансовые цели')
@ApiBearerAuth()
@ -36,7 +41,25 @@ export class GoalsController {
@Get()
@ApiOperation({ summary: 'Получение всех целей пользователя' })
@ApiQuery({ name: 'status', enum: GoalStatus, required: false })
@ApiResponse({ status: 200, description: 'Список целей' })
@ApiOkResponse({
description: 'Список целей',
schema: {
example: [
{
id: 'uuid',
userId: 'uuid',
name: 'Подушка безопасности',
targetAmount: 300000,
currentAmount: 120000,
status: 'ACTIVE',
targetDate: '2024-12-31T00:00:00.000Z',
autoSaveEnabled: true,
autoSaveAmount: 10000,
autoSaveFrequency: 'MONTHLY',
},
],
},
})
async findAll(
@CurrentUser() user: JwtPayload,
@Query('status') status?: GoalStatus,
@ -46,7 +69,19 @@ export class GoalsController {
@Get('summary')
@ApiOperation({ summary: 'Получение сводки по целям' })
@ApiResponse({ status: 200, description: 'Сводка по целям' })
@ApiOkResponse({
description: 'Сводка по целям',
schema: {
example: {
totalGoals: 3,
activeGoals: 2,
completedGoals: 1,
totalTargetAmount: 600000,
totalCurrentAmount: 240000,
overallProgress: 40,
},
},
})
async getSummary(@CurrentUser() user: JwtPayload) {
return this.goalsService.getSummary(user.sub);
}
@ -54,7 +89,21 @@ export class GoalsController {
@Get('upcoming')
@ApiOperation({ summary: 'Получение целей с приближающимся дедлайном' })
@ApiQuery({ name: 'days', required: false, example: 30 })
@ApiResponse({ status: 200, description: 'Список целей с дедлайном' })
@ApiOkResponse({
description: 'Список целей с дедлайном',
schema: {
example: [
{
id: 'uuid',
name: 'Отпуск',
targetAmount: 150000,
currentAmount: 50000,
targetDate: '2024-03-01T00:00:00.000Z',
daysLeft: 20,
},
],
},
})
async getUpcoming(
@CurrentUser() user: JwtPayload,
@Query('days') days?: number,
@ -65,7 +114,23 @@ export class GoalsController {
@Get(':id')
@ApiOperation({ summary: 'Получение цели по ID' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiResponse({ status: 200, description: 'Цель' })
@ApiOkResponse({
description: 'Цель',
schema: {
example: {
id: 'uuid',
userId: 'uuid',
name: 'Подушка безопасности',
targetAmount: 300000,
currentAmount: 120000,
status: 'ACTIVE',
targetDate: '2024-12-31T00:00:00.000Z',
autoSaveEnabled: true,
autoSaveAmount: 10000,
autoSaveFrequency: 'MONTHLY',
},
},
})
@ApiResponse({ status: 404, description: 'Цель не найдена' })
async findOne(
@CurrentUser() user: JwtPayload,
@ -77,18 +142,42 @@ export class GoalsController {
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Создание новой цели' })
@ApiResponse({ status: 201, description: 'Цель создана' })
async create(
@CurrentUser() user: JwtPayload,
@Body() dto: CreateGoalDto,
) {
@ApiCreatedResponse({
description: 'Цель создана',
schema: {
example: {
id: 'uuid',
userId: 'uuid',
name: 'Подушка безопасности',
targetAmount: 300000,
currentAmount: 0,
status: 'ACTIVE',
targetDate: '2024-12-31T00:00:00.000Z',
autoSaveEnabled: true,
autoSaveAmount: 10000,
autoSaveFrequency: 'MONTHLY',
},
},
})
async create(@CurrentUser() user: JwtPayload, @Body() dto: CreateGoalDto) {
return this.goalsService.create(user.sub, dto);
}
@Put(':id')
@ApiOperation({ summary: 'Обновление цели' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiResponse({ status: 200, description: 'Цель обновлена' })
@ApiOkResponse({
description: 'Цель обновлена',
schema: {
example: {
id: 'uuid',
name: 'Подушка безопасности (обновлено)',
targetAmount: 350000,
currentAmount: 120000,
status: 'ACTIVE',
},
},
})
async update(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
@ -100,7 +189,16 @@ export class GoalsController {
@Post(':id/add-funds')
@ApiOperation({ summary: 'Добавление средств к цели' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiResponse({ status: 200, description: 'Средства добавлены' })
@ApiOkResponse({
description: 'Средства добавлены',
schema: {
example: {
id: 'uuid',
currentAmount: 130000,
message: 'Средства добавлены',
},
},
})
async addFunds(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
@ -112,7 +210,16 @@ export class GoalsController {
@Post(':id/withdraw')
@ApiOperation({ summary: 'Снятие средств с цели' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiResponse({ status: 200, description: 'Средства сняты' })
@ApiOkResponse({
description: 'Средства сняты',
schema: {
example: {
id: 'uuid',
currentAmount: 110000,
message: 'Средства сняты',
},
},
})
async withdrawFunds(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,

View File

@ -26,6 +26,11 @@ export enum RecommendationStatus {
DISMISSED = 'DISMISSED',
}
export enum RecommendationSource {
OPENROUTER = 'openrouter',
MOCK = 'mock',
}
@Entity('recommendations')
export class Recommendation {
@PrimaryGeneratedColumn('uuid')
@ -47,16 +52,45 @@ export class Recommendation {
@Column({ name: 'action_text_ru', length: 200, nullable: true })
actionTextRu: string;
@Column({ name: 'priority_score', type: 'decimal', precision: 3, scale: 2, default: 0.5 })
@Column({
name: 'priority_score',
type: 'decimal',
precision: 3,
scale: 2,
default: 0.5,
})
priorityScore: number;
@Column({ name: 'confidence_score', type: 'decimal', precision: 3, scale: 2, default: 0.5 })
@Column({
name: 'confidence_score',
type: 'decimal',
precision: 3,
scale: 2,
default: 0.5,
})
confidenceScore: number;
@Column({ name: 'potential_savings', type: 'decimal', precision: 15, scale: 2, nullable: true })
@Column({
type: 'enum',
enum: RecommendationSource,
default: RecommendationSource.MOCK,
})
source: RecommendationSource;
@Column({
name: 'potential_savings',
type: 'decimal',
precision: 15,
scale: 2,
nullable: true,
})
potentialSavings: number;
@Column({ type: 'enum', enum: RecommendationStatus, default: RecommendationStatus.NEW })
@Column({
type: 'enum',
enum: RecommendationStatus,
default: RecommendationStatus.NEW,
})
status: RecommendationStatus;
@Column({ name: 'action_data', type: 'jsonb', nullable: true })

View File

@ -11,6 +11,7 @@ import {
ApiTags,
ApiOperation,
ApiResponse,
ApiOkResponse,
ApiBearerAuth,
ApiQuery,
ApiParam,
@ -18,19 +19,46 @@ import {
import { RecommendationsService } from './recommendations.service';
import { RecommendationType } from './entities/recommendation.entity';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser, JwtPayload } from '../../common/decorators/user.decorator';
import {
CurrentUser,
JwtPayload,
} from '../../common/decorators/user.decorator';
@ApiTags('Рекомендации')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('recommendations')
export class RecommendationsController {
constructor(private readonly recommendationsService: RecommendationsService) {}
constructor(
private readonly recommendationsService: RecommendationsService,
) {}
@Get()
@ApiOperation({ summary: 'Получение активных рекомендаций' })
@ApiQuery({ name: 'type', enum: RecommendationType, required: false })
@ApiResponse({ status: 200, description: 'Список рекомендаций' })
@ApiOkResponse({
description: 'Список рекомендаций',
schema: {
example: [
{
id: 'uuid',
userId: 'uuid',
type: 'SAVING',
titleRu: 'Увеличьте накопления',
descriptionRu: 'Рекомендуем увеличить норму сбережений до 20%.',
priorityScore: 0.9,
confidenceScore: 0.95,
source: 'openrouter',
status: 'NEW',
actionData: { targetSavingsRate: 20 },
expiresAt: '2024-02-15T00:00:00.000Z',
createdAt: '2024-01-15T00:00:00.000Z',
viewedAt: null,
appliedAt: null,
},
],
},
})
async findAll(
@CurrentUser() user: JwtPayload,
@Query('type') type?: RecommendationType,
@ -40,14 +68,45 @@ export class RecommendationsController {
@Get('stats')
@ApiOperation({ summary: 'Статистика по рекомендациям' })
@ApiResponse({ status: 200, description: 'Статистика' })
@ApiOkResponse({
description: 'Статистика',
schema: {
example: {
total: 10,
new: 4,
viewed: 3,
applied: 2,
dismissed: 1,
},
},
})
async getStats(@CurrentUser() user: JwtPayload) {
return this.recommendationsService.getStats(user.sub);
}
@Post('generate')
@ApiOperation({ summary: 'Генерация новых рекомендаций' })
@ApiResponse({ status: 200, description: 'Сгенерированные рекомендации' })
@ApiOkResponse({
description: 'Сгенерированные рекомендации',
schema: {
example: [
{
id: 'uuid',
userId: 'uuid',
type: 'BUDGET',
titleRu: 'Превышение лимита на необходимое',
descriptionRu: 'Вы израсходовали 95% бюджета на необходимые расходы.',
priorityScore: 0.9,
confidenceScore: 0.95,
source: 'mock',
status: 'NEW',
actionData: null,
expiresAt: '2024-02-15T00:00:00.000Z',
createdAt: '2024-01-15T00:00:00.000Z',
},
],
},
})
async generate(@CurrentUser() user: JwtPayload) {
return this.recommendationsService.generateRecommendations(user.sub);
}
@ -55,7 +114,24 @@ export class RecommendationsController {
@Get(':id')
@ApiOperation({ summary: 'Получение рекомендации по ID' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiResponse({ status: 200, description: 'Рекомендация' })
@ApiOkResponse({
description: 'Рекомендация',
schema: {
example: {
id: 'uuid',
userId: 'uuid',
type: 'SAVING',
titleRu: 'Резервный фонд',
descriptionRu: 'Создайте резервный фонд 36 месячных расходов.',
priorityScore: 0.85,
confidenceScore: 0.9,
source: 'mock',
status: 'NEW',
actionData: { targetAmount: 480000 },
createdAt: '2024-01-15T00:00:00.000Z',
},
},
})
async findOne(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
@ -66,7 +142,16 @@ export class RecommendationsController {
@Post(':id/view')
@ApiOperation({ summary: 'Отметить рекомендацию как просмотренную' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiResponse({ status: 200, description: 'Рекомендация обновлена' })
@ApiOkResponse({
description: 'Рекомендация обновлена',
schema: {
example: {
id: 'uuid',
status: 'VIEWED',
viewedAt: '2024-01-15T10:00:00.000Z',
},
},
})
async markAsViewed(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
@ -77,7 +162,16 @@ export class RecommendationsController {
@Post(':id/apply')
@ApiOperation({ summary: 'Отметить рекомендацию как примененную' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiResponse({ status: 200, description: 'Рекомендация применена' })
@ApiOkResponse({
description: 'Рекомендация применена',
schema: {
example: {
id: 'uuid',
status: 'APPLIED',
appliedAt: '2024-01-15T10:00:00.000Z',
},
},
})
async markAsApplied(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
@ -88,7 +182,15 @@ export class RecommendationsController {
@Post(':id/dismiss')
@ApiOperation({ summary: 'Отклонить рекомендацию' })
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
@ApiResponse({ status: 200, description: 'Рекомендация отклонена' })
@ApiOkResponse({
description: 'Рекомендация отклонена',
schema: {
example: {
id: 'uuid',
status: 'DISMISSED',
},
},
})
async dismiss(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,

View File

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

View File

@ -1,3 +1,4 @@
export * from './create-transaction.dto';
export * from './update-transaction.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,
ApiOperation,
ApiResponse,
ApiOkResponse,
ApiCreatedResponse,
ApiBearerAuth,
ApiParam,
ApiQuery,
} from '@nestjs/swagger';
import { TransactionsService } from './transactions.service';
import { CreateTransactionDto, UpdateTransactionDto, QueryTransactionsDto } from './dto';
import {
CreateTransactionDto,
UpdateTransactionDto,
QueryTransactionsDto,
SuggestCategoryDto,
} from './dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser, JwtPayload } from '../../common/decorators/user.decorator';
import {
CurrentUser,
JwtPayload,
} from '../../common/decorators/user.decorator';
import { AiService } from '../ai/ai.service';
@ApiTags('Транзакции')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('transactions')
export class TransactionsController {
constructor(private readonly transactionsService: TransactionsService) {}
constructor(
private readonly transactionsService: TransactionsService,
private readonly aiService: AiService,
) {}
@Get()
@ApiOperation({ summary: 'Получение списка транзакций' })
@ApiResponse({
status: 200,
@ApiOkResponse({
description: 'Список транзакций с пагинацией',
schema: {
example: {
data: [
{
id: 'uuid',
amount: 1500.5,
type: 'EXPENSE',
categoryId: 'uuid',
transactionDate: '2024-01-15T00:00:00.000Z',
description: 'Покупка продуктов в Пятерочке',
paymentMethod: 'CARD',
},
],
meta: { page: 1, limit: 20, total: 1, totalPages: 1 },
},
},
})
async findAll(
@CurrentUser() user: JwtPayload,
@ -48,9 +77,16 @@ export class TransactionsController {
@ApiOperation({ summary: 'Получение сводки по транзакциям за период' })
@ApiQuery({ name: 'startDate', required: true, example: '2024-01-01' })
@ApiQuery({ name: 'endDate', required: true, example: '2024-01-31' })
@ApiResponse({
status: 200,
@ApiOkResponse({
description: 'Сводка: доходы, расходы, баланс',
schema: {
example: {
totalIncome: 120000,
totalExpense: 85000,
balance: 35000,
transactionCount: 42,
},
},
})
async getSummary(
@CurrentUser() user: JwtPayload,
@ -64,39 +100,69 @@ export class TransactionsController {
@ApiOperation({ summary: 'Получение расходов по категориям' })
@ApiQuery({ name: 'startDate', required: true, example: '2024-01-01' })
@ApiQuery({ name: 'endDate', required: true, example: '2024-01-31' })
@ApiResponse({
status: 200,
@ApiOkResponse({
description: 'Расходы, сгруппированные по категориям',
schema: {
example: [
{
categoryId: 'uuid',
categoryName: 'Продукты',
total: 22000,
percentage: 25.88,
},
],
},
})
async getByCategory(
@CurrentUser() user: JwtPayload,
@Query('startDate') startDate: string,
@Query('endDate') endDate: string,
) {
return this.transactionsService.getSpendingByCategory(user.sub, startDate, endDate);
return this.transactionsService.getSpendingByCategory(
user.sub,
startDate,
endDate,
);
}
@Get(':id')
@ApiOperation({ summary: 'Получение транзакции по ID' })
@ApiParam({ name: 'id', description: 'ID транзакции' })
@ApiResponse({
status: 200,
@ApiOkResponse({
description: 'Транзакция',
schema: {
example: {
id: 'uuid',
amount: 1500.5,
type: 'EXPENSE',
categoryId: 'uuid',
transactionDate: '2024-01-15T00:00:00.000Z',
description: 'Покупка продуктов в Пятерочке',
paymentMethod: 'CARD',
},
},
})
@ApiResponse({ status: 404, description: 'Транзакция не найдена' })
async findOne(
@CurrentUser() user: JwtPayload,
@Param('id') id: string,
) {
async findOne(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
return this.transactionsService.findOne(id, user.sub);
}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Создание транзакции' })
@ApiResponse({
status: 201,
@ApiCreatedResponse({
description: 'Транзакция создана',
schema: {
example: {
id: 'uuid',
amount: 1500.5,
type: 'EXPENSE',
categoryId: 'uuid',
transactionDate: '2024-01-15T00:00:00.000Z',
description: 'Покупка продуктов в Пятерочке',
paymentMethod: 'CARD',
},
},
})
@ApiResponse({ status: 400, description: 'Некорректные данные' })
async create(
@ -106,12 +172,51 @@ export class TransactionsController {
return this.transactionsService.create(user.sub, dto);
}
@Post('suggest-category')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'AI подсказка категории по описанию транзакции' })
@ApiOkResponse({
description: 'Подсказка категории',
schema: {
example: {
suggestedCategoryId: 'unknown',
suggestedCategoryName: 'Продукты',
confidence: 0.82,
reasoning:
'В описании обнаружены ключевые слова, связанные с продуктами.',
source: 'openrouter',
},
},
})
async suggestCategory(
@CurrentUser() user: JwtPayload,
@Body() dto: SuggestCategoryDto,
) {
return this.aiService.categorizeTransaction({
description: dto.description,
amount: dto.amount,
date: dto.date,
});
}
@Post('bulk')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Массовое создание транзакций (импорт)' })
@ApiResponse({
status: 201,
@ApiCreatedResponse({
description: 'Транзакции созданы',
schema: {
example: [
{
id: 'uuid',
amount: 1500.5,
type: 'EXPENSE',
categoryId: 'uuid',
transactionDate: '2024-01-15T00:00:00.000Z',
description: 'Покупка продуктов',
paymentMethod: 'CARD',
},
],
},
})
async bulkCreate(
@CurrentUser() user: JwtPayload,
@ -123,9 +228,19 @@ export class TransactionsController {
@Put(':id')
@ApiOperation({ summary: 'Обновление транзакции' })
@ApiParam({ name: 'id', description: 'ID транзакции' })
@ApiResponse({
status: 200,
@ApiOkResponse({
description: 'Транзакция обновлена',
schema: {
example: {
id: 'uuid',
amount: 1600.0,
type: 'EXPENSE',
categoryId: 'uuid',
transactionDate: '2024-01-15T00:00:00.000Z',
description: 'Покупка продуктов (обновлено)',
paymentMethod: 'CARD',
},
},
})
@ApiResponse({ status: 404, description: 'Транзакция не найдена' })
async update(
@ -145,10 +260,7 @@ export class TransactionsController {
description: 'Транзакция удалена',
})
@ApiResponse({ status: 404, description: 'Транзакция не найдена' })
async remove(
@CurrentUser() user: JwtPayload,
@Param('id') id: string,
) {
async remove(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
await this.transactionsService.remove(id, user.sub);
}
}

View File

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