Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
F
finwise-miniapp-be
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ThinhNC
finwise-miniapp-be
Commits
1b9a470d
Commit
1b9a470d
authored
Aug 03, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(reports): add financial reports and analytics
parent
00379526
Changes
16
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
16 changed files
with
1762 additions
and
7 deletions
+1762
-7
memory.md
.agents/memory.md
+12
-5
project.md
.agents/project.md
+4
-1
migration.sql
...grations/20260803120000_add_budget_currency/migration.sql
+7
-0
schema.prisma
prisma/schema.prisma
+2
-0
swagger.config.ts
src/config/swagger.config.ts
+545
-1
budget.dto.ts
src/modules/budgets/budget.dto.ts
+5
-0
budget.repository.ts
src/modules/budgets/budget.repository.ts
+5
-0
budget.service.ts
src/modules/budgets/budget.service.ts
+3
-0
budget.validation.ts
src/modules/budgets/budget.validation.ts
+9
-0
report.controller.ts
src/modules/reports/report.controller.ts
+55
-0
report.dto.ts
src/modules/reports/report.dto.ts
+186
-0
report.repository.ts
src/modules/reports/report.repository.ts
+163
-0
report.route.ts
src/modules/reports/report.route.ts
+25
-0
report.service.ts
src/modules/reports/report.service.ts
+669
-0
report.validation.ts
src/modules/reports/report.validation.ts
+70
-0
index.ts
src/routes/index.ts
+2
-0
No files found.
.agents/memory.md
View file @
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
có
`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
t
rong 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`
và
-
Các migration
`20260728170000_improve_wallet_management`
và
`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 đó.
...
...
.agents/project.md
View file @
1b9a470d
...
@@ -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.
...
...
prisma/migrations/20260803120000_add_budget_currency/migration.sql
0 → 100644
View file @
1b9a470d
-- 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"
);
prisma/schema.prisma
View file @
1b9a470d
...
@@ -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"
)
}
}
...
...
src/config/swagger.config.ts
View file @
1b9a470d
This diff is collapsed.
Click to expand it.
src/modules/budgets/budget.dto.ts
View file @
1b9a470d
...
@@ -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
;
...
...
src/modules/budgets/budget.repository.ts
View file @
1b9a470d
...
@@ -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
},
...
...
src/modules/budgets/budget.service.ts
View file @
1b9a470d
...
@@ -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
{
...
...
src/modules/budgets/budget.validation.ts
View file @
1b9a470d
...
@@ -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
(),
...
...
src/modules/reports/report.controller.ts
0 → 100644
View file @
1b9a470d
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
);
}
};
}
src/modules/reports/report.dto.ts
0 → 100644
View file @
1b9a470d
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
;
}
src/modules/reports/report.repository.ts
0 → 100644
View file @
1b9a470d
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
),
}));
}
}
src/modules/reports/report.route.ts
0 → 100644
View file @
1b9a470d
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
;
src/modules/reports/report.service.ts
0 → 100644
View file @
1b9a470d
This diff is collapsed.
Click to expand it.
src/modules/reports/report.validation.ts
0 → 100644
View file @
1b9a470d
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'
,
});
}
});
src/routes/index.ts
View file @
1b9a470d
...
@@ -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
;
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment