Skip to content

[FEAT] 환불 - 7일 이내 미열람 자동 환불 흐름 (CANCEL) 및 Refund 모델 구현 #485

@minij02

Description

@minij02

✨ 기능 설명

구매한 프롬프트에 대한 사용자 자동 환불 흐름 구현. 기획 정책상 환불 가능 조건은 7일 이내 + 미열람(미다운로드). 정책 충족 시 사용자가 직접 [환불하기]를 누르면 즉시 Payple 결제 취소 API를 호출하고 Settlement/Purchase까지 정합화.

#480 / #482 후속. Status.Refunded enum 신규로 환불 거래를 회계적으로 구분.

✨ 핵심 결정 사항 (확정)

  • "열람" = 다운로드 — 첫 다운로드 시점에 Purchase.downloaded_at 기록. 이후 환불 불가
  • 데이터 모델: Status.Refunded enum 추가 + Refund 별도 모델 신규 (양쪽 모두 활용)
  • 환불 실행 주체: 사용자가 직접 [환불하기] → 조건 자동 검증 → 즉시 Payple 호출
  • 부분 환불 없음 (디지털 콘텐츠, 전액만)
  • 정산 상태 무관 환불 허용: 이미 Settlement.status='Succeed'여도 환불 가능 (회계 환수는 별도 처리)
  • 환불 후 접근 차단: 환불된 Purchase는 다운로드/조회 거부
  • 관리자 수동 환불은 별도 이슈 (정책 외 케이스 처리용)

✨ 개발 목록

Prisma 스키마

  • Status enum에 Refunded 추가
  • Purchase.downloaded_at DateTime? 컬럼 추가
  • Refund 모델 신규
    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)
      payple_card_trade_num String?  @db.VarChar(64)   // PCD_PAY_CARDTRADENUM (감사 추적)
      refunded_at          DateTime  @default(now())
      purchase             Purchase  @relation(...)
      payment              Payment   @relation(...)
      user                 User      @relation(...)
    }
    
  • 마이그레이션 파일

다운로드 이력 기록

  • Prompt.downloads 카운터와 별개로 Purchase.downloaded_at첫 다운로드 시점에 set
  • 다운로드 컨트롤러/서비스에서 본인의 Purchase 조회 → downloaded_at 미값이면 NOW()로 update

환불 가능 여부 조회

  • GET /api/purchases/:purchaseId/refund-eligibility
    • 응답: { eligible: boolean, reason?: 'EXPIRED_7DAYS' | 'ALREADY_DOWNLOADED' | 'ALREADY_REFUNDED', remaining_seconds?: number }
    • 본인 구매만 (user_id 검증)

환불 실행

  • POST /api/purchases/:purchaseId/refund
    • 검증: 본인 구매 + 7일 이내 + downloaded_at null + Refund 미존재 + Payment.status='Succeed'
    • 위반 시 400 + 사유 코드
    • 통과 시 Payple 결제 취소 호출 → 성공 후 DB 정합화 (트랜잭션)

Payple 결제 취소 유틸 (utils/payple-refund.ts 신규)

  • fetchPaypleRefundAuth()PCD_PAYCANCEL_FLAG=Y 파트너 인증, Auth 15분 Redis 캐시 (custKey 캐시 제외)
  • requestPaypleRefund({ payOid, payDate, amount })
    • Payple 명세에 따라 필수 필드 전송 (PCD_REFUND_KEY 포함)
    • PCD_PAY_RST !== 'success' 처리, 응답 PCD_PAY_CARDTRADENUM 저장
  • 로그 redactor 적용 (PCD_REFUND_KEY 등 신규 마스킹 필드 추가)

DB 정합화 (트랜잭션)

  • Refund row 생성 (initiator='USER')
  • Payment.status = 'Failed' (또는 Refunded enum 사용 시 그쪽)
  • Settlement.status = 'Refunded' (settlement row 존재 시)
  • Purchase는 그대로 유지 (환불 이력은 Refund row로 추적). 또는 is_refunded 별도 컬럼 검토

접근 차단

  • 다운로드 / 프롬프트 내용 노출 API에서 Refund 존재 시 403
  • getDownloadedPromptsByUser 등 구매 목록 조회 시 환불 표시 (UI에서 비활성화)

환경변수

  • PAYPLE_REFUND_KEY — Payple 파트너 관리자에서 발급받은 환불 전용 키 (필수)
  • PAYPLE_REFUND_AUTH_PATH — 환불용 파트너 인증 endpoint (기본값 /php/auth.php, sandbox 검증 후 정정)

Swagger / 검증

  • 신규 2개 API 명세
  • sandbox e2e (정상 환불 / 7일 경과 / 다운로드 후 / 중복 환불 / Payple 실패)
  • pnpm build

관련 후속 흐름

  • #482의 settlement-sync 잡에서 CANCEL 거래가 들어왔을 때 처리:
    • 본 PR에서 Refund 모델이 추가되면 sync 잡이 CANCEL을 받아 Refund row 매칭/생성도 가능
    • 다만 본 PR은 사용자 발 환불만 다루고, sync 잡 측 CANCEL 처리는 후속에서 정리

✨ 별도 이슈로 분리 (본 PR 범위 밖)

  • 관리자 수동 환불 (정책 외 케이스, 부분환불 등)
  • 환불에 따른 매출/회계 자동 환수 로직
  • settlement-sync 잡의 CANCEL 매칭 강화
  • 다운로드 이력을 별도 PromptDownload 테이블로 정규화 (현재는 단일 시점만)

✨ 기타 설명 / 질문

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions