Commit 395534d7 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/wallet-management-api' into 'develop'

feat(wallet): add wallet management API and migration

See merge request !10
parents ade84ace cfbe1566
# Bộ nhớ và quy tắc làm việc của dự án
Thư mục này tách phần hướng dẫn chi tiết khỏi `AGENTS.md` để dễ đọc và chỉnh sửa.
`AGENTS.md` ở root vẫn là điểm vào được Codex tự động phát hiện.
```text
.
├── AGENTS.md # Hướng dẫn chính, Codex tự đọc
└── .agents/
├── README.md # Sơ đồ và cách bảo trì
├── project.md # Bản đồ dự án
├── memory.md # Sự thật/quyết định dài hạn
├── local.md.example # Mẫu ghi chú riêng theo máy
├── rules/
│ ├── architecture.md # Ranh giới các layer/module
│ ├── tech-defaults.md # Quy ước TypeScript, Express, Prisma
│ └── workflow.md # Quy trình sửa và kiểm tra
└── checklists/
├── new-module.md # Checklist thêm module API
└── review.md # Checklist review thay đổi
```
## Cách chỉnh sửa
- Quy tắc áp dụng cho mọi công việc: đặt trong `AGENTS.md`.
- Mô tả cấu trúc thực tế: cập nhật `project.md`.
- Quyết định đã thống nhất và còn hiệu lực: cập nhật `memory.md`.
- Quy tắc chuyên sâu: cập nhật file phù hợp trong `rules/`.
- Ghi chú cá nhân hoặc lệnh riêng theo máy: copy `local.md.example` thành
`local.md`. File này đã được ignore.
Giữ tài liệu ngắn và có thể hành động. Không lưu secret, token, mật khẩu, dữ liệu
khách hàng hoặc nội dung của `.env` trong thư mục này.
# Checklist thêm module API
- [ ] Xác định endpoint, actor, quyền truy cập và response/error contract.
- [ ] Tạo DTO/type cho input/output.
- [ ] Tạo Zod schema cho body/query/params.
- [ ] Tạo repository chứa Prisma query và lọc field nhạy cảm.
- [ ] Tạo service chứa business rule và transaction boundary.
- [ ] Tạo controller chỉ xử lý HTTP.
- [ ] Tạo route với đúng thứ tự auth, role, validation, controller.
- [ ] Mount route ở `src/routes/index.ts`.
- [ ] Dùng `AppError` + `ERROR_CODE` cho expected failure.
- [ ] Kiểm tra ownership, soft delete, pagination và concurrency nếu liên quan.
- [ ] Cập nhật Swagger/README khi public API thay đổi.
- [ ] Chạy build và các kiểm tra Prisma phù hợp.
- [ ] Thêm test khi dự án có test framework; ghi rõ nếu hiện chưa thể test.
# Checklist review thay đổi
## Correctness
- [ ] Happy path và failure path đúng contract.
- [ ] Dữ liệu đã validate trước khi dùng.
- [ ] Null/optional, pagination, sorting và timezone được xử lý rõ.
- [ ] Nhiều write phụ thuộc nhau nằm trong transaction.
## Security
- [ ] Endpoint có auth/role/ownership phù hợp.
- [ ] Không lộ password, token, secret hoặc field riêng tư.
- [ ] Không tin identifier/role do client tự khai báo.
- [ ] CORS, cookie, JWT và rate limit không bị nới lỏng ngoài chủ ý.
## Database
- [ ] Query tôn trọng soft delete.
- [ ] Migration không phá dữ liệu ngoài chủ ý và có index/constraint phù hợp.
- [ ] Không sửa migration lịch sử đã được dùng.
- [ ] Tiền tệ không bị chuyển sang floating point thiếu chính xác.
## Maintainability
- [ ] Đúng ranh giới route/validation/controller/service/repository.
- [ ] Không có refactor hoặc dependency ngoài phạm vi.
- [ ] Documentation và `.agents/memory.md` phản ánh quyết định bền vững mới.
- [ ] Verification được báo chính xác, không đánh đồng build với test.
# Local Agent Notes
Copy file này thành `.agents/local.md` để thêm quy ước riêng cho máy của bạn.
`local.md` không được commit và được đọc sau các quy tắc chung.
Ví dụ các nội dung phù hợp:
- Lệnh khởi động database local.
- Tên container hoặc port riêng trên máy.
- Công cụ kiểm tra bổ sung đã cài local.
- Cách trình bày phản hồi cá nhân.
Không đặt secret, token, mật khẩu hoặc nội dung `.env` ở đây.
# Project Memory
File này chỉ lưu sự thật và quyết định dài hạn giúp các phiên sau không phải suy
đoán lại. Cập nhật khi một mục bên dưới thay đổi; không ghi diễn biến từng phiên.
## Quyết định đang có hiệu lực
- Kiến trúc backend theo module và các layer:
`route -> validation -> controller -> service -> repository`.
- Prisma access nằm trong repository; service giữ business rule.
- API lỗi có chủ đích dùng `AppError` kết hợp `ERROR_CODE`.
- Soft delete user dùng `deletedAt`, `deletedBy`, vô hiệu hóa user và thu hồi
refresh token.
- API version hiện tại là `/api/v1`.
- Package manager chuẩn là pnpm.
## Trạng thái đã biết
- Chưa có test script hoặc test suite trong `package.json`.
- `lint` script tồn tại nhưng repository hiện chưa có ESLint config; không coi
lint là verification khả dụng cho tới khi config được bổ sung.
- Port trong tài liệu/cấu hình chưa đồng nhất: README ghi `3000`,
`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.
- Schema có model tài chính chưa được expose qua route: Category, Transaction và
Budget. Wallet đã có API theo ownership, dùng archive thay cho xóa vật lý.
- Migration `20260728170000_improve_wallet_management` chỉ đồng bộ thay đổi của
Wallet. Migration history cũ vẫn chưa phản ánh đầy đủ các thay đổi schema của
auth, Category và Budget đã được commit trước đó.
## Khi cập nhật file này
- Ghi quyết định cuối cùng, không ghi các phương án đã loại.
- Không ghi secret hoặc dữ liệu từ `.env`.
- Xóa hoặc sửa mục cũ khi nó không còn đúng.
# Bản đồ dự án FinWise Backend
## Mục đích
Backend API cho FinWise, một Zalo Mini App quản lý thu chi và báo cáo tài chính.
Các API đang được mount dưới `/api/v1`; Swagger UI ở `/api/docs`.
## Công nghệ
- Node.js + Express 4 + TypeScript (CommonJS, strict mode)
- Prisma 5 + PostgreSQL
- Zod cho validation
- JWT cho authentication, role middleware cho authorization
- pnpm là package manager chuẩn
## Cấu trúc runtime
```text
src/
├── server.ts # Nạp env và mở HTTP server
├── app.ts # Khởi tạo Express và middleware
├── routes/index.ts # Mount route cấp /api/v1
├── modules/
│ ├── auth/ # Đăng ký, đăng nhập, token, profile, session
│ └── users/ # Quản trị user
├── middlewares/ # Auth, role, validation, rate limit, error
├── config/ # Env, DB, JWT, mail, Swagger
├── database/ # Prisma client
└── common/
├── constants/
├── errors/
├── helpers/
├── services/
└── types/
prisma/
├── schema.prisma # Database schema
├── migrations/ # Lịch sử migration
└── seed.ts # Seed data
scripts/prisma-run.js # Wrapper chạy Prisma với env của dự án
```
## Luồng request chuẩn
```text
Express router
-> auth/role middleware (khi cần)
-> Zod validation
-> controller
-> service
-> repository
-> Prisma/PostgreSQL
-> response hoặc error middleware
```
## Phạm vi hiện tại
- Route hoạt động: health, auth và users.
- Prisma schema đã có thêm Wallet, Category, Transaction và Budget, nhưng chưa có
module API tương ứng trong `src/modules/`.
- Email verification, password reset, cảnh báo thiết bị và quản lý session nằm
trong module auth.
## File sinh tự động hoặc không được sửa trực tiếp
- `dist/`
- `node_modules/`
- Prisma Client được generate
- Migration cũ đã được dùng ở môi trường chia sẻ
# Quy tắc kiến trúc
## Ranh giới layer
### Route
- Khai báo HTTP method/path và thứ tự middleware.
- Áp dụng auth, role và validation trước controller.
- Không chứa business logic hoặc Prisma query.
### Validation
- Dùng Zod cho body/query/params có dữ liệu cần kiểm tra.
- Normalize/coerce dữ liệu tại schema khi phù hợp.
- DTO phải phản ánh dữ liệu sau validation, tránh cast che lỗi kiểu.
### Controller
- Đọc request đã validate, gọi service và tạo HTTP response.
- Chuyển lỗi cho error middleware; không lặp lại mapping lỗi ở từng controller.
- Không query Prisma, hash password hoặc thực thi business rule.
### Service
- Chứa use case, business rule, authorization theo dữ liệu và điều phối nhiều
repository/service.
- Dùng `AppError` + `ERROR_CODE` cho lỗi dự kiến.
- Dùng transaction khi nhiều database write phải thành công hoặc thất bại cùng
nhau.
### Repository
- Là nơi duy nhất trong module thực hiện Prisma query.
- Không tạo HTTP response hoặc phụ thuộc Express.
- Mặc định loại record soft-deleted khi nghiệp vụ yêu cầu dữ liệu đang hoạt động.
- Chỉ trả các field cần thiết; không làm rò `password`, token hay dữ liệu nhạy cảm.
## Thêm module mới
- Theo naming hiện tại:
`<name>.route.ts`, `<name>.validation.ts`, `<name>.controller.ts`,
`<name>.service.ts`, `<name>.repository.ts`, `<name>.dto.ts`.
- Mount route ở `src/routes/index.ts`.
- Dùng checklist `.agents/checklists/new-module.md`.
## Database
- Thay đổi schema phải xem xét migration, index, unique constraint, quan hệ và
`onDelete`.
- Không sửa migration cũ đã chia sẻ; tạo migration mới.
- Không chạy `db:migrate:reset` nếu người dùng chưa yêu cầu rõ ràng.
- Với tiền tệ, giữ Prisma `Decimal`; không âm thầm chuyển sang JavaScript float.
# Quy ước kỹ thuật mặc định
## TypeScript
- Giữ `strict` và tránh thêm `any`; nếu thư viện buộc phải dùng, cô lập và giải
thích ở phạm vi nhỏ nhất.
- Ưu tiên type/interface rõ ràng ở biên module.
- Không dùng type assertion để bỏ qua validation hoặc nullability nếu có thể kiểm
tra đúng tại runtime.
- Theo style hiện tại: single quote, semicolon, trailing comma ở multiline.
## Express và API
- Response thành công giữ shape nhất quán: `{ success: true, ... }`.
- Error đi qua `errorMiddleware` và có `message`, `code`.
- Route mới phải xác định rõ public hay cần `authMiddleware`/`requireRole`.
- Không tin dữ liệu từ `req.body`, `req.query`, `req.params` trước validation.
- Không ghi access token, refresh token, password hay cookie nhạy cảm vào log.
## Auth và security
- Hash password bằng primitive hiện có; không lưu hoặc trả password plaintext.
- Kiểm tra ownership/role ở server, không dựa vào client.
- Secret chỉ lấy từ environment/config; không hard-code secret mới.
- Với thay đổi token/cookie/CORS/rate limit, kiểm tra cả luồng login, refresh,
logout và failure path.
## Prisma
- Query nằm ở repository.
- Dùng `$transaction` cho các write phụ thuộc nhau.
- Cân nhắc pagination và index cho endpoint dạng danh sách.
- Select/omit field nhạy cảm một cách chủ động.
- Chạy validate/generate phù hợp khi sửa `schema.prisma`.
## Dependency và generated output
- Không thêm production dependency nếu thư viện hiện có hoặc Node.js built-in đã
đáp ứng rõ ràng.
- Không sửa `dist/`, `node_modules/` hoặc generated Prisma Client.
- Khi thêm env var, cập nhật `.env.example` bằng placeholder an toàn và cập nhật
config validation/access tương ứng; không sao chép giá trị từ `.env`.
# Quy trình làm việc
## 1. Trước khi sửa
- Chạy `git status --short` và giữ nguyên thay đổi không liên quan của người dùng.
- Đọc entry point, module liên quan, Prisma schema và config cần thiết.
- Tóm tắt phạm vi cùng giả định quan trọng trước thay đổi lớn.
## 2. Khi triển khai
- Tạo thay đổi nhỏ, tập trung và theo kiến trúc hiện tại.
- Không refactor ngoài phạm vi chỉ để làm đẹp.
- Khi hành vi API đổi, cập nhật route/validation/DTO/Swagger hoặc README có liên
quan nếu chúng đang mô tả hành vi đó.
- Khi schema đổi, thêm migration mới và kiểm tra tác động dữ liệu.
## 3. Verification
Chọn kiểm tra tương ứng với thay đổi:
```bash
pnpm build
pnpm exec prisma validate
pnpm run prisma:generate
```
- Luôn ưu tiên `pnpm build` cho thay đổi TypeScript.
- Chạy `prisma validate` khi sửa schema, seed hoặc database config.
- Chạy generate khi Prisma schema làm thay đổi client types.
- Chỉ chạy `pnpm lint` khi ESLint config đã tồn tại và hoạt động.
- Hiện chưa có test suite; không tuyên bố “tests pass” nếu chỉ build thành công.
- Không chạy migration reset hoặc seed lên database không xác định.
## 4. Trước khi bàn giao
- Xem lại `git diff --check`, `git diff``git status --short`.
- Kiểm tra không có `.env`, secret, generated output hoặc file ngoài phạm vi bị
đưa vào diff.
- Báo ngắn gọn: kết quả, file chính, verification đã chạy và phần chưa kiểm tra.
- Cập nhật `.agents/memory.md` nếu có quyết định dài hạn mới.
...@@ -5,3 +5,4 @@ dist/ ...@@ -5,3 +5,4 @@ dist/
storage/exports/* storage/exports/*
!storage/exports/.gitkeep !storage/exports/.gitkeep
.DS_Store .DS_Store
.agents/local.md
# FinWise Backend Agent Guide
Tài liệu này là điểm vào chính cho mọi phiên làm việc với Codex trong repository.
Mục tiêu là giữ cách phân tích, triển khai và kiểm tra thay đổi nhất quán.
## Context bắt buộc
Trước khi sửa code:
1. Đọc `.agents/project.md`.
2. Đọc các quy tắc trong `.agents/rules/` có liên quan; với thay đổi code backend,
tối thiểu đọc cả ba file:
- `.agents/rules/architecture.md`
- `.agents/rules/tech-defaults.md`
- `.agents/rules/workflow.md`
3. Đọc `.agents/memory.md` để biết trạng thái và quyết định dài hạn hiện tại.
4. Nếu có `.agents/local.md`, đọc file đó sau cùng. Đây là ghi chú riêng của máy
và không được commit.
Code và cấu hình đang chạy là nguồn sự thật cao nhất. Nếu tài liệu khác với code,
hãy nêu sự khác biệt, làm theo yêu cầu hiện tại và cập nhật tài liệu khi thay đổi
đã được xác nhận.
## Nguyên tắc cốt lõi
- Giữ thay đổi đúng phạm vi yêu cầu; không tiện tay refactor phần không liên quan.
- Tôn trọng kiến trúc module hiện tại:
`route -> validation -> controller -> service -> repository`.
- Validate dữ liệu ở biên HTTP bằng Zod.
- Controller chỉ xử lý HTTP; business rule thuộc service; Prisma query thuộc
repository.
- Dùng `AppError``ERROR_CODE` cho lỗi nghiệp vụ có chủ đích.
- Không đọc, ghi log, commit hoặc đưa vào phản hồi giá trị bí mật từ `.env`.
- Không sửa trực tiếp `dist/`, `node_modules/` hoặc migration đã được áp dụng.
- Không chạy reset database, xóa dữ liệu hay tạo migration phá hủy nếu chưa có
yêu cầu và xác nhận rõ ràng.
- Dùng `pnpm` theo `packageManager` trong `package.json`.
## Prisma schema, migration và generated client
- Khi code sử dụng model, field, enum, relation, index hoặc default mới trong
`schema.prisma`, luôn kiểm tra cả `prisma/migrations/` và generated Prisma
Client. Không được kết luận “schema đã có nên không cần migration”.
- Mọi thay đổi database chưa có trong migration history phải có migration mới;
không sửa migration cũ đã được áp dụng.
- Sau thay đổi Prisma, bắt buộc chạy theo thứ tự phù hợp:
`pnpm exec prisma validate`, `pnpm run prisma:generate`, rồi `pnpm build`.
Xác nhận generated types thực sự chứa field mới; không chỉ dựa vào một lần
build thành công vì TypeScript hoặc IDE có thể đang resolve client khác/stale.
- Nếu database local đã được đồng bộ bằng `db push` nhưng migration history còn
thiếu, tạo migration cho môi trường mới mà không reset hoặc tự ý apply lại lên
database hiện tại. Báo rõ nhu cầu baseline/`migrate resolve` nếu có.
- Nếu migration diff kéo theo thao tác phá hủy hoặc thay đổi ngoài phạm vi, không
đưa chúng vào âm thầm. Chỉ tạo migration an toàn đúng phạm vi hoặc dừng để xin
xác nhận, đồng thời ghi rõ migration debt còn lại.
## Hoàn tất công việc
- Kiểm tra diff để không ghi đè thay đổi có sẵn của người dùng.
- Chạy kiểm tra phù hợp theo `.agents/rules/workflow.md`.
- Nêu rõ file đã đổi, kiểm tra đã chạy và hạn chế còn lại.
- Khi một quyết định kiến trúc hoặc trạng thái dự án bền vững thay đổi, cập nhật
`.agents/memory.md`; không biến file này thành nhật ký từng phiên.
## Code review
- Ưu tiên lỗi correctness, security, phân quyền, validation, rò rỉ dữ liệu nhạy
cảm, tính nhất quán transaction và migration.
- Mỗi nhận xét phải chỉ ra file/vị trí, tác động và hướng sửa an toàn.
- Không báo lỗi chỉ mang tính format nếu formatter có thể xử lý tự động.
-- DropForeignKey
ALTER TABLE "transactions" DROP CONSTRAINT "transactions_wallet_id_fkey";
-- AlterTable
ALTER TABLE "wallets"
ADD COLUMN "color" TEXT,
ADD COLUMN "currency" VARCHAR(3) NOT NULL DEFAULT 'VND',
ADD COLUMN "description" TEXT,
ADD COLUMN "icon" TEXT,
ADD COLUMN "is_archived" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "is_default" BOOLEAN NOT NULL DEFAULT false,
ALTER COLUMN "balance" SET DEFAULT 0,
ALTER COLUMN "balance" SET DATA TYPE DECIMAL(18, 2);
-- CreateIndex
CREATE INDEX "wallets_user_id_idx" ON "wallets"("user_id");
-- AddForeignKey
ALTER TABLE "transactions"
ADD CONSTRAINT "transactions_wallet_id_fkey"
FOREIGN KEY ("wallet_id") REFERENCES "wallets"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
...@@ -9,6 +9,8 @@ export const ERROR_CODE = { ...@@ -9,6 +9,8 @@ export const ERROR_CODE = {
TOKEN_EXPIRED: 'TOKEN_EXPIRED', TOKEN_EXPIRED: 'TOKEN_EXPIRED',
TOKEN_INVALID: 'TOKEN_INVALID', TOKEN_INVALID: 'TOKEN_INVALID',
DUPLICATE_ENTRY: 'DUPLICATE_ENTRY', DUPLICATE_ENTRY: 'DUPLICATE_ENTRY',
WALLET_ARCHIVED: 'WALLET_ARCHIVED',
DEFAULT_WALLET_REQUIRED: 'DEFAULT_WALLET_REQUIRED',
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',
......
This diff is collapsed.
...@@ -3,11 +3,15 @@ import { ZodSchema } from 'zod'; ...@@ -3,11 +3,15 @@ import { ZodSchema } from 'zod';
import { AppError } from '../common/errors/app-error'; import { AppError } from '../common/errors/app-error';
import { ERROR_CODE } from '../common/errors/error-code'; import { ERROR_CODE } from '../common/errors/error-code';
type ValidateTarget = 'body' | 'query'; type ValidateTarget = 'body' | 'query' | 'params';
export function validate(schema: ZodSchema, target: ValidateTarget = 'body') { export function validate(schema: ZodSchema, target: ValidateTarget = 'body') {
return (req: Request, res: Response, next: NextFunction): void => { return (req: Request, res: Response, next: NextFunction): void => {
const input = target === 'query' ? req.query : req.body; const input = target === 'query'
? req.query
: target === 'params'
? req.params
: req.body;
const result = schema.safeParse(input); const result = schema.safeParse(input);
if (!result.success) { if (!result.success) {
...@@ -18,10 +22,15 @@ export function validate(schema: ZodSchema, target: ValidateTarget = 'body') { ...@@ -18,10 +22,15 @@ export function validate(schema: ZodSchema, target: ValidateTarget = 'body') {
return; return;
} }
if (target === 'query') { switch (target) {
req.query = result.data; case 'query':
} else { req.query = result.data;
req.body = result.data; break;
case 'params':
req.params = result.data;
break;
default:
req.body = result.data;
} }
next(); next();
......
import { NextFunction, Request, Response } from 'express';
import { CreateWalletDto, UpdateWalletDto, WalletQueryDto } from './wallet.dto';
import { WalletService } from './wallet.service';
export class WalletController {
private readonly service = new WalletService();
findAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const query = req.query as unknown as WalletQueryDto;
const result = await this.service.findAll(req.user.id, query);
res.json({ success: true, ...result });
} catch (error) {
next(error);
}
};
findById = async (req: Request, res: Response, next: NextFunction) => {
try {
const wallet = await this.service.findById(req.user.id, req.params.id);
res.json({ success: true, data: wallet });
} catch (error) {
next(error);
}
};
create = async (req: Request, res: Response, next: NextFunction) => {
try {
const wallet = await this.service.create(req.user.id, req.body as CreateWalletDto);
res.status(201).json({ success: true, data: wallet });
} catch (error) {
next(error);
}
};
update = async (req: Request, res: Response, next: NextFunction) => {
try {
const wallet = await this.service.update(
req.user.id,
req.params.id,
req.body as UpdateWalletDto,
);
res.json({ success: true, data: wallet });
} catch (error) {
next(error);
}
};
setDefault = async (req: Request, res: Response, next: NextFunction) => {
try {
const wallet = await this.service.setDefault(req.user.id, req.params.id);
res.json({ success: true, data: wallet });
} catch (error) {
next(error);
}
};
archive = async (req: Request, res: Response, next: NextFunction) => {
try {
const wallet = await this.service.archive(req.user.id, req.params.id);
res.json({
success: true,
message: 'Wallet archived successfully',
data: wallet,
});
} catch (error) {
next(error);
}
};
restore = async (req: Request, res: Response, next: NextFunction) => {
try {
const wallet = await this.service.restore(req.user.id, req.params.id);
res.json({ success: true, data: wallet });
} catch (error) {
next(error);
}
};
}
export type WalletSortField = 'name' | 'balance' | 'createdAt' | 'updatedAt';
export type SortOrder = 'asc' | 'desc';
export interface WalletQueryDto {
includeArchived: boolean;
sortBy: WalletSortField;
order: SortOrder;
page: number;
limit: number;
}
export interface CreateWalletDto {
name: string;
balance?: string;
currency?: string;
icon?: string | null;
color?: string | null;
description?: string | null;
isDefault?: boolean;
}
export interface UpdateWalletDto {
name?: string;
balance?: string;
currency?: string;
icon?: string | null;
color?: string | null;
description?: string | null;
}
export interface WalletResponseDto {
id: string;
name: string;
balance: string;
currency: string;
icon: string | null;
color: string | null;
description: string | null;
isDefault: boolean;
isArchived: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface WalletListResponseDto {
data: WalletResponseDto[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
import { Prisma } from '@prisma/client';
import { prisma } from '../../database/prisma.client';
import {
CreateWalletDto,
UpdateWalletDto,
WalletQueryDto,
WalletResponseDto,
} from './wallet.dto';
const walletSelect = {
id: true,
name: true,
balance: true,
currency: true,
icon: true,
color: true,
description: true,
isDefault: true,
isArchived: true,
createdAt: true,
updatedAt: true,
} satisfies Prisma.WalletSelect;
type WalletRecord = Prisma.WalletGetPayload<{ select: typeof walletSelect }>;
function toWalletResponse(wallet: WalletRecord): WalletResponseDto {
return {
...wallet,
balance: wallet.balance.toFixed(2),
};
}
async function runSerializableTransaction<T>(
operation: (transaction: Prisma.TransactionClient) => 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');
}
export class WalletRepository {
async findAll(userId: string, query: WalletQueryDto) {
const { includeArchived, sortBy, order, page, limit } = query;
const where: Prisma.WalletWhereInput = {
userId,
...(includeArchived ? {} : { isArchived: false }),
};
const skip = (page - 1) * limit;
const [wallets, total] = await prisma.$transaction([
prisma.wallet.findMany({
where,
select: walletSelect,
orderBy: { [sortBy]: order },
skip,
take: limit,
}),
prisma.wallet.count({ where }),
]);
return {
data: wallets.map(toWalletResponse),
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async findById(userId: string, id: string) {
const wallet = await prisma.wallet.findFirst({
where: { id, userId },
select: walletSelect,
});
return wallet ? toWalletResponse(wallet) : null;
}
findByName(userId: string, name: string, excludeId?: string) {
return prisma.wallet.findFirst({
where: {
userId,
name,
...(excludeId ? { id: { not: excludeId } } : {}),
},
select: { id: true },
});
}
async create(userId: string, data: CreateWalletDto) {
const wallet = await runSerializableTransaction(async (transaction) => {
const activeWalletCount = await transaction.wallet.count({
where: { userId, isArchived: false },
});
const shouldBeDefault = data.isDefault === true || activeWalletCount === 0;
if (shouldBeDefault) {
await transaction.wallet.updateMany({
where: { userId, isDefault: true },
data: { isDefault: false },
});
}
return transaction.wallet.create({
data: {
userId,
name: data.name,
balance: data.balance,
currency: data.currency,
icon: data.icon,
color: data.color,
description: data.description,
isDefault: shouldBeDefault,
},
select: walletSelect,
});
});
return toWalletResponse(wallet);
}
async update(id: string, data: UpdateWalletDto) {
const wallet = await prisma.wallet.update({
where: { id },
data,
select: walletSelect,
});
return toWalletResponse(wallet);
}
async setDefault(userId: string, id: string) {
const wallet = await runSerializableTransaction(async (transaction) => {
const targetWallet = await transaction.wallet.findFirst({
where: { id, userId, isArchived: false },
select: { id: true },
});
if (!targetWallet) {
return null;
}
await transaction.wallet.updateMany({
where: { userId, isDefault: true },
data: { isDefault: false },
});
return transaction.wallet.update({
where: { id },
data: { isDefault: true },
select: walletSelect,
});
});
return wallet ? toWalletResponse(wallet) : null;
}
async archive(userId: string, id: string) {
const wallet = await runSerializableTransaction(async (transaction) => {
const targetWallet = await transaction.wallet.findFirst({
where: { id, userId },
select: walletSelect,
});
if (!targetWallet || targetWallet.isDefault) {
return null;
}
if (targetWallet.isArchived) {
return targetWallet;
}
return transaction.wallet.update({
where: { id },
data: { isArchived: true },
select: walletSelect,
});
});
return wallet ? toWalletResponse(wallet) : null;
}
async restore(userId: string, id: string) {
const wallet = await runSerializableTransaction(async (transaction) => {
const defaultWallet = await transaction.wallet.findFirst({
where: { userId, isDefault: true, isArchived: false },
select: { id: true },
});
return transaction.wallet.update({
where: { id },
data: {
isArchived: false,
isDefault: defaultWallet === null,
},
select: walletSelect,
});
});
return toWalletResponse(wallet);
}
}
import { Router } from 'express';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { WalletController } from './wallet.controller';
import {
createWalletSchema,
findWalletsSchema,
updateWalletSchema,
walletParamsSchema,
} from './wallet.validation';
const router = Router();
const controller = new WalletController();
router.use(authMiddleware);
router.get('/', validate(findWalletsSchema, 'query'), controller.findAll);
router.post('/', validate(createWalletSchema), controller.create);
router.get('/:id', validate(walletParamsSchema, 'params'), controller.findById);
router.put(
'/:id',
validate(walletParamsSchema, 'params'),
validate(updateWalletSchema),
controller.update,
);
router.patch(
'/:id/default',
validate(walletParamsSchema, 'params'),
controller.setDefault,
);
router.patch(
'/:id/restore',
validate(walletParamsSchema, 'params'),
controller.restore,
);
router.delete(
'/:id',
validate(walletParamsSchema, 'params'),
controller.archive,
);
export default router;
import { Prisma } from '@prisma/client';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { CreateWalletDto, UpdateWalletDto, WalletQueryDto } from './wallet.dto';
import { WalletRepository } from './wallet.repository';
export class WalletService {
private readonly repository = new WalletRepository();
findAll(userId: string, query: WalletQueryDto) {
return this.repository.findAll(userId, query);
}
async findById(userId: string, id: string) {
const wallet = await this.repository.findById(userId, id);
if (!wallet) {
throw new AppError('Wallet not found', 404, ERROR_CODE.NOT_FOUND);
}
return wallet;
}
async create(userId: string, data: CreateWalletDto) {
await this.ensureUniqueName(userId, data.name);
try {
return await this.repository.create(userId, data);
} catch (error) {
this.handleUniqueConstraint(error);
throw error;
}
}
async update(userId: string, id: string, data: UpdateWalletDto) {
await this.findById(userId, id);
if (data.name !== undefined) {
await this.ensureUniqueName(userId, data.name, id);
}
try {
return await this.repository.update(id, data);
} catch (error) {
this.handleUniqueConstraint(error);
throw error;
}
}
async setDefault(userId: string, id: string) {
const wallet = await this.findById(userId, id);
if (wallet.isArchived) {
throw new AppError('Archived wallet cannot be set as default', 409, ERROR_CODE.WALLET_ARCHIVED);
}
if (wallet.isDefault) {
return wallet;
}
const updatedWallet = await this.repository.setDefault(userId, id);
if (!updatedWallet) {
throw new AppError('Archived wallet cannot be set as default', 409, ERROR_CODE.WALLET_ARCHIVED);
}
return updatedWallet;
}
async archive(userId: string, id: string) {
const wallet = await this.findById(userId, id);
if (wallet.isArchived) {
return wallet;
}
if (wallet.isDefault) {
throw new AppError(
'Set another wallet as default before archiving this wallet',
409,
ERROR_CODE.DEFAULT_WALLET_REQUIRED,
);
}
const archivedWallet = await this.repository.archive(userId, id);
if (!archivedWallet) {
throw new AppError(
'Set another wallet as default before archiving this wallet',
409,
ERROR_CODE.DEFAULT_WALLET_REQUIRED,
);
}
return archivedWallet;
}
async restore(userId: string, id: string) {
const wallet = await this.findById(userId, id);
if (!wallet.isArchived) {
return wallet;
}
return this.repository.restore(userId, id);
}
private async ensureUniqueName(userId: string, name: string, excludeId?: string) {
const wallet = await this.repository.findByName(userId, name, excludeId);
if (wallet) {
throw new AppError('Wallet name already exists', 409, ERROR_CODE.DUPLICATE_ENTRY);
}
}
private handleUniqueConstraint(error: unknown): void {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
throw new AppError('Wallet name already exists', 409, ERROR_CODE.DUPLICATE_ENTRY);
}
}
}
import { z } from 'zod';
const decimalSchema = z
.string()
.trim()
.regex(
/^-?(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/,
'Balance must be a decimal string with at most 16 integer digits and 2 decimal places',
);
const currencySchema = z
.string()
.trim()
.length(3, 'Currency must be a 3-letter code')
.regex(/^[A-Za-z]{3}$/, 'Currency must contain letters only')
.transform((value) => value.toUpperCase());
const nullableIconSchema = z.string().trim().min(1).max(100).nullable();
const nullableColorSchema = z
.string()
.trim()
.regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, 'Color must be a valid hex color')
.nullable();
const nullableDescriptionSchema = z.string().trim().max(500).nullable();
export const walletParamsSchema = z.object({
id: z.string().uuid('Invalid wallet id'),
});
export const findWalletsSchema = z.object({
includeArchived: z
.enum(['true', 'false'])
.transform((value) => value === 'true')
.optional()
.default('false'),
sortBy: z.enum(['name', 'balance', 'createdAt', 'updatedAt']).optional().default('createdAt'),
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),
});
export const createWalletSchema = z.object({
name: z.string().trim().min(1, 'Name is required').max(100),
balance: decimalSchema.optional(),
currency: currencySchema.optional(),
icon: nullableIconSchema.optional(),
color: nullableColorSchema.optional(),
description: nullableDescriptionSchema.optional(),
isDefault: z.boolean().optional(),
});
export const updateWalletSchema = z
.object({
name: z.string().trim().min(1, 'Name cannot be empty').max(100).optional(),
balance: decimalSchema.optional(),
currency: currencySchema.optional(),
icon: nullableIconSchema.optional(),
color: nullableColorSchema.optional(),
description: nullableDescriptionSchema.optional(),
})
.refine((data) => Object.keys(data).length > 0, {
message: 'At least one field is required',
});
import { Router } from 'express'; import { Router } from 'express';
import authRoute from '../modules/auth/auth.route'; 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';
const router = Router(); const router = Router();
...@@ -10,5 +11,6 @@ router.get('/health', (req, res) => { ...@@ -10,5 +11,6 @@ router.get('/health', (req, res) => {
router.use('/auth', authRoute); router.use('/auth', authRoute);
router.use('/users', userRoute); router.use('/users', userRoute);
router.use('/wallets', walletRoute);
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