diff --git a/roome/src/main/java/com/roome/domain/payment/service/TossPaymentClient.java b/roome/src/main/java/com/roome/domain/payment/service/TossPaymentClient.java index 97fa9d4c..cc8f4245 100644 --- a/roome/src/main/java/com/roome/domain/payment/service/TossPaymentClient.java +++ b/roome/src/main/java/com/roome/domain/payment/service/TossPaymentClient.java @@ -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; @@ -61,18 +62,45 @@ public ResponseEntity requestConfirm(PaymentVerifyDto verifyDto) { HttpEntity> requestEntity = new HttpEntity<>(requestBody, headers); - // ✅ RestTemplate을 사용하여 POST 요청 수행 - ResponseEntity response = restTemplate.exchange( - requestUrl, HttpMethod.POST, requestEntity, String.class - ); - - log.info("Toss API 응답 - Status: {}, Headers: {}, Body: {}", - response.getStatusCode(), response.getHeaders(), response.getBody()); - + try { + ResponseEntity 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); } diff --git a/roome/src/main/java/com/roome/global/exception/ErrorCode.java b/roome/src/main/java/com/roome/global/exception/ErrorCode.java index f9e7869b..c087d1d3 100644 --- a/roome/src/main/java/com/roome/global/exception/ErrorCode.java +++ b/roome/src/main/java/com/roome/global/exception/ErrorCode.java @@ -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, "이 결제는 취소할 수 없습니다."), diff --git a/roome/src/test/java/com/roome/domain/payment/service/TossPaymentClientTest.java b/roome/src/test/java/com/roome/domain/payment/service/TossPaymentClientTest.java index 2e920f83..8fa78891 100644 --- a/roome/src/test/java/com/roome/domain/payment/service/TossPaymentClientTest.java +++ b/roome/src/test/java/com/roome/domain/payment/service/TossPaymentClientTest.java @@ -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; @@ -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 {