이호영 이호영 1 days ago
토스페이 캐시 적립/가상계좌 입금 콜백 구현 및 라이브 키 적용
- 카드/간편결제 승인 시 mj_cash 적립 + 회원 잔액(USER_MONEY)/포인트 갱신
- 가상계좌 입금 웹훅(/web/toss/webhook/deposit.do) 추가: 입금완료(DONE) 시 적립, 멱등 처리
- 토스 결제조회 API로 입금상태 재확인(위변조 방지)
- 토스 시크릿/클라이언트 키를 globals_*.properties 로 분리 (라이브 위젯 키 적용, PG 심사용)
- 웹훅 URL 시큐리티 인증 제외(context-security.xml)

Co-Authored-By: Claude Opus 4.8 
@7152e155426e252cb5413ade7fb3915ec01fc689
src/main/java/itn/let/mjo/pay/service/MjonPayTossService.java
--- src/main/java/itn/let/mjo/pay/service/MjonPayTossService.java
+++ src/main/java/itn/let/mjo/pay/service/MjonPayTossService.java
@@ -9,6 +9,14 @@
 public interface MjonPayTossService {
 	Map<String, Object> processTossConfirm(String paymentKey, String orderId, String amount, LoginVO loginVO) throws Exception;
 
+	/**
+	 * 토스페이먼츠 가상계좌 입금 콜백(웹훅) 처리.
+	 * 입금이 완료(DONE)된 주문에 대해 mj_pg 상태를 결제완료로 바꾸고 캐시/포인트를 적립한다.
+	 * @param orderId 상점 주문번호 (MOID)
+	 * @return 처리결과(credited: 실제 적립 여부, message)
+	 */
+	Map<String, Object> processTossVbankDeposit(String orderId) throws Exception;
+
 //	public int selectBLineMberCnt(String mberId) throws Exception;
 //
 //	public int insertPrePayInfo(MjonPrePayVO mjonPrePayVO) throws Exception;
src/main/java/itn/let/mjo/pay/service/impl/MjonPayDAO.java
--- src/main/java/itn/let/mjo/pay/service/impl/MjonPayDAO.java
+++ src/main/java/itn/let/mjo/pay/service/impl/MjonPayDAO.java
@@ -201,6 +201,11 @@
 		return update("mjonPayDAO.updateMjonPgStatus", refundVO);
 	}
 
+	// 토스 가상계좌 입금완료 처리: PG_STATUS 0(입금대기) → 1(결제완료). 멱등 처리를 위해 0인 건만 갱신.
+	public int updateTossVbankDeposit(MjonPayVO mjonPayVO) throws Exception {
+		return update("mjonPayDAO.updateTossVbankDeposit", mjonPayVO);
+	}
+
 	@SuppressWarnings("unchecked")
 	public List<MjonPayVO> selectPayDayChart(MjonPayVO mjonPayVO) throws Exception{
 		return (List<MjonPayVO>)list("mjonPayDAO.selectPayDayChart", mjonPayVO);
src/main/java/itn/let/mjo/pay/service/impl/MjonPayTossServiceImpl.java
--- src/main/java/itn/let/mjo/pay/service/impl/MjonPayTossServiceImpl.java
+++ src/main/java/itn/let/mjo/pay/service/impl/MjonPayTossServiceImpl.java
@@ -1,16 +1,21 @@
 package itn.let.mjo.pay.service.impl;
 
 import egovframework.rte.fdl.cmmn.EgovAbstractServiceImpl;
+import egovframework.rte.fdl.idgnr.EgovIdGnrService;
 import itn.com.cmm.LoginVO;
+import itn.let.mjo.pay.service.MjonPayService;
 import itn.let.mjo.pay.service.MjonPayTossService;
 
 import javax.annotation.Resource;
 import java.io.*;
 import java.util.Map;
 
+import itn.let.mjo.msgdata.service.MjonMsgDataService;
 import itn.let.mjo.pay.service.MjonPayVO;
+import itn.let.sym.site.service.JoinSettingVO;
 import itn.let.uat.uia.service.impl.MberManageDAO;
 import itn.let.uss.umt.service.MberManageVO;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 
 import java.nio.charset.StandardCharsets;
@@ -32,9 +37,30 @@
 	@Resource(name="mjonPayDAO")
 	private MjonPayDAO mjonPayDAO;
 
+	/** 캐시/회원잔액 적립 공용 서비스 (insertCash → mj_cash + LETTNGNRLMBER.USER_MONEY) */
+	@Resource(name="mjonPayService")
+	private MjonPayService mjonPayService;
+
+	/** 포인트 ID 생성기 */
+	@Resource(name="egovMjonPointIdGnrService")
+	private EgovIdGnrService idgenMjonPointId;
+
+	/** 가입설정(포인트 적립비율) 조회 */
+	@Resource(name="MjonMsgDataService")
+	private MjonMsgDataService mjonMsgDataService;
+
 	/** mberManageDAO */
 	@Resource(name="mberManageDAO")
 	private MberManageDAO mberManageDAO;
+
+	/** 토스 시크릿 키 (globals_*.properties, 콜론 없이 키 본문만 저장 → 인증 시 코드에서 ':' 부착) */
+	@Value("#{globalSettings['Globals.pay.toss.secretKey']}")
+	private String tossSecretKey;
+
+	/** 토스 Basic 인증 헤더값 = Base64(secretKey + ":") */
+	private String tossBasicAuth() {
+		return Base64.getEncoder().encodeToString((tossSecretKey + ":").getBytes(StandardCharsets.UTF_8));
+	}
 
 	/**
 	 * 토스페이먼츠 결제 승인 및 내역 저장
@@ -48,8 +74,7 @@
 	public Map<String, Object> processTossConfirm(String paymentKey, String orderId, String amount, LoginVO loginVO) throws Exception {
 
 		// 1. 토스 승인 API 호출 설정
-		String secretKey = "test_gsk_GjLJoQ1aVZ5nzl22xOql3w6KYe2R:"; // 시크릿 키 (뒤에 콜론 필수)
-		String encodedKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
+		String encodedKey = tossBasicAuth();
 
 		URL url = new URL("https://api.tosspayments.com/v1/payments/confirm");
 		HttpURLConnection connection = (HttpURLConnection) url.openConnection();
@@ -111,9 +136,11 @@
 		payVO.setResultMsg("정상처리 되었습니다.");               // #resultMsg#
 		payVO.setAfterPayYn("N");                           // #afterPayYn# (선불 고정)
 
-		// CASH(공급가액) 계산: 부가세 제외 금액
+		// CASH(공급가액) 계산: 부가세 제외 금액 (기존 시스템과 동일 산식: 결제금액/11*10)
+		// 토스 응답의 suppliedAmount와 1원 단위로 어긋나 세금계산서/정산 금액과 불일치하는 것을 방지하기 위해
+		// 기존 setCashVatNotIncluded() 와 동일한 산식으로 통일한다.
 		long totalAmt = resObj.getLong("totalAmount");
-		long cashAmount = resObj.optLong("suppliedAmount", Math.round(totalAmt / 1.1));
+		long cashAmount = Math.round(totalAmt / 11.0 * 10);
 		payVO.setCash(Double.parseDouble(String.valueOf(cashAmount))); // #cash#
 
 		// 4. 결제 수단별 상세 분기 처리
@@ -210,6 +237,14 @@
 		// 5. DB INSERT 실행
 		mjonPayDAO.insertMjPg(payVO);
 
+		// 5-1. 캐시/포인트 적립
+		//   - 카드/간편결제: 즉시 결제완료(pgStatus=1)이므로 바로 적립
+		//   - 가상계좌(VBANK): 아직 입금 전(pgStatus=0)이므로 적립하지 않음.
+		//     실제 입금이 완료되면 토스 입금 콜백(processTossVbankDeposit)에서 적립한다.
+		if (!"VBANK".equals(payVO.getPayMethod())) {
+			creditCashAndPoint(payVO, loginVO.getId());
+		}
+
 		// 컨트롤러 응답용 결과 Map 구성
 		Map<String, Object> result = new HashMap<>();
 		result.put("PG_STATUS", payVO.getPgStatus());
@@ -223,10 +258,141 @@
 	}
 
 	/**
-	 * 입금 대기 중인 가상계좌 조회 (PayView용)
+	 * 토스페이먼츠 가상계좌 입금 콜백(웹훅) 처리.
+	 * 입금 완료(DONE)된 주문에 대해서만 mj_pg 상태를 결제완료로 바꾸고 캐시/포인트를 적립한다.
+	 * 중복 콜백/재시도에 대비해 멱등(idempotent)하게 동작한다.
 	 */
-/*	@Override
-	public Map<String, Object> selectWaitingVBank(String userId) throws Exception {
-		return mjonPayDAO.selectWaitingVBank(userId);
-	}*/
+	@Override
+	public Map<String, Object> processTossVbankDeposit(String orderId) throws Exception {
+
+		Map<String, Object> result = new HashMap<>();
+		result.put("credited", false);
+
+		if (orderId == null || orderId.isEmpty()) {
+			result.put("message", "orderId 없음");
+			return result;
+		}
+
+		// 1. 우리 DB의 결제내역 조회 (없으면 우리쪽 주문이 아님)
+		MjonPayVO dbPay = mjonPayDAO.selectPayInfoByMoid(orderId);
+		if (dbPay == null) {
+			result.put("message", "존재하지 않는 주문번호: " + orderId);
+			return result;
+		}
+		// 이미 처리(결제완료)된 주문이면 중복 콜백 → 무시
+		if ("1".equals(dbPay.getPgStatus())) {
+			result.put("message", "이미 처리된 주문: " + orderId);
+			return result;
+		}
+
+		// 2. 토스 결제조회 API로 실제 상태 재확인 (위변조 방지: 콜백 본문만 신뢰하지 않음)
+		JSONObject payInfo = callTossPaymentByOrderId(orderId);
+		String status = payInfo.optString("status");
+		if (!"DONE".equals(status)) {
+			// 입금 전(WAITING_FOR_DEPOSIT)이거나 취소/만료 등 → 적립하지 않음
+			result.put("message", "입금완료 상태 아님: " + status);
+			return result;
+		}
+
+		// 3. mj_pg 상태를 결제완료(1)로 변경 — PG_STATUS='0' 조건으로 멱등 처리
+		MjonPayVO updVO = new MjonPayVO();
+		updVO.setMoid(orderId);
+		int updated = mjonPayDAO.updateTossVbankDeposit(updVO);
+		if (updated == 0) {
+			// 동시 콜백 등으로 이미 다른 요청이 처리함 → 중복 적립 방지
+			result.put("message", "상태 변경 대상 없음(이미 처리됨): " + orderId);
+			return result;
+		}
+
+		// 4. 캐시/포인트 적립
+		long totalAmt = payInfo.getLong("totalAmount");
+		dbPay.setAmt(String.valueOf(totalAmt));
+		dbPay.setPayMethod("VBANK");
+		creditCashAndPoint(dbPay, dbPay.getUserId());
+
+		result.put("credited", true);
+		result.put("message", "입금 적립 완료: " + orderId);
+		return result;
+	}
+
+	/**
+	 * 캐시/포인트 적립 공통 처리.
+	 * - 캐시: 공급가액(결제금액/11*10)을 mj_cash 에 적립하고 회원 잔액(USER_MONEY) 갱신
+	 * - 포인트: 문자 미할인 회원만 가입설정 비율만큼 mj_point 에 적립하고 회원 포인트 갱신
+	 *
+	 * 주의: 첫결제 이벤트 회원에 대한 포인트 0원 처리/이벤트 회원정보 갱신 등의 특수 로직은
+	 *       포함하지 않는다. (해당 처리가 필요하면 MjonPayServiceImpl 의 일반 결제 흐름 참고)
+	 *
+	 * @param payVO  amt(결제금액, 부가세포함)/moid/payMethod 가 세팅된 VO
+	 * @param userId 적립 대상 회원 ID
+	 */
+	private void creditCashAndPoint(MjonPayVO payVO, String userId) throws Exception {
+
+		// 공급가액 = 결제금액 / 11 * 10 (부가세 제외)
+		long supply = Math.round(Double.parseDouble(payVO.getAmt()) / 11.0 * 10);
+		String methodKo = payMethodKo(payVO.getPayMethod());
+
+		// --- 캐시 적립 (mj_cash + 회원 잔액 갱신) ---
+		payVO.setUserId(userId);
+		payVO.setFrstRegisterId(userId);
+		payVO.setCash(supply);
+		payVO.setOrderId(payVO.getMoid());           // 주문번호
+		payVO.setMsgGroupId(null);                   // 충전이므로 발송그룹 없음
+		payVO.setMemo(methodKo + " " + supply + " 충전");
+		// insertCash() 내부에서 cashId 생성 + insertCash + updateMemberCash 수행
+		mjonPayService.insertCash(payVO);
+
+		// --- 포인트 적립 (문자 발송단가 미할인 회원만) ---
+		int isMsgSalePrice = mjonPayDAO.selectMsgSalePriceCnt(userId);
+		if (isMsgSalePrice == 0) {
+			JoinSettingVO sysJoinSetVO = mjonMsgDataService.selectJoinSettingInfo();
+			float pointPer = (sysJoinSetVO != null) ? sysJoinSetVO.getPointPer() : 0;
+			int point = Math.round(supply * pointPer / 100f);
+
+			payVO.setPointId(idgenMjonPointId.getNextStringId());
+			payVO.setPoint(point);
+			payVO.setPointMemo(methodKo + " " + point + " 충전");
+			mjonPayDAO.insertPoint(payVO);        // mj_point
+			mjonPayDAO.updateMemberPoint(payVO);  // 회원 포인트 갱신
+		}
+	}
+
+	/** 토스 결제수단 코드 → 캐시/포인트 메모용 한글명 */
+	private String payMethodKo(String payMethod) {
+		if ("CARD".equals(payMethod))      return "신용카드";
+		if ("SPAY".equals(payMethod))      return "간편결제";
+		if ("VBANK".equals(payMethod))     return "가상계좌";
+		if ("BANK".equals(payMethod))      return "계좌이체";
+		if ("CELLPHONE".equals(payMethod)) return "휴대폰";
+		return "";
+	}
+
+	/**
+	 * 토스 결제조회 API (주문번호 기준) 호출.
+	 * GET https://api.tosspayments.com/v1/payments/orders/{orderId}
+	 */
+	private JSONObject callTossPaymentByOrderId(String orderId) throws Exception {
+		String encodedKey = tossBasicAuth();
+
+		URL url = new URL("https://api.tosspayments.com/v1/payments/orders/" + orderId);
+		HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+		connection.setRequestMethod("GET");
+		connection.setRequestProperty("Authorization", "Basic " + encodedKey);
+
+		int responseCode = connection.getResponseCode();
+		InputStream responseStream = (responseCode == 200) ? connection.getInputStream() : connection.getErrorStream();
+
+		StringBuilder resBuilder = new StringBuilder();
+		try (BufferedReader reader = new BufferedReader(new InputStreamReader(responseStream, StandardCharsets.UTF_8))) {
+			String line;
+			while ((line = reader.readLine()) != null) {
+				resBuilder.append(line);
+			}
+		}
+
+		if (responseCode != 200) {
+			throw new Exception("토스 결제조회 실패(" + orderId + "): " + resBuilder.toString());
+		}
+		return new JSONObject(resBuilder.toString());
+	}
 }
src/main/java/itn/let/mjo/pay/web/MjonPayTossController.java
--- src/main/java/itn/let/mjo/pay/web/MjonPayTossController.java
+++ src/main/java/itn/let/mjo/pay/web/MjonPayTossController.java
@@ -17,11 +17,15 @@
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Controller;
 import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
 import org.springframework.web.servlet.mvc.support.RedirectAttributes;
 
 import javax.annotation.Resource;
 import javax.servlet.http.HttpServletRequest;
+import java.io.BufferedReader;
 import java.util.Map;
+
+import org.json.JSONObject;
 
 @Controller
 public class MjonPayTossController {
@@ -127,4 +131,63 @@
 
 		return "redirect:/web/member/pay/PayView.do";
 	}
+
+	/**
+	 * 토스페이먼츠 가상계좌 입금 콜백(웹훅).
+	 * 토스 서버 → 우리 서버로 호출되는 server-to-server 요청이므로 로그인 세션이 없다.
+	 * (context-security.xml 에서 /web/toss/webhook/** 는 security="none" 으로 허용)
+	 *
+	 * 콜백 본문(JSON)에서 orderId/status 만 추출한 뒤, 실제 입금완료 여부는
+	 * 서비스에서 토스 결제조회 API로 재확인한 후 캐시/포인트를 적립한다.
+	 * 토스는 2xx 응답을 받지 못하면 재시도하므로, 처리 성공 시 항상 "OK"(200)를 반환한다.
+	 */
+	@RequestMapping(value = "/web/toss/webhook/deposit.do")
+	@ResponseBody
+	public String tossDepositWebhook(HttpServletRequest request, javax.servlet.http.HttpServletResponse response) {
+
+		try {
+			// 1. 요청 본문(JSON) 읽기
+			StringBuilder sb = new StringBuilder();
+			try (BufferedReader reader = request.getReader()) {
+				String line;
+				while ((line = reader.readLine()) != null) {
+					sb.append(line);
+				}
+			}
+			String raw = sb.toString();
+			LOGGER.info("토스 입금 콜백 수신: {}", raw);
+
+			if (raw.isEmpty()) {
+				return "OK";
+			}
+
+			// 2. orderId / status 추출 (DEPOSIT_CALLBACK 평면 구조 / PAYMENT_STATUS_CHANGED data 구조 모두 대응)
+			JSONObject body = new JSONObject(raw);
+			String orderId;
+			String status;
+			if (body.has("data")) {
+				JSONObject data = body.getJSONObject("data");
+				orderId = data.optString("orderId");
+				status = data.optString("status");
+			} else {
+				orderId = body.optString("orderId");
+				status = body.optString("status");
+			}
+
+			// 3. 입금완료(DONE) 콜백만 적립 처리 (그 외 상태는 무시하되 200 응답)
+			if ("DONE".equals(status)) {
+				Map<String, Object> result = mjonPayTossService.processTossVbankDeposit(orderId);
+				LOGGER.info("토스 입금 콜백 처리결과: {}", result);
+			}
+
+		} catch (Exception e) {
+			// 처리 중 오류(네트워크/DB 등)는 500을 반환해 토스의 재시도를 유도한다.
+			// processTossVbankDeposit 은 멱등하므로 재시도되어도 중복 적립되지 않는다.
+			LOGGER.error("토스 입금 콜백 처리 오류", e);
+			response.setStatus(javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+			return "FAIL";
+		}
+
+		return "OK";
+	}
 }
src/main/resources/egovframework/egovProps/globals_dev.properties
--- src/main/resources/egovframework/egovProps/globals_dev.properties
+++ src/main/resources/egovframework/egovProps/globals_dev.properties
@@ -115,6 +115,11 @@
 Globals.pay.kgm.mobile.mcSvcid=170622040674
 Globals.pay.kgm.mobile.payMode=00
 
+#토스페이먼츠 설정 (시크릿키는 콜론 없이 키 본문만 저장) - 라이브(위젯) 키
+Globals.pay.toss.secretKey=live_gsk_DnyRpQWGrNbko96YwQk7rKwv1M9E
+#토스 클라이언트키(공개키) - 프론트 위젯 초기화용 - 라이브(위젯) 키
+Globals.pay.toss.clientKey=live_gck_QbgMGZzorz59DZpopZElVl5E1em4
+
 #Slack
 Globals.slack.hooks.url=https://hooks.slack.com/services/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
 Globals.slack.channel.name=\ud14c\uc2a4\ud2b8_mjon\uba54\uc2dc\uc9c0
src/main/resources/egovframework/egovProps/globals_local.properties
--- src/main/resources/egovframework/egovProps/globals_local.properties
+++ src/main/resources/egovframework/egovProps/globals_local.properties
@@ -123,6 +123,11 @@
 Globals.pay.kgm.mobile.mcSvcid=170622040674
 Globals.pay.kgm.mobile.payMode=00
 
+#토스페이먼츠 설정 (시크릿키는 콜론 없이 키 본문만 저장) - 라이브(위젯) 키
+Globals.pay.toss.secretKey=live_gsk_DnyRpQWGrNbko96YwQk7rKwv1M9E
+#토스 클라이언트키(공개키) - 프론트 위젯 초기화용 - 라이브(위젯) 키
+Globals.pay.toss.clientKey=live_gck_QbgMGZzorz59DZpopZElVl5E1em4
+
 #Slack
 Globals.slack.hooks.url=https://hooks.slack.com/services/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
 Globals.slack.channel.name=\ud14c\uc2a4\ud2b8_mjon\uba54\uc2dc\uc9c0
src/main/resources/egovframework/egovProps/globals_prod.properties
--- src/main/resources/egovframework/egovProps/globals_prod.properties
+++ src/main/resources/egovframework/egovProps/globals_prod.properties
@@ -103,6 +103,11 @@
 Globals.pay.kgm.mobile.mcSvcid=220613125202
 Globals.pay.kgm.mobile.payMode=10
 
+#토스페이먼츠 설정 (시크릿키는 콜론 없이 키 본문만 저장) - 라이브(위젯) 키
+Globals.pay.toss.secretKey=live_gsk_DnyRpQWGrNbko96YwQk7rKwv1M9E
+#토스 클라이언트키(공개키) - 프론트 위젯 초기화용 - 라이브(위젯) 키
+Globals.pay.toss.clientKey=live_gck_QbgMGZzorz59DZpopZElVl5E1em4
+
 #Slack
 Globals.slack.hooks.url=https://hooks.slack.com/services/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
 Globals.slack.channel.name=mjon\uba54\uc2dc\uc9c0
src/main/resources/egovframework/spring/com/context-security.xml
--- src/main/resources/egovframework/spring/com/context-security.xml
+++ src/main/resources/egovframework/spring/com/context-security.xml
@@ -11,6 +11,8 @@
     <security:http pattern="/images/**" security="none"/>
  	<security:http pattern="/js/**" security="none"/>
  	<security:http pattern="/resource/**" security="none"/>
+ 	<!-- 토스페이먼츠 가상계좌 입금 콜백(웹훅) - 토스 서버에서 호출하는 server-to-server 요청이라 인증 제외 -->
+ 	<security:http pattern="/web/toss/webhook/**" security="none"/>
  	<security:http pattern="\A/WEB-INF/jsp/.*\Z" request-matcher="regex" security="none"/>
  	
     <egov-security:config id="securityConfig"
src/main/resources/egovframework/sqlmap/let/pay/MjonPay_SQL_mysql.xml
--- src/main/resources/egovframework/sqlmap/let/pay/MjonPay_SQL_mysql.xml
+++ src/main/resources/egovframework/sqlmap/let/pay/MjonPay_SQL_mysql.xml
@@ -1456,9 +1456,19 @@
 			, CANCEL_TIME = DATE(NOW())
 		</isEqual>
 		WHERE  MOID      = #moid#
-	
+
 	</update>
-	
+
+	<!-- 토스 가상계좌 입금완료 처리 (PG_STATUS 0:입금대기 → 1:결제완료). 멱등 처리를 위해 0인 건만 갱신 -->
+	<update id="mjonPayDAO.updateTossVbankDeposit" parameterClass="mjonPayVO">
+		UPDATE MJ_PG
+		SET    PG_STATUS   = '1',
+		       RESULT_CODE = '0000',
+		       RESULT_MSG  = '입금이 정상 처리되었습니다.'
+		WHERE  MOID      = #moid#
+		AND    PG_STATUS = '0'
+	</update>
+
 	<select id="mjonPayDAO.selectPayDayChartDashboard" parameterClass="mjonPayVO" resultClass="mjonPayVO">
 	
 		SELECT 
src/main/webapp/WEB-INF/jsp/web/pay/PayView.jsp
--- src/main/webapp/WEB-INF/jsp/web/pay/PayView.jsp
+++ src/main/webapp/WEB-INF/jsp/web/pay/PayView.jsp
@@ -6,6 +6,9 @@
 <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%>
 <%@ taglib prefix="ec" uri="/WEB-INF/tld/ecnet_tld.tld"%>
 
+<%-- 토스 클라이언트키(공개키)는 환경별 globals_*.properties 에서 주입 --%>
+<spring:eval expression="@globalSettings['Globals.pay.toss.clientKey']" var="tossClientKey"/>
+
 <style>
 	/*.charg_cont .area_tab li{ width: calc((100% - 80px)/5);}*/
 </style>
@@ -30,39 +33,101 @@
 			alert("결제에 실패하였습니다.\n사유: " + errorMsg);
 		}
 
-
-		setPriceMake();
-		// 다음 결제시 결제수단 SELECT (필요 시 유지)
-		// getNextPayMethod();
-
-		// 토스페이먼츠 위젯 초기화 세팅
-		const clientKey = 'test_gck_KNbdOvk5rk15kdKpqQGo3n07xlzm';
+		// 토스페이먼츠 위젯 초기화 세팅 (clientKey는 서버 globals_*.properties 에서 주입)
+		const clientKey = '${tossClientKey}';
 		const customerKey = 'test_customer_1234'; // 테스트용 가상 고객 ID (실제 연동 시에는 로그인한 유저의 고유 ID를 넣으세요)
 		paymentWidget = PaymentWidget(clientKey, customerKey);
 
-		// 충전금액 세팅 및 렌더링
-		setPriceMake();
-		const initialAmount = parseInt($("#price").val(), 10);
+		// 초기 충전금액(입력값 기준, 공급가액)
+		var initSupply = parseInt(($("#price").val() || '0').replace(/[^0-9]/g, ''), 10) || 0;
+		var initAmount = initSupply + Math.round(initSupply * 0.1);
 
 		// 결제수단 및 약관 위젯 화면에 렌더링
-		paymentMethodWidget = paymentWidget.renderPaymentMethods('#payment-method', { value: initialAmount });
+		paymentMethodWidget = paymentWidget.renderPaymentMethods('#payment-method', { value: initAmount });
 		paymentWidget.renderAgreement('#agreement');
+
+		// 선택한 결제수단 → 아래 신용카드 타이틀 텍스트 동기화
+		try {
+			if (paymentMethodWidget && typeof paymentMethodWidget.on === 'function') {
+				// 위젯 렌더 완료 후 초기 반영 + 선택 변경 감지(폴링: v1은 select 이벤트 미보장)
+				paymentMethodWidget.on('ready', function () {
+					updatePayMethodTitle();
+					setInterval(updatePayMethodTitle, 100);
+				});
+				// select 이벤트가 지원되면 즉시 반영
+				paymentMethodWidget.on('paymentMethodSelect', updatePayMethodTitle);
+			}
+		} catch (e) { /* 이벤트 미지원 환경 무시 */ }
+
+		// 초기 금액 표시 갱신
+		updatePrice(initSupply);
+
+		// 금액 추가 버튼 클릭
+		$('.add_money .btn').on('click', function () {
+			var addAmount = Number($(this).data('price'));
+			var currentValue = $("#price").val().replace(/[^0-9]/g, '');
+			currentValue = currentValue ? Number(currentValue) : 0;
+			var total = currentValue + addAmount;
+			$("#price").val(numberWithCommas(total));
+			updatePrice(total);
+		});
+
+		// 직접 입력
+		$("#price").on('input', function () {
+			var value = $(this).val().replace(/[^0-9]/g, '');
+			value = value ? Number(value) : 0;
+			$(this).val(numberWithCommas(value));
+			updatePrice(value);
+		});
 	});
 
-	function setPriceMake() {
-		var tempPrice = parseInt($('.list_seType1').val(), 10);
-		var vatPrice = Math.round(parseInt(tempPrice, 10) * 0.1);
-		var lastPrice = parseInt(tempPrice, 10) + parseInt(vatPrice, 10);
+	// 금액 업데이트 (price = 공급가액)
+	function updatePrice(price) {
+		var $message = $('.input_message');
 
-		$("#price").val(lastPrice);
-		$('#supplyPriceStr').html(numberWithCommas(tempPrice));
-		$('#vatPriceStr').html(numberWithCommas(vatPrice));
-		$('#lastPriceStr').html(numberWithCommas(lastPrice));
+		var supplyPrice = price;
+		var vatPrice = Math.round(supplyPrice * 0.1);
+		var lastPrice = supplyPrice + vatPrice;
 
-		// [추가] 변경된 최종 결제 금액을 토스 위젯에 업데이트
+		// 화면 표시
+		$('#supplyPriceStr').text(numberWithCommas(supplyPrice));
+		$('#vatPriceStr').text(numberWithCommas(vatPrice));
+		$('#lastPriceStr').text(numberWithCommas(lastPrice));
+
+		// 토스 결제 금액 변경
 		if (typeof paymentMethodWidget !== 'undefined') {
 			paymentMethodWidget.updateAmount(lastPrice);
 		}
+
+		// 충전금액 제한 체크
+		if (price > 150000 || price < 5000) {
+			$(".money_input").addClass('error');
+			$message.addClass('active');
+			if (price < 5001) {
+				$message.find(".msg").text("최소 충전금액 5천원 이상 입력해주세요.");
+			} else {
+				$message.find(".msg").text("휴대폰 결제는 최대 150,000원까지 입력 가능합니다.");
+			}
+		} else {
+			$(".money_input").removeClass('error');
+			$message.removeClass('active');
+		}
+	}
+
+	// 선택된 결제수단명을 신용카드 타이틀에 반영
+	function updatePayMethodTitle() {
+		try {
+			var sel = paymentMethodWidget.getSelectedPaymentMethod();
+			if (!sel || !sel.method) return;
+
+			var method = sel.method; // 카드 / 가상계좌 / 계좌이체 / 휴대폰 / 간편결제 ...
+			var label = (method === '카드') ? '신용·체크카드' : method;
+
+			var $t = $('#payMethodTitle');
+			if ($t.length && $t.text() !== label) {
+				$t.text(label);
+			}
+		} catch (e) { /* 위젯 준비 전 호출 무시 */ }
 	}
 
 	// 후불제여부 체크
@@ -97,28 +162,24 @@
 			return false;
 		}
 
-		var lastPrice = parseInt($("#price").val(), 10);
-		if(lastPrice < 5500){
-			alert("최소 충전금액 5천원 이상 선택해주세요.");
+		// 충전금액(공급가액) 추출
+		var chargePrice = parseInt(($("#price").val() || '0').replace(/[^0-9]/g, ''), 10);
+		if (isNaN(chargePrice) || chargePrice < 5000) {
+			alert("최소 충전금액 5천원 이상 입력해주세요.");
 			return false;
 		}
-		// const paymentMethodsWidget = paymentWidget.renderPaymentMethods();
-		// const selectedPaymentMethod = paymentMethodsWidget.getSelectedPaymentMethod();
-		// console.log('paymentMethodsWidget : ', paymentMethodsWidget);
-		// console.log('selectedPaymentMethod : ', selectedPaymentMethod);
+
+		// 최종 결제금액(부가세 포함)
+		var lastPrice = chargePrice + Math.round(chargePrice * 0.1);
 
 		// 토스페이먼츠 결제창 바로 호출
 		paymentWidget.requestPayment({
 			orderId: 'ORDER_' + new Date().getTime(), // 고유한 상점 주문번호 생성
 			orderName: '문자온 충전 ' + lastPrice + '원',
-			successUrl: window.location.origin + '/web/member/pay/tossSuccess.do', // 결제 성공 시 이동할 URL (백엔드 컨트롤러 생성 필요)
-			// paymentType=NORMAL
-			// &orderId=ORDER_1778047452906
-			// &paymentKey=tmunj2026050615041659Zv9
-			// &amount=55000
-			failUrl: window.location.origin + '/web/member/pay/tossFail.do',       // 결제 실패 시 이동할 URL (백엔드 컨트롤러 생성 필요)
-			customerEmail: $('#mberEmailAdres').val(), // 필요시 로그인한 사용자의 이메일 매핑
-			customerName: $('#mberNm').val(),   // 필요시 로그인한 사용자의 이름 매핑
+			successUrl: window.location.origin + '/web/member/pay/tossSuccess.do', // 결제 성공 시 이동할 URL
+			failUrl: window.location.origin + '/web/member/pay/tossFail.do',       // 결제 실패 시 이동할 URL
+			customerEmail: $('#mberEmailAdres').val(), // 로그인한 사용자의 이메일 매핑
+			customerName: $('#mberNm').val(),   // 로그인한 사용자의 이름 매핑
 			customerMobilePhone: $('#moblphonNo').val()
 		}).catch(function (error) {
 			if (error.code === 'USER_CANCEL') {
@@ -128,13 +189,6 @@
 			}
 		});
 	}
-
-	//충전금액 Change Event
-	$(document).on('change', '#tempPrice', function() {
-	// $(document).on('change', '.list_seType1', function() {
-		// 충전금액 세팅
-		setPriceMake();
-	});
 
 
 /* 윈도우팝업 열기 */
@@ -173,8 +227,7 @@
 	<form id="payTypeForm" name="payTypeForm" method="post"> 
 		<input type="hidden" name="payTypeCode" />
 	</form>
-	<form id="pgForm" name="pgForm" action="/web/member/pay/PayActionAjax.do" method="post"> 
-	<input type="hidden" id="price" name="price" />
+	<form id="pgForm" name="pgForm" action="/web/member/pay/PayActionAjax.do" method="post">
 	<input type="hidden" id="payMethod" name="payMethod" />
 	<input type="hidden" id="accMsg" name="accMsg" />
 	<input type="hidden" id="sendCnt" name="sendCnt" value="<c:out value='${resultMsgInfo.sendCnt}'/>" />
@@ -207,47 +260,60 @@
 						<p>- 모든 요금은 VAT별도 금액입니다.</p>
 					</div>--%>
 					<div>
-						<p class="tab_tit">충전수단 선택</p><%--
-						<ul class="area_tab">
-							<li class="btn_charge1 btn_tab active"><button type="button" onclick="TabTypePay(this,'1');"><i></i>신용카드</button></li>
-							<li class="btn_charge2 btn_tab"><button type="button" onclick="TabTypePay(this,'2');" id="btnDdedicatedAccount"><i></i>전용계좌</button></li>
-							<li class="btn_charge3 btn_tab"><button type="button" onclick="TabTypePay(this,'3');"><i></i>휴대폰결제</button></li>
-							<li class="btn_charge4 btn_tab"><button type="button" onclick="TabTypePay(this,'4');"><i></i>즉시이체</button></li>
-							
-							<li class="btn_charge5 btn_tab simple_pay"><button type="button" onclick="TabTypePay(this,'5');"><i></i></button></li>
-							<li class="btn_charge6 btn_tab simple_pay"><button type="button" onclick="TabTypePay(this,'6');"><i></i></button></li>
-							<li class="btn_charge7 btn_tab simple_pay"><button type="button" onclick="TabTypePay(this,'7');"><i></i></button></li>
-							<li class="btn_charge8 btn_tab simple_pay"><button type="button" onclick="TabTypePay(this,'8');"><i></i></button></li>							
-						</ul>
-						<div class="checkbox_wrap"><input type="checkbox" id="agree"><label for="agree">선택한 수단을 다음 충전 시에도 이용합니다.</label></div>
---%>
+						<!-- 토스페이먼츠 결제수단 위젯 영역 -->
+						<div id="payment-method" class="toss_wrap"></div>
+
+						<!-- 토스페이먼츠 이용약관 영역 -->
+						<div id="agreement" class="toss_wrap"></div>
+
 						<!-- 신용카드 -->
 						<div class="area_tabcont on" id="tab2_1">
-							<p class="tType1_title"><img src="/publish/images/content/icon_charging1_small.png" alt=""> 신용카드</p>
-							<!-- 충전금액 선택 영역 -->
-							<div class="charge_amount_box" style="margin-bottom: 20px;">
-								<label for="tempPrice" style="font-weight: bold; margin-right: 10px;">충전금액 선택 :</label>
-								<select name="tempPrice" id="tempPrice" class="list_seType1">
-									<option value="5000">5,000</option>
-									<option value="10000">10,000</option>
-									<option value="50000" selected>50,000</option>
-									<!-- 필요하신 금액대 옵션 유지 -->
-								</select> 원
-								<p style="margin-top: 10px; color: #666;">
-									최종 결제금액: <strong id="lastPriceStr" style="color: #000; font-size: 16px;">55,000</strong>원 (공급가액 <span id="supplyPriceStr">50,000</span>원 + 부가세 <span id="vatPriceStr">5,000</span>원)
-								</p>
-							</div>
+							<p class="tType1_title"><img src="/publish/images/credit_small.png" alt="신용카드"> <span id="payMethodTitle">신용·체크카드</span></p>
+							<table class="tType1">
+								<colgroup>
+									<col style="width: 100px;">
+									<col style="width: auto;">
+								</colgroup>
+								<tbody>
+									<tr class="charge_content">
+										<th scope="row">충전금액</th>
+										<td class="flex">
+											<div class="money_form_wrap form_wrap">
+												<input type="text" name="price" id="price" class="input money_input" placeholder="5,000원 이상 입력해주세요." value="50,000" />
+												<p class="input_in">원</p>
+												<p class="input_message error">
+													<i class="icon error">!</i> <span class="msg">휴대폰 결제는 최대 150,000원까지 입력 가능합니다.</span>
+												</p>
+											</div>
 
-							<!-- 토스페이먼츠 결제수단 위젯 영역 -->
-							<div id="payment-method"></div>
+											<div class="btn_wrap add_money w100per">
+												<button type="button" class="btn" data-price="10000">+ 1만원</button>
+												<button type="button" class="btn" data-price="50000">+ 5만원</button>
+												<button type="button" class="btn" data-price="100000">+ 10만원</button>
+												<button type="button" class="btn" data-price="1000000">+ 100만원</button>
+											</div>
+										</td>
+									</tr>
 
-							<!-- 토스페이먼츠 이용약관 영역 -->
-							<div id="agreement"></div>
-
-							<!-- 결제하기 버튼 -->
-							<div style="text-align: center; margin-top: 20px;">
-								<button type="button" class="btnType" onclick="pgOpenerPopup(); return false;" style="width: 200px; height: 50px; font-size: 16px;">충전하기</button>
-							</div>
+									<tr>
+										<td colspan="2">
+											<div class="amount_wrap">
+												<dl>
+													<dt>최종 결제금액 :</dt>
+													<dd>
+														<ul>
+															<li><strong id="supplyPriceStr">50,000</strong>원(공급가액)</li>
+															<li><span class="plus"></span><strong id="vatPriceStr">5,000</strong>원(부가세)</li>
+															<li class="total"><span class="equal"></span><strong class="c_e40000" id="lastPriceStr">55,000</strong>원(최종금액)</li>
+														</ul>
+													</dd>
+												</dl>
+												<button type="button" class="btn btnType fill primary btn_pay" onclick="pgOpenerPopup(); return false;">충전하기</button>
+											</div>
+										</td>
+									</tr>
+								</tbody>
+							</table>
 						</div>
 					</div>
 
Add a comment
List