This commit is contained in:
Заид Омар Медхат 2025-12-13 15:57:10 +05:00
parent 33602d0fe9
commit a0febf85f3
8 changed files with 65 additions and 42 deletions

View File

@ -14,6 +14,7 @@ import {
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';
@ApiTags('Аналитика') @ApiTags('Аналитика')
@ApiBearerAuth() @ApiBearerAuth()
@ -49,19 +50,19 @@ export class AnalyticsController {
@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' })
@ApiQuery({ name: 'type', enum: ['INCOME', 'EXPENSE'], required: false }) @ApiQuery({ name: 'type', enum: TransactionType, required: false })
@ApiResponse({ status: 200, description: 'Разбивка расходов по категориям' }) @ApiResponse({ status: 200, description: 'Разбивка расходов по категориям' })
async getCategoryBreakdown( async getCategoryBreakdown(
@CurrentUser() user: JwtPayload, @CurrentUser() user: JwtPayload,
@Query('startDate') startDate: string, @Query('startDate') startDate: string,
@Query('endDate') endDate: string, @Query('endDate') endDate: string,
@Query('type') type?: 'INCOME' | 'EXPENSE', @Query('type') type?: TransactionType,
) { ) {
return this.analyticsService.getCategoryBreakdown( return this.analyticsService.getCategoryBreakdown(
user.sub, user.sub,
new Date(startDate), new Date(startDate),
new Date(endDate), new Date(endDate),
type || 'EXPENSE', type || TransactionType.EXPENSE,
); );
} }

View File

@ -7,6 +7,7 @@ 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';
export interface MonthlyOverview { export interface MonthlyOverview {
month: string; month: string;
@ -80,11 +81,11 @@ export class AnalyticsService {
}); });
const totalIncome = transactions const totalIncome = transactions
.filter(t => t.type === '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 === '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;
@ -93,7 +94,7 @@ export class AnalyticsService {
// 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 === '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 || 'Другое';
@ -138,7 +139,7 @@ export class AnalyticsService {
const transactions = await this.transactionRepository.find({ const transactions = await this.transactionRepository.find({
where: { where: {
userId, userId,
type: 'EXPENSE', type: TransactionType.EXPENSE,
transactionDate: Between(monthStart, monthEnd), transactionDate: Between(monthStart, monthEnd),
}, },
}); });
@ -167,7 +168,7 @@ export class AnalyticsService {
userId: string, userId: string,
startDate: Date, startDate: Date,
endDate: Date, endDate: Date,
type: 'INCOME' | 'EXPENSE' = 'EXPENSE', type: TransactionType = TransactionType.EXPENSE,
): Promise<CategoryBreakdown[]> { ): Promise<CategoryBreakdown[]> {
const transactions = await this.transactionRepository.find({ const transactions = await this.transactionRepository.find({
where: { where: {
@ -246,11 +247,11 @@ export class AnalyticsService {
}); });
const income = transactions const income = transactions
.filter(t => t.type === '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 === '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({
@ -344,7 +345,7 @@ export class AnalyticsService {
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 === '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));
@ -404,11 +405,11 @@ export class AnalyticsService {
}); });
const totalIncome = transactions const totalIncome = transactions
.filter(t => t.type === '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 === '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
@ -418,7 +419,7 @@ export class AnalyticsService {
if (!monthlyData[month]) { if (!monthlyData[month]) {
monthlyData[month] = { income: 0, expenses: 0 }; monthlyData[month] = { income: 0, expenses: 0 };
} }
if (t.type === 'INCOME') { if (t.type === TransactionType.INCOME) {
monthlyData[month].income += Number(t.amount); monthlyData[month].income += Number(t.amount);
} else { } else {
monthlyData[month].expenses += Number(t.amount); monthlyData[month].expenses += Number(t.amount);
@ -445,7 +446,7 @@ 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 === '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]) {

View File

@ -8,6 +8,7 @@ import {
Index, Index,
} from 'typeorm'; } from 'typeorm';
import { User } from '../../auth/entities/user.entity'; import { User } from '../../auth/entities/user.entity';
import { BudgetGroupType, TransactionType } from '../../../common/constants/categories';
@Entity('categories') @Entity('categories')
export class Category { export class Category {
@ -21,11 +22,11 @@ export class Category {
nameEn: string; nameEn: string;
@Index() @Index()
@Column({ length: 10 }) @Column({ type: 'enum', enum: TransactionType })
type: 'INCOME' | 'EXPENSE'; type: TransactionType;
@Column({ name: 'group_type', length: 20, nullable: true }) @Column({ name: 'group_type', type: 'enum', enum: BudgetGroupType, nullable: true })
groupType: 'ESSENTIAL' | 'PERSONAL' | 'SAVINGS' | null; groupType: BudgetGroupType | null;
@Column({ length: 50, nullable: true }) @Column({ length: 50, nullable: true })
icon: string; icon: string;

View File

@ -11,7 +11,7 @@ import {
MaxLength, MaxLength,
Matches, Matches,
} from 'class-validator'; } from 'class-validator';
import { GoalPriority } from '../entities/goal.entity'; import { GoalAutoSaveFrequency, GoalPriority } from '../entities/goal.entity';
export class CreateGoalDto { export class CreateGoalDto {
@ApiProperty({ @ApiProperty({
@ -101,10 +101,10 @@ export class CreateGoalDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Частота автоматического накопления', description: 'Частота автоматического накопления',
enum: ['DAILY', 'WEEKLY', 'MONTHLY'], enum: GoalAutoSaveFrequency,
example: 'MONTHLY', example: 'MONTHLY',
}) })
@IsOptional() @IsOptional()
@IsEnum(['DAILY', 'WEEKLY', 'MONTHLY']) @IsEnum(GoalAutoSaveFrequency)
autoSaveFrequency?: 'DAILY' | 'WEEKLY' | 'MONTHLY'; autoSaveFrequency?: GoalAutoSaveFrequency;
} }

View File

@ -11,7 +11,7 @@ import {
MaxLength, MaxLength,
Matches, Matches,
} from 'class-validator'; } from 'class-validator';
import { GoalPriority, GoalStatus } from '../entities/goal.entity'; import { GoalAutoSaveFrequency, GoalPriority, GoalStatus } from '../entities/goal.entity';
export class UpdateGoalDto { export class UpdateGoalDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
@ -103,12 +103,12 @@ export class UpdateGoalDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Частота автоматического накопления', description: 'Частота автоматического накопления',
enum: ['DAILY', 'WEEKLY', 'MONTHLY'], enum: GoalAutoSaveFrequency,
example: 'MONTHLY', example: 'MONTHLY',
}) })
@IsOptional() @IsOptional()
@IsEnum(['DAILY', 'WEEKLY', 'MONTHLY']) @IsEnum(GoalAutoSaveFrequency)
autoSaveFrequency?: 'DAILY' | 'WEEKLY' | 'MONTHLY'; autoSaveFrequency?: GoalAutoSaveFrequency;
} }
export class AddFundsDto { export class AddFundsDto {

View File

@ -23,6 +23,12 @@ export enum GoalPriority {
HIGH = 'HIGH', HIGH = 'HIGH',
} }
export enum GoalAutoSaveFrequency {
DAILY = 'DAILY',
WEEKLY = 'WEEKLY',
MONTHLY = 'MONTHLY',
}
@Entity('goals') @Entity('goals')
export class Goal { export class Goal {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
@ -69,8 +75,8 @@ export class Goal {
@Column({ name: 'auto_save_amount', type: 'decimal', precision: 15, scale: 2, nullable: true }) @Column({ name: 'auto_save_amount', type: 'decimal', precision: 15, scale: 2, nullable: true })
autoSaveAmount: number; autoSaveAmount: number;
@Column({ name: 'auto_save_frequency', length: 20, nullable: true }) @Column({ name: 'auto_save_frequency', type: 'enum', enum: GoalAutoSaveFrequency, nullable: true })
autoSaveFrequency: 'DAILY' | 'WEEKLY' | 'MONTHLY' | null; autoSaveFrequency: GoalAutoSaveFrequency | null;
// Timestamps // Timestamps
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })

View File

@ -49,9 +49,19 @@ export class GoalsService {
*/ */
async create(userId: string, dto: CreateGoalDto): Promise<Goal> { async create(userId: string, dto: CreateGoalDto): Promise<Goal> {
const goal = this.goalRepository.create({ const goal = this.goalRepository.create({
...dto,
userId, userId,
titleRu: dto.titleRu,
descriptionRu: dto.descriptionRu,
targetAmount: dto.targetAmount,
currentAmount: dto.currentAmount ?? 0,
currency: 'RUB',
targetDate: dto.targetDate ? new Date(dto.targetDate) : undefined, targetDate: dto.targetDate ? new Date(dto.targetDate) : undefined,
priority: dto.priority,
icon: dto.icon,
color: dto.color,
autoSaveEnabled: dto.autoSaveEnabled ?? false,
autoSaveAmount: dto.autoSaveAmount,
autoSaveFrequency: dto.autoSaveFrequency ?? undefined,
}); });
return this.goalRepository.save(goal); return this.goalRepository.save(goal);
@ -63,14 +73,17 @@ export class GoalsService {
async update(id: string, userId: string, dto: UpdateGoalDto): Promise<Goal> { async update(id: string, userId: string, dto: UpdateGoalDto): Promise<Goal> {
const goal = await this.findOne(id, userId); const goal = await this.findOne(id, userId);
if (dto.targetDate) { if (dto.titleRu !== undefined) goal.titleRu = dto.titleRu;
goal.targetDate = new Date(dto.targetDate); if (dto.descriptionRu !== undefined) goal.descriptionRu = dto.descriptionRu;
} if (dto.targetAmount !== undefined) goal.targetAmount = dto.targetAmount;
if (dto.targetDate !== undefined) goal.targetDate = dto.targetDate ? new Date(dto.targetDate) : null as any;
Object.assign(goal, { if (dto.status !== undefined) goal.status = dto.status;
...dto, if (dto.priority !== undefined) goal.priority = dto.priority;
targetDate: dto.targetDate ? new Date(dto.targetDate) : goal.targetDate, if (dto.icon !== undefined) goal.icon = dto.icon;
}); if (dto.color !== undefined) goal.color = dto.color;
if (dto.autoSaveEnabled !== undefined) goal.autoSaveEnabled = dto.autoSaveEnabled;
if (dto.autoSaveAmount !== undefined) goal.autoSaveAmount = dto.autoSaveAmount;
if (dto.autoSaveFrequency !== undefined) goal.autoSaveFrequency = dto.autoSaveFrequency;
// Check if goal is completed // Check if goal is completed
if (goal.isCompleted && goal.status === GoalStatus.ACTIVE) { if (goal.isCompleted && goal.status === GoalStatus.ACTIVE) {

View File

@ -12,6 +12,7 @@ import {
} from 'typeorm'; } from 'typeorm';
import { User } from '../../auth/entities/user.entity'; import { User } from '../../auth/entities/user.entity';
import { Category } from '../../categories/entities/category.entity'; import { Category } from '../../categories/entities/category.entity';
import { PaymentMethod, TransactionType } from '../../../common/constants/categories';
@Entity('transactions') @Entity('transactions')
@Check('"amount" > 0') @Check('"amount" > 0')
@ -30,8 +31,8 @@ export class Transaction {
currency: string; currency: string;
@Index() @Index()
@Column({ length: 10 }) @Column({ type: 'enum', enum: TransactionType })
type: 'INCOME' | 'EXPENSE'; type: TransactionType;
@Index() @Index()
@Column({ name: 'category_id', type: 'uuid', nullable: true }) @Column({ name: 'category_id', type: 'uuid', nullable: true })
@ -44,8 +45,8 @@ export class Transaction {
@Column({ name: 'transaction_date', type: 'date' }) @Column({ name: 'transaction_date', type: 'date' })
transactionDate: Date; transactionDate: Date;
@Column({ name: 'payment_method', length: 20, nullable: true }) @Column({ name: 'payment_method', type: 'enum', enum: PaymentMethod, nullable: true })
paymentMethod: 'CASH' | 'CARD' | 'BANK_TRANSFER' | null; paymentMethod: PaymentMethod | null;
// Receipt tracking // Receipt tracking
@Column({ name: 'receipt_url', length: 500, nullable: true }) @Column({ name: 'receipt_url', length: 500, nullable: true })