Commit da6a541a authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/transaction-management' into 'develop'

feat(transactions): add transaction management and receipt uploads

See merge request !12
parents befb10a2 aea9be81
......@@ -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.
- 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ý.
- 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
......@@ -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`.
- 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.
- 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.
- 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à
Budget đã được commit trước đó.
......
......@@ -26,4 +26,7 @@ APP_URL=http://localhost:7777
TRUST_PROXY=false
ALLOWED_ORIGINS=http://localhost:7777,http://localhost:3000,http://localhost:5173
\ No newline at end of file
ALLOWED_ORIGINS=http://localhost:7777,http://localhost:3000,http://localhost:5173
RECEIPT_UPLOAD_DIR=storage/receipts
RECEIPT_MAX_FILE_SIZE_MB=5
......@@ -4,5 +4,7 @@ dist/
*.log
storage/exports/*
!storage/exports/.gitkeep
storage/receipts/*
!storage/receipts/.gitkeep
.DS_Store
.agents/local.md
......@@ -35,6 +35,9 @@ importers:
morgan:
specifier: ^1.10.0
version: 1.11.0
multer:
specifier: ^2.2.0
version: 2.2.0
nodemailer:
specifier: ^9.0.3
version: 9.0.3
......@@ -63,6 +66,9 @@ importers:
'@types/morgan':
specifier: ^1.9.9
version: 1.9.10
'@types/multer':
specifier: ^2.2.0
version: 2.2.0
'@types/node':
specifier: ^22.10.2
version: 22.20.0
......@@ -407,6 +413,9 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/multer@2.2.0':
resolution: {integrity: sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==}
'@types/node@22.20.0':
resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==}
......@@ -466,6 +475,9 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
append-field@1.0.0:
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
arg@4.1.3:
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
......@@ -506,6 +518,10 @@ packages:
buffer-from@1.1.2:
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:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
......@@ -540,6 +556,10 @@ packages:
concat-map@0.0.1:
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:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
......@@ -959,6 +979,10 @@ packages:
ms@2.1.3:
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:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
......@@ -1069,6 +1093,10 @@ packages:
resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==}
engines: {node: '>= 0.8'}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
......@@ -1147,6 +1175,13 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
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:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
......@@ -1229,6 +1264,9 @@ packages:
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
engines: {node: '>= 0.6'}
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
......@@ -1244,6 +1282,9 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
utils-merge@1.0.1:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
......@@ -1524,6 +1565,10 @@ snapshots:
'@types/ms@2.1.0': {}
'@types/multer@2.2.0':
dependencies:
'@types/express': 4.17.25
'@types/node@22.20.0':
dependencies:
undici-types: 6.21.0
......@@ -1591,6 +1636,8 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.2
append-field@1.0.0: {}
arg@4.1.3: {}
argparse@2.0.1: {}
......@@ -1637,6 +1684,10 @@ snapshots:
buffer-from@1.1.2: {}
busboy@1.6.0:
dependencies:
streamsearch: 1.1.0
bytes@3.1.2: {}
call-bind-apply-helpers@1.0.2:
......@@ -1676,6 +1727,13 @@ snapshots:
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:
dependencies:
safe-buffer: 5.2.1
......@@ -2136,6 +2194,13 @@ snapshots:
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: {}
negotiator@0.6.3: {}
......@@ -2224,6 +2289,12 @@ snapshots:
iconv-lite: 0.4.24
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:
dependencies:
picomatch: 2.3.2
......@@ -2321,6 +2392,12 @@ snapshots:
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-json-comments@2.0.1: {}
......@@ -2408,6 +2485,8 @@ snapshots:
media-typer: 0.3.0
mime-types: 2.1.35
typedarray@0.0.6: {}
typescript@5.9.3: {}
undici-types@6.21.0: {}
......@@ -2418,6 +2497,8 @@ snapshots:
dependencies:
punycode: 2.3.1
util-deprecate@1.0.2: {}
utils-merge@1.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 = {
CATEGORY_PARENT_INVALID: 'CATEGORY_PARENT_INVALID',
CATEGORY_CYCLE: 'CATEGORY_CYCLE',
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_ALREADY_COMPLETED: 'CRAWL_JOB_ALREADY_COMPLETED',
PRIVATE_IP_BLOCKED: 'PRIVATE_IP_BLOCKED',
......
......@@ -31,4 +31,14 @@ export const envConfig = {
? 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;
},
},
};
This diff is collapsed.
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';
import userRoute from '../modules/users/user.route';
import walletRoute from '../modules/wallets/wallet.route';
import categoryRoute from '../modules/categories/category.route';
import transactionRoute from '../modules/transactions/transaction.route';
const router = Router();
......@@ -14,5 +15,6 @@ router.use('/auth', authRoute);
router.use('/users', userRoute);
router.use('/wallets', walletRoute);
router.use('/categories', categoryRoute);
router.use('/transactions', transactionRoute);
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