Commit aea9be81 authored by ThinhNC's avatar ThinhNC

feat(transactions): add transaction management and receipt uploads

parent 70acfd22
...@@ -15,6 +15,10 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê ...@@ -15,6 +15,10 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
- Package manager chuẩn là pnpm. - Package manager chuẩn là pnpm.
- Category hệ thống là dữ liệu dùng chung (`userId = null`, `isSystem = true`) và chỉ đọc - Category hệ thống là dữ liệu dùng chung (`userId = null`, `isSystem = true`) và chỉ đọc
qua API; category người dùng được kiểm soát ownership, dùng archive thay cho xóa vật lý. qua API; category người dùng được kiểm soát ownership, dùng archive thay cho xóa vật lý.
- Transaction dùng số tiền dương và `type` để xác định chiều biến động số dư; create,
update và delete giao dịch cập nhật Wallet trong Prisma transaction mức Serializable.
- Hóa đơn Transaction được lưu cục bộ dưới `storage/receipts`, chỉ đọc qua API có auth;
hỗ trợ JPEG, PNG, WebP, PDF và giới hạn mặc định 5 MB.
## Trạng thái đã biết ## Trạng thái đã biết
...@@ -25,10 +29,12 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê ...@@ -25,10 +29,12 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
`env.config.ts` mặc định `8888`, `.env.example` dùng `7777`. `env.config.ts` mặc định `8888`, `.env.example` dùng `7777`.
- README và một số chuỗi tiếng Việt đang có dấu hiệu sai encoding. Không mở rộng - README và một số chuỗi tiếng Việt đang có dấu hiệu sai encoding. Không mở rộng
phạm vi để sửa hàng loạt nếu công việc hiện tại không yêu cầu. phạm vi để sửa hàng loạt nếu công việc hiện tại không yêu cầu.
- Transaction và Budget chưa được expose qua route. Wallet và Category đã có API theo - Budget chưa được expose qua route. Wallet, Category và Transaction đã có API theo
ownership; Category đồng thời trả các category hệ thống dùng chung. ownership; Category đồng thời trả các category hệ thống dùng chung.
- Các migration `20260728170000_improve_wallet_management` - Các migration `20260728170000_improve_wallet_management`
`20260728190000_add_category_management` đồng bộ thay đổi của Wallet và Category. `20260728190000_add_category_management` đồng bộ thay đổi của Wallet và Category;
`20260728210000_add_transaction_management` đồng bộ Decimal, receipt/location và index
của Transaction.
Migration history cũ vẫn chưa phản ánh đầy đủ các thay đổi schema của auth và Migration history cũ vẫn chưa phản ánh đầy đủ các thay đổi schema của auth và
Budget đã được commit trước đó. Budget đã được commit trước đó.
......
...@@ -26,4 +26,7 @@ APP_URL=http://localhost:7777 ...@@ -26,4 +26,7 @@ APP_URL=http://localhost:7777
TRUST_PROXY=false TRUST_PROXY=false
ALLOWED_ORIGINS=http://localhost:7777,http://localhost:3000,http://localhost:5173 ALLOWED_ORIGINS=http://localhost:7777,http://localhost:3000,http://localhost:5173
\ No newline at end of file
RECEIPT_UPLOAD_DIR=storage/receipts
RECEIPT_MAX_FILE_SIZE_MB=5
...@@ -4,5 +4,7 @@ dist/ ...@@ -4,5 +4,7 @@ dist/
*.log *.log
storage/exports/* storage/exports/*
!storage/exports/.gitkeep !storage/exports/.gitkeep
storage/receipts/*
!storage/receipts/.gitkeep
.DS_Store .DS_Store
.agents/local.md .agents/local.md
...@@ -35,6 +35,9 @@ importers: ...@@ -35,6 +35,9 @@ importers:
morgan: morgan:
specifier: ^1.10.0 specifier: ^1.10.0
version: 1.11.0 version: 1.11.0
multer:
specifier: ^2.2.0
version: 2.2.0
nodemailer: nodemailer:
specifier: ^9.0.3 specifier: ^9.0.3
version: 9.0.3 version: 9.0.3
...@@ -63,6 +66,9 @@ importers: ...@@ -63,6 +66,9 @@ importers:
'@types/morgan': '@types/morgan':
specifier: ^1.9.9 specifier: ^1.9.9
version: 1.9.10 version: 1.9.10
'@types/multer':
specifier: ^2.2.0
version: 2.2.0
'@types/node': '@types/node':
specifier: ^22.10.2 specifier: ^22.10.2
version: 22.20.0 version: 22.20.0
...@@ -407,6 +413,9 @@ packages: ...@@ -407,6 +413,9 @@ packages:
'@types/ms@2.1.0': '@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/multer@2.2.0':
resolution: {integrity: sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==}
'@types/node@22.20.0': '@types/node@22.20.0':
resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==}
...@@ -466,6 +475,9 @@ packages: ...@@ -466,6 +475,9 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
append-field@1.0.0:
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
arg@4.1.3: arg@4.1.3:
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
...@@ -506,6 +518,10 @@ packages: ...@@ -506,6 +518,10 @@ packages:
buffer-from@1.1.2: buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
busboy@1.6.0:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'}
bytes@3.1.2: bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
...@@ -540,6 +556,10 @@ packages: ...@@ -540,6 +556,10 @@ packages:
concat-map@0.0.1: concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
concat-stream@2.0.0:
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
engines: {'0': node >= 6.0}
content-disposition@0.5.4: content-disposition@0.5.4:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
...@@ -959,6 +979,10 @@ packages: ...@@ -959,6 +979,10 @@ packages:
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
multer@2.2.0:
resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==}
engines: {node: '>= 10.16.0'}
natural-compare@1.4.0: natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
...@@ -1069,6 +1093,10 @@ packages: ...@@ -1069,6 +1093,10 @@ packages:
resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readdirp@3.6.0: readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'} engines: {node: '>=8.10.0'}
...@@ -1147,6 +1175,13 @@ packages: ...@@ -1147,6 +1175,13 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
streamsearch@1.1.0:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-bom@3.0.0: strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'} engines: {node: '>=4'}
...@@ -1229,6 +1264,9 @@ packages: ...@@ -1229,6 +1264,9 @@ packages:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
typescript@5.9.3: typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
...@@ -1244,6 +1282,9 @@ packages: ...@@ -1244,6 +1282,9 @@ packages:
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
utils-merge@1.0.1: utils-merge@1.0.1:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'} engines: {node: '>= 0.4.0'}
...@@ -1524,6 +1565,10 @@ snapshots: ...@@ -1524,6 +1565,10 @@ snapshots:
'@types/ms@2.1.0': {} '@types/ms@2.1.0': {}
'@types/multer@2.2.0':
dependencies:
'@types/express': 4.17.25
'@types/node@22.20.0': '@types/node@22.20.0':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
...@@ -1591,6 +1636,8 @@ snapshots: ...@@ -1591,6 +1636,8 @@ snapshots:
normalize-path: 3.0.0 normalize-path: 3.0.0
picomatch: 2.3.2 picomatch: 2.3.2
append-field@1.0.0: {}
arg@4.1.3: {} arg@4.1.3: {}
argparse@2.0.1: {} argparse@2.0.1: {}
...@@ -1637,6 +1684,10 @@ snapshots: ...@@ -1637,6 +1684,10 @@ snapshots:
buffer-from@1.1.2: {} buffer-from@1.1.2: {}
busboy@1.6.0:
dependencies:
streamsearch: 1.1.0
bytes@3.1.2: {} bytes@3.1.2: {}
call-bind-apply-helpers@1.0.2: call-bind-apply-helpers@1.0.2:
...@@ -1676,6 +1727,13 @@ snapshots: ...@@ -1676,6 +1727,13 @@ snapshots:
concat-map@0.0.1: {} concat-map@0.0.1: {}
concat-stream@2.0.0:
dependencies:
buffer-from: 1.1.2
inherits: 2.0.4
readable-stream: 3.6.2
typedarray: 0.0.6
content-disposition@0.5.4: content-disposition@0.5.4:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
...@@ -2136,6 +2194,13 @@ snapshots: ...@@ -2136,6 +2194,13 @@ snapshots:
ms@2.1.3: {} ms@2.1.3: {}
multer@2.2.0:
dependencies:
append-field: 1.0.0
busboy: 1.6.0
concat-stream: 2.0.0
type-is: 1.6.18
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
negotiator@0.6.3: {} negotiator@0.6.3: {}
...@@ -2224,6 +2289,12 @@ snapshots: ...@@ -2224,6 +2289,12 @@ snapshots:
iconv-lite: 0.4.24 iconv-lite: 0.4.24
unpipe: 1.0.0 unpipe: 1.0.0
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
readdirp@3.6.0: readdirp@3.6.0:
dependencies: dependencies:
picomatch: 2.3.2 picomatch: 2.3.2
...@@ -2321,6 +2392,12 @@ snapshots: ...@@ -2321,6 +2392,12 @@ snapshots:
statuses@2.0.2: {} statuses@2.0.2: {}
streamsearch@1.1.0: {}
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
strip-bom@3.0.0: {} strip-bom@3.0.0: {}
strip-json-comments@2.0.1: {} strip-json-comments@2.0.1: {}
...@@ -2408,6 +2485,8 @@ snapshots: ...@@ -2408,6 +2485,8 @@ snapshots:
media-typer: 0.3.0 media-typer: 0.3.0
mime-types: 2.1.35 mime-types: 2.1.35
typedarray@0.0.6: {}
typescript@5.9.3: {} typescript@5.9.3: {}
undici-types@6.21.0: {} undici-types@6.21.0: {}
...@@ -2418,6 +2497,8 @@ snapshots: ...@@ -2418,6 +2497,8 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
util-deprecate@1.0.2: {}
utils-merge@1.0.1: {} utils-merge@1.0.1: {}
v8-compile-cache-lib@3.0.1: {} v8-compile-cache-lib@3.0.1: {}
......
-- Store transaction money with exact decimal precision and add receipt metadata.
ALTER TABLE "transactions"
ALTER COLUMN "amount" SET DATA TYPE DECIMAL(18, 2)
USING "amount"::DECIMAL(18, 2),
ADD COLUMN "receipt_url" TEXT,
ADD COLUMN "location" TEXT;
CREATE INDEX "transactions_user_id_idx"
ON "transactions"("user_id");
CREATE INDEX "transactions_wallet_id_idx"
ON "transactions"("wallet_id");
CREATE INDEX "transactions_category_id_idx"
ON "transactions"("category_id");
CREATE INDEX "transactions_date_idx"
ON "transactions"("date");
CREATE INDEX "transactions_user_id_date_idx"
ON "transactions"("user_id", "date");
CREATE INDEX "transactions_user_id_type_idx"
ON "transactions"("user_id", "type");
...@@ -16,6 +16,10 @@ export const ERROR_CODE = { ...@@ -16,6 +16,10 @@ export const ERROR_CODE = {
CATEGORY_PARENT_INVALID: 'CATEGORY_PARENT_INVALID', CATEGORY_PARENT_INVALID: 'CATEGORY_PARENT_INVALID',
CATEGORY_CYCLE: 'CATEGORY_CYCLE', CATEGORY_CYCLE: 'CATEGORY_CYCLE',
CATEGORY_IN_USE: 'CATEGORY_IN_USE', CATEGORY_IN_USE: 'CATEGORY_IN_USE',
TRANSACTION_CATEGORY_TYPE_MISMATCH: 'TRANSACTION_CATEGORY_TYPE_MISMATCH',
FILE_TYPE_UNSUPPORTED: 'FILE_TYPE_UNSUPPORTED',
FILE_TOO_LARGE: 'FILE_TOO_LARGE',
RECEIPT_NOT_FOUND: 'RECEIPT_NOT_FOUND',
CRAWL_JOB_NOT_FOUND: 'CRAWL_JOB_NOT_FOUND', CRAWL_JOB_NOT_FOUND: 'CRAWL_JOB_NOT_FOUND',
CRAWL_JOB_ALREADY_COMPLETED: 'CRAWL_JOB_ALREADY_COMPLETED', CRAWL_JOB_ALREADY_COMPLETED: 'CRAWL_JOB_ALREADY_COMPLETED',
PRIVATE_IP_BLOCKED: 'PRIVATE_IP_BLOCKED', PRIVATE_IP_BLOCKED: 'PRIVATE_IP_BLOCKED',
......
...@@ -31,4 +31,14 @@ export const envConfig = { ...@@ -31,4 +31,14 @@ export const envConfig = {
? process.env.ALLOWED_ORIGINS.split(',').map(s => s.trim()) ? process.env.ALLOWED_ORIGINS.split(',').map(s => s.trim())
: ['*'], : ['*'],
}, },
receipts: {
uploadDir: process.env.RECEIPT_UPLOAD_DIR || 'storage/receipts',
maxFileSizeMb: (() => {
const value = parseInt(process.env.RECEIPT_MAX_FILE_SIZE_MB || '5', 10);
return Number.isFinite(value) && value > 0 && value <= 25 ? value : 5;
})(),
get maxFileSizeBytes() {
return this.maxFileSizeMb * 1024 * 1024;
},
},
}; };
...@@ -344,6 +344,114 @@ export const swaggerSpec = { ...@@ -344,6 +344,114 @@ export const swaggerSpec = {
}, },
], ],
}, },
TransactionWallet: {
type: 'object',
required: ['id', 'name', 'currency'],
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string', example: 'Cash' },
currency: { type: 'string', minLength: 3, maxLength: 3, example: 'VND' },
},
},
TransactionCategory: {
type: 'object',
required: ['id', 'name', 'type'],
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string', example: 'Groceries' },
type: { type: 'string', enum: ['INCOME', 'EXPENSE'] },
icon: { type: 'string', nullable: true },
color: { type: 'string', nullable: true },
},
},
Transaction: {
type: 'object',
required: [
'id',
'walletId',
'categoryId',
'amount',
'type',
'date',
'createdAt',
'updatedAt',
'wallet',
'category',
],
properties: {
id: { type: 'string', format: 'uuid' },
walletId: { type: 'string', format: 'uuid' },
categoryId: { type: 'string', format: 'uuid' },
amount: {
type: 'string',
pattern: '^(?:0|[1-9]\\d{0,15})(?:\\.\\d{1,2})?$',
example: '125000.00',
description: 'Positive decimal string to preserve monetary precision.',
},
type: { type: 'string', enum: ['INCOME', 'EXPENSE'] },
description: {
type: 'string',
nullable: true,
example: 'Weekly groceries',
},
receiptUrl: {
type: 'string',
nullable: true,
example: '/api/v1/transactions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/receipt',
description: 'Authenticated receipt download endpoint.',
},
location: { type: 'string', nullable: true, example: 'District 1' },
date: { type: 'string', format: 'date-time' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
wallet: { $ref: '#/components/schemas/TransactionWallet' },
category: { $ref: '#/components/schemas/TransactionCategory' },
},
},
CreateTransactionBody: {
type: 'object',
required: ['walletId', 'categoryId', 'amount', 'type', 'date'],
properties: {
walletId: { type: 'string', format: 'uuid' },
categoryId: { type: 'string', format: 'uuid' },
amount: {
type: 'string',
pattern: '^(?:0|[1-9]\\d{0,15})(?:\\.\\d{1,2})?$',
example: '125000.00',
},
type: { type: 'string', enum: ['INCOME', 'EXPENSE'] },
description: { type: 'string', nullable: true, maxLength: 500 },
location: { type: 'string', nullable: true, maxLength: 255 },
date: { type: 'string', format: 'date-time' },
},
},
UpdateTransactionBody: {
type: 'object',
minProperties: 1,
properties: {
walletId: { type: 'string', format: 'uuid' },
categoryId: { type: 'string', format: 'uuid' },
amount: {
type: 'string',
pattern: '^(?:0|[1-9]\\d{0,15})(?:\\.\\d{1,2})?$',
},
type: { type: 'string', enum: ['INCOME', 'EXPENSE'] },
description: { type: 'string', nullable: true, maxLength: 500 },
location: { type: 'string', nullable: true, maxLength: 255 },
date: { type: 'string', format: 'date-time' },
},
},
TransactionResponse: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{
type: 'object',
properties: {
data: { $ref: '#/components/schemas/Transaction' },
},
},
],
},
}, },
parameters: { parameters: {
PageParam: { in: 'query', name: 'page', schema: { type: 'integer', default: 1 } }, PageParam: { in: 'query', name: 'page', schema: { type: 'integer', default: 1 } },
...@@ -363,6 +471,13 @@ export const swaggerSpec = { ...@@ -363,6 +471,13 @@ export const swaggerSpec = {
schema: { type: 'string', format: 'uuid' }, schema: { type: 'string', format: 'uuid' },
description: 'Category ID', description: 'Category ID',
}, },
TransactionIdParam: {
in: 'path',
name: 'id',
required: true,
schema: { type: 'string', format: 'uuid' },
description: 'Transaction ID',
},
}, },
responses: { responses: {
Unauthorized: { description: 'Chưa đăng nhập', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } }, Unauthorized: { description: 'Chưa đăng nhập', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
...@@ -381,6 +496,10 @@ export const swaggerSpec = { ...@@ -381,6 +496,10 @@ export const swaggerSpec = {
name: 'Categories', name: 'Categories',
description: 'Shared system categories and authenticated user category management', description: 'Shared system categories and authenticated user category management',
}, },
{
name: 'Transactions',
description: 'Authenticated transaction management and receipt uploads',
},
], ],
paths: { paths: {
'/health': { '/health': {
...@@ -1375,5 +1494,304 @@ export const swaggerSpec = { ...@@ -1375,5 +1494,304 @@ export const swaggerSpec = {
}, },
}, },
}, },
'/transactions': {
get: {
tags: ['Transactions'],
summary: 'List transactions',
description: 'Searches descriptions, locations, wallet names, and category names. All filters are combined.',
security: [{ BearerAuth: [] }],
parameters: [
{
in: 'query',
name: 'search',
schema: { type: 'string', minLength: 1, maxLength: 200 },
},
{
in: 'query',
name: 'walletId',
schema: { type: 'string', format: 'uuid' },
},
{
in: 'query',
name: 'categoryId',
schema: { type: 'string', format: 'uuid' },
},
{
in: 'query',
name: 'type',
schema: { type: 'string', enum: ['INCOME', 'EXPENSE'] },
},
{
in: 'query',
name: 'dateFrom',
description: 'Inclusive lower date bound.',
schema: { type: 'string', format: 'date-time' },
},
{
in: 'query',
name: 'dateTo',
description: 'Inclusive upper date bound.',
schema: { type: 'string', format: 'date-time' },
},
{
in: 'query',
name: 'minAmount',
schema: {
type: 'string',
pattern: '^(?:0|[1-9]\\d{0,15})(?:\\.\\d{1,2})?$',
},
},
{
in: 'query',
name: 'maxAmount',
schema: {
type: 'string',
pattern: '^(?:0|[1-9]\\d{0,15})(?:\\.\\d{1,2})?$',
},
},
{
in: 'query',
name: 'sortBy',
schema: {
type: 'string',
enum: ['amount', 'date', 'description', 'createdAt', 'updatedAt'],
default: 'date',
},
},
{ $ref: '#/components/parameters/OrderParam' },
{ $ref: '#/components/parameters/PageParam' },
{ $ref: '#/components/parameters/LimitParam' },
],
responses: {
200: {
description: 'Paginated transaction list',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{
type: 'object',
properties: {
data: {
type: 'array',
items: { $ref: '#/components/schemas/Transaction' },
},
meta: { $ref: '#/components/schemas/PaginationMeta' },
},
},
],
},
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
422: { $ref: '#/components/responses/Validation' },
},
},
post: {
tags: ['Transactions'],
summary: 'Create a transaction',
description: 'The wallet balance is updated atomically. The wallet and category must be active and the category type must match.',
security: [{ BearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/CreateTransactionBody' },
},
},
},
responses: {
201: {
description: 'Transaction created and wallet balance updated',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/TransactionResponse' },
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { $ref: '#/components/responses/NotFound' },
409: { $ref: '#/components/responses/Conflict' },
422: { $ref: '#/components/responses/Validation' },
},
},
},
'/transactions/{id}': {
get: {
tags: ['Transactions'],
summary: 'Get a transaction',
security: [{ BearerAuth: [] }],
parameters: [{ $ref: '#/components/parameters/TransactionIdParam' }],
responses: {
200: {
description: 'Transaction details',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/TransactionResponse' },
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { $ref: '#/components/responses/NotFound' },
422: { $ref: '#/components/responses/Validation' },
},
},
put: {
tags: ['Transactions'],
summary: 'Update a transaction',
description: 'Any old wallet impact is reversed and the new impact is applied in one serializable database transaction.',
security: [{ BearerAuth: [] }],
parameters: [{ $ref: '#/components/parameters/TransactionIdParam' }],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/UpdateTransactionBody' },
},
},
},
responses: {
200: {
description: 'Transaction and affected wallet balances updated',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/TransactionResponse' },
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { $ref: '#/components/responses/NotFound' },
409: { $ref: '#/components/responses/Conflict' },
422: { $ref: '#/components/responses/Validation' },
},
},
delete: {
tags: ['Transactions'],
summary: 'Delete a transaction',
description: 'Reverses the wallet impact and deletes the transaction atomically, then removes its receipt file.',
security: [{ BearerAuth: [] }],
parameters: [{ $ref: '#/components/parameters/TransactionIdParam' }],
responses: {
200: {
description: 'Transaction deleted and wallet balance restored',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{
type: 'object',
properties: {
message: {
type: 'string',
example: 'Transaction deleted successfully',
},
data: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
},
},
},
},
],
},
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { $ref: '#/components/responses/NotFound' },
422: { $ref: '#/components/responses/Validation' },
},
},
},
'/transactions/{id}/receipt': {
put: {
tags: ['Transactions'],
summary: 'Upload or replace a receipt',
description: 'Accepts one JPEG, PNG, WebP, or PDF file. The default maximum size is 5 MB.',
security: [{ BearerAuth: [] }],
parameters: [{ $ref: '#/components/parameters/TransactionIdParam' }],
requestBody: {
required: true,
content: {
'multipart/form-data': {
schema: {
type: 'object',
required: ['receipt'],
properties: {
receipt: {
type: 'string',
format: 'binary',
},
},
},
},
},
},
responses: {
200: {
description: 'Receipt uploaded',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/TransactionResponse' },
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { $ref: '#/components/responses/NotFound' },
413: {
description: 'Receipt file is too large',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/ErrorResponse' },
},
},
},
422: { $ref: '#/components/responses/Validation' },
},
},
get: {
tags: ['Transactions'],
summary: 'View or download a receipt',
security: [{ BearerAuth: [] }],
parameters: [{ $ref: '#/components/parameters/TransactionIdParam' }],
responses: {
200: {
description: 'Receipt binary',
content: {
'image/jpeg': { schema: { type: 'string', format: 'binary' } },
'image/png': { schema: { type: 'string', format: 'binary' } },
'image/webp': { schema: { type: 'string', format: 'binary' } },
'application/pdf': { schema: { type: 'string', format: 'binary' } },
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { $ref: '#/components/responses/NotFound' },
422: { $ref: '#/components/responses/Validation' },
},
},
delete: {
tags: ['Transactions'],
summary: 'Delete a receipt',
security: [{ BearerAuth: [] }],
parameters: [{ $ref: '#/components/parameters/TransactionIdParam' }],
responses: {
200: {
description: 'Receipt deleted',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/TransactionResponse' },
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { $ref: '#/components/responses/NotFound' },
422: { $ref: '#/components/responses/Validation' },
},
},
},
}, },
}; };
import { randomUUID } from 'crypto';
import { access, mkdir, unlink, writeFile } from 'fs/promises';
import path from 'path';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { envConfig } from '../../config/env.config';
const extensionByMimeType: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'application/pdf': '.pdf',
};
const mimeTypeByExtension: Record<string, string> = {
'.jpg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.pdf': 'application/pdf',
};
export interface StoredReceipt {
key: string;
absolutePath: string;
mimeType: string;
}
export class ReceiptFileService {
private readonly rootDir = path.resolve(envConfig.receipts.uploadDir);
async save(userId: string, file: Express.Multer.File): Promise<string> {
const extension = extensionByMimeType[file.mimetype];
if (!extension || !this.hasValidSignature(file.mimetype, file.buffer)) {
throw new AppError(
'Receipt content does not match a supported file type',
422,
ERROR_CODE.FILE_TYPE_UNSUPPORTED,
);
}
const fileName = `${randomUUID()}${extension}`;
const key = `${userId}/${fileName}`;
const absolutePath = this.resolveSafePath(key);
const userDir = path.dirname(absolutePath);
await mkdir(userDir, { recursive: true });
await writeFile(absolutePath, file.buffer, { flag: 'wx' });
return key;
}
async resolve(key: string): Promise<StoredReceipt> {
const absolutePath = this.resolveSafePath(key);
try {
await access(absolutePath);
} catch {
throw new AppError('Receipt file not found', 404, ERROR_CODE.RECEIPT_NOT_FOUND);
}
const extension = path.extname(absolutePath).toLowerCase();
const mimeType = mimeTypeByExtension[extension];
if (!mimeType) {
throw new AppError('Receipt file not found', 404, ERROR_CODE.RECEIPT_NOT_FOUND);
}
return { key, absolutePath, mimeType };
}
async remove(key: string | null): Promise<void> {
if (!key) {
return;
}
try {
await unlink(this.resolveSafePath(key));
} catch {
// File cleanup is best effort after the database state has been committed.
}
}
private resolveSafePath(key: string): string {
const normalizedKey = key.replace(/\\/g, '/');
const absolutePath = path.resolve(this.rootDir, normalizedKey);
const rootPrefix = `${this.rootDir}${path.sep}`;
if (!absolutePath.startsWith(rootPrefix)) {
throw new AppError('Receipt file not found', 404, ERROR_CODE.RECEIPT_NOT_FOUND);
}
return absolutePath;
}
private hasValidSignature(mimeType: string, buffer: Buffer): boolean {
if (mimeType === 'image/jpeg') {
return buffer.length >= 3
&& buffer[0] === 0xff
&& buffer[1] === 0xd8
&& buffer[2] === 0xff;
}
if (mimeType === 'image/png') {
return buffer.length >= 8
&& buffer.subarray(0, 8).equals(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
);
}
if (mimeType === 'image/webp') {
return buffer.length >= 12
&& buffer.subarray(0, 4).toString('ascii') === 'RIFF'
&& buffer.subarray(8, 12).toString('ascii') === 'WEBP';
}
if (mimeType === 'application/pdf') {
return buffer.length >= 5
&& buffer.subarray(0, 5).toString('ascii') === '%PDF-';
}
return false;
}
}
import { NextFunction, Request, Response } from 'express';
import multer from 'multer';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { envConfig } from '../../config/env.config';
const allowedMimeTypes = new Set([
'image/jpeg',
'image/png',
'image/webp',
'application/pdf',
]);
const upload = multer({
storage: multer.memoryStorage(),
limits: {
files: 1,
fileSize: envConfig.receipts.maxFileSizeBytes,
},
fileFilter: (_req, file, callback) => {
if (!allowedMimeTypes.has(file.mimetype)) {
callback(
new AppError(
'Receipt must be a JPEG, PNG, WebP, or PDF file',
422,
ERROR_CODE.FILE_TYPE_UNSUPPORTED,
),
);
return;
}
callback(null, true);
},
}).single('receipt');
export function receiptUploadMiddleware(
req: Request,
res: Response,
next: NextFunction,
): void {
upload(req, res, (error) => {
if (error instanceof multer.MulterError) {
if (error.code === 'LIMIT_FILE_SIZE') {
next(
new AppError(
`Receipt must not exceed ${envConfig.receipts.maxFileSizeMb} MB`,
413,
ERROR_CODE.FILE_TOO_LARGE,
),
);
return;
}
next(new AppError(error.message, 422, ERROR_CODE.VALIDATION_ERROR));
return;
}
if (error) {
next(error);
return;
}
if (!req.file) {
next(
new AppError(
'Receipt file is required in the "receipt" field',
422,
ERROR_CODE.VALIDATION_ERROR,
),
);
return;
}
next();
});
}
import path from 'path';
import { NextFunction, Request, Response } from 'express';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import {
CreateTransactionDto,
TransactionQueryDto,
UpdateTransactionDto,
} from './transaction.dto';
import { TransactionService } from './transaction.service';
export class TransactionController {
private readonly service = new TransactionService();
findAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.findAll(
req.user.id,
req.query as unknown as TransactionQueryDto,
);
res.json({ success: true, ...result });
} catch (error) {
next(error);
}
};
findById = async (req: Request, res: Response, next: NextFunction) => {
try {
const transaction = await this.service.findById(req.user.id, req.params.id);
res.json({ success: true, data: transaction });
} catch (error) {
next(error);
}
};
create = async (req: Request, res: Response, next: NextFunction) => {
try {
const transaction = await this.service.create(
req.user.id,
req.body as CreateTransactionDto,
);
res.status(201).json({ success: true, data: transaction });
} catch (error) {
next(error);
}
};
update = async (req: Request, res: Response, next: NextFunction) => {
try {
const transaction = await this.service.update(
req.user.id,
req.params.id,
req.body as UpdateTransactionDto,
);
res.json({ success: true, data: transaction });
} catch (error) {
next(error);
}
};
delete = async (req: Request, res: Response, next: NextFunction) => {
try {
const deleted = await this.service.delete(req.user.id, req.params.id);
res.json({
success: true,
message: 'Transaction deleted successfully',
data: deleted,
});
} catch (error) {
next(error);
}
};
uploadReceipt = async (req: Request, res: Response, next: NextFunction) => {
try {
if (!req.file) {
throw new AppError(
'Receipt file is required in the "receipt" field',
422,
ERROR_CODE.VALIDATION_ERROR,
);
}
const transaction = await this.service.uploadReceipt(
req.user.id,
req.params.id,
req.file,
);
res.json({ success: true, data: transaction });
} catch (error) {
next(error);
}
};
getReceipt = async (req: Request, res: Response, next: NextFunction) => {
try {
const receipt = await this.service.getReceipt(req.user.id, req.params.id);
const fileName = `receipt${path.extname(receipt.absolutePath)}`;
res.sendFile(receipt.absolutePath, {
headers: {
'Content-Type': receipt.mimeType,
'Content-Disposition': `inline; filename="${fileName}"`,
'Cache-Control': 'private, max-age=3600',
},
}, (error) => {
if (error && !res.headersSent) {
next(error);
}
});
} catch (error) {
next(error);
}
};
deleteReceipt = async (req: Request, res: Response, next: NextFunction) => {
try {
const transaction = await this.service.deleteReceipt(
req.user.id,
req.params.id,
);
res.json({
success: true,
message: 'Receipt deleted successfully',
data: transaction,
});
} catch (error) {
next(error);
}
};
}
import { TransactionType } from '@prisma/client';
export type TransactionSortField =
| 'amount'
| 'date'
| 'description'
| 'createdAt'
| 'updatedAt';
export type SortOrder = 'asc' | 'desc';
export interface TransactionQueryDto {
search?: string;
walletId?: string;
categoryId?: string;
type?: TransactionType;
dateFrom?: Date;
dateTo?: Date;
minAmount?: string;
maxAmount?: string;
sortBy: TransactionSortField;
order: SortOrder;
page: number;
limit: number;
}
export interface CreateTransactionDto {
walletId: string;
categoryId: string;
amount: string;
type: TransactionType;
description?: string | null;
location?: string | null;
date: Date;
}
export interface UpdateTransactionDto {
walletId?: string;
categoryId?: string;
amount?: string;
type?: TransactionType;
description?: string | null;
location?: string | null;
date?: Date;
}
export interface TransactionWalletDto {
id: string;
name: string;
currency: string;
}
export interface TransactionCategoryDto {
id: string;
name: string;
type: TransactionType;
icon: string | null;
color: string | null;
}
export interface TransactionResponseDto {
id: string;
walletId: string;
categoryId: string;
amount: string;
type: TransactionType;
description: string | null;
receiptUrl: string | null;
location: string | null;
date: Date;
createdAt: Date;
updatedAt: Date;
wallet: TransactionWalletDto;
category: TransactionCategoryDto;
}
export interface TransactionListResponseDto {
data: TransactionResponseDto[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
import { Prisma, TransactionType } from '@prisma/client';
import { prisma } from '../../database/prisma.client';
import {
CreateTransactionDto,
TransactionQueryDto,
TransactionResponseDto,
UpdateTransactionDto,
} from './transaction.dto';
const transactionSelect = {
id: true,
walletId: true,
categoryId: true,
amount: true,
type: true,
description: true,
receiptUrl: true,
location: true,
date: true,
createdAt: true,
updatedAt: true,
wallet: {
select: {
id: true,
name: true,
currency: true,
},
},
category: {
select: {
id: true,
name: true,
type: true,
icon: true,
color: true,
},
},
} satisfies Prisma.TransactionSelect;
type TransactionRecord = Prisma.TransactionGetPayload<{
select: typeof transactionSelect;
}>;
export type RepositoryTransaction = Prisma.TransactionClient;
function toTransactionResponse(transaction: TransactionRecord): TransactionResponseDto {
return {
...transaction,
amount: transaction.amount.toFixed(2),
receiptUrl: transaction.receiptUrl
? `/api/v1/transactions/${transaction.id}/receipt`
: null,
};
}
function client(transaction?: RepositoryTransaction) {
return transaction ?? prisma;
}
export class TransactionRepository {
async runSerializable<T>(
operation: (transaction: RepositoryTransaction) => 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: TransactionQueryDto) {
const {
search,
walletId,
categoryId,
type,
dateFrom,
dateTo,
minAmount,
maxAmount,
sortBy,
order,
page,
limit,
} = query;
const where: Prisma.TransactionWhereInput = {
userId,
...(walletId ? { walletId } : {}),
...(categoryId ? { categoryId } : {}),
...(type ? { type } : {}),
...(dateFrom || dateTo
? {
date: {
...(dateFrom ? { gte: dateFrom } : {}),
...(dateTo ? { lte: dateTo } : {}),
},
}
: {}),
...(minAmount || maxAmount
? {
amount: {
...(minAmount ? { gte: minAmount } : {}),
...(maxAmount ? { lte: maxAmount } : {}),
},
}
: {}),
...(search
? {
OR: [
{ description: { contains: search, mode: Prisma.QueryMode.insensitive } },
{ location: { contains: search, mode: Prisma.QueryMode.insensitive } },
{ wallet: { name: { contains: search, mode: Prisma.QueryMode.insensitive } } },
{ category: { name: { contains: search, mode: Prisma.QueryMode.insensitive } } },
],
}
: {}),
};
const skip = (page - 1) * limit;
const [transactions, total] = await prisma.$transaction([
prisma.transaction.findMany({
where,
select: transactionSelect,
orderBy: [
{ [sortBy]: order },
{ id: order },
],
skip,
take: limit,
}),
prisma.transaction.count({ where }),
]);
return {
data: transactions.map(toTransactionResponse),
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async findById(
userId: string,
id: string,
transaction?: RepositoryTransaction,
) {
const record = await client(transaction).transaction.findFirst({
where: { id, userId },
select: transactionSelect,
});
return record ? toTransactionResponse(record) : null;
}
findStoredReceipt(userId: string, id: string) {
return prisma.transaction.findFirst({
where: { id, userId },
select: { id: true, receiptUrl: true },
});
}
findWallet(
userId: string,
walletId: string,
transaction: RepositoryTransaction,
) {
return transaction.wallet.findFirst({
where: { id: walletId, userId },
select: { id: true, isArchived: true },
});
}
findCategory(
userId: string,
categoryId: string,
transaction: RepositoryTransaction,
) {
return transaction.category.findFirst({
where: {
id: categoryId,
OR: [
{ userId },
{ userId: null, isSystem: true },
],
},
select: {
id: true,
type: true,
isArchived: true,
},
});
}
async create(
userId: string,
data: CreateTransactionDto,
transaction: RepositoryTransaction,
) {
const created = await transaction.transaction.create({
data: {
userId,
walletId: data.walletId,
categoryId: data.categoryId,
amount: data.amount,
type: data.type,
description: data.description,
location: data.location,
date: data.date,
},
select: transactionSelect,
});
return toTransactionResponse(created);
}
async update(
id: string,
data: UpdateTransactionDto,
transaction: RepositoryTransaction,
) {
const updated = await transaction.transaction.update({
where: { id },
data,
select: transactionSelect,
});
return toTransactionResponse(updated);
}
adjustWalletBalance(
walletId: string,
amount: Prisma.Decimal,
type: TransactionType,
direction: 'APPLY' | 'REVERSE',
transaction: RepositoryTransaction,
) {
const signedAmount = type === TransactionType.INCOME ? amount : amount.negated();
const delta = direction === 'APPLY' ? signedAmount : signedAmount.negated();
return transaction.wallet.update({
where: { id: walletId },
data: { balance: { increment: delta } },
select: { id: true },
});
}
delete(id: string, transaction: RepositoryTransaction) {
return transaction.transaction.delete({
where: { id },
select: { id: true, receiptUrl: true },
});
}
async replaceReceipt(
userId: string,
id: string,
receiptKey: string | null,
) {
return this.runSerializable(async (transaction) => {
const current = await transaction.transaction.findFirst({
where: { id, userId },
select: { id: true, receiptUrl: true },
});
if (!current) {
return null;
}
const updated = await transaction.transaction.update({
where: { id },
data: { receiptUrl: receiptKey },
select: transactionSelect,
});
return {
transaction: toTransactionResponse(updated),
previousReceiptKey: current.receiptUrl,
};
});
}
}
import { Router } from 'express';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { TransactionController } from './transaction.controller';
import { receiptUploadMiddleware } from './transaction-upload.middleware';
import {
createTransactionSchema,
findTransactionsSchema,
transactionParamsSchema,
updateTransactionSchema,
} from './transaction.validation';
const router = Router();
const controller = new TransactionController();
router.use(authMiddleware);
router.get('/', validate(findTransactionsSchema, 'query'), controller.findAll);
router.post('/', validate(createTransactionSchema), controller.create);
router.get('/:id', validate(transactionParamsSchema, 'params'), controller.findById);
router.put(
'/:id',
validate(transactionParamsSchema, 'params'),
validate(updateTransactionSchema),
controller.update,
);
router.delete(
'/:id',
validate(transactionParamsSchema, 'params'),
controller.delete,
);
router.put(
'/:id/receipt',
validate(transactionParamsSchema, 'params'),
receiptUploadMiddleware,
controller.uploadReceipt,
);
router.get(
'/:id/receipt',
validate(transactionParamsSchema, 'params'),
controller.getReceipt,
);
router.delete(
'/:id/receipt',
validate(transactionParamsSchema, 'params'),
controller.deleteReceipt,
);
export default router;
import { Prisma, TransactionType } from '@prisma/client';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { ReceiptFileService, StoredReceipt } from './receipt-file.service';
import {
CreateTransactionDto,
TransactionQueryDto,
UpdateTransactionDto,
} from './transaction.dto';
import {
RepositoryTransaction,
TransactionRepository,
} from './transaction.repository';
export class TransactionService {
private readonly repository = new TransactionRepository();
private readonly receiptFiles = new ReceiptFileService();
findAll(userId: string, query: TransactionQueryDto) {
return this.repository.findAll(userId, query);
}
async findById(userId: string, id: string) {
const transaction = await this.repository.findById(userId, id);
if (!transaction) {
throw new AppError('Transaction not found', 404, ERROR_CODE.NOT_FOUND);
}
return transaction;
}
create(userId: string, data: CreateTransactionDto) {
return this.repository.runSerializable(async (transaction) => {
await this.ensureValidRelations(
userId,
data.walletId,
data.categoryId,
data.type,
transaction,
);
const created = await this.repository.create(userId, data, transaction);
await this.repository.adjustWalletBalance(
data.walletId,
new Prisma.Decimal(data.amount),
data.type,
'APPLY',
transaction,
);
return created;
});
}
update(userId: string, id: string, data: UpdateTransactionDto) {
return this.repository.runSerializable(async (transaction) => {
const current = await this.repository.findById(userId, id, transaction);
if (!current) {
throw new AppError('Transaction not found', 404, ERROR_CODE.NOT_FOUND);
}
const nextWalletId = data.walletId ?? current.walletId;
const nextCategoryId = data.categoryId ?? current.categoryId;
const nextType = data.type ?? current.type;
const nextAmount = new Prisma.Decimal(data.amount ?? current.amount);
const financialDataChanged = nextWalletId !== current.walletId
|| nextType !== current.type
|| !nextAmount.equals(current.amount);
const currentWallet = await this.repository.findWallet(
userId,
current.walletId,
transaction,
);
if (!currentWallet) {
throw new AppError('Wallet not found', 404, ERROR_CODE.NOT_FOUND);
}
await this.ensureValidRelations(
userId,
nextWalletId,
nextCategoryId,
nextType,
transaction,
);
if (financialDataChanged) {
await this.repository.adjustWalletBalance(
current.walletId,
new Prisma.Decimal(current.amount),
current.type,
'REVERSE',
transaction,
);
await this.repository.adjustWalletBalance(
nextWalletId,
nextAmount,
nextType,
'APPLY',
transaction,
);
}
return this.repository.update(id, data, transaction);
});
}
async delete(userId: string, id: string) {
const deleted = await this.repository.runSerializable(async (transaction) => {
const current = await this.repository.findById(userId, id, transaction);
if (!current) {
throw new AppError('Transaction not found', 404, ERROR_CODE.NOT_FOUND);
}
const wallet = await this.repository.findWallet(
userId,
current.walletId,
transaction,
);
if (!wallet) {
throw new AppError('Wallet not found', 404, ERROR_CODE.NOT_FOUND);
}
await this.repository.adjustWalletBalance(
current.walletId,
new Prisma.Decimal(current.amount),
current.type,
'REVERSE',
transaction,
);
return this.repository.delete(id, transaction);
});
await this.receiptFiles.remove(deleted.receiptUrl);
return { id: deleted.id };
}
async uploadReceipt(
userId: string,
id: string,
file: Express.Multer.File,
) {
await this.findById(userId, id);
const receiptKey = await this.receiptFiles.save(userId, file);
try {
const result = await this.repository.replaceReceipt(userId, id, receiptKey);
if (!result) {
throw new AppError('Transaction not found', 404, ERROR_CODE.NOT_FOUND);
}
await this.receiptFiles.remove(result.previousReceiptKey);
return result.transaction;
} catch (error) {
await this.receiptFiles.remove(receiptKey);
throw error;
}
}
async getReceipt(userId: string, id: string): Promise<StoredReceipt> {
const transaction = await this.repository.findStoredReceipt(userId, id);
if (!transaction) {
throw new AppError('Transaction not found', 404, ERROR_CODE.NOT_FOUND);
}
if (!transaction.receiptUrl) {
throw new AppError('Receipt not found', 404, ERROR_CODE.RECEIPT_NOT_FOUND);
}
return this.receiptFiles.resolve(transaction.receiptUrl);
}
async deleteReceipt(userId: string, id: string) {
const result = await this.repository.replaceReceipt(userId, id, null);
if (!result) {
throw new AppError('Transaction not found', 404, ERROR_CODE.NOT_FOUND);
}
if (!result.previousReceiptKey) {
throw new AppError('Receipt not found', 404, ERROR_CODE.RECEIPT_NOT_FOUND);
}
await this.receiptFiles.remove(result.previousReceiptKey);
return result.transaction;
}
private async ensureValidRelations(
userId: string,
walletId: string,
categoryId: string,
type: TransactionType,
transaction: RepositoryTransaction,
) {
const [wallet, category] = await Promise.all([
this.repository.findWallet(userId, walletId, transaction),
this.repository.findCategory(userId, categoryId, transaction),
]);
if (!wallet) {
throw new AppError('Wallet not found', 404, ERROR_CODE.NOT_FOUND);
}
if (wallet.isArchived) {
throw new AppError(
'Archived wallet cannot be used for transactions',
409,
ERROR_CODE.WALLET_ARCHIVED,
);
}
if (!category) {
throw new AppError('Category not found', 404, ERROR_CODE.NOT_FOUND);
}
if (category.isArchived) {
throw new AppError(
'Archived category cannot be used for transactions',
409,
ERROR_CODE.CATEGORY_ARCHIVED,
);
}
if (category.type !== type) {
throw new AppError(
'Transaction type must match category type',
409,
ERROR_CODE.TRANSACTION_CATEGORY_TYPE_MISMATCH,
);
}
}
}
import { Prisma } from '@prisma/client';
import { z } from 'zod';
const transactionTypeSchema = z.enum(['INCOME', 'EXPENSE']);
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 nullableDescriptionSchema = z.string().trim().max(500).nullable();
const nullableLocationSchema = z.string().trim().max(255).nullable();
export const transactionParamsSchema = z.object({
id: z.string().uuid('Invalid transaction id'),
});
export const findTransactionsSchema = z
.object({
search: z.string().trim().min(1).max(200).optional(),
walletId: z.string().uuid('Invalid wallet id').optional(),
categoryId: z.string().uuid('Invalid category id').optional(),
type: transactionTypeSchema.optional(),
dateFrom: dateSchema.optional(),
dateTo: dateSchema.optional(),
minAmount: amountSchema.optional(),
maxAmount: amountSchema.optional(),
sortBy: z
.enum(['amount', 'date', 'description', 'createdAt', 'updatedAt'])
.optional()
.default('date'),
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.dateFrom <= data.dateTo,
{
message: 'dateFrom must be before or equal to dateTo',
path: ['dateFrom'],
},
)
.refine(
(data) => (
data.minAmount === undefined
|| data.maxAmount === undefined
|| new Prisma.Decimal(data.minAmount).lessThanOrEqualTo(data.maxAmount)
),
{
message: 'minAmount must be less than or equal to maxAmount',
path: ['minAmount'],
},
);
export const createTransactionSchema = z.object({
walletId: z.string().uuid('Invalid wallet id'),
categoryId: z.string().uuid('Invalid category id'),
amount: amountSchema,
type: transactionTypeSchema,
description: nullableDescriptionSchema.optional(),
location: nullableLocationSchema.optional(),
date: dateSchema,
});
export const updateTransactionSchema = z
.object({
walletId: z.string().uuid('Invalid wallet id').optional(),
categoryId: z.string().uuid('Invalid category id').optional(),
amount: amountSchema.optional(),
type: transactionTypeSchema.optional(),
description: nullableDescriptionSchema.optional(),
location: nullableLocationSchema.optional(),
date: dateSchema.optional(),
})
.refine((data) => Object.keys(data).length > 0, {
message: 'At least one field is required',
});
...@@ -3,6 +3,7 @@ import authRoute from '../modules/auth/auth.route'; ...@@ -3,6 +3,7 @@ import authRoute from '../modules/auth/auth.route';
import userRoute from '../modules/users/user.route'; import userRoute from '../modules/users/user.route';
import walletRoute from '../modules/wallets/wallet.route'; 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';
const router = Router(); const router = Router();
...@@ -14,5 +15,6 @@ router.use('/auth', authRoute); ...@@ -14,5 +15,6 @@ router.use('/auth', authRoute);
router.use('/users', userRoute); router.use('/users', userRoute);
router.use('/wallets', walletRoute); router.use('/wallets', walletRoute);
router.use('/categories', categoryRoute); router.use('/categories', categoryRoute);
router.use('/transactions', transactionRoute);
export default router; export default router;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment