주문 결제 개선 및 결제취소 조회 인증 만료 처리

- 일반·구독 주문서(PC/모바일) 통화 라벨 표기, 통화 전환 시 첫 결제수단 자동 선택
- Stripe 이메일 자동 입력, 복귀 주소 설정 우선 적용, 진입 이력 GET 처리, 취소 안내·결제결과 회전 표시
- 일반 Stripe 상품명 전체 병 수 요약, 구독 주문은 구독서비스명을 PG에 전달
- 재고 부족 시 상품명·주문 수량·구매 가능 수량 안내
- 결제취소 조회 인증 만료 안내·로그인 이동, 조회 GET 전환, 중복 리다이렉트 종료
- 관련 Java·JS 테스트 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
이주일
2026-11-05 14:34:23 +09:00
parent fa3a84793f
commit 6ac836b5c6
27 changed files with 1069 additions and 102 deletions

View File

@@ -282,6 +282,9 @@ public class BOMaMtngService {
int useMlgAmt = Integer.parseInt(goodsList.get(0).getUseMlgAmt());
int useCashMlgAmt = Integer.parseInt(goodsList.get(0).getUseCashMlgAmt());
Set<String> validKeys = new HashSet<>(); // 변경 구매정보 리스트
InventoryShortageMessage inventoryMessage = new InventoryShortageMessage();
Set<String> validationKeys = Arrays.stream(PurchsValidCode.values())
.map(PurchsValidCode::getCode).collect(Collectors.toSet());
//마일리지 잔액 확인
BOMlgSrchVO mlgVo = new BOMlgSrchVO();
@@ -317,12 +320,14 @@ public class BOMaMtngService {
// 값이 1인 항목 == 변경항목
List<String> rs = resultValid.stream()
.flatMap(map -> map.entrySet().stream())
.filter(entry -> validationKeys.contains(entry.getKey()))
.filter(entry -> "1".equals(StringUtil.isNullToString(entry.getValue())))
.map(entry -> StringUtil.isNullToString(entry.getKey()))
.toList();
//구매정보 변경유형 저장
validKeys.addAll(rs);
inventoryMessage.add(goods, resultValid);
}
@@ -338,7 +343,9 @@ public class BOMaMtngService {
StringBuilder returnMsg = new StringBuilder();
//상품구매수량제한걸렸을 경우
if(validKeys.contains("purchsQyLmtt")){
if (inventoryMessage.hasShortages()) {
returnMsg.append(inventoryMessage.render());
}else if(validKeys.contains("purchsQyLmtt")){
returnMsg.append("구매수량 제한 상품입니다.");
returnMsg.append(System.lineSeparator()); //줄바꿈
@@ -519,6 +526,8 @@ public class BOMaMtngService {
BOPreOrderValidVO.builder()
.sopmalCd(StringUtil.isNullToString(goods.get("sopmalCd")))
.goodsInnb(StringUtil.isNullToString(goods.get("goodsInnb")))
.goodsNm(StringUtil.isNullToString(goods.get("goodsNm")))
.optionNm(StringUtil.isNullToString(goods.get("option")))
.goodsOptnInnb(StringUtil.isNullToString(goods.get("goodsOptnInnb")))
.subsvcInnb(StringUtil.isNullToString(goods.get("subsvcInnb")))
.mbrInnb(StringUtil.isNullToString(goods.get("mbrInnb")))

View File

@@ -0,0 +1,103 @@
package com.wb.domainserver.domain.admin.instcSle.mtng.service;
import com.wb.domainserver.domain.admin.instcSle.mtng.vo.BOPreOrderValidVO;
import com.wb.domainserver.util.dataaccess.DataMap;
import org.springframework.util.StringUtils;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** 재고 판정은 변경하지 않고 조회된 재고 수량으로 안내문만 구성한다. */
final class InventoryShortageMessage {
private final Map<String, Goods> goodsById = new LinkedHashMap<>();
void add(BOPreOrderValidVO request, List<DataMap> rows) {
if (rows.isEmpty()) {
return;
}
boolean subscription = StringUtils.hasText(request.getSubsvcInnb());
String key = request.getSopmalCd() + ":" + (subscription
? "subscription:" + request.getSubsvcInnb() : "goods:" + request.getGoodsInnb());
Goods goods = goodsById.computeIfAbsent(key, ignored -> new Goods());
goods.name = StringUtils.hasText(request.getGoodsNm()) ? request.getGoodsNm()
: "상품번호 " + request.getGoodsInnb();
goods.requested = request.getGoodsTotalQy();
goods.unit = "SK".equals(request.getSopmalCd()) ? "병" : "개";
String optionKey = subscription ? request.getSubsvcInnb() : request.getGoodsOptnInnb();
Option option = goods.options.computeIfAbsent(optionKey, ignored -> new Option());
option.name = StringUtils.hasText(request.getOptionNm()) ? request.getOptionNm()
: "옵션번호 " + request.getGoodsOptnInnb();
option.requested += Long.parseLong(request.getQy());
for (DataMap row : rows) {
if (row.get("stockGoodsNm") != null && StringUtils.hasText(row.get("stockGoodsNm").toString())) {
goods.name = String.valueOf(row.get("stockGoodsNm"));
}
goods.shortage |= "1".equals(String.valueOf(row.get("invntry")))
|| "1".equals(String.valueOf(row.get("invntryPolicy")));
option.available = minimum(option.available, row.get("optionAvailableQy"));
goods.available = minimum(goods.available, row.get("goodsAvailableQy"));
}
}
boolean hasShortages() {
return goodsById.values().stream().anyMatch(goods -> goods.shortage);
}
String render() {
StringBuilder message = new StringBuilder("다음 상품의 구매 가능한 재고가 부족합니다.");
boolean sharedLimit = false;
for (Goods goods : goodsById.values()) {
if (!goods.shortage) {
continue;
}
message.append("\n\n· ").append(goods.name);
if (goods.options.size() == 1) {
Option option = goods.options.values().iterator().next();
message.append("\n 주문 수량: ").append(goods.requested)
.append(goods.unit).append(" / 현재 구매 가능: ")
.append(quantity(minimum(option.available, goods.available), goods.unit));
} else {
sharedLimit = true;
message.append("\n 총 주문 수량: ").append(goods.requested)
.append(goods.unit).append(" / 상품 전체 재고 한도: ").append(quantity(goods.available, goods.unit));
for (Option option : goods.options.values()) {
message.append("\n - ").append(option.name).append(": 주문 ")
.append(option.requested).append(goods.unit).append(" / 옵션별 구매 가능 ")
.append(quantity(minimum(option.available, goods.available), goods.unit));
}
}
}
if (sharedLimit) {
message.append("\n\n옵션별 가능 수량은 상품 전체 재고 한도 안에서 함께 적용됩니다.");
}
return message.append("\n\n주문 수량을 줄이거나 해당 상품을 제외한 후 다시 주문해 주세요.").toString();
}
private static Long minimum(Long current, Object value) {
if (value == null) {
return current;
}
long quantity = Math.max(0L, Long.parseLong(value.toString()));
return current == null ? quantity : Math.min(current, quantity);
}
private static String quantity(Long value, String unit) {
return value == null ? "확인 불가" : value + unit;
}
private static final class Goods {
String name;
String unit;
long requested;
Long available;
boolean shortage;
final Map<String, Option> options = new LinkedHashMap<>();
}
private static final class Option {
String name;
long requested;
Long available;
}
}

View File

@@ -21,6 +21,10 @@ public class BOPreOrderValidVO {
/*상품고유번호*/
private String goodsInnb;
private String goodsNm;
private String optionNm;
/*상품옵션고유번호*/
private String goodsOptnInnb;

View File

@@ -7,6 +7,8 @@ import com.wb.domainserver.domain.gcone.bbs.cnslt.vo.BOCnsltVO;
import com.wb.domainserver.domain.gcone.common.service.BOCommonService;
import com.wb.domainserver.domain.gcone.goods.purchs.mapper.BOPurchsMapper;
import com.wb.domainserver.domain.gcone.goods.purchs.vo.*;
import com.wb.domainserver.domain.gcone.goods.subsvc.service.BOSubsvcService;
import com.wb.domainserver.domain.gcone.goods.subsvc.vo.BOSubsvcVO;
import com.wb.domainserver.domain.gcone.ordr.ordr.mapper.BOOrdrMapper;
import com.wb.domainserver.domain.gcone.ordr.ordr.vo.BOOrderRestAmtVO;
import com.wb.domainserver.exception.WnbtRestException;
@@ -51,6 +53,9 @@ public class BOOrderService {
@Autowired
private BOOrderNoService orderNoService;
@Autowired
private BOSubsvcService subsvcService;
/**
* 관리자 주문 > 주문 등록
* @param param
@@ -976,6 +981,10 @@ public class BOOrderService {
***************************************** */
BOOrderSetleVO setleVO = insertSetle(orderVO, setle, insertedGoodsList, enuri, purchsr); // 결제 정보 등록
if (orderVO.getSubsvcInnb() == null && "stripe".equals(setleVO.getPgCmpnyNm())) {
setleVO.setGoodsNm(buildStripeGoodsName(insertedGoodsList.get(0).getGoodsNm(), goodsList));
}
// 결제금액 원화로 변경
// setleVO.setSetleAmt(setleVO.getWonSetleAmt());
@@ -987,6 +996,13 @@ public class BOOrderService {
return content;
}
static String buildStripeGoodsName(String firstGoodsName, List<LinkedHashMap> goodsList) {
long totalQuantity = goodsList.stream()
.mapToLong(goods -> ((Number) goods.get("qy")).longValue())
.sum();
return totalQuantity > 1 ? firstGoodsName + " 외 " + (totalQuantity - 1) + "병" : firstGoodsName;
}
@Transactional
public void insertCnsltMemo(String sopmalCd, BigInteger ordrInnb, LinkedHashMap purchsr, LinkedHashMap enuri, AnonymousUser user) {
try{
@@ -1231,7 +1247,16 @@ public class BOOrderService {
String goodsNm = "";
if (insertedGoodsList.size() > 1) {
if (orderVO.getSubsvcInnb() != null) {
BOSubsvcVO subscription = new BOSubsvcVO();
subscription.setSopmalCd(sopmalCd);
subscription.setSubsvcInnb(orderVO.getSubsvcInnb().toString());
DataMap detail = subsvcService.selectBOSubsvcDetail(subscription);
goodsNm = detail == null ? "" : StringUtil.isNullToString(detail.get("subsvcNm"));
if (!StringUtils.hasText(goodsNm)) {
throw new WnbtRestException("구독서비스명을 확인할 수 없습니다. 다시 시도해 주세요.");
}
} else if (insertedGoodsList.size() > 1) {
goodsNm = insertedGoodsList.get(0).getGoodsNm() + " 외" + (insertedGoodsList.size() - 1);
} else {
goodsNm = insertedGoodsList.get(0).getGoodsNm();

View File

@@ -153,6 +153,8 @@ public class LoginController {
CookieUtils.deleteCookie("token_expire", "/", request, response);
response.sendRedirect("/sopmal/login/page/" + RealmContextHolder.getRealm() + "?redirect_uri=" + URLEncoder.encode(request.getParameter("redirect_uri"), StandardCharsets.UTF_8));
// 로그인 이동을 확정한 뒤 원래 URL로 다시 리다이렉트하지 않는다.
return;
}else{
//리프레쉬 토큰 있을 경우(존재한다는 자체가 refresh토큰이 만료된 상태가 아님)

View File

@@ -23,13 +23,18 @@ import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.util.UriComponentsBuilder;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import java.util.HashMap;
import java.util.Map;
@@ -47,6 +52,14 @@ public class StripeController {
private Util utilClass;
@Value("${stripe.checkout.return-base-url:}")
private String stripeReturnBaseUrl = "";
String resolveStripeReturnBaseUrl(String shopDomain) {
String baseUrl = stripeReturnBaseUrl.isBlank() ? shopDomain : stripeReturnBaseUrl.trim();
return baseUrl.replaceAll("/+$", "");
}
/**
* stripe 결제 Url 호출
* @return selectStripeTest.jsp
@@ -57,8 +70,7 @@ public class StripeController {
BOMaSopmalVO sopmalVO = new BOMaSopmalVO();
sopmalVO.setSopmalCd(request.getParameter("sopmalCd"));
DataMap sopmalInfo = boMaSopmalService.selectBOSopmalUpdtForm(sopmalVO).get(0);
String domnAdres = sopmalInfo.get("pcDomnAdres").toString();
// domnAdres = "http://loc.soju1sake1.com:18500";
String domnAdres = resolveStripeReturnBaseUrl(sopmalInfo.get("pcDomnAdres").toString());
StripeClient stripeClient = payService.createStripeClient(request.getParameter("sopmalCd"));
SessionCreateParams.Builder paramsBuilder = SessionCreateParams.builder()
@@ -71,6 +83,11 @@ public class StripeController {
.build()
);
String customerEmail = request.getParameter("ordrrEmail");
if (customerEmail != null && !customerEmail.isBlank()) {
paramsBuilder.setCustomerEmail(customerEmail.trim());
}
paramsBuilder.addLineItem(
SessionCreateParams.LineItem.builder()
.setQuantity(1L)
@@ -149,6 +166,10 @@ public class StripeController {
DataMap result = new DataMap();
result.put("checkoutUrl", session.getUrl());
result.put("redirectUrl", UriComponentsBuilder.fromPath(request.getContextPath() + "/sopmal/stripe/checkoutRedirect")
.queryParam("sessionId", session.getId())
.queryParam("sopmalCd", request.getParameter("sopmalCd"))
.build().encode().toUriString());
WnbtResponseBody body = new WnbtResponseBody(result);
WnbtResponse response = WnbtResponse.builder().resBody(body).build();
@@ -156,6 +177,23 @@ public class StripeController {
return ResponseEntity.ok(response);
}
/** Stripe 진입 이력을 GET으로 남겨 뒤로가기 시 주문 POST를 재전송하지 않는다. */
@Policy(AuthPolicy.RESTRICTED)
@GetMapping("/checkoutRedirect")
public String checkoutRedirect(ModelMap model, @RequestParam("sessionId") String sessionId,
@RequestParam("sopmalCd") String sopmalCd) throws StripeException {
Session session = payService.createStripeClient(sopmalCd).v1().checkout().sessions().retrieve(sessionId);
AnonymousUser user = MemberJwtContextHolder.getMemberInfo();
if (!String.valueOf(user.getUserInnb()).equals(session.getMetadata().get("mbrInnb"))
|| !sopmalCd.equals(session.getMetadata().get("sopmalCd"))) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
model.addAttribute("checkoutUrl", "open".equals(session.getStatus()) ? session.getUrl() : "");
model.addAttribute("sessionId", sessionId);
model.addAttribute("sopmalCd", sopmalCd);
return "sopmal/ordr/StripeRedirectR";
}
@Policy(AuthPolicy.RESTRICTED)
@RequestMapping(value = "/stripeSuccess")
public String stripeSuccess(ModelMap model, @RequestParam("sessionId") String sessionId, @RequestParam("sopmalCd") String sopmalCd) throws StripeException {
@@ -214,12 +252,16 @@ public class StripeController {
@Policy(AuthPolicy.RESTRICTED)
@RequestMapping(value = "/stripeCancel")
public String stripeCancel(ModelMap model, @RequestParam("sessionId") String sessionId, @RequestParam("sopmalCd") String sopmalCd) throws StripeException {
public String stripeCancel(HttpServletRequest request, ModelMap model, @RequestParam("sessionId") String sessionId, @RequestParam("sopmalCd") String sopmalCd) throws StripeException {
StripeClient stripeClient = payService.createStripeClient(sopmalCd);
Session session = stripeClient.v1().checkout().sessions().retrieve(sessionId);
String status = session.getStatus();
String paymentStatus = session.getPaymentStatus();
if (!("complete".equals(status) && "paid".equals(paymentStatus))) {
model.addAttribute("mobile", "sopmal_m".equals(Util.getDeviceType(request)));
return "sopmal/ordr/StripeCancelR";
}
JsonObject customerDetails = null;
JsonObject rawJson = session.getRawJsonObject();
if (rawJson != null
@@ -252,6 +294,6 @@ public class StripeController {
model.addAttribute("sesionId", session.getMetadata().get("sesionId"));
model.addAttribute("subsvcInnb", session.getMetadata().get("subsvcInnb"));
return "sopmal/ordr/PayStripeCnclC";
return "sopmal/ordr/PayStripeSucsC";
}
}

View File

@@ -402,6 +402,9 @@
WHEN (IFNULL(GOODS.REST_INVNTRY_QY, 0) - IFNULL(GOODS.AVLINV_LMTT_QY, 0)) >= #{goodsTotalQy} THEN 0
ELSE 1 END
END AS INVNTRY_POLICY -- 상품별 옵션 수량합산 재고유무
, MAX(GOODS.GOODS_NM) AS STOCK_GOODS_NM
, MIN(GREATEST(0, IFNULL(OPTN.INVNTRY_QY, 0) - IFNULL(OPTN.USEFUL_INVNTRY_QY, 0))) AS OPTION_AVAILABLE_QY
, MIN(GREATEST(0, IFNULL(GOODS.REST_INVNTRY_QY, 0) - IFNULL(GOODS.AVLINV_LMTT_QY, 0))) AS GOODS_AVAILABLE_QY
, CASE WHEN IFNULL(OPTN.SLEPRIC, 0) = #{slepric} THEN 0 ELSE 1 END AS SELPRIC -- 판매가 변경여부
, CASE
WHEN OPTN.COMPNB_DSCNT_AT = #{compnbDscntAt} AND OPTN.SCTN_DSCNT_AT = #{sctnDscntAt} THEN 0
@@ -430,6 +433,7 @@
, IFNULL(EVT_DUPL_COUPON.DPLCT_LMTT_COUPON, 0) AS DPLCT_LMTT_COUPON -- 이벤트 중복제한 쿠폰 존재여부
, CASE WHEN OPTN.OPTN_EXPSR_AT = 'Y' THEN 0 ELSE 1 END AS OPTN_EXPSR_AT
FROM (SELECT GG.GOODS_INNB -- 상품 고유번호
, GG.GOODS_NM
, GG.SOPMAL_CD -- 쇼핑몰코드
, GG.DLVY_GROUP_INNB -- 배송그룹고유번호
, GG.GOODS_KND_CD -- 상품구분 01 상품
@@ -801,6 +805,9 @@
ELSE 1
END
END AS INVNTRY_POLICY, -- 상품별 옵션 수량합산 재고유무
MAX(GOODS.GOODS_NM) AS STOCK_GOODS_NM,
MIN(GREATEST(0, GOODS.INVNTRY_QY - IFNULL(GOODS.USEFUL_INVNTRY_QY, 0))) AS OPTION_AVAILABLE_QY,
MIN(GREATEST(0, IFNULL(GOODS.REST_INVNTRY_QY, 0) - IFNULL(GOODS.AVLINV_LMTT_QY, 0))) AS GOODS_AVAILABLE_QY,
CASE
WHEN IFNULL(GOODS.SUBSVC_CT, 0) = #{slepric} THEN 0
ELSE 1
@@ -817,6 +824,7 @@
FROM (
SELECT
GS.SUBSVC_INNB, -- 구독서비스 고유번호
GG.GOODS_NM,
GG.GOODS_INNB, -- 상품 고유번호
GS.SOPMAL_CD, -- 쇼핑몰코드
GS.SUBSVC_CT, -- 구독료

View File

@@ -0,0 +1,46 @@
/* 결제취소 화면 조회 전용. 실제 취소/환불 요청은 처리하지 않는다. */
async function fn_load_refund_popup(url, data, onReady) {
const requestUrl = new URL(url, window.location.href);
Object.entries(data).forEach(([key, value]) => requestUrl.searchParams.set(key, value));
const loginRequired = function () {
window.alert('로그인 시간이 만료되었습니다. 다시 로그인해 주세요.');
const loginUrl = new URL('/sopmal/login', window.location.href);
// 로그인 후 AJAX 팝업 주소가 아닌 원래 주문 상세로 돌아온다.
loginUrl.searchParams.set('redirect_uri', window.location.href);
window.location.assign(loginUrl.href);
};
try {
// 갱신 후 리다이렉트에서도 주문 조회 조건이 유지되도록 GET으로 요청한다.
const response = await fetch(requestUrl.href, {
method: 'GET',
credentials: 'same-origin',
cache: 'no-store',
headers: {'X-Requested-With': 'XMLHttpRequest'}
});
const finalUrl = new URL(response.url || requestUrl.href, window.location.href);
if (response.status === 401 || (response.redirected &&
/^\/sopmal\/(login(?:\/|$)|logout(?:\/|$)|main(?:\/|$))/.test(finalUrl.pathname))) {
loginRequired();
return;
}
if (!response.ok || finalUrl.origin !== requestUrl.origin || finalUrl.pathname !== requestUrl.pathname) {
throw new Error('Unexpected refund popup response');
}
// 로그인 HTML이나 빈 응답은 화면에 넣지 않는다. 반환된 스크립트도 실행하지 않는다.
const html = await response.text();
const parsed = new DOMParser().parseFromString(html, 'text/html');
const popup = parsed.querySelector('#refund-pop');
if (!popup) {
throw new Error('Missing refund popup');
}
popup.querySelectorAll('script').forEach(script => script.remove());
$('#refund-pop').remove();
$('#refundPopupDiv').empty().append(popup);
onReady($(popup));
} catch (error) {
window.alert('결제취소 화면을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.');
}
}

View File

@@ -33,6 +33,7 @@
<c:set var="order" value="${data.order}"/>
<script src="<c:url value='/js/dlvylc/dlvylc.js'/>"></script>
<script src="<c:url value='/js/sopmal/refund-popup.js'/>"></script>
<script>
$(document).ready(function() {
@@ -664,21 +665,11 @@
return;
}
$.ajax({
type:'post',
url:'<c:url value="/sopmal/mpage/ordr/selectSetleCanclPopupForm"/>',
data:{
fn_load_refund_popup('<c:url value="/sopmal/mpage/ordr/selectSetleCanclPopupForm"/>', {
'sopmalCd':sopmalCd,
'ordrInnb':ordrInnb,
'ordrDlvyInnb':ordrDlvyInnb
},
success:function(html) {
$('#refund-pop').remove();
$('#refundPopupDiv').html(html);
const popup = $('#refund-pop');
}, function(popup) {
$(popup).find('.orderRefundPop .select-box select').niceSelect();
$(popup).find('#refundAcntnb').on("input", function () {
@@ -686,7 +677,6 @@
});
$(popup).modal();
}
});
}

View File

@@ -32,6 +32,7 @@
<jsp:include page="/WEB-INF/views/comm/Include.jsp"/>
<script src="<c:url value='/js/dlvylc/dlvylc.js'/>"></script>
<script src="<c:url value='/js/sopmal/refund-popup.js'/>"></script>
<script>
$(document).ready(function() {
@@ -659,21 +660,11 @@
return;
}
$.ajax({
type:'post',
url:'<c:url value="/sopmal/mpage/ordr/selectSetleCanclPopupForm"/>',
data:{
fn_load_refund_popup('<c:url value="/sopmal/mpage/ordr/selectSetleCanclPopupForm"/>', {
'sopmalCd':sopmalCd,
'ordrInnb':ordrInnb,
'ordrDlvyInnb':ordrDlvyInnb
},
success:function(html) {
$('#refund-pop').remove();
$('#refundPopupDiv').html(html);
const popup = $('#refund-pop');
}, function(popup) {
$(popup).find('.orderRefundPop .select-box select').niceSelect();
$(popup).find('#refundAcntnb').on("input", function () {
@@ -681,7 +672,6 @@
});
$(popup).modal();
}
});
}

View File

@@ -521,7 +521,7 @@
});
/* 결제정보 결제수단 버튼 선택 이벤트 */
$('input[name="pg"]').on('click', function() {
$('input[name="pg"]').on('click', function(event, skipDefaultMethod) {
const form = $(this).closest('#setleForm')
const id = $(this).attr('id');
const pgCmpnyNm = $(this).val();
@@ -582,6 +582,14 @@
$('.order-method-normal__btn').removeClass('active');
$('.order-method-normal__content').hide();
}
// 통화를 선택하면 사용 가능한 첫 결제수단을 기존 클릭 처리로 선택한다.
// 결제수단 버튼에서 라디오를 갱신할 때는 재귀 선택을 생략한다.
if (!skipDefaultMethod && ['st01', 'pg03', 'pg04', 'pg06'].includes(id)) {
$(form).find('#' + id + 'Content')
.find('.order-method-normal__btn:visible:not(:disabled)')
.first().trigger('click');
}
});
/* 결제정보 > 일반결제 > 결제수단 버튼 선택 이벤트 */
@@ -590,11 +598,8 @@
const content = $('.order-method-normal__content');
const useCashMlgAmt = $('#useCashMlgAmt').val();
//$('#setleSetupTyCd').val() === '01' ? $('#pg04').click() : $('#pg03').click();
if ($('#st01').is(':checked')){$('#st01').click();}
else if($('#setleSetupTyCd').val() === '01'){$('#pg04').click();}
else if($('#setleSetupTyCd').val() === '02'){$('#pg03').click();}
else if($('#setleSetupTyCd').val() === '03'){$('#pg06').click();}
const pgId = $(this).closest('.pgContent').attr('id').replace(/Content$/, '');
$('#' + pgId).trigger('click', [true]);
$('.order-method-normal__btn').removeClass('active');
$(this).addClass('active');
@@ -4247,7 +4252,7 @@
<c:if test="${'Y' eq payInfo.stripeUseAt}">
<div class="custom-radio is-medium">
<input type="radio" name="pg" id="st01" value="stripe">
<label for="st01">엔화 결제</label>
<label for="st01">엔화(¥) 결제</label>
</div>
<div id="st01Content" class="pgContent order-method-normal" <%-- style="display: none"--%>>
<div class="order-method-normal__btn-container">
@@ -4263,7 +4268,7 @@
<c:if test="${'02' eq payInfo.setleSetupTyCd}">
<div class="custom-radio is-medium">
<input type="radio" name="pg" id="pg03" value="toss">
<label for="pg03">일반결제</label>
<label for="pg03">원화(₩) 결제</label>
</div>
<div id="pg03Content" class="pgContent order-method-normal" <%-- style="display: none"--%>>
<div class="order-method-normal__btn-container">
@@ -4395,7 +4400,7 @@
<c:if test="${'01' eq payInfo.setleSetupTyCd}">
<div class="custom-radio is-medium">
<input type="radio" name="pg" id="pg04" value="kcp">
<label for="pg04">일반결제</label>
<label for="pg04">원화(₩) 결제</label>
</div>
<div id="pg04Content" class="pgContent order-method-normal" <%-- style="display: none"--%>>
<div class="order-method-normal__btn-container">
@@ -4527,7 +4532,7 @@
<c:if test="${'03' eq payInfo.setleSetupTyCd}">
<div class="custom-radio is-medium">
<input type="radio" name="pg" id="pg06" value="kiwoom">
<label for="pg06">일반결제</label>
<label for="pg06">원화(₩) 결제</label>
</div>
<div id="pg06Content" class="pgContent order-method-normal" <%-- style="display: none"--%>>
<div class="order-method-normal__btn-container">

View File

@@ -36,7 +36,7 @@
success : function(res) {
console.log(res);
const data = JSON.parse(res.resBody.result);
window.location.href = data.checkoutUrl;
window.location.replace(data.redirectUrl);
}
});
});

View File

@@ -18,9 +18,51 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<title>결제 성공</title>
<title>결제 결과 확인 중</title>
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #f8fafc;
color: #202938;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.payment-progress {
padding: 32px 20px;
text-align: center;
}
.payment-progress__spinner {
width: 44px;
height: 44px;
margin: 0 auto 24px;
border: 4px solid #e2e8f0;
border-top-color: #0075d4;
border-radius: 50%;
animation: payment-spin 0.9s linear infinite;
}
.payment-progress h1 {
margin: 0 0 10px;
font-size: 20px;
line-height: 1.5;
word-break: keep-all;
}
.payment-progress p {
margin: 0;
color: #64748b;
font-size: 15px;
line-height: 1.6;
}
@keyframes payment-spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.payment-progress__spinner { animation: none; }
}
</style>
<script src="<c:url value='/pc/vendor/jquery-3.7.1.min.js'/>"></script>
<script>
$(document).ready(function() {
@@ -57,8 +99,10 @@
</script>
</head>
<body>
<section>
<h1>결제 성공</h1>
<section class="payment-progress" role="status" aria-live="polite">
<div class="payment-progress__spinner" aria-hidden="true"></div>
<h1>결제 결과를 확인하고 있습니다.</h1>
<p>잠시만 기다려 주세요.</p>
</section>
</body>
</html>

View File

@@ -0,0 +1,51 @@
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<c:choose>
<c:when test="${mobile}">
<jsp:include page="/WEB-INF/views/comm/mobile/Include.jsp"/>
</c:when>
<c:otherwise>
<jsp:include page="/WEB-INF/views/comm/Include.jsp"/>
</c:otherwise>
</c:choose>
<title>결제 중단 안내</title>
</head>
<body>
<div class="screen-wrap">
<c:choose>
<c:when test="${mobile}">
<header class="header header--sub">
<jsp:include page="/WEB-INF/views/comm/mobile/Header.jsp"/>
</header>
<jsp:include page="/WEB-INF/views/comm/mobile/TabBar.jsp"/>
</c:when>
<c:otherwise>
<jsp:include page="/WEB-INF/views/comm/Header.jsp"/>
</c:otherwise>
</c:choose>
<main class="main content-wrap">
<div class="sub-wrapper sub-page">
<div class="sub-content sub-box">
<div class="orderFailed">
<div class="orderFailed__inner">
<h3 class="orderFailed__title">결제를 중단하셨습니다.</h3>
<p class="orderFailed__text">이 결제창에서 결제를 완료하지 않았습니다.<br>주문/배송조회에서 주문 상태를 확인한 뒤 다시 결제해 주세요.</p>
</div>
<div class="sub-bottom-btn-container fullBtnSection">
<a class="btn xl outline" href="<c:url value='/sopmal/mpage/ordr/selectMbrOrdrListInqire'/>">주문/배송조회</a>
<a class="btn xl outline" href="<c:url value='/sopmal/main'/>">쇼핑 계속하기</a>
</div>
</div>
</div>
</div>
</main>
<c:if test="${!mobile}">
<jsp:include page="/WEB-INF/views/comm/Footer.jsp"/>
</c:if>
</div>
</body>
</html>

View File

@@ -0,0 +1,35 @@
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<c:url var="cancelUrl" value="/sopmal/stripe/stripeCancel">
<c:param name="sessionId" value="${sessionId}"/>
<c:param name="sopmalCd" value="${sopmalCd}"/>
</c:url>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>결제창으로 이동</title>
<script>
window.addEventListener('pageshow', function (event) {
const checkout = document.getElementById('stripeCheckout');
const cancel = document.getElementById('stripeCancel');
const navigation = performance.getEntriesByType('navigation')[0];
// 일반 복귀와 BFCache 복귀 모두 새 결제 세션을 만들지 않고 처리한다.
if (event.persisted || (navigation && navigation.type === 'back_forward')
|| (history.state && history.state.stripeRedirected)
|| !checkout.getAttribute('href')) {
window.location.replace(cancel.href);
return;
}
history.replaceState({stripeRedirected: true}, '', window.location.href);
window.location.assign(checkout.href);
});
</script>
</head>
<body>
<p>결제창으로 이동하고 있습니다.</p>
<a id="stripeCheckout" href="<c:out value='${checkoutUrl}'/>">결제창 열기</a>
<a id="stripeCancel" href="<c:out value='${cancelUrl}'/>">결제 중단</a>
</body>
</html>

View File

@@ -524,7 +524,7 @@
});*/
/* 결제정보 결제수단 버튼 선택 이벤트 */
$('input[name="pg"]').on('click', function() {
$('input[name="pg"]').on('click', function(event, skipDefaultMethod) {
const form = $(this).closest('#setleForm')
const id = $(this).attr('id');
const pgCmpnyNm = $(this).val();
@@ -585,6 +585,14 @@
$('.order-method-normal__btn').removeClass('active');
$('.order-method-normal__content').hide();
}
// 통화를 선택하면 사용 가능한 첫 결제수단을 기존 클릭 처리로 선택한다.
// 결제수단 버튼에서 라디오를 갱신할 때는 재귀 선택을 생략한다.
if (!skipDefaultMethod && ['st01', 'pg03', 'pg04', 'pg06'].includes(id)) {
$(form).find('#' + id + 'Content')
.find('.order-method-normal__btn:visible:not(:disabled)')
.first().trigger('click');
}
});
/* 결제정보 > 일반결제 > 결제수단 버튼 선택 이벤트 */
@@ -593,11 +601,8 @@
const content = $('.order-method-normal__content');
const useCashMlgAmt = $('#useCashMlgAmt').val();
//$('#setleSetupTyCd').val() === '01' ? $('#pg04').click() : $('#pg03').click();
if ($('#st01').is(':checked')){$('#st01').click();}
else if($('#setleSetupTyCd').val() === '01'){$('#pg04').click();}
else if($('#setleSetupTyCd').val() === '02'){$('#pg03').click();}
else if($('#setleSetupTyCd').val() === '03'){$('#pg06').click();}
const pgId = $(this).closest('.pgContent').attr('id').replace(/Content$/, '');
$('#' + pgId).trigger('click', [true]);
$('.order-method-normal__btn').removeClass('active');
$(this).addClass('active');
@@ -4213,7 +4218,7 @@
<c:if test="${'Y' eq payInfo.stripeUseAt}">
<div class="custom-radio is-medium">
<input type="radio" name="pg" id="st01" value="stripe">
<label for="st01">엔화 결제</label>
<label for="st01">엔화(¥) 결제</label>
</div>
<div id="st01Content" class="pgContent order-method-normal" <%-- style="display: none"--%>>
<div class="order-method-normal__btn-container">
@@ -4229,7 +4234,7 @@
<c:if test="${'02' eq payInfo.setleSetupTyCd}">
<div class="custom-radio is-medium">
<input type="radio" name="pg" id="pg03" value="toss">
<label for="pg03">일반결제</label>
<label for="pg03">원화(₩) 결제</label>
</div>
<div id="pg03Content" class="pgContent order-method-normal" <%-- style="display: none"--%>>
<div class="order-method-normal__btn-container">
@@ -4361,7 +4366,7 @@
<c:if test="${'01' eq payInfo.setleSetupTyCd}">
<div class="custom-radio is-medium">
<input type="radio" name="pg" id="pg04" value="kcp">
<label for="pg04">일반결제</label>
<label for="pg04">원화(₩) 결제</label>
</div>
<div id="pg04Content" class="pgContent order-method-normal" <%-- style="display: none"--%>>
<div class="order-method-normal__btn-container">
@@ -4493,7 +4498,7 @@
<c:if test="${'03' eq payInfo.setleSetupTyCd}">
<div class="custom-radio is-medium">
<input type="radio" name="pg" id="pg06" value="kiwoom">
<label for="pg06">일반결제</label>
<label for="pg06">원화(₩) 결제</label>
</div>
<div id="pg06Content" class="pgContent order-method-normal" <%-- style="display: none"--%>>
<div class="order-method-normal__btn-container">

View File

@@ -528,7 +528,8 @@
$(form).append($('<input/>', {type: 'hidden', name: 'sopmalCd', id: 'sopmalCd', value: sopmalCd}));
$(form).append($('<input/>', {type: 'hidden', name: 'ordrInnb', id: 'ordrInnb', value: ordrInnb}));
$(form).attr('method', 'post');
// 인증 갱신 후에도 조회 조건을 유지한다. 실제 취소 등록은 기존 POST를 사용한다.
$(form).attr('method', 'get');
$(form).attr('action', '<c:url value="/sopmal/mpage/ordr/selectSetleCanclPopupForm"/>');
$(form).submit();

View File

@@ -510,7 +510,8 @@
$(form).append($('<input/>', {type: 'hidden', name: 'sopmalCd', id: 'sopmalCd', value: sopmalCd}));
$(form).append($('<input/>', {type: 'hidden', name: 'ordrInnb', id: 'ordrInnb', value: ordrInnb}));
$(form).attr('method', 'post');
// 인증 갱신 후에도 조회 조건을 유지한다. 실제 취소 등록은 기존 POST를 사용한다.
$(form).attr('method', 'get');
$(form).attr('action', '<c:url value="/sopmal/mpage/ordr/selectSetleCanclPopupForm"/>');
$(form).submit();

View File

@@ -565,7 +565,7 @@
});
/* 결제정보 결제수단 버튼 선택 이벤트 */
$('input[name="pg"]').on('click', function() {
$('input[name="pg"]').on('click', function(event, skipDefaultMethod) {
const form = $(this).closest('#setleForm')
const id = $(this).attr('id');
const pgCmpnyNm = $(this).val();
@@ -632,6 +632,14 @@
$('.order-method-normal__btn').removeClass('active');
$('.order-method-normal__content').hide();
}
// 통화를 선택하면 사용 가능한 첫 결제수단을 기존 클릭 처리로 선택한다.
// 결제수단 버튼에서 라디오를 갱신할 때는 재귀 선택을 생략한다.
if (!skipDefaultMethod && ['st01', 'pg03', 'pg04', 'pg06'].includes(id)) {
$(form).find('#' + id + 'Content')
.find('.order-method-normal__btn:visible:not(:disabled)')
.first().trigger('click');
}
});
/* 결제정보 > 일반결제 > 결제수단 버튼 선택 이벤트 */
@@ -640,11 +648,8 @@
const content = $('.order-method-normal__content');
const useCashMlgAmt = $('#useCashMlgAmt').val();
//$('#setleSetupTyCd').val() === '01' ? $('#pg04').click() : $('#pg03').click();
if ($('#st01').is(':checked')){$('#st01').click();}
else if($('#setleSetupTyCd').val() === '01'){$('#pg04').click();}
else if($('#setleSetupTyCd').val() === '02'){$('#pg03').click();}
else if($('#setleSetupTyCd').val() === '03'){$('#pg06').click();}
const pgId = $(this).closest('.pgContent').attr('id').replace(/Content$/, '');
$('#' + pgId).trigger('click', [true]);
$('.order-method-normal__btn').removeClass('active');
$(this).addClass('active');
@@ -4330,7 +4335,7 @@
<c:if test="${'Y' eq payInfo.stripeUseAt}">
<div class="custom-radio is-medium">
<input type="radio" name="pg" id="st01" value="stripe">
<label for="st01">엔화 결제</label>
<label for="st01">엔화(¥) 결제</label>
</div>
<div id="st01Content" class="pgContent order-method-normal" <%-- style="display: none"--%>>
<div class="order-method-normal__btn-container">
@@ -4346,7 +4351,7 @@
<c:if test="${'02' eq payInfo.setleSetupTyCd}">
<div class="custom-radio is-medium">
<input type="radio" name="pg" id="pg03" value="toss">
<label for="pg03">일반결제</label>
<label for="pg03">원화(₩) 결제</label>
</div>
<div id="pg03Content" class="pgContent order-method-normal">
<div class="order-method-normal__btn-container">
@@ -4478,7 +4483,7 @@
<c:if test="${'01' eq payInfo.setleSetupTyCd}">
<div class="custom-radio is-medium">
<input type="radio" name="pg" id="pg04" value="kcp">
<label for="pg04">일반결제</label>
<label for="pg04">원화(₩) 결제</label>
</div>
<div id="pg04Content" class="pgContent order-method-normal" >
<div class="order-method-normal__btn-container">
@@ -4610,7 +4615,7 @@
<c:if test="${'03' eq payInfo.setleSetupTyCd}">
<div class="custom-radio is-medium">
<input type="radio" name="pg" id="pg06" value="kiwoom">
<label for="pg06">일반결제</label>
<label for="pg06">원화(₩) 결제</label>
</div>
<div id="pg06Content" class="pgContent order-method-normal" >
<div class="order-method-normal__btn-container">

View File

@@ -568,7 +568,7 @@
});*/
/* 결제정보 결제수단 버튼 선택 이벤트 */
$('input[name="pg"]').on('click', function() {
$('input[name="pg"]').on('click', function(event, skipDefaultMethod) {
const form = $(this).closest('#setleForm')
const id = $(this).attr('id');
const pgCmpnyNm = $(this).val();
@@ -635,6 +635,14 @@
$('.order-method-normal__btn').removeClass('active');
$('.order-method-normal__content').hide();
}
// 통화를 선택하면 사용 가능한 첫 결제수단을 기존 클릭 처리로 선택한다.
// 결제수단 버튼에서 라디오를 갱신할 때는 재귀 선택을 생략한다.
if (!skipDefaultMethod && ['st01', 'pg03', 'pg04', 'pg06'].includes(id)) {
$(form).find('#' + id + 'Content')
.find('.order-method-normal__btn:visible:not(:disabled)')
.first().trigger('click');
}
});
/* 결제정보 > 일반결제 > 결제수단 버튼 선택 이벤트 */
@@ -643,11 +651,8 @@
const content = $('.order-method-normal__content');
const useCashMlgAmt = $('#useCashMlgAmt').val();
//$('#setleSetupTyCd').val() === '01' ? $('#pg04').click() : $('#pg03').click();
if ($('#st01').is(':checked')){$('#st01').click();}
else if($('#setleSetupTyCd').val() === '01'){$('#pg04').click();}
else if($('#setleSetupTyCd').val() === '02'){$('#pg03').click();}
else if($('#setleSetupTyCd').val() === '03'){$('#pg06').click();}
const pgId = $(this).closest('.pgContent').attr('id').replace(/Content$/, '');
$('#' + pgId).trigger('click', [true]);
$('.order-method-normal__btn').removeClass('active');
$(this).addClass('active');
@@ -4319,7 +4324,7 @@
<c:if test="${'Y' eq payInfo.stripeUseAt}">
<div class="custom-radio is-medium">
<input type="radio" name="pg" id="st01" value="stripe">
<label for="st01">엔화 결제</label>
<label for="st01">엔화(¥) 결제</label>
</div>
<div id="st01Content" class="pgContent order-method-normal" <%-- style="display: none"--%>>
<div class="order-method-normal__btn-container">
@@ -4335,7 +4340,7 @@
<c:if test="${'02' eq payInfo.setleSetupTyCd}">
<div class="custom-radio is-medium">
<input type="radio" name="pg" id="pg03" value="toss">
<label for="pg03">일반결제</label>
<label for="pg03">원화(₩) 결제</label>
</div>
<div id="pg03Content" class="pgContent order-method-normal">
<div class="order-method-normal__btn-container">
@@ -4467,7 +4472,7 @@
<c:if test="${'01' eq payInfo.setleSetupTyCd}">
<div class="custom-radio is-medium">
<input type="radio" name="pg" id="pg04" value="kcp">
<label for="pg04">일반결제</label>
<label for="pg04">원화(₩) 결제</label>
</div>
<div id="pg04Content" class="pgContent order-method-normal" >
<div class="order-method-normal__btn-container">
@@ -4599,7 +4604,7 @@
<c:if test="${'03' eq payInfo.setleSetupTyCd}">
<div class="custom-radio is-medium">
<input type="radio" name="pg" id="pg06" value="kiwoom">
<label for="pg06">일반결제</label>
<label for="pg06">원화(₩) 결제</label>
</div>
<div id="pg06Content" class="pgContent order-method-normal" >
<div class="order-method-normal__btn-container">

View File

@@ -0,0 +1,123 @@
package com.wb.domainserver.domain.admin.instcSle.mtng.service;
import com.wb.domainserver.domain.admin.instcSle.mtng.mapper.BOMaMtngMapper;
import com.wb.domainserver.domain.admin.instcSle.mtng.vo.BOPreOrderValidVO;
import com.wb.domainserver.domain.gcone.mbr.mlg.service.BOMlgService;
import com.wb.domainserver.domain.gcone.mbr.mlg.vo.BOMlgSrchVO;
import com.wb.domainserver.util.dataaccess.DataMap;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class InventoryShortageMessageTest {
private BOPreOrderValidVO goods(String id, String option, int quantity, int total) {
return BOPreOrderValidVO.builder().sopmalCd("SK").goodsInnb(id).goodsNm("요청 상품명")
.goodsOptnInnb(option).optionNm("옵션 " + option).qy(String.valueOf(quantity))
.goodsTotalQy(total).subsvcInnb("").mbrInnb("1").mbrDvCd("SK")
.useMlgAmt("0").useCashMlgAmt("0").build();
}
private DataMap row(String name, int optionStock, int totalStock, int optionError, int totalError) {
DataMap row = new DataMap();
row.put("stockGoodsNm", name);
row.put("optionAvailableQy", optionStock);
row.put("goodsAvailableQy", totalStock);
row.put("invntry", optionError);
row.put("invntryPolicy", totalError);
return row;
}
@Test
void allShortageProductsAppearButAvailableProductsDoNot() {
InventoryShortageMessage message = new InventoryShortageMessage();
message.add(goods("138", "1", 2, 2), List.of(row("상품 A", 1, 5, 1, 0)));
message.add(goods("128", "1", 1, 1), List.of(row("상품 B", 0, 0, 1, 1)));
message.add(goods("170", "1", 1, 1), List.of(row("상품 C", 4, 4, 0, 0)));
String text = message.render();
assertTrue(text.contains("상품 A\n 주문 수량: 2병 / 현재 구매 가능: 1병"));
assertTrue(text.contains("상품 B\n 주문 수량: 1병 / 현재 구매 가능: 0병"));
assertFalse(text.contains("상품 C"));
assertFalse(text.contains("GC25"));
}
@Test
void duplicateValidationRowsDoNotDuplicateTheProductOrOrderQuantity() {
InventoryShortageMessage message = new InventoryShortageMessage();
DataMap row = row("상품 A", 0, 0, 1, 1);
message.add(goods("138", "1", 2, 2), List.of(row, row));
assertEquals(1, message.render().split("· 상품 A", -1).length - 1);
assertTrue(message.render().contains("주문 수량: 2병"));
}
@Test
void multipleOptionsShowSharedLimitWithoutAddingTheirCapacities() {
InventoryShortageMessage message = new InventoryShortageMessage();
message.add(goods("138", "1", 2, 4), List.of(row("상품 A", 3, 3, 0, 1)));
message.add(goods("138", "2", 2, 4), List.of(row("상품 A", 5, 3, 0, 1)));
assertTrue(message.render().contains("총 주문 수량: 4병 / 상품 전체 재고 한도: 3병"));
assertTrue(message.render().contains("옵션 1: 주문 2병 / 옵션별 구매 가능 3병"));
assertTrue(message.render().contains("옵션 2: 주문 2병 / 옵션별 구매 가능 3병"));
assertTrue(message.render().contains("상품 전체 재고 한도 안에서 함께 적용"));
}
@Test
void totalLimitAndNegativeStockAreHandled() {
InventoryShortageMessage message = new InventoryShortageMessage();
message.add(goods("138", "1", 2, 2), List.of(row("상품 A", 8, -2, 0, 1)));
assertTrue(message.render().contains("현재 구매 가능: 0병"));
}
@Test
void stockCountOfOneAndNumericProductNameAreNotValidationFailures() {
BOMaMtngService service = serviceWithStockRows(row("1", 1, 1, 0, 0));
Map<String, Object> result = service.selectBOPreOrderValidation(List.of(goods("138", "1", 1, 1)));
assertEquals("N", result.get("result"));
assertFalse(result.containsKey("message"));
}
@Test
void stockFailuresUseReadableMessageThroughTheService() {
BOMaMtngService service = serviceWithStockRows(row("상품 A", 0, 0, 1, 1));
Map<String, Object> result = service.selectBOPreOrderValidation(List.of(goods("138", "1", 2, 2)));
assertEquals("Y", result.get("result"));
assertTrue(result.get("message").toString().contains("상품 A\n 주문 수량: 2병 / 현재 구매 가능: 0병"));
}
@Test
void inventoryNoticeStillListsProductsWhenAnotherQuantityRestrictionFails() {
DataMap row = row("상품 A", 0, 0, 1, 1);
row.put("purchsQyLmtt", 1);
Map<String, Object> result = serviceWithStockRows(row)
.selectBOPreOrderValidation(List.of(goods("138", "1", 2, 2)));
assertTrue(result.get("message").toString().contains("현재 구매 가능: 0병"));
}
@Test
void unrelatedValidationMessageIsPreserved() {
DataMap row = row("상품 A", 5, 5, 0, 0);
row.put("selpric", 1);
Map<String, Object> result = serviceWithStockRows(row)
.selectBOPreOrderValidation(List.of(goods("138", "1", 1, 1)));
assertEquals("Y", result.get("result"));
assertTrue(result.get("message").toString().contains("GC25S0324006"));
}
private BOMaMtngService serviceWithStockRows(DataMap row) {
BOMaMtngService service = new BOMaMtngService();
BOMaMtngMapper mapper = mock(BOMaMtngMapper.class);
when(mapper.selectBOPreOrderValidation(any())).thenReturn(List.of(row));
BOMlgService mileage = mock(BOMlgService.class);
when(mileage.selectBOMlgBlceSum(any(BOMlgSrchVO.class))).thenReturn(BigDecimal.ZERO);
when(mileage.selectBOCashBlce(any())).thenReturn(BigDecimal.ZERO);
ReflectionTestUtils.setField(service, "mapper", mapper);
ReflectionTestUtils.setField(service, "boMlgService", mileage);
return service;
}
}

View File

@@ -0,0 +1,36 @@
package com.wb.domainserver.domain.gcone.goods.purchs.service;
import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class StripeGoodsNameTest {
private LinkedHashMap<String, Object> goods(int quantity) {
LinkedHashMap<String, Object> goods = new LinkedHashMap<>();
goods.put("qy", quantity);
return goods;
}
@Test
void oneBottleKeepsItsName() {
assertEquals("사케 A", BOOrderService.buildStripeGoodsName("사케 A", List.of(goods(1))));
}
@Test
void twoBottlesOfTheSameProductIncludeTheAdditionalBottle() {
assertEquals("사케 A 외 1병", BOOrderService.buildStripeGoodsName("사케 A", List.of(goods(2))));
}
@Test
void multipleProductsUseTheSumOfQuantitiesRatherThanProductCount() {
assertEquals("사케 A 외 4병", BOOrderService.buildStripeGoodsName("사케 A", List.of(goods(2), goods(3))));
}
@Test
void separateOptionsOfOneProductAlsoCountAsBottles() {
assertEquals("사케 A 외 2병", BOOrderService.buildStripeGoodsName("사케 A", List.of(goods(1), goods(2))));
}
}

View File

@@ -0,0 +1,92 @@
package com.wb.domainserver.domain.gcone.goods.purchs.service;
import com.wb.domainserver.domain.gcone.goods.purchs.mapper.BOPurchsMapper;
import com.wb.domainserver.domain.gcone.goods.purchs.vo.BOOrderItemVO;
import com.wb.domainserver.domain.gcone.goods.purchs.vo.BOOrderSetleVO;
import com.wb.domainserver.domain.gcone.goods.purchs.vo.BOOrderVO;
import com.wb.domainserver.domain.gcone.goods.subsvc.service.BOSubsvcService;
import com.wb.domainserver.exception.WnbtRestException;
import com.wb.domainserver.util.dataaccess.DataMap;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.LinkedHashMap;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class SubscriptionPaymentNameTest {
private BOOrderService service;
private BOSubsvcService subscriptions;
private BOPurchsMapper mapper;
private BOOrderVO order;
private List<BOOrderItemVO> items;
@BeforeEach
void setUp() {
service = new BOOrderService();
subscriptions = mock(BOSubsvcService.class);
mapper = mock(BOPurchsMapper.class);
BOOrderNoService numbers = mock(BOOrderNoService.class);
when(numbers.generateOrderSetleNo()).thenReturn(BigInteger.ONE);
ReflectionTestUtils.setField(service, "subsvcService", subscriptions);
ReflectionTestUtils.setField(service, "mapper", mapper);
ReflectionTestUtils.setField(service, "orderNoService", numbers);
order = new BOOrderVO();
order.setSopmalCd("SK");
order.setOrdrInnb(BigInteger.TEN);
order.setSubsvcInnb(28);
BOOrderItemVO item = new BOOrderItemVO();
item.setGoodsNm("구독서비스상품");
items = List.of(item, item, item, item, item, item);
DataMap detail = new DataMap();
detail.put("subsvcNm", "구독서비스 출시 기념 이벤트");
when(subscriptions.selectBOSubsvcDetail(any())).thenReturn(detail);
}
private BOOrderSetleVO settle(String provider) {
LinkedHashMap<String, Object> payment = new LinkedHashMap<>();
payment.put("rlSetleAmt", "15000");
payment.put("wonRlSetleAmt", "142500");
payment.put("pgCmpnyNm", provider);
payment.put("setleTyCd", "01");
payment.put("ordrDvCd", "01");
return service.insertSetle(order, payment, items, new LinkedHashMap<>(), new LinkedHashMap<>());
}
@Test
void stripeUsesSubscriptionNameAndPreservesAmounts() {
BOOrderSetleVO payment = settle("stripe");
assertEquals("구독서비스 출시 기념 이벤트", payment.getGoodsNm());
assertEquals(new BigDecimal("15000"), payment.getSetleAmt());
assertEquals(new BigDecimal("142500"), payment.getWonSetleAmt());
verify(subscriptions).selectBOSubsvcDetail(argThat(vo -> "SK".equals(vo.getSopmalCd())
&& "28".equals(vo.getSubsvcInnb())));
verify(mapper).insertOrderSetle(argThat(vo -> "구독서비스 출시 기념 이벤트".equals(vo.getGoodsNm())));
verify(mapper).insertSales(argThat(vo -> "구독서비스 출시 기념 이벤트".equals(vo.getGoodsNm())));
}
@Test
void kiwoomUsesTheSameSubscriptionName() {
assertEquals("구독서비스 출시 기념 이벤트", settle("kiwoom").getGoodsNm());
}
@Test
void normalOrderKeepsExistingKiwoomName() {
order.setSubsvcInnb(null);
assertEquals("구독서비스상품 외5", settle("kiwoom").getGoodsNm());
verifyNoInteractions(subscriptions);
}
@Test
void missingSubscriptionNameStopsBeforeSavingPayment() {
when(subscriptions.selectBOSubsvcDetail(any())).thenReturn(null);
assertThrows(WnbtRestException.class, () -> settle("stripe"));
verifyNoInteractions(mapper);
}
}

View File

@@ -0,0 +1,119 @@
package com.wb.domainserver.domain.sopmal.login.controller;
import com.wb.domainserver.domain.admin.store.sopmal.service.BOMaSopmalService;
import com.wb.domainserver.domain.gcone.login.service.LoginService;
import com.wb.domainserver.realm.RealmContextHolder;
import com.wb.domainserver.security.filter.JwtAuthenticationFilter;
import com.wb.domainserver.security.user.SecurityUserDetailService;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import java.nio.charset.StandardCharsets;
import org.springframework.security.core.userdetails.User;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class RefundPopupAuthenticationTest {
private final String popupUrl = "http://localhost/sopmal/mpage/ordr/selectSetleCanclPopupForm"
+ "?sopmalCd=SK&ordrInnb=test-order&ordrDlvyInnb=test-delivery";
@BeforeEach
void setUp() {
RealmContextHolder.setRealm("sopmal");
}
@AfterEach
void tearDown() {
RealmContextHolder.clear();
SecurityContextHolder.clearContext();
}
@Test
void missingRefreshTokenRedirectsToLoginOnlyOnce() throws Exception {
LoginService service = mock(LoginService.class);
LoginController controller = new LoginController(service, mock(BOMaSopmalService.class));
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/sopmal/tokens/refresh");
request.setParameter("redirect_uri", popupUrl);
MockHttpServletResponse response = new MockHttpServletResponse();
// MockHttpServletResponse rejects a second redirect after commit, as Tomcat does.
assertDoesNotThrow(() -> controller.refreshToken(request, response));
assertEquals("/sopmal/login/page/sopmal?redirect_uri=" +
java.net.URLEncoder.encode(popupUrl, StandardCharsets.UTF_8), response.getRedirectedUrl());
verifyNoInteractions(service);
}
@Test
void validRefreshTokenRenewsAndReturnsToPopupWithOrderParameters() throws Exception {
LoginService service = mock(LoginService.class);
LoginController controller = new LoginController(service, mock(BOMaSopmalService.class));
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/sopmal/tokens/refresh");
request.setCookies(new Cookie("RFT", "mock-refresh-token"));
request.setParameter("redirect_uri", popupUrl);
MockHttpServletResponse response = new MockHttpServletResponse();
controller.refreshToken(request, response);
verify(service).createRenewLoginToken(request, response);
assertEquals(popupUrl, response.getRedirectedUrl());
}
@Test
void expiredAccessCookieKeepsGetQueryWhenSendingToRefresh() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest("GET",
"/sopmal/mpage/ordr/selectSetleCanclPopupForm");
request.setQueryString("sopmalCd=SK&ordrInnb=test-order&ordrDlvyInnb=test-delivery");
request.setCookies(new Cookie("token_expire", "1"));
FilterChain chain = mock(FilterChain.class);
MockHttpServletResponse response = new MockHttpServletResponse();
new JwtAuthenticationFilter(mock(SecurityUserDetailService.class)).doFilter(request, response, chain);
assertEquals("/sopmal/tokens/refresh?redirect_uri=" +
java.net.URLEncoder.encode(popupUrl, StandardCharsets.UTF_8) + "&expire=p",
response.getRedirectedUrl());
verifyNoInteractions(chain);
}
@Test
void validAccessTokenReachesPopupWithoutRedirect() throws Exception {
SecurityUserDetailService service = mock(SecurityUserDetailService.class);
when(service.loadUserByUsername("valid-test-token"))
.thenReturn(User.withUsername("test").password("unused").authorities("ROLE_USER").build());
MockHttpServletRequest request = new MockHttpServletRequest("GET",
"/sopmal/mpage/ordr/selectSetleCanclPopupForm");
request.setCookies(new Cookie("ACT", "valid-test-token"));
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);
new JwtAuthenticationFilter(service).doFilter(request, response, chain);
verify(chain).doFilter(request, response);
assertNull(response.getRedirectedUrl());
assertTrue(SecurityContextHolder.getContext().getAuthentication().isAuthenticated());
}
@Test
void invalidAccessJwtRedirectsToLogoutWithoutLoadingPopup() throws Exception {
SecurityUserDetailService service = mock(SecurityUserDetailService.class);
when(service.loadUserByUsername("invalid-test-token")).thenThrow(new JwtException("expired test token"));
MockHttpServletRequest request = new MockHttpServletRequest("GET",
"/sopmal/mpage/ordr/selectSetleCanclPopupForm");
request.setCookies(new Cookie("ACT", "invalid-test-token"));
MockHttpServletResponse response = new MockHttpServletResponse();
FilterChain chain = mock(FilterChain.class);
new JwtAuthenticationFilter(service).doFilter(request, response, chain);
assertEquals("/sopmal/logout", response.getRedirectedUrl());
verifyNoInteractions(chain);
}
}

View File

@@ -0,0 +1,108 @@
package com.wb.domainserver.domain.sopmal.stripe.controller;
import com.stripe.StripeClient;
import com.stripe.model.checkout.Session;
import com.wb.domainserver.domain.gcone.ordr.pay.service.BOPayService;
import com.wb.domainserver.security.authentication.AnonymousUser;
import com.wb.domainserver.security.authentication.MemberJwtContextHolder;
import com.wb.domainserver.util.util.Util;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.ui.ModelMap;
import org.springframework.web.server.ResponseStatusException;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class StripeNavigationTest {
private StripeController controller;
private Session session;
@BeforeEach
void setUp() throws Exception {
controller = new StripeController();
BOPayService payService = mock(BOPayService.class);
StripeClient client = mock(StripeClient.class, RETURNS_DEEP_STUBS);
session = mock(Session.class);
when(payService.createStripeClient("TEST")).thenReturn(client);
when(client.v1().checkout().sessions().retrieve("cs_test_navigation")).thenReturn(session);
when(session.getMetadata()).thenReturn(Map.of("mbrInnb", "42", "sopmalCd", "TEST"));
ReflectionTestUtils.setField(controller, "payService", payService);
}
@Test
void localReturnBaseOverridesShopDomainForBothCallbacks() {
ReflectionTestUtils.setField(controller, "stripeReturnBaseUrl", "http://loc.soju1sake1.com:18500/");
assertEquals("http://loc.soju1sake1.com:18500",
controller.resolveStripeReturnBaseUrl("https://dev.example.com"));
}
@Test
void otherProfilesKeepTheShopDomain() {
assertEquals("https://shop.example.com",
controller.resolveStripeReturnBaseUrl("https://shop.example.com/"));
}
@Test
void redirectOnlyAcceptsTheOwningMember() throws Exception {
AnonymousUser user = new AnonymousUser();
user.setUserInnb(42);
try (MockedStatic<MemberJwtContextHolder> context = mockStatic(MemberJwtContextHolder.class)) {
context.when(MemberJwtContextHolder::getMemberInfo).thenReturn(user);
when(session.getStatus()).thenReturn("open");
when(session.getUrl()).thenReturn("https://checkout.stripe.com/test");
ModelMap model = new ModelMap();
assertEquals("sopmal/ordr/StripeRedirectR",
controller.checkoutRedirect(model, "cs_test_navigation", "TEST"));
assertEquals("https://checkout.stripe.com/test", model.get("checkoutUrl"));
user.setUserInnb(99);
assertThrows(ResponseStatusException.class,
() -> controller.checkoutRedirect(new ModelMap(), "cs_test_navigation", "TEST"));
}
}
@Test
void expiredSessionDoesNotReopenCheckout() throws Exception {
AnonymousUser user = new AnonymousUser();
user.setUserInnb(42);
try (MockedStatic<MemberJwtContextHolder> context = mockStatic(MemberJwtContextHolder.class)) {
context.when(MemberJwtContextHolder::getMemberInfo).thenReturn(user);
when(session.getStatus()).thenReturn("expired");
ModelMap model = new ModelMap();
controller.checkoutRedirect(model, "cs_test_navigation", "TEST");
assertEquals("", model.get("checkoutUrl"));
}
}
@Test
void unpaidReturnRendersOneNoticeForDesktopAndMobile() throws Exception {
when(session.getStatus()).thenReturn("open");
when(session.getPaymentStatus()).thenReturn("unpaid");
MockHttpServletRequest request = new MockHttpServletRequest();
try (MockedStatic<Util> util = mockStatic(Util.class)) {
for (String device : new String[]{"sopmal", "sopmal_m"}) {
util.when(() -> Util.getDeviceType(request)).thenReturn(device);
ModelMap model = new ModelMap();
assertEquals("sopmal/ordr/StripeCancelR",
controller.stripeCancel(request, model, "cs_test_navigation", "TEST"));
assertEquals("sopmal_m".equals(device), model.get("mobile"));
assertFalse(model.containsKey("returnUrl"));
}
}
}
@Test
void paidReturnDoesNotShowCancellation() throws Exception {
when(session.getStatus()).thenReturn("complete");
when(session.getPaymentStatus()).thenReturn("paid");
ModelMap model = new ModelMap();
assertEquals("sopmal/ordr/PayStripeSucsC", controller.stripeCancel(
new MockHttpServletRequest(), model, "cs_test_navigation", "TEST"));
assertEquals("/sopmal/ordr/selectOrdrCompt", model.get("returnUrl"));
}
}

View File

@@ -0,0 +1,78 @@
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const assert = require('node:assert/strict');
const source = fs.readFileSync(path.join(__dirname,
'../../main/resources/static/js/sopmal/refund-popup.js'), 'utf8');
const detail = 'http://local/sopmal/mpage/ordr/selectMbrSubsvcOrdrDtlInqire?ordrInnb=test-order&pageIndex=1';
const endpoint = '/sopmal/mpage/ordr/selectSetleCanclPopupForm';
const data = {sopmalCd: 'SK', ordrInnb: 'test-order', ordrDlvyInnb: 'delivery-1'};
async function run({status = 200, finalPath = endpoint, redirected = false, html = 'popup', networkError = false}) {
const calls = {alerts: [], navigations: [], inserts: [], ready: 0, parse: 0, scriptRemoved: 0};
const popup = {querySelectorAll: () => [{remove: () => calls.scriptRemoved++}]};
const context = {
URL, Object,
window: {location: {href: detail, assign: url => calls.navigations.push(url)},
alert: message => calls.alerts.push(message)},
fetch: async (url, options) => {
calls.request = {url: new URL(url), options};
if (networkError) throw new Error('offline');
return {status, ok: status >= 200 && status < 300, redirected,
url: new URL(finalPath, detail).href, text: async () => html};
},
DOMParser: class {
parseFromString(text) {
calls.parse++;
return {querySelector: selector => {
assert.equal(selector, '#refund-pop');
return text === 'popup' ? popup : null;
}};
}
},
$: selector => ({remove() {}, empty() {return this;},
append: node => calls.inserts.push(node)})
};
vm.runInNewContext(source, context);
await context.fn_load_refund_popup(endpoint, data, () => calls.ready++);
return calls;
}
(async () => {
for (const redirected of [false, true]) {
const result = await run({redirected});
assert.equal(result.ready, 1, 'normal / refreshed session opens the popup');
assert.equal(result.alerts.length, 0);
assert.equal(result.scriptRemoved, 1);
assert.equal(result.request.options.method, 'GET');
assert.equal(result.request.options.cache, 'no-store');
assert.equal(result.request.url.searchParams.get('ordrInnb'), data.ordrInnb);
assert.equal(result.request.url.searchParams.get('ordrDlvyInnb'), data.ordrDlvyInnb);
}
for (const auth of [
{status: 401},
{finalPath: '/sopmal/login/page/sopmal', redirected: true, html: '<script>login script</script>'},
{finalPath: '/sopmal/main', redirected: true}
]) {
const result = await run(auth);
assert.equal(result.ready, 0);
assert.equal(result.inserts.length, 0, 'never insert a login/main page into the popup');
assert.equal(result.parse, 0, 'do not parse or execute login HTML');
assert.match(result.alerts[0], /로그인 시간이 만료/);
const login = new URL(result.navigations[0]);
assert.equal(login.pathname, '/sopmal/login');
assert.equal(login.searchParams.get('redirect_uri'), detail);
}
for (const failure of [
{status: 500}, {status: 403}, {html: ''}, {html: 'unexpected HTML'},
{networkError: true}, {finalPath: '/', redirected: true}
]) {
const result = await run(failure);
assert.equal(result.ready, 0);
assert.equal(result.inserts.length, 0);
assert.equal(result.navigations.length, 0, 'ordinary errors must not force a login');
assert.match(result.alerts[0], /불러오지 못했습니다/);
}
console.log('PASS: 11 refund popup authentication, refresh, and error response scenarios');
})().catch(error => {console.error(error); process.exitCode = 1;});

View File

@@ -0,0 +1,40 @@
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const assert = require('node:assert/strict');
const view = fs.readFileSync(path.join(__dirname,
'../../main/webapp/WEB-INF/views/sopmal/ordr/StripeRedirectR.jsp'), 'utf8');
const script = view.match(/<script>([\s\S]*?)<\/script>/)[1];
function check({persisted = false, type = 'navigate', state = null, checkoutUrl = 'https://checkout.stripe.com/test'}, expected) {
let handler;
const calls = [];
const context = {
window: {
addEventListener: (event, callback) => { assert.equal(event, 'pageshow'); handler = callback; },
location: {
href: 'http://local/sopmal/stripe/checkoutRedirect',
replace: url => calls.push(['replace', url]),
assign: url => calls.push(['assign', url])
}
},
document: {getElementById: id => id === 'stripeCheckout'
? {href: checkoutUrl, getAttribute: () => checkoutUrl}
: {href: 'http://local/sopmal/stripe/stripeCancel'}},
performance: {getEntriesByType: () => [{type}]},
history: {state, replaceState: function (next) { this.state = next; }}
};
vm.runInNewContext(script, context);
handler({persisted});
assert.deepEqual(calls, [expected]);
return {handler, calls};
}
const initial = check({}, ['assign', 'https://checkout.stripe.com/test']);
initial.handler({persisted: true});
assert.deepEqual(initial.calls[1], ['replace', 'http://local/sopmal/stripe/stripeCancel']);
check({type: 'back_forward'}, ['replace', 'http://local/sopmal/stripe/stripeCancel']);
check({state: {stripeRedirected: true}}, ['replace', 'http://local/sopmal/stripe/stripeCancel']);
check({checkoutUrl: ''}, ['replace', 'http://local/sopmal/stripe/stripeCancel']);
console.log('PASS: initial entry, BFCache return, history return, repeat entry, expired session');