From b2bd406e7f45767443ed15ed4ef03100128844de Mon Sep 17 00:00:00 2001 From: Umyt Nuryagdiyev Date: Tue, 17 Feb 2026 23:41:50 +0500 Subject: [PATCH 1/2] chart endpoints implemented --- prisma/seed-test-data.sql | 84 ++++++++++++++++++ src/finance/controllers/summary.controller.ts | 24 ++++- src/finance/dto/summary.dto.ts | 18 ++++ src/finance/responses/summary.response.ts | 35 ++++++++ src/finance/services/summary.service.ts | 88 ++++++++++++++++++- 5 files changed, 244 insertions(+), 5 deletions(-) create mode 100644 prisma/seed-test-data.sql diff --git a/prisma/seed-test-data.sql b/prisma/seed-test-data.sql new file mode 100644 index 0000000..f69820b --- /dev/null +++ b/prisma/seed-test-data.sql @@ -0,0 +1,84 @@ +-- ============================================================ +-- SEED: Finance Articles (Categories) & Records +-- User: f6b51ae0-8a8e-45b6-85c3-11aeb6261330 +-- ============================================================ + +-- Expense Categories +INSERT INTO finance_articles (id, user_id, kind, name, color, is_archived, created_at, updated_at) VALUES + ('a0000001-0000-0000-0000-000000000001', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 'Food & Groceries', '#FF6384', false, NOW(), NOW()), + ('a0000001-0000-0000-0000-000000000002', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 'Transport', '#36A2EB', false, NOW(), NOW()), + ('a0000001-0000-0000-0000-000000000003', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 'Entertainment', '#FFCE56', false, NOW(), NOW()), + ('a0000001-0000-0000-0000-000000000004', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 'Utilities', '#4BC0C0', false, NOW(), NOW()), + ('a0000001-0000-0000-0000-000000000005', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 'Health', '#9966FF', false, NOW(), NOW()), + ('a0000001-0000-0000-0000-000000000006', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 'Shopping', '#FF9F40', false, NOW(), NOW()), + ('a0000001-0000-0000-0000-000000000007', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 'Rent', '#C9CBCF', false, NOW(), NOW()); + +-- Income Categories +INSERT INTO finance_articles (id, user_id, kind, name, color, is_archived, created_at, updated_at) VALUES + ('a0000002-0000-0000-0000-000000000001', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'INCOME', 'Salary', '#2ECC71', false, NOW(), NOW()), + ('a0000002-0000-0000-0000-000000000002', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'INCOME', 'Freelance', '#3498DB', false, NOW(), NOW()), + ('a0000002-0000-0000-0000-000000000003', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'INCOME', 'Investments', '#E74C3C', false, NOW(), NOW()); + +-- ============================================================ +-- Expense Records (Jan–Feb 2026) +-- ============================================================ +INSERT INTO finance_records (id, user_id, type, amount, currency, article_id, remark, operation_date, created_at, updated_at) VALUES + -- Food & Groceries (6 records, total 171,500 KZT) + ('r0000001-0000-0000-0000-000000000001', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 45000.0000, 'KZT', 'a0000001-0000-0000-0000-000000000001', 'Weekly groceries at Magnum', '2026-01-05 10:00:00', NOW(), NOW()), + ('r0000001-0000-0000-0000-000000000002', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 12500.0000, 'KZT', 'a0000001-0000-0000-0000-000000000001', 'Lunch delivery Wolt', '2026-01-08 13:00:00', NOW(), NOW()), + ('r0000001-0000-0000-0000-000000000003', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 38000.0000, 'KZT', 'a0000001-0000-0000-0000-000000000001', 'Groceries at Galmart', '2026-01-15 11:00:00', NOW(), NOW()), + ('r0000001-0000-0000-0000-000000000004', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 8700.0000, 'KZT', 'a0000001-0000-0000-0000-000000000001', 'Coffee and snacks', '2026-01-20 09:30:00', NOW(), NOW()), + ('r0000001-0000-0000-0000-000000000005', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 52000.0000, 'KZT', 'a0000001-0000-0000-0000-000000000001', 'Weekly groceries', '2026-02-01 10:00:00', NOW(), NOW()), + ('r0000001-0000-0000-0000-000000000006', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 15300.0000, 'KZT', 'a0000001-0000-0000-0000-000000000001', 'Restaurant dinner', '2026-02-10 19:00:00', NOW(), NOW()), + + -- Transport (4 records, total 47,700 KZT) + ('r0000002-0000-0000-0000-000000000001', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 3500.0000, 'KZT', 'a0000001-0000-0000-0000-000000000002', 'Yandex Go taxi', '2026-01-06 08:00:00', NOW(), NOW()), + ('r0000002-0000-0000-0000-000000000002', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 15000.0000, 'KZT', 'a0000001-0000-0000-0000-000000000002', 'Monthly bus pass', '2026-01-02 09:00:00', NOW(), NOW()), + ('r0000002-0000-0000-0000-000000000003', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 25000.0000, 'KZT', 'a0000001-0000-0000-0000-000000000002', 'Gas for car', '2026-01-18 14:00:00', NOW(), NOW()), + ('r0000002-0000-0000-0000-000000000004', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 4200.0000, 'KZT', 'a0000001-0000-0000-0000-000000000002', 'Taxi to airport', '2026-02-05 06:00:00', NOW(), NOW()), + + -- Entertainment (3 records, total 48,500 KZT) + ('r0000003-0000-0000-0000-000000000001', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 5000.0000, 'KZT', 'a0000001-0000-0000-0000-000000000003', 'Netflix subscription', '2026-01-01 00:00:00', NOW(), NOW()), + ('r0000003-0000-0000-0000-000000000002', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 8500.0000, 'KZT', 'a0000001-0000-0000-0000-000000000003', 'Cinema tickets', '2026-01-12 18:00:00', NOW(), NOW()), + ('r0000003-0000-0000-0000-000000000003', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 35000.0000, 'KZT', 'a0000001-0000-0000-0000-000000000003', 'Concert tickets', '2026-02-14 20:00:00', NOW(), NOW()), + + -- Utilities (3 records, total 38,500 KZT) + ('r0000004-0000-0000-0000-000000000001', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 18000.0000, 'KZT', 'a0000001-0000-0000-0000-000000000004', 'Electricity bill', '2026-01-10 12:00:00', NOW(), NOW()), + ('r0000004-0000-0000-0000-000000000002', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 12000.0000, 'KZT', 'a0000001-0000-0000-0000-000000000004', 'Internet + mobile', '2026-01-10 12:00:00', NOW(), NOW()), + ('r0000004-0000-0000-0000-000000000003', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 8500.0000, 'KZT', 'a0000001-0000-0000-0000-000000000004', 'Water bill', '2026-02-10 12:00:00', NOW(), NOW()), + + -- Health (2 records, total 32,500 KZT) + ('r0000005-0000-0000-0000-000000000001', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 25000.0000, 'KZT', 'a0000001-0000-0000-0000-000000000005', 'Dentist visit', '2026-01-22 15:00:00', NOW(), NOW()), + ('r0000005-0000-0000-0000-000000000002', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 7500.0000, 'KZT', 'a0000001-0000-0000-0000-000000000005', 'Pharmacy', '2026-02-03 11:00:00', NOW(), NOW()), + + -- Shopping (2 records, total 77,000 KZT) + ('r0000006-0000-0000-0000-000000000001', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 65000.0000, 'KZT', 'a0000001-0000-0000-0000-000000000006', 'Winter jacket', '2026-01-25 16:00:00', NOW(), NOW()), + ('r0000006-0000-0000-0000-000000000002', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 12000.0000, 'KZT', 'a0000001-0000-0000-0000-000000000006', 'Books from Marwin', '2026-02-08 14:00:00', NOW(), NOW()), + + -- Rent (2 records, total 360,000 KZT) + ('r0000007-0000-0000-0000-000000000001', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 180000.0000, 'KZT', 'a0000001-0000-0000-0000-000000000007', 'Rent January', '2026-01-01 00:00:00', NOW(), NOW()), + ('r0000007-0000-0000-0000-000000000002', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 180000.0000, 'KZT', 'a0000001-0000-0000-0000-000000000007', 'Rent February', '2026-02-01 00:00:00', NOW(), NOW()), + + -- UNCATEGORIZED expenses (article_id = NULL -> "No Category") + ('r0000008-0000-0000-0000-000000000001', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 6000.0000, 'KZT', NULL, 'ATM withdrawal fee', '2026-01-14 17:00:00', NOW(), NOW()), + ('r0000008-0000-0000-0000-000000000002', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 3500.0000, 'KZT', NULL, 'Random purchase', '2026-01-28 10:00:00', NOW(), NOW()), + ('r0000008-0000-0000-0000-000000000003', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'EXPENSE', 9800.0000, 'KZT', NULL, 'Gift for friend', '2026-02-12 15:00:00', NOW(), NOW()); + +-- ============================================================ +-- Income Records +-- ============================================================ +INSERT INTO finance_records (id, user_id, type, amount, currency, article_id, remark, operation_date, created_at, updated_at) VALUES + -- Salary (2 records, total 1,300,000 KZT) + ('r0000009-0000-0000-0000-000000000001', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'INCOME', 650000.0000, 'KZT', 'a0000002-0000-0000-0000-000000000001', 'Salary January', '2026-01-05 00:00:00', NOW(), NOW()), + ('r0000009-0000-0000-0000-000000000002', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'INCOME', 650000.0000, 'KZT', 'a0000002-0000-0000-0000-000000000001', 'Salary February', '2026-02-05 00:00:00', NOW(), NOW()), + + -- Freelance (2 records, 150,000 KZT + 200 USD) + ('r0000010-0000-0000-0000-000000000001', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'INCOME', 150000.0000, 'KZT', 'a0000002-0000-0000-0000-000000000002', 'Website project', '2026-01-15 00:00:00', NOW(), NOW()), + ('r0000010-0000-0000-0000-000000000002', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'INCOME', 200.0000, 'USD', 'a0000002-0000-0000-0000-000000000002', 'Upwork project payment', '2026-02-01 00:00:00', NOW(), NOW()), + + -- Investments (1 record, 35,000 KZT) + ('r0000011-0000-0000-0000-000000000001', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'INCOME', 35000.0000, 'KZT', 'a0000002-0000-0000-0000-000000000003', 'Stock dividends', '2026-01-20 00:00:00', NOW(), NOW()), + + -- UNCATEGORIZED income (article_id = NULL) + ('r0000012-0000-0000-0000-000000000001', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'INCOME', 20000.0000, 'KZT', NULL, 'Friend returned debt', '2026-01-30 00:00:00', NOW(), NOW()), + ('r0000012-0000-0000-0000-000000000002', 'f6b51ae0-8a8e-45b6-85c3-11aeb6261330', 'INCOME', 5000.0000, 'KZT', NULL, 'Cashback bonus', '2026-02-15 00:00:00', NOW(), NOW()); diff --git a/src/finance/controllers/summary.controller.ts b/src/finance/controllers/summary.controller.ts index e5becb3..37141f0 100644 --- a/src/finance/controllers/summary.controller.ts +++ b/src/finance/controllers/summary.controller.ts @@ -14,8 +14,8 @@ import { import { CurrentUser } from '@common/src/decorators'; import { JwtPayload } from '../../auth/interfaces'; import { SummaryService } from '../services'; -import { SummaryDto } from '../dto'; -import { SummaryResponse } from '../responses'; +import { ChartQueryDto, SummaryDto } from '../dto'; +import { ChartResponse, SummaryResponse } from '../responses'; @ApiTags('finance/summary') @ApiBearerAuth('access-token') @@ -33,4 +33,24 @@ export class SummaryController { ): Promise { return this.summaryService.getSummary(user.id, query); } + + @Get('chart/expenses') + @ApiOperation({ summary: 'Get expense breakdown by category for charts' }) + @ApiOkResponse({ type: ChartResponse }) + async getExpenseChart( + @CurrentUser() user: JwtPayload, + @Query() query: ChartQueryDto, + ): Promise { + return this.summaryService.getExpenseChart(user.id, query); + } + + @Get('chart/income') + @ApiOperation({ summary: 'Get income breakdown by category for charts' }) + @ApiOkResponse({ type: ChartResponse }) + async getIncomeChart( + @CurrentUser() user: JwtPayload, + @Query() query: ChartQueryDto, + ): Promise { + return this.summaryService.getIncomeChart(user.id, query); + } } diff --git a/src/finance/dto/summary.dto.ts b/src/finance/dto/summary.dto.ts index d17ec68..32ff272 100644 --- a/src/finance/dto/summary.dto.ts +++ b/src/finance/dto/summary.dto.ts @@ -35,3 +35,21 @@ export class SummaryDto { }) baseCurrency?: string; } + +export class ChartQueryDto { + @ApiProperty({ + description: 'Start date for chart period', + example: '2024-01-01', + }) + @IsDateString() + @IsNotEmpty() + from: string; + + @ApiProperty({ + description: 'End date for chart period', + example: '2024-12-31', + }) + @IsDateString() + @IsNotEmpty() + to: string; +} diff --git a/src/finance/responses/summary.response.ts b/src/finance/responses/summary.response.ts index 40535a9..fa27d23 100644 --- a/src/finance/responses/summary.response.ts +++ b/src/finance/responses/summary.response.ts @@ -38,3 +38,38 @@ export class SummaryResponse { Object.assign(this, data); } } + +export class ChartItemResponse { + @ApiProperty({ description: 'Article (category) ID, null if uncategorized' }) + articleId: string | null; + + @ApiProperty({ description: 'Category name or "No Category"' }) + categoryName: string; + + @ApiProperty({ description: 'Category hex color' }) + categoryColor: string; + + @ApiProperty({ description: 'Total amount as string' }) + total: string; + + @ApiProperty({ description: 'ISO 4217 currency code' }) + currency: string; + + @ApiProperty({ description: 'Percentage of total for this currency' }) + percentage: number; +} + +export class ChartResponse { + @ApiProperty({ type: [ChartItemResponse] }) + items: ChartItemResponse[]; + + @ApiProperty({ + description: 'Grand total per currency', + example: { KZT: '795000.0000' }, + }) + total: Record; + + constructor(data: ChartResponse) { + Object.assign(this, data); + } +} diff --git a/src/finance/services/summary.service.ts b/src/finance/services/summary.service.ts index c42b0eb..ff1f450 100644 --- a/src/finance/services/summary.service.ts +++ b/src/finance/services/summary.service.ts @@ -1,9 +1,9 @@ import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; -import { SummaryDto } from '../dto'; -import { Prisma } from '@prisma/client'; +import { ChartQueryDto, SummaryDto } from '../dto'; +import { FinanceRecordType, Prisma } from '@prisma/client'; import { RateService } from './rate.service'; -import { SummaryResponse } from '../responses'; +import { ChartResponse, SummaryResponse } from '../responses'; @Injectable() export class SummaryService { @@ -121,6 +121,88 @@ export class SummaryService { return new SummaryResponse(response); } + async getExpenseChart( + userId: string, + query: ChartQueryDto, + ): Promise { + return this.getChart(userId, 'EXPENSE', query); + } + + async getIncomeChart( + userId: string, + query: ChartQueryDto, + ): Promise { + return this.getChart(userId, 'INCOME', query); + } + + private async getChart( + userId: string, + type: FinanceRecordType, + query: ChartQueryDto, + ): Promise { + const fromDate = new Date(query.from); + const toDate = new Date(query.to); + + const grouped = await this.prisma.financeRecord.groupBy({ + by: ['articleId', 'currency'], + where: { + userId, + type, + operationDate: { gte: fromDate, lte: toDate }, + }, + _sum: { amount: true }, + }); + + const articleIds = grouped + .map((g) => g.articleId) + .filter((id): id is string => id !== null); + + const articles = + articleIds.length > 0 + ? await this.prisma.financeArticle.findMany({ + where: { id: { in: articleIds } }, + select: { id: true, name: true, color: true }, + }) + : []; + + const articleMap = new Map(articles.map((a) => [a.id, a])); + + const currencyTotals: Record = {}; + for (const g of grouped) { + const amount = g._sum.amount ?? new Prisma.Decimal(0); + currencyTotals[g.currency] = ( + currencyTotals[g.currency] ?? new Prisma.Decimal(0) + ).add(amount); + } + + const items = grouped + .map((g) => { + const amount = g._sum.amount ?? new Prisma.Decimal(0); + const article = g.articleId ? articleMap.get(g.articleId) : null; + const grandTotal = currencyTotals[g.currency]; + const percentage = grandTotal.isZero() + ? 0 + : parseFloat(amount.mul(100).div(grandTotal).toFixed(2)); + + return { + articleId: g.articleId, + categoryName: article?.name ?? 'No Category', + categoryColor: article?.color ?? '#B0BEC5', + total: amount.toString(), + currency: g.currency, + percentage, + }; + }) + .sort((a, b) => parseFloat(b.total) - parseFloat(a.total)); + + const total: Record = {}; + for (const [currency, amount] of Object.entries(currencyTotals)) { + total[currency] = amount.toString(); + } + + return new ChartResponse({ items, total }); + } + private async convertToBase( userId: string, amount: Prisma.Decimal, From 3ceacd2a1733fff55e1740140a1007c445001bdc Mon Sep 17 00:00:00 2001 From: Umyt Nuryagdiyev Date: Tue, 17 Feb 2026 23:59:40 +0500 Subject: [PATCH 2/2] base currency conversion implemented on charts --- src/finance/dto/summary.dto.ts | 12 ++++++ src/finance/services/summary.service.ts | 53 +++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/finance/dto/summary.dto.ts b/src/finance/dto/summary.dto.ts index 32ff272..fe8e9d6 100644 --- a/src/finance/dto/summary.dto.ts +++ b/src/finance/dto/summary.dto.ts @@ -52,4 +52,16 @@ export class ChartQueryDto { @IsDateString() @IsNotEmpty() to: string; + + @ApiPropertyOptional({ + description: + 'Convert all amounts to this currency. If omitted, amounts stay in their original currency.', + example: 'USD', + }) + @IsString() + @IsOptional() + @Matches(/^[A-Z]{3}$/, { + message: 'Currency must be a 3-letter ISO code in uppercase', + }) + baseCurrency?: string; } diff --git a/src/finance/services/summary.service.ts b/src/finance/services/summary.service.ts index ff1f450..62b4d0e 100644 --- a/src/finance/services/summary.service.ts +++ b/src/finance/services/summary.service.ts @@ -142,6 +142,7 @@ export class SummaryService { ): Promise { const fromDate = new Date(query.from); const toDate = new Date(query.to); + const baseCurrency = query.baseCurrency?.toUpperCase(); const grouped = await this.prisma.financeRecord.groupBy({ by: ['articleId', 'currency'], @@ -167,6 +168,58 @@ export class SummaryService { const articleMap = new Map(articles.map((a) => [a.id, a])); + if (baseCurrency) { + // Convert all amounts to base currency, merge same articleId + const mergedMap = new Map< + string | null, + { amount: Prisma.Decimal; article: (typeof articles)[0] | null } + >(); + + for (const g of grouped) { + const raw = g._sum.amount ?? new Prisma.Decimal(0); + const converted = await this.convertToBase( + userId, + raw, + g.currency, + baseCurrency, + toDate, + ); + const existing = mergedMap.get(g.articleId); + const article = g.articleId + ? articleMap.get(g.articleId) ?? null + : null; + if (existing) { + existing.amount = existing.amount.add(converted); + } else { + mergedMap.set(g.articleId, { amount: converted, article }); + } + } + + let grandTotal = new Prisma.Decimal(0); + for (const { amount } of mergedMap.values()) { + grandTotal = grandTotal.add(amount); + } + + const items = Array.from(mergedMap.entries()) + .map(([articleId, { amount, article }]) => ({ + articleId, + categoryName: article?.name ?? 'No Category', + categoryColor: article?.color ?? '#B0BEC5', + total: amount.toFixed(2), + currency: baseCurrency, + percentage: grandTotal.isZero() + ? 0 + : parseFloat(amount.mul(100).div(grandTotal).toFixed(2)), + })) + .sort((a, b) => parseFloat(b.total) - parseFloat(a.total)); + + return new ChartResponse({ + items, + total: { [baseCurrency]: grandTotal.toFixed(2) }, + }); + } + + // No base currency — keep amounts in original currencies const currencyTotals: Record = {}; for (const g of grouped) { const amount = g._sum.amount ?? new Prisma.Decimal(0);