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ê ...@@ -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. 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ó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. 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ỳ - Budget `currency` riêng (mặc định `VND`), hỗ trợ phạm vi tổng (`OVERALL`) hoặc
`CUSTOM`, `WEEKLY`, `MONTHLY`, `YEARLY` và archive để giữ lịch sử. Mức sử dụng, danh mục chi (`CATEGORY`), chu kỳ `CUSTOM`, `WEEKLY`, `MONTHLY`, `YEARLY` và archive
phần trăm cùng cảnh báo ngưỡng được tổng hợp trực tiếp từ Transaction `EXPENSE` để 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
trong khoảng thời gian `[startDate, endDate)` khi đọc API. 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ử - 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ự 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; độ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. 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 ## 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ê ...@@ -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. 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 - Wallet, Category, Transaction và Budget đã có API theo ownership; Category đồng
thời trả các category hệ thống dùng chung. 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` - Các migration `20260728170000_improve_wallet_management`
`20260728190000_add_category_management` đồng bộ thay đổi của Wallet và Category; `20260728190000_add_category_management` đồng bộ thay đổi của Wallet và Category;
`20260728210000_add_transaction_management` đồng bộ Decimal, receipt/location và index `20260728210000_add_transaction_management` đồng bộ Decimal, receipt/location và index
của Transaction; `20260729100000_add_budget_management` đồng bộ Decimal, loại, 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; 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, `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 đã Migration history cũ vẫn chưa phản ánh đầy đủ các thay đổi schema của auth đã
được commit trước đó. được commit trước đó.
......
...@@ -56,9 +56,12 @@ Express router ...@@ -56,9 +56,12 @@ Express router
## Phạm vi hiện tại ## 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 - Wallet, Category, Transaction và Budget có module API theo ownership trong
`src/modules/`. `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 - Email verification, password reset, cảnh báo thiết bị và quản lý session nằm
trong module auth. 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 { ...@@ -152,6 +152,7 @@ model Budget {
categoryId String? @map("category_id") @db.Uuid categoryId String? @map("category_id") @db.Uuid
name String name String
amount Decimal @db.Decimal(18, 2) amount Decimal @db.Decimal(18, 2)
currency String @default("VND") @db.VarChar(3)
type BudgetType @default(CATEGORY) type BudgetType @default(CATEGORY)
period BudgetPeriod @default(CUSTOM) period BudgetPeriod @default(CUSTOM)
startDate DateTime @map("start_date") startDate DateTime @map("start_date")
...@@ -167,6 +168,7 @@ model Budget { ...@@ -167,6 +168,7 @@ model Budget {
@@index([categoryId]) @@index([categoryId])
@@index([userId, isArchived]) @@index([userId, isArchived])
@@index([userId, startDate, endDate]) @@index([userId, startDate, endDate])
@@index([userId, currency, startDate, endDate])
@@index([userId, type, period]) @@index([userId, type, period])
@@map("budgets") @@map("budgets")
} }
......
...@@ -512,6 +512,7 @@ export const swaggerSpec = { ...@@ -512,6 +512,7 @@ export const swaggerSpec = {
'id', 'id',
'name', 'name',
'amount', 'amount',
'currency',
'type', 'type',
'period', 'period',
'startDate', 'startDate',
...@@ -530,6 +531,7 @@ export const swaggerSpec = { ...@@ -530,6 +531,7 @@ export const swaggerSpec = {
pattern: '^(?:0|[1-9]\\d{0,15})(?:\\.\\d{1,2})?$', pattern: '^(?:0|[1-9]\\d{0,15})(?:\\.\\d{1,2})?$',
example: '1000000.00', example: '1000000.00',
}, },
currency: { type: 'string', pattern: '^[A-Z]{3}$', example: 'VND' },
type: { type: 'string', enum: ['OVERALL', 'CATEGORY'] }, type: { type: 'string', enum: ['OVERALL', 'CATEGORY'] },
period: { period: {
type: 'string', type: 'string',
...@@ -577,6 +579,11 @@ export const swaggerSpec = { ...@@ -577,6 +579,11 @@ export const swaggerSpec = {
pattern: '^(?:0|[1-9]\\d{0,15})(?:\\.\\d{1,2})?$', pattern: '^(?:0|[1-9]\\d{0,15})(?:\\.\\d{1,2})?$',
example: '1000000.00', example: '1000000.00',
}, },
currency: {
type: 'string',
pattern: '^[A-Za-z]{3}$',
default: 'VND',
},
type: { type: 'string', enum: ['OVERALL', 'CATEGORY'] }, type: { type: 'string', enum: ['OVERALL', 'CATEGORY'] },
period: { period: {
type: 'string', type: 'string',
...@@ -611,6 +618,7 @@ export const swaggerSpec = { ...@@ -611,6 +618,7 @@ export const swaggerSpec = {
type: 'string', type: 'string',
pattern: '^(?:0|[1-9]\\d{0,15})(?:\\.\\d{1,2})?$', pattern: '^(?:0|[1-9]\\d{0,15})(?:\\.\\d{1,2})?$',
}, },
currency: { type: 'string', pattern: '^[A-Za-z]{3}$' },
type: { type: 'string', enum: ['OVERALL', 'CATEGORY'] }, type: { type: 'string', enum: ['OVERALL', 'CATEGORY'] },
period: { period: {
type: 'string', type: 'string',
...@@ -858,6 +866,329 @@ export const swaggerSpec = { ...@@ -858,6 +866,329 @@ export const swaggerSpec = {
goal: { $ref: '#/components/schemas/SavingGoal' }, goal: { $ref: '#/components/schemas/SavingGoal' },
}, },
}, },
ReportPeriod: {
type: 'object',
required: ['preset', 'from', 'to', 'timezoneOffsetMinutes', 'generatedAt'],
properties: {
preset: {
type: 'string',
enum: ['DAY', 'WEEK', 'MONTH', 'YEAR', 'CUSTOM'],
},
from: {
type: 'string',
format: 'date-time',
description: 'Inclusive report boundary.',
},
to: {
type: 'string',
format: 'date-time',
description: 'Exclusive report boundary.',
},
timezoneOffsetMinutes: {
type: 'integer',
minimum: -720,
maximum: 840,
example: 420,
description: 'Local offset from UTC in minutes.',
},
generatedAt: { type: 'string', format: 'date-time' },
},
},
ReportMoneyFlow: {
type: 'object',
required: ['currency', 'income', 'expense', 'netCashFlow', 'transactionCount'],
properties: {
currency: { type: 'string', pattern: '^[A-Z]{3}$', example: 'VND' },
income: { type: 'string', example: '20000000.00' },
expense: { type: 'string', example: '12000000.00' },
netCashFlow: { type: 'string', example: '8000000.00' },
transactionCount: { type: 'integer', example: 32 },
},
},
ReportFinancialMetric: {
allOf: [
{ $ref: '#/components/schemas/ReportMoneyFlow' },
{
type: 'object',
required: ['currentBalance', 'savingsRate', 'expenseToIncomeRatio'],
properties: {
currentBalance: { type: 'string', example: '42000000.00' },
savingsRate: {
type: 'string',
nullable: true,
example: '40.00',
description: 'netCashFlow / income * 100; null when income is zero.',
},
expenseToIncomeRatio: {
type: 'string',
nullable: true,
example: '60.00',
description: 'expense / income * 100; null when income is zero.',
},
},
},
],
},
ReportWallet: {
type: 'object',
required: ['id', 'name', 'currency', 'balance', 'isDefault', 'isArchived'],
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string', example: 'Cash' },
currency: { type: 'string', pattern: '^[A-Z]{3}$', example: 'VND' },
balance: { type: 'string', example: '5000000.00' },
isDefault: { type: 'boolean' },
isArchived: { type: 'boolean' },
},
},
ReportBudgetTypeSummary: {
type: 'object',
required: [
'currency',
'type',
'budgetCount',
'budgetAmount',
'spentAmount',
'remainingAmount',
'usagePercentage',
'onTrackCount',
'nearLimitCount',
'exceededCount',
],
properties: {
currency: { type: 'string', pattern: '^[A-Z]{3}$', example: 'VND' },
type: { type: 'string', enum: ['OVERALL', 'CATEGORY'] },
budgetCount: { type: 'integer' },
budgetAmount: { type: 'string', example: '15000000.00' },
spentAmount: { type: 'string', example: '9000000.00' },
remainingAmount: { type: 'string', example: '6000000.00' },
usagePercentage: { type: 'string', example: '60.00' },
onTrackCount: { type: 'integer' },
nearLimitCount: { type: 'integer' },
exceededCount: { type: 'integer' },
},
},
ReportSavingGoalSummary: {
type: 'object',
required: [
'totalGoals',
'activeCount',
'pausedCount',
'completedCount',
'byCurrency',
],
properties: {
totalGoals: { type: 'integer' },
activeCount: { type: 'integer' },
pausedCount: { type: 'integer' },
completedCount: { type: 'integer' },
byCurrency: {
type: 'array',
items: {
type: 'object',
required: [
'currency',
'targetAmount',
'savedAmount',
'remainingAmount',
'contributedInPeriod',
'progressPercentage',
],
properties: {
currency: { type: 'string', pattern: '^[A-Z]{3}$' },
targetAmount: { type: 'string' },
savedAmount: { type: 'string' },
remainingAmount: { type: 'string' },
contributedInPeriod: { type: 'string' },
progressPercentage: { type: 'string' },
},
},
},
},
},
FinancialOverviewReport: {
type: 'object',
required: ['period', 'metricsByCurrency', 'wallets', 'budgets', 'savingGoals'],
properties: {
period: { $ref: '#/components/schemas/ReportPeriod' },
metricsByCurrency: {
type: 'array',
items: { $ref: '#/components/schemas/ReportFinancialMetric' },
},
wallets: {
type: 'object',
required: ['totalWallets', 'archivedWallets', 'items'],
properties: {
totalWallets: { type: 'integer' },
archivedWallets: { type: 'integer' },
items: {
type: 'array',
items: { $ref: '#/components/schemas/ReportWallet' },
},
},
},
budgets: {
type: 'object',
required: ['totalBudgets', 'byType'],
properties: {
totalBudgets: { type: 'integer' },
byType: {
type: 'array',
items: { $ref: '#/components/schemas/ReportBudgetTypeSummary' },
},
},
},
savingGoals: { $ref: '#/components/schemas/ReportSavingGoalSummary' },
},
},
CashFlowReport: {
type: 'object',
required: ['period', 'granularity', 'totalsByCurrency', 'series'],
properties: {
period: { $ref: '#/components/schemas/ReportPeriod' },
granularity: {
type: 'string',
enum: ['HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR'],
},
totalsByCurrency: {
type: 'array',
items: { $ref: '#/components/schemas/ReportMoneyFlow' },
},
series: {
type: 'array',
maxItems: 400,
items: {
type: 'object',
required: ['from', 'to', 'metricsByCurrency'],
properties: {
from: { type: 'string', format: 'date-time' },
to: { type: 'string', format: 'date-time' },
metricsByCurrency: {
type: 'array',
items: { $ref: '#/components/schemas/ReportMoneyFlow' },
},
},
},
},
},
},
SpendingByCategoryReport: {
type: 'object',
required: ['period', 'currencies'],
properties: {
period: { $ref: '#/components/schemas/ReportPeriod' },
currencies: {
type: 'array',
items: {
type: 'object',
required: ['currency', 'totalExpense', 'transactionCount', 'categories'],
properties: {
currency: { type: 'string', pattern: '^[A-Z]{3}$' },
totalExpense: { type: 'string' },
transactionCount: { type: 'integer' },
categories: {
type: 'array',
items: {
type: 'object',
required: ['category', 'amount', 'percentage', 'transactionCount'],
properties: {
category: {
type: 'object',
required: ['id', 'name'],
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string' },
icon: { type: 'string', nullable: true },
color: { type: 'string', nullable: true },
},
},
amount: { type: 'string' },
percentage: { type: 'string' },
transactionCount: { type: 'integer' },
},
},
},
},
},
},
},
},
BudgetPerformanceReport: {
type: 'object',
required: ['period', 'summary', 'budgets'],
properties: {
period: { $ref: '#/components/schemas/ReportPeriod' },
summary: {
type: 'object',
required: ['totalBudgets', 'byType'],
properties: {
totalBudgets: { type: 'integer' },
byType: {
type: 'array',
items: { $ref: '#/components/schemas/ReportBudgetTypeSummary' },
},
},
},
budgets: {
type: 'array',
items: {
type: 'object',
required: [
'id',
'name',
'type',
'currency',
'period',
'budgetAmount',
'spentAmount',
'remainingAmount',
'usagePercentage',
'transactionCount',
'status',
'reportFrom',
'reportTo',
'budgetFrom',
'budgetTo',
'isArchived',
],
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string' },
type: { type: 'string', enum: ['OVERALL', 'CATEGORY'] },
currency: { type: 'string', pattern: '^[A-Z]{3}$' },
period: {
type: 'string',
enum: ['CUSTOM', 'WEEKLY', 'MONTHLY', 'YEARLY'],
},
category: {
type: 'object',
nullable: true,
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string' },
icon: { type: 'string', nullable: true },
color: { type: 'string', nullable: true },
},
},
budgetAmount: { type: 'string' },
spentAmount: { type: 'string' },
remainingAmount: { type: 'string' },
usagePercentage: { type: 'string' },
transactionCount: { type: 'integer' },
status: {
type: 'string',
enum: ['ON_TRACK', 'NEAR_LIMIT', 'EXCEEDED'],
},
reportFrom: { type: 'string', format: 'date-time' },
reportTo: { type: 'string', format: 'date-time' },
budgetFrom: { type: 'string', format: 'date-time' },
budgetTo: { type: 'string', format: 'date-time' },
isArchived: { type: 'boolean' },
},
},
},
},
},
}, },
parameters: { parameters: {
PageParam: { in: 'query', name: 'page', schema: { type: 'integer', default: 1 } }, PageParam: { in: 'query', name: 'page', schema: { type: 'integer', default: 1 } },
...@@ -905,6 +1236,45 @@ export const swaggerSpec = { ...@@ -905,6 +1236,45 @@ export const swaggerSpec = {
schema: { type: 'string', format: 'uuid' }, schema: { type: 'string', format: 'uuid' },
description: 'Saving contribution ID', description: 'Saving contribution ID',
}, },
ReportPeriodParam: {
in: 'query',
name: 'period',
schema: {
type: 'string',
enum: ['DAY', 'WEEK', 'MONTH', 'YEAR', 'CUSTOM'],
default: 'MONTH',
},
},
ReportDateFromParam: {
in: 'query',
name: 'dateFrom',
schema: { type: 'string', format: 'date-time' },
description: 'Required only for CUSTOM; inclusive boundary.',
},
ReportDateToParam: {
in: 'query',
name: 'dateTo',
schema: { type: 'string', format: 'date-time' },
description: 'Required only for CUSTOM; exclusive boundary. Custom ranges are limited to 1830 days.',
},
ReportTimezoneParam: {
in: 'query',
name: 'timezoneOffsetMinutes',
schema: { type: 'integer', minimum: -720, maximum: 840, default: 420 },
description: 'Local offset from UTC in minutes, used for preset boundaries and buckets.',
},
ReportWalletParam: {
in: 'query',
name: 'walletId',
schema: { type: 'string', format: 'uuid' },
description: 'Optional owned wallet scope.',
},
ReportCurrencyParam: {
in: 'query',
name: 'currency',
schema: { type: 'string', pattern: '^[A-Za-z]{3}$', example: 'VND' },
description: 'Optional ISO-style currency filter. Monetary totals are never mixed across currencies.',
},
}, },
responses: { responses: {
Unauthorized: { description: 'Chưa đăng nhập', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }, Unauthorized: { description: 'Chưa đăng nhập', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
...@@ -935,6 +1305,10 @@ export const swaggerSpec = { ...@@ -935,6 +1305,10 @@ export const swaggerSpec = {
name: 'Saving Goals', name: 'Saving Goals',
description: 'Authenticated saving goals, progress, and contribution history', description: 'Authenticated saving goals, progress, and contribution history',
}, },
{
name: 'Reports',
description: 'Authenticated financial reports and analytics grouped safely by currency',
},
], ],
paths: { paths: {
'/health': { '/health': {
...@@ -2232,7 +2606,7 @@ export const swaggerSpec = { ...@@ -2232,7 +2606,7 @@ export const swaggerSpec = {
get: { get: {
tags: ['Budgets'], tags: ['Budgets'],
summary: 'List budgets with real-time usage', summary: 'List budgets with real-time usage',
description: 'Every item aggregates matching EXPENSE transactions in its [startDate, endDate) interval.', description: 'Every item aggregates matching EXPENSE transactions with the same currency in its [startDate, endDate) interval.',
security: [{ BearerAuth: [] }], security: [{ BearerAuth: [] }],
parameters: [ parameters: [
{ {
...@@ -2253,6 +2627,11 @@ export const swaggerSpec = { ...@@ -2253,6 +2627,11 @@ export const swaggerSpec = {
enum: ['CUSTOM', 'WEEKLY', 'MONTHLY', 'YEARLY'], enum: ['CUSTOM', 'WEEKLY', 'MONTHLY', 'YEARLY'],
}, },
}, },
{
in: 'query',
name: 'currency',
schema: { type: 'string', pattern: '^[A-Za-z]{3}$' },
},
{ {
in: 'query', in: 'query',
name: 'categoryId', name: 'categoryId',
...@@ -2451,6 +2830,171 @@ export const swaggerSpec = { ...@@ -2451,6 +2830,171 @@ export const swaggerSpec = {
}, },
}, },
}, },
'/reports/overview': {
get: {
tags: ['Reports'],
summary: 'Get a consolidated financial overview',
description: 'Combines income, expenses, net cash flow, current wallet balances, overlapping budget performance, and non-archived saving goal progress. Preset periods use local calendar boundaries derived from timezoneOffsetMinutes.',
security: [{ BearerAuth: [] }],
parameters: [
{ $ref: '#/components/parameters/ReportPeriodParam' },
{ $ref: '#/components/parameters/ReportDateFromParam' },
{ $ref: '#/components/parameters/ReportDateToParam' },
{ $ref: '#/components/parameters/ReportTimezoneParam' },
{ $ref: '#/components/parameters/ReportWalletParam' },
{ $ref: '#/components/parameters/ReportCurrencyParam' },
],
responses: {
200: {
description: 'Consolidated financial overview',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{
type: 'object',
properties: {
data: { $ref: '#/components/schemas/FinancialOverviewReport' },
},
},
],
},
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { $ref: '#/components/responses/NotFound' },
422: { $ref: '#/components/responses/Validation' },
},
},
},
'/reports/cash-flow': {
get: {
tags: ['Reports'],
summary: 'Get income, expense, and net cash-flow time series',
description: 'AUTO uses hourly buckets for a day, daily buckets for a week or month, monthly buckets for a year, and a range-appropriate resolution for CUSTOM. Explicit resolutions are limited to 400 points.',
security: [{ BearerAuth: [] }],
parameters: [
{ $ref: '#/components/parameters/ReportPeriodParam' },
{ $ref: '#/components/parameters/ReportDateFromParam' },
{ $ref: '#/components/parameters/ReportDateToParam' },
{ $ref: '#/components/parameters/ReportTimezoneParam' },
{ $ref: '#/components/parameters/ReportWalletParam' },
{ $ref: '#/components/parameters/ReportCurrencyParam' },
{
in: 'query',
name: 'granularity',
schema: {
type: 'string',
enum: ['AUTO', 'HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR'],
default: 'AUTO',
},
},
],
responses: {
200: {
description: 'Cash-flow totals and zero-filled time series',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{
type: 'object',
properties: {
data: { $ref: '#/components/schemas/CashFlowReport' },
},
},
],
},
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { $ref: '#/components/responses/NotFound' },
422: { $ref: '#/components/responses/Validation' },
},
},
},
'/reports/spending-by-category': {
get: {
tags: ['Reports'],
summary: 'Get expense distribution by category',
description: 'Returns direct transaction categories ordered by expense amount, with percentages calculated independently for each currency.',
security: [{ BearerAuth: [] }],
parameters: [
{ $ref: '#/components/parameters/ReportPeriodParam' },
{ $ref: '#/components/parameters/ReportDateFromParam' },
{ $ref: '#/components/parameters/ReportDateToParam' },
{ $ref: '#/components/parameters/ReportTimezoneParam' },
{ $ref: '#/components/parameters/ReportWalletParam' },
{ $ref: '#/components/parameters/ReportCurrencyParam' },
],
responses: {
200: {
description: 'Spending distribution grouped by currency and category',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{
type: 'object',
properties: {
data: { $ref: '#/components/schemas/SpendingByCategoryReport' },
},
},
],
},
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { $ref: '#/components/responses/NotFound' },
422: { $ref: '#/components/responses/Validation' },
},
},
},
'/reports/budget-performance': {
get: {
tags: ['Reports'],
summary: 'Get budget utilization and status',
description: 'Includes budgets overlapping the report window. Spending is calculated only for the overlap [reportFrom, reportTo) and can be narrowed by wallet or currency; budget amounts remain their configured full-period values.',
security: [{ BearerAuth: [] }],
parameters: [
{ $ref: '#/components/parameters/ReportPeriodParam' },
{ $ref: '#/components/parameters/ReportDateFromParam' },
{ $ref: '#/components/parameters/ReportDateToParam' },
{ $ref: '#/components/parameters/ReportTimezoneParam' },
{ $ref: '#/components/parameters/ReportWalletParam' },
{ $ref: '#/components/parameters/ReportCurrencyParam' },
],
responses: {
200: {
description: 'Budget summaries and per-budget performance',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{
type: 'object',
properties: {
data: { $ref: '#/components/schemas/BudgetPerformanceReport' },
},
},
],
},
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { $ref: '#/components/responses/NotFound' },
422: { $ref: '#/components/responses/Validation' },
},
},
},
'/saving-goals': { '/saving-goals': {
get: { get: {
tags: ['Saving Goals'], tags: ['Saving Goals'],
......
...@@ -15,6 +15,7 @@ export interface BudgetQueryDto { ...@@ -15,6 +15,7 @@ export interface BudgetQueryDto {
search?: string; search?: string;
type?: BudgetType; type?: BudgetType;
period?: BudgetPeriod; period?: BudgetPeriod;
currency?: string;
categoryId?: string; categoryId?: string;
activeAt?: Date; activeAt?: Date;
includeArchived: boolean; includeArchived: boolean;
...@@ -27,6 +28,7 @@ export interface BudgetQueryDto { ...@@ -27,6 +28,7 @@ export interface BudgetQueryDto {
export interface CreateBudgetDto { export interface CreateBudgetDto {
name: string; name: string;
amount: string; amount: string;
currency: string;
type: BudgetType; type: BudgetType;
period: BudgetPeriod; period: BudgetPeriod;
categoryId?: string | null; categoryId?: string | null;
...@@ -38,6 +40,7 @@ export interface CreateBudgetDto { ...@@ -38,6 +40,7 @@ export interface CreateBudgetDto {
export interface UpdateBudgetDto { export interface UpdateBudgetDto {
name?: string; name?: string;
amount?: string; amount?: string;
currency?: string;
type?: BudgetType; type?: BudgetType;
period?: BudgetPeriod; period?: BudgetPeriod;
categoryId?: string | null; categoryId?: string | null;
...@@ -49,6 +52,7 @@ export interface UpdateBudgetDto { ...@@ -49,6 +52,7 @@ export interface UpdateBudgetDto {
export interface PersistBudgetDto { export interface PersistBudgetDto {
name: string; name: string;
amount: string; amount: string;
currency: string;
type: BudgetType; type: BudgetType;
period: BudgetPeriod; period: BudgetPeriod;
categoryId: string | null; categoryId: string | null;
...@@ -79,6 +83,7 @@ export interface BudgetResponseDto { ...@@ -79,6 +83,7 @@ export interface BudgetResponseDto {
id: string; id: string;
name: string; name: string;
amount: string; amount: string;
currency: string;
type: BudgetType; type: BudgetType;
period: BudgetPeriod; period: BudgetPeriod;
categoryId: string | null; categoryId: string | null;
......
...@@ -12,6 +12,7 @@ const budgetSelect = { ...@@ -12,6 +12,7 @@ const budgetSelect = {
id: true, id: true,
name: true, name: true,
amount: true, amount: true,
currency: true,
type: true, type: true,
period: true, period: true,
categoryId: true, categoryId: true,
...@@ -48,6 +49,7 @@ export class BudgetRepository { ...@@ -48,6 +49,7 @@ export class BudgetRepository {
search, search,
type, type,
period, period,
currency,
categoryId, categoryId,
activeAt, activeAt,
includeArchived, includeArchived,
...@@ -60,6 +62,7 @@ export class BudgetRepository { ...@@ -60,6 +62,7 @@ export class BudgetRepository {
userId, userId,
...(type ? { type } : {}), ...(type ? { type } : {}),
...(period ? { period } : {}), ...(period ? { period } : {}),
...(currency ? { currency } : {}),
...(categoryId ? { categoryId } : {}), ...(categoryId ? { categoryId } : {}),
...(activeAt ...(activeAt
? { ? {
...@@ -137,6 +140,7 @@ export class BudgetRepository { ...@@ -137,6 +140,7 @@ export class BudgetRepository {
categoryId: string | null, categoryId: string | null,
startDate: Date, startDate: Date,
endDate: Date, endDate: Date,
currency: string,
): Promise<BudgetSpendingSummary> { ): Promise<BudgetSpendingSummary> {
const result = await prisma.transaction.aggregate({ const result = await prisma.transaction.aggregate({
where: { where: {
...@@ -147,6 +151,7 @@ export class BudgetRepository { ...@@ -147,6 +151,7 @@ export class BudgetRepository {
gte: startDate, gte: startDate,
lt: endDate, lt: endDate,
}, },
wallet: { currency },
}, },
_sum: { amount: true }, _sum: { amount: true },
_count: { _all: true }, _count: { _all: true },
......
...@@ -121,6 +121,7 @@ export class BudgetService { ...@@ -121,6 +121,7 @@ export class BudgetService {
startDate: data.startDate, startDate: data.startDate,
endDate, endDate,
alertThreshold: data.alertThreshold, alertThreshold: data.alertThreshold,
currency: data.currency,
}; };
} }
...@@ -171,6 +172,7 @@ export class BudgetService { ...@@ -171,6 +172,7 @@ export class BudgetService {
startDate, startDate,
endDate, endDate,
alertThreshold: data.alertThreshold ?? current.alertThreshold.toFixed(2), alertThreshold: data.alertThreshold ?? current.alertThreshold.toFixed(2),
currency: data.currency ?? current.currency,
}; };
} }
...@@ -293,6 +295,7 @@ export class BudgetService { ...@@ -293,6 +295,7 @@ export class BudgetService {
budget.categoryId, budget.categoryId,
budget.startDate, budget.startDate,
budget.endDate, budget.endDate,
budget.currency,
); );
return { return {
......
...@@ -3,6 +3,12 @@ import { z } from 'zod'; ...@@ -3,6 +3,12 @@ import { z } from 'zod';
const budgetTypeSchema = z.enum(['OVERALL', 'CATEGORY']); const budgetTypeSchema = z.enum(['OVERALL', 'CATEGORY']);
const budgetPeriodSchema = z.enum(['CUSTOM', 'WEEKLY', 'MONTHLY', 'YEARLY']); 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 const amountSchema = z
.string() .string()
.trim() .trim()
...@@ -37,6 +43,7 @@ export const findBudgetsSchema = z.object({ ...@@ -37,6 +43,7 @@ export const findBudgetsSchema = z.object({
search: z.string().trim().min(1).max(200).optional(), search: z.string().trim().min(1).max(200).optional(),
type: budgetTypeSchema.optional(), type: budgetTypeSchema.optional(),
period: budgetPeriodSchema.optional(), period: budgetPeriodSchema.optional(),
currency: currencySchema.optional(),
categoryId: z.string().uuid('Invalid category id').optional(), categoryId: z.string().uuid('Invalid category id').optional(),
activeAt: dateSchema.optional(), activeAt: dateSchema.optional(),
includeArchived: z includeArchived: z
...@@ -57,6 +64,7 @@ export const createBudgetSchema = z ...@@ -57,6 +64,7 @@ export const createBudgetSchema = z
.object({ .object({
name: z.string().trim().min(1, 'Name is required').max(100), name: z.string().trim().min(1, 'Name is required').max(100),
amount: amountSchema, amount: amountSchema,
currency: currencySchema.optional().default('VND'),
type: budgetTypeSchema, type: budgetTypeSchema,
period: budgetPeriodSchema, period: budgetPeriodSchema,
categoryId: z.string().uuid('Invalid category id').nullable().optional(), categoryId: z.string().uuid('Invalid category id').nullable().optional(),
...@@ -110,6 +118,7 @@ export const updateBudgetSchema = z ...@@ -110,6 +118,7 @@ export const updateBudgetSchema = z
.object({ .object({
name: z.string().trim().min(1, 'Name cannot be empty').max(100).optional(), name: z.string().trim().min(1, 'Name cannot be empty').max(100).optional(),
amount: amountSchema.optional(), amount: amountSchema.optional(),
currency: currencySchema.optional(),
type: budgetTypeSchema.optional(), type: budgetTypeSchema.optional(),
period: budgetPeriodSchema.optional(), period: budgetPeriodSchema.optional(),
categoryId: z.string().uuid('Invalid category id').nullable().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;
import {
BudgetType,
Prisma,
SavingGoalStatus,
TransactionType,
} from '@prisma/client';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import {
BudgetPerformanceItemDto,
BudgetPerformanceReportDto,
BudgetPerformanceStatus,
BudgetTypeSummaryDto,
CashFlowReportDto,
FinancialOverviewDto,
MoneyFlowDto,
ReportGranularity,
ReportPeriodDto,
ReportQueryDto,
ReportSavingGoalRecord,
ReportTransactionRecord,
ResolvedReportGranularity,
SavingGoalSummaryDto,
SpendingCategoryReportDto,
} from './report.dto';
import {
ContributionSummary,
ReportBudgetRecord,
ReportRepository,
ReportWalletRecord,
} from './report.repository';
const MILLISECONDS_PER_MINUTE = 60 * 1000;
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
const MAX_CUSTOM_RANGE_DAYS = 5 * 366;
const MAX_SERIES_POINTS = 400;
const ZERO = new Prisma.Decimal(0);
interface MutableMoneyFlow {
income: Prisma.Decimal;
expense: Prisma.Decimal;
transactionCount: number;
}
interface ResolvedScope {
wallets: ReportWalletRecord[];
currency?: string;
}
export class ReportService {
private readonly repository = new ReportRepository();
async getOverview(userId: string, query: ReportQueryDto): Promise<FinancialOverviewDto> {
const period = this.resolvePeriod(query);
const scope = await this.resolveScope(userId, query);
const [transactions, budgets, goals] = await Promise.all([
this.repository.findTransactions(
userId,
period.from,
period.to,
query.walletId,
scope.currency,
),
this.repository.findBudgets(userId, period.from, period.to, scope.currency),
this.repository.findSavingGoals(userId, scope.currency),
]);
const [lifetimeContributions, periodContributions] = await Promise.all([
this.repository.findContributionSummaries(goals.map((goal) => goal.id)),
this.repository.findContributionSummaries(
goals.map((goal) => goal.id),
period.from,
period.to,
),
]);
const knownCurrencies = this.getKnownCurrencies(scope.wallets, transactions, scope.currency);
const flows = this.toMoneyFlows(transactions, knownCurrencies);
const currentBalances = this.sumWalletBalances(scope.wallets);
return {
period,
metricsByCurrency: flows.map((flow) => {
const income = new Prisma.Decimal(flow.income);
const expense = new Prisma.Decimal(flow.expense);
const netCashFlow = income.minus(expense);
return {
...flow,
currentBalance: (currentBalances.get(flow.currency) ?? ZERO).toFixed(2),
savingsRate: income.isZero()
? null
: netCashFlow.dividedBy(income).times(100).toFixed(2),
expenseToIncomeRatio: income.isZero()
? null
: expense.dividedBy(income).times(100).toFixed(2),
};
}),
wallets: {
totalWallets: scope.wallets.length,
archivedWallets: scope.wallets.filter((wallet) => wallet.isArchived).length,
items: scope.wallets.map((wallet) => ({
...wallet,
balance: wallet.balance.toFixed(2),
})),
},
budgets: this.buildBudgetSummary(budgets, transactions, period),
savingGoals: this.buildSavingGoalSummary(
goals,
lifetimeContributions,
periodContributions,
),
};
}
async getCashFlow(userId: string, query: ReportQueryDto): Promise<CashFlowReportDto> {
const period = this.resolvePeriod(query);
const scope = await this.resolveScope(userId, query);
const transactions = await this.repository.findTransactions(
userId,
period.from,
period.to,
query.walletId,
scope.currency,
);
const knownCurrencies = this.getKnownCurrencies(scope.wallets, transactions, scope.currency);
const granularity = this.resolveGranularity(query.granularity, period);
const buckets = this.createBuckets(period, granularity);
const transactionsByBucket = new Map<number, ReportTransactionRecord[]>();
transactions.forEach((transaction) => {
const key = this.getBucketStart(
transaction.date,
granularity,
period.timezoneOffsetMinutes,
).getTime();
const bucket = transactionsByBucket.get(key) ?? [];
bucket.push(transaction);
transactionsByBucket.set(key, bucket);
});
return {
period,
granularity,
totalsByCurrency: this.toMoneyFlows(transactions, knownCurrencies),
series: buckets.map((bucket) => ({
from: bucket.reportFrom,
to: bucket.reportTo,
metricsByCurrency: this.toMoneyFlows(
transactionsByBucket.get(bucket.key) ?? [],
knownCurrencies,
),
})),
};
}
async getSpendingByCategory(
userId: string,
query: ReportQueryDto,
): Promise<SpendingCategoryReportDto> {
const period = this.resolvePeriod(query);
const scope = await this.resolveScope(userId, query);
const transactions = await this.repository.findTransactions(
userId,
period.from,
period.to,
query.walletId,
scope.currency,
);
const expenses = transactions.filter(
(transaction) => transaction.type === TransactionType.EXPENSE,
);
const currencies = this.getKnownCurrencies(scope.wallets, expenses, scope.currency);
return {
period,
currencies: currencies.map((currency) => {
const currencyExpenses = expenses.filter(
(transaction) => transaction.wallet.currency === currency,
);
const totalExpense = currencyExpenses.reduce(
(total, transaction) => total.plus(transaction.amount),
ZERO,
);
const categoryMap = new Map<
string,
{ transaction: ReportTransactionRecord; amount: Prisma.Decimal; count: number }
>();
currencyExpenses.forEach((transaction) => {
const current = categoryMap.get(transaction.category.id);
categoryMap.set(transaction.category.id, {
transaction,
amount: (current?.amount ?? ZERO).plus(transaction.amount),
count: (current?.count ?? 0) + 1,
});
});
const categories = Array.from(categoryMap.values())
.sort((left, right) => right.amount.comparedTo(left.amount))
.map((entry) => ({
category: entry.transaction.category,
amount: entry.amount.toFixed(2),
percentage: totalExpense.isZero()
? '0.00'
: entry.amount.dividedBy(totalExpense).times(100).toFixed(2),
transactionCount: entry.count,
}));
return {
currency,
totalExpense: totalExpense.toFixed(2),
transactionCount: currencyExpenses.length,
categories,
};
}),
};
}
async getBudgetPerformance(
userId: string,
query: ReportQueryDto,
): Promise<BudgetPerformanceReportDto> {
const period = this.resolvePeriod(query);
const scope = await this.resolveScope(userId, query);
const [transactions, budgets] = await Promise.all([
this.repository.findTransactions(
userId,
period.from,
period.to,
query.walletId,
scope.currency,
),
this.repository.findBudgets(userId, period.from, period.to, scope.currency),
]);
return {
period,
summary: this.buildBudgetSummary(budgets, transactions, period),
budgets: this.buildBudgetItems(budgets, transactions, period),
};
}
private async resolveScope(userId: string, query: ReportQueryDto): Promise<ResolvedScope> {
let selectedCurrency = query.currency;
if (query.walletId) {
const wallet = await this.repository.findWalletById(userId, query.walletId);
if (!wallet) {
throw new AppError('Wallet not found', 404, ERROR_CODE.NOT_FOUND);
}
if (selectedCurrency && wallet.currency !== selectedCurrency) {
throw new AppError(
'The selected wallet does not use the requested currency',
422,
ERROR_CODE.VALIDATION_ERROR,
);
}
selectedCurrency = wallet.currency;
}
const wallets = await this.repository.findWallets(
userId,
query.walletId,
selectedCurrency,
);
return { wallets, currency: selectedCurrency };
}
private resolvePeriod(query: ReportQueryDto): ReportPeriodDto {
const generatedAt = new Date();
let from: Date;
let to: Date;
if (query.period === 'CUSTOM') {
if (!query.dateFrom || !query.dateTo) {
throw new AppError(
'dateFrom and dateTo are required for a custom period',
422,
ERROR_CODE.VALIDATION_ERROR,
);
}
from = query.dateFrom;
to = query.dateTo;
} else {
const offset = query.timezoneOffsetMinutes * MILLISECONDS_PER_MINUTE;
const localNow = new Date(generatedAt.getTime() + offset);
const year = localNow.getUTCFullYear();
const month = localNow.getUTCMonth();
const day = localNow.getUTCDate();
let localFrom: Date;
let localTo: Date;
switch (query.period) {
case 'DAY':
localFrom = new Date(Date.UTC(year, month, day));
localTo = new Date(localFrom.getTime() + MILLISECONDS_PER_DAY);
break;
case 'WEEK': {
const dayOfWeek = localNow.getUTCDay();
const daysSinceMonday = (dayOfWeek + 6) % 7;
localFrom = new Date(Date.UTC(year, month, day - daysSinceMonday));
localTo = new Date(localFrom.getTime() + 7 * MILLISECONDS_PER_DAY);
break;
}
case 'YEAR':
localFrom = new Date(Date.UTC(year, 0, 1));
localTo = new Date(Date.UTC(year + 1, 0, 1));
break;
default:
localFrom = new Date(Date.UTC(year, month, 1));
localTo = new Date(Date.UTC(year, month + 1, 1));
}
from = new Date(localFrom.getTime() - offset);
to = new Date(localTo.getTime() - offset);
}
if (to.getTime() - from.getTime() > MAX_CUSTOM_RANGE_DAYS * MILLISECONDS_PER_DAY) {
throw new AppError(
`Report range cannot exceed ${MAX_CUSTOM_RANGE_DAYS} days`,
422,
ERROR_CODE.VALIDATION_ERROR,
);
}
return {
preset: query.period,
from,
to,
timezoneOffsetMinutes: query.timezoneOffsetMinutes,
generatedAt,
};
}
private getKnownCurrencies(
wallets: ReportWalletRecord[],
transactions: ReportTransactionRecord[],
requestedCurrency?: string,
) {
const currencies = new Set<string>();
if (requestedCurrency) {
currencies.add(requestedCurrency);
}
wallets.forEach((wallet) => currencies.add(wallet.currency));
transactions.forEach((transaction) => currencies.add(transaction.wallet.currency));
return Array.from(currencies).sort();
}
private toMoneyFlows(
transactions: ReportTransactionRecord[],
knownCurrencies: string[],
): MoneyFlowDto[] {
const flows = new Map<string, MutableMoneyFlow>();
knownCurrencies.forEach((currency) => {
flows.set(currency, { income: ZERO, expense: ZERO, transactionCount: 0 });
});
transactions.forEach((transaction) => {
const currency = transaction.wallet.currency;
const flow = flows.get(currency) ?? {
income: ZERO,
expense: ZERO,
transactionCount: 0,
};
if (transaction.type === TransactionType.INCOME) {
flow.income = flow.income.plus(transaction.amount);
} else {
flow.expense = flow.expense.plus(transaction.amount);
}
flow.transactionCount += 1;
flows.set(currency, flow);
});
return Array.from(flows.entries())
.sort(([left], [right]) => left.localeCompare(right))
.map(([currency, flow]) => ({
currency,
income: flow.income.toFixed(2),
expense: flow.expense.toFixed(2),
netCashFlow: flow.income.minus(flow.expense).toFixed(2),
transactionCount: flow.transactionCount,
}));
}
private sumWalletBalances(wallets: ReportWalletRecord[]) {
const balances = new Map<string, Prisma.Decimal>();
wallets.forEach((wallet) => {
balances.set(
wallet.currency,
(balances.get(wallet.currency) ?? ZERO).plus(wallet.balance),
);
});
return balances;
}
private buildSavingGoalSummary(
goals: ReportSavingGoalRecord[],
lifetimeContributions: ContributionSummary[],
periodContributions: ContributionSummary[],
): SavingGoalSummaryDto {
const lifetimeByGoal = new Map(
lifetimeContributions.map((summary) => [summary.savingGoalId, summary.amount]),
);
const periodByGoal = new Map(
periodContributions.map((summary) => [summary.savingGoalId, summary.amount]),
);
const currencies = new Map<
string,
{
target: Prisma.Decimal;
saved: Prisma.Decimal;
period: Prisma.Decimal;
}
>();
goals.forEach((goal) => {
const current = currencies.get(goal.currency) ?? {
target: ZERO,
saved: ZERO,
period: ZERO,
};
current.target = current.target.plus(goal.targetAmount);
current.saved = current.saved.plus(lifetimeByGoal.get(goal.id) ?? ZERO);
current.period = current.period.plus(periodByGoal.get(goal.id) ?? ZERO);
currencies.set(goal.currency, current);
});
return {
totalGoals: goals.length,
activeCount: goals.filter((goal) => goal.status === SavingGoalStatus.ACTIVE).length,
pausedCount: goals.filter((goal) => goal.status === SavingGoalStatus.PAUSED).length,
completedCount: goals.filter((goal) => goal.status === SavingGoalStatus.COMPLETED).length,
byCurrency: Array.from(currencies.entries())
.sort(([left], [right]) => left.localeCompare(right))
.map(([currency, totals]) => ({
currency,
targetAmount: totals.target.toFixed(2),
savedAmount: totals.saved.toFixed(2),
remainingAmount: Prisma.Decimal.max(totals.target.minus(totals.saved), ZERO).toFixed(2),
contributedInPeriod: totals.period.toFixed(2),
progressPercentage: totals.target.isZero()
? '0.00'
: totals.saved.dividedBy(totals.target).times(100).toFixed(2),
})),
};
}
private buildBudgetSummary(
budgets: ReportBudgetRecord[],
transactions: ReportTransactionRecord[],
period: ReportPeriodDto,
) {
const items = this.buildBudgetItems(budgets, transactions, period);
const currencies = Array.from(
new Set(budgets.map((budget) => budget.currency)),
).sort();
const byType = currencies.flatMap((currency) => (
[BudgetType.OVERALL, BudgetType.CATEGORY].map((type) => {
const typeItems = items.filter(
(item) => item.type === type && item.currency === currency,
);
const budgetAmount = typeItems.reduce(
(total, item) => total.plus(item.budgetAmount),
ZERO,
);
const spentAmount = typeItems.reduce(
(total, item) => total.plus(item.spentAmount),
ZERO,
);
return {
currency,
type,
budgetCount: typeItems.length,
budgetAmount: budgetAmount.toFixed(2),
spentAmount: spentAmount.toFixed(2),
remainingAmount: budgetAmount.minus(spentAmount).toFixed(2),
usagePercentage: budgetAmount.isZero()
? '0.00'
: spentAmount.dividedBy(budgetAmount).times(100).toFixed(2),
onTrackCount: typeItems.filter((item) => item.status === 'ON_TRACK').length,
nearLimitCount: typeItems.filter((item) => item.status === 'NEAR_LIMIT').length,
exceededCount: typeItems.filter((item) => item.status === 'EXCEEDED').length,
} satisfies BudgetTypeSummaryDto;
})
));
return { totalBudgets: budgets.length, byType };
}
private buildBudgetItems(
budgets: ReportBudgetRecord[],
transactions: ReportTransactionRecord[],
period: ReportPeriodDto,
): BudgetPerformanceItemDto[] {
return budgets.map((budget) => {
const reportFrom = new Date(Math.max(period.from.getTime(), budget.startDate.getTime()));
const reportTo = new Date(Math.min(period.to.getTime(), budget.endDate.getTime()));
const matchingTransactions = transactions.filter((transaction) => (
transaction.type === TransactionType.EXPENSE
&& transaction.date >= reportFrom
&& transaction.date < reportTo
&& transaction.wallet.currency === budget.currency
&& (!budget.categoryId || transaction.category.id === budget.categoryId)
));
const spentAmount = matchingTransactions.reduce(
(total, transaction) => total.plus(transaction.amount),
ZERO,
);
const usagePercentage = spentAmount.dividedBy(budget.amount).times(100);
let status: BudgetPerformanceStatus = 'ON_TRACK';
if (spentAmount.greaterThan(budget.amount)) {
status = 'EXCEEDED';
} else if (usagePercentage.greaterThanOrEqualTo(budget.alertThreshold)) {
status = 'NEAR_LIMIT';
}
return {
id: budget.id,
name: budget.name,
type: budget.type,
currency: budget.currency,
period: budget.period,
category: budget.category,
budgetAmount: budget.amount.toFixed(2),
spentAmount: spentAmount.toFixed(2),
remainingAmount: budget.amount.minus(spentAmount).toFixed(2),
usagePercentage: usagePercentage.toFixed(2),
transactionCount: matchingTransactions.length,
status,
reportFrom,
reportTo,
budgetFrom: budget.startDate,
budgetTo: budget.endDate,
isArchived: budget.isArchived,
};
});
}
private resolveGranularity(
requested: ReportGranularity,
period: ReportPeriodDto,
): ResolvedReportGranularity {
if (requested !== 'AUTO') {
return requested;
}
if (period.preset === 'DAY') {
return 'HOUR';
}
if (period.preset === 'WEEK' || period.preset === 'MONTH') {
return 'DAY';
}
if (period.preset === 'YEAR') {
return 'MONTH';
}
const rangeDays = (period.to.getTime() - period.from.getTime()) / MILLISECONDS_PER_DAY;
if (rangeDays <= 2) {
return 'HOUR';
}
if (rangeDays <= 90) {
return 'DAY';
}
if (rangeDays <= 730) {
return 'MONTH';
}
return 'YEAR';
}
private createBuckets(period: ReportPeriodDto, granularity: ResolvedReportGranularity) {
const buckets: Array<{
key: number;
reportFrom: Date;
reportTo: Date;
}> = [];
let bucketStart = this.getBucketStart(
period.from,
granularity,
period.timezoneOffsetMinutes,
);
while (bucketStart < period.to) {
const next = this.getNextBucketStart(
bucketStart,
granularity,
period.timezoneOffsetMinutes,
);
buckets.push({
key: bucketStart.getTime(),
reportFrom: new Date(Math.max(bucketStart.getTime(), period.from.getTime())),
reportTo: new Date(Math.min(next.getTime(), period.to.getTime())),
});
if (buckets.length > MAX_SERIES_POINTS) {
throw new AppError(
`The selected granularity produces more than ${MAX_SERIES_POINTS} data points`,
422,
ERROR_CODE.VALIDATION_ERROR,
);
}
bucketStart = next;
}
return buckets;
}
private getBucketStart(
date: Date,
granularity: ResolvedReportGranularity,
timezoneOffsetMinutes: number,
) {
const offset = timezoneOffsetMinutes * MILLISECONDS_PER_MINUTE;
const local = new Date(date.getTime() + offset);
const year = local.getUTCFullYear();
const month = local.getUTCMonth();
const day = local.getUTCDate();
let localStart: Date;
switch (granularity) {
case 'HOUR':
localStart = new Date(Date.UTC(year, month, day, local.getUTCHours()));
break;
case 'WEEK': {
const daysSinceMonday = (local.getUTCDay() + 6) % 7;
localStart = new Date(Date.UTC(year, month, day - daysSinceMonday));
break;
}
case 'MONTH':
localStart = new Date(Date.UTC(year, month, 1));
break;
case 'YEAR':
localStart = new Date(Date.UTC(year, 0, 1));
break;
default:
localStart = new Date(Date.UTC(year, month, day));
}
return new Date(localStart.getTime() - offset);
}
private getNextBucketStart(
bucketStart: Date,
granularity: ResolvedReportGranularity,
timezoneOffsetMinutes: number,
) {
const offset = timezoneOffsetMinutes * MILLISECONDS_PER_MINUTE;
const local = new Date(bucketStart.getTime() + offset);
switch (granularity) {
case 'HOUR':
local.setUTCHours(local.getUTCHours() + 1);
break;
case 'DAY':
local.setUTCDate(local.getUTCDate() + 1);
break;
case 'WEEK':
local.setUTCDate(local.getUTCDate() + 7);
break;
case 'MONTH':
local.setUTCMonth(local.getUTCMonth() + 1);
break;
case 'YEAR':
local.setUTCFullYear(local.getUTCFullYear() + 1);
break;
}
return new Date(local.getTime() - offset);
}
}
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'; ...@@ -6,6 +6,7 @@ import categoryRoute from '../modules/categories/category.route';
import transactionRoute from '../modules/transactions/transaction.route'; import transactionRoute from '../modules/transactions/transaction.route';
import budgetRoute from '../modules/budgets/budget.route'; import budgetRoute from '../modules/budgets/budget.route';
import savingGoalRoute from '../modules/saving-goals/saving-goal.route'; import savingGoalRoute from '../modules/saving-goals/saving-goal.route';
import reportRoute from '../modules/reports/report.route';
const router = Router(); const router = Router();
...@@ -20,5 +21,6 @@ router.use('/categories', categoryRoute); ...@@ -20,5 +21,6 @@ router.use('/categories', categoryRoute);
router.use('/transactions', transactionRoute); router.use('/transactions', transactionRoute);
router.use('/budgets', budgetRoute); router.use('/budgets', budgetRoute);
router.use('/saving-goals', savingGoalRoute); router.use('/saving-goals', savingGoalRoute);
router.use('/reports', reportRoute);
export default router; 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