Commit 7dc5d0e1 authored by ThinhNC's avatar ThinhNC

feat(transfers): add atomic wallet transfer API

parent b8bcdadc
......@@ -17,6 +17,10 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
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.
- Transfer lưu riêng lịch sử chuyển tiền giữa hai ví cùng tiền tệ. Create yêu cầu hai ví đang hoạt động,
khác nhau, thuộc cùng người dùng và ví nguồn đủ số dư; create/delete cập nhật cả hai số dư cùng bản ghi
Transfer trong Prisma transaction mức Serializable. Delete là thao tác hiệu chỉnh nên vẫn hoàn tác được
vào ví đã archive; mọi thay đổi Transfer đều xóa cache báo cáo tài chính của người dùng.
- 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.
- Upload file mới dùng mô hình browser tải trực tiếp lên Cloudflare R2 qua presigned PUT URL
......@@ -71,6 +75,8 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
- Hệ thống linting đã được cấu hình qua eslint.config.mjs (flat config, bỏ qua các tệp test & configs liên quan) và chạy sạch sẽ khi gọi pnpm run lint.
- `env.config.ts` dùng port fallback `8888`, còn `.env.example` dùng `7777`; README
ghi rõ cả hai và dùng `7777` cho hướng dẫn chạy theo file env mẫu.
- Transfer đã có API theo ownership dưới `/api/v1/transfers`; migration
`20260814120000_add_transfer_management` thêm lịch sử chuyển tiền, quan hệ hai ví và các index truy vấn.
- Wallet, Category, Transaction và Budget đã có API theo ownership; Category đồng
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à
......
......@@ -56,9 +56,9 @@ Express router
## Phạm vi hiện tại
- Route hoạt động: health, auth, users, wallets, categories, transactions, budgets,
- Route hoạt động: health, auth, users, wallets, categories, transactions, transfers, budgets,
saving goals, financial reports và AI Financial Assistant.
- Wallet, Category, Transaction và Budget có module API theo ownership trong
- Wallet, Category, Transaction, Transfer 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.
......
......@@ -18,6 +18,7 @@ Backend API cho **FinWise - Sổ tay Chi tiêu & Báo cáo Tài chính**, phục
- Quản trị người dùng theo role.
- Quản lý ví, danh mục thu/chi và giao dịch theo ownership.
- Tự động cập nhật số dư ví khi tạo, sửa hoặc xóa giao dịch.
- Chuyển tiền giữa hai ví cùng tiền tệ và cập nhật hai số dư nguyên tử.
- Lưu hóa đơn giao dịch dạng JPEG, PNG, WebP hoặc PDF.
- Swagger UI và health check.
......@@ -228,6 +229,16 @@ Toàn bộ nhóm này yêu cầu access token và chỉ thao tác trên dữ li
| `GET` | `/transactions/:id/receipt` | Xem hoặc tải hóa đơn |
| `DELETE` | `/transactions/:id/receipt` | Xóa hóa đơn |
### Transfers
Toàn bộ nhóm này yêu cầu access token. Hai ví phải thuộc người dùng, đang hoạt động, khác nhau, cùng tiền tệ và ví nguồn phải đủ số dư. Bản ghi chuyển tiền cùng hai thay đổi số dư được commit nguyên tử.
| Method | Endpoint | Mô tả |
| -------- | ---------------- | ------------------------------------------------------- |
| `GET` | `/transfers` | Lịch sử có tìm kiếm, lọc ví/ngày, sort và pagination |
| `POST` | `/transfers` | Chuyển tiền và cập nhật nguyên tử số dư của cả hai ví |
| `DELETE` | `/transfers/:id` | Xóa giao dịch chuyển tiền và hoàn tác cả hai thay đổi dư |
### Budgets
Toàn bộ nhóm này yêu cầu access token và chỉ thao tác trên ngân sách của chính người dùng. Dữ liệu sử dụng được tổng hợp từ giao dịch chi tiêu theo thời gian thực.
......@@ -266,7 +277,8 @@ src/
│ ├── users/
│ ├── wallets/
│ ├── categories/
│ └── transactions/
│ ├── transactions/
│ └── transfers/
├── routes/ # Mount route dưới /api/v1
├── app.ts # Cấu hình Express
└── server.ts # HTTP entry point
......
-- Add wallet-to-wallet transfers with exact monetary values and ownership-scoped history.
CREATE TABLE "transfers" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"source_wallet_id" UUID NOT NULL,
"destination_wallet_id" UUID NOT NULL,
"amount" DECIMAL(18, 2) NOT NULL,
"note" TEXT,
"transferred_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "transfers_pkey" PRIMARY KEY ("id"),
CONSTRAINT "transfers_amount_positive_check" CHECK ("amount" > 0),
CONSTRAINT "transfers_wallets_different_check"
CHECK ("source_wallet_id" <> "destination_wallet_id")
);
CREATE INDEX "transfers_user_id_idx"
ON "transfers"("user_id");
CREATE INDEX "transfers_source_wallet_id_idx"
ON "transfers"("source_wallet_id");
CREATE INDEX "transfers_destination_wallet_id_idx"
ON "transfers"("destination_wallet_id");
CREATE INDEX "transfers_transferred_at_idx"
ON "transfers"("transferred_at");
CREATE INDEX "transfers_user_id_transferred_at_idx"
ON "transfers"("user_id", "transferred_at");
ALTER TABLE "transfers"
ADD CONSTRAINT "transfers_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "transfers"
ADD CONSTRAINT "transfers_source_wallet_id_fkey"
FOREIGN KEY ("source_wallet_id") REFERENCES "wallets"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "transfers"
ADD CONSTRAINT "transfers_destination_wallet_id_fkey"
FOREIGN KEY ("destination_wallet_id") REFERENCES "wallets"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
......@@ -106,6 +106,7 @@ model User {
wallets Wallet[]
categories Category[]
transactions Transaction[]
transfers Transfer[]
budgets Budget[]
savingGoals SavingGoal[]
refreshTokens RefreshToken[]
......@@ -147,6 +148,8 @@ model Wallet {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
transactions Transaction[]
outgoingTransfers Transfer[] @relation("TransferSourceWallet")
incomingTransfers Transfer[] @relation("TransferDestinationWallet")
@@unique([userId, name])
@@index([userId])
......@@ -206,6 +209,29 @@ model Transaction {
@@map("transactions")
}
model Transfer {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
sourceWalletId String @map("source_wallet_id") @db.Uuid
destinationWalletId String @map("destination_wallet_id") @db.Uuid
amount Decimal @db.Decimal(18, 2)
note String?
transferredAt DateTime @map("transferred_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
sourceWallet Wallet @relation("TransferSourceWallet", fields: [sourceWalletId], references: [id], onDelete: Restrict)
destinationWallet Wallet @relation("TransferDestinationWallet", fields: [destinationWalletId], references: [id], onDelete: Restrict)
@@index([userId])
@@index([sourceWalletId])
@@index([destinationWalletId])
@@index([transferredAt])
@@index([userId, transferredAt])
@@map("transfers")
}
model Budget {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
......
......@@ -17,6 +17,9 @@ export const ERROR_CODE = {
CATEGORY_CYCLE: 'CATEGORY_CYCLE',
CATEGORY_IN_USE: 'CATEGORY_IN_USE',
TRANSACTION_CATEGORY_TYPE_MISMATCH: 'TRANSACTION_CATEGORY_TYPE_MISMATCH',
TRANSFER_WALLETS_SAME: 'TRANSFER_WALLETS_SAME',
TRANSFER_CURRENCY_MISMATCH: 'TRANSFER_CURRENCY_MISMATCH',
INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE',
BUDGET_ARCHIVED: 'BUDGET_ARCHIVED',
BUDGET_CATEGORY_TYPE_INVALID: 'BUDGET_CATEGORY_TYPE_INVALID',
SAVING_GOAL_ARCHIVED: 'SAVING_GOAL_ARCHIVED',
......
......@@ -14,7 +14,7 @@ export const swaggerSpec = {
info: {
title: 'FinWise API',
version: '1.0.0',
description: 'Backend API cho FinWise - Sổ tay Chi tiêu & Báo cáo Tài chính (Zalo Mini App). API cung cấp xác thực, quản lý người dùng, ví, danh mục, giao dịch, ngân sách, mục tiêu tiết kiệm và Trợ lý Tài chính AI.',
description: 'Backend API cho FinWise - Sổ tay Chi tiêu & Báo cáo Tài chính (Zalo Mini App). API cung cấp xác thực, quản lý người dùng, ví, danh mục, giao dịch, chuyển tiền, ngân sách, mục tiêu tiết kiệm và Trợ lý Tài chính AI.',
contact: { name: 'FinWise Team' },
},
servers: [
......@@ -488,6 +488,80 @@ export const swaggerSpec = {
},
],
},
TransferWallet: {
type: 'object',
required: ['id', 'name', 'currency', 'icon', 'color'],
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string', example: 'Daily wallet' },
currency: { type: 'string', minLength: 3, maxLength: 3, example: 'VND' },
icon: { type: 'string', nullable: true, example: 'wallet' },
color: { type: 'string', nullable: true, example: '#8B7CF6' },
},
},
Transfer: {
type: 'object',
required: [
'id',
'sourceWalletId',
'destinationWalletId',
'amount',
'note',
'transferredAt',
'createdAt',
'updatedAt',
'sourceWallet',
'destinationWallet',
],
properties: {
id: { type: 'string', format: 'uuid' },
sourceWalletId: { type: 'string', format: 'uuid' },
destinationWalletId: { type: 'string', format: 'uuid' },
amount: {
type: 'string',
pattern: '^(?:0|[1-9]\\d{0,15})(?:\\.\\d{1,2})?$',
example: '500000.00',
description: 'Positive decimal string in the shared wallet currency.',
},
note: { type: 'string', nullable: true, maxLength: 500 },
transferredAt: { type: 'string', format: 'date-time' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
sourceWallet: { $ref: '#/components/schemas/TransferWallet' },
destinationWallet: { $ref: '#/components/schemas/TransferWallet' },
},
},
CreateTransferBody: {
type: 'object',
required: [
'sourceWalletId',
'destinationWalletId',
'amount',
'transferredAt',
],
properties: {
sourceWalletId: { type: 'string', format: 'uuid' },
destinationWalletId: { type: 'string', format: 'uuid' },
amount: {
type: 'string',
pattern: '^(?:0|[1-9]\\d{0,15})(?:\\.\\d{1,2})?$',
example: '500000.00',
},
note: { type: 'string', nullable: true, maxLength: 500 },
transferredAt: { type: 'string', format: 'date-time' },
},
},
TransferResponse: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{
type: 'object',
properties: {
data: { $ref: '#/components/schemas/Transfer' },
},
},
],
},
BudgetCategory: {
type: 'object',
required: ['id', 'name', 'type'],
......@@ -1747,6 +1821,13 @@ export const swaggerSpec = {
schema: { type: 'string', format: 'uuid' },
description: 'Transaction ID',
},
TransferIdParam: {
in: 'path',
name: 'id',
required: true,
schema: { type: 'string', format: 'uuid' },
description: 'Transfer ID',
},
BudgetIdParam: {
in: 'path',
name: 'id',
......@@ -1833,6 +1914,10 @@ export const swaggerSpec = {
name: 'Transactions',
description: 'Authenticated transaction management and receipt uploads',
},
{
name: 'Transfers',
description: 'Authenticated wallet-to-wallet transfers with atomic balance updates',
},
{
name: 'Budgets',
description: 'Authenticated budget limits, real-time usage, and threshold alerts',
......@@ -3234,6 +3319,140 @@ export const swaggerSpec = {
},
},
},
'/transfers': {
get: {
tags: ['Transfers'],
summary: 'List wallet transfers',
description: 'Searches notes and source/destination wallet names. A wallet filter matches either side of the transfer.',
security: [{ BearerAuth: [] }],
parameters: [
{
in: 'query',
name: 'search',
schema: { type: 'string', minLength: 1, maxLength: 200 },
},
{
in: 'query',
name: 'walletId',
schema: { type: 'string', format: 'uuid' },
description: 'Matches either the source or destination wallet.',
},
{
in: 'query',
name: 'dateFrom',
description: 'Inclusive lower transferredAt bound.',
schema: { type: 'string', format: 'date-time' },
},
{
in: 'query',
name: 'dateTo',
description: 'Inclusive upper transferredAt bound.',
schema: { type: 'string', format: 'date-time' },
},
{
in: 'query',
name: 'sortBy',
schema: {
type: 'string',
enum: ['amount', 'transferredAt', 'createdAt'],
default: 'transferredAt',
},
},
{ $ref: '#/components/parameters/OrderParam' },
{ $ref: '#/components/parameters/PageParam' },
{ $ref: '#/components/parameters/LimitParam' },
],
responses: {
200: {
description: 'Paginated transfer history',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{
type: 'object',
properties: {
data: {
type: 'array',
items: { $ref: '#/components/schemas/Transfer' },
},
meta: { $ref: '#/components/schemas/PaginationMeta' },
},
},
],
},
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
422: { $ref: '#/components/responses/Validation' },
},
},
post: {
tags: ['Transfers'],
summary: 'Transfer money between wallets',
description: 'Requires two distinct active wallets owned by the user, the same currency, and sufficient source balance. The transfer record and both balance changes commit atomically in a serializable transaction.',
security: [{ BearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/CreateTransferBody' },
},
},
},
responses: {
201: {
description: 'Transfer created and both wallet balances updated',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/TransferResponse' },
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { $ref: '#/components/responses/NotFound' },
409: { $ref: '#/components/responses/Conflict' },
422: { $ref: '#/components/responses/Validation' },
},
},
},
'/transfers/{id}': {
delete: {
tags: ['Transfers'],
summary: 'Delete and reverse a transfer',
description: 'Deletes an owned transfer and reverses both wallet balance changes atomically. Archived wallets may still receive the reversal to preserve accounting history.',
security: [{ BearerAuth: [] }],
parameters: [{ $ref: '#/components/parameters/TransferIdParam' }],
responses: {
200: {
description: 'Transfer deleted and both wallet balance changes reversed',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/TransferResponse' },
{
type: 'object',
properties: {
message: {
type: 'string',
example: 'Transfer deleted successfully',
},
},
},
],
},
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { $ref: '#/components/responses/NotFound' },
422: { $ref: '#/components/responses/Validation' },
},
},
},
'/budgets': {
get: {
tags: ['Budgets'],
......
import { NextFunction, Request, Response } from 'express';
import { CreateTransferDto, TransferQueryDto } from './transfer.dto';
import { TransferService } from './transfer.service';
export class TransferController {
private readonly service = new TransferService();
findAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.findAll(
req.user.id,
req.query as unknown as TransferQueryDto,
);
res.json({ success: true, ...result });
} catch (error) {
next(error);
}
};
create = async (req: Request, res: Response, next: NextFunction) => {
try {
const transfer = await this.service.create(
req.user.id,
req.body as CreateTransferDto,
);
res.status(201).json({ success: true, data: transfer });
} catch (error) {
next(error);
}
};
delete = async (req: Request, res: Response, next: NextFunction) => {
try {
const transfer = await this.service.delete(req.user.id, req.params.id);
res.json({
success: true,
message: 'Transfer deleted successfully',
data: transfer,
});
} catch (error) {
next(error);
}
};
}
export type TransferSortField = 'amount' | 'transferredAt' | 'createdAt';
export type TransferSortOrder = 'asc' | 'desc';
export interface TransferQueryDto {
search?: string;
walletId?: string;
dateFrom?: Date;
dateTo?: Date;
sortBy: TransferSortField;
order: TransferSortOrder;
page: number;
limit: number;
}
export interface CreateTransferDto {
sourceWalletId: string;
destinationWalletId: string;
amount: string;
note?: string | null;
transferredAt: Date;
}
export interface TransferWalletDto {
id: string;
name: string;
currency: string;
icon: string | null;
color: string | null;
}
export interface TransferResponseDto {
id: string;
sourceWalletId: string;
destinationWalletId: string;
amount: string;
note: string | null;
transferredAt: Date;
createdAt: Date;
updatedAt: Date;
sourceWallet: TransferWalletDto;
destinationWallet: TransferWalletDto;
}
export interface TransferListResponseDto {
data: TransferResponseDto[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
import { Prisma } from '@prisma/client';
import { prisma } from '../../database/prisma.client';
import {
CreateTransferDto,
TransferQueryDto,
TransferResponseDto,
} from './transfer.dto';
const transferSelect = {
id: true,
sourceWalletId: true,
destinationWalletId: true,
amount: true,
note: true,
transferredAt: true,
createdAt: true,
updatedAt: true,
sourceWallet: {
select: {
id: true,
name: true,
currency: true,
icon: true,
color: true,
},
},
destinationWallet: {
select: {
id: true,
name: true,
currency: true,
icon: true,
color: true,
},
},
} satisfies Prisma.TransferSelect;
type TransferRecord = Prisma.TransferGetPayload<{
select: typeof transferSelect;
}>;
export type RepositoryTransaction = Prisma.TransactionClient;
function toTransferResponse(transfer: TransferRecord): TransferResponseDto {
return {
...transfer,
amount: transfer.amount.toFixed(2),
};
}
function client(transaction?: RepositoryTransaction) {
return transaction ?? prisma;
}
export class TransferRepository {
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: TransferQueryDto) {
const {
search,
walletId,
dateFrom,
dateTo,
sortBy,
order,
page,
limit,
} = query;
const where: Prisma.TransferWhereInput = {
userId,
AND: [
...(walletId
? [{
OR: [
{ sourceWalletId: walletId },
{ destinationWalletId: walletId },
],
}]
: []),
...(search
? [{
OR: [
{ note: { contains: search, mode: Prisma.QueryMode.insensitive } },
{
sourceWallet: {
name: { contains: search, mode: Prisma.QueryMode.insensitive },
},
},
{
destinationWallet: {
name: { contains: search, mode: Prisma.QueryMode.insensitive },
},
},
],
}]
: []),
],
...(dateFrom || dateTo
? {
transferredAt: {
...(dateFrom ? { gte: dateFrom } : {}),
...(dateTo ? { lte: dateTo } : {}),
},
}
: {}),
};
const skip = (page - 1) * limit;
const [transfers, total] = await prisma.$transaction([
prisma.transfer.findMany({
where,
select: transferSelect,
orderBy: [
{ [sortBy]: order },
{ id: order },
],
skip,
take: limit,
}),
prisma.transfer.count({ where }),
]);
return {
data: transfers.map(toTransferResponse),
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async findById(
userId: string,
id: string,
transaction?: RepositoryTransaction,
) {
const transfer = await client(transaction).transfer.findFirst({
where: { id, userId },
select: transferSelect,
});
return transfer ? toTransferResponse(transfer) : null;
}
findWallets(
userId: string,
walletIds: string[],
transaction: RepositoryTransaction,
) {
return transaction.wallet.findMany({
where: {
userId,
id: { in: walletIds },
},
select: {
id: true,
balance: true,
currency: true,
isArchived: true,
},
});
}
async create(
userId: string,
data: CreateTransferDto,
transaction: RepositoryTransaction,
) {
const created = await transaction.transfer.create({
data: {
userId,
sourceWalletId: data.sourceWalletId,
destinationWalletId: data.destinationWalletId,
amount: data.amount,
note: data.note,
transferredAt: data.transferredAt,
},
select: transferSelect,
});
return toTransferResponse(created);
}
debitWallet(
userId: string,
walletId: string,
amount: Prisma.Decimal,
transaction: RepositoryTransaction,
) {
return transaction.wallet.updateMany({
where: {
id: walletId,
userId,
isArchived: false,
balance: { gte: amount },
},
data: { balance: { decrement: amount } },
});
}
incrementWallet(
walletId: string,
amount: Prisma.Decimal,
transaction: RepositoryTransaction,
) {
return transaction.wallet.update({
where: { id: walletId },
data: { balance: { increment: amount } },
select: { id: true },
});
}
decrementWallet(
walletId: string,
amount: Prisma.Decimal,
transaction: RepositoryTransaction,
) {
return transaction.wallet.update({
where: { id: walletId },
data: { balance: { decrement: amount } },
select: { id: true },
});
}
delete(id: string, transaction: RepositoryTransaction) {
return transaction.transfer.delete({
where: { id },
select: { id: true },
});
}
}
import { Router } from 'express';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { TransferController } from './transfer.controller';
import {
createTransferSchema,
findTransfersSchema,
transferParamsSchema,
} from './transfer.validation';
const router = Router();
const controller = new TransferController();
router.use(authMiddleware);
router.get('/', validate(findTransfersSchema, 'query'), controller.findAll);
router.post('/', validate(createTransferSchema), controller.create);
router.delete(
'/:id',
validate(transferParamsSchema, 'params'),
controller.delete,
);
export default router;
import { Prisma } from '@prisma/client';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { cacheService } from '../../common/services/cache.service';
import { CreateTransferDto, TransferQueryDto } from './transfer.dto';
import {
RepositoryTransaction,
TransferRepository,
} from './transfer.repository';
export class TransferService {
private readonly repository = new TransferRepository();
findAll(userId: string, query: TransferQueryDto) {
return this.repository.findAll(userId, query);
}
async create(userId: string, data: CreateTransferDto) {
const created = await this.repository.runSerializable(async (transaction) => {
if (data.sourceWalletId === data.destinationWalletId) {
throw new AppError(
'Source and destination wallets must be different',
409,
ERROR_CODE.TRANSFER_WALLETS_SAME,
);
}
const [sourceWallet, destinationWallet] = await this.getTransferWallets(
userId,
data.sourceWalletId,
data.destinationWalletId,
transaction,
);
if (sourceWallet.isArchived || destinationWallet.isArchived) {
throw new AppError(
'Archived wallets cannot be used for transfers',
409,
ERROR_CODE.WALLET_ARCHIVED,
);
}
if (sourceWallet.currency !== destinationWallet.currency) {
throw new AppError(
'Source and destination wallets must use the same currency',
409,
ERROR_CODE.TRANSFER_CURRENCY_MISMATCH,
);
}
const amount = new Prisma.Decimal(data.amount);
if (sourceWallet.balance.lessThan(amount)) {
throw new AppError(
'Source wallet has insufficient balance',
409,
ERROR_CODE.INSUFFICIENT_BALANCE,
);
}
const debitResult = await this.repository.debitWallet(
userId,
sourceWallet.id,
amount,
transaction,
);
if (debitResult.count !== 1) {
throw new AppError(
'Source wallet has insufficient balance',
409,
ERROR_CODE.INSUFFICIENT_BALANCE,
);
}
await this.repository.incrementWallet(
destinationWallet.id,
amount,
transaction,
);
return this.repository.create(userId, data, transaction);
});
await this.invalidateReportCache(userId);
return created;
}
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('Transfer not found', 404, ERROR_CODE.NOT_FOUND);
}
const [sourceWallet, destinationWallet] = await this.getTransferWallets(
userId,
current.sourceWalletId,
current.destinationWalletId,
transaction,
);
const amount = new Prisma.Decimal(current.amount);
await this.repository.incrementWallet(sourceWallet.id, amount, transaction);
await this.repository.decrementWallet(destinationWallet.id, amount, transaction);
await this.repository.delete(id, transaction);
return current;
});
await this.invalidateReportCache(userId);
return deleted;
}
private async getTransferWallets(
userId: string,
sourceWalletId: string,
destinationWalletId: string,
transaction: RepositoryTransaction,
) {
const wallets = await this.repository.findWallets(
userId,
[sourceWalletId, destinationWalletId],
transaction,
);
const sourceWallet = wallets.find((wallet) => wallet.id === sourceWalletId);
const destinationWallet = wallets.find((wallet) => wallet.id === destinationWalletId);
if (!sourceWallet || !destinationWallet) {
throw new AppError('Wallet not found', 404, ERROR_CODE.NOT_FOUND);
}
return [sourceWallet, destinationWallet] as const;
}
private async invalidateReportCache(userId: string): Promise<void> {
await cacheService.clearPattern(`finwise:cache:reports:${userId}:*`);
}
}
import { Prisma } from '@prisma/client';
import { z } from 'zod';
const amountSchema = z
.string()
.trim()
.regex(
/^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/,
'Amount must be a positive decimal string with at most 16 integer digits and 2 decimal places',
)
.refine(
(value) => new Prisma.Decimal(value).greaterThan(0),
'Amount must be greater than zero',
);
const dateSchema = z
.string()
.datetime({ offset: true, message: 'Date must be a valid ISO 8601 date-time' })
.transform((value) => new Date(value));
export const transferParamsSchema = z.object({
id: z.string().uuid('Invalid transfer id'),
});
export const findTransfersSchema = z
.object({
search: z.string().trim().min(1).max(200).optional(),
walletId: z.string().uuid('Invalid wallet id').optional(),
dateFrom: dateSchema.optional(),
dateTo: dateSchema.optional(),
sortBy: z
.enum(['amount', 'transferredAt', 'createdAt'])
.optional()
.default('transferredAt'),
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'],
},
);
export const createTransferSchema = z
.object({
sourceWalletId: z.string().uuid('Invalid source wallet id'),
destinationWalletId: z.string().uuid('Invalid destination wallet id'),
amount: amountSchema,
note: z.string().trim().max(500).nullable().optional(),
transferredAt: dateSchema,
})
.refine((data) => data.sourceWalletId !== data.destinationWalletId, {
message: 'Source and destination wallets must be different',
path: ['destinationWalletId'],
});
......@@ -4,6 +4,7 @@ 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';
import transferRoute from '../modules/transfers/transfer.route';
import budgetRoute from '../modules/budgets/budget.route';
import savingGoalRoute from '../modules/saving-goals/saving-goal.route';
import reportRoute from '../modules/reports/report.route';
......@@ -23,6 +24,7 @@ router.use('/users', userRoute);
router.use('/wallets', walletRoute);
router.use('/categories', categoryRoute);
router.use('/transactions', transactionRoute);
router.use('/transfers', transferRoute);
router.use('/budgets', budgetRoute);
router.use('/saving-goals', savingGoalRoute);
router.use('/reports', reportRoute);
......
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