From dc436f0c42ace834e330d2235b0fd528bcbe1435 Mon Sep 17 00:00:00 2001 From: minij02 Date: Sat, 23 May 2026 02:03:06 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=99=98=EB=B6=88=20-=207=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B4=EB=82=B4=20=EB=AF=B8=EC=97=B4=EB=9E=8C=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=ED=99=98=EB=B6=88=20=ED=9D=90=EB=A6=84=20=EB=B0=8F?= =?UTF-8?q?=20Refund=20=EB=AA=A8=EB=8D=B8=20(#485)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기획 정책: 구매 후 7일 이내 + 프롬프트 미다운로드 조건 충족 시 사용자가 [환불하기] 클릭 → 즉시 Payple 결제 취소 + DB 정합화. ### Prisma 스키마 변경 - Status enum에 'Refunded' 추가 (Payment / Settlement 양쪽 적용) - Purchase.downloaded_at DateTime? 컬럼 추가 (첫 다운로드 시점 기록) - Refund 모델 신규 (purchase_id/payment_id @unique, user_id 인덱스, payple_card_trade_num 감사 추적) - 마이그레이션 파일: 20260523140000_add_refund_model_and_status ### 신규 API - GET /api/prompts/purchases/:purchaseId/refund-eligibility — 환불 가능 여부 + 사유 + 잔여 시간 - POST /api/prompts/purchases/:purchaseId/refund — 환불 실행 ### Payple 결제 취소 유틸 (utils/payple-refund.ts) - fetchRefundAuth: PCD_PAYCANCEL_FLAG=Y 파트너 인증, AuthKey Redis 15분 캐시 - requestPaypleRefund: PCD_REFUND_KEY 사용, PCD_PAY_OID/PAY_DATE/REFUND_TOTAL 전송 - 응답 PCD_PAY_CODE/PCD_PAY_CARDTRADENUM 저장 (감사용) - 인증 캐시에는 cstId/custKey 제외 — 매 호출 env 직접 로드 - 로그 redactor 적용 ### 다운로드 흐름 변경 (prompt.download.service.ts) - 유료 프롬프트 다운로드 시: - Refund 존재 시 403 Refunded (재다운로드 차단) - 첫 다운로드 시점에 Purchase.downloaded_at = NOW() set → 이후 환불 불가 - 무료 프롬프트는 영향 없음 ### 환불 실행 흐름 - 이중 검증 (TOCTOU): 서비스 진입 시 + Payple 호출 전 - Payple 성공 후 prisma.$transaction: - Refund row 생성 (initiator='USER', reason='7일 이내 미열람 자동 환불') - Payment.status='Refunded' - Settlement.status='Refunded' (존재 시) - Payple 실패 시 DB 변경 없음 - 트랜잭션 내부 멱등 검사 (Refund 중복 row 차단) ### 로그 redactor 확장 (payple.ts) - REDACTED_FIELDS에 PCD_REFUND_KEY / PCD_PAYER_ID / PCD_PAY_CARDTRADENUM 추가 ### 환경변수 - PAYPLE_REFUND_KEY: Payple 파트너 관리자에서 발급 - PAYPLE_REFUND_AUTH_PATH: 환불용 파트너 인증 endpoint (기본 /php/auth.php) 별도 이슈로 분리: - 관리자 수동 환불 (부분환불 등) - 환불 매출 자동 환수 회계 로직 - settlement-sync 잡의 CANCEL 매칭 보강 - 다운로드 이력 정규화 (PromptDownload 테이블) --- .../migration.sql | 33 ++ prisma/schema.prisma | 382 +++++++++--------- src/index.ts | 2 + .../services/prompt.download.service.ts | 15 +- src/refunds/controllers/refund.controller.ts | 57 +++ src/refunds/dtos/refund.dto.ts | 24 ++ src/refunds/routes/refund.route.ts | 103 +++++ src/refunds/services/refund.service.ts | 156 +++++++ src/settlements/utils/payple-refund.ts | 154 +++++++ src/settlements/utils/payple.ts | 4 + swagger.json | 143 +++++++ 11 files changed, 892 insertions(+), 181 deletions(-) create mode 100644 prisma/migrations/20260523140000_add_refund_model_and_status/migration.sql create mode 100644 src/refunds/controllers/refund.controller.ts create mode 100644 src/refunds/dtos/refund.dto.ts create mode 100644 src/refunds/routes/refund.route.ts create mode 100644 src/refunds/services/refund.service.ts create mode 100644 src/settlements/utils/payple-refund.ts diff --git a/prisma/migrations/20260523140000_add_refund_model_and_status/migration.sql b/prisma/migrations/20260523140000_add_refund_model_and_status/migration.sql new file mode 100644 index 0000000..45fb700 --- /dev/null +++ b/prisma/migrations/20260523140000_add_refund_model_and_status/migration.sql @@ -0,0 +1,33 @@ +-- AlterTable: Purchase 다운로드 시점 컬럼 +ALTER TABLE `Purchase` ADD COLUMN `downloaded_at` DATETIME(3) NULL; + +-- AlterTable: Status enum에 Refunded 추가 +ALTER TABLE `Payment` MODIFY COLUMN `status` ENUM('Pending','Succeed','Failed','Refunded') NOT NULL; +ALTER TABLE `Settlement` MODIFY COLUMN `status` ENUM('Pending','Succeed','Failed','Refunded') NOT NULL; + +-- CreateTable: Refund +CREATE TABLE `Refund` ( + `refund_id` INTEGER NOT NULL AUTO_INCREMENT, + `purchase_id` INTEGER NOT NULL, + `payment_id` INTEGER NOT NULL, + `user_id` INTEGER NOT NULL, + `amount` INTEGER NOT NULL, + `reason` VARCHAR(200) NULL, + `initiator` VARCHAR(20) NOT NULL, + `payple_pay_code` VARCHAR(20) NULL, + `payple_card_trade_num` VARCHAR(64) NULL, + `refunded_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `Refund_purchase_id_key`(`purchase_id`), + UNIQUE INDEX `Refund_payment_id_key`(`payment_id`), + INDEX `Refund_user_id_idx`(`user_id`), + PRIMARY KEY (`refund_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `Refund` ADD CONSTRAINT `Refund_purchase_id_fkey` + FOREIGN KEY (`purchase_id`) REFERENCES `Purchase`(`purchase_id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `Refund` ADD CONSTRAINT `Refund_payment_id_fkey` + FOREIGN KEY (`payment_id`) REFERENCES `Payment`(`payment_id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `Refund` ADD CONSTRAINT `Refund_user_id_fkey` + FOREIGN KEY (`user_id`) REFERENCES `User`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b9cd259..7778175 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,60 +8,61 @@ datasource db { } model User { - user_id Int @id @default(autoincrement()) - name String @db.VarChar(50) - nickname String @db.VarChar(50) - email String @unique @db.VarChar(255) - password String? @db.VarChar(255) - is_initial_setup_required Boolean @default(true) - social_type String @db.VarChar(50) - status Boolean - userstatus userStatus @default(active) - inactive_date DateTime? - last_active_at DateTime? - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - role Role @default(USER) - announcements Announcement[] - receivedMessages Message[] @relation("ReceivedMessages") - sentMessages Message[] @relation("SentMessages") - sentNotifications Notification[] @relation("ActorNotifications") - notifications Notification[] @relation("UserNotifications") - subscribers NotificationSubscription[] @relation("NotificationTargetPrompter") - subscriptions NotificationSubscription[] @relation("NotificationSubscriber") - prompts Prompt[] - prompt_likes PromptLike[] - prompt_reports PromptReport[] @relation("UserReport") - purchases Purchase[] - refreshTokens RefreshToken[] - reviews Review[] - settlements Settlement[] @relation("UserSettlements") - tips Tip[] - profileImage UserImage? - intro UserIntro? - sns_list UserSNS[] - withdraw_requests WithdrawRequest[] - notificationSetting UserNotificationSetting? - settlementAccount SettlementAccount? - consents UserConsent[] - chatRoomAs1 ChatRoom[] @relation("ChatUser1") - chatRoomAs2 ChatRoom[] @relation("ChatUser2") - sentChatMessages ChatMessage[] @relation("UserSentMessages") - chatParticipants ChatParticipant[] - blocks UserBlock[] @relation("BlockerUser") - blockedBy UserBlock[] @relation("BlockedUser") + user_id Int @id @default(autoincrement()) + name String @db.VarChar(50) + nickname String @db.VarChar(50) + email String @unique @db.VarChar(255) + password String? @db.VarChar(255) + is_initial_setup_required Boolean @default(true) + social_type String @db.VarChar(50) + status Boolean + userstatus userStatus @default(active) + inactive_date DateTime? + last_active_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + role Role @default(USER) + announcements Announcement[] + receivedMessages Message[] @relation("ReceivedMessages") + sentMessages Message[] @relation("SentMessages") + sentNotifications Notification[] @relation("ActorNotifications") + notifications Notification[] @relation("UserNotifications") + subscribers NotificationSubscription[] @relation("NotificationTargetPrompter") + subscriptions NotificationSubscription[] @relation("NotificationSubscriber") + prompts Prompt[] + prompt_likes PromptLike[] + prompt_reports PromptReport[] @relation("UserReport") + purchases Purchase[] + refreshTokens RefreshToken[] + reviews Review[] + settlements Settlement[] @relation("UserSettlements") + tips Tip[] + profileImage UserImage? + intro UserIntro? + sns_list UserSNS[] + withdraw_requests WithdrawRequest[] + notificationSetting UserNotificationSetting? + settlementAccount SettlementAccount? + consents UserConsent[] + refunds Refund[] + chatRoomAs1 ChatRoom[] @relation("ChatUser1") + chatRoomAs2 ChatRoom[] @relation("ChatUser2") + sentChatMessages ChatMessage[] @relation("UserSentMessages") + chatParticipants ChatParticipant[] + blocks UserBlock[] @relation("BlockerUser") + blockedBy UserBlock[] @relation("BlockedUser") } model UserConsent { - id Int @id @default(autoincrement()) - user_id Int - consent_type String @db.VarChar(50) - is_agreed Boolean - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) - - @@unique([user_id, consent_type]) + id Int @id @default(autoincrement()) + user_id Int + consent_type String @db.VarChar(50) + is_agreed Boolean + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) + + @@unique([user_id, consent_type]) } model UserIntro { @@ -84,7 +85,7 @@ model UserHistory { model UserSNS { sns_id Int @id @default(autoincrement()) user_id Int - user_sns_id String @db.VarChar(100) @default(" ") + user_sns_id String @default(" ") @db.VarChar(100) url String description String created_at DateTime @default(now()) @@ -177,15 +178,14 @@ model Notification { } model UserNotificationSetting { - setting_id Int @id @default(autoincrement()) - user_id Int @unique - last_notification_check_time DateTime? - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) + setting_id Int @id @default(autoincrement()) + user_id Int @unique + last_notification_check_time DateTime? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) } - model Message { message_id Int @id @default(autoincrement()) sender_id Int @@ -244,28 +244,28 @@ model WithdrawRequest { } model Prompt { - prompt_id Int @id @default(autoincrement()) + prompt_id Int @id @default(autoincrement()) user_id Int - title String @db.Text - prompt String @db.Text - prompt_result String @db.Text + title String @db.Text + prompt String @db.Text + prompt_result String @db.Text has_image Boolean - description String @db.Text - usage_guide String @db.Text + description String @db.Text + usage_guide String @db.Text price Int is_free Boolean downloads Int views Int likes Int - model_version String @db.VarChar(50) @default("") - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + model_version String @default("") @db.VarChar(50) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt inactive_date DateTime? - user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) + user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) images PromptImage[] prompt_likes PromptLike[] models PromptModel[] - prompt_reports PromptReport[] @relation("PromptReport") + prompt_reports PromptReport[] @relation("PromptReport") categories PromptCategory[] purchases Purchase[] reviews Review[] @@ -387,16 +387,18 @@ model PromptReport { } model Purchase { - purchase_id Int @id @default(autoincrement()) - user_id Int - prompt_id Int - amount Int - is_free Boolean - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - payment Payment? - prompt Prompt @relation(fields: [prompt_id], references: [prompt_id]) - user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) + purchase_id Int @id @default(autoincrement()) + user_id Int + prompt_id Int + amount Int + is_free Boolean + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + downloaded_at DateTime? // 첫 다운로드 시점 — 환불 가능 조건 판단용 (#485) + payment Payment? + refund Refund? + prompt Prompt @relation(fields: [prompt_id], references: [prompt_id]) + user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) @@index([prompt_id], map: "Purchase_prompt_id_fkey") @@index([user_id], map: "Purchase_user_id_fkey") @@ -404,40 +406,60 @@ model Purchase { // 결제 수단 enum PaymentMethod { - CARD // 카드 - VIRTUAL_ACCOUNT // 가상계좌 - TRANSFER // 계좌이체 - MOBILE // 휴대폰 - EASY_PAY // 간편결제 + CARD // 카드 + VIRTUAL_ACCOUNT // 가상계좌 + TRANSFER // 계좌이체 + MOBILE // 휴대폰 + EASY_PAY // 간편결제 } // 구체적인 PG사 또는 간편결제사 enum PaymentProvider { - TOSSPAYMENTS // 일반 토스 PG (카드, 가상계좌 등) - KAKAOPAY // 카카오페이 - NAVERPAY // 네이버페이 - TOSSPAY // 토스페이 - SAMSUNGPAY // 삼성페이 - APPLEPAY // 애플페이 - LPAY // 엘페이 - PAYCO // 페이코 - SSG // SSG페이 - PINPAY // 핀페이 + TOSSPAYMENTS // 일반 토스 PG (카드, 가상계좌 등) + KAKAOPAY // 카카오페이 + NAVERPAY // 네이버페이 + TOSSPAY // 토스페이 + SAMSUNGPAY // 삼성페이 + APPLEPAY // 애플페이 + LPAY // 엘페이 + PAYCO // 페이코 + SSG // SSG페이 + PINPAY // 핀페이 } model Payment { - payment_id Int @id @default(autoincrement()) - purchase_id Int @unique - status Status - pcd_pay_oid String @unique - pcd_pay_reqkey String @unique - pay_type String? - card_name String? - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - purchase Purchase @relation(fields: [purchase_id], references: [purchase_id], onDelete: Cascade) - settlement Settlement? - cash_receipt_url String? + payment_id Int @id @default(autoincrement()) + purchase_id Int @unique + status Status + pcd_pay_oid String @unique + pcd_pay_reqkey String @unique + pay_type String? + card_name String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + purchase Purchase @relation(fields: [purchase_id], references: [purchase_id], onDelete: Cascade) + settlement Settlement? + refund Refund? + cash_receipt_url String? +} + +model Refund { + refund_id Int @id @default(autoincrement()) + purchase_id Int @unique + payment_id Int @unique + user_id Int + amount Int + reason String? @db.VarChar(200) + initiator String @db.VarChar(20) // 'USER' | 'ADMIN' + payple_pay_code String? @db.VarChar(20) // PCD_PAY_CODE (e.g. PAYC0000) + payple_card_trade_num String? @db.VarChar(64) // PCD_PAY_CARDTRADENUM 감사 추적용 + refunded_at DateTime @default(now()) + + purchase Purchase @relation(fields: [purchase_id], references: [purchase_id], onDelete: Cascade) + payment Payment @relation(fields: [payment_id], references: [payment_id], onDelete: Cascade) + user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) + + @@index([user_id]) } model Settlement { @@ -461,35 +483,35 @@ enum SellerType { } enum BusinessType { - PERSONAL // 개인사업자 - CORPORATE // 법인사업자 + PERSONAL // 개인사업자 + CORPORATE // 법인사업자 } enum ApprovalStatus { - PENDING // 심사 대기중 - APPROVED // 승인 완료 - REJECTED // 승인 거절 + PENDING // 심사 대기중 + APPROVED // 승인 완료 + REJECTED // 승인 거절 } model SettlementAccount { - id Int @id @default(autoincrement()) - user_id Int @unique // 한 유저당 하나의 계좌만 가질 수 있도록 강제 (1:1 관계) - bank_code String @db.VarChar(50) - account_number String @db.VarChar(30) - account_holder String @db.VarChar(100) - seller_type SellerType @default(INDIVIDUAL) - business_type BusinessType? // BUSINESS일 때만 PERSONAL/CORPORATE 지정 - birth_date String? @db.VarChar(10) // 정책상 미저장 (PII 폐기). Phase 11에서 컬럼 제거 검토 - status ApprovalStatus @default(APPROVED) // 개인은 즉시 승인, 사업자는 PENDING으로 생성 - is_active Boolean @default(true) - representative_name String? @db.VarChar(100) - company_name String? @db.VarChar(100) - business_number String? @unique @db.VarChar(30) - business_license_url String? @db.Text - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) + id Int @id @default(autoincrement()) + user_id Int @unique // 한 유저당 하나의 계좌만 가질 수 있도록 강제 (1:1 관계) + bank_code String @db.VarChar(50) + account_number String @db.VarChar(30) + account_holder String @db.VarChar(100) + seller_type SellerType @default(INDIVIDUAL) + business_type BusinessType? // BUSINESS일 때만 PERSONAL/CORPORATE 지정 + birth_date String? @db.VarChar(10) // 정책상 미저장 (PII 폐기). Phase 11에서 컬럼 제거 검토 + status ApprovalStatus @default(APPROVED) // 개인은 즉시 승인, 사업자는 PENDING으로 생성 + is_active Boolean @default(true) + representative_name String? @db.VarChar(100) + company_name String? @db.VarChar(100) + business_number String? @unique @db.VarChar(30) + business_license_url String? @db.Text + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) @@index([bank_code]) @@index([account_number]) @@ -512,85 +534,85 @@ model Review { } model ChatRoom { - room_id Int @id @default(autoincrement()) - created_at DateTime @default(now()) - last_message_id Int? @unique - user_id1 Int - user_id2 Int + room_id Int @id @default(autoincrement()) + created_at DateTime @default(now()) + last_message_id Int? @unique + user_id1 Int + user_id2 Int - lastMessage ChatMessage? @relation("lastMessage", fields: [last_message_id], references: [message_id], onDelete: SetNull) - user1 User @relation("ChatUser1", fields: [user_id1], references: [user_id]) - user2 User @relation("ChatUser2", fields: [user_id2], references: [user_id]) + lastMessage ChatMessage? @relation("lastMessage", fields: [last_message_id], references: [message_id], onDelete: SetNull) + user1 User @relation("ChatUser1", fields: [user_id1], references: [user_id]) + user2 User @relation("ChatUser2", fields: [user_id2], references: [user_id]) - @@unique([user_id1, user_id2]) // 두 유저간 채팅방은 유일 + messages ChatMessage[] @relation("RoomMessages") + participants ChatParticipant[] @relation("RoomParticipants") - messages ChatMessage[] @relation("RoomMessages") - participants ChatParticipant[] @relation("RoomParticipants") + @@unique([user_id1, user_id2]) // 두 유저간 채팅방은 유일 } model ChatMessage { - message_id Int @id @default(autoincrement()) - content String? @db.Text - sent_at DateTime @default(now()) - sender_id Int - room_id Int + message_id Int @id @default(autoincrement()) + content String? @db.Text + sent_at DateTime @default(now()) + sender_id Int + room_id Int - user User @relation("UserSentMessages", fields: [sender_id], references: [user_id]) - chatRoom ChatRoom @relation("RoomMessages", fields: [room_id], references: [room_id], onDelete: Cascade) + user User @relation("UserSentMessages", fields: [sender_id], references: [user_id]) + chatRoom ChatRoom @relation("RoomMessages", fields: [room_id], references: [room_id], onDelete: Cascade) - lastMessageOf ChatRoom? @relation("lastMessage") - readByParticipants ChatParticipant[] @relation("LastReadMessage") + lastMessageOf ChatRoom? @relation("lastMessage") + readByParticipants ChatParticipant[] @relation("LastReadMessage") - attachments Attachment[] + attachments Attachment[] } model ChatParticipant { - chat_participant_id Int @id @default(autoincrement()) - is_pinned Boolean @default(false) - left_at DateTime? - room_id Int - user_id Int - last_read_message_id Int? - unread_count Int @default(0) + chat_participant_id Int @id @default(autoincrement()) + is_pinned Boolean @default(false) + left_at DateTime? + room_id Int + user_id Int + last_read_message_id Int? + unread_count Int @default(0) + + chatRoom ChatRoom @relation("RoomParticipants", fields: [room_id], references: [room_id], onDelete: Cascade) + user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) + lastReadMessage ChatMessage? @relation("LastReadMessage", fields: [last_read_message_id], references: [message_id], onDelete: SetNull) - chatRoom ChatRoom @relation("RoomParticipants", fields: [room_id], references: [room_id], onDelete: Cascade) - user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) - lastReadMessage ChatMessage? @relation("LastReadMessage", fields: [last_read_message_id], references: [message_id], onDelete: SetNull) - @@unique([room_id, user_id]) } model UserBlock { - block_id Int @id @default(autoincrement()) - blocked_at DateTime @default(now()) - blocker_id Int - blocked_id Int + block_id Int @id @default(autoincrement()) + blocked_at DateTime @default(now()) + blocker_id Int + blocked_id Int - @@unique([blocker_id, blocked_id]) + blockerUser User @relation("BlockerUser", fields: [blocker_id], references: [user_id], onDelete: Cascade) + blockedUser User @relation("BlockedUser", fields: [blocked_id], references: [user_id], onDelete: Cascade) - blockerUser User @relation("BlockerUser", fields: [blocker_id], references: [user_id], onDelete: Cascade) - blockedUser User @relation("BlockedUser", fields: [blocked_id], references: [user_id], onDelete: Cascade) - + @@unique([blocker_id, blocked_id]) } model Attachment { - attachment_id Int @id @default(autoincrement()) - message_id Int - url String @db.Text - type AttachmentType - name String - size Int - created_at DateTime @default(now()) + attachment_id Int @id @default(autoincrement()) + message_id Int + url String @db.Text + type AttachmentType + name String + size Int + created_at DateTime @default(now()) - attachmentMessage ChatMessage @relation(fields: [message_id], references: [message_id], onDelete: Cascade) + attachmentMessage ChatMessage @relation(fields: [message_id], references: [message_id], onDelete: Cascade) - @@index([message_id]) + @@index([message_id]) } enum Status { Pending Succeed Failed + Refunded } enum Role { @@ -633,4 +655,4 @@ enum userStatus { enum AttachmentType { IMAGE FILE -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index b183060..0d0ad33 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import membersRouter from "./members/routes/member.route"; // members 라우터 import promptRoutes from "./prompts/routes/prompt.route"; // 프롬프트 관련 라우터 import ReviewRouter from "./reviews/routes/review.route"; import purchaseRouter from "./purchases/routes/purchase.route"; +import refundRouter from "./refunds/routes/refund.route"; import purchaseWebhookRouter from "./purchases/routes/purchase.webhook.route"; import settlementRouter from "./settlements/routes/settlement.route"; import withdrawalRouter from "./withdrawals/routes/withdrawal.route"; @@ -138,6 +139,7 @@ app.use("/api/prompts", promptRoutes); app.use("/api/prompts/purchases", purchaseWebhookRouter); // 프롬프트 결제 라우터 +app.use("/api/prompts/purchases", refundRouter); app.use( "/api/prompts/purchases", express.text({ type: "text/plain" }), diff --git a/src/prompts/services/prompt.download.service.ts b/src/prompts/services/prompt.download.service.ts index 27e4d74..3932f15 100644 --- a/src/prompts/services/prompt.download.service.ts +++ b/src/prompts/services/prompt.download.service.ts @@ -14,7 +14,7 @@ async getPromptContent(userId: number, promptId: number): Promise { + if (!req.user) return null; + return (req.user as { user_id: number }).user_id; +}; + +const parsePurchaseId = (raw: string): number | null => { + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) return null; + return n; +}; + +export const checkRefundEligibility = async (req: Request, res: Response) => { + const userId = getUserId(req); + if (!userId) { + return res.status(401).json({ error: 'Unauthorized', message: '로그인이 필요합니다.', statusCode: 401 }); + } + const purchaseId = parsePurchaseId(req.params.purchaseId); + if (!purchaseId) { + return res.status(400).json({ error: 'ValidationError', message: 'purchaseId가 올바르지 않습니다.', statusCode: 400 }); + } + try { + const result = await getRefundEligibility(userId, purchaseId); + return res.status(200).json(result); + } catch (error: any) { + const status = error.statusCode || 500; + return res.status(status).json({ + error: error.error || 'InternalServerError', + message: error.message || '서버 오류가 발생했습니다.', + statusCode: status, + }); + } +}; + +export const refundPurchaseHandler = async (req: Request, res: Response) => { + const userId = getUserId(req); + if (!userId) { + return res.status(401).json({ error: 'Unauthorized', message: '로그인이 필요합니다.', statusCode: 401 }); + } + const purchaseId = parsePurchaseId(req.params.purchaseId); + if (!purchaseId) { + return res.status(400).json({ error: 'ValidationError', message: 'purchaseId가 올바르지 않습니다.', statusCode: 400 }); + } + try { + const result = await refundPurchase(userId, purchaseId); + return res.status(200).json(result); + } catch (error: any) { + const status = error.statusCode || 500; + return res.status(status).json({ + error: error.error || 'InternalServerError', + message: error.message || '서버 오류가 발생했습니다.', + statusCode: status, + }); + } +}; diff --git a/src/refunds/dtos/refund.dto.ts b/src/refunds/dtos/refund.dto.ts new file mode 100644 index 0000000..8ff3be4 --- /dev/null +++ b/src/refunds/dtos/refund.dto.ts @@ -0,0 +1,24 @@ +export type RefundIneligibleReason = + | 'EXPIRED_7DAYS' + | 'ALREADY_DOWNLOADED' + | 'ALREADY_REFUNDED' + | 'NOT_OWNER' + | 'NOT_PURCHASED' + | 'PAYMENT_NOT_SUCCEEDED' + | 'FREE_PURCHASE'; + +export interface RefundEligibilityResponseDto { + message: string; + eligible: boolean; + reason?: RefundIneligibleReason; + remaining_seconds?: number; // 환불 가능한 잔여 시간 (eligible=true일 때만) + statusCode: number; +} + +export interface RefundResultDto { + message: string; + refund_id: number; + refunded_amount: number; + refunded_at: string; + statusCode: number; +} diff --git a/src/refunds/routes/refund.route.ts b/src/refunds/routes/refund.route.ts new file mode 100644 index 0000000..46cacb9 --- /dev/null +++ b/src/refunds/routes/refund.route.ts @@ -0,0 +1,103 @@ +import { Router } from 'express'; +import { authenticateJwt } from '../../config/passport'; +import { + checkRefundEligibility, + refundPurchaseHandler, +} from '../controllers/refund.controller'; + +const router = Router(); + +/** + * @swagger + * tags: + * - name: Refund + * description: 구매 환불 (7일 이내 미열람 자동 환불) + */ + +/** + * @swagger + * /api/prompts/purchases/{purchaseId}/refund-eligibility: + * get: + * summary: 환불 가능 여부 조회 + * description: | + * 구매 건이 환불 가능한지 검증. 환불 가능 조건은 다음을 모두 만족: + * - 본인 구매 + * - 유료 구매 + * - 결제 상태 Succeed + * - 환불 이력 없음 + * - 다운로드 이력 없음 (`Purchase.downloaded_at` 미값) + * - 구매 후 7일(168시간) 이내 + * tags: [Refund] + * security: + * - jwt: [] + * parameters: + * - in: path + * name: purchaseId + * required: true + * schema: { type: integer } + * responses: + * 200: + * description: 조회 성공 (eligible true/false) + * content: + * application/json: + * schema: + * type: object + * properties: + * message: { type: string } + * eligible: { type: boolean } + * reason: + * type: string + * enum: [EXPIRED_7DAYS, ALREADY_DOWNLOADED, ALREADY_REFUNDED, NOT_OWNER, NOT_PURCHASED, PAYMENT_NOT_SUCCEEDED, FREE_PURCHASE] + * description: eligible=false일 때만 존재 + * remaining_seconds: + * type: integer + * description: eligible=true일 때 환불 가능 잔여 시간(초) + * statusCode: { type: integer, example: 200 } + * 401: + * description: 로그인 필요 + * 400: + * description: 잘못된 purchaseId + */ +router.get('/:purchaseId/refund-eligibility', authenticateJwt, checkRefundEligibility); + +/** + * @swagger + * /api/prompts/purchases/{purchaseId}/refund: + * post: + * summary: 환불 실행 + * description: | + * 7일 이내 + 미열람 조건을 만족하면 Payple 결제 취소를 호출하고 DB(Refund/Payment/Settlement) 정합화. + * 조건 미충족 시 400 RefundNotEligible. + * tags: [Refund] + * security: + * - jwt: [] + * parameters: + * - in: path + * name: purchaseId + * required: true + * schema: { type: integer } + * responses: + * 200: + * description: 환불 성공 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: { type: string, example: 환불이 완료되었습니다. } + * refund_id: { type: integer } + * refunded_amount: { type: integer } + * refunded_at: { type: string, format: date-time } + * statusCode: { type: integer, example: 200 } + * 400: + * description: 환불 불가 (RefundNotEligible) + * 401: + * description: 로그인 필요 + * 404: + * description: 환불 대상 결제 정보를 찾을 수 없음 + * 502: + * description: Payple 환불 호출 실패 (PaypleRefundFailed) + */ +router.post('/:purchaseId/refund', authenticateJwt, refundPurchaseHandler); + +export default router; diff --git a/src/refunds/services/refund.service.ts b/src/refunds/services/refund.service.ts new file mode 100644 index 0000000..6a42051 --- /dev/null +++ b/src/refunds/services/refund.service.ts @@ -0,0 +1,156 @@ +import prisma from '../../config/prisma'; +import { AppError } from '../../errors/AppError'; +import { + RefundEligibilityResponseDto, + RefundResultDto, + RefundIneligibleReason, +} from '../dtos/refund.dto'; +import { requestPaypleRefund } from '../../settlements/utils/payple-refund'; + +const REFUND_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +interface EligibilityResult { + eligible: boolean; + reason?: RefundIneligibleReason; + remaining_seconds?: number; +} + +const checkEligibility = async (userId: number, purchaseId: number): Promise => { + const purchase = await prisma.purchase.findUnique({ + where: { purchase_id: purchaseId }, + select: { + user_id: true, + created_at: true, + downloaded_at: true, + is_free: true, + payment: { select: { status: true } }, + refund: { select: { refund_id: true } }, + }, + }); + + if (!purchase) { + return { eligible: false, reason: 'NOT_PURCHASED' }; + } + if (purchase.user_id !== userId) { + return { eligible: false, reason: 'NOT_OWNER' }; + } + if (purchase.is_free) { + return { eligible: false, reason: 'FREE_PURCHASE' }; + } + if (!purchase.payment || purchase.payment.status !== 'Succeed') { + return { eligible: false, reason: 'PAYMENT_NOT_SUCCEEDED' }; + } + if (purchase.refund) { + return { eligible: false, reason: 'ALREADY_REFUNDED' }; + } + if (purchase.downloaded_at) { + return { eligible: false, reason: 'ALREADY_DOWNLOADED' }; + } + + const elapsed = Date.now() - purchase.created_at.getTime(); + if (elapsed >= REFUND_WINDOW_MS) { + return { eligible: false, reason: 'EXPIRED_7DAYS' }; + } + + return { + eligible: true, + remaining_seconds: Math.floor((REFUND_WINDOW_MS - elapsed) / 1000), + }; +}; + +export const getRefundEligibility = async ( + userId: number, + purchaseId: number, +): Promise => { + const result = await checkEligibility(userId, purchaseId); + return { + message: result.eligible ? '환불 가능' : '환불 불가', + eligible: result.eligible, + reason: result.reason, + remaining_seconds: result.remaining_seconds, + statusCode: 200, + }; +}; + +const formatYyyymmdd = (date: Date): string => { + const yyyy = date.getUTCFullYear(); + const mm = String(date.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(date.getUTCDate()).padStart(2, '0'); + return `${yyyy}${mm}${dd}`; +}; + +export const refundPurchase = async ( + userId: number, + purchaseId: number, +): Promise => { + // 환불 가능 여부 재검증 (TOCTOU 차단을 위해 트랜잭션 안에서도 다시 검사) + const preCheck = await checkEligibility(userId, purchaseId); + if (!preCheck.eligible) { + throw new AppError(`환불 불가: ${preCheck.reason}`, 400, 'RefundNotEligible'); + } + + // Payple 호출 전에 필요한 정보 로드 + const purchase = await prisma.purchase.findUnique({ + where: { purchase_id: purchaseId }, + select: { + purchase_id: true, + user_id: true, + amount: true, + created_at: true, + payment: { + select: { payment_id: true, pcd_pay_oid: true, created_at: true }, + }, + }, + }); + if (!purchase || !purchase.payment) { + throw new AppError('환불 대상 결제 정보를 찾을 수 없습니다.', 404, 'NotFound'); + } + + // Payple 결제 취소 호출 (실패 시 DB는 손대지 않음) + const paypleResult = await requestPaypleRefund({ + payOid: purchase.payment.pcd_pay_oid, + payDate: formatYyyymmdd(purchase.payment.created_at), + refundTotal: purchase.amount, + }); + + // 성공 시 DB 정합화 — Refund row 생성 + Payment/Settlement 상태 전이 + const refund = await prisma.$transaction(async (tx) => { + // 트랜잭션 내부에서도 멱등 검사: 이미 환불 row 있으면 그대로 반환 + const existing = await tx.refund.findUnique({ where: { purchase_id: purchaseId } }); + if (existing) return existing; + + const created = await tx.refund.create({ + data: { + purchase_id: purchase.purchase_id, + payment_id: purchase.payment!.payment_id, + user_id: purchase.user_id, + amount: purchase.amount, + initiator: 'USER', + reason: '7일 이내 미열람 자동 환불', + payple_pay_code: paypleResult.payCode, + payple_card_trade_num: paypleResult.cardTradeNum ?? null, + }, + }); + + await tx.payment.update({ + where: { payment_id: purchase.payment!.payment_id }, + data: { status: 'Refunded' }, + }); + + // Settlement이 있는 경우만 (status 무관하게) Refunded로 전이 + await tx.settlement.updateMany({ + where: { payment_id: purchase.payment!.payment_id }, + data: { status: 'Refunded' }, + }); + + return created; + }); + + return { + message: '환불이 완료되었습니다.', + refund_id: refund.refund_id, + refunded_amount: refund.amount, + refunded_at: refund.refunded_at.toISOString(), + statusCode: 200, + }; +}; diff --git a/src/settlements/utils/payple-refund.ts b/src/settlements/utils/payple-refund.ts new file mode 100644 index 0000000..62d49c6 --- /dev/null +++ b/src/settlements/utils/payple-refund.ts @@ -0,0 +1,154 @@ +import axios from 'axios'; +import redisClient from '../../config/redis'; +import { AppError } from '../../errors/AppError'; +import { redactPaypleLog } from './payple'; + +// Payple 결제 취소(환불) 유틸. +// PCD_PAYCANCEL_FLAG=Y로 파트너 인증 → 응답의 PCD_PAY_HOST + PCD_PAY_URL로 취소 요청. +// +// 보안 정책: +// - Auth 캐시 TTL 15분 (정산 조회용과 동일) +// - cstId/custKey는 캐시 제외, 매 호출 env 직접 로드 +// - 요청/응답 로그는 redactPaypleLog로 마스킹 (PCD_REFUND_KEY/PCD_AUTH_KEY 등) + +const AUTH_CACHE_KEY = 'payple:refund:auth'; +const AUTH_CACHE_TTL_SECONDS = 15 * 60; + +interface PaypleRefundAuthCache { + authKey: string; + payHost: string; + payUrl: string; +} + +interface PaypleRefundAuth extends PaypleRefundAuthCache { + cstId: string; + custKey: string; +} + +const loadCredentialsFromEnv = (): { cstId: string; custKey: string; refundKey: string } => { + const cstId = process.env.PAYPLE_CST_ID; + const custKey = process.env.PAYPLE_CUST_KEY; + const refundKey = process.env.PAYPLE_REFUND_KEY; + if (!cstId || !custKey || !refundKey) { + throw new AppError( + 'Payple 환불 설정이 누락되었습니다 (CST_ID / CUST_KEY / REFUND_KEY).', + 500, + 'ConfigError', + ); + } + return { cstId, custKey, refundKey }; +}; + +const getCpayBaseUrl = (): string => { + const url = process.env.PAYPLE_CPAY_URL; + if (!url) { + throw new AppError('PAYPLE_CPAY_URL 환경변수가 설정되지 않았습니다.', 500, 'ConfigError'); + } + return url; +}; + +const getRefundAuthPath = (): string => + process.env.PAYPLE_REFUND_AUTH_PATH || '/php/auth.php'; + +const fetchRefundAuth = async (): Promise => { + const { cstId, custKey } = loadCredentialsFromEnv(); + + const cached = await redisClient.get(AUTH_CACHE_KEY); + if (cached) { + try { + const parsed: PaypleRefundAuthCache = JSON.parse(cached); + if (parsed.authKey && parsed.payHost && parsed.payUrl) { + return { ...parsed, cstId, custKey }; + } + } catch { + // 캐시 손상 — 재발급 + } + } + + const url = `${getCpayBaseUrl()}${getRefundAuthPath()}`; + const res = await axios.post( + url, + { cst_id: cstId, custKey, PCD_PAYCANCEL_FLAG: 'Y' }, + { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' } }, + ); + + if (res.data?.result !== 'success') { + console.error('[payple-refund] auth failed', { code: res.data?.result }); + throw new AppError('Payple 환불 인증에 실패했습니다.', 502, 'PaypleAuthFailed'); + } + + const cacheable: PaypleRefundAuthCache = { + authKey: res.data.AuthKey, + payHost: res.data.PCD_PAY_HOST, + payUrl: res.data.PCD_PAY_URL, + }; + await redisClient.set(AUTH_CACHE_KEY, JSON.stringify(cacheable), { EX: AUTH_CACHE_TTL_SECONDS }); + return { ...cacheable, cstId, custKey }; +}; + +export interface RequestRefundParams { + payOid: string; // PCD_PAY_OID — Payment.pcd_pay_oid + payDate: string; // PCD_PAY_DATE — yyyyMMdd (원거래 결제일) + refundTotal: number; // PCD_REFUND_TOTAL — 환불 금액 (원거래보다 작으면 부분취소) +} + +export interface PaypleRefundResult { + payCode: string; // PCD_PAY_CODE (e.g. PAYC0000) + payMsg: string; + cardTradeNum?: string; // PCD_PAY_CARDTRADENUM (감사 추적) + refundTotal: number; + payTime: string; // PCD_PAY_TIME yyyyMMddHHmmss +} + +// 결제 취소(환불) 요청. +// Payple 응답 PCD_PAY_RST가 'success'가 아니면 AppError. +export const requestPaypleRefund = async ( + params: RequestRefundParams, +): Promise => { + const { refundKey } = loadCredentialsFromEnv(); + const auth = await fetchRefundAuth(); + + const body = { + PCD_CST_ID: auth.cstId, + PCD_CUST_KEY: auth.custKey, + PCD_AUTH_KEY: auth.authKey, + PCD_REFUND_KEY: refundKey, + PCD_PAYCANCEL_FLAG: 'Y', + PCD_PAY_OID: params.payOid, + PCD_PAY_DATE: params.payDate, + PCD_REFUND_TOTAL: String(params.refundTotal), + }; + + const url = `${auth.payHost}${auth.payUrl}`; + let res; + try { + res = await axios.post(url, body, { + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' }, + }); + } catch (err: any) { + console.error('[payple-refund] request network error', { + response: redactPaypleLog(err?.response?.data), + }); + throw new AppError('Payple 환불 요청 통신에 실패했습니다.', 502, 'PaypleRefundFailed'); + } + + if (res.data?.PCD_PAY_RST !== 'success') { + console.error('[payple-refund] request rejected', { + code: res.data?.PCD_PAY_CODE, + response: redactPaypleLog(res.data), + }); + throw new AppError( + `Payple 환불에 실패했습니다. (${res.data?.PCD_PAY_CODE ?? 'UNKNOWN'}) ${res.data?.PCD_PAY_MSG ?? ''}`, + 502, + 'PaypleRefundFailed', + ); + } + + return { + payCode: res.data.PCD_PAY_CODE, + payMsg: res.data.PCD_PAY_MSG, + cardTradeNum: res.data.PCD_PAY_CARDTRADENUM, + refundTotal: Number(res.data.PCD_REFUND_TOTAL), + payTime: res.data.PCD_PAY_TIME, + }; +}; diff --git a/src/settlements/utils/payple.ts b/src/settlements/utils/payple.ts index 68d9e91..cc59ca6 100644 --- a/src/settlements/utils/payple.ts +++ b/src/settlements/utils/payple.ts @@ -40,6 +40,10 @@ const REDACTED_FIELDS = new Set([ 'PCD_PAY_CARDNUM', 'PCD_LASTKEY', 'AuthKey', + // 결제 취소 (payple-refund.ts에서 재사용) + 'PCD_REFUND_KEY', + 'PCD_PAYER_ID', + 'PCD_PAY_CARDTRADENUM', ]); const redactValue = (v: unknown): string => { diff --git a/swagger.json b/swagger.json index df36c62..a22e396 100644 --- a/swagger.json +++ b/swagger.json @@ -4958,6 +4958,145 @@ } } }, + "/api/prompts/purchases/{purchaseId}/refund-eligibility": { + "get": { + "summary": "환불 가능 여부 조회", + "description": "구매 건이 환불 가능한지 검증. 환불 가능 조건은 다음을 모두 만족:\n- 본인 구매\n- 유료 구매\n- 결제 상태 Succeed\n- 환불 이력 없음\n- 다운로드 이력 없음 (`Purchase.downloaded_at` 미값)\n- 구매 후 7일(168시간) 이내\n", + "tags": [ + "Refund" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "purchaseId", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "조회 성공 (eligible true/false)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "eligible": { + "type": "boolean" + }, + "reason": { + "type": "string", + "enum": [ + "EXPIRED_7DAYS", + "ALREADY_DOWNLOADED", + "ALREADY_REFUNDED", + "NOT_OWNER", + "NOT_PURCHASED", + "PAYMENT_NOT_SUCCEEDED", + "FREE_PURCHASE" + ], + "description": "eligible=false일 때만 존재" + }, + "remaining_seconds": { + "type": "integer", + "description": "eligible=true일 때 환불 가능 잔여 시간(초)" + }, + "statusCode": { + "type": "integer", + "example": 200 + } + } + } + } + } + }, + "400": { + "description": "잘못된 purchaseId" + }, + "401": { + "description": "로그인 필요" + } + } + } + }, + "/api/prompts/purchases/{purchaseId}/refund": { + "post": { + "summary": "환불 실행", + "description": "7일 이내 + 미열람 조건을 만족하면 Payple 결제 취소를 호출하고 DB(Refund/Payment/Settlement) 정합화.\n조건 미충족 시 400 RefundNotEligible.\n", + "tags": [ + "Refund" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "purchaseId", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "환불 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "환불이 완료되었습니다." + }, + "refund_id": { + "type": "integer" + }, + "refunded_amount": { + "type": "integer" + }, + "refunded_at": { + "type": "string", + "format": "date-time" + }, + "statusCode": { + "type": "integer", + "example": 200 + } + } + } + } + } + }, + "400": { + "description": "환불 불가 (RefundNotEligible)" + }, + "401": { + "description": "로그인 필요" + }, + "404": { + "description": "환불 대상 결제 정보를 찾을 수 없음" + }, + "502": { + "description": "Payple 환불 호출 실패 (PaypleRefundFailed)" + } + } + } + }, "/api/reports": { "post": { "summary": "프롬프트 신고 등록", @@ -8673,6 +8812,10 @@ "name": "Purchase", "description": "결제/구매 관련 API" }, + { + "name": "Refund", + "description": "구매 환불 (7일 이내 미열람 자동 환불)" + }, { "name": "Report", "description": "프롬프트 신고 관련 API"