Commit 8afd3011 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/financial-reports-analytics' into 'develop'

feat(reports): add financial reports and analytics

See merge request !16
parents 6bcdde17 1b9a470d
......@@ -19,14 +19,17 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
update và delete giao dịch cập nhật Wallet trong Prisma transaction mức Serializable.
- Hóa đơn Transaction được lưu cục bộ dưới `storage/receipts`, chỉ đọc qua API có auth;
hỗ trợ JPEG, PNG, WebP, PDF và giới hạn mặc định 5 MB.
- Budget hỗ trợ phạm vi tổng (`OVERALL`) hoặc danh mục chi (`CATEGORY`), chu kỳ
`CUSTOM`, `WEEKLY`, `MONTHLY`, `YEARLY` và archive để giữ lịch sử. Mức sử dụng,
phần trăm cùng cảnh báo ngưỡng được tổng hợp trực tiếp từ Transaction `EXPENSE`
trong khoảng thời gian `[startDate, endDate)` khi đọc API.
- Budget `currency` riêng (mặc định `VND`), hỗ trợ phạm vi tổng (`OVERALL`) hoặc
danh mục chi (`CATEGORY`), chu kỳ `CUSTOM`, `WEEKLY`, `MONTHLY`, `YEARLY` và archive
để giữ lịch sử. Mức sử dụng, phần trăm cùng cảnh báo ngưỡng được tổng hợp trực tiếp
từ Transaction `EXPENSE` cùng currency trong `[startDate, endDate)` khi đọc API.
- Saving Goal có trạng thái `ACTIVE`, `PAUSED`, `COMPLETED`, dùng archive để giữ lịch sử
và tổng hợp tiến độ từ Saving Contribution. Trạng thái hoàn thành được đồng bộ tự
động trong transaction Serializable khi contribution hoặc số tiền mục tiêu thay đổi;
contribution không tự động thay đổi số dư Wallet.
- Financial Reports là module chỉ đọc, tổng hợp trực tiếp Wallet, Transaction, Budget và
Saving Goal. Báo cáo dùng khoảng thời gian `[from, to)`, hỗ trợ preset ngày/tuần/tháng/năm
hoặc custom tối đa 1830 ngày, bucket theo offset múi giờ và luôn tách số tiền theo currency.
## Trạng thái đã biết
......@@ -37,13 +40,17 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
ghi rõ cả hai và dùng `7777` cho hướng dẫn chạy theo file env mẫu.
- Wallet, Category, Transaction và Budget đã có API theo ownership; Category đồng
thời trả các category hệ thống dùng chung.
- Financial Reports có API tổng quan, chuỗi dòng tiền, cơ cấu chi tiêu theo danh mục và
hiệu quả ngân sách dưới `/api/v1/reports`.
- Các migration `20260728170000_improve_wallet_management`
`20260728190000_add_category_management` đồng bộ thay đổi của Wallet và Category;
`20260728210000_add_transaction_management` đồng bộ Decimal, receipt/location và index
của Transaction; `20260729100000_add_budget_management` đồng bộ Decimal, loại,
chu kỳ, ngưỡng cảnh báo, archive và index của Budget;
`20260731100000_add_saving_goals_management` thêm Saving Goal, Saving Contribution,
lifecycle, constraint và index phục vụ theo dõi tiến độ.
lifecycle, constraint và index phục vụ theo dõi tiến độ;
`20260803120000_add_budget_currency` thêm currency và index thời gian theo currency
cho Budget, backfill dữ liệu hiện có bằng `VND`.
Migration history cũ vẫn chưa phản ánh đầy đủ các thay đổi schema của auth đã
được commit trước đó.
......
......@@ -56,9 +56,12 @@ Express router
## Phạm vi hiện tại
- Route hoạt động: health, auth, users, wallets, categories, transactions và budgets.
- Route hoạt động: health, auth, users, wallets, categories, transactions, budgets,
saving goals và financial reports.
- Wallet, Category, Transaction và Budget có module API theo ownership trong
`src/modules/`.
- Financial Reports tổng hợp dữ liệu hiện có theo khoảng thời gian và currency,
không lưu snapshot báo cáo riêng trong database.
- Email verification, password reset, cảnh báo thiết bị và quản lý session nằm
trong module auth.
......
-- Keep budget utilization currency-safe. Existing budgets use the project's
-- historical default currency and can be updated explicitly after deployment.
ALTER TABLE "budgets"
ADD COLUMN "currency" VARCHAR(3) NOT NULL DEFAULT 'VND';
CREATE INDEX "budgets_user_id_currency_start_date_end_date_idx"
ON "budgets"("user_id", "currency", "start_date", "end_date");
......@@ -152,6 +152,7 @@ model Budget {
categoryId String? @map("category_id") @db.Uuid
name String
amount Decimal @db.Decimal(18, 2)
currency String @default("VND") @db.VarChar(3)
type BudgetType @default(CATEGORY)
period BudgetPeriod @default(CUSTOM)
startDate DateTime @map("start_date")
......@@ -167,6 +168,7 @@ model Budget {
@@index([categoryId])
@@index([userId, isArchived])
@@index([userId, startDate, endDate])
@@index([userId, currency, startDate, endDate])
@@index([userId, type, period])
@@map("budgets")
}
......
This diff is collapsed.
......@@ -15,6 +15,7 @@ export interface BudgetQueryDto {
search?: string;
type?: BudgetType;
period?: BudgetPeriod;
currency?: string;
categoryId?: string;
activeAt?: Date;
includeArchived: boolean;
......@@ -27,6 +28,7 @@ export interface BudgetQueryDto {
export interface CreateBudgetDto {
name: string;
amount: string;
currency: string;
type: BudgetType;
period: BudgetPeriod;
categoryId?: string | null;
......@@ -38,6 +40,7 @@ export interface CreateBudgetDto {
export interface UpdateBudgetDto {
name?: string;
amount?: string;
currency?: string;
type?: BudgetType;
period?: BudgetPeriod;
categoryId?: string | null;
......@@ -49,6 +52,7 @@ export interface UpdateBudgetDto {
export interface PersistBudgetDto {
name: string;
amount: string;
currency: string;
type: BudgetType;
period: BudgetPeriod;
categoryId: string | null;
......@@ -79,6 +83,7 @@ export interface BudgetResponseDto {
id: string;
name: string;
amount: string;
currency: string;
type: BudgetType;
period: BudgetPeriod;
categoryId: string | null;
......
......@@ -12,6 +12,7 @@ const budgetSelect = {
id: true,
name: true,
amount: true,
currency: true,
type: true,
period: true,
categoryId: true,
......@@ -48,6 +49,7 @@ export class BudgetRepository {
search,
type,
period,
currency,
categoryId,
activeAt,
includeArchived,
......@@ -60,6 +62,7 @@ export class BudgetRepository {
userId,
...(type ? { type } : {}),
...(period ? { period } : {}),
...(currency ? { currency } : {}),
...(categoryId ? { categoryId } : {}),
...(activeAt
? {
......@@ -137,6 +140,7 @@ export class BudgetRepository {
categoryId: string | null,
startDate: Date,
endDate: Date,
currency: string,
): Promise<BudgetSpendingSummary> {
const result = await prisma.transaction.aggregate({
where: {
......@@ -147,6 +151,7 @@ export class BudgetRepository {
gte: startDate,
lt: endDate,
},
wallet: { currency },
},
_sum: { amount: true },
_count: { _all: true },
......
......@@ -121,6 +121,7 @@ export class BudgetService {
startDate: data.startDate,
endDate,
alertThreshold: data.alertThreshold,
currency: data.currency,
};
}
......@@ -171,6 +172,7 @@ export class BudgetService {
startDate,
endDate,
alertThreshold: data.alertThreshold ?? current.alertThreshold.toFixed(2),
currency: data.currency ?? current.currency,
};
}
......@@ -293,6 +295,7 @@ export class BudgetService {
budget.categoryId,
budget.startDate,
budget.endDate,
budget.currency,
);
return {
......
......@@ -3,6 +3,12 @@ import { z } from 'zod';
const budgetTypeSchema = z.enum(['OVERALL', 'CATEGORY']);
const budgetPeriodSchema = z.enum(['CUSTOM', 'WEEKLY', 'MONTHLY', 'YEARLY']);
const currencySchema = z
.string()
.trim()
.length(3, 'Currency must contain exactly 3 letters')
.regex(/^[A-Za-z]{3}$/, 'Currency must contain only letters')
.transform((value) => value.toUpperCase());
const amountSchema = z
.string()
.trim()
......@@ -37,6 +43,7 @@ export const findBudgetsSchema = z.object({
search: z.string().trim().min(1).max(200).optional(),
type: budgetTypeSchema.optional(),
period: budgetPeriodSchema.optional(),
currency: currencySchema.optional(),
categoryId: z.string().uuid('Invalid category id').optional(),
activeAt: dateSchema.optional(),
includeArchived: z
......@@ -57,6 +64,7 @@ export const createBudgetSchema = z
.object({
name: z.string().trim().min(1, 'Name is required').max(100),
amount: amountSchema,
currency: currencySchema.optional().default('VND'),
type: budgetTypeSchema,
period: budgetPeriodSchema,
categoryId: z.string().uuid('Invalid category id').nullable().optional(),
......@@ -110,6 +118,7 @@ export const updateBudgetSchema = z
.object({
name: z.string().trim().min(1, 'Name cannot be empty').max(100).optional(),
amount: amountSchema.optional(),
currency: currencySchema.optional(),
type: budgetTypeSchema.optional(),
period: budgetPeriodSchema.optional(),
categoryId: z.string().uuid('Invalid category id').nullable().optional(),
......
import { NextFunction, Request, Response } from 'express';
import { ReportQueryDto } from './report.dto';
import { ReportService } from './report.service';
export class ReportController {
private readonly service = new ReportService();
overview = async (req: Request, res: Response, next: NextFunction) => {
try {
const data = await this.service.getOverview(
req.user.id,
req.query as unknown as ReportQueryDto,
);
res.json({ success: true, data });
} catch (error) {
next(error);
}
};
cashFlow = async (req: Request, res: Response, next: NextFunction) => {
try {
const data = await this.service.getCashFlow(
req.user.id,
req.query as unknown as ReportQueryDto,
);
res.json({ success: true, data });
} catch (error) {
next(error);
}
};
spendingByCategory = async (req: Request, res: Response, next: NextFunction) => {
try {
const data = await this.service.getSpendingByCategory(
req.user.id,
req.query as unknown as ReportQueryDto,
);
res.json({ success: true, data });
} catch (error) {
next(error);
}
};
budgetPerformance = async (req: Request, res: Response, next: NextFunction) => {
try {
const data = await this.service.getBudgetPerformance(
req.user.id,
req.query as unknown as ReportQueryDto,
);
res.json({ success: true, data });
} catch (error) {
next(error);
}
};
}
import { BudgetType, SavingGoalStatus, TransactionType } from '@prisma/client';
export type ReportPeriodPreset = 'DAY' | 'WEEK' | 'MONTH' | 'YEAR' | 'CUSTOM';
export type ReportGranularity = 'AUTO' | 'HOUR' | 'DAY' | 'WEEK' | 'MONTH' | 'YEAR';
export type ResolvedReportGranularity = Exclude<ReportGranularity, 'AUTO'>;
export type BudgetPerformanceStatus = 'ON_TRACK' | 'NEAR_LIMIT' | 'EXCEEDED';
export interface ReportQueryDto {
period: ReportPeriodPreset;
dateFrom?: Date;
dateTo?: Date;
timezoneOffsetMinutes: number;
walletId?: string;
currency?: string;
granularity: ReportGranularity;
}
export interface ReportPeriodDto {
preset: ReportPeriodPreset;
from: Date;
to: Date;
timezoneOffsetMinutes: number;
generatedAt: Date;
}
export interface MoneyFlowDto {
currency: string;
income: string;
expense: string;
netCashFlow: string;
transactionCount: number;
}
export interface FinancialMetricDto extends MoneyFlowDto {
currentBalance: string;
savingsRate: string | null;
expenseToIncomeRatio: string | null;
}
export interface ReportWalletDto {
id: string;
name: string;
currency: string;
balance: string;
isDefault: boolean;
isArchived: boolean;
}
export interface BudgetTypeSummaryDto {
currency: string;
type: BudgetType;
budgetCount: number;
budgetAmount: string;
spentAmount: string;
remainingAmount: string;
usagePercentage: string;
onTrackCount: number;
nearLimitCount: number;
exceededCount: number;
}
export interface SavingGoalCurrencySummaryDto {
currency: string;
targetAmount: string;
savedAmount: string;
remainingAmount: string;
contributedInPeriod: string;
progressPercentage: string;
}
export interface SavingGoalSummaryDto {
totalGoals: number;
activeCount: number;
pausedCount: number;
completedCount: number;
byCurrency: SavingGoalCurrencySummaryDto[];
}
export interface FinancialOverviewDto {
period: ReportPeriodDto;
metricsByCurrency: FinancialMetricDto[];
wallets: {
totalWallets: number;
archivedWallets: number;
items: ReportWalletDto[];
};
budgets: {
totalBudgets: number;
byType: BudgetTypeSummaryDto[];
};
savingGoals: SavingGoalSummaryDto;
}
export interface CashFlowBucketDto {
from: Date;
to: Date;
metricsByCurrency: MoneyFlowDto[];
}
export interface CashFlowReportDto {
period: ReportPeriodDto;
granularity: ResolvedReportGranularity;
totalsByCurrency: MoneyFlowDto[];
series: CashFlowBucketDto[];
}
export interface SpendingCategoryDto {
category: {
id: string;
name: string;
icon: string | null;
color: string | null;
};
amount: string;
percentage: string;
transactionCount: number;
}
export interface SpendingCategoryCurrencyDto {
currency: string;
totalExpense: string;
transactionCount: number;
categories: SpendingCategoryDto[];
}
export interface SpendingCategoryReportDto {
period: ReportPeriodDto;
currencies: SpendingCategoryCurrencyDto[];
}
export interface BudgetPerformanceItemDto {
id: string;
name: string;
type: BudgetType;
currency: string;
period: string;
category: {
id: string;
name: string;
icon: string | null;
color: string | null;
} | null;
budgetAmount: string;
spentAmount: string;
remainingAmount: string;
usagePercentage: string;
transactionCount: number;
status: BudgetPerformanceStatus;
reportFrom: Date;
reportTo: Date;
budgetFrom: Date;
budgetTo: Date;
isArchived: boolean;
}
export interface BudgetPerformanceReportDto {
period: ReportPeriodDto;
summary: {
totalBudgets: number;
byType: BudgetTypeSummaryDto[];
};
budgets: BudgetPerformanceItemDto[];
}
export interface ReportTransactionRecord {
amount: import('@prisma/client').Prisma.Decimal;
type: TransactionType;
date: Date;
wallet: {
id: string;
currency: string;
};
category: {
id: string;
name: string;
icon: string | null;
color: string | null;
};
}
export interface ReportSavingGoalRecord {
id: string;
targetAmount: import('@prisma/client').Prisma.Decimal;
currency: string;
status: SavingGoalStatus;
}
import { Prisma } from '@prisma/client';
import { prisma } from '../../database/prisma.client';
import { ReportSavingGoalRecord, ReportTransactionRecord } from './report.dto';
const reportWalletSelect = {
id: true,
name: true,
balance: true,
currency: true,
isDefault: true,
isArchived: true,
} satisfies Prisma.WalletSelect;
const reportTransactionSelect = {
amount: true,
type: true,
date: true,
wallet: {
select: {
id: true,
currency: true,
},
},
category: {
select: {
id: true,
name: true,
icon: true,
color: true,
},
},
} satisfies Prisma.TransactionSelect;
const reportBudgetSelect = {
id: true,
name: true,
amount: true,
currency: true,
type: true,
period: true,
categoryId: true,
startDate: true,
endDate: true,
alertThreshold: true,
isArchived: true,
category: {
select: {
id: true,
name: true,
icon: true,
color: true,
},
},
} satisfies Prisma.BudgetSelect;
const reportSavingGoalSelect = {
id: true,
targetAmount: true,
currency: true,
status: true,
} satisfies Prisma.SavingGoalSelect;
export type ReportWalletRecord = Prisma.WalletGetPayload<{
select: typeof reportWalletSelect;
}>;
export type ReportBudgetRecord = Prisma.BudgetGetPayload<{
select: typeof reportBudgetSelect;
}>;
export interface ContributionSummary {
savingGoalId: string;
amount: Prisma.Decimal;
}
export class ReportRepository {
findWalletById(userId: string, walletId: string) {
return prisma.wallet.findFirst({
where: { id: walletId, userId },
select: reportWalletSelect,
});
}
findWallets(userId: string, walletId?: string, currency?: string) {
return prisma.wallet.findMany({
where: {
userId,
...(walletId ? { id: walletId } : {}),
...(currency ? { currency } : {}),
},
select: reportWalletSelect,
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }, { id: 'asc' }],
});
}
findTransactions(
userId: string,
from: Date,
to: Date,
walletId?: string,
currency?: string,
): Promise<ReportTransactionRecord[]> {
return prisma.transaction.findMany({
where: {
userId,
date: { gte: from, lt: to },
...(walletId ? { walletId } : {}),
...(currency ? { wallet: { currency } } : {}),
},
select: reportTransactionSelect,
orderBy: [{ date: 'asc' }, { id: 'asc' }],
});
}
findBudgets(userId: string, from: Date, to: Date, currency?: string) {
return prisma.budget.findMany({
where: {
userId,
startDate: { lt: to },
endDate: { gt: from },
...(currency ? { currency } : {}),
},
select: reportBudgetSelect,
orderBy: [{ startDate: 'asc' }, { id: 'asc' }],
});
}
findSavingGoals(userId: string, currency?: string): Promise<ReportSavingGoalRecord[]> {
return prisma.savingGoal.findMany({
where: {
userId,
isArchived: false,
...(currency ? { currency } : {}),
},
select: reportSavingGoalSelect,
orderBy: [{ currency: 'asc' }, { targetDate: 'asc' }, { id: 'asc' }],
});
}
async findContributionSummaries(
savingGoalIds: string[],
from?: Date,
to?: Date,
): Promise<ContributionSummary[]> {
if (savingGoalIds.length === 0) {
return [];
}
const summaries = await prisma.savingContribution.groupBy({
by: ['savingGoalId'],
where: {
savingGoalId: { in: savingGoalIds },
...(from && to ? { contributedAt: { gte: from, lt: to } } : {}),
},
_sum: { amount: true },
});
return summaries.map((summary) => ({
savingGoalId: summary.savingGoalId,
amount: summary._sum.amount ?? new Prisma.Decimal(0),
}));
}
}
import { Router } from 'express';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { ReportController } from './report.controller';
import { reportQuerySchema } from './report.validation';
const router = Router();
const controller = new ReportController();
router.use(authMiddleware);
router.get('/overview', validate(reportQuerySchema, 'query'), controller.overview);
router.get('/cash-flow', validate(reportQuerySchema, 'query'), controller.cashFlow);
router.get(
'/spending-by-category',
validate(reportQuerySchema, 'query'),
controller.spendingByCategory,
);
router.get(
'/budget-performance',
validate(reportQuerySchema, 'query'),
controller.budgetPerformance,
);
export default router;
This diff is collapsed.
import { z } from 'zod';
const dateSchema = z
.string()
.datetime({
offset: true,
message: 'Date must be a valid ISO 8601 date-time',
})
.transform((value) => new Date(value));
export const reportQuerySchema = z
.object({
period: z
.enum(['DAY', 'WEEK', 'MONTH', 'YEAR', 'CUSTOM'])
.optional()
.default('MONTH'),
dateFrom: dateSchema.optional(),
dateTo: dateSchema.optional(),
timezoneOffsetMinutes: z.coerce
.number()
.int()
.min(-720)
.max(840)
.optional()
.default(420),
walletId: z.string().uuid('Invalid wallet id').optional(),
currency: z
.string()
.trim()
.length(3, 'Currency must contain exactly 3 letters')
.regex(/^[A-Za-z]{3}$/, 'Currency must contain only letters')
.transform((value) => value.toUpperCase())
.optional(),
granularity: z
.enum(['AUTO', 'HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR'])
.optional()
.default('AUTO'),
})
.superRefine((data, context) => {
if (data.period === 'CUSTOM') {
if (!data.dateFrom) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['dateFrom'],
message: 'dateFrom is required for a custom period',
});
}
if (!data.dateTo) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['dateTo'],
message: 'dateTo is required for a custom period',
});
}
} else if (data.dateFrom || data.dateTo) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['period'],
message: 'dateFrom and dateTo are only accepted for a CUSTOM period',
});
}
if (data.dateFrom && data.dateTo && data.dateFrom >= data.dateTo) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['dateTo'],
message: 'dateTo must be after dateFrom',
});
}
});
......@@ -6,6 +6,7 @@ import categoryRoute from '../modules/categories/category.route';
import transactionRoute from '../modules/transactions/transaction.route';
import budgetRoute from '../modules/budgets/budget.route';
import savingGoalRoute from '../modules/saving-goals/saving-goal.route';
import reportRoute from '../modules/reports/report.route';
const router = Router();
......@@ -20,5 +21,6 @@ router.use('/categories', categoryRoute);
router.use('/transactions', transactionRoute);
router.use('/budgets', budgetRoute);
router.use('/saving-goals', savingGoalRoute);
router.use('/reports', reportRoute);
export default router;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment