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 @@ -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: 결제 승인 성공 및 상태 확인 완료");
Expand All @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -78,65 +77,6 @@ public ResponseEntity<String> 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<String> requestEntity = new HttpEntity<>(headers);

try {
ResponseEntity<String> 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<TossPaymentInfo> findPaymentByOrderId(String orderId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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.*;
Expand All @@ -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<String> 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<String> 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<String> 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 {
Expand Down Expand Up @@ -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));
}
}
Loading