쇼핑몰 웹사이트 만들어보기- 주문 취소 구현(토스 api 활용)

Shiba·2024년 8월 26일
0

프로젝트 및 일기

목록 보기
25/29

주문 취소 구현

++2024/08/26 22:57)
주문 취소자체는 쉽지만 여기도 주문 취소시, 결제했던 계좌로 돈이 다시 입금되도록 하기위해서는 토스api에서 결제 취소api를 사용해야한다.
결제 취소 api를 살펴보니 paymentKey가 필요하다고한다. 근데 해당 키를 이때까지 저장하지 않았다..! 그래서 결제정보 테이블도 변화가 필요해보인다.

찾아보니 orderId로 결제정보 조회가 가능하고, 조회를 하면 paymentKey를 얻을 수 있다. 즉, 두가지 선택지가 있는 셈이다.

  1. 귀찮으니 그냥 paymentKey도 저장하도록 하고 해당 키를 통해 주문 취소하기
  2. 결제정보를 조회(get)해서 paymentKey를 얻고 해당 키로 주문 취소

여기서 토스 api를 더 잘 사용해보기 위해 2번으로 해보기로 했다.

현재 어찌저찌 코드를 짜봤는데 받아온 payment 객체를 타임리프로 값을 넣어주고 사용하려고 하는데 잘 안된다.. 값을 제대로 받지 못하는 모습이다
- 해결했다. 코드를 보니 시크릿키를 제대로 적어주지 않았었다.(api키를 깃허브에 안올리려고 고쳐놓고 그대로 코드를 실행했음)

paymentKey를 받아왔으니 이제 환불을 하는 post명령을 줘보자

++2024/08/27 00:43) 환불 기능을 만드는데에 성공한거 같다! 코드를 보면서 정리해보도록 하자.

orderId로 get요청을 보내서 payment객체 받기

먼저, 결제정보에 저장해둔 orderId를 이용하여 payment객체를 받아오자. payment객체안에 paymentKey가 존재하기 때문이다.

컨트롤러로 get요청하기

@Transactional
@GetMapping("/researchOrder")
public String research(@RequestParam(name = "purchase_id") int purchase_id,
                                               Model model) throws Exception {

        Purchases purchases = purchaseService.findById(purchase_id);

        // 토스페이먼츠 API는 시크릿 키를 사용자 ID로 사용하고, 비밀번호는 사용하지 않습니다.
        // 비밀번호가 없다는 것을 알리기 위해 시크릿 키 뒤에 콜론을 추가합니다.
        String widgetSecretKey = "결제 연동 시크릿 키";
        Base64.Encoder encoder = Base64.getEncoder();
        byte[] encodedBytes = encoder.encode((widgetSecretKey + ":").getBytes(StandardCharsets.UTF_8));
        String authorizations = "Basic " + new String(encodedBytes);

        // 결제를 승인하면 결제수단에서 금액이 차감돼요.
        URL url = new URL("https://api.tosspayments.com/v1/payments/orders/" + purchases.getOrder_id());
        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
        connection.setRequestProperty("Authorization", authorizations);
        connection.setRequestMethod("GET");

        int code = connection.getResponseCode();
        boolean isSuccess = code == 200;

        InputStream responseStream = isSuccess ? connection.getInputStream() : connection.getErrorStream();

        ObjectMapper objectMapper = new ObjectMapper();
        Map<String, Object> paymentData = objectMapper.readValue(responseStream, Map.class);

        model.addAttribute("paymentData", paymentData);
        model.addAttribute("purchase", purchases);
        return "/toss/withdrawal";  // 타임리프 템플릿 이름
    }

다음과 같이 토스 api요청으로 payment객체를 받아온다. 상태코드가 200이면 responseStream에 payment객체가 들어오게 되는데, 이걸 Map을 이용하여 변환한 후, 모델에 넣어서 html로 보내는 방식이다.

환불 요청 페이지 만들기

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>환불 요청</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 0;
            padding: 0px;
            background-color: #333;
            color: #cccccc;
            height: 100vh;
        }

        /*상단 바*/
        .header {
            background-color: #333;
            color: #fff;
            padding: 10px;
            height: 130px;
            display: flex;
            position: relative;
            min-width: 1200px;
        }

        /*로고가 좌측 상단에 위치하도록 조정*/
        #home_logo {
            display: inline-block;
            width: 270px;
            height: 90px;
        }

        /*선택자를 이용해 로고의 크기 조정*/
        #home_logo > img {
            width: 270px;
            height: 90px;
        }
        .container {
            max-width: 800px;
            text-align: center;
            color: #222222;
            background-color: #ffffff;
            padding: 30px;
            box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);
            border-radius: 5px;
            margin: 0 auto;
        }
        .photo, .product_name, .product_info, .but_box{
            display: inline-block;
            padding: 7px 40px 0px 16px;
        }

        .product_name, .product_info, .but_box{
            position: relative;;
        }

        .product_name{
            text-decoration: none;
            width: 200px;
            white-space: nowrap; /* 텍스트를 한 줄로 표시 */
            overflow: hidden; /* 넘치는 텍스트를 숨김 */
            text-overflow: ellipsis; /* 넘치는 텍스트에 '...' 추가 */
            top: 63px;
        }

        .product_info{
            width: 200px;
            top: 60px
        }

        h2 {
            color: #333;
            margin-bottom: 20px;
        }
        label {
            display: block;
            margin-bottom: 8px;
            font-weight: bold;
        }
        input[type="text"],
        input[type="number"],
        select{
            width: 100%;
            padding: 10px;
            margin-bottom: 15px;
            border: 1px solid #ccc;
            border-radius: 5px;
        }
        button {
            display: inline-block;
            width: 100%;
            padding: 10px;
            background-color: rgba(86, 132, 203, 0.98);
            color: white;
            border: none;
            border-radius: 5px;
            cursor: pointer;
            transition: background-color 0.3s ease;
        }
        button:hover {
            background-color: #31b0d5;
        }
    </style>
    <script>
        function handleSubmit(event) {
            event.preventDefault(); // 기본 폼 제출 동작 방지
            const form = event.target;

            // 폼 데이터를 객체로 생성
            const formData = new FormData(form);

            // Fetch API를 사용하여 비동기 요청 보내기
            fetch(form.action, {
                method: form.method,
                body: formData,
                headers: {
                    'X-Requested-With': 'XMLHttpRequest'
                }
            }).then(response => {
                if (response.ok) {
                    return response.text();
                } else {
                    throw new Error('네트워크 응답이 올바르지 않습니다.');
                }
            }).then(data => {
                alert(data);
                if(data === "환불 완료"){
                    window.location.href = "/user/status";
                }
            })
                .catch(error => {
                    console.error('Error:', error);
                    if(error === 'Network response was not ok.')
                        alert('네트워크 오류.');
                });
        }
    </script>
</head>
<body>
<div class="header">
    <a href="/" id="home_logo">
        <img src="/images/icons/logo.png" alt="Home Logo"/>
    </a>
</div>
<div class="container">
    <h2>환불 요청</h2>
    <form th:action="@{/refund}" method="post" onsubmit="handleSubmit(event)">
        <input type="hidden" id="paymentKey" name="paymentKey" th:value="${paymentData[paymentKey]}"/>
        <input type="hidden" id="taxFreeAmount" name="taxFreeAmount" th:value="${paymentData[taxExemptionAmount]}"/>
        <input type="hidden" id="cancelAmount" name="cancelAmount" th:value="${purchase.price}"/>
        <input type="hidden" id="purchase_id" name="purchase_id" th:value="${purchase.id}"/>

        <div class="order_box" style="display: flex">
            <div class="photo">
                <img id="image" th:src="@{'/images/uploads/' + ${purchase.products.photo}}" alt="Product Image" width="150" height="150">
            </div>
            <div class="product_name">
                <label th:text="${purchase.products.product_name}"></label>
            </div>
            <div class="product_info">
                <span th:text="'결제가격: ' + ${purchase.price} + '원'"></span>
                <span th:text="${purchase.product_cnt} + '개'"></span>
            </div>
        </div>
        <label for="cancelReason">환불 사유</label>
        <select id="cancelReason" name="cancelReason">
            <option value="" disabled selected>환불 사유를 선택하세요</option>
            <option value="상품이 파손되었습니다">상품이 파손되었습니다</option>
            <option value="잘못된 상품을 받았습니다">잘못된 상품을 받았습니다</option>
            <option value="중복된 주문이었습니다">중복된 주문이었습니다</option>
            <option value="마음이 바뀌었습니다">마음이 바뀌었습니다</option>
            <option value="기타">기타</option>
        </select>

        <button type="submit">환불 하기</button>
    </form>
</div>
</body>
</html>

환불 요청 페이지에서 방금 get으로 보낸 값들을 타임리프를 통해 input값에 넣어둔다. submit을 하게되면 우리가 원하던 paymentKey를 post요청으로 보내버릴 수 있게된다.
여기서 환불 사유도 필수 값이니 꼭 까먹지말고 보내도록 하자. 환불할 금액은 부분 취소도 가능하고 전액 취소도 가능한데 여러 상품을 동시에 주문했다면 부분 취소로 진행된다.

이렇게 프론트쪽에서 post요청을 보내주었다면 서버에서 받아서 처리를 하면 된다.

서버에서 결제 취소 처리하기

결제를 취소하기 위해서 postmapping으로 컨트롤러 코드를 작성해야 한다.

결제 취소 컨트롤러 코드 작성

@PostMapping("/refund")
    public ResponseEntity<String> quitUser( @RequestParam(name = "cancelReason") String cancelReason,
                                            @RequestParam(name = "cancelAmount") String cancelAmount,
                                            @RequestParam(name = "taxFreeAmount") String taxFreeAmount,
                                            @RequestParam(name = "paymentKey") String paymentKey,
                                            @RequestParam(name = "purchase_id") int purchase_id) {
        ResponseEntity response;
        try {
            JSONObject obj = new JSONObject();
            obj.put("cancelReason", cancelReason);
            obj.put("cancelAmount", cancelAmount);
            obj.put("taxFreeAmount", taxFreeAmount);

            // 토스페이먼츠 API는 시크릿 키를 사용자 ID로 사용하고, 비밀번호는 사용하지 않습니다.
            // 비밀번호가 없다는 것을 알리기 위해 시크릿 키 뒤에 콜론을 추가합니다.
            String widgetSecretKey = "결제 연동 시크릿 키";
            Base64.Encoder encoder = Base64.getEncoder();
            byte[] encodedBytes = encoder.encode((widgetSecretKey + ":").getBytes(StandardCharsets.UTF_8));
            String authorizations = "Basic " + new String(encodedBytes);

            // 결제를 승인하면 결제수단에서 금액이 차감돼요.
            URL url = new URL("https://api.tosspayments.com/v1/payments/" + paymentKey + "/cancel");
            HttpURLConnection connection = (HttpURLConnection) url.openConnection();
            connection.setRequestProperty("Authorization", authorizations);
            connection.setRequestProperty("Content-Type", "application/json");
            connection.setRequestMethod("POST");
            connection.setDoOutput(true);

            OutputStream outputStream = connection.getOutputStream();
            outputStream.write(obj.toString().getBytes("UTF-8"));

            int code = connection.getResponseCode();
            boolean isSuccess = code == 200;

            if(isSuccess){
                Purchases purchases = purchaseService.findById(purchase_id);
                purchaseService.deletePurchase(purchases);
                response = ResponseEntity
                        .status(HttpStatus.CREATED)
                        .body("환불 완료");
            }
            else {
                response = ResponseEntity
                        .status(HttpStatus.CREATED)
                        .body("환불 실패");

            }
        } catch (Exception ex) {
            response = ResponseEntity
                    .status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body("An exception occured due to " + ex.getMessage());
        }
        return response;
    }

obj에 body값에 넣어야 하는 값들을 넣어준다. (무통장 입금으로 결제했다면 추가적인 파라미터가 필요하지만 여기서는 토스페이로만 결제해서 생략했다)
obj에 값들을 모두 추가했다면 이를 outputStream을 통하여 요청 본문에 추가해서 post요청을 api주소로 보낸다. api가 해당 요청을 받아서 작업 후, 알아서 처리해준다. (api 사용법만 알면 해당 코드들을 구현하지 않아도 된다는게 정말 편하다)

토스가 결제를 취소처리 해주었다면, 이제 우리가 가진 결제정보 테이블의 정보도 삭제해주면 된다. 그래서 상태코드가 200일 시, 결제 정보의 id를 받아서 해당 행을 삭제한다. 그 후, 환불 완료라는 알림을 띄우고 주문 내역창으로 다시 이동하게 되는 방식이다.


테스트 결과


테스트 결과는 위 gif에서 보듯이 원하는 방식대로 동작된다.

또한, api로그와, 이메일, 휴대폰 알림을 통하여 정상적으로 취소가 되었음을 확인할 수 있었다.

profile
모르는 것 정리하기

0개의 댓글