diff --git a/src/common/exceptions/escrow.exceptions.ts b/src/common/exceptions/escrow.exceptions.ts index a8ad70a..21eb5ef 100644 --- a/src/common/exceptions/escrow.exceptions.ts +++ b/src/common/exceptions/escrow.exceptions.ts @@ -114,3 +114,9 @@ export class BuyerWalletNotFoundException extends NotFoundException { super(`Buyer with wallet address "${walletAddress}" not found`); } } + +export class WalletUserNotFoundException extends NotFoundException { + constructor(walletAddress: string) { + super(`No user found with wallet address "${walletAddress}"`); + } +} diff --git a/src/modules/escrow-payments/__tests__/create.spec.ts b/src/modules/escrow-payments/__tests__/create.spec.ts index 5bfa5b7..cda0f3a 100644 --- a/src/modules/escrow-payments/__tests__/create.spec.ts +++ b/src/modules/escrow-payments/__tests__/create.spec.ts @@ -98,7 +98,12 @@ describe("EscrowPaymentsCrudService › create", () => { }); it("save() 호출", async () => { - const instance = { save: jest.fn().mockResolvedValue({}) }; + const payment = { buyerId: BUYER_ID, sellerId: SELLER_ID }; + const instance = { + save: jest.fn().mockReturnThis(), + toObject: jest.fn().mockReturnValue(payment), + }; + instance.save.mockResolvedValue(instance); ctx.escrowPaymentModel.mockReturnValue(instance); await ctx.service.create(makeDto(), BUYER_ID.toString()); diff --git a/src/modules/escrow-payments/__tests__/helpers.ts b/src/modules/escrow-payments/__tests__/helpers.ts index 9f1e35c..3502d07 100644 --- a/src/modules/escrow-payments/__tests__/helpers.ts +++ b/src/modules/escrow-payments/__tests__/helpers.ts @@ -95,6 +95,7 @@ export function makePayment(overrides: object = {}) { ...overrides, }; doc.save = jest.fn().mockResolvedValue(doc); + doc.toObject = jest.fn().mockReturnValue(doc); return doc; } @@ -129,6 +130,7 @@ export function makeEscrowPaymentModelMock() { const ModelMock: any = jest.fn().mockImplementation((data: any) => { const instance = { ...data }; instance.save = jest.fn().mockResolvedValue(instance); + instance.toObject = jest.fn().mockReturnValue(instance); return instance; }); ModelMock.findById = jest.fn().mockReturnValue(makeQueryChain(null)); diff --git a/src/modules/escrow-payments/dto/escrow-payment-response.dto.ts b/src/modules/escrow-payments/dto/escrow-payment-response.dto.ts new file mode 100644 index 0000000..2436bc7 --- /dev/null +++ b/src/modules/escrow-payments/dto/escrow-payment-response.dto.ts @@ -0,0 +1,166 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Types } from "mongoose"; + +export class EventApprovalResponse { + @ApiProperty({ description: "이벤트 이름" }) + eventType: string; + + @ApiProperty({ description: "구매자 승인 여부" }) + buyerApproved: boolean; + + @ApiPropertyOptional({ description: "구매자 승인 일시" }) + buyerApprovedAt?: Date; + + @ApiProperty({ description: "판매자 승인 여부" }) + sellerApproved: boolean; + + @ApiPropertyOptional({ description: "판매자 승인 일시" }) + sellerApprovedAt?: Date; + + @ApiPropertyOptional({ description: "이벤트 완료 일시" }) + completedAt?: Date; +} + +export class EscrowItemResponse { + @ApiProperty({ type: String, description: "에스크로 항목 ID" }) + _id: Types.ObjectId; + + @ApiProperty({ description: "에스크로 항목 라벨" }) + label: string; + + @ApiProperty({ description: "XRP 금액" }) + amountXrp: number; + + @ApiProperty({ description: "항목 순서" }) + order: number; + + @ApiProperty({ + description: "항목 상태", + enum: [ + "PENDING_ESCROW", + "SUBMITTING", + "ESCROWED", + "RELEASING", + "RELEASED", + "CANCELLING", + "CANCELLED", + ], + }) + status: string; + + @ApiProperty({ type: [String], description: "필요 이벤트 목록" }) + requiredEventTypes: string[]; + + @ApiProperty({ + type: [EventApprovalResponse], + description: "이벤트 승인 현황", + }) + approvals: EventApprovalResponse[]; + + @ApiPropertyOptional({ description: "XRPL 시퀀스 번호" }) + xrplSequence?: number; + + @ApiPropertyOptional({ description: "XRPL 에스크로 조건" }) + condition?: string; + + @ApiPropertyOptional({ description: "에스크로 생성 트랜잭션 해시" }) + txHashCreate?: string; + + @ApiPropertyOptional({ description: "에스크로 해제 트랜잭션 해시" }) + txHashRelease?: string; + + @ApiPropertyOptional({ description: "XRPL 제출 일시" }) + submittingAt?: Date; + + @ApiPropertyOptional({ description: "에스크로 확정 일시" }) + escrowedAt?: Date; + + @ApiPropertyOptional({ description: "에스크로 해제 일시" }) + releasedAt?: Date; +} + +export class EscrowPaymentResponse { + @ApiProperty({ type: String, description: "에스크로 결제 ID" }) + _id: Types.ObjectId; + + @ApiProperty({ type: String, description: "내 유저 ID" }) + myId: Types.ObjectId; + + @ApiProperty({ type: String, description: "상대방 유저 ID" }) + partnerId: Types.ObjectId; + + @ApiProperty({ description: "내 이름" }) + myName: string; + + @ApiProperty({ description: "상대방 이름" }) + partnerName: string; + + @ApiProperty({ description: "내 지갑 주소" }) + myWalletAddress: string; + + @ApiProperty({ description: "상대방 지갑 주소" }) + partnerWalletAddress: string; + + @ApiProperty({ description: "총 XRP 금액" }) + totalAmountXrp: number; + + @ApiProperty({ description: "통화 (XRP/RLUSD)", enum: ["XRP", "RLUSD"] }) + currency: string; + + @ApiProperty({ + description: "결제 상태", + enum: [ + "PENDING_APPROVAL", + "APPROVED", + "PROCESSING", + "ACTIVE", + "COMPLETED", + "CANCELLED", + ], + }) + status: string; + + @ApiProperty({ description: "구매자 승인 여부" }) + buyerApproved: boolean; + + @ApiPropertyOptional({ description: "구매자 승인 일시" }) + buyerApprovedAt?: Date; + + @ApiProperty({ description: "판매자 승인 여부" }) + sellerApproved: boolean; + + @ApiPropertyOptional({ description: "판매자 승인 일시" }) + sellerApprovedAt?: Date; + + @ApiProperty({ description: "메모" }) + memo: string; + + @ApiProperty({ + type: [EscrowItemResponse], + description: "세부 에스크로 항목", + }) + escrows: EscrowItemResponse[]; + + @ApiProperty({ description: "생성 일시" }) + createdAt: Date; + + @ApiProperty({ description: "수정 일시" }) + updatedAt: Date; +} + +export class EscrowPaymentListResponse { + @ApiProperty({ + type: [EscrowPaymentResponse], + description: "결제 목록 데이터", + }) + data: EscrowPaymentResponse[]; + + @ApiProperty({ description: "전체 항목 수" }) + total: number; + + @ApiProperty({ description: "현재 페이지 번호" }) + page: number; + + @ApiProperty({ description: "페이지당 항목 수" }) + limit: number; +} diff --git a/src/modules/escrow-payments/escrow-payments-crud.service.ts b/src/modules/escrow-payments/escrow-payments-crud.service.ts index ee94f52..5bc19fe 100644 --- a/src/modules/escrow-payments/escrow-payments-crud.service.ts +++ b/src/modules/escrow-payments/escrow-payments-crud.service.ts @@ -6,6 +6,7 @@ import { EscrowPaymentNotFoundException, SellerWalletNotFoundException, UnauthorizedPaymentActionException, + WalletUserNotFoundException, } from "../../common/exceptions"; import { EscrowPayment, @@ -15,25 +16,10 @@ import { User, UserDocument } from "../users/schemas/user.schema"; import { CreateEscrowPaymentDto } from "./dto/create-escrow-payment.dto"; import { QueryEscrowPaymentDto } from "./dto/query-escrow-payment.dto"; -export interface EscrowPaymentResponse extends Omit< - EscrowPayment, - | "buyerId" - | "sellerId" - | "buyerName" - | "sellerName" - | "buyerWalletAddress" - | "sellerWalletAddress" -> { - _id: Types.ObjectId; - myId: Types.ObjectId; - partnerId: Types.ObjectId; - myName: string; - myWalletAddress: string; - partnerName: string; - partnerWalletAddress: string; - createdAt: Date; - updatedAt: Date; -} +import { + EscrowPaymentListResponse, + EscrowPaymentResponse, +} from "./dto/escrow-payment-response.dto"; @Injectable() export class EscrowPaymentsCrudService { @@ -50,7 +36,7 @@ export class EscrowPaymentsCrudService { async create( dto: CreateEscrowPaymentDto, userId: string, - ): Promise { + ): Promise { const creator = await this.userModel .findById(userId, { type: 1, name: 1, wallet: 1 }) .lean(); @@ -109,7 +95,7 @@ export class EscrowPaymentsCrudService { } if (!buyerWalletAddress || !sellerWalletAddress) { - throw new UnauthorizedPaymentActionException(); // Or a more specific exception if wallet is missing + throw new UnauthorizedPaymentActionException(); } const totalAmountXrp = dto.escrows.reduce((sum, e) => sum + e.amountXrp, 0); @@ -142,7 +128,8 @@ export class EscrowPaymentsCrudService { })), }); - return doc.save(); + const saved = await doc.save(); + return this.mapToResponse(saved.toObject(), userId); } /** @@ -151,12 +138,7 @@ export class EscrowPaymentsCrudService { async findAll( userId: string, dto: QueryEscrowPaymentDto, - ): Promise<{ - data: EscrowPaymentResponse[]; - total: number; - page: number; - limit: number; - }> { + ): Promise { const { status, group, page = 1, limit = 5 } = dto; const uid = new Types.ObjectId(userId); @@ -185,37 +167,9 @@ export class EscrowPaymentsCrudService { this.escrowPaymentModel.countDocuments(filter), ]); - const mappedData = data.map((item: any) => { - const isBuyer = item.buyerId.toString() === userId; - const myId = isBuyer ? item.buyerId : item.sellerId; - const partnerId = isBuyer ? item.sellerId : item.buyerId; - const myName = isBuyer ? item.buyerName : item.sellerName; - const partnerName = isBuyer ? item.sellerName : item.buyerName; - const myWalletAddress = isBuyer - ? item.buyerWalletAddress - : item.sellerWalletAddress; - const partnerWalletAddress = isBuyer - ? item.sellerWalletAddress - : item.buyerWalletAddress; - - const rest = { ...item }; - delete rest.buyerId; - delete rest.sellerId; - delete rest.buyerName; - delete rest.sellerName; - delete rest.buyerWalletAddress; - delete rest.sellerWalletAddress; - - return { - ...rest, - myId, - partnerId, - myName, - myWalletAddress, - partnerName, - partnerWalletAddress, - }; - }); + const mappedData = data.map((item: any) => + this.mapToResponse(item, userId), + ); return { data: mappedData, @@ -235,19 +189,34 @@ export class EscrowPaymentsCrudService { throw new UnauthorizedPaymentActionException(); } - const isBuyer = doc.buyerId.toString() === userId; - const myId = isBuyer ? doc.buyerId : doc.sellerId; - const partnerId = isBuyer ? doc.sellerId : doc.buyerId; - const myName = isBuyer ? doc.buyerName : doc.sellerName; - const partnerName = isBuyer ? doc.sellerName : doc.buyerName; + return this.mapToResponse(doc, userId); + } + + /** + * XRPL 지갑 주소로 사용자 조회 + */ + async findUserByWalletAddress(walletAddress: string): Promise { + const user = await this.userModel + .findOne({ "wallet.address": walletAddress }) + .lean(); + if (!user) throw new WalletUserNotFoundException(walletAddress); + return user as UserDocument; + } + + private mapToResponse(item: any, userId: string): EscrowPaymentResponse { + const isBuyer = item.buyerId.toString() === userId; + const myId = isBuyer ? item.buyerId : item.sellerId; + const partnerId = isBuyer ? item.sellerId : item.buyerId; + const myName = isBuyer ? item.buyerName : item.sellerName; + const partnerName = isBuyer ? item.sellerName : item.buyerName; const myWalletAddress = isBuyer - ? doc.buyerWalletAddress - : doc.sellerWalletAddress; + ? item.buyerWalletAddress + : item.sellerWalletAddress; const partnerWalletAddress = isBuyer - ? doc.sellerWalletAddress - : doc.buyerWalletAddress; + ? item.sellerWalletAddress + : item.buyerWalletAddress; - const rest = { ...(doc as any) }; + const rest = { ...item }; delete rest.buyerId; delete rest.sellerId; delete rest.buyerName; @@ -265,15 +234,4 @@ export class EscrowPaymentsCrudService { partnerWalletAddress, }; } - - /** - * XRPL 지갑 주소로 사용자 조회 - */ - async findUserByWalletAddress(walletAddress: string): Promise { - const user = await this.userModel - .findOne({ "wallet.address": walletAddress }) - .lean(); - if (!user) throw new SellerWalletNotFoundException(walletAddress); - return user; - } } diff --git a/src/modules/escrow-payments/escrow-payments.controller.ts b/src/modules/escrow-payments/escrow-payments.controller.ts index 17e77c3..196c8d7 100644 --- a/src/modules/escrow-payments/escrow-payments.controller.ts +++ b/src/modules/escrow-payments/escrow-payments.controller.ts @@ -27,6 +27,11 @@ import { import { ParseMongoIdPipe } from "../../common/pipes/parse-mongo-id.pipe"; import { ParseXrplAddressPipe } from "../../common/pipes/parse-xrpl-address.pipe"; +import { + EscrowPaymentListResponse, + EscrowPaymentResponse, +} from "./dto/escrow-payment-response.dto"; + @ApiTags("Escrow Payments") @ApiCookieAuth() @UseGuards(SessionGuard) @@ -61,8 +66,8 @@ export class EscrowPaymentsController { }) @ApiResponse({ status: 200, - description: - "{ data: EscrowPayment[], total: number, page: number, limit: number }", + description: "목록 조회 성공", + type: EscrowPaymentListResponse, }) findAll( @Query() query: QueryEscrowPaymentDto, @@ -78,7 +83,11 @@ export class EscrowPaymentsController { "buyer/seller 간 에스크로 결제 계획을 생성합니다. 생성 직후 상태는 PENDING_APPROVAL이며, 양측이 모두 승인해야 APPROVED로 전환됩니다.", }) @ApiBody({ type: CreateEscrowPaymentDto }) - @ApiResponse({ status: 201, description: "결제 내역 생성 성공" }) + @ApiResponse({ + status: 201, + description: "결제 내역 생성 성공", + type: EscrowPaymentResponse, + }) @ApiResponse({ status: 400, description: "잘못된 요청 (유효성 검사 실패)" }) @ApiResponse({ status: 401, description: "인증되지 않은 사용자" }) create( @@ -94,7 +103,11 @@ export class EscrowPaymentsController { description: "결제 ID로 에스크로 결제 내역 전체를 조회합니다.", }) @ApiParam({ name: "id", description: "에스크로 결제 ID (MongoDB ObjectId)" }) - @ApiResponse({ status: 200, description: "조회 성공" }) + @ApiResponse({ + status: 200, + description: "조회 성공", + type: EscrowPaymentResponse, + }) @ApiResponse({ status: 401, description: "인증되지 않은 사용자" }) @ApiResponse({ status: 404, description: "결제 내역 없음" }) findById( diff --git a/test/escrow-mock.e2e-spec.ts b/test/escrow-mock.e2e-spec.ts index 84f0a31..dfa19b8 100644 --- a/test/escrow-mock.e2e-spec.ts +++ b/test/escrow-mock.e2e-spec.ts @@ -500,7 +500,7 @@ describe("EscrowPayments (e2e)", () => { targetId = res.body._id; }); - it("존재하는 결제 → 200, 정확한 데이터 반환", async () => { + it("존재하는 결제 → 200, 정확한 데이터 반환 (추상화된 필드 확인)", async () => { const res = await request(app.getHttpServer()) .get(`/escrow-payments/${targetId}`) .set(asBuyer()) @@ -508,6 +508,20 @@ describe("EscrowPayments (e2e)", () => { expect(res.body._id).toBe(targetId); expect(res.body.memo).toBe("단건 조회 테스트"); + + // 추상화된 필드 확인 + expect(res.body.myId).toBe(buyerObjectId.toString()); + expect(res.body.partnerId).toBe(sellerObjectId.toString()); + expect(res.body.myName).toBe("Test Buyer Corp"); + expect(res.body.partnerName).toBe("Test Seller Corp"); + expect(res.body.myWalletAddress).toBe(BUYER_WALLET_ADDR); + expect(res.body.partnerWalletAddress).toBe(SELLER_WALLET_ADDR); + + // 제거된 필드 확인 + expect(res.body.buyerId).toBeUndefined(); + expect(res.body.sellerId).toBeUndefined(); + expect(res.body.buyerName).toBeUndefined(); + expect(res.body.sellerName).toBeUndefined(); }); it("존재하지 않는 id → 404", async () => {