Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions prisma/seed-test-data.sql
Original file line number Diff line number Diff line change
@@ -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());
24 changes: 22 additions & 2 deletions src/finance/controllers/summary.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -33,4 +33,24 @@ export class SummaryController {
): Promise<SummaryResponse> {
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<ChartResponse> {
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<ChartResponse> {
return this.summaryService.getIncomeChart(user.id, query);
}
}
30 changes: 30 additions & 0 deletions src/finance/dto/summary.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,33 @@ 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;

@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;
}
35 changes: 35 additions & 0 deletions src/finance/responses/summary.response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;

constructor(data: ChartResponse) {
Object.assign(this, data);
}
}
141 changes: 138 additions & 3 deletions src/finance/services/summary.service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -121,6 +121,141 @@ export class SummaryService {
return new SummaryResponse(response);
}

async getExpenseChart(
userId: string,
query: ChartQueryDto,
): Promise<ChartResponse> {
return this.getChart(userId, 'EXPENSE', query);
}

async getIncomeChart(
userId: string,
query: ChartQueryDto,
): Promise<ChartResponse> {
return this.getChart(userId, 'INCOME', query);
}

private async getChart(
userId: string,
type: FinanceRecordType,
query: ChartQueryDto,
): Promise<ChartResponse> {
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'],
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]));

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<string, Prisma.Decimal> = {};
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<string, string> = {};
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,
Expand Down