init
This commit is contained in:
parent
221878529f
commit
640632ca7e
@ -32,9 +32,13 @@ LOCKOUT_DURATION_MINUTES=30
|
|||||||
|
|
||||||
# AI Integration (Phase 2)
|
# AI Integration (Phase 2)
|
||||||
DEEPSEEK_API_KEY=
|
DEEPSEEK_API_KEY=
|
||||||
OPENROUTER_API_KEY=
|
|
||||||
AI_SERVICE_URL=http://localhost:8000
|
AI_SERVICE_URL=http://localhost:8000
|
||||||
AI_ENABLED=false
|
AI_ENABLED=true
|
||||||
|
|
||||||
|
OPENROUTER_API_KEY=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
|
||||||
|
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
OPENROUTER_MODEL=openai/gpt-oss-20b:free
|
||||||
|
OPENROUTER_TIMEOUT_MS=20000
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|||||||
@ -33,6 +33,9 @@ LOCKOUT_DURATION_MINUTES=30
|
|||||||
# AI Integration (Phase 2 - DeepSeek via OpenRouter)
|
# AI Integration (Phase 2 - DeepSeek via OpenRouter)
|
||||||
DEEPSEEK_API_KEY=
|
DEEPSEEK_API_KEY=
|
||||||
OPENROUTER_API_KEY=
|
OPENROUTER_API_KEY=
|
||||||
|
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||||
|
OPENROUTER_MODEL=openai/gpt-oss-120b:free
|
||||||
|
OPENROUTER_TIMEOUT_MS=20000
|
||||||
AI_SERVICE_URL=http://localhost:8000
|
AI_SERVICE_URL=http://localhost:8000
|
||||||
AI_ENABLED=false
|
AI_ENABLED=false
|
||||||
|
|
||||||
|
|||||||
61
.gitea/workflows/deploy-production.yml
Normal file
61
.gitea/workflows/deploy-production.yml
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
name: Deploy Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy_production:
|
||||||
|
name: Deploy to Production
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: production
|
||||||
|
url: https://api-finance.ai-assistant-bot.xyz
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install SSH and rsync
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends openssh-client rsync
|
||||||
|
|
||||||
|
- name: Configure SSH
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.SSH_PRIVATE_KEY }}" | tr -d '\r' > ~/.ssh/id_rsa
|
||||||
|
chmod 600 ~/.ssh/id_rsa
|
||||||
|
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Ensure remote directory exists
|
||||||
|
run: |
|
||||||
|
ssh -o StrictHostKeyChecking=yes "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
|
||||||
|
"mkdir -p /opt/apps/api-finance"
|
||||||
|
|
||||||
|
- name: Sync repository to server
|
||||||
|
run: |
|
||||||
|
rsync -az --delete \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='.env' \
|
||||||
|
--exclude='.env.*' \
|
||||||
|
--exclude='node_modules' \
|
||||||
|
--exclude='coverage' \
|
||||||
|
--exclude='dist' \
|
||||||
|
./ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:/opt/apps/api-finance/"
|
||||||
|
|
||||||
|
- name: Rebuild and restart Docker Compose
|
||||||
|
run: |
|
||||||
|
ssh -o StrictHostKeyChecking=yes "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" "
|
||||||
|
set -e
|
||||||
|
cd /opt/apps/api-finance
|
||||||
|
docker compose -f docker-compose.server.yml pull
|
||||||
|
docker compose -f docker-compose.server.yml up -d --build
|
||||||
|
docker image prune -f
|
||||||
|
"
|
||||||
|
|
||||||
|
- name: Optional: Check service health
|
||||||
|
run: |
|
||||||
|
ssh -o StrictHostKeyChecking=yes "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" "
|
||||||
|
set -e
|
||||||
|
curl --fail --silent --show-error https://api-finance.ai-assistant-bot.xyz/ || exit 1
|
||||||
|
"
|
||||||
43
package-lock.json
generated
43
package-lock.json
generated
@ -18,6 +18,7 @@
|
|||||||
"@nestjs/swagger": "^7.4.0",
|
"@nestjs/swagger": "^7.4.0",
|
||||||
"@nestjs/throttler": "^5.1.2",
|
"@nestjs/throttler": "^5.1.2",
|
||||||
"@nestjs/typeorm": "^10.0.2",
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
|
"axios": "^1.6.8",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
@ -4640,7 +4641,6 @@
|
|||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/available-typed-arrays": {
|
"node_modules/available-typed-arrays": {
|
||||||
@ -4658,6 +4658,17 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.13.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||||
|
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.15.6",
|
||||||
|
"form-data": "^4.0.4",
|
||||||
|
"proxy-from-env": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/b4a": {
|
"node_modules/b4a": {
|
||||||
"version": "1.7.3",
|
"version": "1.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz",
|
||||||
@ -5490,7 +5501,6 @@
|
|||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"delayed-stream": "~1.0.0"
|
"delayed-stream": "~1.0.0"
|
||||||
@ -5874,7 +5884,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
@ -6124,7 +6133,6 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@ -6924,6 +6932,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.15.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
|
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/for-each": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@ -7029,7 +7057,6 @@
|
|||||||
"version": "4.0.5",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
@ -10162,6 +10189,12 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
|||||||
@ -35,6 +35,7 @@
|
|||||||
"@nestjs/swagger": "^7.4.0",
|
"@nestjs/swagger": "^7.4.0",
|
||||||
"@nestjs/throttler": "^5.1.2",
|
"@nestjs/throttler": "^5.1.2",
|
||||||
"@nestjs/typeorm": "^10.0.2",
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
|
"axios": "^1.6.8",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
|
|||||||
@ -17,15 +17,20 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
const response = ctx.getResponse<Response>();
|
const response = ctx.getResponse<Response>();
|
||||||
const request = ctx.getRequest<Request>();
|
const request = ctx.getRequest<Request>();
|
||||||
|
|
||||||
|
if (response.headersSent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
let message = 'Внутренняя ошибка сервера';
|
let message = 'Внутренняя ошибка сервера';
|
||||||
|
|
||||||
if (exception instanceof HttpException) {
|
if (exception instanceof HttpException) {
|
||||||
status = exception.getStatus();
|
status = exception.getStatus();
|
||||||
const exceptionResponse = exception.getResponse();
|
const exceptionResponse = exception.getResponse();
|
||||||
message = typeof exceptionResponse === 'string'
|
message =
|
||||||
? exceptionResponse
|
typeof exceptionResponse === 'string'
|
||||||
: (exceptionResponse as any).message || message;
|
? exceptionResponse
|
||||||
|
: (exceptionResponse as any).message || message;
|
||||||
} else if (exception instanceof Error) {
|
} else if (exception instanceof Error) {
|
||||||
// Log the actual error for debugging
|
// Log the actual error for debugging
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
|
|||||||
@ -3,6 +3,13 @@ import { registerAs } from '@nestjs/config';
|
|||||||
export default registerAs('ai', () => ({
|
export default registerAs('ai', () => ({
|
||||||
deepseekApiKey: process.env.DEEPSEEK_API_KEY || '',
|
deepseekApiKey: process.env.DEEPSEEK_API_KEY || '',
|
||||||
openrouterApiKey: process.env.OPENROUTER_API_KEY || '',
|
openrouterApiKey: process.env.OPENROUTER_API_KEY || '',
|
||||||
|
openrouterBaseUrl:
|
||||||
|
process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1',
|
||||||
|
openrouterModel: process.env.OPENROUTER_MODEL || 'openai/gpt-oss-120b:free',
|
||||||
|
openrouterTimeoutMs: parseInt(
|
||||||
|
process.env.OPENROUTER_TIMEOUT_MS || '20000',
|
||||||
|
10,
|
||||||
|
),
|
||||||
serviceUrl: process.env.AI_SERVICE_URL || 'http://localhost:8000',
|
serviceUrl: process.env.AI_SERVICE_URL || 'http://localhost:8000',
|
||||||
enabled: process.env.AI_ENABLED === 'true',
|
enabled: process.env.AI_ENABLED === 'true',
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI Service Placeholder for DeepSeek Integration via OpenRouter
|
* AI Service Placeholder for DeepSeek Integration via OpenRouter
|
||||||
@ -17,6 +18,7 @@ export interface TransactionCategorizationResponse {
|
|||||||
suggestedCategoryName: string;
|
suggestedCategoryName: string;
|
||||||
confidence: number;
|
confidence: number;
|
||||||
reasoning: string;
|
reasoning: string;
|
||||||
|
source?: 'openrouter' | 'mock';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpendingAnalysisRequest {
|
export interface SpendingAnalysisRequest {
|
||||||
@ -38,18 +40,56 @@ export interface SpendingAnalysisResponse {
|
|||||||
}>;
|
}>;
|
||||||
insights: string[];
|
insights: string[];
|
||||||
recommendations: string[];
|
recommendations: string[];
|
||||||
|
source?: 'openrouter' | 'mock';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FinancialRecommendation {
|
export interface FinancialRecommendation {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'SAVING' | 'SPENDING' | 'INVESTMENT' | 'TAX' | 'DEBT';
|
type:
|
||||||
|
| 'SAVING'
|
||||||
|
| 'SPENDING'
|
||||||
|
| 'INVESTMENT'
|
||||||
|
| 'TAX'
|
||||||
|
| 'DEBT'
|
||||||
|
| 'BUDGET'
|
||||||
|
| 'GOAL';
|
||||||
titleRu: string;
|
titleRu: string;
|
||||||
descriptionRu: string;
|
descriptionRu: string;
|
||||||
priorityScore: number;
|
priorityScore: number;
|
||||||
confidenceScore: number;
|
confidenceScore: number;
|
||||||
actionData?: Record<string, any>;
|
actionData?: Record<string, any>;
|
||||||
|
source?: 'openrouter' | 'mock';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsNarrativeRequest {
|
||||||
|
period: string;
|
||||||
|
totals: {
|
||||||
|
income: number;
|
||||||
|
expenses: number;
|
||||||
|
netSavings: number;
|
||||||
|
savingsRate: number;
|
||||||
|
};
|
||||||
|
topCategories: Array<{ name: string; amount: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsNarrativeResponse {
|
||||||
|
summaryRu: string;
|
||||||
|
insightsRu: string[];
|
||||||
|
actionsRu: string[];
|
||||||
|
source?: 'openrouter' | 'mock';
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenRouterChatMessage = {
|
||||||
|
role: 'system' | 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenRouterChatCompletionResponse = {
|
||||||
|
choices?: Array<{
|
||||||
|
message?: { content?: string };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ForecastRequest {
|
export interface ForecastRequest {
|
||||||
userId: string;
|
userId: string;
|
||||||
historicalData: Array<{
|
historicalData: Array<{
|
||||||
@ -78,13 +118,45 @@ export interface ForecastResponse {
|
|||||||
export class AiService {
|
export class AiService {
|
||||||
private readonly logger = new Logger(AiService.name);
|
private readonly logger = new Logger(AiService.name);
|
||||||
private readonly isEnabled: boolean;
|
private readonly isEnabled: boolean;
|
||||||
|
private readonly http: AxiosInstance;
|
||||||
|
private readonly openrouterApiKey: string;
|
||||||
|
private readonly openrouterBaseUrl: string;
|
||||||
|
private readonly openrouterModel: string;
|
||||||
|
private readonly openrouterTimeoutMs: number;
|
||||||
|
|
||||||
constructor(private configService: ConfigService) {
|
constructor(private configService: ConfigService) {
|
||||||
this.isEnabled = this.configService.get<boolean>('ai.enabled') || false;
|
this.openrouterApiKey =
|
||||||
|
this.configService.get<string>('ai.openrouterApiKey') || '';
|
||||||
if (!this.isEnabled) {
|
this.openrouterBaseUrl =
|
||||||
this.logger.warn('AI Service is disabled. Using mock implementations.');
|
this.configService.get<string>('ai.openrouterBaseUrl') ||
|
||||||
|
'https://openrouter.ai/api/v1';
|
||||||
|
this.openrouterModel =
|
||||||
|
this.configService.get<string>('ai.openrouterModel') ||
|
||||||
|
'openai/gpt-oss-120b:free';
|
||||||
|
this.openrouterTimeoutMs =
|
||||||
|
this.configService.get<number>('ai.openrouterTimeoutMs') || 20000;
|
||||||
|
|
||||||
|
const enabledFlag = this.configService.get<boolean>('ai.enabled') || false;
|
||||||
|
this.isEnabled = enabledFlag && !!this.openrouterApiKey;
|
||||||
|
|
||||||
|
if (!enabledFlag) {
|
||||||
|
this.logger.warn(
|
||||||
|
'AI Service is disabled (AI_ENABLED=false). Using mock implementations.',
|
||||||
|
);
|
||||||
|
} else if (!this.openrouterApiKey) {
|
||||||
|
this.logger.warn(
|
||||||
|
'AI Service enabled but OPENROUTER_API_KEY is missing. Using mock implementations.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.http = axios.create({
|
||||||
|
baseURL: this.openrouterBaseUrl,
|
||||||
|
timeout: this.openrouterTimeoutMs,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${this.openrouterApiKey}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -100,9 +172,38 @@ export class AiService {
|
|||||||
return this.mockCategorizeTransaction(request);
|
return this.mockCategorizeTransaction(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement DeepSeek API call via OpenRouter
|
try {
|
||||||
// const response = await this.callDeepSeek('categorize', request);
|
const messages: OpenRouterChatMessage[] = [
|
||||||
return this.mockCategorizeTransaction(request);
|
{
|
||||||
|
role: 'system',
|
||||||
|
content:
|
||||||
|
'Ты финансовый ассистент. Подбери категорию транзакции. Верни ОДНУ строку валидного JSON (без markdown, без пояснений, без переносов строк). ВАЖНО: внутри строк обязательно экранируй кавычки и переносы (используй \\" и \\n). Не ставь запятых после последнего поля. Держи строки короткими (до 200 символов). Формат строго: {"suggestedCategoryName":"...","confidence":0.0,"reasoning":"..."}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: JSON.stringify(request),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const json = await this.callOpenRouterJson<{
|
||||||
|
suggestedCategoryName: string;
|
||||||
|
confidence: number;
|
||||||
|
reasoning: string;
|
||||||
|
}>(messages);
|
||||||
|
|
||||||
|
return {
|
||||||
|
suggestedCategoryId: 'unknown',
|
||||||
|
suggestedCategoryName: json.suggestedCategoryName || 'Другое',
|
||||||
|
confidence: this.clamp01(json.confidence ?? 0.5),
|
||||||
|
reasoning: json.reasoning || 'AI suggested category',
|
||||||
|
source: 'openrouter',
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(
|
||||||
|
`OpenRouter categorizeTransaction failed, using mock. Reason: ${(e as Error)?.message}`,
|
||||||
|
);
|
||||||
|
return this.mockCategorizeTransaction(request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -118,8 +219,39 @@ export class AiService {
|
|||||||
return this.mockAnalyzeSpending(request);
|
return this.mockAnalyzeSpending(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement DeepSeek API call via OpenRouter
|
try {
|
||||||
return this.mockAnalyzeSpending(request);
|
const safePayload = {
|
||||||
|
userId: request.userId,
|
||||||
|
startDate: request.startDate,
|
||||||
|
endDate: request.endDate,
|
||||||
|
transactions: request.transactions.slice(0, 200),
|
||||||
|
};
|
||||||
|
|
||||||
|
const messages: OpenRouterChatMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content:
|
||||||
|
'Ты финансовый аналитик. Проанализируй траты и верни ОДНУ строку валидного JSON (без markdown, без пояснений, без переносов строк). ВАЖНО: экранируй кавычки и переносы внутри строк (\\" и \\n), без лишних запятых. Держи массивы короткими (patterns<=5, insights<=5, recommendations<=5), строки до 200 символов. Формат строго: {"patterns":[{"pattern":"...","description":"...","impact":"positive|negative|neutral"}],"insights":["..."],"recommendations":["..."]}',
|
||||||
|
},
|
||||||
|
{ role: 'user', content: JSON.stringify(safePayload) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const json =
|
||||||
|
await this.callOpenRouterJson<SpendingAnalysisResponse>(messages);
|
||||||
|
return {
|
||||||
|
patterns: Array.isArray(json.patterns) ? json.patterns : [],
|
||||||
|
insights: Array.isArray(json.insights) ? json.insights : [],
|
||||||
|
recommendations: Array.isArray(json.recommendations)
|
||||||
|
? json.recommendations
|
||||||
|
: [],
|
||||||
|
source: 'openrouter',
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(
|
||||||
|
`OpenRouter analyzeSpending failed, using mock. Reason: ${(e as Error)?.message}`,
|
||||||
|
);
|
||||||
|
return this.mockAnalyzeSpending(request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -141,8 +273,49 @@ export class AiService {
|
|||||||
return this.mockGenerateRecommendations(context);
|
return this.mockGenerateRecommendations(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement DeepSeek API call via OpenRouter
|
try {
|
||||||
return this.mockGenerateRecommendations(context);
|
const messages: OpenRouterChatMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content:
|
||||||
|
'Ты персональный финансовый ассистент. Сгенерируй 3-5 рекомендаций на русском. Верни ОДНУ строку валидного JSON (без markdown, без пояснений, без переносов строк). ВАЖНО: экранируй кавычки и переносы внутри строк (\\" и \\n), без лишних запятых. titleRu<=120 символов, descriptionRu<=300 символов. priorityScore/confidenceScore: 0.0..1.0. actionData должен быть объектом (может быть пустым {}). Формат строго: {"recommendations":[{"id":"...","type":"SAVING|SPENDING|INVESTMENT|TAX|DEBT|BUDGET|GOAL","titleRu":"...","descriptionRu":"...","priorityScore":0.0,"confidenceScore":0.0,"actionData":{}}]}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: JSON.stringify({ userId, context }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const json = await this.callOpenRouterJson<{
|
||||||
|
recommendations: FinancialRecommendation[];
|
||||||
|
}>(messages);
|
||||||
|
const recs = Array.isArray(json.recommendations)
|
||||||
|
? json.recommendations
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const normalized = recs
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((r) => ({
|
||||||
|
id: r.id || `ai-${Date.now()}`,
|
||||||
|
type: r.type,
|
||||||
|
titleRu: r.titleRu,
|
||||||
|
descriptionRu: r.descriptionRu,
|
||||||
|
priorityScore: this.clamp01(Number(r.priorityScore ?? 0.5)),
|
||||||
|
confidenceScore: this.clamp01(Number(r.confidenceScore ?? 0.5)),
|
||||||
|
actionData: r.actionData,
|
||||||
|
source: 'openrouter' as const,
|
||||||
|
}))
|
||||||
|
.filter((r) => !!r.titleRu && !!r.descriptionRu);
|
||||||
|
|
||||||
|
return normalized.length > 0
|
||||||
|
? normalized
|
||||||
|
: this.mockGenerateRecommendations(context);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(
|
||||||
|
`OpenRouter generateRecommendations failed, using mock. Reason: ${(e as Error)?.message}`,
|
||||||
|
);
|
||||||
|
return this.mockGenerateRecommendations(context);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -156,10 +329,238 @@ export class AiService {
|
|||||||
return this.mockForecastFinances(request);
|
return this.mockForecastFinances(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement DeepSeek API call via OpenRouter
|
// Forecasting can be expensive; keep mock fallback for now.
|
||||||
return this.mockForecastFinances(request);
|
return this.mockForecastFinances(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an AI narrative for analytics dashboards (C option)
|
||||||
|
* Returns mock-like output when AI is disabled or fails.
|
||||||
|
*/
|
||||||
|
async generateAnalyticsNarrative(
|
||||||
|
request: AnalyticsNarrativeRequest,
|
||||||
|
): Promise<AnalyticsNarrativeResponse> {
|
||||||
|
if (!this.isEnabled) {
|
||||||
|
return {
|
||||||
|
summaryRu: 'Краткий обзор недоступен (AI отключен).',
|
||||||
|
insightsRu: [],
|
||||||
|
actionsRu: [],
|
||||||
|
source: 'mock',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const safePayload = {
|
||||||
|
...request,
|
||||||
|
topCategories: request.topCategories.slice(0, 8),
|
||||||
|
};
|
||||||
|
|
||||||
|
const messages: OpenRouterChatMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content:
|
||||||
|
'Ты финансовый аналитик. Сформируй краткий обзор по метрикам. Верни ОДНУ строку валидного JSON (без markdown, без пояснений, без переносов строк). ВАЖНО: экранируй кавычки и переносы внутри строк (\\" и \\n), без лишних запятых. summaryRu<=300 символов, insightsRu<=5, actionsRu<=5, строки до 200 символов. Формат строго: {"summaryRu":"...","insightsRu":["..."],"actionsRu":["..."]}',
|
||||||
|
},
|
||||||
|
{ role: 'user', content: JSON.stringify(safePayload) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const json =
|
||||||
|
await this.callOpenRouterJson<AnalyticsNarrativeResponse>(messages);
|
||||||
|
|
||||||
|
return {
|
||||||
|
summaryRu: json.summaryRu || 'Обзор недоступен',
|
||||||
|
insightsRu: Array.isArray(json.insightsRu) ? json.insightsRu : [],
|
||||||
|
actionsRu: Array.isArray(json.actionsRu) ? json.actionsRu : [],
|
||||||
|
source: 'openrouter',
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(
|
||||||
|
`OpenRouter generateAnalyticsNarrative failed. Reason: ${(e as Error)?.message}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
summaryRu: 'Не удалось получить AI-обзор. Попробуйте позже.',
|
||||||
|
insightsRu: [],
|
||||||
|
actionsRu: [],
|
||||||
|
source: 'mock',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clamp01(value: number): number {
|
||||||
|
if (Number.isNaN(value)) return 0.5;
|
||||||
|
return Math.max(0, Math.min(1, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async callOpenRouterJson<T>(
|
||||||
|
messages: OpenRouterChatMessage[],
|
||||||
|
): Promise<T> {
|
||||||
|
const model = this.openrouterModel;
|
||||||
|
|
||||||
|
const maxAttempts = 2;
|
||||||
|
let lastError: unknown;
|
||||||
|
|
||||||
|
let repairHintAdded = false;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
const attemptMessages = repairHintAdded
|
||||||
|
? [
|
||||||
|
...messages,
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content:
|
||||||
|
'Твой предыдущий ответ был НЕВАЛИДНЫМ JSON (ошибка парсинга). Верни снова ответ ТОЛЬКО как валидный JSON (без markdown, без пояснений, без лишнего текста).',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: messages;
|
||||||
|
|
||||||
|
// Keep response size controlled (reduce truncation risk for strict JSON)
|
||||||
|
const payload = {
|
||||||
|
model,
|
||||||
|
messages: attemptMessages,
|
||||||
|
temperature: 0,
|
||||||
|
max_tokens: 500,
|
||||||
|
// Some OpenRouter providers support OpenAI-style JSON enforcement.
|
||||||
|
// If unsupported, it should be ignored; we still validate/repair/fallback.
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const resp = await this.http.post<OpenRouterChatCompletionResponse>(
|
||||||
|
'/chat/completions',
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'HTTP-Referer': 'http://localhost',
|
||||||
|
'X-Title': 'Finance App',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = resp.data?.choices?.[0]?.message?.content;
|
||||||
|
if (!content) {
|
||||||
|
throw new Error('OpenRouter response content is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
const extracted = this.extractJson(content);
|
||||||
|
return JSON.parse(extracted) as T;
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e;
|
||||||
|
const anyErr = e as any;
|
||||||
|
const msg = anyErr?.message || String(e);
|
||||||
|
const status = anyErr?.response?.status as number | undefined;
|
||||||
|
|
||||||
|
this.logger.warn(
|
||||||
|
`OpenRouter attempt ${attempt}/${maxAttempts} failed${status ? ` (status ${status})` : ''}: ${msg}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If this isn't the last attempt, wait a bit before retrying.
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
let delayMs = 500 * Math.pow(2, attempt - 1); // 500ms, 1000ms...
|
||||||
|
|
||||||
|
// If model returned invalid JSON, retry once with a strict repair hint.
|
||||||
|
if (
|
||||||
|
!repairHintAdded &&
|
||||||
|
/\bJSON\b/i.test(msg) &&
|
||||||
|
/position|unterminated|unexpected|parse/i.test(msg)
|
||||||
|
) {
|
||||||
|
repairHintAdded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If rate limited, honor Retry-After header when possible.
|
||||||
|
if (status === 429) {
|
||||||
|
const retryAfterHeader = anyErr?.response?.headers?.['retry-after'];
|
||||||
|
const retryAfterSec = retryAfterHeader
|
||||||
|
? Number(retryAfterHeader)
|
||||||
|
: NaN;
|
||||||
|
if (!Number.isNaN(retryAfterSec) && retryAfterSec > 0) {
|
||||||
|
delayMs = Math.min(30000, retryAfterSec * 1000);
|
||||||
|
} else {
|
||||||
|
delayMs = Math.min(30000, 2000 * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.sleep(delayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractJson(text: string): string {
|
||||||
|
// Some models may wrap JSON in text; attempt to extract first JSON object/array.
|
||||||
|
const firstBrace = text.indexOf('{');
|
||||||
|
const firstBracket = text.indexOf('[');
|
||||||
|
|
||||||
|
let start = -1;
|
||||||
|
if (firstBrace === -1) start = firstBracket;
|
||||||
|
else if (firstBracket === -1) start = firstBrace;
|
||||||
|
else start = Math.min(firstBrace, firstBracket);
|
||||||
|
|
||||||
|
if (start === -1) {
|
||||||
|
throw new Error('No JSON found in model output');
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = text
|
||||||
|
.slice(start)
|
||||||
|
.trim()
|
||||||
|
.replace(/```[a-z]*\s*/gi, '')
|
||||||
|
.replace(/```\s*$/g, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return this.extractBalancedJson(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractBalancedJson(candidate: string): string {
|
||||||
|
const startChar = candidate[0];
|
||||||
|
const endChar = startChar === '{' ? '}' : startChar === '[' ? ']' : '';
|
||||||
|
if (!endChar) {
|
||||||
|
throw new Error('JSON must start with { or [');
|
||||||
|
}
|
||||||
|
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escape = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < candidate.length; i++) {
|
||||||
|
const ch = candidate[i];
|
||||||
|
|
||||||
|
if (inString) {
|
||||||
|
if (escape) {
|
||||||
|
escape = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '\\') {
|
||||||
|
escape = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') {
|
||||||
|
inString = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '"') {
|
||||||
|
inString = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === startChar) depth++;
|
||||||
|
if (ch === endChar) depth--;
|
||||||
|
|
||||||
|
if (depth === 0) {
|
||||||
|
return candidate.slice(0, i + 1).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we couldn't find a balanced end, return full candidate (JSON.parse will fail with a useful error)
|
||||||
|
return candidate.trim();
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Mock Implementations (Phase 1)
|
// Mock Implementations (Phase 1)
|
||||||
// ============================================
|
// ============================================
|
||||||
@ -171,17 +572,17 @@ export class AiService {
|
|||||||
|
|
||||||
// Simple keyword-based categorization
|
// Simple keyword-based categorization
|
||||||
const categoryMap: Record<string, { id: string; name: string }> = {
|
const categoryMap: Record<string, { id: string; name: string }> = {
|
||||||
'продукты': { id: 'groceries', name: 'Продукты' },
|
продукты: { id: 'groceries', name: 'Продукты' },
|
||||||
'пятерочка': { id: 'groceries', name: 'Продукты' },
|
пятерочка: { id: 'groceries', name: 'Продукты' },
|
||||||
'магнит': { id: 'groceries', name: 'Продукты' },
|
магнит: { id: 'groceries', name: 'Продукты' },
|
||||||
'такси': { id: 'transport', name: 'Транспорт' },
|
такси: { id: 'transport', name: 'Транспорт' },
|
||||||
'яндекс': { id: 'transport', name: 'Транспорт' },
|
яндекс: { id: 'transport', name: 'Транспорт' },
|
||||||
'метро': { id: 'transport', name: 'Транспорт' },
|
метро: { id: 'transport', name: 'Транспорт' },
|
||||||
'ресторан': { id: 'restaurants', name: 'Рестораны и кафе' },
|
ресторан: { id: 'restaurants', name: 'Рестораны и кафе' },
|
||||||
'кафе': { id: 'restaurants', name: 'Рестораны и кафе' },
|
кафе: { id: 'restaurants', name: 'Рестораны и кафе' },
|
||||||
'аптека': { id: 'healthcare', name: 'Медицина' },
|
аптека: { id: 'healthcare', name: 'Медицина' },
|
||||||
'жкх': { id: 'utilities', name: 'Коммуналка' },
|
жкх: { id: 'utilities', name: 'Коммуналка' },
|
||||||
'электричество': { id: 'utilities', name: 'Коммуналка' },
|
электричество: { id: 'utilities', name: 'Коммуналка' },
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const [keyword, category] of Object.entries(categoryMap)) {
|
for (const [keyword, category] of Object.entries(categoryMap)) {
|
||||||
@ -191,6 +592,7 @@ export class AiService {
|
|||||||
suggestedCategoryName: category.name,
|
suggestedCategoryName: category.name,
|
||||||
confidence: 0.85,
|
confidence: 0.85,
|
||||||
reasoning: `Ключевое слово "${keyword}" найдено в описании`,
|
reasoning: `Ключевое слово "${keyword}" найдено в описании`,
|
||||||
|
source: 'mock',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -200,6 +602,7 @@ export class AiService {
|
|||||||
suggestedCategoryName: 'Другое',
|
suggestedCategoryName: 'Другое',
|
||||||
confidence: 0.3,
|
confidence: 0.3,
|
||||||
reasoning: 'Не удалось определить категорию по описанию',
|
reasoning: 'Не удалось определить категорию по описанию',
|
||||||
|
source: 'mock',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -229,6 +632,7 @@ export class AiService {
|
|||||||
'Увеличьте автоматические переводы на накопления до 20%',
|
'Увеличьте автоматические переводы на накопления до 20%',
|
||||||
'Используйте карту с кэшбэком для регулярных покупок',
|
'Используйте карту с кэшбэком для регулярных покупок',
|
||||||
],
|
],
|
||||||
|
source: 'mock',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -253,6 +657,7 @@ export class AiService {
|
|||||||
targetSavingsRate: 20,
|
targetSavingsRate: 20,
|
||||||
monthlySavingsTarget: context.monthlyIncome * 0.2,
|
monthlySavingsTarget: context.monthlyIncome * 0.2,
|
||||||
},
|
},
|
||||||
|
source: 'mock',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -261,9 +666,11 @@ export class AiService {
|
|||||||
id: 'tax-deduction',
|
id: 'tax-deduction',
|
||||||
type: 'TAX',
|
type: 'TAX',
|
||||||
titleRu: 'Налоговый вычет',
|
titleRu: 'Налоговый вычет',
|
||||||
descriptionRu: 'Проверьте возможность получения налогового вычета за медицинские услуги или образование (3-НДФЛ).',
|
descriptionRu:
|
||||||
|
'Проверьте возможность получения налогового вычета за медицинские услуги или образование (3-НДФЛ).',
|
||||||
priorityScore: 0.7,
|
priorityScore: 0.7,
|
||||||
confidenceScore: 0.8,
|
confidenceScore: 0.8,
|
||||||
|
source: 'mock',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Emergency fund recommendation
|
// Emergency fund recommendation
|
||||||
@ -271,12 +678,14 @@ export class AiService {
|
|||||||
id: 'emergency-fund',
|
id: 'emergency-fund',
|
||||||
type: 'SAVING',
|
type: 'SAVING',
|
||||||
titleRu: 'Резервный фонд',
|
titleRu: 'Резервный фонд',
|
||||||
descriptionRu: 'Рекомендуем создать резервный фонд в размере 3-6 месячных расходов.',
|
descriptionRu:
|
||||||
|
'Рекомендуем создать резервный фонд в размере 3-6 месячных расходов.',
|
||||||
priorityScore: 0.85,
|
priorityScore: 0.85,
|
||||||
confidenceScore: 0.9,
|
confidenceScore: 0.9,
|
||||||
actionData: {
|
actionData: {
|
||||||
targetAmount: context.totalExpenses * 6,
|
targetAmount: context.totalExpenses * 6,
|
||||||
},
|
},
|
||||||
|
source: 'mock',
|
||||||
});
|
});
|
||||||
|
|
||||||
return recommendations;
|
return recommendations;
|
||||||
|
|||||||
@ -1,19 +1,18 @@
|
|||||||
import {
|
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Query,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
|
ApiOkResponse,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiQuery,
|
ApiQuery,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { AnalyticsService } from './analytics.service';
|
import { AnalyticsService } from './analytics.service';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { CurrentUser, JwtPayload } from '../../common/decorators/user.decorator';
|
import {
|
||||||
|
CurrentUser,
|
||||||
|
JwtPayload,
|
||||||
|
} from '../../common/decorators/user.decorator';
|
||||||
import { TransactionType } from '../../common/constants/categories';
|
import { TransactionType } from '../../common/constants/categories';
|
||||||
|
|
||||||
@ApiTags('Аналитика')
|
@ApiTags('Аналитика')
|
||||||
@ -25,8 +24,32 @@ export class AnalyticsController {
|
|||||||
|
|
||||||
@Get('overview')
|
@Get('overview')
|
||||||
@ApiOperation({ summary: 'Обзор за месяц' })
|
@ApiOperation({ summary: 'Обзор за месяц' })
|
||||||
@ApiQuery({ name: 'month', required: false, example: '2024-01', description: 'Месяц в формате YYYY-MM' })
|
@ApiQuery({
|
||||||
@ApiResponse({ status: 200, description: 'Обзор за месяц' })
|
name: 'month',
|
||||||
|
required: false,
|
||||||
|
example: '2024-01',
|
||||||
|
description: 'Месяц в формате YYYY-MM',
|
||||||
|
})
|
||||||
|
@ApiOkResponse({
|
||||||
|
description: 'Обзор за месяц',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
month: '2024-01',
|
||||||
|
totalIncome: 120000,
|
||||||
|
totalExpenses: 85000,
|
||||||
|
netSavings: 35000,
|
||||||
|
savingsRate: 29.17,
|
||||||
|
topCategories: [
|
||||||
|
{
|
||||||
|
categoryId: 'uuid',
|
||||||
|
categoryName: 'Продукты',
|
||||||
|
amount: 22000,
|
||||||
|
percentage: 25.88,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
async getMonthlyOverview(
|
async getMonthlyOverview(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Query('month') month?: string,
|
@Query('month') month?: string,
|
||||||
@ -35,10 +58,45 @@ export class AnalyticsController {
|
|||||||
return this.analyticsService.getMonthlyOverview(user.sub, date);
|
return this.analyticsService.getMonthlyOverview(user.sub, date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('narrative')
|
||||||
|
@ApiOperation({ summary: 'AI обзор и рекомендации за месяц' })
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'month',
|
||||||
|
required: false,
|
||||||
|
example: '2024-01',
|
||||||
|
description: 'Месяц в формате YYYY-MM',
|
||||||
|
})
|
||||||
|
@ApiOkResponse({
|
||||||
|
description: 'AI обзор за месяц',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
summaryRu: 'В этом месяце вы сэкономили 29% дохода.',
|
||||||
|
insightsRu: ['Самая крупная категория расходов — Продукты.'],
|
||||||
|
actionsRu: ['Установите лимит на рестораны на следующий месяц.'],
|
||||||
|
source: 'openrouter',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
async getMonthlyNarrative(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Query('month') month?: string,
|
||||||
|
) {
|
||||||
|
const date = month ? new Date(month + '-01') : new Date();
|
||||||
|
return this.analyticsService.getMonthlyNarrative(user.sub, date);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('trends')
|
@Get('trends')
|
||||||
@ApiOperation({ summary: 'Тренды расходов' })
|
@ApiOperation({ summary: 'Тренды расходов' })
|
||||||
@ApiQuery({ name: 'months', required: false, example: 6 })
|
@ApiQuery({ name: 'months', required: false, example: 6 })
|
||||||
@ApiResponse({ status: 200, description: 'Тренды расходов по месяцам' })
|
@ApiOkResponse({
|
||||||
|
description: 'Тренды расходов по месяцам',
|
||||||
|
schema: {
|
||||||
|
example: [
|
||||||
|
{ period: '2024-01', amount: 85000, change: 0, changePercent: 0 },
|
||||||
|
{ period: '2024-02', amount: 90000, change: 5000, changePercent: 5.88 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
async getSpendingTrends(
|
async getSpendingTrends(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Query('months') months?: number,
|
@Query('months') months?: number,
|
||||||
@ -51,7 +109,23 @@ export class AnalyticsController {
|
|||||||
@ApiQuery({ name: 'startDate', required: true, example: '2024-01-01' })
|
@ApiQuery({ name: 'startDate', required: true, example: '2024-01-01' })
|
||||||
@ApiQuery({ name: 'endDate', required: true, example: '2024-01-31' })
|
@ApiQuery({ name: 'endDate', required: true, example: '2024-01-31' })
|
||||||
@ApiQuery({ name: 'type', enum: TransactionType, required: false })
|
@ApiQuery({ name: 'type', enum: TransactionType, required: false })
|
||||||
@ApiResponse({ status: 200, description: 'Разбивка расходов по категориям' })
|
@ApiOkResponse({
|
||||||
|
description: 'Разбивка расходов по категориям',
|
||||||
|
schema: {
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
categoryId: 'uuid',
|
||||||
|
categoryName: 'Продукты',
|
||||||
|
categoryIcon: 'shopping-cart',
|
||||||
|
categoryColor: '#4CAF50',
|
||||||
|
amount: 22000,
|
||||||
|
percentage: 25.88,
|
||||||
|
transactionCount: 12,
|
||||||
|
averageTransaction: 1833.33,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
async getCategoryBreakdown(
|
async getCategoryBreakdown(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Query('startDate') startDate: string,
|
@Query('startDate') startDate: string,
|
||||||
@ -69,7 +143,15 @@ export class AnalyticsController {
|
|||||||
@Get('income-vs-expenses')
|
@Get('income-vs-expenses')
|
||||||
@ApiOperation({ summary: 'Сравнение доходов и расходов' })
|
@ApiOperation({ summary: 'Сравнение доходов и расходов' })
|
||||||
@ApiQuery({ name: 'months', required: false, example: 12 })
|
@ApiQuery({ name: 'months', required: false, example: 12 })
|
||||||
@ApiResponse({ status: 200, description: 'Сравнение по месяцам' })
|
@ApiOkResponse({
|
||||||
|
description: 'Сравнение по месяцам',
|
||||||
|
schema: {
|
||||||
|
example: [
|
||||||
|
{ month: '2024-01', income: 120000, expenses: 85000, balance: 35000 },
|
||||||
|
{ month: '2024-02', income: 120000, expenses: 90000, balance: 30000 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
async getIncomeVsExpenses(
|
async getIncomeVsExpenses(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Query('months') months?: number,
|
@Query('months') months?: number,
|
||||||
@ -79,7 +161,23 @@ export class AnalyticsController {
|
|||||||
|
|
||||||
@Get('health')
|
@Get('health')
|
||||||
@ApiOperation({ summary: 'Оценка финансового здоровья' })
|
@ApiOperation({ summary: 'Оценка финансового здоровья' })
|
||||||
@ApiResponse({ status: 200, description: 'Оценка и рекомендации' })
|
@ApiOkResponse({
|
||||||
|
description: 'Оценка и рекомендации',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
score: 78,
|
||||||
|
grade: 'B',
|
||||||
|
factors: [
|
||||||
|
{
|
||||||
|
name: 'Норма сбережений',
|
||||||
|
score: 75,
|
||||||
|
description: 'Вы сохраняете около 20% дохода.',
|
||||||
|
recommendation: 'Попробуйте увеличить до 25% в следующем месяце.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
async getFinancialHealth(@CurrentUser() user: JwtPayload) {
|
async getFinancialHealth(@CurrentUser() user: JwtPayload) {
|
||||||
return this.analyticsService.getFinancialHealth(user.sub);
|
return this.analyticsService.getFinancialHealth(user.sub);
|
||||||
}
|
}
|
||||||
@ -87,11 +185,26 @@ export class AnalyticsController {
|
|||||||
@Get('yearly')
|
@Get('yearly')
|
||||||
@ApiOperation({ summary: 'Годовой отчет' })
|
@ApiOperation({ summary: 'Годовой отчет' })
|
||||||
@ApiQuery({ name: 'year', required: false, example: 2024 })
|
@ApiQuery({ name: 'year', required: false, example: 2024 })
|
||||||
@ApiResponse({ status: 200, description: 'Годовая сводка' })
|
@ApiOkResponse({
|
||||||
|
description: 'Годовая сводка',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
year: 2024,
|
||||||
|
totalIncome: 1440000,
|
||||||
|
totalExpenses: 1020000,
|
||||||
|
netSavings: 420000,
|
||||||
|
savingsRate: 29.17,
|
||||||
|
topExpenseCategories: [{ categoryName: 'Продукты', amount: 240000 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
async getYearlySummary(
|
async getYearlySummary(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Query('year') year?: number,
|
@Query('year') year?: number,
|
||||||
) {
|
) {
|
||||||
return this.analyticsService.getYearlySummary(user.sub, year || new Date().getFullYear());
|
return this.analyticsService.getYearlySummary(
|
||||||
|
user.sub,
|
||||||
|
year || new Date().getFullYear(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,10 +6,12 @@ import { Transaction } from '../transactions/entities/transaction.entity';
|
|||||||
import { Category } from '../categories/entities/category.entity';
|
import { Category } from '../categories/entities/category.entity';
|
||||||
import { Budget } from '../budgets/entities/budget.entity';
|
import { Budget } from '../budgets/entities/budget.entity';
|
||||||
import { Goal } from '../goals/entities/goal.entity';
|
import { Goal } from '../goals/entities/goal.entity';
|
||||||
|
import { AiModule } from '../ai/ai.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Transaction, Category, Budget, Goal]),
|
TypeOrmModule.forFeature([Transaction, Category, Budget, Goal]),
|
||||||
|
AiModule,
|
||||||
],
|
],
|
||||||
controllers: [AnalyticsController],
|
controllers: [AnalyticsController],
|
||||||
providers: [AnalyticsService],
|
providers: [AnalyticsService],
|
||||||
|
|||||||
@ -5,9 +5,17 @@ import { Transaction } from '../transactions/entities/transaction.entity';
|
|||||||
import { Category } from '../categories/entities/category.entity';
|
import { Category } from '../categories/entities/category.entity';
|
||||||
import { Budget } from '../budgets/entities/budget.entity';
|
import { Budget } from '../budgets/entities/budget.entity';
|
||||||
import { Goal } from '../goals/entities/goal.entity';
|
import { Goal } from '../goals/entities/goal.entity';
|
||||||
import { startOfMonth, endOfMonth, subMonths, format, startOfYear, endOfYear } from 'date-fns';
|
import {
|
||||||
|
startOfMonth,
|
||||||
|
endOfMonth,
|
||||||
|
subMonths,
|
||||||
|
format,
|
||||||
|
startOfYear,
|
||||||
|
endOfYear,
|
||||||
|
} from 'date-fns';
|
||||||
import { formatRubles } from '../../common/utils/currency.utils';
|
import { formatRubles } from '../../common/utils/currency.utils';
|
||||||
import { TransactionType } from '../../common/constants/categories';
|
import { TransactionType } from '../../common/constants/categories';
|
||||||
|
import { AiService } from '../ai/ai.service';
|
||||||
|
|
||||||
export interface MonthlyOverview {
|
export interface MonthlyOverview {
|
||||||
month: string;
|
month: string;
|
||||||
@ -63,12 +71,16 @@ export class AnalyticsService {
|
|||||||
private budgetRepository: Repository<Budget>,
|
private budgetRepository: Repository<Budget>,
|
||||||
@InjectRepository(Goal)
|
@InjectRepository(Goal)
|
||||||
private goalRepository: Repository<Goal>,
|
private goalRepository: Repository<Goal>,
|
||||||
|
private aiService: AiService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get monthly overview for a specific month
|
* Get monthly overview for a specific month
|
||||||
*/
|
*/
|
||||||
async getMonthlyOverview(userId: string, date: Date = new Date()): Promise<MonthlyOverview> {
|
async getMonthlyOverview(
|
||||||
|
userId: string,
|
||||||
|
date: Date = new Date(),
|
||||||
|
): Promise<MonthlyOverview> {
|
||||||
const monthStart = startOfMonth(date);
|
const monthStart = startOfMonth(date);
|
||||||
const monthEnd = endOfMonth(date);
|
const monthEnd = endOfMonth(date);
|
||||||
|
|
||||||
@ -81,21 +93,22 @@ export class AnalyticsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const totalIncome = transactions
|
const totalIncome = transactions
|
||||||
.filter(t => t.type === TransactionType.INCOME)
|
.filter((t) => t.type === TransactionType.INCOME)
|
||||||
.reduce((sum, t) => sum + Number(t.amount), 0);
|
.reduce((sum, t) => sum + Number(t.amount), 0);
|
||||||
|
|
||||||
const totalExpenses = transactions
|
const totalExpenses = transactions
|
||||||
.filter(t => t.type === TransactionType.EXPENSE)
|
.filter((t) => t.type === TransactionType.EXPENSE)
|
||||||
.reduce((sum, t) => sum + Number(t.amount), 0);
|
.reduce((sum, t) => sum + Number(t.amount), 0);
|
||||||
|
|
||||||
const netSavings = totalIncome - totalExpenses;
|
const netSavings = totalIncome - totalExpenses;
|
||||||
const savingsRate = totalIncome > 0 ? (netSavings / totalIncome) * 100 : 0;
|
const savingsRate = totalIncome > 0 ? (netSavings / totalIncome) * 100 : 0;
|
||||||
|
|
||||||
// Calculate top spending categories
|
// Calculate top spending categories
|
||||||
const categorySpending: Record<string, { name: string; amount: number }> = {};
|
const categorySpending: Record<string, { name: string; amount: number }> =
|
||||||
|
{};
|
||||||
transactions
|
transactions
|
||||||
.filter(t => t.type === TransactionType.EXPENSE)
|
.filter((t) => t.type === TransactionType.EXPENSE)
|
||||||
.forEach(t => {
|
.forEach((t) => {
|
||||||
const catId = t.categoryId || 'other';
|
const catId = t.categoryId || 'other';
|
||||||
const catName = t.category?.nameRu || 'Другое';
|
const catName = t.category?.nameRu || 'Другое';
|
||||||
if (!categorySpending[catId]) {
|
if (!categorySpending[catId]) {
|
||||||
@ -124,10 +137,31 @@ export class AnalyticsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMonthlyNarrative(userId: string, date: Date = new Date()) {
|
||||||
|
const overview = await this.getMonthlyOverview(userId, date);
|
||||||
|
|
||||||
|
return this.aiService.generateAnalyticsNarrative({
|
||||||
|
period: overview.month,
|
||||||
|
totals: {
|
||||||
|
income: overview.totalIncome,
|
||||||
|
expenses: overview.totalExpenses,
|
||||||
|
netSavings: overview.netSavings,
|
||||||
|
savingsRate: overview.savingsRate,
|
||||||
|
},
|
||||||
|
topCategories: overview.topCategories.map((c) => ({
|
||||||
|
name: c.categoryName,
|
||||||
|
amount: c.amount,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get spending trends over multiple months
|
* Get spending trends over multiple months
|
||||||
*/
|
*/
|
||||||
async getSpendingTrends(userId: string, months: number = 6): Promise<SpendingTrend[]> {
|
async getSpendingTrends(
|
||||||
|
userId: string,
|
||||||
|
months: number = 6,
|
||||||
|
): Promise<SpendingTrend[]> {
|
||||||
const trends: SpendingTrend[] = [];
|
const trends: SpendingTrend[] = [];
|
||||||
let previousAmount = 0;
|
let previousAmount = 0;
|
||||||
|
|
||||||
@ -146,7 +180,8 @@ export class AnalyticsService {
|
|||||||
|
|
||||||
const amount = transactions.reduce((sum, t) => sum + Number(t.amount), 0);
|
const amount = transactions.reduce((sum, t) => sum + Number(t.amount), 0);
|
||||||
const change = previousAmount > 0 ? amount - previousAmount : 0;
|
const change = previousAmount > 0 ? amount - previousAmount : 0;
|
||||||
const changePercent = previousAmount > 0 ? (change / previousAmount) * 100 : 0;
|
const changePercent =
|
||||||
|
previousAmount > 0 ? (change / previousAmount) * 100 : 0;
|
||||||
|
|
||||||
trends.push({
|
trends.push({
|
||||||
period: format(date, 'yyyy-MM'),
|
period: format(date, 'yyyy-MM'),
|
||||||
@ -179,17 +214,23 @@ export class AnalyticsService {
|
|||||||
relations: ['category'],
|
relations: ['category'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalAmount = transactions.reduce((sum, t) => sum + Number(t.amount), 0);
|
const totalAmount = transactions.reduce(
|
||||||
|
(sum, t) => sum + Number(t.amount),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
const categoryData: Record<string, {
|
const categoryData: Record<
|
||||||
name: string;
|
string,
|
||||||
icon: string;
|
{
|
||||||
color: string;
|
name: string;
|
||||||
amount: number;
|
icon: string;
|
||||||
count: number;
|
color: string;
|
||||||
}> = {};
|
amount: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
> = {};
|
||||||
|
|
||||||
transactions.forEach(t => {
|
transactions.forEach((t) => {
|
||||||
const catId = t.categoryId || 'other';
|
const catId = t.categoryId || 'other';
|
||||||
if (!categoryData[catId]) {
|
if (!categoryData[catId]) {
|
||||||
categoryData[catId] = {
|
categoryData[catId] = {
|
||||||
@ -221,12 +262,17 @@ export class AnalyticsService {
|
|||||||
/**
|
/**
|
||||||
* Get income vs expenses comparison
|
* Get income vs expenses comparison
|
||||||
*/
|
*/
|
||||||
async getIncomeVsExpenses(userId: string, months: number = 12): Promise<Array<{
|
async getIncomeVsExpenses(
|
||||||
period: string;
|
userId: string,
|
||||||
income: number;
|
months: number = 12,
|
||||||
expenses: number;
|
): Promise<
|
||||||
savings: number;
|
Array<{
|
||||||
}>> {
|
period: string;
|
||||||
|
income: number;
|
||||||
|
expenses: number;
|
||||||
|
savings: number;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
const result: Array<{
|
const result: Array<{
|
||||||
period: string;
|
period: string;
|
||||||
income: number;
|
income: number;
|
||||||
@ -247,11 +293,11 @@ export class AnalyticsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const income = transactions
|
const income = transactions
|
||||||
.filter(t => t.type === TransactionType.INCOME)
|
.filter((t) => t.type === TransactionType.INCOME)
|
||||||
.reduce((sum, t) => sum + Number(t.amount), 0);
|
.reduce((sum, t) => sum + Number(t.amount), 0);
|
||||||
|
|
||||||
const expenses = transactions
|
const expenses = transactions
|
||||||
.filter(t => t.type === TransactionType.EXPENSE)
|
.filter((t) => t.type === TransactionType.EXPENSE)
|
||||||
.reduce((sum, t) => sum + Number(t.amount), 0);
|
.reduce((sum, t) => sum + Number(t.amount), 0);
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
@ -282,21 +328,25 @@ export class AnalyticsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const totalIncome = transactions
|
const totalIncome = transactions
|
||||||
.filter(t => t.type === 'INCOME')
|
.filter((t) => t.type === 'INCOME')
|
||||||
.reduce((sum, t) => sum + Number(t.amount), 0);
|
.reduce((sum, t) => sum + Number(t.amount), 0);
|
||||||
|
|
||||||
const totalExpenses = transactions
|
const totalExpenses = transactions
|
||||||
.filter(t => t.type === 'EXPENSE')
|
.filter((t) => t.type === 'EXPENSE')
|
||||||
.reduce((sum, t) => sum + Number(t.amount), 0);
|
.reduce((sum, t) => sum + Number(t.amount), 0);
|
||||||
|
|
||||||
// Factor 1: Savings Rate (target: 20%+)
|
// Factor 1: Savings Rate (target: 20%+)
|
||||||
const savingsRate = totalIncome > 0 ? ((totalIncome - totalExpenses) / totalIncome) * 100 : 0;
|
const savingsRate =
|
||||||
|
totalIncome > 0 ? ((totalIncome - totalExpenses) / totalIncome) * 100 : 0;
|
||||||
const savingsScore = Math.min(100, (savingsRate / 20) * 100);
|
const savingsScore = Math.min(100, (savingsRate / 20) * 100);
|
||||||
factors.push({
|
factors.push({
|
||||||
name: 'Норма сбережений',
|
name: 'Норма сбережений',
|
||||||
score: savingsScore,
|
score: savingsScore,
|
||||||
description: `Ваша норма сбережений: ${savingsRate.toFixed(1)}%`,
|
description: `Ваша норма сбережений: ${savingsRate.toFixed(1)}%`,
|
||||||
recommendation: savingsRate < 20 ? 'Рекомендуем увеличить сбережения до 20% от дохода' : undefined,
|
recommendation:
|
||||||
|
savingsRate < 20
|
||||||
|
? 'Рекомендуем увеличить сбережения до 20% от дохода'
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
totalScore += savingsScore * 0.3;
|
totalScore += savingsScore * 0.3;
|
||||||
|
|
||||||
@ -307,14 +357,20 @@ export class AnalyticsService {
|
|||||||
|
|
||||||
let budgetScore = 50; // Default if no budget
|
let budgetScore = 50; // Default if no budget
|
||||||
if (currentBudget) {
|
if (currentBudget) {
|
||||||
const budgetUsage = (currentBudget.totalSpent / Number(currentBudget.totalIncome)) * 100;
|
const budgetUsage =
|
||||||
budgetScore = budgetUsage <= 100 ? 100 - Math.max(0, budgetUsage - 80) : 0;
|
(currentBudget.totalSpent / Number(currentBudget.totalIncome)) * 100;
|
||||||
|
budgetScore =
|
||||||
|
budgetUsage <= 100 ? 100 - Math.max(0, budgetUsage - 80) : 0;
|
||||||
}
|
}
|
||||||
factors.push({
|
factors.push({
|
||||||
name: 'Соблюдение бюджета',
|
name: 'Соблюдение бюджета',
|
||||||
score: budgetScore,
|
score: budgetScore,
|
||||||
description: currentBudget ? `Использовано ${((currentBudget.totalSpent / Number(currentBudget.totalIncome)) * 100).toFixed(0)}% бюджета` : 'Бюджет не установлен',
|
description: currentBudget
|
||||||
recommendation: !currentBudget ? 'Создайте бюджет для лучшего контроля расходов' : undefined,
|
? `Использовано ${((currentBudget.totalSpent / Number(currentBudget.totalIncome)) * 100).toFixed(0)}% бюджета`
|
||||||
|
: 'Бюджет не установлен',
|
||||||
|
recommendation: !currentBudget
|
||||||
|
? 'Создайте бюджет для лучшего контроля расходов'
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
totalScore += budgetScore * 0.25;
|
totalScore += budgetScore * 0.25;
|
||||||
|
|
||||||
@ -325,14 +381,21 @@ export class AnalyticsService {
|
|||||||
|
|
||||||
let goalScore = 50;
|
let goalScore = 50;
|
||||||
if (goals.length > 0) {
|
if (goals.length > 0) {
|
||||||
const avgProgress = goals.reduce((sum, g) => sum + g.progressPercent, 0) / goals.length;
|
const avgProgress =
|
||||||
|
goals.reduce((sum, g) => sum + g.progressPercent, 0) / goals.length;
|
||||||
goalScore = avgProgress;
|
goalScore = avgProgress;
|
||||||
}
|
}
|
||||||
factors.push({
|
factors.push({
|
||||||
name: 'Прогресс по целям',
|
name: 'Прогресс по целям',
|
||||||
score: goalScore,
|
score: goalScore,
|
||||||
description: goals.length > 0 ? `${goals.length} активных целей` : 'Нет активных целей',
|
description:
|
||||||
recommendation: goals.length === 0 ? 'Создайте финансовые цели для мотивации' : undefined,
|
goals.length > 0
|
||||||
|
? `${goals.length} активных целей`
|
||||||
|
: 'Нет активных целей',
|
||||||
|
recommendation:
|
||||||
|
goals.length === 0
|
||||||
|
? 'Создайте финансовые цели для мотивации'
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
totalScore += goalScore * 0.2;
|
totalScore += goalScore * 0.2;
|
||||||
|
|
||||||
@ -343,24 +406,37 @@ export class AnalyticsService {
|
|||||||
const monthStart = startOfMonth(date);
|
const monthStart = startOfMonth(date);
|
||||||
const monthEnd = endOfMonth(date);
|
const monthEnd = endOfMonth(date);
|
||||||
|
|
||||||
const monthTransactions = transactions.filter(t => {
|
const monthTransactions = transactions.filter((t) => {
|
||||||
const tDate = new Date(t.transactionDate);
|
const tDate = new Date(t.transactionDate);
|
||||||
return t.type === TransactionType.EXPENSE && tDate >= monthStart && tDate <= monthEnd;
|
return (
|
||||||
|
t.type === TransactionType.EXPENSE &&
|
||||||
|
tDate >= monthStart &&
|
||||||
|
tDate <= monthEnd
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
monthlyExpenses.push(monthTransactions.reduce((sum, t) => sum + Number(t.amount), 0));
|
monthlyExpenses.push(
|
||||||
|
monthTransactions.reduce((sum, t) => sum + Number(t.amount), 0),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const avgExpense = monthlyExpenses.reduce((a, b) => a + b, 0) / monthlyExpenses.length;
|
const avgExpense =
|
||||||
const variance = monthlyExpenses.reduce((sum, e) => sum + Math.pow(e - avgExpense, 2), 0) / monthlyExpenses.length;
|
monthlyExpenses.reduce((a, b) => a + b, 0) / monthlyExpenses.length;
|
||||||
|
const variance =
|
||||||
|
monthlyExpenses.reduce((sum, e) => sum + Math.pow(e - avgExpense, 2), 0) /
|
||||||
|
monthlyExpenses.length;
|
||||||
const stdDev = Math.sqrt(variance);
|
const stdDev = Math.sqrt(variance);
|
||||||
const consistencyScore = avgExpense > 0 ? Math.max(0, 100 - (stdDev / avgExpense) * 100) : 50;
|
const consistencyScore =
|
||||||
|
avgExpense > 0 ? Math.max(0, 100 - (stdDev / avgExpense) * 100) : 50;
|
||||||
|
|
||||||
factors.push({
|
factors.push({
|
||||||
name: 'Стабильность расходов',
|
name: 'Стабильность расходов',
|
||||||
score: consistencyScore,
|
score: consistencyScore,
|
||||||
description: `Отклонение расходов: ${((stdDev / avgExpense) * 100).toFixed(0)}%`,
|
description: `Отклонение расходов: ${((stdDev / avgExpense) * 100).toFixed(0)}%`,
|
||||||
recommendation: consistencyScore < 70 ? 'Старайтесь поддерживать стабильный уровень расходов' : undefined,
|
recommendation:
|
||||||
|
consistencyScore < 70
|
||||||
|
? 'Старайтесь поддерживать стабильный уровень расходов'
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
totalScore += consistencyScore * 0.25;
|
totalScore += consistencyScore * 0.25;
|
||||||
|
|
||||||
@ -382,7 +458,10 @@ export class AnalyticsService {
|
|||||||
/**
|
/**
|
||||||
* Get yearly summary
|
* Get yearly summary
|
||||||
*/
|
*/
|
||||||
async getYearlySummary(userId: string, year: number = new Date().getFullYear()): Promise<{
|
async getYearlySummary(
|
||||||
|
userId: string,
|
||||||
|
year: number = new Date().getFullYear(),
|
||||||
|
): Promise<{
|
||||||
year: number;
|
year: number;
|
||||||
totalIncome: number;
|
totalIncome: number;
|
||||||
totalExpenses: number;
|
totalExpenses: number;
|
||||||
@ -391,7 +470,11 @@ export class AnalyticsService {
|
|||||||
averageMonthlyExpenses: number;
|
averageMonthlyExpenses: number;
|
||||||
bestMonth: { month: string; savings: number };
|
bestMonth: { month: string; savings: number };
|
||||||
worstMonth: { month: string; savings: number };
|
worstMonth: { month: string; savings: number };
|
||||||
topExpenseCategories: Array<{ name: string; amount: number; percentage: number }>;
|
topExpenseCategories: Array<{
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
percentage: number;
|
||||||
|
}>;
|
||||||
}> {
|
}> {
|
||||||
const yearStart = startOfYear(new Date(year, 0, 1));
|
const yearStart = startOfYear(new Date(year, 0, 1));
|
||||||
const yearEnd = endOfYear(new Date(year, 0, 1));
|
const yearEnd = endOfYear(new Date(year, 0, 1));
|
||||||
@ -405,16 +488,17 @@ export class AnalyticsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const totalIncome = transactions
|
const totalIncome = transactions
|
||||||
.filter(t => t.type === TransactionType.INCOME)
|
.filter((t) => t.type === TransactionType.INCOME)
|
||||||
.reduce((sum, t) => sum + Number(t.amount), 0);
|
.reduce((sum, t) => sum + Number(t.amount), 0);
|
||||||
|
|
||||||
const totalExpenses = transactions
|
const totalExpenses = transactions
|
||||||
.filter(t => t.type === TransactionType.EXPENSE)
|
.filter((t) => t.type === TransactionType.EXPENSE)
|
||||||
.reduce((sum, t) => sum + Number(t.amount), 0);
|
.reduce((sum, t) => sum + Number(t.amount), 0);
|
||||||
|
|
||||||
// Calculate monthly data
|
// Calculate monthly data
|
||||||
const monthlyData: Record<string, { income: number; expenses: number }> = {};
|
const monthlyData: Record<string, { income: number; expenses: number }> =
|
||||||
transactions.forEach(t => {
|
{};
|
||||||
|
transactions.forEach((t) => {
|
||||||
const month = format(new Date(t.transactionDate), 'yyyy-MM');
|
const month = format(new Date(t.transactionDate), 'yyyy-MM');
|
||||||
if (!monthlyData[month]) {
|
if (!monthlyData[month]) {
|
||||||
monthlyData[month] = { income: 0, expenses: 0 };
|
monthlyData[month] = { income: 0, expenses: 0 };
|
||||||
@ -444,10 +528,11 @@ export class AnalyticsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Top expense categories
|
// Top expense categories
|
||||||
const categoryExpenses: Record<string, { name: string; amount: number }> = {};
|
const categoryExpenses: Record<string, { name: string; amount: number }> =
|
||||||
|
{};
|
||||||
transactions
|
transactions
|
||||||
.filter(t => t.type === TransactionType.EXPENSE)
|
.filter((t) => t.type === TransactionType.EXPENSE)
|
||||||
.forEach(t => {
|
.forEach((t) => {
|
||||||
const catName = t.category?.nameRu || 'Другое';
|
const catName = t.category?.nameRu || 'Другое';
|
||||||
if (!categoryExpenses[catName]) {
|
if (!categoryExpenses[catName]) {
|
||||||
categoryExpenses[catName] = { name: catName, amount: 0 };
|
categoryExpenses[catName] = { name: catName, amount: 0 };
|
||||||
@ -456,7 +541,7 @@ export class AnalyticsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const topExpenseCategories = Object.values(categoryExpenses)
|
const topExpenseCategories = Object.values(categoryExpenses)
|
||||||
.map(cat => ({
|
.map((cat) => ({
|
||||||
name: cat.name,
|
name: cat.name,
|
||||||
amount: cat.amount,
|
amount: cat.amount,
|
||||||
percentage: totalExpenses > 0 ? (cat.amount / totalExpenses) * 100 : 0,
|
percentage: totalExpenses > 0 ? (cat.amount / totalExpenses) * 100 : 0,
|
||||||
|
|||||||
@ -9,20 +9,33 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiCreatedResponse,
|
||||||
ApiBody,
|
ApiBody,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiCookieAuth,
|
ApiCookieAuth,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { RegisterDto, LoginDto, UpdateProfileDto, ChangePasswordDto, AuthResponseDto, LogoutResponseDto } from './dto';
|
import {
|
||||||
|
RegisterDto,
|
||||||
|
LoginDto,
|
||||||
|
UpdateProfileDto,
|
||||||
|
ChangePasswordDto,
|
||||||
|
AuthResponseDto,
|
||||||
|
LogoutResponseDto,
|
||||||
|
} from './dto';
|
||||||
import { Public } from '../../common/decorators/public.decorator';
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
import { CurrentUser, JwtPayload } from '../../common/decorators/user.decorator';
|
import {
|
||||||
|
CurrentUser,
|
||||||
|
JwtPayload,
|
||||||
|
} from '../../common/decorators/user.decorator';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
@ -39,10 +52,21 @@ export class AuthController {
|
|||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({ summary: 'Регистрация нового пользователя' })
|
@ApiOperation({ summary: 'Регистрация нового пользователя' })
|
||||||
@ApiBody({ type: RegisterDto })
|
@ApiBody({ type: RegisterDto })
|
||||||
@ApiResponse({
|
@ApiCreatedResponse({
|
||||||
status: 201,
|
|
||||||
description: 'Пользователь успешно зарегистрирован',
|
description: 'Пользователь успешно зарегистрирован',
|
||||||
type: AuthResponseDto,
|
type: AuthResponseDto,
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
userId: 'uuid',
|
||||||
|
email: 'user@example.com',
|
||||||
|
firstName: 'Omar',
|
||||||
|
lastName: 'Zaid',
|
||||||
|
tokens: {
|
||||||
|
accessToken: 'jwt-access-token',
|
||||||
|
expiresIn: 900,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 400, description: 'Некорректные данные' })
|
@ApiResponse({ status: 400, description: 'Некорректные данные' })
|
||||||
@ApiResponse({ status: 409, description: 'Пользователь уже существует' })
|
@ApiResponse({ status: 409, description: 'Пользователь уже существует' })
|
||||||
@ -81,10 +105,21 @@ export class AuthController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Вход в систему' })
|
@ApiOperation({ summary: 'Вход в систему' })
|
||||||
@ApiBody({ type: LoginDto })
|
@ApiBody({ type: LoginDto })
|
||||||
@ApiResponse({
|
@ApiOkResponse({
|
||||||
status: 200,
|
|
||||||
description: 'Успешный вход',
|
description: 'Успешный вход',
|
||||||
type: AuthResponseDto,
|
type: AuthResponseDto,
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
userId: 'uuid',
|
||||||
|
email: 'user@example.com',
|
||||||
|
firstName: 'Omar',
|
||||||
|
lastName: 'Zaid',
|
||||||
|
tokens: {
|
||||||
|
accessToken: 'jwt-access-token',
|
||||||
|
expiresIn: 900,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 401, description: 'Неверный email или пароль' })
|
@ApiResponse({ status: 401, description: 'Неверный email или пароль' })
|
||||||
async login(
|
async login(
|
||||||
@ -118,9 +153,14 @@ export class AuthController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Обновление токена доступа' })
|
@ApiOperation({ summary: 'Обновление токена доступа' })
|
||||||
@ApiCookieAuth()
|
@ApiCookieAuth()
|
||||||
@ApiResponse({
|
@ApiOkResponse({
|
||||||
status: 200,
|
|
||||||
description: 'Токен успешно обновлен',
|
description: 'Токен успешно обновлен',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
accessToken: 'jwt-access-token',
|
||||||
|
expiresIn: 900,
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 401, description: 'Недействительный refresh токен' })
|
@ApiResponse({ status: 401, description: 'Недействительный refresh токен' })
|
||||||
async refresh(
|
async refresh(
|
||||||
@ -130,21 +170,13 @@ export class AuthController {
|
|||||||
const refreshToken = req.cookies?.refresh_token;
|
const refreshToken = req.cookies?.refresh_token;
|
||||||
|
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
res.status(HttpStatus.UNAUTHORIZED).json({
|
throw new UnauthorizedException('Refresh токен не найден');
|
||||||
statusCode: 401,
|
|
||||||
message: 'Refresh токен не найден',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode token to get user ID
|
// Decode token to get user ID
|
||||||
const decoded = this.decodeToken(refreshToken);
|
const decoded = this.decodeToken(refreshToken);
|
||||||
if (!decoded) {
|
if (!decoded) {
|
||||||
res.status(HttpStatus.UNAUTHORIZED).json({
|
throw new UnauthorizedException('Недействительный токен');
|
||||||
statusCode: 401,
|
|
||||||
message: 'Недействительный токен',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = {
|
const metadata = {
|
||||||
@ -171,10 +203,14 @@ export class AuthController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Выход из системы' })
|
@ApiOperation({ summary: 'Выход из системы' })
|
||||||
@ApiResponse({
|
@ApiOkResponse({
|
||||||
status: 200,
|
|
||||||
description: 'Успешный выход',
|
description: 'Успешный выход',
|
||||||
type: LogoutResponseDto,
|
type: LogoutResponseDto,
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
message: 'Выход выполнен успешно',
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
async logout(
|
async logout(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@ -199,10 +235,14 @@ export class AuthController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Выход со всех устройств' })
|
@ApiOperation({ summary: 'Выход со всех устройств' })
|
||||||
@ApiResponse({
|
@ApiOkResponse({
|
||||||
status: 200,
|
|
||||||
description: 'Успешный выход со всех устройств',
|
description: 'Успешный выход со всех устройств',
|
||||||
type: LogoutResponseDto,
|
type: LogoutResponseDto,
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
message: 'Выход выполнен успешно',
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
async logoutAll(
|
async logoutAll(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@ -266,7 +306,11 @@ export class AuthController {
|
|||||||
userAgent: req.get('user-agent'),
|
userAgent: req.get('user-agent'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const profile = await this.authService.updateProfile(user.sub, dto, metadata);
|
const profile = await this.authService.updateProfile(
|
||||||
|
user.sub,
|
||||||
|
dto,
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
email: profile.email,
|
email: profile.email,
|
||||||
@ -312,7 +356,11 @@ export class AuthController {
|
|||||||
/**
|
/**
|
||||||
* Set HTTP-only cookies for tokens
|
* Set HTTP-only cookies for tokens
|
||||||
*/
|
*/
|
||||||
private setTokenCookies(res: Response, accessToken: string, refreshToken: string): void {
|
private setTokenCookies(
|
||||||
|
res: Response,
|
||||||
|
accessToken: string,
|
||||||
|
refreshToken: string,
|
||||||
|
): void {
|
||||||
const isProduction = this.configService.get('app.nodeEnv') === 'production';
|
const isProduction = this.configService.get('app.nodeEnv') === 'production';
|
||||||
const cookieDomain = this.configService.get<string>('jwt.cookieDomain');
|
const cookieDomain = this.configService.get<string>('jwt.cookieDomain');
|
||||||
|
|
||||||
@ -331,7 +379,7 @@ export class AuthController {
|
|||||||
res.cookie('refresh_token', refreshToken, {
|
res.cookie('refresh_token', refreshToken, {
|
||||||
...commonOptions,
|
...commonOptions,
|
||||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||||
path: '/auth', // Only send to auth endpoints
|
path: '/',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -342,7 +390,7 @@ export class AuthController {
|
|||||||
const cookieDomain = this.configService.get<string>('jwt.cookieDomain');
|
const cookieDomain = this.configService.get<string>('jwt.cookieDomain');
|
||||||
|
|
||||||
res.clearCookie('access_token', { domain: cookieDomain });
|
res.clearCookie('access_token', { domain: cookieDomain });
|
||||||
res.clearCookie('refresh_token', { domain: cookieDomain, path: '/auth' });
|
res.clearCookie('refresh_token', { domain: cookieDomain, path: '/' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -351,7 +399,9 @@ export class AuthController {
|
|||||||
private decodeToken(token: string): JwtPayload | null {
|
private decodeToken(token: string): JwtPayload | null {
|
||||||
try {
|
try {
|
||||||
return this.configService.get('jwt.refreshSecret')
|
return this.configService.get('jwt.refreshSecret')
|
||||||
? (JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()) as JwtPayload)
|
? (JSON.parse(
|
||||||
|
Buffer.from(token.split('.')[1], 'base64').toString(),
|
||||||
|
) as JwtPayload)
|
||||||
: null;
|
: null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -14,13 +14,18 @@ import {
|
|||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiCreatedResponse,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiQuery,
|
ApiQuery,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { BudgetsService } from './budgets.service';
|
import { BudgetsService } from './budgets.service';
|
||||||
import { CreateBudgetDto, UpdateBudgetDto } from './dto';
|
import { CreateBudgetDto, UpdateBudgetDto } from './dto';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { CurrentUser, JwtPayload } from '../../common/decorators/user.decorator';
|
import {
|
||||||
|
CurrentUser,
|
||||||
|
JwtPayload,
|
||||||
|
} from '../../common/decorators/user.decorator';
|
||||||
|
|
||||||
@ApiTags('Бюджеты (50/30/20)')
|
@ApiTags('Бюджеты (50/30/20)')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ -31,22 +36,73 @@ export class BudgetsController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'Получение всех бюджетов пользователя' })
|
@ApiOperation({ summary: 'Получение всех бюджетов пользователя' })
|
||||||
@ApiResponse({ status: 200, description: 'Список бюджетов' })
|
@ApiOkResponse({
|
||||||
|
description: 'Список бюджетов',
|
||||||
|
schema: {
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
id: 'uuid',
|
||||||
|
month: '2024-01-01',
|
||||||
|
totalIncome: 120000,
|
||||||
|
essentialsLimit: 60000,
|
||||||
|
personalLimit: 36000,
|
||||||
|
savingsLimit: 24000,
|
||||||
|
essentialsSpent: 42000,
|
||||||
|
personalSpent: 28000,
|
||||||
|
savingsSpent: 15000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
async findAll(@CurrentUser() user: JwtPayload) {
|
async findAll(@CurrentUser() user: JwtPayload) {
|
||||||
return this.budgetsService.findAll(user.sub);
|
return this.budgetsService.findAll(user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('current')
|
@Get('current')
|
||||||
@ApiOperation({ summary: 'Получение бюджета текущего месяца' })
|
@ApiOperation({ summary: 'Получение бюджета текущего месяца' })
|
||||||
@ApiResponse({ status: 200, description: 'Бюджет текущего месяца' })
|
@ApiOkResponse({
|
||||||
|
description: 'Бюджет текущего месяца',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'uuid',
|
||||||
|
month: '2024-01-01',
|
||||||
|
totalIncome: 120000,
|
||||||
|
essentialsLimit: 60000,
|
||||||
|
personalLimit: 36000,
|
||||||
|
savingsLimit: 24000,
|
||||||
|
essentialsSpent: 42000,
|
||||||
|
personalSpent: 28000,
|
||||||
|
savingsSpent: 15000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
async findCurrent(@CurrentUser() user: JwtPayload) {
|
async findCurrent(@CurrentUser() user: JwtPayload) {
|
||||||
return this.budgetsService.findCurrent(user.sub);
|
return this.budgetsService.findCurrent(user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('month')
|
@Get('month')
|
||||||
@ApiOperation({ summary: 'Получение бюджета за конкретный месяц' })
|
@ApiOperation({ summary: 'Получение бюджета за конкретный месяц' })
|
||||||
@ApiQuery({ name: 'month', example: '2024-01-01', description: 'Первый день месяца' })
|
@ApiQuery({
|
||||||
@ApiResponse({ status: 200, description: 'Бюджет' })
|
name: 'month',
|
||||||
|
example: '2024-01-01',
|
||||||
|
description: 'Первый день месяца',
|
||||||
|
})
|
||||||
|
@ApiOkResponse({
|
||||||
|
description: 'Бюджет',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'uuid',
|
||||||
|
month: '2024-01-01',
|
||||||
|
totalIncome: 120000,
|
||||||
|
essentialsLimit: 60000,
|
||||||
|
personalLimit: 36000,
|
||||||
|
savingsLimit: 24000,
|
||||||
|
essentialsSpent: 42000,
|
||||||
|
personalSpent: 28000,
|
||||||
|
savingsSpent: 15000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@ApiResponse({ status: 404, description: 'Бюджет не найден' })
|
@ApiResponse({ status: 404, description: 'Бюджет не найден' })
|
||||||
async findByMonth(
|
async findByMonth(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@ -58,7 +114,17 @@ export class BudgetsController {
|
|||||||
@Get('progress')
|
@Get('progress')
|
||||||
@ApiOperation({ summary: 'Получение прогресса по бюджету' })
|
@ApiOperation({ summary: 'Получение прогресса по бюджету' })
|
||||||
@ApiQuery({ name: 'month', example: '2024-01-01' })
|
@ApiQuery({ name: 'month', example: '2024-01-01' })
|
||||||
@ApiResponse({ status: 200, description: 'Прогресс по категориям 50/30/20' })
|
@ApiOkResponse({
|
||||||
|
description: 'Прогресс по категориям 50/30/20',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
month: '2024-01-01',
|
||||||
|
essentials: { limit: 60000, spent: 42000, percent: 70 },
|
||||||
|
personal: { limit: 36000, spent: 28000, percent: 77.78 },
|
||||||
|
savings: { limit: 24000, spent: 15000, percent: 62.5 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
async getProgress(
|
async getProgress(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Query('month') month: string,
|
@Query('month') month: string,
|
||||||
@ -69,19 +135,43 @@ export class BudgetsController {
|
|||||||
@Post()
|
@Post()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({ summary: 'Создание бюджета на месяц' })
|
@ApiOperation({ summary: 'Создание бюджета на месяц' })
|
||||||
@ApiResponse({ status: 201, description: 'Бюджет создан' })
|
@ApiCreatedResponse({
|
||||||
@ApiResponse({ status: 409, description: 'Бюджет на этот месяц уже существует' })
|
description: 'Бюджет создан',
|
||||||
async create(
|
schema: {
|
||||||
@CurrentUser() user: JwtPayload,
|
example: {
|
||||||
@Body() dto: CreateBudgetDto,
|
id: 'uuid',
|
||||||
) {
|
month: '2024-01-01',
|
||||||
|
totalIncome: 120000,
|
||||||
|
essentialsLimit: 60000,
|
||||||
|
personalLimit: 36000,
|
||||||
|
savingsLimit: 24000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 409,
|
||||||
|
description: 'Бюджет на этот месяц уже существует',
|
||||||
|
})
|
||||||
|
async create(@CurrentUser() user: JwtPayload, @Body() dto: CreateBudgetDto) {
|
||||||
return this.budgetsService.create(user.sub, dto);
|
return this.budgetsService.create(user.sub, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put()
|
@Put()
|
||||||
@ApiOperation({ summary: 'Обновление бюджета' })
|
@ApiOperation({ summary: 'Обновление бюджета' })
|
||||||
@ApiQuery({ name: 'month', example: '2024-01-01' })
|
@ApiQuery({ name: 'month', example: '2024-01-01' })
|
||||||
@ApiResponse({ status: 200, description: 'Бюджет обновлен' })
|
@ApiOkResponse({
|
||||||
|
description: 'Бюджет обновлен',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'uuid',
|
||||||
|
month: '2024-01-01',
|
||||||
|
totalIncome: 120000,
|
||||||
|
essentialsLimit: 65000,
|
||||||
|
personalLimit: 35000,
|
||||||
|
savingsLimit: 20000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
async update(
|
async update(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Query('month') month: string,
|
@Query('month') month: string,
|
||||||
@ -94,11 +184,14 @@ export class BudgetsController {
|
|||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Удаление бюджета' })
|
@ApiOperation({ summary: 'Удаление бюджета' })
|
||||||
@ApiQuery({ name: 'month', example: '2024-01-01' })
|
@ApiQuery({ name: 'month', example: '2024-01-01' })
|
||||||
@ApiResponse({ status: 204, description: 'Бюджет удален' })
|
@ApiResponse({
|
||||||
async remove(
|
status: 204,
|
||||||
@CurrentUser() user: JwtPayload,
|
description: 'Бюджет удален',
|
||||||
@Query('month') month: string,
|
schema: {
|
||||||
) {
|
example: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
async remove(@CurrentUser() user: JwtPayload, @Query('month') month: string) {
|
||||||
await this.budgetsService.remove(user.sub, month);
|
await this.budgetsService.remove(user.sub, month);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,8 @@ import {
|
|||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiCreatedResponse,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiQuery,
|
ApiQuery,
|
||||||
ApiParam,
|
ApiParam,
|
||||||
@ -22,7 +24,10 @@ import {
|
|||||||
import { CategoriesService } from './categories.service';
|
import { CategoriesService } from './categories.service';
|
||||||
import { CreateCategoryDto, UpdateCategoryDto } from './dto';
|
import { CreateCategoryDto, UpdateCategoryDto } from './dto';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { CurrentUser, JwtPayload } from '../../common/decorators/user.decorator';
|
import {
|
||||||
|
CurrentUser,
|
||||||
|
JwtPayload,
|
||||||
|
} from '../../common/decorators/user.decorator';
|
||||||
|
|
||||||
@ApiTags('Категории')
|
@ApiTags('Категории')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ -39,9 +44,22 @@ export class CategoriesController {
|
|||||||
enum: ['INCOME', 'EXPENSE'],
|
enum: ['INCOME', 'EXPENSE'],
|
||||||
description: 'Фильтр по типу категории',
|
description: 'Фильтр по типу категории',
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiOkResponse({
|
||||||
status: 200,
|
|
||||||
description: 'Список категорий',
|
description: 'Список категорий',
|
||||||
|
schema: {
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
id: 'uuid',
|
||||||
|
nameRu: 'Продукты',
|
||||||
|
nameEn: 'Groceries',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
icon: 'shopping-cart',
|
||||||
|
color: '#4CAF50',
|
||||||
|
groupType: 'ESSENTIAL',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
async findAll(
|
async findAll(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@ -51,10 +69,39 @@ export class CategoriesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('grouped')
|
@Get('grouped')
|
||||||
@ApiOperation({ summary: 'Получение категорий, сгруппированных по типу бюджета' })
|
@ApiOperation({
|
||||||
@ApiResponse({
|
summary: 'Получение категорий, сгруппированных по типу бюджета',
|
||||||
status: 200,
|
})
|
||||||
|
@ApiOkResponse({
|
||||||
description: 'Категории, сгруппированные по ESSENTIAL/PERSONAL/SAVINGS',
|
description: 'Категории, сгруппированные по ESSENTIAL/PERSONAL/SAVINGS',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
ESSENTIAL: [
|
||||||
|
{
|
||||||
|
id: 'uuid',
|
||||||
|
nameRu: 'Продукты',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
groupType: 'ESSENTIAL',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
PERSONAL: [
|
||||||
|
{
|
||||||
|
id: 'uuid',
|
||||||
|
nameRu: 'Развлечения',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
groupType: 'PERSONAL',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
SAVINGS: [
|
||||||
|
{
|
||||||
|
id: 'uuid',
|
||||||
|
nameRu: 'Накопления',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
groupType: 'SAVINGS',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
async findGrouped(@CurrentUser() user: JwtPayload) {
|
async findGrouped(@CurrentUser() user: JwtPayload) {
|
||||||
return this.categoriesService.findByBudgetGroup(user.sub);
|
return this.categoriesService.findByBudgetGroup(user.sub);
|
||||||
@ -63,24 +110,43 @@ export class CategoriesController {
|
|||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Получение категории по ID' })
|
@ApiOperation({ summary: 'Получение категории по ID' })
|
||||||
@ApiParam({ name: 'id', description: 'ID категории' })
|
@ApiParam({ name: 'id', description: 'ID категории' })
|
||||||
@ApiResponse({
|
@ApiOkResponse({
|
||||||
status: 200,
|
|
||||||
description: 'Категория',
|
description: 'Категория',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'uuid',
|
||||||
|
nameRu: 'Продукты',
|
||||||
|
nameEn: 'Groceries',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
icon: 'shopping-cart',
|
||||||
|
color: '#4CAF50',
|
||||||
|
groupType: 'ESSENTIAL',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 404, description: 'Категория не найдена' })
|
@ApiResponse({ status: 404, description: 'Категория не найдена' })
|
||||||
async findOne(
|
async findOne(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
|
||||||
@CurrentUser() user: JwtPayload,
|
|
||||||
@Param('id') id: string,
|
|
||||||
) {
|
|
||||||
return this.categoriesService.findOne(id, user.sub);
|
return this.categoriesService.findOne(id, user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({ summary: 'Создание пользовательской категории' })
|
@ApiOperation({ summary: 'Создание пользовательской категории' })
|
||||||
@ApiResponse({
|
@ApiCreatedResponse({
|
||||||
status: 201,
|
|
||||||
description: 'Категория создана',
|
description: 'Категория создана',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'uuid',
|
||||||
|
nameRu: 'Кофе',
|
||||||
|
nameEn: 'Coffee',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
icon: 'coffee',
|
||||||
|
color: '#795548',
|
||||||
|
groupType: 'PERSONAL',
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 400, description: 'Некорректные данные' })
|
@ApiResponse({ status: 400, description: 'Некорректные данные' })
|
||||||
@ApiResponse({ status: 409, description: 'Категория уже существует' })
|
@ApiResponse({ status: 409, description: 'Категория уже существует' })
|
||||||
@ -94,11 +160,23 @@ export class CategoriesController {
|
|||||||
@Put(':id')
|
@Put(':id')
|
||||||
@ApiOperation({ summary: 'Обновление пользовательской категории' })
|
@ApiOperation({ summary: 'Обновление пользовательской категории' })
|
||||||
@ApiParam({ name: 'id', description: 'ID категории' })
|
@ApiParam({ name: 'id', description: 'ID категории' })
|
||||||
@ApiResponse({
|
@ApiOkResponse({
|
||||||
status: 200,
|
|
||||||
description: 'Категория обновлена',
|
description: 'Категория обновлена',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'uuid',
|
||||||
|
nameRu: 'Кофе (обновлено)',
|
||||||
|
type: 'EXPENSE',
|
||||||
|
icon: 'coffee',
|
||||||
|
color: '#795548',
|
||||||
|
groupType: 'PERSONAL',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 403,
|
||||||
|
description: 'Нельзя изменить стандартную категорию',
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 403, description: 'Нельзя изменить стандартную категорию' })
|
|
||||||
@ApiResponse({ status: 404, description: 'Категория не найдена' })
|
@ApiResponse({ status: 404, description: 'Категория не найдена' })
|
||||||
async update(
|
async update(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@ -116,12 +194,12 @@ export class CategoriesController {
|
|||||||
status: 204,
|
status: 204,
|
||||||
description: 'Категория удалена',
|
description: 'Категория удалена',
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 403, description: 'Нельзя удалить стандартную категорию' })
|
@ApiResponse({
|
||||||
|
status: 403,
|
||||||
|
description: 'Нельзя удалить стандартную категорию',
|
||||||
|
})
|
||||||
@ApiResponse({ status: 404, description: 'Категория не найдена' })
|
@ApiResponse({ status: 404, description: 'Категория не найдена' })
|
||||||
async remove(
|
async remove(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
|
||||||
@CurrentUser() user: JwtPayload,
|
|
||||||
@Param('id') id: string,
|
|
||||||
) {
|
|
||||||
await this.categoriesService.remove(id, user.sub);
|
await this.categoriesService.remove(id, user.sub);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,8 @@ import {
|
|||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiCreatedResponse,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiQuery,
|
ApiQuery,
|
||||||
ApiParam,
|
ApiParam,
|
||||||
@ -24,7 +26,10 @@ import { GoalsService } from './goals.service';
|
|||||||
import { CreateGoalDto, UpdateGoalDto, AddFundsDto } from './dto';
|
import { CreateGoalDto, UpdateGoalDto, AddFundsDto } from './dto';
|
||||||
import { GoalStatus } from './entities/goal.entity';
|
import { GoalStatus } from './entities/goal.entity';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { CurrentUser, JwtPayload } from '../../common/decorators/user.decorator';
|
import {
|
||||||
|
CurrentUser,
|
||||||
|
JwtPayload,
|
||||||
|
} from '../../common/decorators/user.decorator';
|
||||||
|
|
||||||
@ApiTags('Финансовые цели')
|
@ApiTags('Финансовые цели')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ -36,7 +41,25 @@ export class GoalsController {
|
|||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'Получение всех целей пользователя' })
|
@ApiOperation({ summary: 'Получение всех целей пользователя' })
|
||||||
@ApiQuery({ name: 'status', enum: GoalStatus, required: false })
|
@ApiQuery({ name: 'status', enum: GoalStatus, required: false })
|
||||||
@ApiResponse({ status: 200, description: 'Список целей' })
|
@ApiOkResponse({
|
||||||
|
description: 'Список целей',
|
||||||
|
schema: {
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
id: 'uuid',
|
||||||
|
userId: 'uuid',
|
||||||
|
name: 'Подушка безопасности',
|
||||||
|
targetAmount: 300000,
|
||||||
|
currentAmount: 120000,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
targetDate: '2024-12-31T00:00:00.000Z',
|
||||||
|
autoSaveEnabled: true,
|
||||||
|
autoSaveAmount: 10000,
|
||||||
|
autoSaveFrequency: 'MONTHLY',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
async findAll(
|
async findAll(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Query('status') status?: GoalStatus,
|
@Query('status') status?: GoalStatus,
|
||||||
@ -46,7 +69,19 @@ export class GoalsController {
|
|||||||
|
|
||||||
@Get('summary')
|
@Get('summary')
|
||||||
@ApiOperation({ summary: 'Получение сводки по целям' })
|
@ApiOperation({ summary: 'Получение сводки по целям' })
|
||||||
@ApiResponse({ status: 200, description: 'Сводка по целям' })
|
@ApiOkResponse({
|
||||||
|
description: 'Сводка по целям',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
totalGoals: 3,
|
||||||
|
activeGoals: 2,
|
||||||
|
completedGoals: 1,
|
||||||
|
totalTargetAmount: 600000,
|
||||||
|
totalCurrentAmount: 240000,
|
||||||
|
overallProgress: 40,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
async getSummary(@CurrentUser() user: JwtPayload) {
|
async getSummary(@CurrentUser() user: JwtPayload) {
|
||||||
return this.goalsService.getSummary(user.sub);
|
return this.goalsService.getSummary(user.sub);
|
||||||
}
|
}
|
||||||
@ -54,7 +89,21 @@ export class GoalsController {
|
|||||||
@Get('upcoming')
|
@Get('upcoming')
|
||||||
@ApiOperation({ summary: 'Получение целей с приближающимся дедлайном' })
|
@ApiOperation({ summary: 'Получение целей с приближающимся дедлайном' })
|
||||||
@ApiQuery({ name: 'days', required: false, example: 30 })
|
@ApiQuery({ name: 'days', required: false, example: 30 })
|
||||||
@ApiResponse({ status: 200, description: 'Список целей с дедлайном' })
|
@ApiOkResponse({
|
||||||
|
description: 'Список целей с дедлайном',
|
||||||
|
schema: {
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
id: 'uuid',
|
||||||
|
name: 'Отпуск',
|
||||||
|
targetAmount: 150000,
|
||||||
|
currentAmount: 50000,
|
||||||
|
targetDate: '2024-03-01T00:00:00.000Z',
|
||||||
|
daysLeft: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
async getUpcoming(
|
async getUpcoming(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Query('days') days?: number,
|
@Query('days') days?: number,
|
||||||
@ -65,7 +114,23 @@ export class GoalsController {
|
|||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Получение цели по ID' })
|
@ApiOperation({ summary: 'Получение цели по ID' })
|
||||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
@ApiResponse({ status: 200, description: 'Цель' })
|
@ApiOkResponse({
|
||||||
|
description: 'Цель',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'uuid',
|
||||||
|
userId: 'uuid',
|
||||||
|
name: 'Подушка безопасности',
|
||||||
|
targetAmount: 300000,
|
||||||
|
currentAmount: 120000,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
targetDate: '2024-12-31T00:00:00.000Z',
|
||||||
|
autoSaveEnabled: true,
|
||||||
|
autoSaveAmount: 10000,
|
||||||
|
autoSaveFrequency: 'MONTHLY',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@ApiResponse({ status: 404, description: 'Цель не найдена' })
|
@ApiResponse({ status: 404, description: 'Цель не найдена' })
|
||||||
async findOne(
|
async findOne(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@ -77,18 +142,42 @@ export class GoalsController {
|
|||||||
@Post()
|
@Post()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({ summary: 'Создание новой цели' })
|
@ApiOperation({ summary: 'Создание новой цели' })
|
||||||
@ApiResponse({ status: 201, description: 'Цель создана' })
|
@ApiCreatedResponse({
|
||||||
async create(
|
description: 'Цель создана',
|
||||||
@CurrentUser() user: JwtPayload,
|
schema: {
|
||||||
@Body() dto: CreateGoalDto,
|
example: {
|
||||||
) {
|
id: 'uuid',
|
||||||
|
userId: 'uuid',
|
||||||
|
name: 'Подушка безопасности',
|
||||||
|
targetAmount: 300000,
|
||||||
|
currentAmount: 0,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
targetDate: '2024-12-31T00:00:00.000Z',
|
||||||
|
autoSaveEnabled: true,
|
||||||
|
autoSaveAmount: 10000,
|
||||||
|
autoSaveFrequency: 'MONTHLY',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
async create(@CurrentUser() user: JwtPayload, @Body() dto: CreateGoalDto) {
|
||||||
return this.goalsService.create(user.sub, dto);
|
return this.goalsService.create(user.sub, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@ApiOperation({ summary: 'Обновление цели' })
|
@ApiOperation({ summary: 'Обновление цели' })
|
||||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
@ApiResponse({ status: 200, description: 'Цель обновлена' })
|
@ApiOkResponse({
|
||||||
|
description: 'Цель обновлена',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'uuid',
|
||||||
|
name: 'Подушка безопасности (обновлено)',
|
||||||
|
targetAmount: 350000,
|
||||||
|
currentAmount: 120000,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
async update(
|
async update(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@ -100,7 +189,16 @@ export class GoalsController {
|
|||||||
@Post(':id/add-funds')
|
@Post(':id/add-funds')
|
||||||
@ApiOperation({ summary: 'Добавление средств к цели' })
|
@ApiOperation({ summary: 'Добавление средств к цели' })
|
||||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
@ApiResponse({ status: 200, description: 'Средства добавлены' })
|
@ApiOkResponse({
|
||||||
|
description: 'Средства добавлены',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'uuid',
|
||||||
|
currentAmount: 130000,
|
||||||
|
message: 'Средства добавлены',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
async addFunds(
|
async addFunds(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@ -112,7 +210,16 @@ export class GoalsController {
|
|||||||
@Post(':id/withdraw')
|
@Post(':id/withdraw')
|
||||||
@ApiOperation({ summary: 'Снятие средств с цели' })
|
@ApiOperation({ summary: 'Снятие средств с цели' })
|
||||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
@ApiResponse({ status: 200, description: 'Средства сняты' })
|
@ApiOkResponse({
|
||||||
|
description: 'Средства сняты',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'uuid',
|
||||||
|
currentAmount: 110000,
|
||||||
|
message: 'Средства сняты',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
async withdrawFunds(
|
async withdrawFunds(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
|||||||
@ -26,6 +26,11 @@ export enum RecommendationStatus {
|
|||||||
DISMISSED = 'DISMISSED',
|
DISMISSED = 'DISMISSED',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum RecommendationSource {
|
||||||
|
OPENROUTER = 'openrouter',
|
||||||
|
MOCK = 'mock',
|
||||||
|
}
|
||||||
|
|
||||||
@Entity('recommendations')
|
@Entity('recommendations')
|
||||||
export class Recommendation {
|
export class Recommendation {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
@ -47,16 +52,45 @@ export class Recommendation {
|
|||||||
@Column({ name: 'action_text_ru', length: 200, nullable: true })
|
@Column({ name: 'action_text_ru', length: 200, nullable: true })
|
||||||
actionTextRu: string;
|
actionTextRu: string;
|
||||||
|
|
||||||
@Column({ name: 'priority_score', type: 'decimal', precision: 3, scale: 2, default: 0.5 })
|
@Column({
|
||||||
|
name: 'priority_score',
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 3,
|
||||||
|
scale: 2,
|
||||||
|
default: 0.5,
|
||||||
|
})
|
||||||
priorityScore: number;
|
priorityScore: number;
|
||||||
|
|
||||||
@Column({ name: 'confidence_score', type: 'decimal', precision: 3, scale: 2, default: 0.5 })
|
@Column({
|
||||||
|
name: 'confidence_score',
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 3,
|
||||||
|
scale: 2,
|
||||||
|
default: 0.5,
|
||||||
|
})
|
||||||
confidenceScore: number;
|
confidenceScore: number;
|
||||||
|
|
||||||
@Column({ name: 'potential_savings', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: RecommendationSource,
|
||||||
|
default: RecommendationSource.MOCK,
|
||||||
|
})
|
||||||
|
source: RecommendationSource;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'potential_savings',
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 15,
|
||||||
|
scale: 2,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
potentialSavings: number;
|
potentialSavings: number;
|
||||||
|
|
||||||
@Column({ type: 'enum', enum: RecommendationStatus, default: RecommendationStatus.NEW })
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: RecommendationStatus,
|
||||||
|
default: RecommendationStatus.NEW,
|
||||||
|
})
|
||||||
status: RecommendationStatus;
|
status: RecommendationStatus;
|
||||||
|
|
||||||
@Column({ name: 'action_data', type: 'jsonb', nullable: true })
|
@Column({ name: 'action_data', type: 'jsonb', nullable: true })
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
|
ApiOkResponse,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiQuery,
|
ApiQuery,
|
||||||
ApiParam,
|
ApiParam,
|
||||||
@ -18,19 +19,46 @@ import {
|
|||||||
import { RecommendationsService } from './recommendations.service';
|
import { RecommendationsService } from './recommendations.service';
|
||||||
import { RecommendationType } from './entities/recommendation.entity';
|
import { RecommendationType } from './entities/recommendation.entity';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { CurrentUser, JwtPayload } from '../../common/decorators/user.decorator';
|
import {
|
||||||
|
CurrentUser,
|
||||||
|
JwtPayload,
|
||||||
|
} from '../../common/decorators/user.decorator';
|
||||||
|
|
||||||
@ApiTags('Рекомендации')
|
@ApiTags('Рекомендации')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('recommendations')
|
@Controller('recommendations')
|
||||||
export class RecommendationsController {
|
export class RecommendationsController {
|
||||||
constructor(private readonly recommendationsService: RecommendationsService) {}
|
constructor(
|
||||||
|
private readonly recommendationsService: RecommendationsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'Получение активных рекомендаций' })
|
@ApiOperation({ summary: 'Получение активных рекомендаций' })
|
||||||
@ApiQuery({ name: 'type', enum: RecommendationType, required: false })
|
@ApiQuery({ name: 'type', enum: RecommendationType, required: false })
|
||||||
@ApiResponse({ status: 200, description: 'Список рекомендаций' })
|
@ApiOkResponse({
|
||||||
|
description: 'Список рекомендаций',
|
||||||
|
schema: {
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
id: 'uuid',
|
||||||
|
userId: 'uuid',
|
||||||
|
type: 'SAVING',
|
||||||
|
titleRu: 'Увеличьте накопления',
|
||||||
|
descriptionRu: 'Рекомендуем увеличить норму сбережений до 20%.',
|
||||||
|
priorityScore: 0.9,
|
||||||
|
confidenceScore: 0.95,
|
||||||
|
source: 'openrouter',
|
||||||
|
status: 'NEW',
|
||||||
|
actionData: { targetSavingsRate: 20 },
|
||||||
|
expiresAt: '2024-02-15T00:00:00.000Z',
|
||||||
|
createdAt: '2024-01-15T00:00:00.000Z',
|
||||||
|
viewedAt: null,
|
||||||
|
appliedAt: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
async findAll(
|
async findAll(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Query('type') type?: RecommendationType,
|
@Query('type') type?: RecommendationType,
|
||||||
@ -40,14 +68,45 @@ export class RecommendationsController {
|
|||||||
|
|
||||||
@Get('stats')
|
@Get('stats')
|
||||||
@ApiOperation({ summary: 'Статистика по рекомендациям' })
|
@ApiOperation({ summary: 'Статистика по рекомендациям' })
|
||||||
@ApiResponse({ status: 200, description: 'Статистика' })
|
@ApiOkResponse({
|
||||||
|
description: 'Статистика',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
total: 10,
|
||||||
|
new: 4,
|
||||||
|
viewed: 3,
|
||||||
|
applied: 2,
|
||||||
|
dismissed: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
async getStats(@CurrentUser() user: JwtPayload) {
|
async getStats(@CurrentUser() user: JwtPayload) {
|
||||||
return this.recommendationsService.getStats(user.sub);
|
return this.recommendationsService.getStats(user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('generate')
|
@Post('generate')
|
||||||
@ApiOperation({ summary: 'Генерация новых рекомендаций' })
|
@ApiOperation({ summary: 'Генерация новых рекомендаций' })
|
||||||
@ApiResponse({ status: 200, description: 'Сгенерированные рекомендации' })
|
@ApiOkResponse({
|
||||||
|
description: 'Сгенерированные рекомендации',
|
||||||
|
schema: {
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
id: 'uuid',
|
||||||
|
userId: 'uuid',
|
||||||
|
type: 'BUDGET',
|
||||||
|
titleRu: 'Превышение лимита на необходимое',
|
||||||
|
descriptionRu: 'Вы израсходовали 95% бюджета на необходимые расходы.',
|
||||||
|
priorityScore: 0.9,
|
||||||
|
confidenceScore: 0.95,
|
||||||
|
source: 'mock',
|
||||||
|
status: 'NEW',
|
||||||
|
actionData: null,
|
||||||
|
expiresAt: '2024-02-15T00:00:00.000Z',
|
||||||
|
createdAt: '2024-01-15T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
async generate(@CurrentUser() user: JwtPayload) {
|
async generate(@CurrentUser() user: JwtPayload) {
|
||||||
return this.recommendationsService.generateRecommendations(user.sub);
|
return this.recommendationsService.generateRecommendations(user.sub);
|
||||||
}
|
}
|
||||||
@ -55,7 +114,24 @@ export class RecommendationsController {
|
|||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Получение рекомендации по ID' })
|
@ApiOperation({ summary: 'Получение рекомендации по ID' })
|
||||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
@ApiResponse({ status: 200, description: 'Рекомендация' })
|
@ApiOkResponse({
|
||||||
|
description: 'Рекомендация',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'uuid',
|
||||||
|
userId: 'uuid',
|
||||||
|
type: 'SAVING',
|
||||||
|
titleRu: 'Резервный фонд',
|
||||||
|
descriptionRu: 'Создайте резервный фонд 3–6 месячных расходов.',
|
||||||
|
priorityScore: 0.85,
|
||||||
|
confidenceScore: 0.9,
|
||||||
|
source: 'mock',
|
||||||
|
status: 'NEW',
|
||||||
|
actionData: { targetAmount: 480000 },
|
||||||
|
createdAt: '2024-01-15T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
async findOne(
|
async findOne(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@ -66,7 +142,16 @@ export class RecommendationsController {
|
|||||||
@Post(':id/view')
|
@Post(':id/view')
|
||||||
@ApiOperation({ summary: 'Отметить рекомендацию как просмотренную' })
|
@ApiOperation({ summary: 'Отметить рекомендацию как просмотренную' })
|
||||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
@ApiResponse({ status: 200, description: 'Рекомендация обновлена' })
|
@ApiOkResponse({
|
||||||
|
description: 'Рекомендация обновлена',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'uuid',
|
||||||
|
status: 'VIEWED',
|
||||||
|
viewedAt: '2024-01-15T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
async markAsViewed(
|
async markAsViewed(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@ -77,7 +162,16 @@ export class RecommendationsController {
|
|||||||
@Post(':id/apply')
|
@Post(':id/apply')
|
||||||
@ApiOperation({ summary: 'Отметить рекомендацию как примененную' })
|
@ApiOperation({ summary: 'Отметить рекомендацию как примененную' })
|
||||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
@ApiResponse({ status: 200, description: 'Рекомендация применена' })
|
@ApiOkResponse({
|
||||||
|
description: 'Рекомендация применена',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'uuid',
|
||||||
|
status: 'APPLIED',
|
||||||
|
appliedAt: '2024-01-15T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
async markAsApplied(
|
async markAsApplied(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@ -88,7 +182,15 @@ export class RecommendationsController {
|
|||||||
@Post(':id/dismiss')
|
@Post(':id/dismiss')
|
||||||
@ApiOperation({ summary: 'Отклонить рекомендацию' })
|
@ApiOperation({ summary: 'Отклонить рекомендацию' })
|
||||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
@ApiResponse({ status: 200, description: 'Рекомендация отклонена' })
|
@ApiOkResponse({
|
||||||
|
description: 'Рекомендация отклонена',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'uuid',
|
||||||
|
status: 'DISMISSED',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
async dismiss(
|
async dismiss(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, LessThan, MoreThan } from 'typeorm';
|
import { Repository, LessThan, MoreThan } from 'typeorm';
|
||||||
import { Recommendation, RecommendationType, RecommendationStatus } from './entities/recommendation.entity';
|
import {
|
||||||
|
Recommendation,
|
||||||
|
RecommendationType,
|
||||||
|
RecommendationStatus,
|
||||||
|
RecommendationSource,
|
||||||
|
} from './entities/recommendation.entity';
|
||||||
import { AiService } from '../ai/ai.service';
|
import { AiService } from '../ai/ai.service';
|
||||||
import { TransactionsService } from '../transactions/transactions.service';
|
import { TransactionsService } from '../transactions/transactions.service';
|
||||||
import { BudgetsService } from '../budgets/budgets.service';
|
import { BudgetsService } from '../budgets/budgets.service';
|
||||||
@ -21,7 +26,10 @@ export class RecommendationsService {
|
|||||||
/**
|
/**
|
||||||
* Get all active recommendations for a user
|
* Get all active recommendations for a user
|
||||||
*/
|
*/
|
||||||
async findAll(userId: string, type?: RecommendationType): Promise<Recommendation[]> {
|
async findAll(
|
||||||
|
userId: string,
|
||||||
|
type?: RecommendationType,
|
||||||
|
): Promise<Recommendation[]> {
|
||||||
const where: any = {
|
const where: any = {
|
||||||
userId,
|
userId,
|
||||||
status: RecommendationStatus.NEW,
|
status: RecommendationStatus.NEW,
|
||||||
@ -55,7 +63,7 @@ export class RecommendationsService {
|
|||||||
*/
|
*/
|
||||||
async markAsViewed(id: string, userId: string): Promise<Recommendation> {
|
async markAsViewed(id: string, userId: string): Promise<Recommendation> {
|
||||||
const recommendation = await this.findOne(id, userId);
|
const recommendation = await this.findOne(id, userId);
|
||||||
|
|
||||||
if (recommendation.status === RecommendationStatus.NEW) {
|
if (recommendation.status === RecommendationStatus.NEW) {
|
||||||
recommendation.status = RecommendationStatus.VIEWED;
|
recommendation.status = RecommendationStatus.VIEWED;
|
||||||
recommendation.viewedAt = new Date();
|
recommendation.viewedAt = new Date();
|
||||||
@ -70,10 +78,10 @@ export class RecommendationsService {
|
|||||||
*/
|
*/
|
||||||
async markAsApplied(id: string, userId: string): Promise<Recommendation> {
|
async markAsApplied(id: string, userId: string): Promise<Recommendation> {
|
||||||
const recommendation = await this.findOne(id, userId);
|
const recommendation = await this.findOne(id, userId);
|
||||||
|
|
||||||
recommendation.status = RecommendationStatus.APPLIED;
|
recommendation.status = RecommendationStatus.APPLIED;
|
||||||
recommendation.appliedAt = new Date();
|
recommendation.appliedAt = new Date();
|
||||||
|
|
||||||
return this.recommendationRepository.save(recommendation);
|
return this.recommendationRepository.save(recommendation);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,9 +90,9 @@ export class RecommendationsService {
|
|||||||
*/
|
*/
|
||||||
async dismiss(id: string, userId: string): Promise<Recommendation> {
|
async dismiss(id: string, userId: string): Promise<Recommendation> {
|
||||||
const recommendation = await this.findOne(id, userId);
|
const recommendation = await this.findOne(id, userId);
|
||||||
|
|
||||||
recommendation.status = RecommendationStatus.DISMISSED;
|
recommendation.status = RecommendationStatus.DISMISSED;
|
||||||
|
|
||||||
return this.recommendationRepository.save(recommendation);
|
return this.recommendationRepository.save(recommendation);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,22 +109,24 @@ export class RecommendationsService {
|
|||||||
|
|
||||||
// Calculate context for AI
|
// Calculate context for AI
|
||||||
const totalExpenses = transactions.data
|
const totalExpenses = transactions.data
|
||||||
.filter(t => t.type === 'EXPENSE')
|
.filter((t) => t.type === 'EXPENSE')
|
||||||
.reduce((sum, t) => sum + Number(t.amount), 0);
|
.reduce((sum, t) => sum + Number(t.amount), 0);
|
||||||
|
|
||||||
const totalIncome = transactions.data
|
const totalIncome = transactions.data
|
||||||
.filter(t => t.type === 'INCOME')
|
.filter((t) => t.type === 'INCOME')
|
||||||
.reduce((sum, t) => sum + Number(t.amount), 0);
|
.reduce((sum, t) => sum + Number(t.amount), 0);
|
||||||
|
|
||||||
const savingsRate = totalIncome > 0 ? ((totalIncome - totalExpenses) / totalIncome) * 100 : 0;
|
const savingsRate =
|
||||||
|
totalIncome > 0 ? ((totalIncome - totalExpenses) / totalIncome) * 100 : 0;
|
||||||
|
|
||||||
// Get top spending categories
|
// Get top spending categories
|
||||||
const categorySpending: Record<string, number> = {};
|
const categorySpending: Record<string, number> = {};
|
||||||
transactions.data
|
transactions.data
|
||||||
.filter(t => t.type === 'EXPENSE')
|
.filter((t) => t.type === 'EXPENSE')
|
||||||
.forEach(t => {
|
.forEach((t) => {
|
||||||
const catName = t.category?.nameRu || 'Другое';
|
const catName = t.category?.nameRu || 'Другое';
|
||||||
categorySpending[catName] = (categorySpending[catName] || 0) + Number(t.amount);
|
categorySpending[catName] =
|
||||||
|
(categorySpending[catName] || 0) + Number(t.amount);
|
||||||
});
|
});
|
||||||
|
|
||||||
const topCategories = Object.entries(categorySpending)
|
const topCategories = Object.entries(categorySpending)
|
||||||
@ -125,12 +135,15 @@ export class RecommendationsService {
|
|||||||
.map(([name]) => name);
|
.map(([name]) => name);
|
||||||
|
|
||||||
// Generate AI recommendations
|
// Generate AI recommendations
|
||||||
const aiRecommendations = await this.aiService.generateRecommendations(userId, {
|
const aiRecommendations = await this.aiService.generateRecommendations(
|
||||||
monthlyIncome: totalIncome,
|
userId,
|
||||||
totalExpenses,
|
{
|
||||||
savingsRate,
|
monthlyIncome: totalIncome,
|
||||||
topCategories,
|
totalExpenses,
|
||||||
});
|
savingsRate,
|
||||||
|
topCategories,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Save recommendations to database
|
// Save recommendations to database
|
||||||
const recommendations: Recommendation[] = [];
|
const recommendations: Recommendation[] = [];
|
||||||
@ -153,17 +166,26 @@ export class RecommendationsService {
|
|||||||
descriptionRu: rec.descriptionRu,
|
descriptionRu: rec.descriptionRu,
|
||||||
priorityScore: rec.priorityScore,
|
priorityScore: rec.priorityScore,
|
||||||
confidenceScore: rec.confidenceScore,
|
confidenceScore: rec.confidenceScore,
|
||||||
|
source:
|
||||||
|
rec.source === 'openrouter'
|
||||||
|
? RecommendationSource.OPENROUTER
|
||||||
|
: RecommendationSource.MOCK,
|
||||||
actionData: rec.actionData,
|
actionData: rec.actionData,
|
||||||
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
|
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
|
||||||
});
|
});
|
||||||
|
|
||||||
recommendations.push(await this.recommendationRepository.save(recommendation));
|
recommendations.push(
|
||||||
|
await this.recommendationRepository.save(recommendation),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add budget-based recommendations
|
// Add budget-based recommendations
|
||||||
if (currentBudget) {
|
if (currentBudget) {
|
||||||
const budgetRecommendations = this.generateBudgetRecommendations(currentBudget, userId);
|
const budgetRecommendations = this.generateBudgetRecommendations(
|
||||||
|
currentBudget,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
for (const rec of budgetRecommendations) {
|
for (const rec of budgetRecommendations) {
|
||||||
recommendations.push(await this.recommendationRepository.save(rec));
|
recommendations.push(await this.recommendationRepository.save(rec));
|
||||||
}
|
}
|
||||||
@ -171,7 +193,10 @@ export class RecommendationsService {
|
|||||||
|
|
||||||
// Add goal-based recommendations
|
// Add goal-based recommendations
|
||||||
if (goalsSummary.activeGoals > 0) {
|
if (goalsSummary.activeGoals > 0) {
|
||||||
const goalRecommendations = this.generateGoalRecommendations(goalsSummary, userId);
|
const goalRecommendations = this.generateGoalRecommendations(
|
||||||
|
goalsSummary,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
for (const rec of goalRecommendations) {
|
for (const rec of goalRecommendations) {
|
||||||
recommendations.push(await this.recommendationRepository.save(rec));
|
recommendations.push(await this.recommendationRepository.save(rec));
|
||||||
}
|
}
|
||||||
@ -183,35 +208,50 @@ export class RecommendationsService {
|
|||||||
/**
|
/**
|
||||||
* Generate budget-based recommendations
|
* Generate budget-based recommendations
|
||||||
*/
|
*/
|
||||||
private generateBudgetRecommendations(budget: any, userId: string): Recommendation[] {
|
private generateBudgetRecommendations(
|
||||||
|
budget: any,
|
||||||
|
userId: string,
|
||||||
|
): Recommendation[] {
|
||||||
const recommendations: Recommendation[] = [];
|
const recommendations: Recommendation[] = [];
|
||||||
|
|
||||||
// Check if overspending on essentials
|
// Check if overspending on essentials
|
||||||
const essentialsPercent = (Number(budget.essentialsSpent) / Number(budget.essentialsLimit)) * 100;
|
const essentialsPercent =
|
||||||
|
(Number(budget.essentialsSpent) / Number(budget.essentialsLimit)) * 100;
|
||||||
if (essentialsPercent > 90) {
|
if (essentialsPercent > 90) {
|
||||||
recommendations.push(this.recommendationRepository.create({
|
recommendations.push(
|
||||||
userId,
|
this.recommendationRepository.create({
|
||||||
type: RecommendationType.BUDGET,
|
userId,
|
||||||
titleRu: 'Превышение лимита на необходимое',
|
type: RecommendationType.BUDGET,
|
||||||
descriptionRu: `Вы израсходовали ${essentialsPercent.toFixed(0)}% бюджета на необходимые расходы. Рекомендуем пересмотреть траты или увеличить лимит.`,
|
titleRu: 'Превышение лимита на необходимое',
|
||||||
priorityScore: 0.9,
|
descriptionRu: `Вы израсходовали ${essentialsPercent.toFixed(0)}% бюджета на необходимые расходы. Рекомендуем пересмотреть траты или увеличить лимит.`,
|
||||||
confidenceScore: 0.95,
|
priorityScore: 0.9,
|
||||||
potentialSavings: Number(budget.essentialsSpent) - Number(budget.essentialsLimit),
|
confidenceScore: 0.95,
|
||||||
}));
|
source: RecommendationSource.MOCK,
|
||||||
|
potentialSavings:
|
||||||
|
Number(budget.essentialsSpent) - Number(budget.essentialsLimit),
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if underspending on savings
|
// Check if underspending on savings
|
||||||
const savingsPercent = (Number(budget.savingsSpent) / Number(budget.savingsLimit)) * 100;
|
const savingsPercent =
|
||||||
|
(Number(budget.savingsSpent) / Number(budget.savingsLimit)) * 100;
|
||||||
if (savingsPercent < 50 && new Date().getDate() > 15) {
|
if (savingsPercent < 50 && new Date().getDate() > 15) {
|
||||||
recommendations.push(this.recommendationRepository.create({
|
recommendations.push(
|
||||||
userId,
|
this.recommendationRepository.create({
|
||||||
type: RecommendationType.SAVING,
|
userId,
|
||||||
titleRu: 'Увеличьте накопления',
|
type: RecommendationType.SAVING,
|
||||||
descriptionRu: `Вы накопили только ${savingsPercent.toFixed(0)}% от запланированного. До конца месяца осталось время - переведите средства на накопления.`,
|
titleRu: 'Увеличьте накопления',
|
||||||
priorityScore: 0.8,
|
descriptionRu: `Вы накопили только ${savingsPercent.toFixed(0)}% от запланированного. До конца месяца осталось время - переведите средства на накопления.`,
|
||||||
confidenceScore: 0.9,
|
priorityScore: 0.8,
|
||||||
actionData: { targetAmount: Number(budget.savingsLimit) - Number(budget.savingsSpent) },
|
confidenceScore: 0.9,
|
||||||
}));
|
source: RecommendationSource.MOCK,
|
||||||
|
actionData: {
|
||||||
|
targetAmount:
|
||||||
|
Number(budget.savingsLimit) - Number(budget.savingsSpent),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return recommendations;
|
return recommendations;
|
||||||
@ -220,22 +260,28 @@ export class RecommendationsService {
|
|||||||
/**
|
/**
|
||||||
* Generate goal-based recommendations
|
* Generate goal-based recommendations
|
||||||
*/
|
*/
|
||||||
private generateGoalRecommendations(summary: any, userId: string): Recommendation[] {
|
private generateGoalRecommendations(
|
||||||
|
summary: any,
|
||||||
|
userId: string,
|
||||||
|
): Recommendation[] {
|
||||||
const recommendations: Recommendation[] = [];
|
const recommendations: Recommendation[] = [];
|
||||||
|
|
||||||
if (summary.overallProgress < 30 && summary.activeGoals > 0) {
|
if (summary.overallProgress < 30 && summary.activeGoals > 0) {
|
||||||
recommendations.push(this.recommendationRepository.create({
|
recommendations.push(
|
||||||
userId,
|
this.recommendationRepository.create({
|
||||||
type: RecommendationType.GOAL,
|
userId,
|
||||||
titleRu: 'Ускорьте достижение целей',
|
type: RecommendationType.GOAL,
|
||||||
descriptionRu: `Общий прогресс по вашим целям составляет ${summary.overallProgress.toFixed(0)}%. Рассмотрите возможность увеличения регулярных отчислений.`,
|
titleRu: 'Ускорьте достижение целей',
|
||||||
priorityScore: 0.7,
|
descriptionRu: `Общий прогресс по вашим целям составляет ${summary.overallProgress.toFixed(0)}%. Рассмотрите возможность увеличения регулярных отчислений.`,
|
||||||
confidenceScore: 0.85,
|
priorityScore: 0.7,
|
||||||
actionData: {
|
confidenceScore: 0.85,
|
||||||
currentProgress: summary.overallProgress,
|
source: RecommendationSource.MOCK,
|
||||||
activeGoals: summary.activeGoals,
|
actionData: {
|
||||||
},
|
currentProgress: summary.overallProgress,
|
||||||
}));
|
activeGoals: summary.activeGoals,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return recommendations;
|
return recommendations;
|
||||||
@ -270,12 +316,22 @@ export class RecommendationsService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
total: recommendations.length,
|
total: recommendations.length,
|
||||||
new: recommendations.filter(r => r.status === RecommendationStatus.NEW).length,
|
new: recommendations.filter((r) => r.status === RecommendationStatus.NEW)
|
||||||
viewed: recommendations.filter(r => r.status === RecommendationStatus.VIEWED).length,
|
.length,
|
||||||
applied: recommendations.filter(r => r.status === RecommendationStatus.APPLIED).length,
|
viewed: recommendations.filter(
|
||||||
dismissed: recommendations.filter(r => r.status === RecommendationStatus.DISMISSED).length,
|
(r) => r.status === RecommendationStatus.VIEWED,
|
||||||
|
).length,
|
||||||
|
applied: recommendations.filter(
|
||||||
|
(r) => r.status === RecommendationStatus.APPLIED,
|
||||||
|
).length,
|
||||||
|
dismissed: recommendations.filter(
|
||||||
|
(r) => r.status === RecommendationStatus.DISMISSED,
|
||||||
|
).length,
|
||||||
potentialSavings: recommendations
|
potentialSavings: recommendations
|
||||||
.filter(r => r.status !== RecommendationStatus.DISMISSED && r.potentialSavings)
|
.filter(
|
||||||
|
(r) =>
|
||||||
|
r.status !== RecommendationStatus.DISMISSED && r.potentialSavings,
|
||||||
|
)
|
||||||
.reduce((sum, r) => sum + Number(r.potentialSavings || 0), 0),
|
.reduce((sum, r) => sum + Number(r.potentialSavings || 0), 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
export * from './create-transaction.dto';
|
export * from './create-transaction.dto';
|
||||||
export * from './update-transaction.dto';
|
export * from './update-transaction.dto';
|
||||||
export * from './query-transactions.dto';
|
export * from './query-transactions.dto';
|
||||||
|
export * from './suggest-category.dto';
|
||||||
|
|||||||
33
src/modules/transactions/dto/suggest-category.dto.ts
Normal file
33
src/modules/transactions/dto/suggest-category.dto.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsDateString,
|
||||||
|
IsNumber,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class SuggestCategoryDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Описание транзакции',
|
||||||
|
example: 'Покупка продуктов в Пятерочке',
|
||||||
|
})
|
||||||
|
@IsString({ message: 'Описание должно быть строкой' })
|
||||||
|
@MaxLength(500, { message: 'Описание не должно превышать 500 символов' })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Сумма транзакции',
|
||||||
|
example: 1500,
|
||||||
|
})
|
||||||
|
@IsNumber({}, { message: 'Сумма должна быть числом' })
|
||||||
|
@Min(0.01, { message: 'Сумма должна быть положительным числом' })
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Дата транзакции (YYYY-MM-DD)',
|
||||||
|
example: '2024-01-15',
|
||||||
|
})
|
||||||
|
@IsDateString({}, { message: 'Некорректный формат даты' })
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
@ -15,27 +15,56 @@ import {
|
|||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiCreatedResponse,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiParam,
|
ApiParam,
|
||||||
ApiQuery,
|
ApiQuery,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { TransactionsService } from './transactions.service';
|
import { TransactionsService } from './transactions.service';
|
||||||
import { CreateTransactionDto, UpdateTransactionDto, QueryTransactionsDto } from './dto';
|
import {
|
||||||
|
CreateTransactionDto,
|
||||||
|
UpdateTransactionDto,
|
||||||
|
QueryTransactionsDto,
|
||||||
|
SuggestCategoryDto,
|
||||||
|
} from './dto';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { CurrentUser, JwtPayload } from '../../common/decorators/user.decorator';
|
import {
|
||||||
|
CurrentUser,
|
||||||
|
JwtPayload,
|
||||||
|
} from '../../common/decorators/user.decorator';
|
||||||
|
import { AiService } from '../ai/ai.service';
|
||||||
|
|
||||||
@ApiTags('Транзакции')
|
@ApiTags('Транзакции')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('transactions')
|
@Controller('transactions')
|
||||||
export class TransactionsController {
|
export class TransactionsController {
|
||||||
constructor(private readonly transactionsService: TransactionsService) {}
|
constructor(
|
||||||
|
private readonly transactionsService: TransactionsService,
|
||||||
|
private readonly aiService: AiService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'Получение списка транзакций' })
|
@ApiOperation({ summary: 'Получение списка транзакций' })
|
||||||
@ApiResponse({
|
@ApiOkResponse({
|
||||||
status: 200,
|
|
||||||
description: 'Список транзакций с пагинацией',
|
description: 'Список транзакций с пагинацией',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'uuid',
|
||||||
|
amount: 1500.5,
|
||||||
|
type: 'EXPENSE',
|
||||||
|
categoryId: 'uuid',
|
||||||
|
transactionDate: '2024-01-15T00:00:00.000Z',
|
||||||
|
description: 'Покупка продуктов в Пятерочке',
|
||||||
|
paymentMethod: 'CARD',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
meta: { page: 1, limit: 20, total: 1, totalPages: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
async findAll(
|
async findAll(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@ -48,9 +77,16 @@ export class TransactionsController {
|
|||||||
@ApiOperation({ summary: 'Получение сводки по транзакциям за период' })
|
@ApiOperation({ summary: 'Получение сводки по транзакциям за период' })
|
||||||
@ApiQuery({ name: 'startDate', required: true, example: '2024-01-01' })
|
@ApiQuery({ name: 'startDate', required: true, example: '2024-01-01' })
|
||||||
@ApiQuery({ name: 'endDate', required: true, example: '2024-01-31' })
|
@ApiQuery({ name: 'endDate', required: true, example: '2024-01-31' })
|
||||||
@ApiResponse({
|
@ApiOkResponse({
|
||||||
status: 200,
|
|
||||||
description: 'Сводка: доходы, расходы, баланс',
|
description: 'Сводка: доходы, расходы, баланс',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
totalIncome: 120000,
|
||||||
|
totalExpense: 85000,
|
||||||
|
balance: 35000,
|
||||||
|
transactionCount: 42,
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
async getSummary(
|
async getSummary(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@ -64,39 +100,69 @@ export class TransactionsController {
|
|||||||
@ApiOperation({ summary: 'Получение расходов по категориям' })
|
@ApiOperation({ summary: 'Получение расходов по категориям' })
|
||||||
@ApiQuery({ name: 'startDate', required: true, example: '2024-01-01' })
|
@ApiQuery({ name: 'startDate', required: true, example: '2024-01-01' })
|
||||||
@ApiQuery({ name: 'endDate', required: true, example: '2024-01-31' })
|
@ApiQuery({ name: 'endDate', required: true, example: '2024-01-31' })
|
||||||
@ApiResponse({
|
@ApiOkResponse({
|
||||||
status: 200,
|
|
||||||
description: 'Расходы, сгруппированные по категориям',
|
description: 'Расходы, сгруппированные по категориям',
|
||||||
|
schema: {
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
categoryId: 'uuid',
|
||||||
|
categoryName: 'Продукты',
|
||||||
|
total: 22000,
|
||||||
|
percentage: 25.88,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
async getByCategory(
|
async getByCategory(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@Query('startDate') startDate: string,
|
@Query('startDate') startDate: string,
|
||||||
@Query('endDate') endDate: string,
|
@Query('endDate') endDate: string,
|
||||||
) {
|
) {
|
||||||
return this.transactionsService.getSpendingByCategory(user.sub, startDate, endDate);
|
return this.transactionsService.getSpendingByCategory(
|
||||||
|
user.sub,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Получение транзакции по ID' })
|
@ApiOperation({ summary: 'Получение транзакции по ID' })
|
||||||
@ApiParam({ name: 'id', description: 'ID транзакции' })
|
@ApiParam({ name: 'id', description: 'ID транзакции' })
|
||||||
@ApiResponse({
|
@ApiOkResponse({
|
||||||
status: 200,
|
|
||||||
description: 'Транзакция',
|
description: 'Транзакция',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'uuid',
|
||||||
|
amount: 1500.5,
|
||||||
|
type: 'EXPENSE',
|
||||||
|
categoryId: 'uuid',
|
||||||
|
transactionDate: '2024-01-15T00:00:00.000Z',
|
||||||
|
description: 'Покупка продуктов в Пятерочке',
|
||||||
|
paymentMethod: 'CARD',
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 404, description: 'Транзакция не найдена' })
|
@ApiResponse({ status: 404, description: 'Транзакция не найдена' })
|
||||||
async findOne(
|
async findOne(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
|
||||||
@CurrentUser() user: JwtPayload,
|
|
||||||
@Param('id') id: string,
|
|
||||||
) {
|
|
||||||
return this.transactionsService.findOne(id, user.sub);
|
return this.transactionsService.findOne(id, user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({ summary: 'Создание транзакции' })
|
@ApiOperation({ summary: 'Создание транзакции' })
|
||||||
@ApiResponse({
|
@ApiCreatedResponse({
|
||||||
status: 201,
|
|
||||||
description: 'Транзакция создана',
|
description: 'Транзакция создана',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'uuid',
|
||||||
|
amount: 1500.5,
|
||||||
|
type: 'EXPENSE',
|
||||||
|
categoryId: 'uuid',
|
||||||
|
transactionDate: '2024-01-15T00:00:00.000Z',
|
||||||
|
description: 'Покупка продуктов в Пятерочке',
|
||||||
|
paymentMethod: 'CARD',
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 400, description: 'Некорректные данные' })
|
@ApiResponse({ status: 400, description: 'Некорректные данные' })
|
||||||
async create(
|
async create(
|
||||||
@ -106,12 +172,51 @@ export class TransactionsController {
|
|||||||
return this.transactionsService.create(user.sub, dto);
|
return this.transactionsService.create(user.sub, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('suggest-category')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'AI подсказка категории по описанию транзакции' })
|
||||||
|
@ApiOkResponse({
|
||||||
|
description: 'Подсказка категории',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
suggestedCategoryId: 'unknown',
|
||||||
|
suggestedCategoryName: 'Продукты',
|
||||||
|
confidence: 0.82,
|
||||||
|
reasoning:
|
||||||
|
'В описании обнаружены ключевые слова, связанные с продуктами.',
|
||||||
|
source: 'openrouter',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
async suggestCategory(
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
@Body() dto: SuggestCategoryDto,
|
||||||
|
) {
|
||||||
|
return this.aiService.categorizeTransaction({
|
||||||
|
description: dto.description,
|
||||||
|
amount: dto.amount,
|
||||||
|
date: dto.date,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Post('bulk')
|
@Post('bulk')
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({ summary: 'Массовое создание транзакций (импорт)' })
|
@ApiOperation({ summary: 'Массовое создание транзакций (импорт)' })
|
||||||
@ApiResponse({
|
@ApiCreatedResponse({
|
||||||
status: 201,
|
|
||||||
description: 'Транзакции созданы',
|
description: 'Транзакции созданы',
|
||||||
|
schema: {
|
||||||
|
example: [
|
||||||
|
{
|
||||||
|
id: 'uuid',
|
||||||
|
amount: 1500.5,
|
||||||
|
type: 'EXPENSE',
|
||||||
|
categoryId: 'uuid',
|
||||||
|
transactionDate: '2024-01-15T00:00:00.000Z',
|
||||||
|
description: 'Покупка продуктов',
|
||||||
|
paymentMethod: 'CARD',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
async bulkCreate(
|
async bulkCreate(
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
@ -123,9 +228,19 @@ export class TransactionsController {
|
|||||||
@Put(':id')
|
@Put(':id')
|
||||||
@ApiOperation({ summary: 'Обновление транзакции' })
|
@ApiOperation({ summary: 'Обновление транзакции' })
|
||||||
@ApiParam({ name: 'id', description: 'ID транзакции' })
|
@ApiParam({ name: 'id', description: 'ID транзакции' })
|
||||||
@ApiResponse({
|
@ApiOkResponse({
|
||||||
status: 200,
|
|
||||||
description: 'Транзакция обновлена',
|
description: 'Транзакция обновлена',
|
||||||
|
schema: {
|
||||||
|
example: {
|
||||||
|
id: 'uuid',
|
||||||
|
amount: 1600.0,
|
||||||
|
type: 'EXPENSE',
|
||||||
|
categoryId: 'uuid',
|
||||||
|
transactionDate: '2024-01-15T00:00:00.000Z',
|
||||||
|
description: 'Покупка продуктов (обновлено)',
|
||||||
|
paymentMethod: 'CARD',
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 404, description: 'Транзакция не найдена' })
|
@ApiResponse({ status: 404, description: 'Транзакция не найдена' })
|
||||||
async update(
|
async update(
|
||||||
@ -145,10 +260,7 @@ export class TransactionsController {
|
|||||||
description: 'Транзакция удалена',
|
description: 'Транзакция удалена',
|
||||||
})
|
})
|
||||||
@ApiResponse({ status: 404, description: 'Транзакция не найдена' })
|
@ApiResponse({ status: 404, description: 'Транзакция не найдена' })
|
||||||
async remove(
|
async remove(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
|
||||||
@CurrentUser() user: JwtPayload,
|
|
||||||
@Param('id') id: string,
|
|
||||||
) {
|
|
||||||
await this.transactionsService.remove(id, user.sub);
|
await this.transactionsService.remove(id, user.sub);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { TransactionsController } from './transactions.controller';
|
import { TransactionsController } from './transactions.controller';
|
||||||
import { TransactionsService } from './transactions.service';
|
import { TransactionsService } from './transactions.service';
|
||||||
import { Transaction } from './entities/transaction.entity';
|
import { Transaction } from './entities/transaction.entity';
|
||||||
|
import { AiModule } from '../ai/ai.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Transaction])],
|
imports: [TypeOrmModule.forFeature([Transaction]), AiModule],
|
||||||
controllers: [TransactionsController],
|
controllers: [TransactionsController],
|
||||||
providers: [TransactionsService],
|
providers: [TransactionsService],
|
||||||
exports: [TransactionsService],
|
exports: [TransactionsService],
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user