Skip to content

Commit c33ade8

Browse files
authored
Fix: 부분 취소는 거부하고, 환불 금액 및 포인트를 결제 기준으로 파생하도록 수정 (#415)
1 parent cef4f72 commit c33ade8

4 files changed

Lines changed: 51 additions & 9 deletions

File tree

roome/src/main/java/com/roome/domain/payment/controller/PaymentController.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ public ResponseEntity<Void> failPayment(
9696
@Operation(summary = "결제 취소 (환불)", description = "결제 취소 요청을 처리하고 환불을 진행한다.")
9797
@ApiResponses(value = {
9898
@ApiResponse(responseCode = "200", description = "결제 취소 성공"),
99-
@ApiResponse(responseCode = "400", description = "잘못된 결제 취소 요청 (INVALID_PAYMENT_CANCEL)"),
99+
@ApiResponse(responseCode = "400", description = "잘못된 결제 취소 요청 / 부분 취소 미지원 (PARTIAL_CANCEL_NOT_SUPPORTED)"),
100+
@ApiResponse(responseCode = "403", description = "본인의 결제가 아님 (PAYMENT_ACCESS_DENIED)"),
100101
@ApiResponse(responseCode = "404", description = "해당 결제 정보를 찾을 수 없음 (PAYMENT_NOT_FOUND)"),
101102
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
102103
})

roome/src/main/java/com/roome/domain/payment/service/PaymentService.java

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -278,11 +278,14 @@ public PaymentResponseDto cancelPayment(Long userId, String paymentKey, String c
278278
throw new BusinessException(ErrorCode.PAYMENT_ALREADY_USED);
279279
}
280280

281-
// 환불 금액이 판매 중인 상품 가격 단위인지 검증하고, 차감 포인트를 카탈로그에서 파생한다.
282-
if (cancelAmount == null) {
283-
throw new BusinessException(ErrorCode.INVALID_REFUND_AMOUNT);
281+
// 부분 취소는 지원하지 않으므로 cancelAmount가 주어졌다면 결제 전액과 일치해야 함
282+
if (cancelAmount != null && cancelAmount != payment.getAmount()) {
283+
throw new BusinessException(ErrorCode.PARTIAL_CANCEL_NOT_SUPPORTED);
284284
}
285-
PointProduct refundProduct = PointProduct.findByPrice(cancelAmount)
285+
286+
// 환불 금액과 차감 포인트는 클라이언트 값이 아니라 이 결제에서 파생
287+
int refundAmount = payment.getAmount();
288+
PointProduct refundProduct = PointProduct.findByPrice(refundAmount)
286289
.orElseThrow(() -> new BusinessException(ErrorCode.INVALID_REFUND_AMOUNT));
287290
int refundPoints = refundProduct.getPoints();
288291

@@ -295,17 +298,17 @@ public PaymentResponseDto cancelPayment(Long userId, String paymentKey, String c
295298
// 결제 상태 업데이트
296299
payment.markCanceled(LocalDateTime.now());
297300

298-
saveRefundLog(payment, cancelAmount, paymentKey);
301+
saveRefundLog(payment, refundAmount, paymentKey);
299302

300-
// Toss API에 결제 취소 요청
303+
// Toss API에 결제 취소 요청 (전액 취소)
301304
boolean isCanceled = tossPaymentClient.cancelPayment(payment.getPaymentKey(), cancelReason,
302-
cancelAmount);
305+
refundAmount);
303306
if (!isCanceled) {
304307
throw new BusinessException(ErrorCode.PAYMENT_CANCEL_FAILED);
305308
}
306309

307310
log.info("결제 취소 완료: paymentKey={}, userId={}, refundPoints={}, refundAmount={}",
308-
paymentKey, userId, refundPoints, cancelAmount);
311+
paymentKey, userId, refundPoints, refundAmount);
309312

310313
return PaymentResponseDto.builder()
311314
.orderId(payment.getOrderId())

roome/src/main/java/com/roome/global/exception/ErrorCode.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ public enum ErrorCode {
7474
PAYMENT_ALREADY_USED(HttpStatus.BAD_REQUEST, "포인트를 이미 사용하여 환불할 수 없습니다."),
7575
PAYMENT_ALREADY_PROCESSED(HttpStatus.BAD_REQUEST, "이미 처리된 결제입니다."),
7676
ORDER_ID_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 사용 중인 주문 ID입니다."),
77+
PARTIAL_CANCEL_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "부분 취소는 지원하지 않습니다. 전액 취소만 가능합니다."),
7778
POINT_PURCHASE_NOT_FOUND(HttpStatus.NOT_FOUND, "포인트 결제 정보를 찾을 수 없습니다."),
7879
// 서평 관련 예외
7980
MY_BOOK_REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "서평을 찾을 수 없습니다."),

roome/src/test/java/com/roome/domain/payment/service/PaymentServiceTest.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,43 @@ void cancelPayment_NoUsage_Success() {
220220
inOrder.verify(tossPaymentClient).cancelPayment("pk123", "단순 변심", 5_000);
221221
}
222222

223+
@Test
224+
@DisplayName("cancelAmount가 결제 전액과 다르면 부분 취소로 간주해 거부되어야 한다.")
225+
void cancelPayment_PartialAmount_Rejected() {
226+
// given: 5,000원 결제에 1,000원만 취소 요청
227+
Payment payment = successPayment("pk123");
228+
when(paymentRepository.findByPaymentKey("pk123")).thenReturn(Optional.of(payment));
229+
when(pointHistoryRepository.hasUsedPointsAfter(eq(1L), eq(payment.getApprovedAt()),
230+
anyList())).thenReturn(false);
231+
232+
// when & then
233+
assertThatThrownBy(() -> paymentService.cancelPayment(1L, "pk123", "단순 변심", 1_000))
234+
.isInstanceOf(BusinessException.class)
235+
.hasMessageContaining(ErrorCode.PARTIAL_CANCEL_NOT_SUPPORTED.getMessage());
236+
237+
verify(pointService, never()).usePoints(any(), any());
238+
verify(tossPaymentClient, never()).cancelPayment(any(), any(), any());
239+
}
240+
241+
@Test
242+
@DisplayName("cancelAmount가 없으면 결제 전액 기준으로 취소되어야 한다.")
243+
void cancelPayment_NullAmount_FullCancel() {
244+
// given
245+
Payment payment = successPayment("pk123");
246+
when(paymentRepository.findByPaymentKey("pk123")).thenReturn(Optional.of(payment));
247+
when(pointHistoryRepository.hasUsedPointsAfter(eq(1L), eq(payment.getApprovedAt()),
248+
anyList())).thenReturn(false);
249+
when(tossPaymentClient.cancelPayment("pk123", "단순 변심", 5_000)).thenReturn(true);
250+
251+
// when: cancelAmount = null
252+
PaymentResponseDto response = paymentService.cancelPayment(1L, "pk123", "단순 변심", null);
253+
254+
// then: 결제 전액(5,000)과 카탈로그 파생 포인트(550)로 취소
255+
assertThat(response.getStatus()).isEqualTo(PaymentStatus.CANCELED);
256+
verify(pointService).usePoints(testUser, PointReason.POINT_REFUND_550);
257+
verify(tossPaymentClient).cancelPayment("pk123", "단순 변심", 5_000);
258+
}
259+
223260
@Test
224261
@DisplayName("환불 기한(7일)이 지난 결제는 최근에 다른 구매가 있어도 환불이 거부되어야 한다.")
225262
void cancelPayment_ApprovedOverSevenDaysAgo_PeriodExceeded() {

0 commit comments

Comments
 (0)