diff --git a/roome/src/main/java/com/roome/domain/payment/service/PaymentService.java b/roome/src/main/java/com/roome/domain/payment/service/PaymentService.java index bfc0205c..0ae25c0e 100644 --- a/roome/src/main/java/com/roome/domain/payment/service/PaymentService.java +++ b/roome/src/main/java/com/roome/domain/payment/service/PaymentService.java @@ -162,6 +162,16 @@ public PaymentResponseDto verifyPayment(Long userId, PaymentVerifyDto verifyDto) throw new BusinessException(ErrorCode.PAYMENT_VERIFICATION_FAILED); } + + // 승인 결과의 원본인 confirm 응답으로 금액 검증 + // confirm 응답 자체가 승인 사실의 신뢰 원천이므로 재조회는 지연만 늘리고 불일치 위험을 키울 수 있음 + int approvedAmount = jsonResponse.path("totalAmount").asInt(-1); + if (approvedAmount != payment.getAmount()) { + log.error("❌ Step 9: 승인 금액 불일치 - orderId={}, 저장 금액={}, Toss 승인 금액={}", + verifyDto.getOrderId(), payment.getAmount(), approvedAmount); + throw new BusinessException(ErrorCode.PAYMENT_AMOUNT_MISMATCH); + } + // Toss가 확정한 승인 시각을 원장에 기록하기 위해 추출 (없으면 null로 두고 완결 시 서버 시각으로 대체) approvedAt = TossPaymentClient.parseTossDateTime(jsonResponse.path("approvedAt").asText(null)); log.info("✅ Step 10: 결제 승인 성공 및 상태 확인 완료"); @@ -172,22 +182,6 @@ public PaymentResponseDto verifyPayment(Long userId, PaymentVerifyDto verifyDto) throw new BusinessException(ErrorCode.PAYMENT_VERIFICATION_FAILED); } - log.info("✅ Step 12: 결제 상태 업데이트 및 포인트 지급 시작"); - - // 토스 API에서 결제 상태 확인 - log.info("토스 결제 검증 요청: paymentKey={}, orderId={}, amount={}", - verifyDto.getPaymentKey(), verifyDto.getOrderId(), verifyDto.getAmount()); - - boolean isVerified = tossPaymentClient.verifyPayment( - verifyDto.getPaymentKey(), verifyDto.getOrderId(), verifyDto.getAmount() - ); - if (!isVerified) { - log.error("토스 결제 검증 실패: orderId={}, paymentKey={}", verifyDto.getOrderId(), - verifyDto.getPaymentKey()); - throw new BusinessException(ErrorCode.PAYMENT_VERIFICATION_FAILED); - } - - // 결제 완결 처리 (상태 변경 + 포인트 지급 + 로그 저장) completePayment(payment, verifyDto.getPaymentKey(), approvedAt); 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 cc9efaed..97fa9d4c 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 @@ -14,7 +14,6 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestTemplate; -import org.springframework.web.util.UriComponentsBuilder; import java.io.IOException; import java.net.URI; @@ -78,65 +77,6 @@ public ResponseEntity requestConfirm(PaymentVerifyDto verifyDto) { - //토스 페이먼츠 API를 사용하여 결제 검증 - public boolean verifyPayment(String paymentKey, String orderId, int amount) { - String requestUrl = UriComponentsBuilder.fromHttpUrl(TOSS_API_URL + "/" + paymentKey) - .toUriString(); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("Authorization", encodeSecretKey()); - - HttpEntity requestEntity = new HttpEntity<>(headers); - - try { - ResponseEntity response = restTemplate.exchange( - requestUrl, HttpMethod.GET, requestEntity, String.class - ); - - log.info("!!!!!! 토스 응답: status={}, body={}", response.getStatusCode(), response.getBody()); - - - if (response.getStatusCode() == HttpStatus.OK) { - JsonNode jsonResponse = objectMapper.readTree(response.getBody()); - // ✅ status 값 안전하게 가져오기 - String status = jsonResponse.has("status") ? jsonResponse.get("status").asText() : null; - - // ✅ amount 값 안전하게 가져오기 (card.amount or totalAmount 확인) - int responseAmount = 0; - if (jsonResponse.has("totalAmount")) { - responseAmount = jsonResponse.get("totalAmount").asInt(); - } else if (jsonResponse.has("card") && jsonResponse.get("card").has("amount")) { - responseAmount = jsonResponse.get("card").get("amount").asInt(); - } - - // ✅ 값이 올바르게 가져와졌는지 확인 - if (status == null) { - log.error("결제 검증 실패: status 값이 응답에 없음."); - return false; - } - if (responseAmount == 0) { - log.error("결제 검증 실패: amount 값이 응답에 없음."); - return false; - } - - // ✅ 결제 상태와 금액 비교 후 검증 성공 여부 결정 - if ("DONE".equals(status) && responseAmount == amount) { - log.info("결제 검증 성공: paymentKey={}, orderId={}, amount={}", paymentKey, orderId, amount); - return true; - } else { - log.warn("결제 검증 실패: 예상 금액={}, 응답 금액={}, 상태={}", amount, responseAmount, status); - return false; - } - } - } catch (Exception e) { - log.error("결제 검증 중 오류 발생: paymentKey={}, orderId={}", paymentKey, orderId, e); - throw new BusinessException(ErrorCode.PAYMENT_VERIFICATION_FAILED); - } - - return false; - } - // orderId로 Toss 결제 조회 (대사 배치용 — PENDING 결제는 paymentKey가 없어 orderId로 조회) // Toss에 결제 기록이 없으면(결제창까지 도달하지 못한 경우) Optional.empty() 반환 public Optional findPaymentByOrderId(String orderId) { diff --git a/roome/src/test/java/com/roome/domain/payment/service/PaymentServiceTest.java b/roome/src/test/java/com/roome/domain/payment/service/PaymentServiceTest.java index a0f6bf1f..0cf43b53 100644 --- a/roome/src/test/java/com/roome/domain/payment/service/PaymentServiceTest.java +++ b/roome/src/test/java/com/roome/domain/payment/service/PaymentServiceTest.java @@ -30,6 +30,8 @@ import java.time.LocalDateTime; import java.util.Optional; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -161,6 +163,49 @@ void verifyPayment_AlreadySuccess_ReturnsIdempotently() { verify(pointService, never()).earnPoints(any(), any()); } + @Test + @DisplayName("결제 검증은 confirm 응답만으로 완결되어야 한다.") + void verifyPayment_CompletesFromConfirmResponse() { + // given + Payment payment = Payment.builder() + .user(testUser).orderId("order123").amount(5_000).purchasedPoints(550) + .status(PaymentStatus.PENDING).build(); + when(paymentRepository.findByOrderId("order123")).thenReturn(Optional.of(payment)); + String confirmBody = "{\"status\":\"DONE\",\"totalAmount\":5000,\"approvedAt\":\"2026-07-15T10:00:00+09:00\"}"; + when(tossPaymentClient.requestConfirm(any())) + .thenReturn(new ResponseEntity<>(confirmBody, HttpStatus.OK)); + + // when + PaymentResponseDto response = + paymentService.verifyPayment(1L, new PaymentVerifyDto("pk123", "order123", 5_000)); + + // then: confirm 응답만으로 완결 + assertThat(response.getStatus()).isEqualTo(PaymentStatus.SUCCESS); + verify(tossPaymentClient, org.mockito.Mockito.times(1)).requestConfirm(any()); + verify(pointService).earnPoints(payment.getUser(), PointReason.POINT_PURCHASE_550); + } + + @Test + @DisplayName("confirm 응답의 승인 금액이 저장 금액과 다르면 완결하지 않고 예외가 발생해야 한다.") + void verifyPayment_ConfirmAmountMismatch_Rejected() { + // given: Toss가 승인한 금액(9999)이 저장 금액(5000)과 다름 + Payment payment = Payment.builder() + .user(testUser).orderId("order123").amount(5_000).purchasedPoints(550) + .status(PaymentStatus.PENDING).build(); + when(paymentRepository.findByOrderId("order123")).thenReturn(Optional.of(payment)); + String confirmBody = "{\"status\":\"DONE\",\"totalAmount\":9999}"; + when(tossPaymentClient.requestConfirm(any())) + .thenReturn(new ResponseEntity<>(confirmBody, HttpStatus.OK)); + + // when & then + assertThatThrownBy( + () -> paymentService.verifyPayment(1L, new PaymentVerifyDto("pk123", "order123", 5_000))) + .isInstanceOf(BusinessException.class) + .hasMessageContaining(ErrorCode.PAYMENT_AMOUNT_MISMATCH.getMessage()); + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.PENDING); + verify(pointService, never()).earnPoints(any(), any()); + } + @Test @DisplayName("PENDING이 아닌(FAILED/CANCELED) 결제를 검증 요청하면 예외가 발생해야 한다.") void verifyPayment_NotPending_Rejected() { 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 2d2194ec..2e920f83 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,9 +1,6 @@ package com.roome.domain.payment.service; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.roome.global.exception.BusinessException; -import com.roome.global.exception.ErrorCode; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -14,7 +11,6 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.client.RestTemplate; import org.springframework.web.client.HttpClientErrorException; -import org.springframework.web.client.HttpServerErrorException; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -38,116 +34,6 @@ void setUp() { ReflectionTestUtils.setField(tossPaymentClient, "secretKey", testSecretKey); } - @Test - @DisplayName("결제 검증 성공 - 정상 응답") - void verifyPayment_Success() throws Exception { - // given - String paymentKey = "paymentKey123"; - String orderId = "order123"; - int amount = 1000; - String responseBody = """ - { - "status": "DONE", - "totalAmount": 1000 - } - """; - - ResponseEntity mockResponse = new ResponseEntity<>(responseBody, HttpStatus.OK); - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) - .thenReturn(mockResponse); - - JsonNode mockJsonNode = new ObjectMapper().readTree(responseBody); - when(objectMapper.readTree(responseBody)).thenReturn(mockJsonNode); - - // when - boolean result = tossPaymentClient.verifyPayment(paymentKey, orderId, amount); - - // then - assertTrue(result); - verify(restTemplate, times(1)).exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class)); - verify(objectMapper, times(1)).readTree(responseBody); // ObjectMapper 호출 확인 - } - - - @Test - @DisplayName("결제 검증 실패 - 결제 금액 불일치") - void verifyPayment_Fail_AmountMismatch() throws Exception { - // given - String paymentKey = "paymentKey123"; - String orderId = "order123"; - int expectedAmount = 1000; - int actualAmount = 500; - String responseBody = """ - { - "status": "DONE", - "totalAmount": 500 - } - """; - - ResponseEntity mockResponse = new ResponseEntity<>(responseBody, HttpStatus.OK); - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) - .thenReturn(mockResponse); - - JsonNode mockJsonNode = new ObjectMapper().readTree(responseBody); - when(objectMapper.readTree(responseBody)).thenReturn(mockJsonNode); - - // when - boolean result = tossPaymentClient.verifyPayment(paymentKey, orderId, expectedAmount); - - // then - assertFalse(result); - verify(restTemplate, times(1)).exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class)); - verify(objectMapper, times(1)).readTree(responseBody); - } - - - @Test - @DisplayName("결제 검증 실패 - 결제 상태가 DONE이 아님") - void verifyPayment_Fail_StatusNotDone() throws Exception { - // given - String paymentKey = "paymentKey123"; - String orderId = "order123"; - int amount = 1000; - String responseBody = """ - { - "status": "CANCELED", - "totalAmount": 1000 - } - """; - - ResponseEntity mockResponse = new ResponseEntity<>(responseBody, HttpStatus.OK); - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) - .thenReturn(mockResponse); - - JsonNode mockJsonNode = new ObjectMapper().readTree(responseBody); - when(objectMapper.readTree(responseBody)).thenReturn(mockJsonNode); - - // when - boolean result = tossPaymentClient.verifyPayment(paymentKey, orderId, amount); - - // then - assertFalse(result); - verify(restTemplate, times(1)).exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class)); - verify(objectMapper, times(1)).readTree(responseBody); - } - - - @Test - @DisplayName("결제 검증 실패 - API 응답이 4xx 에러 발생") - void verifyPayment_Fail_ClientError() { - // given - String paymentKey = "paymentKey123"; - String orderId = "order123"; - int amount = 1000; - - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) - .thenThrow(new HttpClientErrorException(HttpStatus.BAD_REQUEST)); - - // when & then - assertThrows(BusinessException.class, () -> tossPaymentClient.verifyPayment(paymentKey, orderId, amount)); - verify(restTemplate, times(1)).exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class)); - } - @Test @DisplayName("orderId 결제 조회 성공 - 응답 파싱") void findPaymentByOrderId_Success() throws Exception { @@ -188,23 +74,4 @@ void findPaymentByOrderId_NotFound_ReturnsEmpty() { // then assertTrue(result.isEmpty()); } - - @Test - @DisplayName("결제 검증 실패 - API 응답이 5xx 서버 오류 발생") - void verifyPayment_Fail_ServerError() { - // given - String paymentKey = "paymentKey123"; - String orderId = "order123"; - int amount = 1000; - - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) - .thenThrow(new HttpServerErrorException(HttpStatus.INTERNAL_SERVER_ERROR)); - - // when & then - BusinessException exception = assertThrows(BusinessException.class, - () -> tossPaymentClient.verifyPayment(paymentKey, orderId, amount)); - - assertEquals(ErrorCode.PAYMENT_VERIFICATION_FAILED, exception.getErrorCode()); - verify(restTemplate, times(1)).exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class)); - } }