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
bc2abecc
Commit
bc2abecc
authored
Aug 05, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(ai): add Gemini-powered financial assistant
parent
132dba7b
Changes
19
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
19 changed files
with
2092 additions
and
2 deletions
+2092
-2
memory.md
.agents/memory.md
+11
-0
project.md
.agents/project.md
+4
-1
.env.example
.env.example
+10
-0
ai-provider.factory.ts
src/common/ai/ai-provider.factory.ts
+27
-0
ai-provider.ts
src/common/ai/ai-provider.ts
+47
-0
gemini.provider.ts
src/common/ai/gemini.provider.ts
+178
-0
error-code.ts
src/common/errors/error-code.ts
+4
-0
env.config.ts
src/config/env.config.ts
+36
-0
swagger.config.ts
src/config/swagger.config.ts
+484
-1
ai-rate-limit.middleware.ts
src/middlewares/ai-rate-limit.middleware.ts
+52
-0
ai-assistant-provider.schema.ts
src/modules/ai-assistant/ai-assistant-provider.schema.ts
+171
-0
ai-assistant-response.validation.ts
src/modules/ai-assistant/ai-assistant-response.validation.ts
+78
-0
ai-assistant.controller.ts
src/modules/ai-assistant/ai-assistant.controller.ts
+78
-0
ai-assistant.dto.ts
src/modules/ai-assistant/ai-assistant.dto.ts
+52
-0
ai-assistant.repository.ts
src/modules/ai-assistant/ai-assistant.repository.ts
+235
-0
ai-assistant.route.ts
src/modules/ai-assistant/ai-assistant.route.ts
+44
-0
ai-assistant.service.ts
src/modules/ai-assistant/ai-assistant.service.ts
+484
-0
ai-assistant.validation.ts
src/modules/ai-assistant/ai-assistant.validation.ts
+95
-0
index.ts
src/routes/index.ts
+2
-0
No files found.
.agents/memory.md
View file @
bc2abecc
...
...
@@ -38,6 +38,15 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
có thể tắt hoặc chỉnh chu kỳ bằng các biến
`NOTIFICATION_*`
.
-
Giao dịch chi bất thường được cảnh báo khi có ít nhất 5 giao dịch lịch sử 90 ngày trong cùng
ví và số tiền mới đạt ít nhất 3 lần trung bình; đây là heuristic có thể thay bằng AI sau.
-
AI Financial Assistant là module read-only/stateless dưới
`/api/v1/ai-assistant`
, gồm phân loại
giao dịch, OCR hóa đơn, hỏi đáp, phân tích xu hướng/bất thường và khuyến nghị ngân sách/tiết kiệm.
Tích hợp AI đi qua
`AIProvider`
; adapter mặc định là Gemini REST và hỗ trợ danh sách key xoay vòng,
failover. Context không chứa thông tin profile, location hoặc receipt URL, có giới hạn dữ liệu,
output token, timeout và rate limit riêng; mọi structured output được Zod kiểm tra lại.
-
Quy ước tổ chức validation của module AI:
`ai-assistant.validation.ts`
chỉ chứa Zod schema cho
HTTP input;
`ai-assistant-response.validation.ts`
chứa Zod schema kiểm tra output từ AI;
`ai-assistant-provider.schema.ts`
chứa JSON Schema gửi cho provider. Service chỉ chọn và áp dụng
validator/schema theo use case, không khai báo Zod schema trực tiếp trong file service.
## Trạng thái đã biết
...
...
@@ -50,6 +59,8 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
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`
.
-
AI Financial Assistant có 5 API đã mô tả trong Swagger. Module không cần model/migration mới;
cần cấu hình
`GEMINI_API_KEYS`
để gọi provider, nếu thiếu thì chỉ các AI endpoint trả lỗi 503.
-
Các migration
`20260728170000_improve_wallet_management`
và
`20260728190000_add_category_management`
đồng bộ thay đổi của Wallet và Category;
`20260728210000_add_transaction_management`
đồng bộ Decimal, receipt/location và index
...
...
.agents/project.md
View file @
bc2abecc
...
...
@@ -57,11 +57,14 @@ Express router
## Phạm vi hiện tại
-
Route hoạt động: health, auth, users, wallets, categories, transactions, budgets,
saving goals
và financial reports
.
saving goals
, financial reports và AI Financial Assistant
.
-
Wallet, Category, Transaction và Budget có module API theo ownership trong
`src/modules/`
.
-
Financial Reports tổng hợp dữ liệu hiện có theo khoảng thời gian và currency,
không lưu snapshot báo cáo riêng trong database.
-
AI Financial Assistant cung cấp phân loại giao dịch, OCR hóa đơn, hỏi đáp tài chính,
phân tích xu hướng/bất thường và khuyến nghị. Module chỉ đọc dữ liệu thuộc người dùng,
không lưu hội thoại hoặc kết quả AI và truy cập mô hình qua provider interface.
-
Notification cung cấp inbox, trạng thái đã đọc, cấu hình kênh và outbox giao nhận;
Reminder hỗ trợ lịch một lần hoặc lặp lại và được xử lý bởi worker nền.
-
Email verification, password reset, cảnh báo thiết bị và quản lý session nằm
...
...
.env.example
View file @
bc2abecc
...
...
@@ -34,3 +34,13 @@ RECEIPT_MAX_FILE_SIZE_MB=5
NOTIFICATION_WORKER_ENABLED=true
NOTIFICATION_WORKER_INTERVAL_MS=60000
NOTIFICATION_FINANCIAL_SCAN_INTERVAL_MS=300000
AI_PROVIDER=gemini
GEMINI_API_KEYS=replace_with_key_1,replace_with_key_2
GEMINI_MODEL=gemini-2.5-flash
GEMINI_API_BASE_URL=https://generativelanguage.googleapis.com/v1beta
AI_REQUEST_TIMEOUT_MS=30000
AI_MAX_OUTPUT_TOKENS=2048
AI_MAX_CONTEXT_TRANSACTIONS=200
AI_RATE_LIMIT_MAX_REQUESTS=20
AI_RATE_LIMIT_WINDOW_MS=900000
src/common/ai/ai-provider.factory.ts
0 → 100644
View file @
bc2abecc
import
{
envConfig
}
from
'../../config/env.config'
;
import
{
AIProvider
,
AIProviderError
}
from
'./ai-provider'
;
import
{
GeminiProvider
}
from
'./gemini.provider'
;
let
provider
:
AIProvider
|
undefined
;
export
function
getAIProvider
():
AIProvider
{
if
(
provider
)
{
return
provider
;
}
if
(
envConfig
.
ai
.
provider
!==
'gemini'
)
{
throw
new
AIProviderError
(
'NOT_CONFIGURED'
,
`Unsupported AI provider:
${
envConfig
.
ai
.
provider
}
`
,
);
}
provider
=
new
GeminiProvider
({
apiKeys
:
envConfig
.
ai
.
geminiApiKeys
,
model
:
envConfig
.
ai
.
geminiModel
,
baseUrl
:
envConfig
.
ai
.
geminiBaseUrl
,
timeoutMs
:
envConfig
.
ai
.
requestTimeoutMs
,
defaultMaxOutputTokens
:
envConfig
.
ai
.
maxOutputTokens
,
});
return
provider
;
}
src/common/ai/ai-provider.ts
0 → 100644
View file @
bc2abecc
export
interface
AIInlineData
{
mimeType
:
string
;
data
:
string
;
}
export
type
AIContentPart
=
|
{
text
:
string
}
|
{
inlineData
:
AIInlineData
};
export
interface
AIGenerateRequest
{
systemInstruction
:
string
;
parts
:
AIContentPart
[];
responseJsonSchema
:
Record
<
string
,
unknown
>
;
temperature
?:
number
;
maxOutputTokens
?:
number
;
}
export
interface
AIUsage
{
promptTokens
:
number
|
null
;
completionTokens
:
number
|
null
;
totalTokens
:
number
|
null
;
}
export
interface
AIGenerateResponse
{
data
:
unknown
;
provider
:
string
;
model
:
string
;
usage
:
AIUsage
;
}
export
type
AIProviderErrorReason
=
|
'NOT_CONFIGURED'
|
'UNAVAILABLE'
|
'INVALID_RESPONSE'
;
export
class
AIProviderError
extends
Error
{
constructor
(
public
readonly
reason
:
AIProviderErrorReason
,
message
:
string
)
{
super
(
message
);
Object
.
setPrototypeOf
(
this
,
new
.
target
.
prototype
);
}
}
export
interface
AIProvider
{
readonly
name
:
string
;
readonly
model
:
string
;
generateStructured
(
request
:
AIGenerateRequest
):
Promise
<
AIGenerateResponse
>
;
}
src/common/ai/gemini.provider.ts
0 → 100644
View file @
bc2abecc
import
{
AIGenerateRequest
,
AIGenerateResponse
,
AIProvider
,
AIProviderError
,
}
from
'./ai-provider'
;
interface
GeminiProviderOptions
{
apiKeys
:
string
[];
model
:
string
;
baseUrl
:
string
;
timeoutMs
:
number
;
defaultMaxOutputTokens
:
number
;
}
interface
GeminiResponse
{
candidates
?:
Array
<
{
content
?:
{
parts
?:
Array
<
{
text
?:
string
}
>
;
};
}
>
;
usageMetadata
?:
{
promptTokenCount
?:
number
;
candidatesTokenCount
?:
number
;
totalTokenCount
?:
number
;
};
}
class
GeminiHttpError
extends
Error
{
constructor
(
public
readonly
status
:
number
,
public
readonly
retryable
:
boolean
,
)
{
super
(
`Gemini request failed with status
${
status
}
`
);
}
}
export
class
GeminiProvider
implements
AIProvider
{
readonly
name
=
'gemini'
;
readonly
model
:
string
;
private
readonly
apiKeys
:
string
[];
private
readonly
baseUrl
:
string
;
private
readonly
timeoutMs
:
number
;
private
readonly
defaultMaxOutputTokens
:
number
;
private
nextKeyIndex
=
0
;
constructor
(
options
:
GeminiProviderOptions
)
{
this
.
apiKeys
=
options
.
apiKeys
;
this
.
model
=
options
.
model
;
this
.
baseUrl
=
options
.
baseUrl
.
replace
(
/
\/
$/
,
''
);
this
.
timeoutMs
=
options
.
timeoutMs
;
this
.
defaultMaxOutputTokens
=
options
.
defaultMaxOutputTokens
;
}
async
generateStructured
(
request
:
AIGenerateRequest
):
Promise
<
AIGenerateResponse
>
{
if
(
this
.
apiKeys
.
length
===
0
)
{
throw
new
AIProviderError
(
'NOT_CONFIGURED'
,
'The AI provider has not been configured'
,
);
}
const
startIndex
=
this
.
nextKeyIndex
%
this
.
apiKeys
.
length
;
this
.
nextKeyIndex
=
(
this
.
nextKeyIndex
+
1
)
%
this
.
apiKeys
.
length
;
let
lastError
:
unknown
;
for
(
let
offset
=
0
;
offset
<
this
.
apiKeys
.
length
;
offset
+=
1
)
{
const
keyIndex
=
(
startIndex
+
offset
)
%
this
.
apiKeys
.
length
;
try
{
return
await
this
.
requestWithKey
(
this
.
apiKeys
[
keyIndex
],
request
);
}
catch
(
error
)
{
lastError
=
error
;
if
(
error
instanceof
AIProviderError
&&
error
.
reason
===
'INVALID_RESPONSE'
)
{
throw
error
;
}
if
(
error
instanceof
GeminiHttpError
&&
!
error
.
retryable
)
{
break
;
}
}
}
if
(
lastError
instanceof
AIProviderError
)
{
throw
lastError
;
}
throw
new
AIProviderError
(
'UNAVAILABLE'
,
'The AI provider is temporarily unavailable'
,
);
}
private
async
requestWithKey
(
apiKey
:
string
,
request
:
AIGenerateRequest
,
):
Promise
<
AIGenerateResponse
>
{
const
controller
=
new
AbortController
();
const
timeout
=
setTimeout
(()
=>
controller
.
abort
(),
this
.
timeoutMs
);
try
{
const
response
=
await
fetch
(
`
${
this
.
baseUrl
}
/models/
${
encodeURIComponent
(
this
.
model
)}
:generateContent`
,
{
method
:
'POST'
,
headers
:
{
'Content-Type'
:
'application/json'
,
'x-goog-api-key'
:
apiKey
,
},
body
:
JSON
.
stringify
({
systemInstruction
:
{
parts
:
[{
text
:
request
.
systemInstruction
}],
},
contents
:
[{
role
:
'user'
,
parts
:
request
.
parts
}],
generationConfig
:
{
temperature
:
request
.
temperature
??
0.2
,
maxOutputTokens
:
request
.
maxOutputTokens
??
this
.
defaultMaxOutputTokens
,
responseMimeType
:
'application/json'
,
responseJsonSchema
:
request
.
responseJsonSchema
,
},
}),
signal
:
controller
.
signal
,
},
);
if
(
!
response
.
ok
)
{
const
retryable
=
response
.
status
===
401
||
response
.
status
===
403
||
response
.
status
===
408
||
response
.
status
===
429
||
response
.
status
>=
500
;
throw
new
GeminiHttpError
(
response
.
status
,
retryable
);
}
const
payload
=
await
response
.
json
()
as
GeminiResponse
;
const
text
=
payload
.
candidates
?.[
0
]?.
content
?.
parts
?.
map
((
part
)
=>
part
.
text
??
''
)
.
join
(
''
)
.
trim
();
if
(
!
text
)
{
throw
new
AIProviderError
(
'INVALID_RESPONSE'
,
'The AI provider returned an empty response'
,
);
}
let
data
:
unknown
;
try
{
data
=
JSON
.
parse
(
text
);
}
catch
{
throw
new
AIProviderError
(
'INVALID_RESPONSE'
,
'The AI provider returned malformed structured data'
,
);
}
return
{
data
,
provider
:
this
.
name
,
model
:
this
.
model
,
usage
:
{
promptTokens
:
payload
.
usageMetadata
?.
promptTokenCount
??
null
,
completionTokens
:
payload
.
usageMetadata
?.
candidatesTokenCount
??
null
,
totalTokens
:
payload
.
usageMetadata
?.
totalTokenCount
??
null
,
},
};
}
catch
(
error
)
{
if
(
error
instanceof
AIProviderError
||
error
instanceof
GeminiHttpError
)
{
throw
error
;
}
throw
new
AIProviderError
(
'UNAVAILABLE'
,
'The AI provider request timed out or could not be completed'
,
);
}
finally
{
clearTimeout
(
timeout
);
}
}
}
src/common/errors/error-code.ts
View file @
bc2abecc
...
...
@@ -27,6 +27,10 @@ export const ERROR_CODE = {
FILE_TYPE_UNSUPPORTED
:
'FILE_TYPE_UNSUPPORTED'
,
FILE_TOO_LARGE
:
'FILE_TOO_LARGE'
,
RECEIPT_NOT_FOUND
:
'RECEIPT_NOT_FOUND'
,
AI_PROVIDER_NOT_CONFIGURED
:
'AI_PROVIDER_NOT_CONFIGURED'
,
AI_PROVIDER_UNAVAILABLE
:
'AI_PROVIDER_UNAVAILABLE'
,
AI_RESPONSE_INVALID
:
'AI_RESPONSE_INVALID'
,
AI_RATE_LIMIT_EXCEEDED
:
'AI_RATE_LIMIT_EXCEEDED'
,
CRAWL_JOB_NOT_FOUND
:
'CRAWL_JOB_NOT_FOUND'
,
CRAWL_JOB_ALREADY_COMPLETED
:
'CRAWL_JOB_ALREADY_COMPLETED'
,
PRIVATE_IP_BLOCKED
:
'PRIVATE_IP_BLOCKED'
,
...
...
src/config/env.config.ts
View file @
bc2abecc
...
...
@@ -59,4 +59,40 @@ export const envConfig = {
:
300000
;
})(),
},
ai
:
{
provider
:
process
.
env
.
AI_PROVIDER
||
'gemini'
,
geminiApiKeys
:
Array
.
from
(
new
Set
(
(
process
.
env
.
GEMINI_API_KEYS
||
process
.
env
.
GEMINI_API_KEY
||
''
)
.
split
(
','
)
.
map
((
value
)
=>
value
.
trim
())
.
filter
(
Boolean
),
)),
geminiModel
:
process
.
env
.
GEMINI_MODEL
||
'gemini-2.5-flash'
,
geminiBaseUrl
:
process
.
env
.
GEMINI_API_BASE_URL
||
'https://generativelanguage.googleapis.com/v1beta'
,
requestTimeoutMs
:
(()
=>
{
const
value
=
parseInt
(
process
.
env
.
AI_REQUEST_TIMEOUT_MS
||
'30000'
,
10
);
return
Number
.
isFinite
(
value
)
&&
value
>=
1000
&&
value
<=
120000
?
value
:
30000
;
})(),
maxOutputTokens
:
(()
=>
{
const
value
=
parseInt
(
process
.
env
.
AI_MAX_OUTPUT_TOKENS
||
'2048'
,
10
);
return
Number
.
isFinite
(
value
)
&&
value
>=
256
&&
value
<=
8192
?
value
:
2048
;
})(),
maxContextTransactions
:
(()
=>
{
const
value
=
parseInt
(
process
.
env
.
AI_MAX_CONTEXT_TRANSACTIONS
||
'200'
,
10
);
return
Number
.
isFinite
(
value
)
&&
value
>=
20
&&
value
<=
500
?
value
:
200
;
})(),
rateLimit
:
{
maxRequests
:
(()
=>
{
const
value
=
parseInt
(
process
.
env
.
AI_RATE_LIMIT_MAX_REQUESTS
||
'20'
,
10
);
return
Number
.
isFinite
(
value
)
&&
value
>=
1
&&
value
<=
500
?
value
:
20
;
})(),
windowMs
:
(()
=>
{
const
value
=
parseInt
(
process
.
env
.
AI_RATE_LIMIT_WINDOW_MS
||
'900000'
,
10
);
return
Number
.
isFinite
(
value
)
&&
value
>=
1000
&&
value
<=
86400000
?
value
:
900000
;
})(),
},
},
};
src/config/swagger.config.ts
View file @
bc2abecc
This diff is collapsed.
Click to expand it.
src/middlewares/ai-rate-limit.middleware.ts
0 → 100644
View file @
bc2abecc
import
{
NextFunction
,
Request
,
Response
}
from
'express'
;
import
{
ERROR_CODE
}
from
'../common/errors/error-code'
;
import
{
envConfig
}
from
'../config/env.config'
;
interface
RateLimitRecord
{
count
:
number
;
resetAt
:
number
;
}
const
requestCounts
=
new
Map
<
string
,
RateLimitRecord
>
();
let
lastCleanupAt
=
0
;
export
function
aiRateLimitMiddleware
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
,
):
void
{
const
key
=
req
.
user
?.
id
||
req
.
ip
||
req
.
socket
.
remoteAddress
||
'unknown'
;
const
now
=
Date
.
now
();
if
(
now
-
lastCleanupAt
>=
envConfig
.
ai
.
rateLimit
.
windowMs
)
{
requestCounts
.
forEach
((
value
,
recordKey
)
=>
{
if
(
now
>=
value
.
resetAt
)
{
requestCounts
.
delete
(
recordKey
);
}
});
lastCleanupAt
=
now
;
}
const
record
=
requestCounts
.
get
(
key
);
if
(
!
record
||
now
>=
record
.
resetAt
)
{
requestCounts
.
set
(
key
,
{
count
:
1
,
resetAt
:
now
+
envConfig
.
ai
.
rateLimit
.
windowMs
,
});
next
();
return
;
}
record
.
count
+=
1
;
if
(
record
.
count
<=
envConfig
.
ai
.
rateLimit
.
maxRequests
)
{
next
();
return
;
}
const
retryAfterSeconds
=
Math
.
max
(
1
,
Math
.
ceil
((
record
.
resetAt
-
now
)
/
1000
));
res
.
setHeader
(
'Retry-After'
,
retryAfterSeconds
.
toString
());
res
.
status
(
429
).
json
({
success
:
false
,
message
:
'AI request limit exceeded, please try again later'
,
code
:
ERROR_CODE
.
AI_RATE_LIMIT_EXCEEDED
,
});
}
src/modules/ai-assistant/ai-assistant-provider.schema.ts
0 → 100644
View file @
bc2abecc
export
const
classificationJsonSchema
=
{
type
:
'object'
,
additionalProperties
:
false
,
required
:
[
'categoryId'
,
'confidence'
,
'reasoning'
],
properties
:
{
categoryId
:
{
type
:
'string'
},
confidence
:
{
type
:
'number'
,
minimum
:
0
,
maximum
:
1
},
reasoning
:
{
type
:
'string'
},
},
}
satisfies
Record
<
string
,
unknown
>
;
export
const
receiptJsonSchema
=
{
type
:
'object'
,
additionalProperties
:
false
,
required
:
[
'merchant'
,
'transactionDate'
,
'totalAmount'
,
'currency'
,
'taxAmount'
,
'categoryId'
,
'lineItems'
,
'rawText'
,
'confidence'
,
'warnings'
,
],
properties
:
{
merchant
:
{
type
:
[
'string'
,
'null'
]
},
transactionDate
:
{
type
:
[
'string'
,
'null'
]
},
totalAmount
:
{
type
:
[
'string'
,
'null'
]
},
currency
:
{
type
:
[
'string'
,
'null'
]
},
taxAmount
:
{
type
:
[
'string'
,
'null'
]
},
categoryId
:
{
type
:
[
'string'
,
'null'
]
},
lineItems
:
{
type
:
'array'
,
maxItems
:
100
,
items
:
{
type
:
'object'
,
additionalProperties
:
false
,
required
:
[
'name'
,
'quantity'
,
'unitPrice'
,
'totalAmount'
],
properties
:
{
name
:
{
type
:
'string'
},
quantity
:
{
type
:
[
'number'
,
'null'
],
minimum
:
0
},
unitPrice
:
{
type
:
[
'string'
,
'null'
]
},
totalAmount
:
{
type
:
[
'string'
,
'null'
]
},
},
},
},
rawText
:
{
type
:
'string'
},
confidence
:
{
type
:
'number'
,
minimum
:
0
,
maximum
:
1
},
warnings
:
{
type
:
'array'
,
maxItems
:
10
,
items
:
{
type
:
'string'
}
},
},
}
satisfies
Record
<
string
,
unknown
>
;
export
const
chatJsonSchema
=
{
type
:
'object'
,
additionalProperties
:
false
,
required
:
[
'answer'
,
'highlights'
,
'caveats'
,
'suggestedActions'
],
properties
:
{
answer
:
{
type
:
'string'
},
highlights
:
{
type
:
'array'
,
maxItems
:
8
,
items
:
{
type
:
'string'
}
},
caveats
:
{
type
:
'array'
,
maxItems
:
8
,
items
:
{
type
:
'string'
}
},
suggestedActions
:
{
type
:
'array'
,
maxItems
:
8
,
items
:
{
type
:
'string'
}
},
},
}
satisfies
Record
<
string
,
unknown
>
;
export
const
insightsJsonSchema
=
{
type
:
'object'
,
additionalProperties
:
false
,
required
:
[
'summary'
,
'trends'
,
'anomalies'
,
'recommendations'
],
properties
:
{
summary
:
{
type
:
'string'
},
trends
:
{
type
:
'array'
,
maxItems
:
8
,
items
:
{
type
:
'object'
,
additionalProperties
:
false
,
required
:
[
'title'
,
'direction'
,
'description'
,
'evidence'
],
properties
:
{
title
:
{
type
:
'string'
},
direction
:
{
type
:
'string'
,
enum
:
[
'UP'
,
'DOWN'
,
'STABLE'
]
},
description
:
{
type
:
'string'
},
evidence
:
{
type
:
'string'
},
},
},
},
anomalies
:
{
type
:
'array'
,
maxItems
:
8
,
items
:
{
type
:
'object'
,
additionalProperties
:
false
,
required
:
[
'title'
,
'severity'
,
'description'
,
'evidence'
],
properties
:
{
title
:
{
type
:
'string'
},
severity
:
{
type
:
'string'
,
enum
:
[
'LOW'
,
'MEDIUM'
,
'HIGH'
]
},
description
:
{
type
:
'string'
},
evidence
:
{
type
:
'string'
},
},
},
},
recommendations
:
{
type
:
'array'
,
maxItems
:
8
,
items
:
{
type
:
'object'
,
additionalProperties
:
false
,
required
:
[
'title'
,
'priority'
,
'description'
],
properties
:
{
title
:
{
type
:
'string'
},
priority
:
{
type
:
'string'
,
enum
:
[
'LOW'
,
'MEDIUM'
,
'HIGH'
]
},
description
:
{
type
:
'string'
},
},
},
},
},
}
satisfies
Record
<
string
,
unknown
>
;
export
const
recommendationsJsonSchema
=
{
type
:
'object'
,
additionalProperties
:
false
,
required
:
[
'summary'
,
'budgetRecommendations'
,
'savingRecommendations'
,
'actions'
],
properties
:
{
summary
:
{
type
:
'string'
},
budgetRecommendations
:
{
type
:
'array'
,
maxItems
:
10
,
items
:
{
type
:
'object'
,
additionalProperties
:
false
,
required
:
[
'categoryName'
,
'currency'
,
'suggestedLimit'
,
'rationale'
],
properties
:
{
categoryName
:
{
type
:
[
'string'
,
'null'
]
},
currency
:
{
type
:
'string'
},
suggestedLimit
:
{
type
:
'string'
},
rationale
:
{
type
:
'string'
},
},
},
},
savingRecommendations
:
{
type
:
'array'
,
maxItems
:
10
,
items
:
{
type
:
'object'
,
additionalProperties
:
false
,
required
:
[
'goalName'
,
'currency'
,
'suggestedMonthlyContribution'
,
'rationale'
],
properties
:
{
goalName
:
{
type
:
[
'string'
,
'null'
]
},
currency
:
{
type
:
'string'
},
suggestedMonthlyContribution
:
{
type
:
'string'
},
rationale
:
{
type
:
'string'
},
},
},
},
actions
:
{
type
:
'array'
,
maxItems
:
10
,
items
:
{
type
:
'object'
,
additionalProperties
:
false
,
required
:
[
'title'
,
'priority'
,
'description'
],
properties
:
{
title
:
{
type
:
'string'
},
priority
:
{
type
:
'string'
,
enum
:
[
'LOW'
,
'MEDIUM'
,
'HIGH'
]
},
description
:
{
type
:
'string'
},
},
},
},
},
}
satisfies
Record
<
string
,
unknown
>
;
src/modules/ai-assistant/ai-assistant-response.validation.ts
0 → 100644
View file @
bc2abecc
import
{
z
}
from
'zod'
;
const
MONEY_PATTERN
=
/^
(?:
0|
[
1-9
]\d{0,15})(?:\.\d{1,2})?
$/
;
export
type
AIResponseValidator
<
T
>
=
z
.
ZodType
<
T
>
;
export
const
classificationResponseSchema
=
z
.
object
({
categoryId
:
z
.
string
().
uuid
(),
confidence
:
z
.
number
().
min
(
0
).
max
(
1
),
reasoning
:
z
.
string
().
trim
().
min
(
1
).
max
(
1000
),
});
export
const
receiptResponseSchema
=
z
.
object
({
merchant
:
z
.
string
().
trim
().
max
(
300
).
nullable
(),
transactionDate
:
z
.
string
().
regex
(
/^
\d{4}
-
\d{2}
-
\d{2}
$/
).
nullable
(),
totalAmount
:
z
.
string
().
regex
(
MONEY_PATTERN
).
nullable
(),
currency
:
z
.
string
().
regex
(
/^
[
A-Z
]{3}
$/
).
nullable
(),
taxAmount
:
z
.
string
().
regex
(
MONEY_PATTERN
).
nullable
(),
categoryId
:
z
.
string
().
uuid
().
nullable
(),
lineItems
:
z
.
array
(
z
.
object
({
name
:
z
.
string
().
trim
().
min
(
1
).
max
(
300
),
quantity
:
z
.
number
().
nonnegative
().
nullable
(),
unitPrice
:
z
.
string
().
regex
(
MONEY_PATTERN
).
nullable
(),
totalAmount
:
z
.
string
().
regex
(
MONEY_PATTERN
).
nullable
(),
})).
max
(
100
),
rawText
:
z
.
string
().
max
(
8000
),
confidence
:
z
.
number
().
min
(
0
).
max
(
1
),
warnings
:
z
.
array
(
z
.
string
().
trim
().
min
(
1
).
max
(
500
)).
max
(
10
),
});
export
const
chatResponseSchema
=
z
.
object
({
answer
:
z
.
string
().
trim
().
min
(
1
).
max
(
5000
),
highlights
:
z
.
array
(
z
.
string
().
trim
().
min
(
1
).
max
(
500
)).
max
(
8
),
caveats
:
z
.
array
(
z
.
string
().
trim
().
min
(
1
).
max
(
500
)).
max
(
8
),
suggestedActions
:
z
.
array
(
z
.
string
().
trim
().
min
(
1
).
max
(
500
)).
max
(
8
),
});
export
const
insightsResponseSchema
=
z
.
object
({
summary
:
z
.
string
().
trim
().
min
(
1
).
max
(
3000
),
trends
:
z
.
array
(
z
.
object
({
title
:
z
.
string
().
trim
().
min
(
1
).
max
(
200
),
direction
:
z
.
enum
([
'UP'
,
'DOWN'
,
'STABLE'
]),
description
:
z
.
string
().
trim
().
min
(
1
).
max
(
1000
),
evidence
:
z
.
string
().
trim
().
min
(
1
).
max
(
1000
),
})).
max
(
8
),
anomalies
:
z
.
array
(
z
.
object
({
title
:
z
.
string
().
trim
().
min
(
1
).
max
(
200
),
severity
:
z
.
enum
([
'LOW'
,
'MEDIUM'
,
'HIGH'
]),
description
:
z
.
string
().
trim
().
min
(
1
).
max
(
1000
),
evidence
:
z
.
string
().
trim
().
min
(
1
).
max
(
1000
),
})).
max
(
8
),
recommendations
:
z
.
array
(
z
.
object
({
title
:
z
.
string
().
trim
().
min
(
1
).
max
(
200
),
priority
:
z
.
enum
([
'LOW'
,
'MEDIUM'
,
'HIGH'
]),
description
:
z
.
string
().
trim
().
min
(
1
).
max
(
1000
),
})).
max
(
8
),
});
export
const
recommendationsResponseSchema
=
z
.
object
({
summary
:
z
.
string
().
trim
().
min
(
1
).
max
(
3000
),
budgetRecommendations
:
z
.
array
(
z
.
object
({
categoryName
:
z
.
string
().
trim
().
min
(
1
).
max
(
200
).
nullable
(),
currency
:
z
.
string
().
regex
(
/^
[
A-Z
]{3}
$/
),
suggestedLimit
:
z
.
string
().
regex
(
MONEY_PATTERN
),
rationale
:
z
.
string
().
trim
().
min
(
1
).
max
(
1000
),
})).
max
(
10
),
savingRecommendations
:
z
.
array
(
z
.
object
({
goalName
:
z
.
string
().
trim
().
min
(
1
).
max
(
200
).
nullable
(),
currency
:
z
.
string
().
regex
(
/^
[
A-Z
]{3}
$/
),
suggestedMonthlyContribution
:
z
.
string
().
regex
(
MONEY_PATTERN
),
rationale
:
z
.
string
().
trim
().
min
(
1
).
max
(
1000
),
})).
max
(
10
),
actions
:
z
.
array
(
z
.
object
({
title
:
z
.
string
().
trim
().
min
(
1
).
max
(
200
),
priority
:
z
.
enum
([
'LOW'
,
'MEDIUM'
,
'HIGH'
]),
description
:
z
.
string
().
trim
().
min
(
1
).
max
(
1000
),
})).
max
(
10
),
});
src/modules/ai-assistant/ai-assistant.controller.ts
0 → 100644
View file @
bc2abecc
import
{
NextFunction
,
Request
,
Response
}
from
'express'
;
import
{
CategorizeTransactionDto
,
ExtractReceiptDto
,
FinancialChatDto
,
FinancialInsightsDto
,
FinancialRecommendationsDto
,
}
from
'./ai-assistant.dto'
;
import
{
AIAssistantService
}
from
'./ai-assistant.service'
;
export
class
AIAssistantController
{
private
readonly
service
=
new
AIAssistantService
();
categorizeTransaction
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
,
)
=>
{
try
{
const
result
=
await
this
.
service
.
categorizeTransaction
(
req
.
user
.
id
,
req
.
body
as
CategorizeTransactionDto
,
);
res
.
json
({
success
:
true
,
...
result
});
}
catch
(
error
)
{
next
(
error
);
}
};
extractReceipt
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
const
result
=
await
this
.
service
.
extractReceipt
(
req
.
user
.
id
,
req
.
file
!
,
req
.
body
as
ExtractReceiptDto
,
);
res
.
json
({
success
:
true
,
...
result
});
}
catch
(
error
)
{
next
(
error
);
}
};
chat
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
const
result
=
await
this
.
service
.
chat
(
req
.
user
.
id
,
req
.
body
as
FinancialChatDto
,
);
res
.
json
({
success
:
true
,
...
result
});
}
catch
(
error
)
{
next
(
error
);
}
};
analyzeInsights
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
const
result
=
await
this
.
service
.
analyzeInsights
(
req
.
user
.
id
,
req
.
body
as
FinancialInsightsDto
,
);
res
.
json
({
success
:
true
,
...
result
});
}
catch
(
error
)
{
next
(
error
);
}
};
recommend
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
const
result
=
await
this
.
service
.
recommend
(
req
.
user
.
id
,
req
.
body
as
FinancialRecommendationsDto
,
);
res
.
json
({
success
:
true
,
...
result
});
}
catch
(
error
)
{
next
(
error
);
}
};
}
src/modules/ai-assistant/ai-assistant.dto.ts
0 → 100644
View file @
bc2abecc
import
{
TransactionType
}
from
'@prisma/client'
;
import
{
AIUsage
}
from
'../../common/ai/ai-provider'
;
export
interface
AIAnalysisScopeDto
{
dateFrom
?:
Date
;
dateTo
?:
Date
;
currency
?:
string
;
}
export
interface
CategorizeTransactionDto
{
description
:
string
;
amount
?:
string
;
type
?:
TransactionType
;
merchant
?:
string
;
occurredAt
?:
Date
;
}
export
interface
ExtractReceiptDto
{
languageHint
?:
string
;
currencyHint
?:
string
;
}
export
interface
FinancialChatDto
extends
AIAnalysisScopeDto
{
question
:
string
;
}
export
interface
FinancialInsightsDto
extends
AIAnalysisScopeDto
{
focus
:
'ALL'
|
'SPENDING'
|
'INCOME'
|
'CASH_FLOW'
;
}
export
interface
FinancialRecommendationsDto
extends
AIAnalysisScopeDto
{
priority
:
'BALANCED'
|
'REDUCE_SPENDING'
|
'GROW_SAVINGS'
;
}
export
interface
AIResponseMetaDto
{
provider
:
string
;
model
:
string
;
usage
:
AIUsage
;
context
?:
{
from
:
Date
;
to
:
Date
;
currency
:
string
|
null
;
transactionCount
:
number
;
totalTransactionCount
:
number
;
truncated
:
boolean
;
};
}
export
interface
AIServiceResult
<
T
>
{
data
:
T
;
meta
:
AIResponseMetaDto
;
}
src/modules/ai-assistant/ai-assistant.repository.ts
0 → 100644
View file @
bc2abecc
import
{
Prisma
,
TransactionType
}
from
'@prisma/client'
;
import
{
prisma
}
from
'../../database/prisma.client'
;
const
aiCategorySelect
=
{
id
:
true
,
name
:
true
,
type
:
true
,
isSystem
:
true
,
}
satisfies
Prisma
.
CategorySelect
;
const
aiWalletSelect
=
{
id
:
true
,
name
:
true
,
balance
:
true
,
currency
:
true
,
isDefault
:
true
,
isArchived
:
true
,
}
satisfies
Prisma
.
WalletSelect
;
const
aiTransactionSelect
=
{
walletId
:
true
,
categoryId
:
true
,
amount
:
true
,
type
:
true
,
description
:
true
,
date
:
true
,
wallet
:
{
select
:
{
name
:
true
,
currency
:
true
,
},
},
category
:
{
select
:
{
id
:
true
,
name
:
true
,
},
},
}
satisfies
Prisma
.
TransactionSelect
;
const
aiBudgetSelect
=
{
name
:
true
,
amount
:
true
,
currency
:
true
,
type
:
true
,
startDate
:
true
,
endDate
:
true
,
alertThreshold
:
true
,
category
:
{
select
:
{
name
:
true
}
},
}
satisfies
Prisma
.
BudgetSelect
;
const
aiSavingGoalSelect
=
{
id
:
true
,
name
:
true
,
targetAmount
:
true
,
currency
:
true
,
targetDate
:
true
,
status
:
true
,
}
satisfies
Prisma
.
SavingGoalSelect
;
export
type
AICategoryRecord
=
Prisma
.
CategoryGetPayload
<
{
select
:
typeof
aiCategorySelect
;
}
>
;
export
type
AIWalletRecord
=
Prisma
.
WalletGetPayload
<
{
select
:
typeof
aiWalletSelect
;
}
>
;
export
type
AITransactionRecord
=
Prisma
.
TransactionGetPayload
<
{
select
:
typeof
aiTransactionSelect
;
}
>
;
export
type
AIBudgetRecord
=
Prisma
.
BudgetGetPayload
<
{
select
:
typeof
aiBudgetSelect
;
}
>
;
export
type
AISavingGoalRecord
=
Prisma
.
SavingGoalGetPayload
<
{
select
:
typeof
aiSavingGoalSelect
;
}
>
;
export
interface
AISavingContributionSummary
{
savingGoalId
:
string
;
amount
:
Prisma
.
Decimal
;
}
export
interface
AIFinancialContextRecord
{
wallets
:
AIWalletRecord
[];
categories
:
AICategoryRecord
[];
transactions
:
AITransactionRecord
[];
totalTransactionCount
:
number
;
transactionSummaries
:
Array
<
{
walletId
:
string
;
categoryId
:
string
;
type
:
TransactionType
;
amount
:
Prisma
.
Decimal
;
count
:
number
;
}
>
;
budgets
:
AIBudgetRecord
[];
totalBudgetCount
:
number
;
savingGoals
:
AISavingGoalRecord
[];
totalSavingGoalCount
:
number
;
contributionSummaries
:
AISavingContributionSummary
[];
}
export
class
AIAssistantRepository
{
findVisibleCategories
(
userId
:
string
,
type
?:
TransactionType
,
limit
=
200
)
{
return
prisma
.
category
.
findMany
({
where
:
{
isArchived
:
false
,
...(
type
?
{
type
}
:
{}),
OR
:
[
{
userId
,
isSystem
:
false
},
{
userId
:
null
,
isSystem
:
true
},
],
},
select
:
aiCategorySelect
,
orderBy
:
[{
type
:
'asc'
},
{
name
:
'asc'
},
{
id
:
'asc'
}],
take
:
limit
,
});
}
async
getFinancialContext
(
userId
:
string
,
from
:
Date
,
to
:
Date
,
maxTransactions
:
number
,
currency
?:
string
,
):
Promise
<
AIFinancialContextRecord
>
{
const
transactionWhere
:
Prisma
.
TransactionWhereInput
=
{
userId
,
date
:
{
gte
:
from
,
lt
:
to
},
...(
currency
?
{
wallet
:
{
currency
}
}
:
{}),
};
const
[
wallets
,
categories
,
transactions
,
totalTransactionCount
,
groupedTransactions
,
budgets
,
totalBudgetCount
,
savingGoals
,
totalSavingGoalCount
,
]
=
await
Promise
.
all
([
prisma
.
wallet
.
findMany
({
where
:
{
userId
,
...(
currency
?
{
currency
}
:
{}),
},
select
:
aiWalletSelect
,
orderBy
:
[{
isDefault
:
'desc'
},
{
name
:
'asc'
}],
}),
this
.
findVisibleCategories
(
userId
,
undefined
,
500
),
prisma
.
transaction
.
findMany
({
where
:
transactionWhere
,
select
:
aiTransactionSelect
,
orderBy
:
[{
date
:
'desc'
},
{
id
:
'desc'
}],
take
:
maxTransactions
,
}),
prisma
.
transaction
.
count
({
where
:
transactionWhere
}),
prisma
.
transaction
.
groupBy
({
by
:
[
'walletId'
,
'categoryId'
,
'type'
],
where
:
transactionWhere
,
_sum
:
{
amount
:
true
},
_count
:
{
_all
:
true
},
}),
prisma
.
budget
.
findMany
({
where
:
{
userId
,
isArchived
:
false
,
startDate
:
{
lt
:
to
},
endDate
:
{
gt
:
from
},
...(
currency
?
{
currency
}
:
{}),
},
select
:
aiBudgetSelect
,
orderBy
:
[{
endDate
:
'asc'
},
{
id
:
'asc'
}],
take
:
100
,
}),
prisma
.
budget
.
count
({
where
:
{
userId
,
isArchived
:
false
,
startDate
:
{
lt
:
to
},
endDate
:
{
gt
:
from
},
...(
currency
?
{
currency
}
:
{}),
},
}),
prisma
.
savingGoal
.
findMany
({
where
:
{
userId
,
isArchived
:
false
,
...(
currency
?
{
currency
}
:
{}),
},
select
:
aiSavingGoalSelect
,
orderBy
:
[{
targetDate
:
'asc'
},
{
id
:
'asc'
}],
take
:
100
,
}),
prisma
.
savingGoal
.
count
({
where
:
{
userId
,
isArchived
:
false
,
...(
currency
?
{
currency
}
:
{}),
},
}),
]);
const
goalIds
=
savingGoals
.
map
((
goal
)
=>
goal
.
id
);
const
groupedContributions
=
goalIds
.
length
===
0
?
[]
:
await
prisma
.
savingContribution
.
groupBy
({
by
:
[
'savingGoalId'
],
where
:
{
savingGoalId
:
{
in
:
goalIds
}
},
_sum
:
{
amount
:
true
},
});
return
{
wallets
,
categories
,
transactions
,
totalTransactionCount
,
transactionSummaries
:
groupedTransactions
.
map
((
item
)
=>
({
walletId
:
item
.
walletId
,
categoryId
:
item
.
categoryId
,
type
:
item
.
type
,
amount
:
item
.
_sum
.
amount
??
new
Prisma
.
Decimal
(
0
),
count
:
item
.
_count
.
_all
,
})),
budgets
,
totalBudgetCount
,
savingGoals
,
totalSavingGoalCount
,
contributionSummaries
:
groupedContributions
.
map
((
item
)
=>
({
savingGoalId
:
item
.
savingGoalId
,
amount
:
item
.
_sum
.
amount
??
new
Prisma
.
Decimal
(
0
),
})),
};
}
}
src/modules/ai-assistant/ai-assistant.route.ts
0 → 100644
View file @
bc2abecc
import
{
Router
}
from
'express'
;
import
{
aiRateLimitMiddleware
}
from
'../../middlewares/ai-rate-limit.middleware'
;
import
{
authMiddleware
}
from
'../../middlewares/auth.middleware'
;
import
{
validate
}
from
'../../middlewares/validate.middleware'
;
import
{
receiptUploadMiddleware
}
from
'../transactions/transaction-upload.middleware'
;
import
{
AIAssistantController
}
from
'./ai-assistant.controller'
;
import
{
categorizeTransactionSchema
,
extractReceiptSchema
,
financialChatSchema
,
financialInsightsSchema
,
financialRecommendationsSchema
,
}
from
'./ai-assistant.validation'
;
const
router
=
Router
();
const
controller
=
new
AIAssistantController
();
router
.
use
(
authMiddleware
);
router
.
use
(
aiRateLimitMiddleware
);
router
.
post
(
'/categorize'
,
validate
(
categorizeTransactionSchema
),
controller
.
categorizeTransaction
,
);
router
.
post
(
'/receipts/extract'
,
receiptUploadMiddleware
,
validate
(
extractReceiptSchema
),
controller
.
extractReceipt
,
);
router
.
post
(
'/chat'
,
validate
(
financialChatSchema
),
controller
.
chat
);
router
.
post
(
'/insights/analyze'
,
validate
(
financialInsightsSchema
),
controller
.
analyzeInsights
,
);
router
.
post
(
'/recommendations'
,
validate
(
financialRecommendationsSchema
),
controller
.
recommend
,
);
export
default
router
;
src/modules/ai-assistant/ai-assistant.service.ts
0 → 100644
View file @
bc2abecc
This diff is collapsed.
Click to expand it.
src/modules/ai-assistant/ai-assistant.validation.ts
0 → 100644
View file @
bc2abecc
import
{
z
}
from
'zod'
;
const
moneySchema
=
z
.
string
()
.
trim
()
.
regex
(
/^
(?:
0|
[
1-9
]\d{0,15})(?:\.\d{1,2})?
$/
,
'Amount must be a positive decimal value'
);
const
optionalDateSchema
=
z
.
preprocess
(
(
value
)
=>
value
===
''
?
undefined
:
value
,
z
.
string
()
.
datetime
({
offset
:
true
,
message
:
'Date must be a valid ISO 8601 date-time'
})
.
transform
((
value
)
=>
new
Date
(
value
))
.
optional
(),
);
const
optionalCurrencySchema
=
z
.
preprocess
(
(
value
)
=>
value
===
''
?
undefined
:
value
,
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
(),
);
const
analysisScopeShape
=
{
dateFrom
:
optionalDateSchema
,
dateTo
:
optionalDateSchema
,
currency
:
optionalCurrencySchema
,
};
function
validateDateRange
(
data
:
{
dateFrom
?:
Date
;
dateTo
?:
Date
},
context
:
z
.
RefinementCtx
,
)
{
if
((
data
.
dateFrom
&&
!
data
.
dateTo
)
||
(
!
data
.
dateFrom
&&
data
.
dateTo
))
{
context
.
addIssue
({
code
:
z
.
ZodIssueCode
.
custom
,
path
:
[
data
.
dateFrom
?
'dateTo'
:
'dateFrom'
],
message
:
'dateFrom and dateTo must be provided together'
,
});
}
if
(
data
.
dateFrom
&&
data
.
dateTo
&&
data
.
dateFrom
>=
data
.
dateTo
)
{
context
.
addIssue
({
code
:
z
.
ZodIssueCode
.
custom
,
path
:
[
'dateTo'
],
message
:
'dateTo must be after dateFrom'
,
});
}
}
export
const
categorizeTransactionSchema
=
z
.
object
({
description
:
z
.
string
().
trim
().
min
(
2
).
max
(
500
),
amount
:
moneySchema
.
optional
(),
type
:
z
.
enum
([
'INCOME'
,
'EXPENSE'
]).
optional
(),
merchant
:
z
.
string
().
trim
().
min
(
1
).
max
(
200
).
optional
(),
occurredAt
:
optionalDateSchema
,
});
export
const
extractReceiptSchema
=
z
.
object
({
languageHint
:
z
.
preprocess
(
(
value
)
=>
value
===
''
?
undefined
:
value
,
z
.
string
().
trim
().
min
(
2
).
max
(
20
).
optional
(),
),
currencyHint
:
optionalCurrencySchema
,
});
export
const
financialChatSchema
=
z
.
object
({
...
analysisScopeShape
,
question
:
z
.
string
().
trim
().
min
(
3
).
max
(
1000
),
})
.
superRefine
(
validateDateRange
);
export
const
financialInsightsSchema
=
z
.
object
({
...
analysisScopeShape
,
focus
:
z
.
enum
([
'ALL'
,
'SPENDING'
,
'INCOME'
,
'CASH_FLOW'
])
.
optional
()
.
default
(
'ALL'
),
})
.
superRefine
(
validateDateRange
);
export
const
financialRecommendationsSchema
=
z
.
object
({
...
analysisScopeShape
,
priority
:
z
.
enum
([
'BALANCED'
,
'REDUCE_SPENDING'
,
'GROW_SAVINGS'
])
.
optional
()
.
default
(
'BALANCED'
),
})
.
superRefine
(
validateDateRange
);
src/routes/index.ts
View file @
bc2abecc
...
...
@@ -9,6 +9,7 @@ import savingGoalRoute from '../modules/saving-goals/saving-goal.route';
import
reportRoute
from
'../modules/reports/report.route'
;
import
notificationRoute
from
'../modules/notifications/notification.route'
;
import
reminderRoute
from
'../modules/reminders/reminder.route'
;
import
aiAssistantRoute
from
'../modules/ai-assistant/ai-assistant.route'
;
const
router
=
Router
();
...
...
@@ -26,5 +27,6 @@ router.use('/saving-goals', savingGoalRoute);
router
.
use
(
'/reports'
,
reportRoute
);
router
.
use
(
'/notifications'
,
notificationRoute
);
router
.
use
(
'/reminders'
,
reminderRoute
);
router
.
use
(
'/ai-assistant'
,
aiAssistantRoute
);
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