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
00379526
Commit
00379526
authored
Jul 31, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(saving-goals): add saving goals and contribution management
parent
180233b3
Changes
12
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
1969 additions
and
2 deletions
+1969
-2
memory.md
.agents/memory.md
+7
-1
migration.sql
.../20260731100000_add_saving_goals_management/migration.sql
+66
-0
schema.prisma
prisma/schema.prisma
+47
-0
error-code.ts
src/common/errors/error-code.ts
+4
-0
swagger.config.ts
src/config/swagger.config.ts
+634
-1
saving-goal.controller.ts
src/modules/saving-goals/saving-goal.controller.ts
+165
-0
saving-goal.dto.ts
src/modules/saving-goals/saving-goal.dto.ts
+98
-0
saving-goal.repository.ts
src/modules/saving-goals/saving-goal.repository.ts
+346
-0
saving-goal.route.ts
src/modules/saving-goals/saving-goal.route.ts
+68
-0
saving-goal.service.ts
src/modules/saving-goals/saving-goal.service.ts
+374
-0
saving-goal.validation.ts
src/modules/saving-goals/saving-goal.validation.ts
+158
-0
index.ts
src/routes/index.ts
+2
-0
No files found.
.agents/memory.md
View file @
00379526
...
@@ -23,6 +23,10 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
...
@@ -23,6 +23,10 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
`CUSTOM`
,
`WEEKLY`
,
`MONTHLY`
,
`YEARLY`
và archive để giữ lịch sử. Mức sử dụng,
`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`
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.
trong khoảng thời gian
`[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.
## Trạng thái đã biết
## Trạng thái đã biết
...
@@ -37,7 +41,9 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
...
@@ -37,7 +41,9 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
`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,
lifecycle, constraint và index phục vụ theo dõi tiến độ.
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 đó.
...
...
prisma/migrations/20260731100000_add_saving_goals_management/migration.sql
0 → 100644
View file @
00379526
-- Add saving goals and their contribution history.
CREATE
TYPE
"SavingGoalStatus"
AS
ENUM
(
'ACTIVE'
,
'PAUSED'
,
'COMPLETED'
);
CREATE
TABLE
"saving_goals"
(
"id"
UUID
NOT
NULL
,
"user_id"
UUID
NOT
NULL
,
"name"
TEXT
NOT
NULL
,
"target_amount"
DECIMAL
(
18
,
2
)
NOT
NULL
,
"currency"
VARCHAR
(
3
)
NOT
NULL
DEFAULT
'VND'
,
"target_date"
TIMESTAMP
(
3
)
NOT
NULL
,
"description"
TEXT
,
"icon"
TEXT
,
"color"
TEXT
,
"status"
"SavingGoalStatus"
NOT
NULL
DEFAULT
'ACTIVE'
,
"completed_at"
TIMESTAMP
(
3
),
"is_archived"
BOOLEAN
NOT
NULL
DEFAULT
false
,
"created_at"
TIMESTAMP
(
3
)
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP
,
"updated_at"
TIMESTAMP
(
3
)
NOT
NULL
,
CONSTRAINT
"saving_goals_pkey"
PRIMARY
KEY
(
"id"
),
CONSTRAINT
"saving_goals_target_amount_positive_check"
CHECK
(
"target_amount"
>
0
),
CONSTRAINT
"saving_goals_currency_format_check"
CHECK
(
"currency"
~
'^[A-Z]{3}$'
),
CONSTRAINT
"saving_goals_completion_check"
CHECK
(
(
"status"
=
'COMPLETED'
AND
"completed_at"
IS
NOT
NULL
)
OR
(
"status"
<>
'COMPLETED'
AND
"completed_at"
IS
NULL
)
)
);
CREATE
TABLE
"saving_contributions"
(
"id"
UUID
NOT
NULL
,
"saving_goal_id"
UUID
NOT
NULL
,
"amount"
DECIMAL
(
18
,
2
)
NOT
NULL
,
"contributed_at"
TIMESTAMP
(
3
)
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP
,
"note"
TEXT
,
"created_at"
TIMESTAMP
(
3
)
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP
,
"updated_at"
TIMESTAMP
(
3
)
NOT
NULL
,
CONSTRAINT
"saving_contributions_pkey"
PRIMARY
KEY
(
"id"
),
CONSTRAINT
"saving_contributions_amount_positive_check"
CHECK
(
"amount"
>
0
)
);
CREATE
INDEX
"saving_goals_user_id_is_archived_idx"
ON
"saving_goals"
(
"user_id"
,
"is_archived"
);
CREATE
INDEX
"saving_goals_user_id_status_idx"
ON
"saving_goals"
(
"user_id"
,
"status"
);
CREATE
INDEX
"saving_goals_user_id_target_date_idx"
ON
"saving_goals"
(
"user_id"
,
"target_date"
);
CREATE
INDEX
"saving_contributions_saving_goal_id_contributed_at_idx"
ON
"saving_contributions"
(
"saving_goal_id"
,
"contributed_at"
);
ALTER
TABLE
"saving_goals"
ADD
CONSTRAINT
"saving_goals_user_id_fkey"
FOREIGN
KEY
(
"user_id"
)
REFERENCES
"users"
(
"id"
)
ON
DELETE
CASCADE
ON
UPDATE
CASCADE
;
ALTER
TABLE
"saving_contributions"
ADD
CONSTRAINT
"saving_contributions_saving_goal_id_fkey"
FOREIGN
KEY
(
"saving_goal_id"
)
REFERENCES
"saving_goals"
(
"id"
)
ON
DELETE
CASCADE
ON
UPDATE
CASCADE
;
prisma/schema.prisma
View file @
00379526
...
@@ -24,6 +24,12 @@ enum BudgetPeriod {
...
@@ -24,6 +24,12 @@ enum BudgetPeriod {
YEARLY
YEARLY
}
}
enum
SavingGoalStatus
{
ACTIVE
PAUSED
COMPLETED
}
model
User
{
model
User
{
id
String
@
id
@
default
(
uuid
())
@
db
.
Uuid
id
String
@
id
@
default
(
uuid
())
@
db
.
Uuid
email
String
?
@
unique
email
String
?
@
unique
...
@@ -44,6 +50,7 @@ model User {
...
@@ -44,6 +50,7 @@ model User {
categories
Category
[]
categories
Category
[]
transactions
Transaction
[]
transactions
Transaction
[]
budgets
Budget
[]
budgets
Budget
[]
savingGoals
SavingGoal
[]
refreshTokens
RefreshToken
[]
refreshTokens
RefreshToken
[]
socialAccounts
UserSocial
[]
socialAccounts
UserSocial
[]
verificationTokens
VerificationToken
[]
verificationTokens
VerificationToken
[]
...
@@ -164,6 +171,46 @@ model Budget {
...
@@ -164,6 +171,46 @@ model Budget {
@@
map
(
"budgets"
)
@@
map
(
"budgets"
)
}
}
model
SavingGoal
{
id
String
@
id
@
default
(
uuid
())
@
db
.
Uuid
userId
String
@
map
(
"user_id"
)
@
db
.
Uuid
name
String
targetAmount
Decimal
@
map
(
"target_amount"
)
@
db
.
Decimal
(
18
,
2
)
currency
String
@
default
(
"VND"
)
@
db
.
VarChar
(
3
)
targetDate
DateTime
@
map
(
"target_date"
)
description
String
?
icon
String
?
color
String
?
status
SavingGoalStatus
@
default
(
ACTIVE
)
completedAt
DateTime
?
@
map
(
"completed_at"
)
isArchived
Boolean
@
default
(
false
)
@
map
(
"is_archived"
)
createdAt
DateTime
@
default
(
now
())
@
map
(
"created_at"
)
updatedAt
DateTime
@
updatedAt
@
map
(
"updated_at"
)
user
User
@
relation
(
fields
:
[
userId
],
references
:
[
id
],
onDelete
:
Cascade
)
contributions
SavingContribution
[]
@@
index
([
userId
,
isArchived
])
@@
index
([
userId
,
status
])
@@
index
([
userId
,
targetDate
])
@@
map
(
"saving_goals"
)
}
model
SavingContribution
{
id
String
@
id
@
default
(
uuid
())
@
db
.
Uuid
savingGoalId
String
@
map
(
"saving_goal_id"
)
@
db
.
Uuid
amount
Decimal
@
db
.
Decimal
(
18
,
2
)
contributedAt
DateTime
@
default
(
now
())
@
map
(
"contributed_at"
)
note
String
?
createdAt
DateTime
@
default
(
now
())
@
map
(
"created_at"
)
updatedAt
DateTime
@
updatedAt
@
map
(
"updated_at"
)
savingGoal
SavingGoal
@
relation
(
fields
:
[
savingGoalId
],
references
:
[
id
],
onDelete
:
Cascade
)
@@
index
([
savingGoalId
,
contributedAt
])
@@
map
(
"saving_contributions"
)
}
model
RefreshToken
{
model
RefreshToken
{
id
String
@
id
@
default
(
uuid
())
@
db
.
Uuid
id
String
@
id
@
default
(
uuid
())
@
db
.
Uuid
token
String
@
unique
token
String
@
unique
...
...
src/common/errors/error-code.ts
View file @
00379526
...
@@ -19,6 +19,10 @@ export const ERROR_CODE = {
...
@@ -19,6 +19,10 @@ export const ERROR_CODE = {
TRANSACTION_CATEGORY_TYPE_MISMATCH
:
'TRANSACTION_CATEGORY_TYPE_MISMATCH'
,
TRANSACTION_CATEGORY_TYPE_MISMATCH
:
'TRANSACTION_CATEGORY_TYPE_MISMATCH'
,
BUDGET_ARCHIVED
:
'BUDGET_ARCHIVED'
,
BUDGET_ARCHIVED
:
'BUDGET_ARCHIVED'
,
BUDGET_CATEGORY_TYPE_INVALID
:
'BUDGET_CATEGORY_TYPE_INVALID'
,
BUDGET_CATEGORY_TYPE_INVALID
:
'BUDGET_CATEGORY_TYPE_INVALID'
,
SAVING_GOAL_ARCHIVED
:
'SAVING_GOAL_ARCHIVED'
,
SAVING_GOAL_PAUSED
:
'SAVING_GOAL_PAUSED'
,
SAVING_GOAL_COMPLETED
:
'SAVING_GOAL_COMPLETED'
,
SAVING_GOAL_CURRENCY_LOCKED
:
'SAVING_GOAL_CURRENCY_LOCKED'
,
FILE_TYPE_UNSUPPORTED
:
'FILE_TYPE_UNSUPPORTED'
,
FILE_TYPE_UNSUPPORTED
:
'FILE_TYPE_UNSUPPORTED'
,
FILE_TOO_LARGE
:
'FILE_TOO_LARGE'
,
FILE_TOO_LARGE
:
'FILE_TOO_LARGE'
,
RECEIPT_NOT_FOUND
:
'RECEIPT_NOT_FOUND'
,
RECEIPT_NOT_FOUND
:
'RECEIPT_NOT_FOUND'
,
...
...
src/config/swagger.config.ts
View file @
00379526
This diff is collapsed.
Click to expand it.
src/modules/saving-goals/saving-goal.controller.ts
0 → 100644
View file @
00379526
import
{
NextFunction
,
Request
,
Response
}
from
'express'
;
import
{
CreateSavingContributionDto
,
CreateSavingGoalDto
,
SavingContributionQueryDto
,
SavingGoalQueryDto
,
UpdateSavingContributionDto
,
UpdateSavingGoalDto
,
}
from
'./saving-goal.dto'
;
import
{
SavingGoalService
}
from
'./saving-goal.service'
;
export
class
SavingGoalController
{
private
readonly
service
=
new
SavingGoalService
();
findAll
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
const
result
=
await
this
.
service
.
findAll
(
req
.
user
.
id
,
req
.
query
as
unknown
as
SavingGoalQueryDto
,
);
res
.
json
({
success
:
true
,
...
result
});
}
catch
(
error
)
{
next
(
error
);
}
};
findById
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
const
goal
=
await
this
.
service
.
findById
(
req
.
user
.
id
,
req
.
params
.
id
);
res
.
json
({
success
:
true
,
data
:
goal
});
}
catch
(
error
)
{
next
(
error
);
}
};
create
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
const
goal
=
await
this
.
service
.
create
(
req
.
user
.
id
,
req
.
body
as
CreateSavingGoalDto
,
);
res
.
status
(
201
).
json
({
success
:
true
,
data
:
goal
});
}
catch
(
error
)
{
next
(
error
);
}
};
update
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
const
goal
=
await
this
.
service
.
update
(
req
.
user
.
id
,
req
.
params
.
id
,
req
.
body
as
UpdateSavingGoalDto
,
);
res
.
json
({
success
:
true
,
data
:
goal
});
}
catch
(
error
)
{
next
(
error
);
}
};
archive
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
const
goal
=
await
this
.
service
.
archive
(
req
.
user
.
id
,
req
.
params
.
id
);
res
.
json
({
success
:
true
,
message
:
'Saving goal archived successfully'
,
data
:
goal
,
});
}
catch
(
error
)
{
next
(
error
);
}
};
restore
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
const
goal
=
await
this
.
service
.
restore
(
req
.
user
.
id
,
req
.
params
.
id
);
res
.
json
({
success
:
true
,
data
:
goal
});
}
catch
(
error
)
{
next
(
error
);
}
};
findContributions
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
,
)
=>
{
try
{
const
result
=
await
this
.
service
.
findContributions
(
req
.
user
.
id
,
req
.
params
.
id
,
req
.
query
as
unknown
as
SavingContributionQueryDto
,
);
res
.
json
({
success
:
true
,
...
result
});
}
catch
(
error
)
{
next
(
error
);
}
};
createContribution
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
,
)
=>
{
try
{
const
result
=
await
this
.
service
.
createContribution
(
req
.
user
.
id
,
req
.
params
.
id
,
req
.
body
as
CreateSavingContributionDto
,
);
res
.
status
(
201
).
json
({
success
:
true
,
data
:
result
});
}
catch
(
error
)
{
next
(
error
);
}
};
updateContribution
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
,
)
=>
{
try
{
const
result
=
await
this
.
service
.
updateContribution
(
req
.
user
.
id
,
req
.
params
.
id
,
req
.
params
.
contributionId
,
req
.
body
as
UpdateSavingContributionDto
,
);
res
.
json
({
success
:
true
,
data
:
result
});
}
catch
(
error
)
{
next
(
error
);
}
};
deleteContribution
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
,
)
=>
{
try
{
const
result
=
await
this
.
service
.
deleteContribution
(
req
.
user
.
id
,
req
.
params
.
id
,
req
.
params
.
contributionId
,
);
res
.
json
({
success
:
true
,
message
:
'Saving contribution deleted successfully'
,
data
:
result
,
});
}
catch
(
error
)
{
next
(
error
);
}
};
}
src/modules/saving-goals/saving-goal.dto.ts
0 → 100644
View file @
00379526
import
{
SavingGoalStatus
}
from
'@prisma/client'
;
export
type
SavingGoalSortField
=
'name'
|
'targetAmount'
|
'targetDate'
|
'createdAt'
|
'updatedAt'
;
export
type
SavingContributionSortField
=
'amount'
|
'contributedAt'
|
'createdAt'
|
'updatedAt'
;
export
type
SortOrder
=
'asc'
|
'desc'
;
export
interface
SavingGoalQueryDto
{
search
?:
string
;
status
?:
SavingGoalStatus
;
dueFrom
?:
Date
;
dueTo
?:
Date
;
includeArchived
:
boolean
;
sortBy
:
SavingGoalSortField
;
order
:
SortOrder
;
page
:
number
;
limit
:
number
;
}
export
interface
CreateSavingGoalDto
{
name
:
string
;
targetAmount
:
string
;
currency
:
string
;
targetDate
:
Date
;
description
?:
string
|
null
;
icon
?:
string
|
null
;
color
?:
string
|
null
;
}
export
interface
UpdateSavingGoalDto
{
name
?:
string
;
targetAmount
?:
string
;
currency
?:
string
;
targetDate
?:
Date
;
description
?:
string
|
null
;
icon
?:
string
|
null
;
color
?:
string
|
null
;
status
?:
Extract
<
SavingGoalStatus
,
'ACTIVE'
|
'PAUSED'
>
;
}
export
interface
SavingContributionQueryDto
{
dateFrom
?:
Date
;
dateTo
?:
Date
;
sortBy
:
SavingContributionSortField
;
order
:
SortOrder
;
page
:
number
;
limit
:
number
;
}
export
interface
CreateSavingContributionDto
{
amount
:
string
;
contributedAt
:
Date
;
note
?:
string
|
null
;
}
export
interface
UpdateSavingContributionDto
{
amount
?:
string
;
contributedAt
?:
Date
;
note
?:
string
|
null
;
}
export
interface
SavingGoalProgressDto
{
savedAmount
:
string
;
remainingAmount
:
string
;
progressPercentage
:
string
;
contributionCount
:
number
;
lastContributionAt
:
Date
|
null
;
daysRemaining
:
number
;
isOverdue
:
boolean
;
}
export
interface
SavingGoalResponseDto
{
id
:
string
;
name
:
string
;
targetAmount
:
string
;
currency
:
string
;
targetDate
:
Date
;
description
:
string
|
null
;
icon
:
string
|
null
;
color
:
string
|
null
;
status
:
SavingGoalStatus
;
completedAt
:
Date
|
null
;
isArchived
:
boolean
;
createdAt
:
Date
;
updatedAt
:
Date
;
progress
:
SavingGoalProgressDto
;
}
export
interface
SavingContributionResponseDto
{
id
:
string
;
savingGoalId
:
string
;
amount
:
string
;
contributedAt
:
Date
;
note
:
string
|
null
;
createdAt
:
Date
;
updatedAt
:
Date
;
}
src/modules/saving-goals/saving-goal.repository.ts
0 → 100644
View file @
00379526
import
{
Prisma
,
SavingGoalStatus
}
from
'@prisma/client'
;
import
{
prisma
}
from
'../../database/prisma.client'
;
import
{
CreateSavingContributionDto
,
CreateSavingGoalDto
,
SavingContributionQueryDto
,
SavingContributionResponseDto
,
SavingGoalQueryDto
,
UpdateSavingContributionDto
,
UpdateSavingGoalDto
,
}
from
'./saving-goal.dto'
;
const
savingGoalSelect
=
{
id
:
true
,
name
:
true
,
targetAmount
:
true
,
currency
:
true
,
targetDate
:
true
,
description
:
true
,
icon
:
true
,
color
:
true
,
status
:
true
,
completedAt
:
true
,
isArchived
:
true
,
createdAt
:
true
,
updatedAt
:
true
,
}
satisfies
Prisma
.
SavingGoalSelect
;
const
savingContributionSelect
=
{
id
:
true
,
savingGoalId
:
true
,
amount
:
true
,
contributedAt
:
true
,
note
:
true
,
createdAt
:
true
,
updatedAt
:
true
,
}
satisfies
Prisma
.
SavingContributionSelect
;
export
type
SavingGoalRecord
=
Prisma
.
SavingGoalGetPayload
<
{
select
:
typeof
savingGoalSelect
;
}
>
;
type
SavingContributionRecord
=
Prisma
.
SavingContributionGetPayload
<
{
select
:
typeof
savingContributionSelect
;
}
>
;
export
type
SavingGoalTransaction
=
Prisma
.
TransactionClient
;
export
interface
SavingContributionSummary
{
savingGoalId
:
string
;
amount
:
Prisma
.
Decimal
;
contributionCount
:
number
;
lastContributionAt
:
Date
|
null
;
}
type
SavingGoalUpdateData
=
Omit
<
UpdateSavingGoalDto
,
'status'
>
&
{
status
?:
SavingGoalStatus
;
completedAt
?:
Date
|
null
;
};
function
client
(
transaction
?:
SavingGoalTransaction
)
{
return
transaction
??
prisma
;
}
function
toContributionResponse
(
contribution
:
SavingContributionRecord
,
):
SavingContributionResponseDto
{
return
{
...
contribution
,
amount
:
contribution
.
amount
.
toFixed
(
2
),
};
}
export
class
SavingGoalRepository
{
async
runSerializable
<
T
>
(
operation
:
(
transaction
:
SavingGoalTransaction
)
=>
Promise
<
T
>
,
):
Promise
<
T
>
{
const
maxAttempts
=
3
;
for
(
let
attempt
=
1
;
attempt
<=
maxAttempts
;
attempt
+=
1
)
{
try
{
return
await
prisma
.
$transaction
(
operation
,
{
isolationLevel
:
Prisma
.
TransactionIsolationLevel
.
Serializable
,
});
}
catch
(
error
)
{
const
shouldRetry
=
error
instanceof
Prisma
.
PrismaClientKnownRequestError
&&
error
.
code
===
'P2034'
&&
attempt
<
maxAttempts
;
if
(
!
shouldRetry
)
{
throw
error
;
}
}
}
throw
new
Error
(
'Serializable transaction retry limit reached'
);
}
async
findAll
(
userId
:
string
,
query
:
SavingGoalQueryDto
)
{
const
{
search
,
status
,
dueFrom
,
dueTo
,
includeArchived
,
sortBy
,
order
,
page
,
limit
,
}
=
query
;
const
where
:
Prisma
.
SavingGoalWhereInput
=
{
userId
,
...(
status
?
{
status
}
:
{}),
...(
includeArchived
?
{}
:
{
isArchived
:
false
}),
...(
dueFrom
||
dueTo
?
{
targetDate
:
{
...(
dueFrom
?
{
gte
:
dueFrom
}
:
{}),
...(
dueTo
?
{
lte
:
dueTo
}
:
{}),
},
}
:
{}),
...(
search
?
{
OR
:
[
{
name
:
{
contains
:
search
,
mode
:
Prisma
.
QueryMode
.
insensitive
},
},
{
description
:
{
contains
:
search
,
mode
:
Prisma
.
QueryMode
.
insensitive
,
},
},
],
}
:
{}),
};
const
skip
=
(
page
-
1
)
*
limit
;
const
[
goals
,
total
]
=
await
prisma
.
$transaction
([
prisma
.
savingGoal
.
findMany
({
where
,
select
:
savingGoalSelect
,
orderBy
:
[{
[
sortBy
]:
order
},
{
id
:
order
}],
skip
,
take
:
limit
,
}),
prisma
.
savingGoal
.
count
({
where
}),
]);
return
{
data
:
goals
,
meta
:
{
total
,
page
,
limit
,
totalPages
:
Math
.
ceil
(
total
/
limit
),
},
};
}
findById
(
userId
:
string
,
id
:
string
,
transaction
?:
SavingGoalTransaction
)
{
return
client
(
transaction
).
savingGoal
.
findFirst
({
where
:
{
id
,
userId
},
select
:
savingGoalSelect
,
});
}
create
(
userId
:
string
,
data
:
CreateSavingGoalDto
)
{
return
prisma
.
savingGoal
.
create
({
data
:
{
userId
,
...
data
,
},
select
:
savingGoalSelect
,
});
}
update
(
id
:
string
,
data
:
SavingGoalUpdateData
,
transaction
:
SavingGoalTransaction
,
)
{
return
transaction
.
savingGoal
.
update
({
where
:
{
id
},
data
,
select
:
savingGoalSelect
,
});
}
archive
(
id
:
string
,
transaction
:
SavingGoalTransaction
)
{
return
transaction
.
savingGoal
.
update
({
where
:
{
id
},
data
:
{
isArchived
:
true
},
select
:
savingGoalSelect
,
});
}
restore
(
id
:
string
,
status
:
SavingGoalStatus
,
completedAt
:
Date
|
null
,
transaction
:
SavingGoalTransaction
,
)
{
return
transaction
.
savingGoal
.
update
({
where
:
{
id
},
data
:
{
isArchived
:
false
,
status
,
completedAt
,
},
select
:
savingGoalSelect
,
});
}
async
findSummaries
(
savingGoalIds
:
string
[],
transaction
?:
SavingGoalTransaction
,
):
Promise
<
SavingContributionSummary
[]
>
{
if
(
savingGoalIds
.
length
===
0
)
{
return
[];
}
const
summaries
=
await
client
(
transaction
).
savingContribution
.
groupBy
({
by
:
[
'savingGoalId'
],
where
:
{
savingGoalId
:
{
in
:
savingGoalIds
}
},
_sum
:
{
amount
:
true
},
_count
:
{
_all
:
true
},
_max
:
{
contributedAt
:
true
},
});
return
summaries
.
map
((
summary
)
=>
({
savingGoalId
:
summary
.
savingGoalId
,
amount
:
summary
.
_sum
.
amount
??
new
Prisma
.
Decimal
(
0
),
contributionCount
:
summary
.
_count
.
_all
,
lastContributionAt
:
summary
.
_max
.
contributedAt
,
}));
}
async
findSummary
(
savingGoalId
:
string
,
transaction
?:
SavingGoalTransaction
,
):
Promise
<
SavingContributionSummary
>
{
const
result
=
await
client
(
transaction
).
savingContribution
.
aggregate
({
where
:
{
savingGoalId
},
_sum
:
{
amount
:
true
},
_count
:
{
_all
:
true
},
_max
:
{
contributedAt
:
true
},
});
return
{
savingGoalId
,
amount
:
result
.
_sum
.
amount
??
new
Prisma
.
Decimal
(
0
),
contributionCount
:
result
.
_count
.
_all
,
lastContributionAt
:
result
.
_max
.
contributedAt
,
};
}
async
findContributions
(
savingGoalId
:
string
,
query
:
SavingContributionQueryDto
,
)
{
const
{
dateFrom
,
dateTo
,
sortBy
,
order
,
page
,
limit
}
=
query
;
const
where
:
Prisma
.
SavingContributionWhereInput
=
{
savingGoalId
,
...(
dateFrom
||
dateTo
?
{
contributedAt
:
{
...(
dateFrom
?
{
gte
:
dateFrom
}
:
{}),
...(
dateTo
?
{
lte
:
dateTo
}
:
{}),
},
}
:
{}),
};
const
skip
=
(
page
-
1
)
*
limit
;
const
[
contributions
,
total
]
=
await
prisma
.
$transaction
([
prisma
.
savingContribution
.
findMany
({
where
,
select
:
savingContributionSelect
,
orderBy
:
[{
[
sortBy
]:
order
},
{
id
:
order
}],
skip
,
take
:
limit
,
}),
prisma
.
savingContribution
.
count
({
where
}),
]);
return
{
data
:
contributions
.
map
(
toContributionResponse
),
meta
:
{
total
,
page
,
limit
,
totalPages
:
Math
.
ceil
(
total
/
limit
),
},
};
}
findContributionById
(
savingGoalId
:
string
,
id
:
string
,
transaction
:
SavingGoalTransaction
,
)
{
return
transaction
.
savingContribution
.
findFirst
({
where
:
{
id
,
savingGoalId
},
select
:
savingContributionSelect
,
});
}
async
createContribution
(
savingGoalId
:
string
,
data
:
CreateSavingContributionDto
,
transaction
:
SavingGoalTransaction
,
)
{
const
contribution
=
await
transaction
.
savingContribution
.
create
({
data
:
{
savingGoalId
,
...
data
,
},
select
:
savingContributionSelect
,
});
return
toContributionResponse
(
contribution
);
}
async
updateContribution
(
id
:
string
,
data
:
UpdateSavingContributionDto
,
transaction
:
SavingGoalTransaction
,
)
{
const
contribution
=
await
transaction
.
savingContribution
.
update
({
where
:
{
id
},
data
,
select
:
savingContributionSelect
,
});
return
toContributionResponse
(
contribution
);
}
deleteContribution
(
id
:
string
,
transaction
:
SavingGoalTransaction
)
{
return
transaction
.
savingContribution
.
delete
({
where
:
{
id
},
select
:
{
id
:
true
},
});
}
}
src/modules/saving-goals/saving-goal.route.ts
0 → 100644
View file @
00379526
import
{
Router
}
from
'express'
;
import
{
authMiddleware
}
from
'../../middlewares/auth.middleware'
;
import
{
validate
}
from
'../../middlewares/validate.middleware'
;
import
{
SavingGoalController
}
from
'./saving-goal.controller'
;
import
{
createSavingContributionSchema
,
createSavingGoalSchema
,
findSavingContributionsSchema
,
findSavingGoalsSchema
,
savingContributionParamsSchema
,
savingGoalParamsSchema
,
updateSavingContributionSchema
,
updateSavingGoalSchema
,
}
from
'./saving-goal.validation'
;
const
router
=
Router
();
const
controller
=
new
SavingGoalController
();
router
.
use
(
authMiddleware
);
router
.
get
(
'/'
,
validate
(
findSavingGoalsSchema
,
'query'
),
controller
.
findAll
);
router
.
post
(
'/'
,
validate
(
createSavingGoalSchema
),
controller
.
create
);
router
.
get
(
'/:id/contributions'
,
validate
(
savingGoalParamsSchema
,
'params'
),
validate
(
findSavingContributionsSchema
,
'query'
),
controller
.
findContributions
,
);
router
.
post
(
'/:id/contributions'
,
validate
(
savingGoalParamsSchema
,
'params'
),
validate
(
createSavingContributionSchema
),
controller
.
createContribution
,
);
router
.
put
(
'/:id/contributions/:contributionId'
,
validate
(
savingContributionParamsSchema
,
'params'
),
validate
(
updateSavingContributionSchema
),
controller
.
updateContribution
,
);
router
.
delete
(
'/:id/contributions/:contributionId'
,
validate
(
savingContributionParamsSchema
,
'params'
),
controller
.
deleteContribution
,
);
router
.
get
(
'/:id'
,
validate
(
savingGoalParamsSchema
,
'params'
),
controller
.
findById
,
);
router
.
put
(
'/:id'
,
validate
(
savingGoalParamsSchema
,
'params'
),
validate
(
updateSavingGoalSchema
),
controller
.
update
,
);
router
.
patch
(
'/:id/restore'
,
validate
(
savingGoalParamsSchema
,
'params'
),
controller
.
restore
,
);
router
.
delete
(
'/:id'
,
validate
(
savingGoalParamsSchema
,
'params'
),
controller
.
archive
,
);
export
default
router
;
src/modules/saving-goals/saving-goal.service.ts
0 → 100644
View file @
00379526
This diff is collapsed.
Click to expand it.
src/modules/saving-goals/saving-goal.validation.ts
0 → 100644
View file @
00379526
import
{
Prisma
}
from
'@prisma/client'
;
import
{
z
}
from
'zod'
;
const
amountSchema
=
z
.
string
()
.
trim
()
.
regex
(
/^
(?:
0|
[
1-9
]\d{0,15})(?:\.\d{1,2})?
$/
,
'Amount must be a positive decimal string with at most 16 integer digits and 2 decimal places'
,
)
.
refine
(
(
value
)
=>
new
Prisma
.
Decimal
(
value
).
greaterThan
(
0
),
'Amount must be greater than zero'
,
);
const
dateSchema
=
z
.
string
()
.
datetime
({
offset
:
true
,
message
:
'Date must be a valid ISO 8601 date-time'
,
})
.
transform
((
value
)
=>
new
Date
(
value
));
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
nullableText
=
(
max
:
number
)
=>
z
.
string
().
trim
().
max
(
max
).
nullable
().
optional
();
export
const
savingGoalParamsSchema
=
z
.
object
({
id
:
z
.
string
().
uuid
(
'Invalid saving goal id'
),
});
export
const
savingContributionParamsSchema
=
z
.
object
({
id
:
z
.
string
().
uuid
(
'Invalid saving goal id'
),
contributionId
:
z
.
string
().
uuid
(
'Invalid saving contribution id'
),
});
export
const
findSavingGoalsSchema
=
z
.
object
({
search
:
z
.
string
().
trim
().
min
(
1
).
max
(
200
).
optional
(),
status
:
z
.
enum
([
'ACTIVE'
,
'PAUSED'
,
'COMPLETED'
]).
optional
(),
dueFrom
:
dateSchema
.
optional
(),
dueTo
:
dateSchema
.
optional
(),
includeArchived
:
z
.
enum
([
'true'
,
'false'
])
.
transform
((
value
)
=>
value
===
'true'
)
.
optional
()
.
default
(
'false'
),
sortBy
:
z
.
enum
([
'name'
,
'targetAmount'
,
'targetDate'
,
'createdAt'
,
'updatedAt'
])
.
optional
()
.
default
(
'targetDate'
),
order
:
z
.
enum
([
'asc'
,
'desc'
]).
optional
().
default
(
'asc'
),
page
:
z
.
coerce
.
number
().
int
().
positive
().
optional
().
default
(
1
),
limit
:
z
.
coerce
.
number
().
int
().
min
(
1
).
max
(
100
).
optional
().
default
(
20
),
})
.
refine
(
(
data
)
=>
!
data
.
dueFrom
||
!
data
.
dueTo
||
data
.
dueTo
>=
data
.
dueFrom
,
{
path
:
[
'dueTo'
],
message
:
'dueTo must be on or after dueFrom'
},
);
export
const
createSavingGoalSchema
=
z
.
object
({
name
:
z
.
string
().
trim
().
min
(
1
,
'Name is required'
).
max
(
100
),
targetAmount
:
amountSchema
,
currency
:
currencySchema
.
optional
().
default
(
'VND'
),
targetDate
:
dateSchema
,
description
:
nullableText
(
500
),
icon
:
nullableText
(
100
),
color
:
z
.
string
()
.
trim
()
.
regex
(
/^#
(?:[
0-9a-fA-F
]{3}
|
[
0-9a-fA-F
]{6})
$/
,
'Color must be a valid hex color'
,
)
.
nullable
()
.
optional
(),
})
.
refine
((
data
)
=>
data
.
targetDate
>
new
Date
(),
{
path
:
[
'targetDate'
],
message
:
'targetDate must be in the future'
,
});
export
const
updateSavingGoalSchema
=
z
.
object
({
name
:
z
.
string
().
trim
().
min
(
1
,
'Name cannot be empty'
).
max
(
100
).
optional
(),
targetAmount
:
amountSchema
.
optional
(),
currency
:
currencySchema
.
optional
(),
targetDate
:
dateSchema
.
optional
(),
description
:
nullableText
(
500
),
icon
:
nullableText
(
100
),
color
:
z
.
string
()
.
trim
()
.
regex
(
/^#
(?:[
0-9a-fA-F
]{3}
|
[
0-9a-fA-F
]{6})
$/
,
'Color must be a valid hex color'
,
)
.
nullable
()
.
optional
(),
status
:
z
.
enum
([
'ACTIVE'
,
'PAUSED'
]).
optional
(),
})
.
refine
((
data
)
=>
Object
.
keys
(
data
).
length
>
0
,
{
message
:
'At least one field is required'
,
})
.
refine
((
data
)
=>
!
data
.
targetDate
||
data
.
targetDate
>
new
Date
(),
{
path
:
[
'targetDate'
],
message
:
'targetDate must be in the future'
,
});
export
const
findSavingContributionsSchema
=
z
.
object
({
dateFrom
:
dateSchema
.
optional
(),
dateTo
:
dateSchema
.
optional
(),
sortBy
:
z
.
enum
([
'amount'
,
'contributedAt'
,
'createdAt'
,
'updatedAt'
])
.
optional
()
.
default
(
'contributedAt'
),
order
:
z
.
enum
([
'asc'
,
'desc'
]).
optional
().
default
(
'desc'
),
page
:
z
.
coerce
.
number
().
int
().
positive
().
optional
().
default
(
1
),
limit
:
z
.
coerce
.
number
().
int
().
min
(
1
).
max
(
100
).
optional
().
default
(
20
),
})
.
refine
(
(
data
)
=>
!
data
.
dateFrom
||
!
data
.
dateTo
||
data
.
dateTo
>=
data
.
dateFrom
,
{
path
:
[
'dateTo'
],
message
:
'dateTo must be on or after dateFrom'
},
);
export
const
createSavingContributionSchema
=
z
.
object
({
amount
:
amountSchema
,
contributedAt
:
dateSchema
.
optional
(),
note
:
nullableText
(
500
),
})
.
transform
((
data
)
=>
({
...
data
,
contributedAt
:
data
.
contributedAt
??
new
Date
(),
}))
.
refine
((
data
)
=>
data
.
contributedAt
<=
new
Date
(),
{
path
:
[
'contributedAt'
],
message
:
'contributedAt cannot be in the future'
,
});
export
const
updateSavingContributionSchema
=
z
.
object
({
amount
:
amountSchema
.
optional
(),
contributedAt
:
dateSchema
.
optional
(),
note
:
nullableText
(
500
),
})
.
refine
((
data
)
=>
Object
.
keys
(
data
).
length
>
0
,
{
message
:
'At least one field is required'
,
})
.
refine
((
data
)
=>
!
data
.
contributedAt
||
data
.
contributedAt
<=
new
Date
(),
{
path
:
[
'contributedAt'
],
message
:
'contributedAt cannot be in the future'
,
});
src/routes/index.ts
View file @
00379526
...
@@ -5,6 +5,7 @@ import walletRoute from '../modules/wallets/wallet.route';
...
@@ -5,6 +5,7 @@ import walletRoute from '../modules/wallets/wallet.route';
import
categoryRoute
from
'../modules/categories/category.route'
;
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'
;
const
router
=
Router
();
const
router
=
Router
();
...
@@ -18,5 +19,6 @@ router.use('/wallets', walletRoute);
...
@@ -18,5 +19,6 @@ router.use('/wallets', walletRoute);
router
.
use
(
'/categories'
,
categoryRoute
);
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
);
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