From 640632ca7eda0b587f646b8cf8e9196ad7a3866a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=97=D0=B0=D0=B8=D0=B4=20=D0=9E=D0=BC=D0=B0=D1=80=20?= =?UTF-8?q?=D0=9C=D0=B5=D0=B4=D1=85=D0=B0=D1=82?= Date: Thu, 25 Dec 2025 23:33:02 +0500 Subject: [PATCH] init --- .env.development | 8 +- .env.example | 3 + .gitea/workflows/deploy-production.yml | 61 +++ package-lock.json | 43 +- package.json | 1 + src/common/filters/all-exceptions.filter.ts | 11 +- src/config/ai.config.ts | 7 + src/modules/ai/ai.service.ts | 461 +++++++++++++++++- src/modules/analytics/analytics.controller.ts | 143 +++++- src/modules/analytics/analytics.module.ts | 2 + src/modules/analytics/analytics.service.ts | 193 ++++++-- src/modules/auth/auth.controller.ts | 104 +++- src/modules/budgets/budgets.controller.ts | 129 ++++- .../categories/categories.controller.ts | 122 ++++- src/modules/goals/goals.controller.ts | 133 ++++- .../entities/recommendation.entity.ts | 42 +- .../recommendations.controller.ts | 120 ++++- .../recommendations.service.ts | 178 ++++--- src/modules/transactions/dto/index.ts | 1 + .../transactions/dto/suggest-category.dto.ts | 33 ++ .../transactions/transactions.controller.ts | 164 ++++++- .../transactions/transactions.module.ts | 3 +- 22 files changed, 1676 insertions(+), 286 deletions(-) create mode 100644 .gitea/workflows/deploy-production.yml create mode 100644 src/modules/transactions/dto/suggest-category.dto.ts diff --git a/.env.development b/.env.development index ce67145..bc619ad 100644 --- a/.env.development +++ b/.env.development @@ -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 diff --git a/.env.example b/.env.example index 925f370..f8ef54a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/deploy-production.yml b/.gitea/workflows/deploy-production.yml new file mode 100644 index 0000000..ad4d2bc --- /dev/null +++ b/.gitea/workflows/deploy-production.yml @@ -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 + " diff --git a/package-lock.json b/package-lock.json index 19d23d9..b1749c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 87e6a7b..208abbf 100644 --- a/package.json +++ b/package.json @@ -35,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", diff --git a/src/common/filters/all-exceptions.filter.ts b/src/common/filters/all-exceptions.filter.ts index daa7b34..58f6a19 100644 --- a/src/common/filters/all-exceptions.filter.ts +++ b/src/common/filters/all-exceptions.filter.ts @@ -17,15 +17,20 @@ export class AllExceptionsFilter implements ExceptionFilter { const response = ctx.getResponse(); const request = ctx.getRequest(); + 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( diff --git a/src/config/ai.config.ts b/src/config/ai.config.ts index f69cd90..771009d 100644 --- a/src/config/ai.config.ts +++ b/src/config/ai.config.ts @@ -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', })); diff --git a/src/modules/ai/ai.service.ts b/src/modules/ai/ai.service.ts index c17101b..15b6079 100644 --- a/src/modules/ai/ai.service.ts +++ b/src/modules/ai/ai.service.ts @@ -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; + 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('ai.enabled') || false; - - if (!this.isEnabled) { - this.logger.warn('AI Service is disabled. Using mock implementations.'); + this.openrouterApiKey = + this.configService.get('ai.openrouterApiKey') || ''; + this.openrouterBaseUrl = + this.configService.get('ai.openrouterBaseUrl') || + 'https://openrouter.ai/api/v1'; + this.openrouterModel = + this.configService.get('ai.openrouterModel') || + 'openai/gpt-oss-120b:free'; + this.openrouterTimeoutMs = + this.configService.get('ai.openrouterTimeoutMs') || 20000; + + const enabledFlag = this.configService.get('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(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 { + 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(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( + messages: OpenRouterChatMessage[], + ): Promise { + 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( + '/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 { + 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 = { - 'продукты': { 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; diff --git a/src/modules/analytics/analytics.controller.ts b/src/modules/analytics/analytics.controller.ts index 3e07874..73874bd 100644 --- a/src/modules/analytics/analytics.controller.ts +++ b/src/modules/analytics/analytics.controller.ts @@ -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(), + ); } } diff --git a/src/modules/analytics/analytics.module.ts b/src/modules/analytics/analytics.module.ts index ef248cd..3d8e237 100644 --- a/src/modules/analytics/analytics.module.ts +++ b/src/modules/analytics/analytics.module.ts @@ -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], diff --git a/src/modules/analytics/analytics.service.ts b/src/modules/analytics/analytics.service.ts index 1996ca1..baca9e3 100644 --- a/src/modules/analytics/analytics.service.ts +++ b/src/modules/analytics/analytics.service.ts @@ -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, @InjectRepository(Goal) private goalRepository: Repository, + private aiService: AiService, ) {} /** * Get monthly overview for a specific month */ - async getMonthlyOverview(userId: string, date: Date = new Date()): Promise { + async getMonthlyOverview( + userId: string, + date: Date = new Date(), + ): Promise { 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 = {}; + const categorySpending: Record = + {}; 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 { + async getSpendingTrends( + userId: string, + months: number = 6, + ): Promise { 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 = {}; + 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> { + 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 = {}; - transactions.forEach(t => { + const monthlyData: Record = + {}; + 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 = {}; + const categoryExpenses: Record = + {}; 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, diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index d2a2dc4..e0db41d 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -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('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('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; diff --git a/src/modules/budgets/budgets.controller.ts b/src/modules/budgets/budgets.controller.ts index 4a9ef8e..f5974c1 100644 --- a/src/modules/budgets/budgets.controller.ts +++ b/src/modules/budgets/budgets.controller.ts @@ -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); } } diff --git a/src/modules/categories/categories.controller.ts b/src/modules/categories/categories.controller.ts index d38af2f..9167293 100644 --- a/src/modules/categories/categories.controller.ts +++ b/src/modules/categories/categories.controller.ts @@ -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); } } diff --git a/src/modules/goals/goals.controller.ts b/src/modules/goals/goals.controller.ts index 63b79d6..62e2712 100644 --- a/src/modules/goals/goals.controller.ts +++ b/src/modules/goals/goals.controller.ts @@ -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, diff --git a/src/modules/recommendations/entities/recommendation.entity.ts b/src/modules/recommendations/entities/recommendation.entity.ts index 836a557..7284ab0 100644 --- a/src/modules/recommendations/entities/recommendation.entity.ts +++ b/src/modules/recommendations/entities/recommendation.entity.ts @@ -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 }) diff --git a/src/modules/recommendations/recommendations.controller.ts b/src/modules/recommendations/recommendations.controller.ts index ae06b12..5dcef6f 100644 --- a/src/modules/recommendations/recommendations.controller.ts +++ b/src/modules/recommendations/recommendations.controller.ts @@ -11,6 +11,7 @@ import { ApiTags, ApiOperation, ApiResponse, + ApiOkResponse, ApiBearerAuth, ApiQuery, ApiParam, @@ -18,19 +19,46 @@ import { import { RecommendationsService } from './recommendations.service'; import { RecommendationType } from './entities/recommendation.entity'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; -import { CurrentUser, JwtPayload } from '../../common/decorators/user.decorator'; +import { + CurrentUser, + JwtPayload, +} from '../../common/decorators/user.decorator'; @ApiTags('Рекомендации') @ApiBearerAuth() @UseGuards(JwtAuthGuard) @Controller('recommendations') export class RecommendationsController { - constructor(private readonly recommendationsService: RecommendationsService) {} + constructor( + private readonly recommendationsService: RecommendationsService, + ) {} @Get() @ApiOperation({ summary: 'Получение активных рекомендаций' }) @ApiQuery({ name: 'type', enum: RecommendationType, required: false }) - @ApiResponse({ status: 200, description: 'Список рекомендаций' }) + @ApiOkResponse({ + description: 'Список рекомендаций', + schema: { + example: [ + { + id: 'uuid', + userId: 'uuid', + type: 'SAVING', + titleRu: 'Увеличьте накопления', + descriptionRu: 'Рекомендуем увеличить норму сбережений до 20%.', + priorityScore: 0.9, + confidenceScore: 0.95, + source: 'openrouter', + status: 'NEW', + actionData: { targetSavingsRate: 20 }, + expiresAt: '2024-02-15T00:00:00.000Z', + createdAt: '2024-01-15T00:00:00.000Z', + viewedAt: null, + appliedAt: null, + }, + ], + }, + }) async findAll( @CurrentUser() user: JwtPayload, @Query('type') type?: RecommendationType, @@ -40,14 +68,45 @@ export class RecommendationsController { @Get('stats') @ApiOperation({ summary: 'Статистика по рекомендациям' }) - @ApiResponse({ status: 200, description: 'Статистика' }) + @ApiOkResponse({ + description: 'Статистика', + schema: { + example: { + total: 10, + new: 4, + viewed: 3, + applied: 2, + dismissed: 1, + }, + }, + }) async getStats(@CurrentUser() user: JwtPayload) { return this.recommendationsService.getStats(user.sub); } @Post('generate') @ApiOperation({ summary: 'Генерация новых рекомендаций' }) - @ApiResponse({ status: 200, description: 'Сгенерированные рекомендации' }) + @ApiOkResponse({ + description: 'Сгенерированные рекомендации', + schema: { + example: [ + { + id: 'uuid', + userId: 'uuid', + type: 'BUDGET', + titleRu: 'Превышение лимита на необходимое', + descriptionRu: 'Вы израсходовали 95% бюджета на необходимые расходы.', + priorityScore: 0.9, + confidenceScore: 0.95, + source: 'mock', + status: 'NEW', + actionData: null, + expiresAt: '2024-02-15T00:00:00.000Z', + createdAt: '2024-01-15T00:00:00.000Z', + }, + ], + }, + }) async generate(@CurrentUser() user: JwtPayload) { return this.recommendationsService.generateRecommendations(user.sub); } @@ -55,7 +114,24 @@ export class RecommendationsController { @Get(':id') @ApiOperation({ summary: 'Получение рекомендации по ID' }) @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) - @ApiResponse({ status: 200, description: 'Рекомендация' }) + @ApiOkResponse({ + description: 'Рекомендация', + schema: { + example: { + id: 'uuid', + userId: 'uuid', + type: 'SAVING', + titleRu: 'Резервный фонд', + descriptionRu: 'Создайте резервный фонд 3–6 месячных расходов.', + priorityScore: 0.85, + confidenceScore: 0.9, + source: 'mock', + status: 'NEW', + actionData: { targetAmount: 480000 }, + createdAt: '2024-01-15T00:00:00.000Z', + }, + }, + }) async findOne( @CurrentUser() user: JwtPayload, @Param('id', ParseUUIDPipe) id: string, @@ -66,7 +142,16 @@ export class RecommendationsController { @Post(':id/view') @ApiOperation({ summary: 'Отметить рекомендацию как просмотренную' }) @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) - @ApiResponse({ status: 200, description: 'Рекомендация обновлена' }) + @ApiOkResponse({ + description: 'Рекомендация обновлена', + schema: { + example: { + id: 'uuid', + status: 'VIEWED', + viewedAt: '2024-01-15T10:00:00.000Z', + }, + }, + }) async markAsViewed( @CurrentUser() user: JwtPayload, @Param('id', ParseUUIDPipe) id: string, @@ -77,7 +162,16 @@ export class RecommendationsController { @Post(':id/apply') @ApiOperation({ summary: 'Отметить рекомендацию как примененную' }) @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) - @ApiResponse({ status: 200, description: 'Рекомендация применена' }) + @ApiOkResponse({ + description: 'Рекомендация применена', + schema: { + example: { + id: 'uuid', + status: 'APPLIED', + appliedAt: '2024-01-15T10:00:00.000Z', + }, + }, + }) async markAsApplied( @CurrentUser() user: JwtPayload, @Param('id', ParseUUIDPipe) id: string, @@ -88,7 +182,15 @@ export class RecommendationsController { @Post(':id/dismiss') @ApiOperation({ summary: 'Отклонить рекомендацию' }) @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) - @ApiResponse({ status: 200, description: 'Рекомендация отклонена' }) + @ApiOkResponse({ + description: 'Рекомендация отклонена', + schema: { + example: { + id: 'uuid', + status: 'DISMISSED', + }, + }, + }) async dismiss( @CurrentUser() user: JwtPayload, @Param('id', ParseUUIDPipe) id: string, diff --git a/src/modules/recommendations/recommendations.service.ts b/src/modules/recommendations/recommendations.service.ts index 8b501f6..fa91145 100644 --- a/src/modules/recommendations/recommendations.service.ts +++ b/src/modules/recommendations/recommendations.service.ts @@ -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 { + async findAll( + userId: string, + type?: RecommendationType, + ): Promise { const where: any = { userId, status: RecommendationStatus.NEW, @@ -55,7 +63,7 @@ export class RecommendationsService { */ async markAsViewed(id: string, userId: string): Promise { const recommendation = await this.findOne(id, userId); - + if (recommendation.status === RecommendationStatus.NEW) { recommendation.status = RecommendationStatus.VIEWED; recommendation.viewedAt = new Date(); @@ -70,10 +78,10 @@ export class RecommendationsService { */ async markAsApplied(id: string, userId: string): Promise { const recommendation = await this.findOne(id, userId); - + recommendation.status = RecommendationStatus.APPLIED; recommendation.appliedAt = new Date(); - + return this.recommendationRepository.save(recommendation); } @@ -82,9 +90,9 @@ export class RecommendationsService { */ async dismiss(id: string, userId: string): Promise { const recommendation = await this.findOne(id, userId); - + recommendation.status = RecommendationStatus.DISMISSED; - + return this.recommendationRepository.save(recommendation); } @@ -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 = {}; 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), }; } diff --git a/src/modules/transactions/dto/index.ts b/src/modules/transactions/dto/index.ts index 52895a0..a6cda9b 100644 --- a/src/modules/transactions/dto/index.ts +++ b/src/modules/transactions/dto/index.ts @@ -1,3 +1,4 @@ export * from './create-transaction.dto'; export * from './update-transaction.dto'; export * from './query-transactions.dto'; +export * from './suggest-category.dto'; diff --git a/src/modules/transactions/dto/suggest-category.dto.ts b/src/modules/transactions/dto/suggest-category.dto.ts new file mode 100644 index 0000000..20ef397 --- /dev/null +++ b/src/modules/transactions/dto/suggest-category.dto.ts @@ -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; +} diff --git a/src/modules/transactions/transactions.controller.ts b/src/modules/transactions/transactions.controller.ts index 60f9b72..ab49cc4 100644 --- a/src/modules/transactions/transactions.controller.ts +++ b/src/modules/transactions/transactions.controller.ts @@ -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); } } diff --git a/src/modules/transactions/transactions.module.ts b/src/modules/transactions/transactions.module.ts index 9ec7de0..2f363ce 100644 --- a/src/modules/transactions/transactions.module.ts +++ b/src/modules/transactions/transactions.module.ts @@ -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],