Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestTemplate;

import java.io.IOException;
Expand Down Expand Up @@ -61,18 +62,45 @@ public ResponseEntity<String> requestConfirm(PaymentVerifyDto verifyDto) {

HttpEntity<Map<String, Object>> requestEntity = new HttpEntity<>(requestBody, headers);

// ✅ RestTemplate을 사용하여 POST 요청 수행
ResponseEntity<String> response = restTemplate.exchange(
requestUrl, HttpMethod.POST, requestEntity, String.class
);

log.info("Toss API 응답 - Status: {}, Headers: {}, Body: {}",
response.getStatusCode(), response.getHeaders(), response.getBody());

try {
ResponseEntity<String> response = restTemplate.exchange(
requestUrl, HttpMethod.POST, requestEntity, String.class
);
log.info("Toss 결제 승인 응답 - Status: {}", response.getStatusCode());
return response;
} catch (HttpStatusCodeException e) {
// Toss는 실패 시 응답 body에 {code, message}를 담아 주니까 이걸 파싱해 도메인 에러로 매핑
throw mapConfirmError(e);
}
}

log.info("Toss 결제 승인 응답 - Status: {}, Body: {}", response.getStatusCode(), response.getBody());
// Toss 승인 실패 응답의 error code를 도메인 에러로 매핑
// 알려진 코드만 세분화하고, 나머지는 일반 실패로 두되 실제 code와 message를 로그로 남김
private BusinessException mapConfirmError(HttpStatusCodeException e) {
String code = null;
String message = null;
try {
JsonNode body = objectMapper.readTree(e.getResponseBodyAsString());
code = body.path("code").asText(null);
message = body.path("message").asText(null);
} catch (Exception parseError) {
log.warn("Toss 오류 응답 파싱 실패: body={}", e.getResponseBodyAsString());
}

return response;
log.error("Toss 결제 승인 실패: httpStatus={}, code={}, message={}",
e.getStatusCode(), code, message);

ErrorCode mapped = switch (code == null ? "" : code) {
// 이미 승인 처리된 결제 (재시도/중복 요청)
case "ALREADY_PROCESSED_PAYMENT" -> ErrorCode.PAYMENT_ALREADY_PROCESSED;
// 카드사/결제 수단 거절 (사용자 조치 필요)
case "REJECT_CARD_COMPANY", "REJECT_ACCOUNT_PAYMENT", "INVALID_STOPPED_CARD",
"EXCEED_MAX_DAILY_PAYMENT_COUNT", "NOT_ENOUGH_BALANCE",
"INVALID_CARD_EXPIRATION", "EXCEED_MAX_PAYMENT_AMOUNT" -> ErrorCode.PAYMENT_REJECTED;
// 그 외: 실제 code는 로그로 남기고 일반 실패로 처리
default -> ErrorCode.PAYMENT_VERIFICATION_FAILED;
};
return new BusinessException(mapped);
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public enum ErrorCode {
PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 결제 정보를 찾을 수 없습니다."),
PAYMENT_AMOUNT_MISMATCH(HttpStatus.BAD_REQUEST, "결제 금액이 일치하지 않습니다."),
PAYMENT_VERIFICATION_FAILED(HttpStatus.BAD_REQUEST, "결제 검증에 실패했습니다."),
PAYMENT_REJECTED(HttpStatus.BAD_REQUEST, "카드사/결제 수단에서 결제가 거절되었습니다."),
PAYMENT_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "결제 처리 중 오류가 발생했습니다."),
PAYMENT_ACCESS_DENIED(HttpStatus.FORBIDDEN, "해당 결제에 대한 접근 권한이 없습니다."),
PAYMENT_NOT_CANCELABLE(HttpStatus.BAD_REQUEST, "이 결제는 취소할 수 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package com.roome.domain.payment.service;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.roome.domain.payment.dto.PaymentVerifyDto;
import com.roome.global.exception.BusinessException;
import com.roome.global.exception.ErrorCode;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -34,6 +38,43 @@ void setUp() {
ReflectionTestUtils.setField(tossPaymentClient, "secretKey", testSecretKey);
}

@Test
@DisplayName("Toss 승인 실패 - ALREADY_PROCESSED_PAYMENT는 PAYMENT_ALREADY_PROCESSED로 매핑된다.")
void requestConfirm_AlreadyProcessed_Mapped() throws Exception {
BusinessException thrown = assertConfirmErrorMapped(
"{\"code\":\"ALREADY_PROCESSED_PAYMENT\",\"message\":\"이미 처리된 결제 입니다.\"}");
assertEquals(ErrorCode.PAYMENT_ALREADY_PROCESSED, thrown.getErrorCode());
}

@Test
@DisplayName("Toss 승인 실패 - 카드 거절 코드는 PAYMENT_REJECTED로 매핑된다.")
void requestConfirm_CardRejected_Mapped() throws Exception {
BusinessException thrown = assertConfirmErrorMapped(
"{\"code\":\"REJECT_CARD_COMPANY\",\"message\":\"카드사에서 승인을 거절했습니다.\"}");
assertEquals(ErrorCode.PAYMENT_REJECTED, thrown.getErrorCode());
}

@Test
@DisplayName("Toss 승인 실패 - 알 수 없는 코드는 일반 실패(PAYMENT_VERIFICATION_FAILED)로 매핑된다.")
void requestConfirm_UnknownCode_FallsBack() throws Exception {
BusinessException thrown = assertConfirmErrorMapped(
"{\"code\":\"SOME_NEW_CODE\",\"message\":\"알 수 없는 오류\"}");
assertEquals(ErrorCode.PAYMENT_VERIFICATION_FAILED, thrown.getErrorCode());
}

// Toss가 4xx 오류 body를 반환하는 상황을 구성하고, requestConfirm이 던지는 BusinessException을 돌려준다.
private BusinessException assertConfirmErrorMapped(String errorBody) throws Exception {
HttpClientErrorException httpError = HttpClientErrorException.create(
HttpStatus.BAD_REQUEST, "Bad Request", HttpHeaders.EMPTY,
errorBody.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8);
when(restTemplate.exchange(anyString(), eq(HttpMethod.POST), any(HttpEntity.class), eq(String.class)))
.thenThrow(httpError);
when(objectMapper.readTree(errorBody)).thenReturn(new ObjectMapper().readTree(errorBody));

return assertThrows(BusinessException.class,
() -> tossPaymentClient.requestConfirm(new PaymentVerifyDto("pk123", "order123", 5000)));
}

@Test
@DisplayName("orderId 결제 조회 성공 - 응답 파싱")
void findPaymentByOrderId_Success() throws Exception {
Expand Down
Loading