2024. 4. 3. 00:21ㆍ카테고리 없음
이전 포스팅에서, 사전검증을 끝내고 나서, 결제창을 띄우는 것까지 해보았다.
결제창이 띄워지면, 어떻게 되는지, 일단 전체적인 흐름을 정리해두는 게 좋을 거라고 생각했다.
1. 첫번째 결제흐름
결제창이 띄워지면, 사용자는 아래와 같이 선택하고,
아래와 같이 정보를 입력하고,
아래와 같이 이메일 정보까지 입력한 다음에, 결제버튼을 딱 클릭하면
아래와 같이 alert 창으로 결제가 성공했다고 알려준 후
아래 처음 결제를 시작했던,
이 부분으로 오게 된다.
그럼 위에서 보듯이, 20:50:33 초에 결제된 이니시스-정기 1원이 출금된 걸 확인할 수 있다.
결제된 금액은 이니시스의 경우 아래에서 보듯, 23:00 ~ 23:50 사이에 취소되기 때문에, 걱정하지 않아도 된다.
2. 두번째 결제흐름
만약, 이니시스 결제창까지 띄운 사용자가 "에라이 안살래~" 하고 이니시스 결제창을 꺼버리면?
그럼 아래와 같은 alert 창이 띄워지게 되는데, alert 문구 중, "사용자가 결제를 취소하셨습니다" 부분은 내 애플리케이션에서 만들어준 문구가 아니라, 포트원에서 보내준 문구를 그대로 띄워준 것이다.
이렇게 결제가 성공적으로 끝까지 마친 경우, 그리고, 결제가 취소된 경우 이렇게 두가지의 흐름이 있고, 이걸 구현해볼것이다.
----------------------------------------------------------------------------------------------------------------------
우선, 결제가 성공하는 흐름에 대해서 먼저 할 것이다. 그러면, 우리가 저번 포스팅에서부터 봤던, orderSheet.jsp 파일의 script 태그를 다시 봐야 한다.
<head>
<link rel="stylesheet" href="../../css/payment/orderSheet.css">
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="https://cdn.iamport.kr/v1/iamport.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function() {
var IMP = window.IMP;
IMP.init("여기에는 뭐를 써야 할까요?????????");
// 폼 선택
const form = document.getElementById('order-form');
form.addEventListener('submit', function(event) { //submit(제출) 되면 이벤트 발생
event.preventDefault(); // 폼의 기본 제출 동작을 방지
// FormData 객체 생성
const formData = new FormData(form);
// fetch API를 사용하여 폼 데이터를 서버로 비동기적으로 전송
fetch('/order-process', {
method: 'POST',
body: formData, // 폼 데이터. , 끝에 붙이는 거 문제 안된다고 함. 오히려 개발자들이 선호한다고 함.
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json(); // 서버로부터 반환된 JSON 응답을 파싱. 이러면 아래 data 여기에 알아서 파싱된 json 이 담김. data.json키 를 쓰면, value 를 얻을 수 있지.
})
.then(data => {
if(data.result = 'success'){
//사전검증 시작
fetch('/pre-validation?productId=${productId}&merchantUid='+ data.merchantUid)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
//사전검증이 끝나면 할 것들 : 포트원 api 통신
//일단, 재고부족인 경우 처리
if(data.outOfStock == true){
alert('재고가 부족하여 주문이 취소되었습니다.'); // alert창 띄우고,
window.location.href="/payment-home"; // paymentHome.jsp 로 인도하는 컨트롤러로 get요청.
}
// 재고가 있는 경우,
var merchantUid = data.merchantUid;
var productName = data.productName;
var amount = data.amount;
var email = data.memberTotalData.email;
var domain = data.memberTotalData.domain;
var emailaddress = email + '@' + domain;
var userName = data.memberTotalData.userName;
var phoneNumStart = data.memberTotalData.phoneNumStart;
var phoneNumMiddle = data.memberTotalData.phoneNumMiddle;
var phoneNumEnd = data.memberTotalData.phoneNumEnd;
var phone = phoneNumStart + phoneNumMiddle + phoneNumEnd;
var streetAddress = data.memberTotalData.streetAddress;
var zipCode = data.memberTotalData.zipCode;
//여기서, portone 시작
IMP.request_pay(
///////////////////////// 첫번째 ////////////////////////////////
{
pg: "html5_inicis",
pay_method: "card",
merchant_uid: merchantUid,
name: productName,
amount: amount,
buyer_email: emailaddress,
buyer_name: userName,
buyer_tel: phone,
buyer_addr: streetAddress,
buyer_postcode: zipCode,
},
////////////////////////두번째 //////////////////////////////////
function (rsp) {
if (rsp.success) {
// axios로 HTTP 요청
axios({
url: "/payment-response", // 실제 서버의 엔드포인트 주소로 수정
method: "post",
headers: { "Content-Type": "application/json" },
data: {
imp_uid: rsp.imp_uid,
merchant_uid: rsp.merchant_uid
}
})
.then((response) => {
// 서버 결제 API 성공 시 로직
if(response = 'success'){ // 성공한 경우
alert('결제가 성공하였습니다.');
window.location.href="/payment-home";
} else {
alert(response); // 그 외의 경우
window.location.href="/payment-home";
}
})
.catch((error) => {
console.error(error);
});
} else {
alert('결제에 실패하였습니다. 에러 내용 : ' + rsp.error_msg);
//결제에 실패한 경우, 재고 +1 해줘야 하고, orders테이블에서 행 삭제
window.location.href="/payment-fail?merchantUid=" + merchantUid + "&impUid=" + rsp.imp_uid;
}
}
);
//여기서, portone 끝
})
.catch(error => {
console.error('There has been a problem with your fetch operation:', error)
alert('결제 중 오류가 발생했습니다.');
});
//사전검증 끝
}
})
.catch(error => {
console.error('There has been a problem with your fetch operation:',
error);
});
});
});
</script>
</head>
위 코드 중 아래부분을 시작할 차례이다.
저번 포스팅에서 말했듯, rsp 에는 결제결과가 들어있다.
if(rsp.success){} : 만약, 결제가 성공했다면~ axios 로 ajax 통신을 하고 있다.
else{} : 만약 결제가 어떠한 이유로 취소되었다면, 포트원이 보내준 에러메세지를 띄워주고 있다.
지금 볼건, if(rsp.success){여기} 여기에 들어있는 것들을 볼것이다.
axios 로 /payment-response 라는 url 로 HTTP 요청을 POST 방식으로 보내고 있다.
뭘 담아줬냐면, rsp.imp_uid 와 rsp.merchant_uid 를 담아줬다.
즉, 포트원서버가 내려준 데이터에서 imp_uid 와 merchant_uid 를 보내준 것이다.
여기서, merchant_uid 는 뭐냐면, 내 애플리케이션에서 ORDERS 테이블에 행을 삽입할 때 만들었던, 각 주문에 부여된 다른 주문과 구분되는 고유한 값을 말한다. 즉, 내 애플리케이션에서의 주문번호 를 말한다.
imp_uid 는 뭐냐면, 포트원서버에서 해당 주문건에 대해 부여한 고유한 값이다.
////////////////////////두번째 //////////////////////////////////
function (rsp) {
if (rsp.success) {
// axios로 HTTP 요청
axios({
url: "/payment-response", // 실제 서버의 엔드포인트 주소로 수정
method: "post",
headers: { "Content-Type": "application/json" },
data: {
imp_uid: rsp.imp_uid,
merchant_uid: rsp.merchant_uid
}
})
.then((response) => {
// 서버 결제 API 성공 시 로직
if(response = 'success'){ // 성공한 경우
alert('결제가 성공하였습니다.');
window.location.href="/payment-home";
} else {
alert(response); // 그 외의 경우
window.location.href="/payment-home";
}
})
.catch((error) => {
console.error(error);
});
} else {
alert('결제에 실패하였습니다. 에러 내용 : ' + rsp.error_msg);
//결제에 실패한 경우, 재고 +1 해줘야 하고, orders테이블에서 행 삭제
window.location.href="/payment-fail?merchantUid=" + merchantUid + "&impUid=" + rsp.imp_uid;
}
}
이제 /payment-response 를 매핑하는 컨트롤러로 가보자. 아래와 같다.
@PostMapping("/payment-response")
@ResponseBody
public String paymentResponeControllerMethod(@RequestBody PaymentDTO paymentDTO, HttpServletRequest request) {
// 1. orders 테이블에 결제건에 해당하는 행의 status 컬럼을 Processing 이라고 바꿔줘야함.
// 여기서 Processing 이란, 주문에 대한 결제를 처리 중이다. 라는 뜻이다. 아직 사후검증(결제됬어야 할 금액과 실제로 결제한 금액이 같은지 여부를 검증)을 하기 전이니, 결제를 처리 중인게 맞지.
ordersMapper.updateStatusByMerchantUid(paymentDTO.getMerchant_uid(),"Processing");
// 2. 사후검증 시작
try {
// 2-1) Imp_uid 와 merchant_uid 를 꺼내오기.
String impUid = paymentDTO.getImp_uid();
String merchantUid = paymentDTO.getMerchant_uid();
// 2-2) access 토큰 얻어오기
TokenResponseDTO tokenDTO = accessToken.getAccessToken();
// 2-3) 얻은 토큰으로 결제단건조회를 함.
Integer memberId = FindLoginedMemberIdUtil.findLoginedMember(request);
ResponseEntity<String> response = singlePaymentQueryService.singlePaymentQuery(impUid, tokenDTO);
// 2-4) 일단, payments 테이블에 행을 삽입한다. 왜? 사후검증(결제됬어야 할 금액과 실제로 결제한 금액이 같은지 여부를 검증)여부와는 무관하게, 일단은 결제가 됬으니까.
// ㄱ. 포트원으로부터 온 HTTP응답의 body 가져오기
String responseBody = response.getBody();
// ㄴ. INSERT INTO PAYMENTS
insertPaymentsRowService.insertRow(memberId, responseBody);
// 2-5) 사후검증
postValidationService.postValidation(response);
//판매자들을 인도하는 페이지에서, 판매자들이 택배회사와 송장번호를 입력하면 그걸 받는 컨트롤러에서 orders 테이블의 status 컬럼값을 Shipped 로 변경하도록 해주면 됨.
//그리고 고객들이 보는 페이지에서는 payments 테이블의 status 컬럼이 아니라, orders 테이블의 status 컬럼값에 따라서, 표시가 되도록 하면 됨.
return "success";
} catch (PaymentVerificationException e) {
//사후검증에 실패한 경우 :
// orders 테이블의 status, fail_reason 컬럼을 실패했다고 업데이트 해주는 것은 SinglePaymentQueryService 에서 해줬기 때문에 그 과정 생략.
Integer portOneAmount = e.getPortOneAmount();
Integer myDbAmount = e.getMyDbAmount();
Integer priceDifference = e.getPriceDifference();
if (priceDifference > 0) {
//덜 결제된 경우
return "결제했어야 할 금액 : " + myDbAmount + ", 결제된 금액 : " + portOneAmount + ", " + priceDifference + "가 더 결제되어야 합니다. 최대한 빠른 시일 내에 당사 직원이 연락드리겠습니다. " ;
} else{
//더 결제된 경우 => 정상처리로 보고 정상처리를 해줘야지.
return "결제했어야 할 금액 : " + myDbAmount + ", 결제된 금액 : " + portOneAmount + ", " + priceDifference + "가 더 결제되었습니다. 최대한 빠른 시일 내에 당사 직원이 연락드리겠습니다." ;
}
} catch (JsonProcessingException e) {
//JSON 파싱에 실패한 경우
return "결제 중 오류가 발생되었습니다. JsonParsingException";
} catch(Exception e){
return "결제 중 오류가 발생되었습니다. Exception";
}
}
우선, 클라이언트에서 넘겨준 데이터를 담는 DTO로서, PaymentDTO 를 아래와 같이 만들었다.
@Data
public class PaymentDTO {
private String imp_uid;
private String merchant_uid;
}
제일 먼저 실행될 코드는
ordersMapper.updateStatusByMerchantUid(paymentDTO.getMerchant_uid(),"Processing");
이거다.
어떤 코드냐면, ORDERS 테이블에 삽입되었던 해당 merchant_uid 를 가진 행의 status 컬럼값을 Pending 에서 Processing 으로 바꾸는 코드이다.
Pending 이 "주문이 생성되었지만 아직 그 주문이 결제되거나 하는등의 처리가 되지 않은 상태" 라고 했다면,
Processing 은 "결제 처리중 이라는 상태" 라는 뜻으로 사용했다.
포트원에서 결제는 되었으나, 사후검증까지 모두 마친 게 결제처리의 전 과정이라고 생각했기 때문에, Processing 이라고 표현하였다.
// 2-1) Imp_uid 와 merchant_uid 를 꺼내오기.
String impUid = paymentDTO.getImp_uid();
String merchantUid = paymentDTO.getMerchant_uid();
// 2-2) access 토큰 얻어오기
TokenResponseDTO tokenDTO = accessToken.getAccessToken();
다음으로 오는 코드는 위와 같은데, 여기까지는 복잡할 게 없으니, 간단히만 설명하자면, 클라이언트로부터 온 imp_uid 와 merchant_uid 를 꺼내오고, 포트원서버에게 방금 한 결제에 대한 정보를 조회(결제단건조회)를 할건데, 포트원서버에게 뭐 달라고 하려면 결제하는 거 빼고는 access token 이 필요하다고 했으니까, accessToken 을 얻어온 것이다.
// 2-3) 얻은 토큰으로 결제단건조회를 함.
Integer memberId = FindLoginedMemberIdUtil.findLoginedMember(request);
ResponseEntity<String> response = singlePaymentQueryService.singlePaymentQuery(impUid, tokenDTO);
이제 얻은 코드로, 결제단건조회를 시작한다.
FindLoginedMemberIdUtil 클래스의 findLoginedMember() 메서드를 호출하면서 서블릿 컨테이너가 만들어준 HttpServletRequest 타입 객체를 넘겨주면, 세션객체를 뒤져서, 현재 HTTP요청을 보낸 사용자의 MEMBER 테이블 id 가 나오도록 코드를 짜두었다. 아래 접은글에 남겨둠.
public static Integer findLoginedMember(HttpServletRequest request){
HttpSession session = request.getSession(false);
MemberJoinDTO loginedMember = (MemberJoinDTO) session.getAttribute("loginedMember");
Integer id = loginedMember.getId();
return id;
}
왜 HTTP요청을 보낸 사용자의 MEMBER 테이블 id 컬럼값을 조회해왔냐면, 지금 필요한 건 아니고, 이 코드 밑에 부분에 PAYMENTS 테이블에 행을 삽입할 때 필요해서 미리 꺼내둔 것이다.
이제 singlePaymentQueryService 클래스의 singlePaymentQuery 메서드에 대해서 얘기할건데,
우선, 이 메서드가 바로 결제단건조회(즉, 방금 결제된 행에 대한 정보를 달라고 포트원서버에게 요청하는 것)를 하는 메서드이다.
파라미터로, 포트원 서버가 내려준 데이터인 impUid 와 얻어온 access token 을 넘겨주었는데, 아래와 같이 생긴 메서드이다.
@Service
// @Transactional 안쓴다. 왜? db 접근하는 코드 없음.
@Slf4j
@RequiredArgsConstructor
public class SinglePaymentQueryService {
private final RestTemplate restTemplate;
/*
* 결제 단건 조회
* */
public ResponseEntity<String> singlePaymentQuery(String impUid, TokenResponseDTO tokenDTO) {
String url = "https://api.iamport.kr/payments/" + impUid;
//헤더 세팅
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", tokenDTO.getResponse().getAccess_token());
//바디에 담을 거
HttpEntity<String> entity = new HttpEntity<>(headers);
// RestTemplate 사용해서 GET 요청 전송.
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
return response;
}
}
결제단건조회가 일어난 후 다음 코드가 실행되어야 하기 때문에, 비동기 방식인 WebClient 말고 RestTemplate 을 사용하였다.
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
이 코드로 인해, 결제단건조회가 실행되고, 그 응답결과가 response 라는 ResponseEntity<String> 타입 객체에 담기게 되었다.
그럼 이 응답결과(ResponseEntity<String> response)를 리턴해준다.
그럼 이제, 아래코드가 실행된다.
// 2-4) 일단, payments 테이블에 행을 삽입한다. 왜? 사후검증(결제됬어야 할 금액과 실제로 결제한 금액이 같은지 여부를 검증)여부와는 무관하게, 일단은 결제가 됬으니까.
// ㄱ. 포트원으로부터 온 HTTP응답의 body 가져오기
String responseBody = response.getBody();
// ㄴ. INSERT INTO PAYMENTS
insertPaymentsRowService.insertRow(memberId, responseBody);
response.getBody(); 라고 해서, ResponseEntity<String> 타입 객체에 대고 .getBody() 메서드를 호출하게 되면, ResponseEntity<String> 타입 객체 안에 들어있는 포트원 서버가 준 HTTP응답의 바디 부분이 String 타입으로 리턴되게 된다.
참고로, ResponseEntity<여기> 여기에 String 타입이었기 때문에, .getBody() 를 했을 때 String 타입이 반환되는 것이다.
그리고 나서, InsertPaymentRowService 라는 클래스(서비스계층)가 있는데, 이 안에 있는 insertRow 라는 메서드를 호출한다. 파라미터로는 "이전에 구해놨던 현재 HTTP요청을 한 구매자의 MEMBER테이블 id 컬럼값"과 "포트원 서버가 준 HTTP 응답의 body 부분을 String 타입으로 변환한 걸" 넘겨주고 있다.
insertRow 라는 메서드는 아래와 같이 생겼다.
넘겨받은 응답을 가지고, 데이터들을 꺼내서,PAYMENTS 테이블에 행을 삽입하는 코드이다.
@Slf4j
@RequiredArgsConstructor
@Service
public class InsertPaymentsRowService {
private final OrdersMapper ordersMapper;
private final PaymentsMapper paymentsMapper;
public void insertRow(Integer memberId, String responseBody ){
//member_id 는 memberId 를 그대로 넣으면 됨.
//responseBody 에 보면, merchant_uid 라는 게 있는데, 그걸 이용해서 order테이블에서 행을 조회해서, 그 행의 id컬럼값을 빼서 payment테이블의 order_id 컬럼의 값으로 하면 됨.
//responseBody에서 merchant_uid 꺼내기
try {
ObjectMapper objectMapper = new ObjectMapper();
JsonNode rootNode = objectMapper.readTree(responseBody);
JsonNode responseNode = rootNode.path("response");
//결제취소시 필요한 정보 3개 : amount, impUid, merchantUid + 혹시 몰라서, currency 까지 포함.
String impUid = responseNode.path("imp_uid").asText(); //포트원 거래 고유번호 : imp_088022300265
String merchantUid = responseNode.path("merchant_uid").asText(); //가맹점 주문번호. 내 프로젝트에서 결제건마다 부여한 고유번호로서 랜덤한 숫자로 구성함 : 57008833-33010
int amount = responseNode.path("amount").asInt(); //결제건의 결제금액 : 1
String currency = responseNode.path("currency").asText(); //결제통화 구분코드 (KRW..등) : KRW
//관리자 페이지에서 status 가 fail 이거나 cancelled 인 것만 보기 뭐 이런거 할 때 필요할 거 같아서.
String status = responseNode.path("status").asText(); //결제건의 결제상태 : paid(결제완료), ready(브라우저 창 이탈, 가상계좌 발급완료 미결제 상태), failed(신용카드 한도초과, 체크카드 잔액부족, 브라우저 창 종료, 취소버튼 클릭 등 결제실패상태)
//결제실패 (결제 자체가 성공된 적이 없음. 카드한도초과, 유효하지 않은 카드번호, 은행 거절 등)
String failReason = responseNode.path("fail_reason").asText(); // 결제실패 사유로, 결제상태가 결제실패(failed)가 아닐 경우 null로 표시됨 : null
Long failedAtLong = responseNode.path("failed_at").asLong(); //결제실패시각. 결제상태가 결제실패(failed)가 아닌 경우 0으로 표시됨. : 0
LocalDateTime failedAt = LocalDateTime.ofInstant(Instant.ofEpochSecond(failedAtLong), ZoneId.systemDefault());
String payMethod = responseNode.path("pay_method").asText(); //결제수단 구분코드 :
// card 외
// trans(실시간 계좌이체),
// vbank(가상계좌),
// phone(휴대폰소액결제),
// cultureland(컬쳐랜드상품권 (구)문화상품권),
// smatculture(스마트문상(게임 문화 상품권)),
// happymoney(해피머니),
// booknlif(도서문화상품권),
// culturegift(문화상품권)
// samsung(삼성페이),
// kakaopay(카카오페이)
// naverpay(네이버페이)
// payco(페이코)
// lpay(LPAY),
// ssgpay(SSG페이),
// tosspay(토스페이),
// applepay(애플페이),
// pinpay (핀페이)
// skpay(11pay(구.SKPay))
// wechat(위쳇페이),
// alipay(알리페이),
// unionpay(유니온페이),
// tenpay(텐페이)
// paysbuy(페이스바이)
// econtext(편의점 결제)
// molpay(MOL페이)
// point(베네피아 포인트 등 포인트 결제),
// paypal(페이팔)
// toss_brandpay(토스페이먼츠 브랜드페이)
// naverpay_card(네이버페이 - 카드)
// naverpay_point(네이버페이 - 포인트)
// 결제와 관련된 정보
String name = responseNode.path("name").asText(); // 결제건의 제품명 : \ub2f9\uadfc 10kg
Long paidAtLong = responseNode.path("paid_at").asLong(); //결제된 시각. 결제상태가 결제완료(paid)가 아닌 경우 0으로 표시됨. : 1709722069
LocalDateTime paidAt = LocalDateTime.ofInstant(Instant.ofEpochSecond(paidAtLong), ZoneId.systemDefault());
String receiptUrl = responseNode.path("receipt_url").asText(); //결제건의 매출전표 URL로 PG사 또는 결제수단에 따라 매출전표가 없을 수 있다. : https:\/\/iniweb.inicis.com\/receipt\/iniReceipt.jsp?noTid=StdpayCARDINIBillTst20240306194748374762
//startedAtLong : 1709722022L
//startedAt : 2024-03-06 10:47:02
Long startedAtLong = responseNode.path("started_at").asLong(); //결제건의 결제요청 시각 UNIX timestamp : 1709722022
LocalDateTime startedAt = LocalDateTime.ofInstant(Instant.ofEpochSecond(startedAtLong), ZoneId.systemDefault());
String userAgent = responseNode.path("user_agent").asText(); //구매자가 결제시 사용한 단말기의 UserAgent 문자열 : Mozilla\/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit\/605.1.15 (KHTML, like Gecko) Version\/16.6 Safari\/605.1.15
//구매자에 대한 정보- buyer 테이블 (확)
String buyerName = responseNode.path("buyer_name").asText(); //주문자 이름 : \ud3ec\ud2b8\uc6d0 \uae30\uc220\uc9c0\uc6d0\ud300
String buyerTel = responseNode.path("buyer_tel").asText(); //주문자 전화번호 : 010-1234-5678
String buyerAddr = responseNode.path("buyer_addr").asText(); //주문자 주소 : \uc11c\uc6b8\ud2b9\ubcc4\uc2dc \uac15\ub0a8\uad6c \uc0bc\uc131\ub3d9
String buyerPostcode = responseNode.path("buyer_postcode").asText(); //주문자 우편번호 : 123-456
String buyerEmail = responseNode.path("buyer_email").asText(); //주문자 이메일 : wowns590@naver.com
//카드 결제
String applyNum = responseNode.path("apply_num").asText(); //결제건의 신용카드 승인번호 : 31491375
String cardCode = responseNode.path("card_code").asText(); //결제건의 카드사 코드번호(금융결제원 표준코드번호) - 카드 결제건의 경우 : 366
String cardName = responseNode.path("card_name").asText(); //결제건의 카드사명 - 카드 결제건의 경우 : \uc2e0\ud55c\uce74\ub4dc
String cardNumber = responseNode.path("card_number").asText(); //카드번호 : 559410*********4
Integer cardQuota = responseNode.path("card-quota").asInt(); //할부개월 수 : 0
Integer cardType = responseNode.path("card-type").asInt(); //0이면 신용카드, 1이면 체크카드. 해당 정보를 제공하지 않는 일부 PG사의 경우 null로 응답됨.(ex : JTNet, 이니시스-빌링) : 1 내가 체크카드로 결제해서 그래
//실시간 계좌이체
String bankCode = responseNode.path("bank_code").asText(); //결제건의 은행 표준코드(금융결제원기준) - 실시간계좌이체 결제건의 경우 : null
String bankName = responseNode.path("bank_name").asText(); //결제건의 은행명 - 실시간계좌이체 결제건의 경우 : null
//가상계좌(virtual bank)
String vbankCode = responseNode.path("vbank_code").asText(); //결제건의 가상계좌 은행 표준코드(금융결제원기준) - 가상계좌 결제건의 경우 : null
Integer vbankDate = responseNode.path("vbank_date").asInt(); //결제건의 가상계좌 입금기한 - 가상계좌 결제 건의 경우 : 0
String vbankHolder = responseNode.path("vbank_holder").asText(); //결제건의 입금받을 가상계좌 입금주 - 가상계좌 결제건의 경우 : null
Long vbankIssuedAtLong = responseNode.path("vbank_issued_at").asLong(); //결제건의 가상계좌 생성시각 UNIX timestamp - 가상계좌 결제건의 경우 : 0
LocalDateTime vbankIssuedAt = LocalDateTime.ofInstant(Instant.ofEpochSecond(vbankIssuedAtLong), ZoneId.systemDefault());
String vbankNum = responseNode.path("vbank_num").asText(); //결제건의 입금받을 가상계좌 계좌번호 - 가상계좌 결제건의 경우 : null
String vbankName = responseNode.path("vbank_name").asText(); //결제건의 입금받을 가상계좌 은행명 - 가상계좌 결제건의 경우 : null
//쩌리
String customData = responseNode.path("custom_data").asText(); //결제 요청시 가맹점에서 전달한 추가정보(JSON string으로 전달) : null
String customerUid = responseNode.path("customer_uid").asText(); //결제건에 사용된 빌링키와 매핑되며 가맹점에서 채번하는 구매자의 결제수단 식별 고유번호 : null
String customerUidUsage = responseNode.path("customer_uid_usage").asText(); //결제처리에 사용된 구매자의 결제수단 식별 고유번호의 사용 구분코드 : null
String channel = responseNode.path("channel").asText(); //결제환경 구분코드 : pc
Boolean cashReceiptIssuedBoolean = responseNode.path("cash_receipt_issued").asBoolean(); //결제건의 현금영수증 발급 여부 : false
String cashReceiptIssued = String.valueOf(cashReceiptIssuedBoolean);
boolean escrowBoolean = responseNode.path("escrow").asBoolean(); //에스크로결제 여부 : false
String escrow = String.valueOf(escrowBoolean);
//PG사
String pgId = responseNode.path("pg_id").asText(); //PG사 상점아이디 : INIBillTst
String pgProvider = responseNode.path("pg_provider").asText(); //PG사 구분코드 : html5_inicis
String embPgProvider = responseNode.path("emb_pg_provider").asText(); //허브형 결제 PG사 구분코드 : null
String pgTid = responseNode.path("pg_tid").asText(); //PG사 거래번호 : StdpayCARDINIBillTst20240306194748374762
//결제취소와 관련 정보 (결제 성공했다가 취소되는 경우. 구매자의 요청에 의한 취소, 상품의 결함으로 인한 취소 등.)
int cancelAmount = responseNode.path("cancel_amount").asInt(); //결제건의 누적 취소금액 : 0
String cancelReason = responseNode.path("cancel_reason").asText(); //결제취소된 사유. 결제상태가 결제취소(cancelled)가 아닐경우 null 로 표시됨. : null
String cancelledAt = responseNode.path("cancelled_at").asText(); //결제취소된 시각. 결제상태가 결제취소(cancelled)가 아닐 경우 null 로 표시됨. : 0 ? 이거 왜 null 로 표시 안되어있지? 뭐가 들어올지 모르므로 String 으로 받아오자.
// "cancel_history":[], -- 결제건의 취소/부분취소 내역 : PaymentCancelAnnotation[]
// "cancel_receipt_urls":[], -- 더이상 사용하지 않는다고 한다. cancel_history 를 쓰도록 권장하고 있음.
JsonNode cancelHistoryNode = responseNode.path("cancel_history");
PaymentCancelAnnotation[] paymentCancelArr = objectMapper.treeToValue(cancelHistoryNode, PaymentCancelAnnotation[].class);
for (PaymentCancelAnnotation anno : paymentCancelArr) {
String pgTid2 = anno.getPgTid();
Integer amount1 = anno.getAmount();
Integer cancelledAt1 = anno.getCancelledAt();
String reason = anno.getReason();
String cancellationId = anno.getCancellationId();
String receiptUrl1 = anno.getReceiptUrl();
//cancel_history 테이블에 insert
}
//merchantUid 로 orders 테이블에서 행을 찾아서, 그 행의 id 컬럼값을 가져온다.
Integer orderId = ordersMapper.findOrderIdByMerchantUid(merchantUid);
paymentsMapper.insertPayment(memberId, orderId, impUid, merchantUid, amount, currency, status, failReason, failedAt, payMethod, name, paidAt, receiptUrl, startedAt, userAgent, buyerName, buyerTel, buyerAddr, buyerPostcode, buyerEmail, applyNum, cardCode, cardName,cardNumber ,cardQuota ,cardType , bankCode, bankName, vbankCode, vbankDate, vbankHolder, vbankIssuedAt, vbankNum, vbankName, customData, customerUid, customerUidUsage, channel, cashReceiptIssued, escrow, pgId, pgProvider, embPgProvider, pgTid, cancelAmount, cancelReason, cancelledAt);
//paymentsMapper.insertPayment(memberId);
} catch (JsonProcessingException e) {
log.error("SinglePaymentQueryService : JSON 파싱 중 오류 발생", e);
throw new JsonParsingException("InsertPaymentRowService : JSON 파싱 중 오류 발생");
}
}
}
void 타입 메서드이기 때문에, 반환되는 건 없다.
이제 드디어 사후검증하는 코드인
postValidationService.postValidation(response); 이다.
@Service
@Slf4j
@RequiredArgsConstructor
// @Transactional 쓰지 않는다. update 문 여러 개 있는 거 아님? 왜 안씀? 아니지. 지금 if else 조건문으로 쓰여져 있잖아. 즉, 어떠한 경우라도 단 하나의 update 쿼리만 발생하게 된다.
// 다시말해서, 하나의 update 쿼리이기 때문에, @Transactional 을 쓰지 않는다.
public class PostValidationService {
private final OrdersMapper ordersMapper;
private final InsertPaymentsRowService insertPaymentsRowService;
private final ProductsMapper productsMapper;
public void postValidation(ResponseEntity<String> response)throws JsonProcessingException {
log.info("response={}", response); // 아래와 같은 것들이 포트원으로부터 왔다.
//<200 OK OK,
// { -- HTTP응답의 body 시작
// "code":0,
// "message":null,
// "response":
// {
// "amount":1,
// "apply_num":"31491375",
// "bank_code":null,
// "bank_name":null,
// "buyer_addr":"\uc11c\uc6b8\ud2b9\ubcc4\uc2dc \uac15\ub0a8\uad6c \uc0bc\uc131\ub3d9",
// "buyer_email":"wowns590@naver.com",
// "buyer_name":"\ud3ec\ud2b8\uc6d0 \uae30\uc220\uc9c0\uc6d0\ud300",
// "buyer_postcode":"123-456",
// "buyer_tel":"010-1234-5678",
// "cancel_amount":0,
// "cancel_history":[],
// "cancel_reason":null,
// "cancel_receipt_urls":[],
// "cancelled_at":0,
// "card_code":"366",
// "card_name":"\uc2e0\ud55c\uce74\ub4dc",
// "card_number":"559410*********4",
// "card_quota":0,
// "card_type":1,
// "cash_receipt_issued":false,
// "channel":"pc",
// "currency":"KRW",
// "custom_data":null,
// "customer_uid":null,
// "customer_uid_usage":null,
// "emb_pg_provider":null,
// "escrow":false,
// "fail_reason":null,
// "failed_at":0,
// "imp_uid":"imp_088022300265",
// "merchant_uid":"57008833-33010",
// "name":"\ub2f9\uadfc 10kg",
// "paid_at":1709722069,
// "pay_method":"card",
// "pg_id":"INIBillTst",
// "pg_provider":"html5_inicis",
// "pg_tid":"StdpayCARDINIBillTst20240306194748374762",
// "receipt_url":"https:\/\/iniweb.inicis.com\/receipt\/iniReceipt.jsp?noTid=StdpayCARDINIBillTst20240306194748374762",
// "started_at":1709722022,
// "status":"paid",
// "user_agent":"Mozilla\/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit\/605.1.15 (KHTML, like Gecko) Version\/16.6 Safari\/605.1.15",
// "vbank_code":null,
// "vbank_date":0,
// "vbank_holder":null,
// "vbank_issued_at":0,
// "vbank_name":null,
// "vbank_num":null
// }
// }-- HTTP응답의 body 끝,
// [
// Date:"Wed, 06 Mar 2024 10:47:49 GMT",
// Content-Type:"application/json;
// charset=UTF-8",
// Content-Length:"1393",
// Connection:"keep-alive",
// Server:"Apache",
// X-Content-Type-Options:"nosniff",
// X-XSS-Protection:"1; mode=block",
// X-Frame-Options:"SAMEORIGIN"
// ]
// >
// 사후검증 계획은 아래와 같다.
// 1. 포트원으로부터 온 데이터 중, "merchant_uid":"57008833-33010" 이거를 꺼내서,
// 내 DB의 orders 테이블에 있는 merchant_uid 컬럼의 값이 57008833-33010 인 행을 조회한다.
// 2. 조회된 행의 amount 컬럼의 값은 해당 주문이 결제했어야 하는 금액을 넣어두었다.
// 3. 따라서, 포트원으로부터 온 데이터 중 amount 와 방금 조회된 행의 amount 컬럼값이 일치하는지 검사한다.
// 4. 같다면, 사후검증 통과.
// 사후검증 시작.
// 1. 포트원으로부터 온 HTTP응답 메세지 바디 가져오기
String responseBody = response.getBody();
int amount = 0;
Integer findAmount = 0;
String merchantUid = "";
try {
// 2.포트원이 보내준 데이터 중 amount 와 merchant_uid 를 파싱.
// 그러기 위해서, 포트원으로부터 온 HTTP응답을 보면, 크게 code, message, response 이렇게 3가지로 구성되어 있는데, 이 중 response 를 파헤친다.
// 2-1) jackson 라이브러리의 ObjectMapper 인스턴스 생성.
// 왜? ObjectMapper 는 유연해서, 'JSON 형식'의 '문자열'을 파싱해서 JsonNode 타입 객체로 변환해줄 수 있다.
// JsonNode 타입 객체를 알면, .path 메서드를 통해 쉽게 JSON 데이터에 접근할 수 있게된다.
ObjectMapper objectMapper = new ObjectMapper();
JsonNode rootNode = objectMapper.readTree(responseBody);
JsonNode responseNode = rootNode.path("response");
// 2-2) 포트원이 나에게 보내준 HTTP응답메세지 중 얻고 싶었던 데이터인 amount 와 merchantUid 를 얻었다. 얻어오는 과정을 보니, JsonNode 타입 객체는 JSON 데이터를 계층적으로 구분해 놓는다고 예상됨.
amount = responseNode.path("amount").asInt();
merchantUid = responseNode.path("merchant_uid").asText();
// 3. 내 데이터베이스에서 파싱해서 얻은 정보 중 merchantUid 와 동일한 merchant_uid 컬럼값을 지닌 행의 amount 를 조회
// SELECT amount FROM orders
// WHERE merchant_uid = #{merchantUid}
findAmount = ordersMapper.findAmountByMerchantUid(merchantUid);
// 4. 사후검증 판단
if (amount == findAmount) {
// 4-1) 일치하는 경우
// ORDERS 테이블의 status 컬럼값을 Paid 로 바꾸어주면 된다. 이때, Paid 의 의미는 이니시스에서의 결제는 성공적으로 처리되었으며, 사후검증도 통과했다는 의미로 정의한다.
ordersMapper.updateStatusByMerchantUid(merchantUid,"Paid");
// PAYMENTS 테이블의 status 컬럼값을 바꾸어주어야 하는지에 대하여 생각해봤다. 결론 : 건들지 않는다.
// 왜? payments 테이블의 status 컬럼값과 fail_reason 컬럼값을 포함한 모든 컬럼값은 결제단건조회한 결과 그대로를 저장해두는 것이 옳다고 생각한다. 즉, payments 테이블 = 포트원 결제단건조회 결과
} else {
// 4-2) 일치하지 않는 경우
// 예외를 터뜨린다. 내가 만든 RuntimeException 인 PaymentVerificationException 을 터뜨려 줄 것이다.
throw new PaymentVerificationException("SinglePaymentQueryService : 결제 검증 실패 - 결제금액 불일치");
}
} catch (PaymentVerificationException e) {
// (사후검증에 통과하지 못한 경우) == (결제됬어야 하는 금액과 실제 결제된 금액이 불일치 하는 경우)
log.error("SinglePaymentQueryService : 사후검증 중 결제금액 불일치", e);
Integer priceDifference = findAmount - amount;
if(priceDifference >0){
// 사용자가 덜 지불한 경우
ordersMapper.updateStatusByMerchantUid(merchantUid, "POST_VALIDATION_FAILED_MINUS");
// payments 테이블에도 status 를 fail 로 해둬야할까?
// payments 테이블의 status 컬럼값과 fail_reason 컬럼값을 포함한 모든 컬럼값은 결제단건조회한 결과 그대로를 저장해두는 것이 옳다고 생각한다. 즉, payments 테이블 = 포트원 결제단건조회 결과
ordersMapper.updateFailReasonByMerchantUid(merchantUid, "고객님이 " + priceDifference + "원 더 결제하셔야 합니다.");
throw new PaymentVerificationException(amount, findAmount, priceDifference);
} else if (priceDifference < 0) {
// 사용자가 더 지불한 경우
ordersMapper.updateStatusByMerchantUid(merchantUid, "POST_VALIDATION_FAILED_PLUS");
ordersMapper.updateFailReasonByMerchantUid(merchantUid, "고객님이" + priceDifference + "원 더 결제하셨습니다.");
throw new PaymentVerificationException(amount, findAmount, priceDifference);
}
} catch (JsonProcessingException e) {
// JsonNode rootNode = objectMapper.readTree(responseBody); 이거 하다가 발생할 수 있는 예외를 처리해줘야 함.
// 컨트롤러로 다시 예외를 발생시키도록 했음. 왜? 이렇게 잡고 다시 던지면, 로그를 남길 수 있잖아. 그래서, throws 로 던질수도 있었는데, 그렇게 안함.
log.error("SinglePaymentQueryService : JSON 파싱 중 오류 발생", e);
throw e;
}
}
}
우선 주석이 써두었듯이, 사후검증을 어떻게 할 계획이냐면,
// 1. 포트원으로부터 온 데이터 중, "merchant_uid":"57008833-33010" 이거를 꺼내서,
// 내 DB의 orders 테이블에 있는 merchant_uid 컬럼의 값이 57008833-33010 인 행을 조회한다.
// 2. 조회된 행의 amount 컬럼의 값은 해당 주문이 결제했어야 하는 금액을 넣어두었다.
// 3. 따라서, 포트원으로부터 온 데이터 중 amount 와 방금 조회된 행의 amount 컬럼값이 일치하는지 검사한다.
// 4. 같다면, 사후검증 통과.
이렇게 할 생각이었다.
그래서, 포트원으로부터 온 데이터 중 merchant_uid 를 뽑아오려고 하였다.
String responseBody = response.getBody();
이렇게 일단, ResponseEntity<String> 타입 객체에 대고 .getBody() 를 호출해서 String 타입으로 HTTP 응답 메세지의 바디부분을 추출하였다.
문제는 이 String 타입 객체에서 어떻게 merchant_id 만 뽑아올 수 있을까? 였다.
왜냐하면, 이 String 타입객체에는 아래와 같은 문자열이 들어있을 것이기 때문이었다.
//<200 OK OK,
// { -- HTTP응답의 body 시작
// "code":0,
// "message":null,
// "response":
// {
// "amount":1,
// "apply_num":"31491375",
// "bank_code":null,
// "bank_name":null,
// "buyer_addr":"\uc11c\uc6b8\ud2b9\ubcc4\uc2dc \uac15\ub0a8\uad6c \uc0bc\uc131\ub3d9",
// "buyer_email":"wowns590@naver.com",
// "buyer_name":"\ud3ec\ud2b8\uc6d0 \uae30\uc220\uc9c0\uc6d0\ud300",
// "buyer_postcode":"123-456",
// "buyer_tel":"010-1234-5678",
// "cancel_amount":0,
// "cancel_history":[],
// "cancel_reason":null,
// "cancel_receipt_urls":[],
// "cancelled_at":0,
// "card_code":"366",
// "card_name":"\uc2e0\ud55c\uce74\ub4dc",
// "card_number":"559410*********4",
// "card_quota":0,
// "card_type":1,
// "cash_receipt_issued":false,
// "channel":"pc",
// "currency":"KRW",
// "custom_data":null,
// "customer_uid":null,
// "customer_uid_usage":null,
// "emb_pg_provider":null,
// "escrow":false,
// "fail_reason":null,
// "failed_at":0,
// "imp_uid":"imp_088022300265",
// "merchant_uid":"57008833-33010",
// "name":"\ub2f9\uadfc 10kg",
// "paid_at":1709722069,
// "pay_method":"card",
// "pg_id":"INIBillTst",
// "pg_provider":"html5_inicis",
// "pg_tid":"StdpayCARDINIBillTst20240306194748374762",
// "receipt_url":"https:\/\/iniweb.inicis.com\/receipt\/iniReceipt.jsp?noTid=StdpayCARDINIBillTst20240306194748374762",
// "started_at":1709722022,
// "status":"paid",
// "user_agent":"Mozilla\/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit\/605.1.15 (KHTML, like Gecko) Version\/16.6 Safari\/605.1.15",
// "vbank_code":null,
// "vbank_date":0,
// "vbank_holder":null,
// "vbank_issued_at":0,
// "vbank_name":null,
// "vbank_num":null
// }
// }-- HTTP응답의 body 끝,
// [
// Date:"Wed, 06 Mar 2024 10:47:49 GMT",
// Content-Type:"application/json;
// charset=UTF-8",
// Content-Length:"1393",
// Connection:"keep-alive",
// Server:"Apache",
// X-Content-Type-Options:"nosniff",
// X-XSS-Protection:"1; mode=block",
// X-Frame-Options:"SAMEORIGIN"
// ]
// >
그래서, 포트원이 준 HTTP 응답 중 merchant_uid 를 꺼내기 위해서, 아래와 같이 ObjectMapper(); 와 JsonNode 를 사용하였다.
merchant_uid 뿐만 아니라, amount 값도 꺼내왔는데, 이는 이 값과 내 ORDERS 테이블의 amount 값(해당 주문건에서 결제됬어야 할 금액) 을 비교하기 위해서이다.
// 2.포트원이 보내준 데이터 중 amount 와 merchant_uid 를 파싱.
// 그러기 위해서, 포트원으로부터 온 HTTP응답을 보면, 크게 code, message, response 이렇게 3가지로 구성되어 있는데, 이 중 response 를 파헤친다.
// 2-1) jackson 라이브러리의 ObjectMapper 인스턴스 생성.
// 왜? ObjectMapper 는 유연해서, 'JSON 형식'의 '문자열'을 파싱해서 JsonNode 타입 객체로 변환해줄 수 있다.
// JsonNode 타입 객체를 알면, .path 메서드를 통해 쉽게 JSON 데이터에 접근할 수 있게된다.
ObjectMapper objectMapper = new ObjectMapper();
JsonNode rootNode = objectMapper.readTree(responseBody);
JsonNode responseNode = rootNode.path("response");
// 2-2) 포트원이 나에게 보내준 HTTP응답메세지 중 얻고 싶었던 데이터인 amount 와 merchantUid 를 얻었다. 얻어오는 과정을 보니, JsonNode 타입 객체는 JSON 데이터를 계층적으로 구분해 놓는다고 예상됨.
amount = responseNode.path("amount").asInt();
merchantUid = responseNode.path("merchant_uid").asText();
그 다음에 아래 코드처럼 얻어낸 merchantUid를 가지고 ORDERS 테이블에서 그 merchantUid 값을 가진 행의 amount(가격)을 찾아왔다. 즉, 해당 주문건에서 결제됬어야 할 금액을 찾아온 것이다.
// 3. 내 데이터베이스에서 파싱해서 얻은 정보 중 merchantUid 와 동일한 merchant_uid 컬럼값을 지닌 행의 amount 를 조회
// SELECT amount FROM orders
// WHERE merchant_uid = #{merchantUid}
findAmount = ordersMapper.findAmountByMerchantUid(merchantUid);
두 값("ORDERS 테이블에 저장된 해당 주문건에서 결제됬어야 할 금액" 과 "포트원서버에서 결제되었다고 전달해준 amount 값")이 일치한다면, ORDERS 테이블에서 해당행의 status 컬럼값을 Paid 로 바꾸도록 해주었다. 결제되었다는 의미이다.
그리고 일치하지 않는 경우, 즉 결제됬어야 할 금액과 실제로 결제한 금액이 일치하지 않는다면, PaymetnVerificationException 이라는 예외가 터지도록 하였다.
// 4. 사후검증 판단
if (amount == findAmount) {
// 4-1) 일치하는 경우
// ORDERS 테이블의 status 컬럼값을 Paid 로 바꾸어주면 된다. 이때, Paid 의 의미는 이니시스에서의 결제는 성공적으로 처리되었으며, 사후검증도 통과했다는 의미로 정의한다.
ordersMapper.updateStatusByMerchantUid(merchantUid,"Paid");
// PAYMENTS 테이블의 status 컬럼값을 바꾸어주어야 하는지에 대하여 생각해봤다. 결론 : 건들지 않는다.
// 왜? payments 테이블의 status 컬럼값과 fail_reason 컬럼값을 포함한 모든 컬럼값은 결제단건조회한 결과 그대로를 저장해두는 것이 옳다고 생각한다. 즉, payments 테이블 = 포트원 결제단건조회 결과
} else {
// 4-2) 일치하지 않는 경우
// 예외를 터뜨린다. 내가 만든 RuntimeException 인 PaymentVerificationException 을 터뜨려 줄 것이다.
throw new PaymentVerificationException("SinglePaymentQueryService : 결제 검증 실패 - 결제금액 불일치");
}
PaymetnVerificationException 예외는 내가 만든 예외클래스로 만든 예외객체이다.
아래와 같이 생겼다.
public class PaymentVerificationException extends RuntimeException {
private Integer portOneAmount; // 실제 결제한 금액
private Integer myDbAmount; // 결제했어야 할 db 내 금액
private Integer priceDifference; // 둘의 가격차이
public PaymentVerificationException() {
}
public PaymentVerificationException(String message) {
super(message);
}
public PaymentVerificationException(Integer portOneAmount, Integer myDbAmount, Integer priceDifference) {
this.portOneAmount = portOneAmount;
this.myDbAmount = myDbAmount;
this.priceDifference = priceDifference;
}
//getter
public Integer getPortOneAmount() {
return portOneAmount;
}
public Integer getMyDbAmount() {
return myDbAmount;
}
public Integer getPriceDifference() {
return priceDifference;
}
}
catch 문은 아래와 같이 2개가 있다.
일단, PaymentVerificationException 이 발생했다는 건, 결제됬어야 할 금액과 실제로 결제된 금액이 일치하지 않아 사후검증에 걸린경우를 의미한다.
일단, 로그를 찍어줬다.
그리고 Integer priceDifference = findAmount - amount;
이렇게 두 값을 뺏다.
그리고, 이 값이 양수라면 사용자가 덜 지불한 경우이므로, ORDERS 테이블에 해당 행의 status 컬럼값으로POST_VALIDATION_FAILED_MINUS 라고 넣었다.
그리고선, ORDERS 테이블의 해당 행의 fail_reason 컬럼값을 예를 들어, "최재준 고객님이 2000원 더 결제하셔야 합니다." 이런식으로 삽입되도록 하였다.
그리고선, PaymentVerificationException 을 한번 더 발생시켰는데, 이 인스턴스를 만들 때에, 파라미터로 실제로 결제된 가격(amount) , 결제될거라고 저장해뒀던 가격(findAmount), 두 가격의 차이(priceDifference) 를 넘겨줬다.
priceDifference 값이 음수라면 사용자가 더 지불한 경우이므로, ORDERS 테이블의 해당 행의 status 값을 POST_VALIDATION_FAILED_PLUS 로 두었고, ORDERS 테이블의 fail_reason 컬럼값을 업데이트 시켜주었다.
그리고선, PaymentVerificationException 타입 객체를 다시 발생시켜주면서 amount, findAmount, priceDifference 를 담아주었다.
JsonProcessingException 은 ObjectMapper 타입 객체에 대고 .readTree 메서드를 호출하게 되면 발생할 수 있는 체크예외라서 잡고 다시 발생시켜줌.
} catch (PaymentVerificationException e) {
// (사후검증에 통과하지 못한 경우) == (결제됬어야 하는 금액과 실제 결제된 금액이 불일치 하는 경우)
log.error("SinglePaymentQueryService : 사후검증 중 결제금액 불일치", e);
Integer priceDifference = findAmount - amount;
if(priceDifference >0){
// 사용자가 덜 지불한 경우
ordersMapper.updateStatusByMerchantUid(merchantUid, "POST_VALIDATION_FAILED_MINUS");
// payments 테이블에도 status 를 fail 로 해둬야할까?
// payments 테이블의 status 컬럼값과 fail_reason 컬럼값을 포함한 모든 컬럼값은 결제단건조회한 결과 그대로를 저장해두는 것이 옳다고 생각한다. 즉, payments 테이블 = 포트원 결제단건조회 결과
ordersMapper.updateFailReasonByMerchantUid(merchantUid, "고객님이 " + priceDifference + "원 더 결제하셔야 합니다.");
throw new PaymentVerificationException(amount, findAmount, priceDifference);
} else if (priceDifference < 0) {
// 사용자가 더 지불한 경우
ordersMapper.updateStatusByMerchantUid(merchantUid, "POST_VALIDATION_FAILED_PLUS");
ordersMapper.updateFailReasonByMerchantUid(merchantUid, "고객님이" + priceDifference + "원 더 결제하셨습니다.");
throw new PaymentVerificationException(amount, findAmount, priceDifference);
}
} catch (JsonProcessingException e) {
// JsonNode rootNode = objectMapper.readTree(responseBody); 이거 하다가 발생할 수 있는 예외를 처리해줘야 함.
// 컨트롤러로 다시 예외를 발생시키도록 했음. 왜? 이렇게 잡고 다시 던지면, 로그를 남길 수 있잖아. 그래서, throws 로 던질수도 있었는데, 그렇게 안함.
log.error("SinglePaymentQueryService : JSON 파싱 중 오류 발생", e);
throw e;
}
그럼 다시 아래 컨트롤러로 리턴될 것이다.
@PostMapping("/payment-response")
@ResponseBody
public String paymentResponeControllerMethod(@RequestBody PaymentDTO paymentDTO, HttpServletRequest request) {
// 1. orders 테이블에 결제건에 해당하는 행의 status 컬럼을 Processing 이라고 바꿔줘야함.
// 여기서 Processing 이란, 주문에 대한 결제를 처리 중이다. 라는 뜻이다. 아직 사후검증(결제됬어야 할 금액과 실제로 결제한 금액이 같은지 여부를 검증)을 하기 전이니, 결제를 처리 중인게 맞지.
ordersMapper.updateStatusByMerchantUid(paymentDTO.getMerchant_uid(),"Processing");
// 2. 사후검증 시작
try {
// 2-1) Imp_uid 와 merchant_uid 를 꺼내오기.
String impUid = paymentDTO.getImp_uid();
String merchantUid = paymentDTO.getMerchant_uid();
// 2-2) access 토큰 얻어오기
TokenResponseDTO tokenDTO = accessToken.getAccessToken();
// 2-3) 얻은 토큰으로 결제단건조회를 함.
Integer memberId = FindLoginedMemberIdUtil.findLoginedMember(request);
ResponseEntity<String> response = singlePaymentQueryService.singlePaymentQuery(impUid, tokenDTO);
// 2-4) 일단, payments 테이블에 행을 삽입한다. 왜? 사후검증(결제됬어야 할 금액과 실제로 결제한 금액이 같은지 여부를 검증)여부와는 무관하게, 일단은 결제가 됬으니까.
// ㄱ. 포트원으로부터 온 HTTP응답의 body 가져오기
String responseBody = response.getBody();
// ㄴ. INSERT INTO PAYMENTS
insertPaymentsRowService.insertRow(memberId, responseBody);
// 2-5) 사후검증
postValidationService.postValidation(response);
//판매자들을 인도하는 페이지에서, 판매자들이 택배회사와 송장번호를 입력하면 그걸 받는 컨트롤러에서 orders 테이블의 status 컬럼값을 Shipped 로 변경하도록 해주면 됨.
//그리고 고객들이 보는 페이지에서는 payments 테이블의 status 컬럼이 아니라, orders 테이블의 status 컬럼값에 따라서, 표시가 되도록 하면 됨.
return "success";
} catch (PaymentVerificationException e) {
//사후검증에 실패한 경우 :
// orders 테이블의 status, fail_reason 컬럼을 실패했다고 업데이트 해주는 것은 SinglePaymentQueryService 에서 해줬기 때문에 그 과정 생략.
Integer portOneAmount = e.getPortOneAmount();
Integer myDbAmount = e.getMyDbAmount();
Integer priceDifference = e.getPriceDifference();
if (priceDifference > 0) {
//덜 결제된 경우
return "결제했어야 할 금액 : " + myDbAmount + ", 결제된 금액 : " + portOneAmount + ", " + priceDifference + "가 더 결제되어야 합니다. 최대한 빠른 시일 내에 당사 직원이 연락드리겠습니다. " ;
} else{
//더 결제된 경우 => 정상처리로 보고 정상처리를 해줘야지.
return "결제했어야 할 금액 : " + myDbAmount + ", 결제된 금액 : " + portOneAmount + ", " + priceDifference + "가 더 결제되었습니다. 최대한 빠른 시일 내에 당사 직원이 연락드리겠습니다." ;
}
} catch (JsonProcessingException e) {
//JSON 파싱에 실패한 경우
return "결제 중 오류가 발생되었습니다. JsonParsingException";
} catch(Exception e){
return "결제 중 오류가 발생되었습니다. Exception";
}
}
만약, 사전검증에 통과한 경우, "success" 라는 문자열이 리턴되도록 했고,
사전검증에 걸린 경우, 더 결제됬어야 했다면 얼마나 더 결제됬어야 하는지를 문자열로 리턴해줬다.
사전검증에 걸린 경우, 덜 결제됬어야 했다면 얼마나 덜 결제됬어야 했는지를 문자열로 리턴해줬다.
그럼 다시 orderSheet.jsp 파일로 가보자.
<head>
<link rel="stylesheet" href="../../css/payment/orderSheet.css">
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="https://cdn.iamport.kr/v1/iamport.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function() {
var IMP = window.IMP;
IMP.init("imp46526078");
// 폼 선택
const form = document.getElementById('order-form');
form.addEventListener('submit', function(event) { //submit(제출) 되면 이벤트 발생
event.preventDefault(); // 폼의 기본 제출 동작을 방지
// FormData 객체 생성
const formData = new FormData(form);
// fetch API를 사용하여 폼 데이터를 서버로 비동기적으로 전송
fetch('/order-process', {
method: 'POST',
body: formData, // 폼 데이터. , 끝에 붙이는 거 문제 안된다고 함. 오히려 개발자들이 선호한다고 함.
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json(); // 서버로부터 반환된 JSON 응답을 파싱. 이러면 아래 data 여기에 알아서 파싱된 json 이 담김. data.json키 를 쓰면, value 를 얻을 수 있지.
})
.then(data => {
if(data.result = 'success'){
//사전검증 시작
fetch('/pre-validation?productId=${productId}&merchantUid='+ data.merchantUid)
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
//사전검증이 끝나면 할 것들 : 포트원 api 통신
//일단, 재고부족인 경우 처리
if(data.outOfStock == true){
alert('재고가 부족하여 주문이 취소되었습니다.'); // alert창 띄우고,
window.location.href="/payment-home"; // paymentHome.jsp 로 인도하는 컨트롤러로 get요청.
}
// 재고가 있는 경우,
var merchantUid = data.merchantUid;
var productName = data.productName;
var amount = data.amount;
var email = data.memberTotalData.email;
var domain = data.memberTotalData.domain;
var emailaddress = email + '@' + domain;
var userName = data.memberTotalData.userName;
var phoneNumStart = data.memberTotalData.phoneNumStart;
var phoneNumMiddle = data.memberTotalData.phoneNumMiddle;
var phoneNumEnd = data.memberTotalData.phoneNumEnd;
var phone = phoneNumStart + phoneNumMiddle + phoneNumEnd;
var streetAddress = data.memberTotalData.streetAddress;
var zipCode = data.memberTotalData.zipCode;
//여기서, portone 시작
IMP.request_pay(
///////////////////////// 첫번째 ////////////////////////////////
{
pg: "html5_inicis",
pay_method: "card",
merchant_uid: merchantUid,
name: productName,
amount: amount,
buyer_email: emailaddress,
buyer_name: userName,
buyer_tel: phone,
buyer_addr: streetAddress,
buyer_postcode: zipCode,
},
////////////////////////두번째 //////////////////////////////////
function (rsp) {
if (rsp.success) {
// axios로 HTTP 요청
axios({
url: "/payment-response", // 실제 서버의 엔드포인트 주소로 수정
method: "post",
headers: { "Content-Type": "application/json" },
data: {
imp_uid: rsp.imp_uid,
merchant_uid: rsp.merchant_uid
}
})
.then((response) => {
// 서버 결제 API 성공 시 로직
if(response = 'success'){ // 성공한 경우
alert('결제가 성공하였습니다.');
window.location.href="/payment-home";
} else {
alert(response); // 그 외의 경우
window.location.href="/payment-home";
}
})
.catch((error) => {
console.error(error);
});
} else {
alert('결제에 실패하였습니다. 에러 내용 : ' + rsp.error_msg);
//결제에 실패한 경우, 재고 +1 해줘야 하고, orders테이블에서 행 삭제
window.location.href="/payment-fail?merchantUid=" + merchantUid + "&impUid=" + rsp.imp_uid;
}
}
);
//여기서, portone 끝
})
.catch(error => {
console.error('There has been a problem with your fetch operation:', error)
alert('결제 중 오류가 발생했습니다.');
});
//사전검증 끝
}
})
.catch(error => {
console.error('There has been a problem with your fetch operation:',
error);
});
});
});
</script>
</head>
위 코드 중 현재 어디에 있냐면, 아래 부분으로 온다.
.then((response) => {
// 서버 결제 API 성공 시 로직
if(response = 'success'){ // 성공한 경우
alert('결제가 성공하였습니다.');
window.location.href="/payment-home";
} else {
alert(response); // 그 외의 경우
window.location.href="/payment-home";
}
})
.catch((error) => {
console.error(error);
});
온 문자열이 success 라면, '결제가 성공하였습니다.' 라는 alert 창을 띄워주고, /payment-home 으로 get 요청하고 있다.
온 문자열이 success 가 아니면, 온 문자열을 그대로 alert 창으로 띄워주면 되겠지. 그리고, /payment-home 으로 get요청하고 있다.
참고로, /payment-home 이라는 url 로 get요청 보내면,
위페이지가 렌더링되도록 되어있다.
그 다음은 아래와 같은 코드가 있었다.
} else {
alert('결제에 실패하였습니다. 에러 내용 : ' + rsp.error_msg);
//결제에 실패한 경우, 재고 +1 해줘야 하고, orders테이블에서 행 삭제
window.location.href="/payment-fail?merchantUid=" + merchantUid + "&impUid=" + rsp.imp_uid;
}
구매자가 포트원 결제창을 그냥 닫았다거나 해서 결제가 진행되지 않았을 때, 포트원 서버가 보내준 error_msg 를 alert창으로 보여주는 것이다.
그리고 나서, /payment-fail 이라는 url 로 get요청을 보내게 된다. /payment-fail 이라는 url 을 매핑하는 컨트롤러는 아래와 같다.
@RequestMapping("/payment-fail")
public String paymentFail(@RequestParam String merchantUid, HttpServletRequest request, @RequestParam String impUid){
// 1. 결제자체가 실패한 경우이므로, 사전검증에서 감소시켰던 재고를 +1 해줘야 한다.
// 1-1) orders 테이블에서 해당 행의 productId 를 찾아라.
Integer productId = ordersMapper.findProductIdByMerchantUid(merchantUid);
// 1-2) 그 productId 를 가진 상품의 stock 컬럼을 1 증가시키자.
productsMapper.stockCountPlusByProductId(String.valueOf(productId), 1);
// 2. orders 테이블의 status 를 PAYMENT_FAILED 로 처리한다.
// orders 테이블에 해당 merchantUid 를 가진 행의 status컬럼값이 PAYMENT_FAILED 이면 결제자체가 실패 됬다는걸로 알면 됨.
ordersMapper.updateStatusByMerchantUid(merchantUid, "PAYMENT_FAILED");
// 3. payments 테이블의 행을 넣어준다.
// 결제가 성공했든, 결제가 (잔액부족, 카드정보오류등으로) 실패했든, 결제단건조회를 해서, 포트원으로부터 받은 정보 그대로를 payments 테이블에 저장해두도록 한다.
// 즉, payments 테이블 = 포트원 결제단건조회 결과
// 3-1) access 토큰 얻어오기
TokenResponseDTO tokenDTO = accessToken.getAccessToken();
// 3-2) 얻은 토큰으로 결제단건조회를 함.
Integer memberId = FindLoginedMemberIdUtil.findLoginedMember(request);
ResponseEntity<String> response = singlePaymentQueryService.singlePaymentQuery(impUid, tokenDTO);
// 3-3) payments 테이블에 행을 삽입한다.
// ㄱ. 포트원으로부터 온 HTTP응답의 body 가져오기
String responseBody = response.getBody();
// ㄴ. INSERT INTO PAYMENTS
insertPaymentsRowService.insertRow(memberId, responseBody);
return "/payment/paymentHome";
}
끝. 이제 orderSheet.jsp 파일은 끝났다.
근데, 이제 웹훅이 남았다. 다음 포스팅으로..