CreditService.java

package qwerty.chaekit.service.ebook.credit;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import qwerty.chaekit.domain.ebook.credit.payment.CreditPaymentTransaction;
import qwerty.chaekit.domain.ebook.credit.payment.CreditPaymentTransactionRepository;
import qwerty.chaekit.domain.ebook.credit.payment.CreditPaymentTransactionType;
import qwerty.chaekit.domain.ebook.credit.wallet.CreditWallet;
import qwerty.chaekit.domain.ebook.credit.wallet.CreditWalletRepository;
import qwerty.chaekit.dto.ebook.credit.CreditProductInfoResponse;
import qwerty.chaekit.dto.ebook.credit.CreditTransactionResponse;
import qwerty.chaekit.dto.ebook.credit.CreditWalletResponse;
import qwerty.chaekit.dto.ebook.credit.payment.CreditPaymentApproveResponse;
import qwerty.chaekit.dto.ebook.credit.payment.CreditPaymentReadyRequest;
import qwerty.chaekit.dto.external.kakaopay.KakaoPayApproveResponse;
import qwerty.chaekit.dto.page.PageResponse;
import qwerty.chaekit.global.constant.CreditProduct;
import qwerty.chaekit.global.security.resolver.UserToken;
import qwerty.chaekit.service.ebook.credit.exception.PaymentCancelFailedException;

import java.util.Arrays;
import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class CreditService {
    private final KakaoPayService kakaoPayService;
    private final CreditPaymentTransactionRepository creditPaymentTransactionRepository;
    private final CreditWalletRepository creditWalletRepository;

    @Transactional(readOnly = true)
    public List<CreditProductInfoResponse> getCreditProductList() {
        return Arrays.stream(CreditProduct.values())
                .map(creditProduct -> CreditProductInfoResponse.builder()
                        .id(creditProduct.getId())
                        .creditAmount(creditProduct.getCreditAmount())
                        .price(creditProduct.getPrice())
                        .build()
                ).toList();
    }

    @Transactional
    public String requestKakaoPay(UserToken userToken, CreditPaymentReadyRequest request) {
        return kakaoPayService.requestKakaoPay(userToken, request);
    }

    @Transactional
    public CreditPaymentApproveResponse approveKakaoPayPayment(UserToken userToken, String pgToken) {
        Long userId = userToken.userId();

        // 카카오페이 결제 승인
        KakaoPayApproveResponse response = kakaoPayService.approveKakaoPayPayment(userId, pgToken);

        // 트랜잭션 내 처리
        try {
            finalizePayment(response, userId);
        } catch (Exception ex) {
            // 예외 발생 시 결제 취소 및 에러 처리
            handlePaymentFailureAndCancel(ex, response);
        }

        CreditProduct creditProduct = CreditProduct.getCreditProduct(Integer.parseInt(response.item_code()));

        return CreditPaymentApproveResponse.builder()
                .orderId(response.partner_order_id())
                .creditProductId(creditProduct.getId())
                .creditProductName(creditProduct.getName())
                .paymentMethod(response.payment_method_type())
                .paymentAmount(response.amount().total())
                .approvedAt(response.approved_at())
                .build();
    }

    private void handlePaymentFailureAndCancel(Exception ex, KakaoPayApproveResponse response) {
        String tid = response.tid();
        try {
            kakaoPayService.cancelKakaoPayPayment(tid, response.amount().total());
            log.info("카카오페이 결제 취소 완료: tid={} 이유={}", tid, ex.getMessage());
            throw new RuntimeException("시스템 오류로 결제가 자동 환불되었습니다.", ex);
        } catch (PaymentCancelFailedException cancelEx) {
            log.error("카카오페이 결제 취소 실패: tid={}, 이유={}", tid, cancelEx.getMessage());
            throw new RuntimeException("결제 취소에 실패했습니다. 고객센터에 문의해주세요.", ex);
        }
    }

    private void finalizePayment(KakaoPayApproveResponse response, Long userId) {
        CreditWallet wallet = creditWalletRepository.findByUser_Id(userId)
                .orElseThrow(() -> new IllegalStateException("Credit Wallet not found"));
        int creditAmount = CreditProduct.getCreditProduct(Integer.parseInt(response.item_code())).getCreditAmount();
        if (isFirstPurchase(wallet)) {
            log.info("첫 결제 사용자: userId={}, creditAmount={}", userId, creditAmount);
            creditAmount = (int) (creditAmount * 1.1); // 첫 결제 시 10% 보너스
        }
        wallet.addCredit(creditAmount);
        creditPaymentTransactionRepository.save(
                CreditPaymentTransaction.builder()
                        .tid(response.tid())
                        .orderId(response.partner_order_id())
                        .creditProductId(Integer.parseInt(response.item_code()))
                        .creditProductName(response.item_name())
                        .wallet(wallet)
                        .transactionType(CreditPaymentTransactionType.CHARGE)
                        .creditAmount(creditAmount)
                        .paymentAmount(response.amount().total())
                        .approvedAt(response.approved_at())
                        .build()
        );
    }

    private boolean isFirstPurchase(CreditWallet wallet) {
        return creditWalletRepository.existsByUserAndPaymentTransactionsEmpty(wallet.getUser());
    }

    @Transactional(readOnly = true)
    public CreditWalletResponse getMyWallet(UserToken userToken) {
        return creditWalletRepository.findByUser_Id(userToken.userId())
                .map(wallet -> CreditWalletResponse.builder()
                        .walletId(wallet.getId())
                        .balance(wallet.getBalance())
                        .build()
                ).orElseThrow(() -> new IllegalStateException("Credit Wallet not found"));
    }

    @Transactional(readOnly = true)
    public PageResponse<CreditTransactionResponse> getMyWalletTransactions(UserToken userToken, Pageable pageable) {
        Page<CreditTransactionResponse> result = creditPaymentTransactionRepository.getCreditTransactionsByWallet_User_Id(
                userToken.userId(), pageable
        ).map(transaction -> CreditTransactionResponse.builder()
                .orderId(transaction.getOrderId())
                .productId(transaction.getCreditProductId())
                .productName(transaction.getCreditProductName())
                .type(transaction.getTransactionType())
                .creditAmount(transaction.getCreditAmount())
                .paymentAmount(transaction.getPaymentAmount())
                .approvedAt(transaction.getApprovedAt())
                .build()
        );

        return PageResponse.of(result);
    }
}