From 32ca8aad64e8b6531307eef6d1032def7f81d9e9 Mon Sep 17 00:00:00 2001 From: minij02 Date: Wed, 29 Jul 2026 23:31:24 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=ED=99=98=EB=B6=88=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EC=99=84=EC=84=B1=20=E2=80=94=207=EC=9D=BC=20KST?= =?UTF-8?q?=20=EA=B8=B0=EC=A4=80=20=EC=A0=95=EC=A0=95,=20=ED=99=98?= =?UTF-8?q?=EB=B6=88=20=EA=B0=80=EB=8A=A5=20=ED=94=8C=EB=9E=98=EA=B7=B8,?= =?UTF-8?q?=20=EC=97=B4=EB=9E=8C=20=ED=9B=84=20=EC=88=98=EB=8F=99=20?= =?UTF-8?q?=ED=99=98=EB=B6=88=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=20(#5?= =?UTF-8?q?33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 7일 판정을 168시간 절대값에서 KST 날짜 기준(첫날 제외)으로 정정. 7/23 구매 시 7/30 23:59:59까지 신청 가능. - 정책 판정을 refund-policy 순수 함수로 분리해 단건/목록/수동신청이 공유. 경계값 자체 검증(refund-policy.check.ts) 추가. - 다운로드 목록에 refundable / refund_deadline / manual_refund_available / refund_status 노출 — FE가 항목마다 eligibility를 호출하지 않아도 되도록. - Refund에 status(REQUESTED/APPROVED/REJECTED/COMPLETED) 추가. 기존 자동 환불 레코드는 COMPLETED로 백필. - refunded_at을 nullable로 좁혀 신청 단계가 환불 완료로 오인되지 않게 함. 이에 맞춰 재다운로드 차단과 is_refunded를 확정 상태(APPROVED/COMPLETED)로 판정. - 열람 후 수동 환불 신청 API + 관리자 검토 API(목록/상세/승인/거절/수동완료) 추가. 상세 응답에 프롬프트 본문·설명을 실어 부실 여부를 판단할 수 있게 함. - 승인 후 Payple 취소 실패 시 승인을 롤백하지 않고 APPROVED에서 정지 + 실패 코드 기록. 롤백하면 카드사 취소 기간이 지난 건이 영영 환불 불가로 남기 때문. - 결제 요청에 refund_policy_agreed 필수화, 동의 시각을 Purchase에 기록. --- .../migration.sql | 24 + prisma/schema.prisma | 36 +- src/index.ts | 3 + src/prompts/dtos/prompt.download.dto.ts | 6 +- .../prompt.download.repository.ts | 4 +- src/prompts/routes/prompt.download.route.ts | 18 +- .../services/prompt.download.service.ts | 18 +- src/purchases/dtos/purchase.request.dto.ts | 2 + .../purchase.complete.repository.ts | 1 + src/purchases/routes/purchase.route.ts | 9 + .../services/purchase.complete.service.ts | 9 + .../services/purchase.request.service.ts | 11 + src/purchases/utils/payple.ts | 2 +- .../controllers/admin-refund.controller.ts | 110 +++++ src/refunds/controllers/refund.controller.ts | 29 +- src/refunds/dtos/refund.dto.ts | 79 +++- src/refunds/routes/admin-refund.route.ts | 187 ++++++++ src/refunds/routes/refund.route.ts | 78 +++- src/refunds/services/admin-refund.service.ts | 296 ++++++++++++ src/refunds/services/refund.service.ts | 262 +++++++---- src/refunds/utils/refund-policy.check.ts | 107 +++++ src/refunds/utils/refund-policy.ts | 144 ++++++ src/settlements/utils/payple-refund.ts | 17 +- swagger.json | 433 +++++++++++++++++- 24 files changed, 1760 insertions(+), 125 deletions(-) create mode 100644 prisma/migrations/20260729140000_add_refund_workflow_and_policy_consent/migration.sql create mode 100644 src/refunds/controllers/admin-refund.controller.ts create mode 100644 src/refunds/routes/admin-refund.route.ts create mode 100644 src/refunds/services/admin-refund.service.ts create mode 100644 src/refunds/utils/refund-policy.check.ts create mode 100644 src/refunds/utils/refund-policy.ts diff --git a/prisma/migrations/20260729140000_add_refund_workflow_and_policy_consent/migration.sql b/prisma/migrations/20260729140000_add_refund_workflow_and_policy_consent/migration.sql new file mode 100644 index 0000000..2161ee9 --- /dev/null +++ b/prisma/migrations/20260729140000_add_refund_workflow_and_policy_consent/migration.sql @@ -0,0 +1,24 @@ +-- AlterTable: Purchase 환불정책 동의 시점 (#533) +ALTER TABLE `Purchase` ADD COLUMN `refund_policy_agreed_at` DATETIME(3) NULL; + +-- AlterTable: Refund 수동 환불 워크플로 컬럼 (#533) +-- status 기본값을 COMPLETED로 두어 기존 자동 환불 레코드가 그대로 완료 상태로 백필된다. +ALTER TABLE `Refund` + ADD COLUMN `status` ENUM('REQUESTED', 'APPROVED', 'REJECTED', 'COMPLETED') NOT NULL DEFAULT 'COMPLETED', + ADD COLUMN `request_reason` VARCHAR(500) NULL, + ADD COLUMN `reject_reason` VARCHAR(500) NULL, + ADD COLUMN `reviewed_by` INTEGER NULL, + ADD COLUMN `reviewed_at` DATETIME(3) NULL, + ADD COLUMN `payple_fail_code` VARCHAR(40) NULL, + ADD COLUMN `requested_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3); + +-- 기존 레코드의 requested_at은 실제 환불 시점으로 맞춘다 (기본값 CURRENT_TIMESTAMP 대신). +UPDATE `Refund` SET `requested_at` = `refunded_at`; + +-- refunded_at은 "환불 완료 시각"으로 의미를 좁힌다. +-- REQUESTED/REJECTED 단계에서 값이 채워지면 완료된 환불로 오인되므로 nullable + 기본값 제거. +-- 기존 레코드는 모두 COMPLETED이므로 값이 그대로 유지된다. +ALTER TABLE `Refund` MODIFY COLUMN `refunded_at` DATETIME(3) NULL; + +-- CreateIndex +CREATE INDEX `Refund_status_idx` ON `Refund`(`status`); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8816afe..981c7ee 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -396,6 +396,7 @@ model Purchase { created_at DateTime @default(now()) updated_at DateTime @updatedAt downloaded_at DateTime? // 첫 다운로드 시점 — 환불 가능 조건 판단용 (#485) + refund_policy_agreed_at DateTime? // 결제 시 "열람 후 단순변심 환불 불가" 동의 시점 (#533) payment Payment? refund Refund? prompt Prompt @relation(fields: [prompt_id], references: [prompt_id]) @@ -444,23 +445,42 @@ model Payment { cash_receipt_url String? } +// 환불 처리 상태 (#533) +// 열람 전 7일 이내 자동 환불은 곧바로 COMPLETED로 생성되고, +// 열람 후 수동 환불은 REQUESTED → APPROVED/REJECTED → COMPLETED 로 진행된다. +enum RefundStatus { + REQUESTED // 사용자 신청, 담당자 검토 대기 + APPROVED // 승인됐으나 Payple 취소 미완료 (카드사 기간 초과 등) — 수동 송금 대상 + REJECTED // 담당자 거절 + COMPLETED // 환불 완료 +} + model Refund { - refund_id Int @id @default(autoincrement()) - purchase_id Int @unique - payment_id Int @unique + 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()) + 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? // 실제 환불 완료 시각 — REQUESTED/REJECTED 단계에서는 null (#533) + // 수동 환불 워크플로 (#533) + status RefundStatus @default(COMPLETED) // 기존 자동 환불 레코드는 COMPLETED + request_reason String? @db.VarChar(500) // 사용자 신청 사유 + reject_reason String? @db.VarChar(500) // 담당자 거절 사유 + reviewed_by Int? // 처리한 관리자 user_id — 감사용이라 FK 없이 값만 보관 + reviewed_at DateTime? + payple_fail_code String? @db.VarChar(40) // 승인 후 Payple 취소 실패 시 응답 코드 + requested_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]) + @@index([status]) } model Settlement { diff --git a/src/index.ts b/src/index.ts index 5a6c4dd..746319e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ 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 adminRefundRouter from "./refunds/routes/admin-refund.route"; import payoutWebhookRouter from "./settlements/routes/payout-webhook.route"; import purchaseWebhookRouter from "./purchases/routes/purchase.webhook.route"; import settlementRouter from "./settlements/routes/settlement.route"; @@ -173,6 +174,8 @@ app.use("/api/prompts", promptLikeRouter); app.use("/api/admin/prompts", adminPromptRouter); app.use("/api/admin/sellers", adminSellerRouter); app.use("/api/admin/stats", adminStatsRouter); +app.use("/api/admin/refunds", adminRefundRouter); +// adminMemberRouter는 /api/admin 전체를 받으므로 하위 경로 라우터보다 뒤에 마운트. app.use("/api/admin", adminMemberRouter); // 팁 라우터 diff --git a/src/prompts/dtos/prompt.download.dto.ts b/src/prompts/dtos/prompt.download.dto.ts index dfece09..4cf60e6 100644 --- a/src/prompts/dtos/prompt.download.dto.ts +++ b/src/prompts/dtos/prompt.download.dto.ts @@ -11,7 +11,11 @@ export interface DownloadedPromptResponseDTO { message: string; prompt_id: number; purchase_id: number; - is_refunded: boolean; + is_refunded: boolean; // 환불 확정(APPROVED/COMPLETED) 여부 + refund_status: string | null; // REQUESTED | APPROVED | REJECTED | COMPLETED (#533) + refundable: boolean; // 열람 전 + 7일 이내 → 즉시 환불 버튼 활성화 (#533) + refund_deadline: string | null; // 자동 환불 마감 시각 (#533) + manual_refund_available: boolean; // 열람 후 + 3개월 이내 → 환불 신청 버튼 활성화 (#533) title: string; description: string; models: string[]; diff --git a/src/prompts/repositories/prompt.download.repository.ts b/src/prompts/repositories/prompt.download.repository.ts index a6b116a..b7abd69 100644 --- a/src/prompts/repositories/prompt.download.repository.ts +++ b/src/prompts/repositories/prompt.download.repository.ts @@ -28,7 +28,9 @@ export const PromptDownloadRepository = { return prisma.purchase.findMany({ where: { user_id: userId }, include: { - refund: { select: { refund_id: true } }, + // 환불 가능 여부 판정에 필요한 필드 (#533) — 판정 자체는 refund-policy가 담당 + refund: { select: { refund_id: true, status: true } }, + payment: { select: { status: true } }, prompt: { select: { prompt_id: true, diff --git a/src/prompts/routes/prompt.download.route.ts b/src/prompts/routes/prompt.download.route.ts index 89ba6ff..7609c0f 100644 --- a/src/prompts/routes/prompt.download.route.ts +++ b/src/prompts/routes/prompt.download.route.ts @@ -99,7 +99,23 @@ router.get('/:promptId/downloads', authenticateJwt, PromptDownloadController.get * properties: * prompt_id: { type: integer } * purchase_id: { type: integer, description: 환불 API 호출에 필요한 구매 ID } - * is_refunded: { type: boolean, description: 환불 완료 여부 } + * is_refunded: { type: boolean, description: '환불 확정 여부 (APPROVED/COMPLETED)' } + * refund_status: + * type: string + * nullable: true + * enum: [REQUESTED, APPROVED, REJECTED, COMPLETED] + * description: 환불 진행 상태 (환불 이력 없으면 null) + * refundable: + * type: boolean + * description: '즉시 환불 버튼 활성화 여부 — 미열람 + 구매 후 7일 이내(KST 날짜 기준)' + * refund_deadline: + * type: string + * format: date-time + * nullable: true + * description: 즉시 환불 마감 시각 + * manual_refund_available: + * type: boolean + * description: '환불 신청 버튼 활성화 여부 — 열람함 + 구매 후 3개월 이내' * title: { type: string } * description: { type: string, nullable: true } * price: { type: integer } diff --git a/src/prompts/services/prompt.download.service.ts b/src/prompts/services/prompt.download.service.ts index 2af7c65..b74b10f 100644 --- a/src/prompts/services/prompt.download.service.ts +++ b/src/prompts/services/prompt.download.service.ts @@ -2,6 +2,8 @@ import { PromptDownloadRepository } from '../repositories/prompt.download.reposi import { PromptDownloadResponseDTO, DownloadedPromptResponseDTO } from '../dtos/prompt.download.dto'; import { AppError } from '../../errors/AppError'; import prisma from "../../config/prisma"; +import { checkAutoRefund, checkManualRefund, isRefundSettled } from '../../refunds/utils/refund-policy'; +import { toPolicyInput } from '../../refunds/services/refund.service'; export const PromptDownloadService = { async getPromptContent(userId: number, promptId: number): Promise { @@ -22,11 +24,12 @@ async getPromptContent(userId: number, promptId: number): Promise { const { prompt } = purchase; + // 환불 버튼 활성화 판정 — 단건 환불 API와 동일한 정책 함수를 쓴다 (#533) + const policyInput = toPolicyInput(purchase); + const autoVerdict = checkAutoRefund(policyInput, userId); + const manualVerdict = checkManualRefund(policyInput, userId); + const userReviewRaw = prompt.reviews[0]; const hasReview = !!userReviewRaw; const isRecentReview = hasReview && new Date(userReviewRaw.created_at) >= THIRTY_DAYS_AGO; @@ -115,7 +123,11 @@ async getDownloadedPrompts(userId: number): Promise { + if (!raw) return null; + const parsed = new Date(raw); + return Number.isNaN(parsed.getTime()) ? null : parsed; +}; + export const PurchaseCompleteService = { async completePurchase(userId: number, dto: PurchaseCompleteRequestDTO): Promise { const verifiedPayment = await verifyPayplePayment(dto, { amount: -1 }); @@ -32,6 +39,8 @@ export const PurchaseCompleteService = { prompt_id: prompt.prompt_id, amount: serverPrice, is_free: false, + // 주문서 생성 시 검증된 환불정책 동의 시각 (#533) + refund_policy_agreed_at: parseAgreedAt(verifiedPayment.customData?.agreed_at), }); const payment = await PurchaseCompleteRepository.createPaymentTx(tx, { diff --git a/src/purchases/services/purchase.request.service.ts b/src/purchases/services/purchase.request.service.ts index 4a7fc1b..730f6a2 100644 --- a/src/purchases/services/purchase.request.service.ts +++ b/src/purchases/services/purchase.request.service.ts @@ -6,6 +6,15 @@ import { requestPaypleAuth } from '../utils/payple'; export const PurchaseRequestService = { async createPurchaseRequest(userId: number, dto: PurchaseRequestDTO): Promise { + // 환불정책 동의 없이는 주문서 자체를 만들지 않는다 (#533) + if (dto.refund_policy_agreed !== true) { + throw new AppError( + '디지털콘텐츠 특성상 열람(제공 개시) 후에는 단순 변심 환불이 불가합니다. 동의가 필요합니다.', + 400, + 'RefundPolicyNotAgreed', + ); + } + const prompt = await PurchaseRequestRepository.findPromptWithSeller(dto.prompt_id); if (!prompt) throw new AppError('프롬프트를 찾을 수 없습니다.', 404, 'NotFound'); @@ -35,6 +44,8 @@ export const PurchaseRequestService = { PCD_USER_DEFINE1: JSON.stringify({ prompt_id: dto.prompt_id, user_id: userId, + // 동의 시각은 결제 완료 시 Purchase에 기록된다 (#533) + agreed_at: new Date().toISOString(), }), PCD_RST_URL: process.env.PAYPLE_RST_URL || '', }; diff --git a/src/purchases/utils/payple.ts b/src/purchases/utils/payple.ts index 9d4388a..fdaca8a 100644 --- a/src/purchases/utils/payple.ts +++ b/src/purchases/utils/payple.ts @@ -114,7 +114,7 @@ export type PaypleVerifiedPayment = { bankName?: string | null; bankNum?: string | null; cashReceiptUrl?: string | null; - customData: { prompt_id?: number; user_id?: number }; + customData: { prompt_id?: number; user_id?: number; agreed_at?: string }; }; function parseCustomDefine(define?: string): any { diff --git a/src/refunds/controllers/admin-refund.controller.ts b/src/refunds/controllers/admin-refund.controller.ts new file mode 100644 index 0000000..f7c020c --- /dev/null +++ b/src/refunds/controllers/admin-refund.controller.ts @@ -0,0 +1,110 @@ +import { Request, Response } from 'express'; +import { + approveRefund, + completeManualRefund, + getRefundDetail, + listRefunds, + rejectRefund, +} from '../services/admin-refund.service'; +import { RefundStatusValue } from '../utils/refund-policy'; + +const VALID_STATUSES: RefundStatusValue[] = ['REQUESTED', 'APPROVED', 'REJECTED', 'COMPLETED']; + +const getAdminId = (req: Request): number => (req.user as { user_id: number }).user_id; + +const parseRefundId = (raw: string): number | null => { + const n = Number(raw); + return Number.isInteger(n) && n > 0 ? n : null; +}; + +const fail = (res: Response, error: any) => { + const status = error.statusCode || 500; + return res.status(status).json({ + error: error.error || 'InternalServerError', + message: error.message || '서버 오류가 발생했습니다.', + statusCode: status, + }); +}; + +const badRefundId = (res: Response) => + res.status(400).json({ + error: 'ValidationError', + message: 'refundId가 올바르지 않습니다.', + statusCode: 400, + }); + +export const getRefundListHandler = async (req: Request, res: Response) => { + const rawStatus = req.query.status as string | undefined; + if (rawStatus && !VALID_STATUSES.includes(rawStatus as RefundStatusValue)) { + return res.status(400).json({ + error: 'ValidationError', + message: `status는 ${VALID_STATUSES.join(', ')} 중 하나여야 합니다.`, + statusCode: 400, + }); + } + + try { + const result = await listRefunds({ + status: rawStatus as RefundStatusValue | undefined, + page: Number(req.query.page) || 1, + size: Number(req.query.size) || 20, + }); + return res.status(200).json(result); + } catch (error: any) { + return fail(res, error); + } +}; + +// 검토 대기 목록 — 목록 핸들러에 status를 고정한 얇은 래퍼. +export const getPendingRefundListHandler = async (req: Request, res: Response) => { + try { + const result = await listRefunds({ + status: 'REQUESTED', + page: Number(req.query.page) || 1, + size: Number(req.query.size) || 20, + }); + return res.status(200).json(result); + } catch (error: any) { + return fail(res, error); + } +}; + +export const getRefundDetailHandler = async (req: Request, res: Response) => { + const refundId = parseRefundId(req.params.refundId); + if (!refundId) return badRefundId(res); + try { + return res.status(200).json(await getRefundDetail(refundId)); + } catch (error: any) { + return fail(res, error); + } +}; + +export const approveRefundHandler = async (req: Request, res: Response) => { + const refundId = parseRefundId(req.params.refundId); + if (!refundId) return badRefundId(res); + try { + return res.status(200).json(await approveRefund(refundId, getAdminId(req))); + } catch (error: any) { + return fail(res, error); + } +}; + +export const rejectRefundHandler = async (req: Request, res: Response) => { + const refundId = parseRefundId(req.params.refundId); + if (!refundId) return badRefundId(res); + try { + return res.status(200).json(await rejectRefund(refundId, getAdminId(req), req.body?.reason)); + } catch (error: any) { + return fail(res, error); + } +}; + +export const completeManualRefundHandler = async (req: Request, res: Response) => { + const refundId = parseRefundId(req.params.refundId); + if (!refundId) return badRefundId(res); + try { + return res.status(200).json(await completeManualRefund(refundId, getAdminId(req))); + } catch (error: any) { + return fail(res, error); + } +}; diff --git a/src/refunds/controllers/refund.controller.ts b/src/refunds/controllers/refund.controller.ts index 4b6974d..7def65e 100644 --- a/src/refunds/controllers/refund.controller.ts +++ b/src/refunds/controllers/refund.controller.ts @@ -1,5 +1,9 @@ import { Request, Response } from 'express'; -import { getRefundEligibility, refundPurchase } from '../services/refund.service'; +import { + getRefundEligibility, + refundPurchase, + requestManualRefund, +} from '../services/refund.service'; const getUserId = (req: Request): number | null => { if (!req.user) return null; @@ -55,3 +59,26 @@ export const refundPurchaseHandler = async (req: Request, res: Response) => { }); } }; + +// 열람 후 수동 환불 신청 — 담당자 검토 대기 상태로 접수만 한다. (#533) +export const requestManualRefundHandler = 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 requestManualRefund(userId, purchaseId, req.body?.reason); + 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 index 8ff3be4..766001b 100644 --- a/src/refunds/dtos/refund.dto.ts +++ b/src/refunds/dtos/refund.dto.ts @@ -1,17 +1,15 @@ -export type RefundIneligibleReason = - | 'EXPIRED_7DAYS' - | 'ALREADY_DOWNLOADED' - | 'ALREADY_REFUNDED' - | 'NOT_OWNER' - | 'NOT_PURCHASED' - | 'PAYMENT_NOT_SUCCEEDED' - | 'FREE_PURCHASE'; +// 판정 사유는 정책 함수(refund-policy)가 단일 소스이므로 여기서는 재노출만 한다. (#533) +export type { RefundIneligibleReason } from '../utils/refund-policy'; +import type { RefundIneligibleReason } from '../utils/refund-policy'; export interface RefundEligibilityResponseDto { message: string; - eligible: boolean; + eligible: boolean; // 열람 전 7일 이내 자동 환불 가능 여부 reason?: RefundIneligibleReason; - remaining_seconds?: number; // 환불 가능한 잔여 시간 (eligible=true일 때만) + remaining_seconds?: number; // 자동 환불 잔여 시간 (eligible=true일 때만) + refund_deadline: string | null; // 자동 환불 마감 시각 (KST 기준 D+7 종료) + manual_refund_available: boolean; // 열람 후 수동 환불 신청 가능 여부 + manual_refund_deadline: string | null; // 수동 환불 신청 마감 (구매 후 3개월) statusCode: number; } @@ -22,3 +20,64 @@ export interface RefundResultDto { refunded_at: string; statusCode: number; } + +export interface RefundRequestResultDto { + message: string; + refund_id: number; + status: 'REQUESTED'; + requested_at: string; + statusCode: number; +} + +// --- 관리자 --- + +export interface AdminRefundListItemDto { + refund_id: number; + purchase_id: number; + status: string; + amount: number; + request_reason: string | null; + reject_reason: string | null; + payple_fail_code: string | null; + requested_at: string; + reviewed_at: string | null; + reviewed_by: number | null; + buyer: { user_id: number; nickname: string; email: string }; + prompt: { prompt_id: number; title: string }; +} + +export interface AdminRefundListResponseDto { + message: string; + refunds: AdminRefundListItemDto[]; + total: number; + page: number; + size: number; + statusCode: number; +} + +// 담당자가 "본문이 부실한지 / 외부에서 가져온 것인지"를 판단해야 하므로 +// 상세 응답에 프롬프트 본문과 상세페이지 설명을 함께 싣는다. +export interface AdminRefundDetailDto extends AdminRefundListItemDto { + purchased_at: string; + downloaded_at: string | null; + prompt_detail: { + description: string | null; + prompt: string | null; + models: string[]; + }; +} + +export interface AdminRefundDetailResponseDto { + message: string; + refund: AdminRefundDetailDto; + statusCode: number; +} + +export interface AdminRefundActionResultDto { + message: string; + refund_id: number; + status: string; + payple_cancel_failed?: boolean; // 승인됐으나 Payple 취소가 실패 → 수동 송금 필요 + payple_fail_code?: string | null; + statusCode: number; +} diff --git a/src/refunds/routes/admin-refund.route.ts b/src/refunds/routes/admin-refund.route.ts new file mode 100644 index 0000000..60b9409 --- /dev/null +++ b/src/refunds/routes/admin-refund.route.ts @@ -0,0 +1,187 @@ +import { Router } from 'express'; +import { authenticateJwt } from '../../config/passport'; +import { isAdmin } from '../../middlewares/isAdmin'; +import { + approveRefundHandler, + completeManualRefundHandler, + getPendingRefundListHandler, + getRefundDetailHandler, + getRefundListHandler, + rejectRefundHandler, +} from '../controllers/admin-refund.controller'; + +const router = Router(); + +/** + * @swagger + * tags: + * - name: AdminRefund + * description: 관리자 - 환불 신청 검토 (열람 후 환불, 최장 3개월) + */ + +/** + * @swagger + * /api/admin/refunds/pending: + * get: + * summary: 검토 대기 환불 신청 목록 + * description: status가 REQUESTED인 환불 신청만 조회합니다. + * tags: [AdminRefund] + * security: + * - jwt: [] + * parameters: + * - in: query + * name: page + * schema: { type: integer, default: 1 } + * - in: query + * name: size + * schema: { type: integer, default: 20, maximum: 100 } + * responses: + * 200: + * description: 조회 성공 + * 401: { description: 로그인 필요 } + * 403: { description: 관리자 권한 필요 } + */ +router.get('/pending', authenticateJwt, isAdmin, getPendingRefundListHandler); + +/** + * @swagger + * /api/admin/refunds: + * get: + * summary: 환불 전체 이력 조회 + * description: | + * status로 필터링합니다. `APPROVED`는 담당자 승인 후 PG 결제 취소가 실패해 + * 수동 송금 처리가 필요한 건입니다. + * tags: [AdminRefund] + * security: + * - jwt: [] + * parameters: + * - in: query + * name: status + * schema: + * type: string + * enum: [REQUESTED, APPROVED, REJECTED, COMPLETED] + * - in: query + * name: page + * schema: { type: integer, default: 1 } + * - in: query + * name: size + * schema: { type: integer, default: 20, maximum: 100 } + * responses: + * 200: { description: 조회 성공 } + * 401: { description: 로그인 필요 } + * 403: { description: 관리자 권한 필요 } + */ +router.get('/', authenticateJwt, isAdmin, getRefundListHandler); + +/** + * @swagger + * /api/admin/refunds/{refundId}: + * get: + * summary: 환불 신청 상세 조회 + * description: | + * 담당자가 부실 여부를 판단할 수 있도록 프롬프트 본문(`prompt_detail.prompt`)과 + * 상세페이지 설명(`prompt_detail.description`), 지원 모델 목록을 함께 반환합니다. + * tags: [AdminRefund] + * security: + * - jwt: [] + * parameters: + * - in: path + * name: refundId + * required: true + * schema: { type: integer } + * responses: + * 200: { description: 조회 성공 } + * 404: { description: 환불 건 없음 } + */ +router.get('/:refundId', authenticateJwt, isAdmin, getRefundDetailHandler); + +/** + * @swagger + * /api/admin/refunds/{refundId}/approve: + * patch: + * summary: 환불 신청 승인 + * description: | + * 상태를 APPROVED로 확정한 뒤 Payple 결제 취소를 호출합니다. + * - 취소 성공 → `status: COMPLETED` + * - 취소 실패(카드사 취소 가능 기간 초과 등) → `status: APPROVED` 유지 + + * `payple_cancel_failed: true`. 승인을 되돌리지 않으므로 계좌 송금 등으로 + * 수동 처리 후 `/complete-manual`을 호출해야 합니다. + * tags: [AdminRefund] + * security: + * - jwt: [] + * parameters: + * - in: path + * name: refundId + * required: true + * schema: { type: integer } + * responses: + * 200: + * description: 처리 완료 (payple_cancel_failed 확인 필요) + * content: + * application/json: + * schema: + * type: object + * properties: + * message: { type: string } + * refund_id: { type: integer } + * status: { type: string, enum: [COMPLETED, APPROVED] } + * payple_cancel_failed: { type: boolean } + * payple_fail_code: { type: string, nullable: true } + * statusCode: { type: integer, example: 200 } + * 409: { description: 이미 처리된 환불 건 } + * 404: { description: 환불 건 없음 } + */ +router.patch('/:refundId/approve', authenticateJwt, isAdmin, approveRefundHandler); + +/** + * @swagger + * /api/admin/refunds/{refundId}/reject: + * patch: + * summary: 환불 신청 거절 + * tags: [AdminRefund] + * security: + * - jwt: [] + * parameters: + * - in: path + * name: refundId + * required: true + * schema: { type: integer } + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [reason] + * properties: + * reason: { type: string, maxLength: 500, description: 거절 사유 } + * responses: + * 200: { description: 거절 완료 } + * 400: { description: 거절 사유 누락 } + * 409: { description: 이미 처리된 환불 건 } + */ +router.patch('/:refundId/reject', authenticateJwt, isAdmin, rejectRefundHandler); + +/** + * @swagger + * /api/admin/refunds/{refundId}/complete-manual: + * patch: + * summary: 수동 송금 완료 처리 + * description: | + * PG 취소가 실패해 APPROVED에서 멈춘 건을 계좌 송금 등으로 처리한 뒤 + * COMPLETED로 전이시킵니다. Payment/Settlement도 이 시점에 Refunded로 전이됩니다. + * tags: [AdminRefund] + * security: + * - jwt: [] + * parameters: + * - in: path + * name: refundId + * required: true + * schema: { type: integer } + * responses: + * 200: { description: 완료 처리됨 } + * 409: { description: APPROVED 상태가 아님 } + */ +router.patch('/:refundId/complete-manual', authenticateJwt, isAdmin, completeManualRefundHandler); + +export default router; diff --git a/src/refunds/routes/refund.route.ts b/src/refunds/routes/refund.route.ts index 46cacb9..6edcb2b 100644 --- a/src/refunds/routes/refund.route.ts +++ b/src/refunds/routes/refund.route.ts @@ -3,6 +3,7 @@ import { authenticateJwt } from '../../config/passport'; import { checkRefundEligibility, refundPurchaseHandler, + requestManualRefundHandler, } from '../controllers/refund.controller'; const router = Router(); @@ -26,7 +27,9 @@ const router = Router(); * - 결제 상태 Succeed * - 환불 이력 없음 * - 다운로드 이력 없음 (`Purchase.downloaded_at` 미값) - * - 구매 후 7일(168시간) 이내 + * - 구매 후 7일 이내 — **KST 날짜 기준, 첫날 제외.** 7/23 구매 시 7/30 23:59:59까지 + * + * 이미 열람한 건은 `manual_refund_available`로 수동 환불 신청 가능 여부를 확인하세요. * tags: [Refund] * security: * - jwt: [] @@ -47,11 +50,23 @@ const router = Router(); * eligible: { type: boolean } * reason: * type: string - * enum: [EXPIRED_7DAYS, ALREADY_DOWNLOADED, ALREADY_REFUNDED, NOT_OWNER, NOT_PURCHASED, PAYMENT_NOT_SUCCEEDED, FREE_PURCHASE] + * enum: [EXPIRED_7DAYS, EXPIRED_3MONTHS, ALREADY_DOWNLOADED, NOT_DOWNLOADED, ALREADY_REFUNDED, REFUND_IN_REVIEW, REFUND_REJECTED, NOT_OWNER, NOT_PURCHASED, PAYMENT_NOT_SUCCEEDED, FREE_PURCHASE] * description: eligible=false일 때만 존재 * remaining_seconds: * type: integer * description: eligible=true일 때 환불 가능 잔여 시간(초) + * refund_deadline: + * type: string + * format: date-time + * nullable: true + * description: 자동 환불 마감 시각 (KST 기준 D+7 종료) + * manual_refund_available: + * type: boolean + * description: 열람 후 수동 환불 신청 가능 여부 (구매 후 3개월 이내) + * manual_refund_deadline: + * type: string + * format: date-time + * nullable: true * statusCode: { type: integer, example: 200 } * 401: * description: 로그인 필요 @@ -100,4 +115,63 @@ router.get('/:purchaseId/refund-eligibility', authenticateJwt, checkRefundEligib */ router.post('/:purchaseId/refund', authenticateJwt, refundPurchaseHandler); +/** + * @swagger + * /api/prompts/purchases/{purchaseId}/refund-request: + * post: + * summary: 수동 환불 신청 (열람 후) + * description: | + * 이미 열람한 프롬프트의 환불을 신청합니다. 단순 변심은 불가하며, + * 담당자가 아래 사유에 해당하는지 확인 후 승인/거절합니다. + * - 본문이 비어 있거나 의미 있는 지시문이라 볼 수 없는 경우 + * - 본문 분량·구성이 상세페이지 안내 수준에 현저히 미달하는 경우 + * - 명시된 AI 모델에서 실행해도 상세페이지 예시와 같은 범주의 결과물을 얻을 수 없는 경우 + * - 작성자가 직접 작성하지 않고 외부에서 가져온 경우 + * + * 신청 가능 조건: 열람함(`downloaded_at` 있음) + 구매 후 3개월 이내 + 기존 환불 이력 없음. + * 아직 열람하지 않았고 7일이 지나지 않았다면 검토 없이 즉시 환불되므로 + * `POST /refund`를 사용해야 하며, 이 경우 400 `UseAutoRefund`를 반환합니다. + * tags: [Refund] + * security: + * - jwt: [] + * parameters: + * - in: path + * name: purchaseId + * required: true + * schema: { type: integer } + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [reason] + * properties: + * reason: + * type: string + * minLength: 10 + * maxLength: 500 + * description: 환불 신청 사유 + * responses: + * 200: + * description: 신청 접수 완료 + * content: + * application/json: + * schema: + * type: object + * properties: + * message: { type: string } + * refund_id: { type: integer } + * status: { type: string, example: REQUESTED } + * requested_at: { type: string, format: date-time } + * statusCode: { type: integer, example: 200 } + * 400: + * description: 신청 불가 (RefundNotEligible / UseAutoRefund / ValidationError) + * 401: + * description: 로그인 필요 + * 404: + * description: 환불 대상 결제 정보를 찾을 수 없음 + */ +router.post('/:purchaseId/refund-request', authenticateJwt, requestManualRefundHandler); + export default router; diff --git a/src/refunds/services/admin-refund.service.ts b/src/refunds/services/admin-refund.service.ts new file mode 100644 index 0000000..4225e4a --- /dev/null +++ b/src/refunds/services/admin-refund.service.ts @@ -0,0 +1,296 @@ +import prisma from '../../config/prisma'; +import { AppError } from '../../errors/AppError'; +import { requestPaypleRefund } from '../../settlements/utils/payple-refund'; +import { + AdminRefundActionResultDto, + AdminRefundDetailResponseDto, + AdminRefundListResponseDto, +} from '../dtos/refund.dto'; +import { formatYyyymmdd, markPaymentRefunded } from './refund.service'; +import { RefundStatusValue } from '../utils/refund-policy'; + +const MAX_PAGE_SIZE = 100; +const MAX_REJECT_REASON = 500; + +const listSelect = { + refund_id: true, + purchase_id: true, + status: true, + amount: true, + request_reason: true, + reject_reason: true, + payple_fail_code: true, + requested_at: true, + reviewed_at: true, + reviewed_by: true, + user: { select: { user_id: true, nickname: true, email: true } }, + purchase: { + select: { + created_at: true, + downloaded_at: true, + prompt: { select: { prompt_id: true, title: true } }, + }, + }, +} as const; + +const toListItem = (r: any) => ({ + refund_id: r.refund_id, + purchase_id: r.purchase_id, + status: r.status, + amount: r.amount, + request_reason: r.request_reason, + reject_reason: r.reject_reason, + payple_fail_code: r.payple_fail_code, + requested_at: r.requested_at.toISOString(), + reviewed_at: r.reviewed_at?.toISOString() ?? null, + reviewed_by: r.reviewed_by, + buyer: { + user_id: r.user.user_id, + nickname: r.user.nickname, + email: r.user.email, + }, + prompt: { + prompt_id: r.purchase.prompt.prompt_id, + title: r.purchase.prompt.title, + }, +}); + +export const listRefunds = async (params: { + status?: RefundStatusValue; + page: number; + size: number; +}): Promise => { + const size = Math.min(Math.max(params.size, 1), MAX_PAGE_SIZE); + const page = Math.max(params.page, 1); + const where = params.status ? { status: params.status } : {}; + + const [rows, total] = await Promise.all([ + prisma.refund.findMany({ + where, + select: listSelect, + orderBy: { requested_at: 'desc' }, + skip: (page - 1) * size, + take: size, + }), + prisma.refund.count({ where }), + ]); + + return { + message: '환불 목록 조회 성공', + refunds: rows.map(toListItem), + total, + page, + size, + statusCode: 200, + }; +}; + +export const getRefundDetail = async ( + refundId: number, +): Promise => { + const refund = await prisma.refund.findUnique({ + where: { refund_id: refundId }, + select: { + ...listSelect, + purchase: { + select: { + created_at: true, + downloaded_at: true, + prompt: { + select: { + prompt_id: true, + title: true, + description: true, + prompt: true, + models: { include: { model: { select: { name: true } } } }, + }, + }, + }, + }, + }, + }); + + if (!refund) { + throw new AppError('환불 건을 찾을 수 없습니다.', 404, 'NotFound'); + } + + return { + message: '환불 상세 조회 성공', + refund: { + ...toListItem(refund), + purchased_at: refund.purchase.created_at.toISOString(), + downloaded_at: refund.purchase.downloaded_at?.toISOString() ?? null, + // 담당자가 부실 여부를 직접 판단해야 하므로 본문과 상세페이지 설명을 함께 제공한다. + prompt_detail: { + description: refund.purchase.prompt.description ?? null, + prompt: refund.purchase.prompt.prompt ?? null, + models: refund.purchase.prompt.models.map((m: any) => m.model.name), + }, + }, + statusCode: 200, + }; +}; + +// 승인 — 상태를 먼저 APPROVED로 확정한 뒤 Payple 취소를 시도한다. +// 취소가 실패해도 승인을 되돌리지 않는다. 되돌리면 카드사 취소 기간이 지난 건은 +// 시스템상 영영 환불 불가로 남기 때문에, APPROVED에서 멈추고 수동 송금 대상으로 넘긴다. +export const approveRefund = async ( + refundId: number, + adminId: number, +): Promise => { + const refund = await prisma.refund.findUnique({ + where: { refund_id: refundId }, + select: { + refund_id: true, + status: true, + amount: true, + payment_id: true, + payment: { select: { pcd_pay_oid: true, created_at: true } }, + }, + }); + + if (!refund) throw new AppError('환불 건을 찾을 수 없습니다.', 404, 'NotFound'); + if (refund.status !== 'REQUESTED') { + throw new AppError( + `이미 처리된 환불 건입니다. (현재 상태: ${refund.status})`, + 409, + 'RefundAlreadyReviewed', + ); + } + if (!refund.payment) { + throw new AppError('환불 대상 결제 정보를 찾을 수 없습니다.', 404, 'NotFound'); + } + + await prisma.refund.update({ + where: { refund_id: refundId }, + data: { status: 'APPROVED', reviewed_by: adminId, reviewed_at: new Date() }, + }); + + try { + const result = await requestPaypleRefund({ + payOid: refund.payment.pcd_pay_oid, + payDate: formatYyyymmdd(refund.payment.created_at), + refundTotal: refund.amount, + }); + + await prisma.$transaction(async (tx) => { + await tx.refund.update({ + where: { refund_id: refundId }, + data: { + status: 'COMPLETED', + refunded_at: new Date(), + payple_pay_code: result.payCode, + payple_card_trade_num: result.cardTradeNum ?? null, + payple_fail_code: null, + }, + }); + await markPaymentRefunded(tx, refund.payment_id); + }); + + return { + message: '환불이 승인되어 결제 취소까지 완료되었습니다.', + refund_id: refundId, + status: 'COMPLETED', + statusCode: 200, + }; + } catch (err: any) { + const failCode = (err?.paypleCode as string | undefined) ?? 'UNKNOWN'; + console.error('[admin-refund] payple cancel failed after approval', { + refundId, + failCode, + }); + await prisma.refund.update({ + where: { refund_id: refundId }, + data: { payple_fail_code: failCode }, + }); + + return { + message: + '환불은 승인됐으나 PG 결제 취소에 실패했습니다. 계좌 송금 등으로 수동 처리 후 완료 처리해주세요.', + refund_id: refundId, + status: 'APPROVED', + payple_cancel_failed: true, + payple_fail_code: failCode, + statusCode: 200, + }; + } +}; + +export const rejectRefund = async ( + refundId: number, + adminId: number, + reason: string, +): Promise => { + const trimmed = (reason ?? '').trim(); + if (!trimmed || trimmed.length > MAX_REJECT_REASON) { + throw new AppError( + `거절 사유는 1자 이상 ${MAX_REJECT_REASON}자 이하로 입력해주세요.`, + 400, + 'ValidationError', + ); + } + + const refund = await prisma.refund.findUnique({ + where: { refund_id: refundId }, + select: { status: true }, + }); + if (!refund) throw new AppError('환불 건을 찾을 수 없습니다.', 404, 'NotFound'); + if (refund.status !== 'REQUESTED') { + throw new AppError( + `이미 처리된 환불 건입니다. (현재 상태: ${refund.status})`, + 409, + 'RefundAlreadyReviewed', + ); + } + + await prisma.refund.update({ + where: { refund_id: refundId }, + data: { + status: 'REJECTED', + reject_reason: trimmed, + reviewed_by: adminId, + reviewed_at: new Date(), + }, + }); + + return { + message: '환불 신청을 거절했습니다.', + refund_id: refundId, + status: 'REJECTED', + statusCode: 200, + }; +}; + +// PG 취소가 실패해 APPROVED에서 멈춘 건을 오프라인 송금 후 완료 처리. +export const completeManualRefund = async ( + refundId: number, + adminId: number, +): Promise => { + const refund = await prisma.refund.findUnique({ + where: { refund_id: refundId }, + select: { status: true, payment_id: true }, + }); + if (!refund) throw new AppError('환불 건을 찾을 수 없습니다.', 404, 'NotFound'); + if (refund.status !== 'APPROVED') { + throw new AppError( + `수동 완료 처리는 승인(APPROVED) 상태에서만 가능합니다. (현재 상태: ${refund.status})`, + 409, + 'RefundNotApproved', + ); + } + + await prisma.$transaction(async (tx) => { + await tx.refund.update({ + where: { refund_id: refundId }, + data: { status: 'COMPLETED', refunded_at: new Date(), reviewed_by: adminId }, + }); + await markPaymentRefunded(tx, refund.payment_id); + }); + + return { + message: '수동 환불 완료 처리되었습니다.', + refund_id: refundId, + status: 'COMPLETED', + statusCode: 200, + }; +}; diff --git a/src/refunds/services/refund.service.ts b/src/refunds/services/refund.service.ts index 6a42051..79385a5 100644 --- a/src/refunds/services/refund.service.ts +++ b/src/refunds/services/refund.service.ts @@ -1,115 +1,135 @@ +import { Prisma } from '@prisma/client'; import prisma from '../../config/prisma'; import { AppError } from '../../errors/AppError'; import { RefundEligibilityResponseDto, RefundResultDto, - RefundIneligibleReason, + RefundRequestResultDto, } 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; +import { + checkAutoRefund, + checkManualRefund, + RefundPolicyInput, +} from '../utils/refund-policy'; + +// 정책 판정에 필요한 필드 — 목록 API도 동일한 필드를 읽도록 여기서 공개한다. (#533) +export const PURCHASE_POLICY_SELECT = { + purchase_id: true, + user_id: true, + amount: true, + created_at: true, + downloaded_at: true, + is_free: true, + payment: { + select: { payment_id: true, pcd_pay_oid: true, created_at: true, status: true }, + }, + refund: { select: { refund_id: true, status: true } }, +} as const; + +interface PurchaseWithPolicyFields { + user_id: number; + created_at: Date; + downloaded_at: Date | null; + is_free: boolean; + payment?: { status: string } | null; + refund?: { status: string } | null; } -const checkEligibility = async (userId: number, purchaseId: number): Promise => { - const purchase = await prisma.purchase.findUnique({ +export const toPolicyInput = (purchase: PurchaseWithPolicyFields): RefundPolicyInput => ({ + purchase_user_id: purchase.user_id, + created_at: purchase.created_at, + downloaded_at: purchase.downloaded_at, + is_free: purchase.is_free, + payment_status: purchase.payment?.status, + refund_status: (purchase.refund?.status as RefundPolicyInput['refund_status']) ?? null, +}); + +const loadPurchase = async (purchaseId: number) => + 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 } }, - }, + select: PURCHASE_POLICY_SELECT, }); - 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' }; - } +export 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}`; +}; - return { - eligible: true, - remaining_seconds: Math.floor((REFUND_WINDOW_MS - elapsed) / 1000), - }; +// 결제 취소 성공 후 Payment/Settlement 상태 전이. 자동 환불과 관리자 승인이 공유. +export const markPaymentRefunded = async ( + tx: Prisma.TransactionClient, + paymentId: number, +): Promise => { + await tx.payment.update({ + where: { payment_id: paymentId }, + data: { status: 'Refunded' }, + }); + // Settlement이 있는 경우만 (status 무관하게) Refunded로 전이 + await tx.settlement.updateMany({ + where: { payment_id: paymentId }, + data: { status: 'Refunded' }, + }); }; export const getRefundEligibility = async ( userId: number, purchaseId: number, ): Promise => { - const result = await checkEligibility(userId, purchaseId); + const purchase = await loadPurchase(purchaseId); + if (!purchase) { + return { + message: '환불 불가', + eligible: false, + reason: 'NOT_PURCHASED', + refund_deadline: null, + manual_refund_available: false, + manual_refund_deadline: null, + statusCode: 200, + }; + } + + const input = toPolicyInput(purchase); + const auto = checkAutoRefund(input, userId); + const manual = checkManualRefund(input, userId); + return { - message: result.eligible ? '환불 가능' : '환불 불가', - eligible: result.eligible, - reason: result.reason, - remaining_seconds: result.remaining_seconds, + message: auto.eligible ? '환불 가능' : '환불 불가', + eligible: auto.eligible, + reason: auto.reason, + remaining_seconds: auto.remaining_seconds, + refund_deadline: auto.refund_deadline, + manual_refund_available: manual.eligible, + manual_refund_deadline: manual.refund_deadline, 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}`; -}; - +// 열람 전 + 7일 이내 자동 환불 — 즉시 Payple 취소까지 수행. 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'); + const purchase = await loadPurchase(purchaseId); + if (!purchase) { + throw new AppError('환불 불가: NOT_PURCHASED', 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) { + const verdict = checkAutoRefund(toPolicyInput(purchase), userId); + if (!verdict.eligible) { + throw new AppError(`환불 불가: ${verdict.reason}`, 400, 'RefundNotEligible'); + } + if (!purchase.payment) { throw new AppError('환불 대상 결제 정보를 찾을 수 없습니다.', 404, 'NotFound'); } + const payment = purchase.payment; // Payple 결제 취소 호출 (실패 시 DB는 손대지 않음) const paypleResult = await requestPaypleRefund({ - payOid: purchase.payment.pcd_pay_oid, - payDate: formatYyyymmdd(purchase.payment.created_at), + payOid: payment.pcd_pay_oid, + payDate: formatYyyymmdd(payment.created_at), refundTotal: purchase.amount, }); @@ -122,26 +142,19 @@ export const refundPurchase = async ( const created = await tx.refund.create({ data: { purchase_id: purchase.purchase_id, - payment_id: purchase.payment!.payment_id, + payment_id: payment.payment_id, user_id: purchase.user_id, amount: purchase.amount, initiator: 'USER', reason: '7일 이내 미열람 자동 환불', + status: 'COMPLETED', + refunded_at: new Date(), 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' }, - }); + await markPaymentRefunded(tx, payment.payment_id); return created; }); @@ -150,7 +163,78 @@ export const refundPurchase = async ( message: '환불이 완료되었습니다.', refund_id: refund.refund_id, refunded_amount: refund.amount, - refunded_at: refund.refunded_at.toISOString(), + refunded_at: (refund.refunded_at ?? new Date()).toISOString(), + statusCode: 200, + }; +}; + +const MIN_REASON_LENGTH = 10; +const MAX_REASON_LENGTH = 500; + +// 열람 후 수동 환불 신청 — 담당자 검토 대기 상태로만 기록하고 결제 취소는 하지 않는다. +export const requestManualRefund = async ( + userId: number, + purchaseId: number, + reason: string, +): Promise => { + const trimmed = (reason ?? '').trim(); + if (trimmed.length < MIN_REASON_LENGTH || trimmed.length > MAX_REASON_LENGTH) { + throw new AppError( + `환불 사유는 ${MIN_REASON_LENGTH}자 이상 ${MAX_REASON_LENGTH}자 이하로 입력해주세요.`, + 400, + 'ValidationError', + ); + } + + const purchase = await loadPurchase(purchaseId); + if (!purchase) { + throw new AppError('환불 신청 불가: NOT_PURCHASED', 400, 'RefundNotEligible'); + } + + const input = toPolicyInput(purchase); + const verdict = checkManualRefund(input, userId); + if (!verdict.eligible) { + // 미열람 + 7일 이내라면 검토 없이 즉시 환불받을 수 있으므로 그쪽으로 안내한다. + if (verdict.reason === 'NOT_DOWNLOADED' && checkAutoRefund(input, userId).eligible) { + throw new AppError( + '아직 열람하지 않은 구매 건은 검토 없이 즉시 환불받을 수 있습니다. 환불하기를 이용해주세요.', + 400, + 'UseAutoRefund', + ); + } + throw new AppError(`환불 신청 불가: ${verdict.reason}`, 400, 'RefundNotEligible'); + } + if (!purchase.payment) { + throw new AppError('환불 대상 결제 정보를 찾을 수 없습니다.', 404, 'NotFound'); + } + + let refund; + try { + refund = await prisma.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: '열람 후 환불 신청', + request_reason: trimmed, + status: 'REQUESTED', + }, + }); + } catch (err) { + // 동시 신청 경합 — purchase_id/payment_id unique 충돌은 "이미 신청됨"으로 돌려준다. + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + throw new AppError('이미 접수된 환불 신청이 있습니다.', 409, 'RefundAlreadyRequested'); + } + throw err; + } + + return { + message: '환불 신청이 접수되었습니다. 담당자 확인 후 처리됩니다.', + refund_id: refund.refund_id, + status: 'REQUESTED', + requested_at: refund.requested_at.toISOString(), statusCode: 200, }; }; diff --git a/src/refunds/utils/refund-policy.check.ts b/src/refunds/utils/refund-policy.check.ts new file mode 100644 index 0000000..926a058 --- /dev/null +++ b/src/refunds/utils/refund-policy.check.ts @@ -0,0 +1,107 @@ +// 환불 기간 경계 자체 검증. 실행: npx ts-node src/refunds/utils/refund-policy.check.ts +// 정책의 핵심은 "시:분을 무시한 KST 날짜 기준, 첫날 제외"이므로 경계값만 확인한다. (#533) +import assert from 'assert'; +import { + checkAutoRefund, + checkManualRefund, + getAutoRefundDeadline, + getManualRefundDeadline, + RefundPolicyInput, +} from './refund-policy'; + +// KST 시각을 UTC Date로 +const kst = (iso: string): Date => new Date(`${iso}+09:00`); + +const paid = (createdAt: Date, downloadedAt: Date | null = null): RefundPolicyInput => ({ + purchase_user_id: 1, + created_at: createdAt, + downloaded_at: downloadedAt, + is_free: false, + payment_status: 'Succeed', + refund_status: null, +}); + +// --- 자동 환불 마감: 7/23 구매 → 7/30 23:59:59까지 (= 7/31 00:00 KST 직전) --- +{ + const expected = kst('2026-07-31T00:00:00'); + for (const t of ['2026-07-23T00:00:00', '2026-07-23T15:00:00', '2026-07-23T23:59:59']) { + assert.strictEqual( + getAutoRefundDeadline(kst(t)).getTime(), + expected.getTime(), + `구매 시각 ${t} 의 마감이 7/31 00:00 KST 여야 함 (시간대 무시)`, + ); + } +} + +// --- 경계: 마감 직전은 가능, 마감 시각부터 불가 --- +{ + const purchase = paid(kst('2026-07-23T15:00:00')); + assert.strictEqual(checkAutoRefund(purchase, 1, kst('2026-07-30T23:59:59')).eligible, true); + assert.strictEqual(checkAutoRefund(purchase, 1, kst('2026-07-31T00:00:00')).eligible, false); + assert.strictEqual( + checkAutoRefund(purchase, 1, kst('2026-07-31T00:00:00')).reason, + 'EXPIRED_7DAYS', + ); + // 구매 당일도 당연히 가능 + assert.strictEqual(checkAutoRefund(purchase, 1, kst('2026-07-23T15:00:01')).eligible, true); +} + +// --- 열람하면 자동 환불 불가, 대신 수동 환불 대상 --- +{ + const opened = paid(kst('2026-07-23T15:00:00'), kst('2026-07-24T10:00:00')); + const now = kst('2026-07-25T00:00:00'); + assert.strictEqual(checkAutoRefund(opened, 1, now).reason, 'ALREADY_DOWNLOADED'); + assert.strictEqual(checkManualRefund(opened, 1, now).eligible, true); +} + +// --- 미열람 건은 수동 환불 신청 대상이 아님 (자동 환불로 안내) --- +{ + const unopened = paid(kst('2026-07-23T15:00:00')); + assert.strictEqual( + checkManualRefund(unopened, 1, kst('2026-07-25T00:00:00')).reason, + 'NOT_DOWNLOADED', + ); +} + +// --- 수동 환불 3개월 마감 + 말일 보정 (1/31 + 3개월 → 4/30) --- +{ + assert.strictEqual( + getManualRefundDeadline(kst('2026-01-31T12:00:00')).getTime(), + kst('2026-05-01T00:00:00').getTime(), + '1/31 구매의 3개월 마감은 4/30 종료여야 함', + ); + const opened = paid(kst('2026-07-23T15:00:00'), kst('2026-07-24T10:00:00')); + assert.strictEqual(checkManualRefund(opened, 1, kst('2026-10-23T23:59:59')).eligible, true); + assert.strictEqual( + checkManualRefund(opened, 1, kst('2026-10-24T00:00:00')).reason, + 'EXPIRED_3MONTHS', + ); +} + +// --- 공통 전제: 소유권 / 무료 / 결제상태 / 기존 환불 이력 --- +{ + const now = kst('2026-07-24T00:00:00'); + const base = paid(kst('2026-07-23T15:00:00')); + assert.strictEqual(checkAutoRefund(base, 999, now).reason, 'NOT_OWNER'); + assert.strictEqual(checkAutoRefund({ ...base, is_free: true }, 1, now).reason, 'FREE_PURCHASE'); + assert.strictEqual( + checkAutoRefund({ ...base, payment_status: 'Pending' }, 1, now).reason, + 'PAYMENT_NOT_SUCCEEDED', + ); + assert.strictEqual( + checkAutoRefund({ ...base, refund_status: 'COMPLETED' }, 1, now).reason, + 'ALREADY_REFUNDED', + ); + assert.strictEqual( + checkAutoRefund({ ...base, refund_status: 'REQUESTED' }, 1, now).reason, + 'REFUND_IN_REVIEW', + ); + assert.strictEqual( + checkAutoRefund({ ...base, refund_status: 'REJECTED' }, 1, now).reason, + 'REFUND_REJECTED', + ); + // 소유권 위반은 다른 사유보다 먼저 걸러져야 함 (정보 노출 방지) + assert.strictEqual(checkAutoRefund({ ...base, is_free: true }, 999, now).reason, 'NOT_OWNER'); +} + +console.log('refund-policy: 모든 경계 검증 통과'); diff --git a/src/refunds/utils/refund-policy.ts b/src/refunds/utils/refund-policy.ts new file mode 100644 index 0000000..549a51e --- /dev/null +++ b/src/refunds/utils/refund-policy.ts @@ -0,0 +1,144 @@ +// 환불 정책 판정 — 단건 조회 / 목록 / 수동 신청이 공유하는 단일 소스. +// 정책이 여러 곳에 복제되면 한쪽만 고쳐져 목록의 버튼 상태와 실제 환불 결과가 어긋나므로, +// 판정은 전부 이 파일의 순수 함수를 거친다. (#533) +// +// - 자동 환불: 열람 전 + 구매 후 7일 이내 +// - 수동 환불: 열람 후 + 구매 후 3개월 이내 (담당자 검토) +// +// 7일 기준은 시:분을 무시한 KST 날짜 기준이며 첫날을 제외한다. +// 예) 7/23 어느 시각에 구매하든 7/30 23:59:59(KST)까지 신청 가능. + +export type RefundStatusValue = 'REQUESTED' | 'APPROVED' | 'REJECTED' | 'COMPLETED'; + +export type RefundIneligibleReason = + | 'EXPIRED_7DAYS' + | 'EXPIRED_3MONTHS' + | 'ALREADY_DOWNLOADED' + | 'NOT_DOWNLOADED' + | 'ALREADY_REFUNDED' + | 'REFUND_IN_REVIEW' + | 'REFUND_REJECTED' + | 'NOT_OWNER' + | 'NOT_PURCHASED' + | 'PAYMENT_NOT_SUCCEEDED' + | 'FREE_PURCHASE'; + +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +const DAY_MS = 24 * 60 * 60 * 1000; + +export const AUTO_REFUND_DAYS = 7; +export const MANUAL_REFUND_MONTHS = 3; + +// 환불이 확정된 상태 — 콘텐츠 재열람을 막고 "환불됨"으로 표시해야 하는 구간. +// APPROVED는 담당자 승인이 끝나 금액이 돌아가는 중(Payple 취소 실패 시 수동 송금 대기)이므로 +// COMPLETED와 동일하게 취급한다. REQUESTED는 아직 검토 중이라 접근을 막지 않는다. +export const isRefundSettled = (status?: string | null): boolean => + status === 'APPROVED' || status === 'COMPLETED'; + +// 해당 시각이 속한 KST 날짜의 일련번호 (1970-01-01 KST = 0) +const kstDayIndex = (t: Date): number => Math.floor((t.getTime() + KST_OFFSET_MS) / DAY_MS); + +// KST 날짜 일련번호의 종료 시각 = 다음 날 00:00 KST (경계는 미포함) +const endOfKstDay = (dayIndex: number): Date => + new Date((dayIndex + 1) * DAY_MS - KST_OFFSET_MS); + +// 자동 환불 마감 — 구매일 다음 날부터 7일째 되는 날의 끝 (첫날 제외). +export const getAutoRefundDeadline = (purchasedAt: Date): Date => + endOfKstDay(kstDayIndex(purchasedAt) + AUTO_REFUND_DAYS); + +// 수동 환불 마감 — 구매일로부터 3개월 후 같은 날짜의 끝 (KST). +// 말일 보정: 1/31 + 3개월은 4/31이 없으므로 4/30으로 당긴다. +export const getManualRefundDeadline = (purchasedAt: Date): Date => { + const kst = new Date(purchasedAt.getTime() + KST_OFFSET_MS); + const year = kst.getUTCFullYear(); + const month = kst.getUTCMonth(); + const date = kst.getUTCDate(); + + // Date.UTC(y, m+4, 0) => (m+3)월의 말일 + const lastDateOfTargetMonth = new Date( + Date.UTC(year, month + MANUAL_REFUND_MONTHS + 1, 0), + ).getUTCDate(); + const targetDate = Math.min(date, lastDateOfTargetMonth); + + return new Date(Date.UTC(year, month + MANUAL_REFUND_MONTHS, targetDate + 1) - KST_OFFSET_MS); +}; + +export interface RefundPolicyInput { + purchase_user_id: number; + created_at: Date; + downloaded_at: Date | null; + is_free: boolean; + payment_status: string | null | undefined; + refund_status: RefundStatusValue | null; +} + +export interface RefundVerdict { + eligible: boolean; + reason?: RefundIneligibleReason; + refund_deadline: string | null; + remaining_seconds?: number; +} + +// 자동/수동 공통 전제 — 소유권, 유료 여부, 결제 성공, 기존 환불 이력. +const checkCommon = ( + input: RefundPolicyInput, + requesterId: number, +): RefundIneligibleReason | null => { + if (input.purchase_user_id !== requesterId) return 'NOT_OWNER'; + if (input.is_free) return 'FREE_PURCHASE'; + if (input.payment_status !== 'Succeed') return 'PAYMENT_NOT_SUCCEEDED'; + + switch (input.refund_status) { + case 'REQUESTED': + return 'REFUND_IN_REVIEW'; + case 'REJECTED': + return 'REFUND_REJECTED'; + case 'APPROVED': + case 'COMPLETED': + return 'ALREADY_REFUNDED'; + default: + return null; + } +}; + +const verdict = ( + reason: RefundIneligibleReason | null, + deadline: Date, + now: Date, +): RefundVerdict => { + if (reason) return { eligible: false, reason, refund_deadline: deadline.toISOString() }; + return { + eligible: true, + refund_deadline: deadline.toISOString(), + remaining_seconds: Math.floor((deadline.getTime() - now.getTime()) / 1000), + }; +}; + +// 열람 전 7일 이내 자동 환불 가능 여부. +export const checkAutoRefund = ( + input: RefundPolicyInput, + requesterId: number, + now: Date = new Date(), +): RefundVerdict => { + const deadline = getAutoRefundDeadline(input.created_at); + const common = checkCommon(input, requesterId); + if (common) return verdict(common, deadline, now); + if (input.downloaded_at) return verdict('ALREADY_DOWNLOADED', deadline, now); + if (now.getTime() >= deadline.getTime()) return verdict('EXPIRED_7DAYS', deadline, now); + return verdict(null, deadline, now); +}; + +// 열람 후 3개월 이내 수동 환불 신청 가능 여부. +export const checkManualRefund = ( + input: RefundPolicyInput, + requesterId: number, + now: Date = new Date(), +): RefundVerdict => { + const deadline = getManualRefundDeadline(input.created_at); + const common = checkCommon(input, requesterId); + if (common) return verdict(common, deadline, now); + // 미열람 건은 수동 검토 대상이 아니라 자동 환불 대상. + if (!input.downloaded_at) return verdict('NOT_DOWNLOADED', deadline, now); + if (now.getTime() >= deadline.getTime()) return verdict('EXPIRED_3MONTHS', deadline, now); + return verdict(null, deadline, now); +}; diff --git a/src/settlements/utils/payple-refund.ts b/src/settlements/utils/payple-refund.ts index 9e9f39a..1d3fdf7 100644 --- a/src/settlements/utils/payple-refund.ts +++ b/src/settlements/utils/payple-refund.ts @@ -130,7 +130,10 @@ export const requestPaypleRefund = async ( console.error('[payple-refund] request network error', { response: redactPaypleLog(err?.response?.data), }); - throw new AppError('Payple 환불 요청 통신에 실패했습니다.', 502, 'PaypleRefundFailed'); + throw Object.assign( + new AppError('Payple 환불 요청 통신에 실패했습니다.', 502, 'PaypleRefundFailed'), + { paypleCode: 'NETWORK_ERROR' }, + ); } if (res.data?.PCD_PAY_RST !== 'success') { @@ -138,10 +141,14 @@ export const requestPaypleRefund = async ( 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', + // 관리자 승인 흐름에서 실패 코드를 기록해야 하므로 구조화해서 함께 전달한다 (#533) + throw Object.assign( + new AppError( + `Payple 환불에 실패했습니다. (${res.data?.PCD_PAY_CODE ?? 'UNKNOWN'}) ${res.data?.PCD_PAY_MSG ?? ''}`, + 502, + 'PaypleRefundFailed', + ), + { paypleCode: String(res.data?.PCD_PAY_CODE ?? 'UNKNOWN').slice(0, 40) }, ); } diff --git a/swagger.json b/swagger.json index 43b660f..b1e0634 100644 --- a/swagger.json +++ b/swagger.json @@ -3944,7 +3944,32 @@ }, "is_refunded": { "type": "boolean", - "description": "환불 완료 여부" + "description": "환불 확정 여부 (APPROVED/COMPLETED)" + }, + "refund_status": { + "type": "string", + "nullable": true, + "enum": [ + "REQUESTED", + "APPROVED", + "REJECTED", + "COMPLETED" + ], + "description": "환불 진행 상태 (환불 이력 없으면 null)" + }, + "refundable": { + "type": "boolean", + "description": "즉시 환불 버튼 활성화 여부 — 미열람 + 구매 후 7일 이내(KST 날짜 기준)" + }, + "refund_deadline": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "즉시 환불 마감 시각" + }, + "manual_refund_available": { + "type": "boolean", + "description": "환불 신청 버튼 활성화 여부 — 열람함 + 구매 후 3개월 이내" }, "title": { "type": "string" @@ -4858,7 +4883,8 @@ "schema": { "type": "object", "required": [ - "prompt_id" + "prompt_id", + "refund_policy_agreed" ], "properties": { "prompt_id": { @@ -4873,6 +4899,11 @@ ], "default": "card", "description": "결제 수단 (card=카드, transfer=계좌이체)" + }, + "refund_policy_agreed": { + "type": "boolean", + "example": true, + "description": "환불정책 동의 체크박스. 결제 화면에 아래 문구와 함께 노출해야 하며,\ntrue가 아니면 400 `RefundPolicyNotAgreed`로 주문서가 생성되지 않습니다.\n\n\"디지털콘텐츠 특성상 열람(제공 개시) 후에는 단순 변심 환불이 불가합니다\"\n" } } } @@ -5130,10 +5161,297 @@ } } }, + "/api/admin/refunds/pending": { + "get": { + "summary": "검토 대기 환불 신청 목록", + "description": "status가 REQUESTED인 환불 신청만 조회합니다.", + "tags": [ + "AdminRefund" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "in": "query", + "name": "size", + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "조회 성공" + }, + "401": { + "description": "로그인 필요" + }, + "403": { + "description": "관리자 권한 필요" + } + } + } + }, + "/api/admin/refunds": { + "get": { + "summary": "환불 전체 이력 조회", + "description": "status로 필터링합니다. `APPROVED`는 담당자 승인 후 PG 결제 취소가 실패해\n수동 송금 처리가 필요한 건입니다.\n", + "tags": [ + "AdminRefund" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "REQUESTED", + "APPROVED", + "REJECTED", + "COMPLETED" + ] + } + }, + { + "in": "query", + "name": "page", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "in": "query", + "name": "size", + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "조회 성공" + }, + "401": { + "description": "로그인 필요" + }, + "403": { + "description": "관리자 권한 필요" + } + } + } + }, + "/api/admin/refunds/{refundId}": { + "get": { + "summary": "환불 신청 상세 조회", + "description": "담당자가 부실 여부를 판단할 수 있도록 프롬프트 본문(`prompt_detail.prompt`)과\n상세페이지 설명(`prompt_detail.description`), 지원 모델 목록을 함께 반환합니다.\n", + "tags": [ + "AdminRefund" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "refundId", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "조회 성공" + }, + "404": { + "description": "환불 건 없음" + } + } + } + }, + "/api/admin/refunds/{refundId}/approve": { + "patch": { + "summary": "환불 신청 승인", + "description": "상태를 APPROVED로 확정한 뒤 Payple 결제 취소를 호출합니다.\n- 취소 성공 → `status: COMPLETED`\n- 취소 실패(카드사 취소 가능 기간 초과 등) → `status: APPROVED` 유지 +\n `payple_cancel_failed: true`. 승인을 되돌리지 않으므로 계좌 송금 등으로\n 수동 처리 후 `/complete-manual`을 호출해야 합니다.\n", + "tags": [ + "AdminRefund" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "refundId", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "처리 완료 (payple_cancel_failed 확인 필요)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "refund_id": { + "type": "integer" + }, + "status": { + "type": "string", + "enum": [ + "COMPLETED", + "APPROVED" + ] + }, + "payple_cancel_failed": { + "type": "boolean" + }, + "payple_fail_code": { + "type": "string", + "nullable": true + }, + "statusCode": { + "type": "integer", + "example": 200 + } + } + } + } + } + }, + "404": { + "description": "환불 건 없음" + }, + "409": { + "description": "이미 처리된 환불 건" + } + } + } + }, + "/api/admin/refunds/{refundId}/reject": { + "patch": { + "summary": "환불 신청 거절", + "tags": [ + "AdminRefund" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "refundId", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "reason" + ], + "properties": { + "reason": { + "type": "string", + "maxLength": 500, + "description": "거절 사유" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "거절 완료" + }, + "400": { + "description": "거절 사유 누락" + }, + "409": { + "description": "이미 처리된 환불 건" + } + } + } + }, + "/api/admin/refunds/{refundId}/complete-manual": { + "patch": { + "summary": "수동 송금 완료 처리", + "description": "PG 취소가 실패해 APPROVED에서 멈춘 건을 계좌 송금 등으로 처리한 뒤\nCOMPLETED로 전이시킵니다. Payment/Settlement도 이 시점에 Refunded로 전이됩니다.\n", + "tags": [ + "AdminRefund" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "refundId", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "완료 처리됨" + }, + "409": { + "description": "APPROVED 상태가 아님" + } + } + } + }, "/api/prompts/purchases/{purchaseId}/refund-eligibility": { "get": { "summary": "환불 가능 여부 조회", - "description": "구매 건이 환불 가능한지 검증. 환불 가능 조건은 다음을 모두 만족:\n- 본인 구매\n- 유료 구매\n- 결제 상태 Succeed\n- 환불 이력 없음\n- 다운로드 이력 없음 (`Purchase.downloaded_at` 미값)\n- 구매 후 7일(168시간) 이내\n", + "description": "구매 건이 환불 가능한지 검증. 환불 가능 조건은 다음을 모두 만족:\n- 본인 구매\n- 유료 구매\n- 결제 상태 Succeed\n- 환불 이력 없음\n- 다운로드 이력 없음 (`Purchase.downloaded_at` 미값)\n- 구매 후 7일 이내 — **KST 날짜 기준, 첫날 제외.** 7/23 구매 시 7/30 23:59:59까지\n\n이미 열람한 건은 `manual_refund_available`로 수동 환불 신청 가능 여부를 확인하세요.\n", "tags": [ "Refund" ], @@ -5170,8 +5488,12 @@ "type": "string", "enum": [ "EXPIRED_7DAYS", + "EXPIRED_3MONTHS", "ALREADY_DOWNLOADED", + "NOT_DOWNLOADED", "ALREADY_REFUNDED", + "REFUND_IN_REVIEW", + "REFUND_REJECTED", "NOT_OWNER", "NOT_PURCHASED", "PAYMENT_NOT_SUCCEEDED", @@ -5183,6 +5505,21 @@ "type": "integer", "description": "eligible=true일 때 환불 가능 잔여 시간(초)" }, + "refund_deadline": { + "type": "string", + "format": "date-time", + "nullable": true, + "description": "자동 환불 마감 시각 (KST 기준 D+7 종료)" + }, + "manual_refund_available": { + "type": "boolean", + "description": "열람 후 수동 환불 신청 가능 여부 (구매 후 3개월 이내)" + }, + "manual_refund_deadline": { + "type": "string", + "format": "date-time", + "nullable": true + }, "statusCode": { "type": "integer", "example": 200 @@ -5269,6 +5606,92 @@ } } }, + "/api/prompts/purchases/{purchaseId}/refund-request": { + "post": { + "summary": "수동 환불 신청 (열람 후)", + "description": "이미 열람한 프롬프트의 환불을 신청합니다. 단순 변심은 불가하며,\n담당자가 아래 사유에 해당하는지 확인 후 승인/거절합니다.\n- 본문이 비어 있거나 의미 있는 지시문이라 볼 수 없는 경우\n- 본문 분량·구성이 상세페이지 안내 수준에 현저히 미달하는 경우\n- 명시된 AI 모델에서 실행해도 상세페이지 예시와 같은 범주의 결과물을 얻을 수 없는 경우\n- 작성자가 직접 작성하지 않고 외부에서 가져온 경우\n\n신청 가능 조건: 열람함(`downloaded_at` 있음) + 구매 후 3개월 이내 + 기존 환불 이력 없음.\n아직 열람하지 않았고 7일이 지나지 않았다면 검토 없이 즉시 환불되므로\n`POST /refund`를 사용해야 하며, 이 경우 400 `UseAutoRefund`를 반환합니다.\n", + "tags": [ + "Refund" + ], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "purchaseId", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "reason" + ], + "properties": { + "reason": { + "type": "string", + "minLength": 10, + "maxLength": 500, + "description": "환불 신청 사유" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "신청 접수 완료", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "refund_id": { + "type": "integer" + }, + "status": { + "type": "string", + "example": "REQUESTED" + }, + "requested_at": { + "type": "string", + "format": "date-time" + }, + "statusCode": { + "type": "integer", + "example": 200 + } + } + } + } + } + }, + "400": { + "description": "신청 불가 (RefundNotEligible / UseAutoRefund / ValidationError)" + }, + "401": { + "description": "로그인 필요" + }, + "404": { + "description": "환불 대상 결제 정보를 찾을 수 없음" + } + } + } + }, "/api/reports": { "post": { "summary": "프롬프트 신고 등록", @@ -9096,6 +9519,10 @@ "name": "Purchase", "description": "결제/구매 관련 API" }, + { + "name": "AdminRefund", + "description": "관리자 - 환불 신청 검토 (열람 후 환불, 최장 3개월)" + }, { "name": "Refund", "description": "구매 환불 (7일 이내 미열람 자동 환불)" From 9535e81d75d88591f87b36f5e433c82135c4702b Mon Sep 17 00:00:00 2001 From: minij02 Date: Wed, 29 Jul 2026 23:39:34 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EC=9B=B9=ED=9B=85=20?= =?UTF-8?q?=EB=8F=99=EC=9D=98=20=EA=B8=B0=EB=A1=9D=20=EB=88=84=EB=9D=BD,?= =?UTF-8?q?=20=EC=8A=B9=EC=9D=B8=20=EA=B2=BD=ED=95=A9,=20=EC=9D=B4?= =?UTF-8?q?=EC=A4=91=20=ED=99=98=EB=B6=88=20=EC=9C=A0=EB=B0=9C=20catch=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20(#533)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/prompt.download.service.ts | 8 +- .../services/purchase.complete.service.ts | 9 +- .../services/purchase.webhook.service.ts | 5 +- src/purchases/utils/payple.ts | 9 ++ src/refunds/routes/admin-refund.route.ts | 4 + src/refunds/services/admin-refund.service.ts | 124 +++++++++++------- src/refunds/services/refund.service.ts | 24 +--- src/refunds/utils/refund-policy.check.ts | 35 +++++ src/refunds/utils/refund-policy.ts | 20 +++ swagger.json | 3 + 10 files changed, 157 insertions(+), 84 deletions(-) diff --git a/src/prompts/services/prompt.download.service.ts b/src/prompts/services/prompt.download.service.ts index b74b10f..3efcb08 100644 --- a/src/prompts/services/prompt.download.service.ts +++ b/src/prompts/services/prompt.download.service.ts @@ -2,8 +2,12 @@ import { PromptDownloadRepository } from '../repositories/prompt.download.reposi import { PromptDownloadResponseDTO, DownloadedPromptResponseDTO } from '../dtos/prompt.download.dto'; import { AppError } from '../../errors/AppError'; import prisma from "../../config/prisma"; -import { checkAutoRefund, checkManualRefund, isRefundSettled } from '../../refunds/utils/refund-policy'; -import { toPolicyInput } from '../../refunds/services/refund.service'; +import { + checkAutoRefund, + checkManualRefund, + isRefundSettled, + toPolicyInput, +} from '../../refunds/utils/refund-policy'; export const PromptDownloadService = { async getPromptContent(userId: number, promptId: number): Promise { diff --git a/src/purchases/services/purchase.complete.service.ts b/src/purchases/services/purchase.complete.service.ts index 54ffcf0..594eb4d 100644 --- a/src/purchases/services/purchase.complete.service.ts +++ b/src/purchases/services/purchase.complete.service.ts @@ -3,16 +3,9 @@ import { PurchaseRequestRepository } from '../repositories/purchase.request.repo import { PurchaseCompleteRepository } from '../repositories/purchase.complete.repository'; import { AppError } from '../../errors/AppError'; import prisma from '../../config/prisma'; -import { verifyPayplePayment } from '../utils/payple'; +import { parseAgreedAt, verifyPayplePayment } from '../utils/payple'; import { calculateSettlementFee } from '../utils/fee'; -// 주문서에 실은 동의 시각. 손상된 값이면 기록을 생략하고 결제는 그대로 진행한다 (#533) -const parseAgreedAt = (raw?: string): Date | null => { - if (!raw) return null; - const parsed = new Date(raw); - return Number.isNaN(parsed.getTime()) ? null : parsed; -}; - export const PurchaseCompleteService = { async completePurchase(userId: number, dto: PurchaseCompleteRequestDTO): Promise { const verifiedPayment = await verifyPayplePayment(dto, { amount: -1 }); diff --git a/src/purchases/services/purchase.webhook.service.ts b/src/purchases/services/purchase.webhook.service.ts index dffaeb7..49549f0 100644 --- a/src/purchases/services/purchase.webhook.service.ts +++ b/src/purchases/services/purchase.webhook.service.ts @@ -1,7 +1,7 @@ import { PurchaseRequestRepository } from '../repositories/purchase.request.repository'; import { PurchaseCompleteRepository } from '../repositories/purchase.complete.repository'; import prisma from '../../config/prisma'; -import { PayplePaymentResult, verifyPayplePayment } from '../utils/payple'; +import { PayplePaymentResult, parseAgreedAt, verifyPayplePayment } from '../utils/payple'; import { calculateSettlementFee } from '../utils/fee'; export const WebhookService = { @@ -44,6 +44,9 @@ export const WebhookService = { prompt_id: prompt.prompt_id, amount: serverPrice, is_free: false, + // 웹훅이 /complete보다 먼저 도착하면 여기서 Purchase가 만들어지므로 + // 동의 시각도 같이 기록해야 유실되지 않는다 (#533) + refund_policy_agreed_at: parseAgreedAt(verified.customData?.agreed_at), }); const payment = await PurchaseCompleteRepository.createPaymentTx(tx, { diff --git a/src/purchases/utils/payple.ts b/src/purchases/utils/payple.ts index fdaca8a..c66a130 100644 --- a/src/purchases/utils/payple.ts +++ b/src/purchases/utils/payple.ts @@ -126,6 +126,15 @@ function parseCustomDefine(define?: string): any { } } +// 주문서(PCD_USER_DEFINE1)에 실어 보낸 환불정책 동의 시각. +// 결제 완료 경로가 클라이언트 /complete와 웹훅 두 갈래라 양쪽이 같은 파서를 쓴다. (#533) +// 값이 손상됐으면 기록만 생략하고 결제 자체는 진행한다. +export function parseAgreedAt(raw?: string): Date | null { + if (!raw) return null; + const parsed = new Date(raw); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + function parsePaypleTime(t?: string): Date { if (!t) return new Date(); const m = t.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/); diff --git a/src/refunds/routes/admin-refund.route.ts b/src/refunds/routes/admin-refund.route.ts index 60b9409..29e874b 100644 --- a/src/refunds/routes/admin-refund.route.ts +++ b/src/refunds/routes/admin-refund.route.ts @@ -130,6 +130,10 @@ router.get('/:refundId', authenticateJwt, isAdmin, getRefundDetailHandler); * statusCode: { type: integer, example: 200 } * 409: { description: 이미 처리된 환불 건 } * 404: { description: 환불 건 없음 } + * 500: + * description: | + * `RefundStateSyncFailed` — PG 취소는 성공했으나 상태 반영에 실패. + * 돈은 이미 나갔으므로 재송금 금지, `/complete-manual`로 상태만 맞춰야 합니다. */ router.patch('/:refundId/approve', authenticateJwt, isAdmin, approveRefundHandler); diff --git a/src/refunds/services/admin-refund.service.ts b/src/refunds/services/admin-refund.service.ts index 4225e4a..a0ad919 100644 --- a/src/refunds/services/admin-refund.service.ts +++ b/src/refunds/services/admin-refund.service.ts @@ -150,49 +150,33 @@ export const approveRefund = async ( }); if (!refund) throw new AppError('환불 건을 찾을 수 없습니다.', 404, 'NotFound'); - if (refund.status !== 'REQUESTED') { - throw new AppError( - `이미 처리된 환불 건입니다. (현재 상태: ${refund.status})`, - 409, - 'RefundAlreadyReviewed', - ); - } if (!refund.payment) { throw new AppError('환불 대상 결제 정보를 찾을 수 없습니다.', 404, 'NotFound'); } - await prisma.refund.update({ - where: { refund_id: refundId }, + // 상태 검사와 전이를 한 번의 조건부 UPDATE로 묶는다. + // 따로 하면 동시 승인 시 둘 다 검사를 통과해 Payple 취소가 두 번 나간다. + const claimed = await prisma.refund.updateMany({ + where: { refund_id: refundId, status: 'REQUESTED' }, data: { status: 'APPROVED', reviewed_by: adminId, reviewed_at: new Date() }, }); + if (claimed.count === 0) { + throw new AppError( + `이미 처리된 환불 건입니다. (현재 상태: ${refund.status})`, + 409, + 'RefundAlreadyReviewed', + ); + } + // Payple 호출만 감싼다. DB 반영 실패까지 여기서 잡으면 "이미 취소된 건"이 + // 취소 실패로 보고돼 담당자가 수동 송금 → 이중 환불이 된다. + let result; try { - const result = await requestPaypleRefund({ + result = await requestPaypleRefund({ payOid: refund.payment.pcd_pay_oid, payDate: formatYyyymmdd(refund.payment.created_at), refundTotal: refund.amount, }); - - await prisma.$transaction(async (tx) => { - await tx.refund.update({ - where: { refund_id: refundId }, - data: { - status: 'COMPLETED', - refunded_at: new Date(), - payple_pay_code: result.payCode, - payple_card_trade_num: result.cardTradeNum ?? null, - payple_fail_code: null, - }, - }); - await markPaymentRefunded(tx, refund.payment_id); - }); - - return { - message: '환불이 승인되어 결제 취소까지 완료되었습니다.', - refund_id: refundId, - status: 'COMPLETED', - statusCode: 200, - }; } catch (err: any) { const failCode = (err?.paypleCode as string | undefined) ?? 'UNKNOWN'; console.error('[admin-refund] payple cancel failed after approval', { @@ -214,6 +198,42 @@ export const approveRefund = async ( statusCode: 200, }; } + + // 여기부터는 PG에서 실제로 환불이 나간 상태다. + try { + await prisma.$transaction(async (tx) => { + await tx.refund.update({ + where: { refund_id: refundId }, + data: { + status: 'COMPLETED', + refunded_at: new Date(), + payple_pay_code: result.payCode, + payple_card_trade_num: result.cardTradeNum ?? null, + payple_fail_code: null, + }, + }); + await markPaymentRefunded(tx, refund.payment_id); + }); + } catch (dbErr) { + // 돈은 이미 돌아갔으므로 재송금은 절대 금물. 상태만 수동으로 맞춰야 한다. + console.error('[admin-refund] PG 취소 성공 후 DB 반영 실패 — 수동 완료 처리 필요', { + refundId, + payCode: result.payCode, + error: dbErr, + }); + throw new AppError( + 'PG 결제 취소는 완료됐으나 상태 반영에 실패했습니다. 중복 송금하지 말고 수동 완료 처리해주세요.', + 500, + 'RefundStateSyncFailed', + ); + } + + return { + message: '환불이 승인되어 결제 취소까지 완료되었습니다.', + refund_id: refundId, + status: 'COMPLETED', + statusCode: 200, + }; }; export const rejectRefund = async ( @@ -235,16 +255,9 @@ export const rejectRefund = async ( select: { status: true }, }); if (!refund) throw new AppError('환불 건을 찾을 수 없습니다.', 404, 'NotFound'); - if (refund.status !== 'REQUESTED') { - throw new AppError( - `이미 처리된 환불 건입니다. (현재 상태: ${refund.status})`, - 409, - 'RefundAlreadyReviewed', - ); - } - await prisma.refund.update({ - where: { refund_id: refundId }, + const claimed = await prisma.refund.updateMany({ + where: { refund_id: refundId, status: 'REQUESTED' }, data: { status: 'REJECTED', reject_reason: trimmed, @@ -252,6 +265,13 @@ export const rejectRefund = async ( reviewed_at: new Date(), }, }); + if (claimed.count === 0) { + throw new AppError( + `이미 처리된 환불 건입니다. (현재 상태: ${refund.status})`, + 409, + 'RefundAlreadyReviewed', + ); + } return { message: '환불 신청을 거절했습니다.', @@ -271,19 +291,23 @@ export const completeManualRefund = async ( select: { status: true, payment_id: true }, }); if (!refund) throw new AppError('환불 건을 찾을 수 없습니다.', 404, 'NotFound'); - if (refund.status !== 'APPROVED') { - throw new AppError( - `수동 완료 처리는 승인(APPROVED) 상태에서만 가능합니다. (현재 상태: ${refund.status})`, - 409, - 'RefundNotApproved', - ); - } + + // PG 밖에서 돈이 오간 건이므로 누가 완료 처리했는지 로그로 남긴다. + console.log('[admin-refund] manual completion', { refundId, adminId }); await prisma.$transaction(async (tx) => { - await tx.refund.update({ - where: { refund_id: refundId }, - data: { status: 'COMPLETED', refunded_at: new Date(), reviewed_by: adminId }, + // reviewed_by는 승인한 담당자를 가리키므로 완료 처리로 덮어쓰지 않는다. + const claimed = await tx.refund.updateMany({ + where: { refund_id: refundId, status: 'APPROVED' }, + data: { status: 'COMPLETED', refunded_at: new Date() }, }); + if (claimed.count === 0) { + throw new AppError( + `수동 완료 처리는 승인(APPROVED) 상태에서만 가능합니다. (현재 상태: ${refund.status})`, + 409, + 'RefundNotApproved', + ); + } await markPaymentRefunded(tx, refund.payment_id); }); diff --git a/src/refunds/services/refund.service.ts b/src/refunds/services/refund.service.ts index 79385a5..138ebd0 100644 --- a/src/refunds/services/refund.service.ts +++ b/src/refunds/services/refund.service.ts @@ -7,11 +7,7 @@ import { RefundRequestResultDto, } from '../dtos/refund.dto'; import { requestPaypleRefund } from '../../settlements/utils/payple-refund'; -import { - checkAutoRefund, - checkManualRefund, - RefundPolicyInput, -} from '../utils/refund-policy'; +import { checkAutoRefund, checkManualRefund, toPolicyInput } from '../utils/refund-policy'; // 정책 판정에 필요한 필드 — 목록 API도 동일한 필드를 읽도록 여기서 공개한다. (#533) export const PURCHASE_POLICY_SELECT = { @@ -27,24 +23,6 @@ export const PURCHASE_POLICY_SELECT = { refund: { select: { refund_id: true, status: true } }, } as const; -interface PurchaseWithPolicyFields { - user_id: number; - created_at: Date; - downloaded_at: Date | null; - is_free: boolean; - payment?: { status: string } | null; - refund?: { status: string } | null; -} - -export const toPolicyInput = (purchase: PurchaseWithPolicyFields): RefundPolicyInput => ({ - purchase_user_id: purchase.user_id, - created_at: purchase.created_at, - downloaded_at: purchase.downloaded_at, - is_free: purchase.is_free, - payment_status: purchase.payment?.status, - refund_status: (purchase.refund?.status as RefundPolicyInput['refund_status']) ?? null, -}); - const loadPurchase = async (purchaseId: number) => prisma.purchase.findUnique({ where: { purchase_id: purchaseId }, diff --git a/src/refunds/utils/refund-policy.check.ts b/src/refunds/utils/refund-policy.check.ts index 926a058..e8a8c06 100644 --- a/src/refunds/utils/refund-policy.check.ts +++ b/src/refunds/utils/refund-policy.check.ts @@ -6,6 +6,8 @@ import { checkManualRefund, getAutoRefundDeadline, getManualRefundDeadline, + isRefundSettled, + toPolicyInput, RefundPolicyInput, } from './refund-policy'; @@ -104,4 +106,37 @@ const paid = (createdAt: Date, downloadedAt: Date | null = null): RefundPolicyIn assert.strictEqual(checkAutoRefund({ ...base, is_free: true }, 999, now).reason, 'NOT_OWNER'); } +// --- 환불 확정 판정: 재다운로드 차단 / is_refunded 표시의 기준 --- +{ + // 검토 중(REQUESTED)이나 거절(REJECTED)은 아직 환불이 아니므로 열람을 막으면 안 된다. + assert.strictEqual(isRefundSettled('REQUESTED'), false); + assert.strictEqual(isRefundSettled('REJECTED'), false); + assert.strictEqual(isRefundSettled('APPROVED'), true); + assert.strictEqual(isRefundSettled('COMPLETED'), true); + assert.strictEqual(isRefundSettled(null), false); + assert.strictEqual(isRefundSettled(undefined), false); +} + +// --- Prisma 행 → 판정 입력 변환 --- +{ + const row = { + user_id: 7, + created_at: kst('2026-07-23T15:00:00'), + downloaded_at: null, + is_free: false, + payment: { status: 'Succeed' }, + refund: null, + }; + assert.strictEqual(checkAutoRefund(toPolicyInput(row), 7, kst('2026-07-24T00:00:00')).eligible, true); + // 결제가 성공하지 않은 행은 목록에서도 환불 버튼이 켜지면 안 된다. + assert.strictEqual( + checkAutoRefund( + toPolicyInput({ ...row, payment: { status: 'Pending' } }), + 7, + kst('2026-07-24T00:00:00'), + ).eligible, + false, + ); +} + console.log('refund-policy: 모든 경계 검증 통과'); diff --git a/src/refunds/utils/refund-policy.ts b/src/refunds/utils/refund-policy.ts index 549a51e..06a32b8 100644 --- a/src/refunds/utils/refund-policy.ts +++ b/src/refunds/utils/refund-policy.ts @@ -72,6 +72,26 @@ export interface RefundPolicyInput { refund_status: RefundStatusValue | null; } +// Prisma로 조회한 Purchase 행을 판정 입력으로 옮긴다. +// 정책을 쓰는 쪽(환불 API / 다운로드 목록)이 모두 같은 형태로 넘기도록 여기에 둔다. +export interface PurchaseWithPolicyFields { + user_id: number; + created_at: Date; + downloaded_at: Date | null; + is_free: boolean; + payment?: { status: string } | null; + refund?: { status: string } | null; +} + +export const toPolicyInput = (purchase: PurchaseWithPolicyFields): RefundPolicyInput => ({ + purchase_user_id: purchase.user_id, + created_at: purchase.created_at, + downloaded_at: purchase.downloaded_at, + is_free: purchase.is_free, + payment_status: purchase.payment?.status, + refund_status: (purchase.refund?.status as RefundStatusValue) ?? null, +}); + export interface RefundVerdict { eligible: boolean; reason?: RefundIneligibleReason; diff --git a/swagger.json b/swagger.json index b1e0634..4e1ce19 100644 --- a/swagger.json +++ b/swagger.json @@ -5358,6 +5358,9 @@ }, "409": { "description": "이미 처리된 환불 건" + }, + "500": { + "description": "`RefundStateSyncFailed` — PG 취소는 성공했으나 상태 반영에 실패.\n돈은 이미 나갔으므로 재송금 금지, `/complete-manual`로 상태만 맞춰야 합니다.\n" } } }