Skip to content

Commit a3a4e08

Browse files
authored
Merge pull request #487 from PromptPlace/develop
Develop
2 parents 0be8ba7 + f9d8332 commit a3a4e08

11 files changed

Lines changed: 892 additions & 181 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
-- AlterTable: Purchase 다운로드 시점 컬럼
2+
ALTER TABLE `Purchase` ADD COLUMN `downloaded_at` DATETIME(3) NULL;
3+
4+
-- AlterTable: Status enum에 Refunded 추가
5+
ALTER TABLE `Payment` MODIFY COLUMN `status` ENUM('Pending','Succeed','Failed','Refunded') NOT NULL;
6+
ALTER TABLE `Settlement` MODIFY COLUMN `status` ENUM('Pending','Succeed','Failed','Refunded') NOT NULL;
7+
8+
-- CreateTable: Refund
9+
CREATE TABLE `Refund` (
10+
`refund_id` INTEGER NOT NULL AUTO_INCREMENT,
11+
`purchase_id` INTEGER NOT NULL,
12+
`payment_id` INTEGER NOT NULL,
13+
`user_id` INTEGER NOT NULL,
14+
`amount` INTEGER NOT NULL,
15+
`reason` VARCHAR(200) NULL,
16+
`initiator` VARCHAR(20) NOT NULL,
17+
`payple_pay_code` VARCHAR(20) NULL,
18+
`payple_card_trade_num` VARCHAR(64) NULL,
19+
`refunded_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
20+
21+
UNIQUE INDEX `Refund_purchase_id_key`(`purchase_id`),
22+
UNIQUE INDEX `Refund_payment_id_key`(`payment_id`),
23+
INDEX `Refund_user_id_idx`(`user_id`),
24+
PRIMARY KEY (`refund_id`)
25+
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
26+
27+
-- AddForeignKey
28+
ALTER TABLE `Refund` ADD CONSTRAINT `Refund_purchase_id_fkey`
29+
FOREIGN KEY (`purchase_id`) REFERENCES `Purchase`(`purchase_id`) ON DELETE CASCADE ON UPDATE CASCADE;
30+
ALTER TABLE `Refund` ADD CONSTRAINT `Refund_payment_id_fkey`
31+
FOREIGN KEY (`payment_id`) REFERENCES `Payment`(`payment_id`) ON DELETE CASCADE ON UPDATE CASCADE;
32+
ALTER TABLE `Refund` ADD CONSTRAINT `Refund_user_id_fkey`
33+
FOREIGN KEY (`user_id`) REFERENCES `User`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE;

prisma/schema.prisma

Lines changed: 202 additions & 180 deletions
Large diffs are not rendered by default.

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import membersRouter from "./members/routes/member.route"; // members 라우터
1616
import promptRoutes from "./prompts/routes/prompt.route"; // 프롬프트 관련 라우터
1717
import ReviewRouter from "./reviews/routes/review.route";
1818
import purchaseRouter from "./purchases/routes/purchase.route";
19+
import refundRouter from "./refunds/routes/refund.route";
1920
import purchaseWebhookRouter from "./purchases/routes/purchase.webhook.route";
2021
import settlementRouter from "./settlements/routes/settlement.route";
2122
import withdrawalRouter from "./withdrawals/routes/withdrawal.route";
@@ -138,6 +139,7 @@ app.use("/api/prompts", promptRoutes);
138139
app.use("/api/prompts/purchases", purchaseWebhookRouter);
139140

140141
// 프롬프트 결제 라우터
142+
app.use("/api/prompts/purchases", refundRouter);
141143
app.use(
142144
"/api/prompts/purchases",
143145
express.text({ type: "text/plain" }),

src/prompts/services/prompt.download.service.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,35 @@ async getPromptContent(userId: number, promptId: number): Promise<PromptDownload
1414
let isPaid = false;
1515

1616
if (!prompt.is_free) {
17-
// 유료 프롬프트일 경우 결제 상태 확인
17+
// 유료 프롬프트일 경우 결제 상태 + 환불 여부 확인
1818
const purchase = await prisma.purchase.findFirst({
1919
where: {
2020
user_id: userId,
2121
prompt_id: promptId,
2222
},
2323
include: {
2424
payment: true,
25+
refund: { select: { refund_id: true } },
2526
},
2627
});
2728

29+
if (purchase?.refund) {
30+
throw new AppError('환불된 프롬프트는 다시 다운로드할 수 없습니다.', 403, 'Refunded');
31+
}
32+
2833
isPaid = purchase?.payment?.status === 'Succeed';
2934

3035
if (!isPaid) {
3136
throw new AppError('해당 프롬프트는 무료가 아니며, 결제가 완료되지 않았습니다.', 403, 'Forbidden');
3237
}
38+
39+
// 첫 다운로드 시점 기록 — 이후 환불 불가 (#485)
40+
if (purchase && !purchase.downloaded_at) {
41+
await prisma.purchase.update({
42+
where: { purchase_id: purchase.purchase_id },
43+
data: { downloaded_at: new Date() },
44+
});
45+
}
3346
} else {
3447
// 무료 프롬프트일 경우 purchase 기록이 없다면 추가
3548
const existingPurchase = await prisma.purchase.findFirst({
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { Request, Response } from 'express';
2+
import { getRefundEligibility, refundPurchase } from '../services/refund.service';
3+
4+
const getUserId = (req: Request): number | null => {
5+
if (!req.user) return null;
6+
return (req.user as { user_id: number }).user_id;
7+
};
8+
9+
const parsePurchaseId = (raw: string): number | null => {
10+
const n = Number(raw);
11+
if (!Number.isInteger(n) || n <= 0) return null;
12+
return n;
13+
};
14+
15+
export const checkRefundEligibility = async (req: Request, res: Response) => {
16+
const userId = getUserId(req);
17+
if (!userId) {
18+
return res.status(401).json({ error: 'Unauthorized', message: '로그인이 필요합니다.', statusCode: 401 });
19+
}
20+
const purchaseId = parsePurchaseId(req.params.purchaseId);
21+
if (!purchaseId) {
22+
return res.status(400).json({ error: 'ValidationError', message: 'purchaseId가 올바르지 않습니다.', statusCode: 400 });
23+
}
24+
try {
25+
const result = await getRefundEligibility(userId, purchaseId);
26+
return res.status(200).json(result);
27+
} catch (error: any) {
28+
const status = error.statusCode || 500;
29+
return res.status(status).json({
30+
error: error.error || 'InternalServerError',
31+
message: error.message || '서버 오류가 발생했습니다.',
32+
statusCode: status,
33+
});
34+
}
35+
};
36+
37+
export const refundPurchaseHandler = async (req: Request, res: Response) => {
38+
const userId = getUserId(req);
39+
if (!userId) {
40+
return res.status(401).json({ error: 'Unauthorized', message: '로그인이 필요합니다.', statusCode: 401 });
41+
}
42+
const purchaseId = parsePurchaseId(req.params.purchaseId);
43+
if (!purchaseId) {
44+
return res.status(400).json({ error: 'ValidationError', message: 'purchaseId가 올바르지 않습니다.', statusCode: 400 });
45+
}
46+
try {
47+
const result = await refundPurchase(userId, purchaseId);
48+
return res.status(200).json(result);
49+
} catch (error: any) {
50+
const status = error.statusCode || 500;
51+
return res.status(status).json({
52+
error: error.error || 'InternalServerError',
53+
message: error.message || '서버 오류가 발생했습니다.',
54+
statusCode: status,
55+
});
56+
}
57+
};

src/refunds/dtos/refund.dto.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export type RefundIneligibleReason =
2+
| 'EXPIRED_7DAYS'
3+
| 'ALREADY_DOWNLOADED'
4+
| 'ALREADY_REFUNDED'
5+
| 'NOT_OWNER'
6+
| 'NOT_PURCHASED'
7+
| 'PAYMENT_NOT_SUCCEEDED'
8+
| 'FREE_PURCHASE';
9+
10+
export interface RefundEligibilityResponseDto {
11+
message: string;
12+
eligible: boolean;
13+
reason?: RefundIneligibleReason;
14+
remaining_seconds?: number; // 환불 가능한 잔여 시간 (eligible=true일 때만)
15+
statusCode: number;
16+
}
17+
18+
export interface RefundResultDto {
19+
message: string;
20+
refund_id: number;
21+
refunded_amount: number;
22+
refunded_at: string;
23+
statusCode: number;
24+
}

src/refunds/routes/refund.route.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { Router } from 'express';
2+
import { authenticateJwt } from '../../config/passport';
3+
import {
4+
checkRefundEligibility,
5+
refundPurchaseHandler,
6+
} from '../controllers/refund.controller';
7+
8+
const router = Router();
9+
10+
/**
11+
* @swagger
12+
* tags:
13+
* - name: Refund
14+
* description: 구매 환불 (7일 이내 미열람 자동 환불)
15+
*/
16+
17+
/**
18+
* @swagger
19+
* /api/prompts/purchases/{purchaseId}/refund-eligibility:
20+
* get:
21+
* summary: 환불 가능 여부 조회
22+
* description: |
23+
* 구매 건이 환불 가능한지 검증. 환불 가능 조건은 다음을 모두 만족:
24+
* - 본인 구매
25+
* - 유료 구매
26+
* - 결제 상태 Succeed
27+
* - 환불 이력 없음
28+
* - 다운로드 이력 없음 (`Purchase.downloaded_at` 미값)
29+
* - 구매 후 7일(168시간) 이내
30+
* tags: [Refund]
31+
* security:
32+
* - jwt: []
33+
* parameters:
34+
* - in: path
35+
* name: purchaseId
36+
* required: true
37+
* schema: { type: integer }
38+
* responses:
39+
* 200:
40+
* description: 조회 성공 (eligible true/false)
41+
* content:
42+
* application/json:
43+
* schema:
44+
* type: object
45+
* properties:
46+
* message: { type: string }
47+
* eligible: { type: boolean }
48+
* reason:
49+
* type: string
50+
* enum: [EXPIRED_7DAYS, ALREADY_DOWNLOADED, ALREADY_REFUNDED, NOT_OWNER, NOT_PURCHASED, PAYMENT_NOT_SUCCEEDED, FREE_PURCHASE]
51+
* description: eligible=false일 때만 존재
52+
* remaining_seconds:
53+
* type: integer
54+
* description: eligible=true일 때 환불 가능 잔여 시간(초)
55+
* statusCode: { type: integer, example: 200 }
56+
* 401:
57+
* description: 로그인 필요
58+
* 400:
59+
* description: 잘못된 purchaseId
60+
*/
61+
router.get('/:purchaseId/refund-eligibility', authenticateJwt, checkRefundEligibility);
62+
63+
/**
64+
* @swagger
65+
* /api/prompts/purchases/{purchaseId}/refund:
66+
* post:
67+
* summary: 환불 실행
68+
* description: |
69+
* 7일 이내 + 미열람 조건을 만족하면 Payple 결제 취소를 호출하고 DB(Refund/Payment/Settlement) 정합화.
70+
* 조건 미충족 시 400 RefundNotEligible.
71+
* tags: [Refund]
72+
* security:
73+
* - jwt: []
74+
* parameters:
75+
* - in: path
76+
* name: purchaseId
77+
* required: true
78+
* schema: { type: integer }
79+
* responses:
80+
* 200:
81+
* description: 환불 성공
82+
* content:
83+
* application/json:
84+
* schema:
85+
* type: object
86+
* properties:
87+
* message: { type: string, example: 환불이 완료되었습니다. }
88+
* refund_id: { type: integer }
89+
* refunded_amount: { type: integer }
90+
* refunded_at: { type: string, format: date-time }
91+
* statusCode: { type: integer, example: 200 }
92+
* 400:
93+
* description: 환불 불가 (RefundNotEligible)
94+
* 401:
95+
* description: 로그인 필요
96+
* 404:
97+
* description: 환불 대상 결제 정보를 찾을 수 없음
98+
* 502:
99+
* description: Payple 환불 호출 실패 (PaypleRefundFailed)
100+
*/
101+
router.post('/:purchaseId/refund', authenticateJwt, refundPurchaseHandler);
102+
103+
export default router;

0 commit comments

Comments
 (0)