PaymentServiceImpl.java

package hello.commerce.payment;

import hello.commerce.common.exception.BusinessException;
import hello.commerce.common.exception.ErrorCode;
import hello.commerce.common.properties.KakaoPayProperties;
import hello.commerce.order.OrderReader;
import hello.commerce.order.OrderRepository;
import hello.commerce.order.model.Order;
import hello.commerce.order.model.OrderStatus;
import hello.commerce.payment.dto.KakaoPayApproveResponseV1;
import hello.commerce.payment.dto.KakaoPayReadyRequestV1;
import hello.commerce.payment.dto.KakaoPayReadyResponseV1;
import hello.commerce.payment.model.Payment;
import hello.commerce.payment.model.PaymentHistory;
import hello.commerce.payment.model.PaymentStatus;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;

import java.util.HashMap;
import java.util.Map;

@Slf4j
@Service
@RequiredArgsConstructor
public class PaymentServiceImpl implements PaymentService {

    private final OrderRepository orderRepository;
    private final OrderReader orderReader;
    private final WebClient webClient;
    private final KakaoPayProperties kakaoPayProps;
    private final PaymentRepository paymentRepository;
    private final PaymentHistoryRepository paymentHistoryRepository;

    @Transactional
    @Override
    public KakaoPayReadyResponseV1 prepareKakaoPay(Long orderId) {
        // 1. 유효성 검사 및 중복 방지
        Order order = validateOrderCondition(orderId);
        checkDuplicatePayment(orderId);

        // 2. 카카오페이 요청
        KakaoPayReadyRequestV1 kakaoRequest = createKakaoPayReadyRequest(orderId, order);
        KakaoPayReadyResponseV1 response = callKakaoPayReady(kakaoRequest);

        // 3. 결제 정보 저장
        Payment payment = createPayment(order, response);
        paymentSave(payment);

        return response;
    }

    @Transactional
    @Override
    public void approveKakaoPay(Long orderId, String pgToken) {
        // 1. 유효성 검사
        Order order = validateOrderCondition(orderId);

        // 2. 결제 준비 시 저장해둔 TID 조회
        Payment payment = getValidPayment(orderId);
        String tid = payment.getTransactionId();

        // 3. 카카오페이에 결제 승인 요청
        KakaoPayApproveResponseV1 response = callKakaoPayApprove(pgToken, tid, order);

        // 4. 결제 성공 처리
        order.setOrderStatus(OrderStatus.PAID);
        orderRepository.save(order);

        payment.setPaymentStatus(PaymentStatus.PAID);
        payment.setPaidAt(response.getApprovedAt());
        payment.setPgToken(pgToken);
        paymentSave(payment);
    }


    private Order validateOrderCondition(Long orderId) {
        // order 데이터
        Order order = orderReader.findByIdForUpdate(orderId);

        // OrderStatus 상태 검증
        if (order.getOrderStatus() != OrderStatus.INITIAL) {
            throw new BusinessException(ErrorCode.INVALID_ORDER_STATUS_TRANSITION);
        }

        return order;
    }

    private void checkDuplicatePayment(Long orderId) {
        paymentRepository.findByOrderId(orderId)
                .ifPresent(p -> { throw new BusinessException(ErrorCode.ALREADY_PREPARED_PAYMENT); });
    }

    private KakaoPayReadyRequestV1 createKakaoPayReadyRequest(Long orderId, Order order) {
        return KakaoPayReadyRequestV1.builder()
                .cid(kakaoPayProps.getCid())
                .partnerOrderId(order.getId().toString())
                .partnerUserId(order.getUserId().toString())
                .itemName(order.getProduct().getName())
                .quantity(order.getQuantity())
                .totalAmount(order.getTotalAmount())
                .taxFreeAmount(kakaoPayProps.getTaxFreeAmount())
                .approvalUrl(kakaoPayProps.getApprovalRedirectUrl(orderId))
                .cancelUrl(kakaoPayProps.getCancelRedirectUrl(orderId))
                .failUrl(kakaoPayProps.getFailRedirectUrl(orderId))
                .build();
    }

    private KakaoPayReadyResponseV1 callKakaoPayReady(KakaoPayReadyRequestV1 request) {
        Map<Object, Object> requestBody = new HashMap<>();
        requestBody.put("cid", request.getCid());
        requestBody.put("partner_order_id", request.getPartnerOrderId());
        requestBody.put("partner_user_id", request.getPartnerUserId());
        requestBody.put("item_name", request.getItemName());
        requestBody.put("quantity", String.valueOf(request.getQuantity()));
        requestBody.put("total_amount", String.valueOf(request.getTotalAmount()));
        requestBody.put("tax_free_amount", String.valueOf(request.getTaxFreeAmount()));
        requestBody.put("approval_url", request.getApprovalUrl());
        requestBody.put("cancel_url", request.getCancelUrl());
        requestBody.put("fail_url", request.getFailUrl());

        // 응답 객체는 KakaoPayReadyResponseV1와 동일 구조를 맞춰야 함
        KakaoPayReadyResponseV1 response = webClient.post()
                .uri(kakaoPayProps.getReadyUrl()) // /v1/payment/ready
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(requestBody)
                .retrieve()
                .onStatus(
                        HttpStatusCode::isError,
                        clientResponse -> clientResponse.bodyToMono(String.class)
                                .flatMap(errorBody -> {
                                    log.error("카카오페이 오류 응답 본문: {}", errorBody);
                                    return Mono.error(new BusinessException(ErrorCode.KAKAO_API_ERROR));
                                })
                )
                .bodyToMono(KakaoPayReadyResponseV1.class)
                .block();// 동기

        // 응답 값 유효성 검사
        if (response == null || response.getTid() == null || response.getNextRedirectPcUrl() == null) {
            log.error("카카오페이 결제 요청 응답 필드가 누락됨: {}", response);
            throw new BusinessException(ErrorCode.KAKAO_API_ERROR);
        }

        return response;
    }

    private Payment getValidPayment(Long orderId) {
        Payment payment = paymentRepository.findByOrderIdAndTransactionIdIsNotNull(orderId)
                .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND_PAYMENT));

        if (payment.getPaymentStatus() != PaymentStatus.INITIAL) {
            throw new BusinessException(ErrorCode.INVALID_ORDER_STATUS_TRANSITION);
        }

        return payment;
    }

    private KakaoPayApproveResponseV1 callKakaoPayApprove(String pgToken, String tid, Order order) {
        HashMap<String, String> form = new HashMap<>();
        form.put("cid", kakaoPayProps.getCid());
        form.put("tid", tid);
        form.put("partner_order_id", order.getId().toString());
        form.put("partner_user_id", order.getUserId().toString());
        form.put("pg_token", pgToken);

        KakaoPayApproveResponseV1 response = webClient.post()
                .uri(kakaoPayProps.getApproveUrl()) // /v1/payment/approve
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(form)
                .retrieve()
                .onStatus(
                        HttpStatusCode::isError,
                        clientResponse -> clientResponse.bodyToMono(String.class)
                                .flatMap(errorBody -> {
                                    log.error("카카오페이 오류 응답 본문: {}", errorBody);
                                    return Mono.error(new BusinessException(ErrorCode.KAKAO_API_ERROR));
                                })
                )
                .bodyToMono(KakaoPayApproveResponseV1.class)
                .block();

        // 응답 값 유효성 검사
        if (response == null || response.getTid() == null || response.getApprovedAt() == null) {
            log.error("카카오페이 승인 응답 필드가 누락됨: {}", response);
            throw new BusinessException(ErrorCode.KAKAO_API_ERROR);
        }

        return response;
    }

    private Payment createPayment(Order order, KakaoPayReadyResponseV1 response) {
        return Payment.builder()
                .order(order)
                .paymentMethod("KAKAOPAY")
                .paymentStatus(PaymentStatus.INITIAL)
                .totalAmount(order.getTotalAmount())
                .transactionId(response.getTid())
                .isTest(true) // 또는 yml에서 분기
                .build();
    }

    private void paymentSave(Payment payment) {
        paymentRepository.save(payment);
        paymentHistoryRepository.save(new PaymentHistory(payment));
    }
}