Files
Gnuboard7/plugins/_bundled/sirsoft-pay_nhnkcp/resources/js/handlers/requestPayment.ts
T
HeuJung 1e89335258 fix(core,extensions): 동적 스크립트 주입 경로 전부에 출처 게이트 적용
레이아웃 scripts[] 에만 있던 원격 스크립트 차단 게이트가 다른 주입 경로에는
없어, 저장측(SafeLayoutExpressions·NoExternalUrls)이 외부 URL 저장을 422 로
막아도 런타임 디스패치 한 번으로 임의 원격 코드가 로드됐다. 저장 검증이
통째로 무의미해지는 상태였고 오류도 경고도 남지 않았다.

판정식을 support/scriptSrcPolicy.ts 로 분리해 런타임 SSoT 를 하나로 두고,
loadScript 액션 · 확장 핸들러 재로드 · 편집기 프리뷰 · 편집기 자산 매니페스트 ·
G7Core.asset.loadScript · PG 플러그인 SDK 주입이 모두 그것을 경유하게 했다.
사본을 두지 않은 이유는 차집합이 그대로 우회로가 되기 때문이다.

- 게이트는 캐시 검사보다 앞에 둔다 (뒤에 두면 이미 로드된 미신뢰 스크립트가
 캐시 히트로 통과한다)
- NoExternalUrls 순회를 9키로 확대 — 번들 레이아웃 601개 사전 스캔으로
 정당한 외부 URL 0건을 확인한 뒤 넓혔다 (저장 회귀 없음)
- callExternal 은 생성자를 참조 동일성으로 거부하고(별칭 전역 포함),
 프로토타입 경로 세그먼트를 읽기·매핑 키 양쪽에서 차단한다
- PG SDK 는 서비스 SDK 라 자체 호스팅이 불가능하므로 manifest 선언 +
 주입 직전 호스트 확인이 게이트다. tosspayments SDK URL 은 확장자가 없어
 정적 검사에 걸리지 않아 그 런타임 확인이 유일한 게이트다
- 함께 드러난 결함: 동시 loadScript 가 로드 전에 완료 처리되던 문제,
 확장 재로드에서 script 기존재가 CSS 까지 건너뛰던 문제, loadCSS 의
 in-flight 미공유
2026-09-02 21:43:13 +09:00

673 lines
25 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
import {
PaymentCloseReportContext,
preparePaymentRetry,
rememberPendingClose,
reportPaymentWindowClosed,
} from '../paymentCloseReport';
import {
applePayUnsupportedMessage,
isIosMobileDevice,
isNhnKcpApplePayMethod,
} from '../support/applePayDevice';
interface PgPaymentData {
order_number: string;
order_name: string;
amount: number;
currency?: string;
pay_method?: string;
/**
* 서버가 저장한 결제수단 ID — 확장 수단이면 확장 ID 그대로(예: 'nhnkcp_naverpay').
* 확장 결제수단이 1급 시민이 되면서 서버가 원본 수단을 알고 내려준다(#475).
*/
payment_method?: string;
customer_name?: string;
customer_email?: string;
customer_phone?: string;
/** 복합과세 분할용 */
tax_amount?: number;
vat_amount?: number;
tax_free_amount?: number;
}
interface RequestPaymentParams {
pgPaymentData: PgPaymentData;
paymentMethod?: string;
}
function normalizeCurrency(currency?: string): string {
const normalized = (currency ?? 'KRW').trim().toUpperCase();
return normalized || 'KRW';
}
export function isSupportedKcpCurrency(currency?: string): boolean {
return normalizeCurrency(currency) === 'KRW';
}
/**
* SDK 스크립트를 로드할 수 있는 호스트 (plugin.json `trusted_script_hosts` 미러).
*
* NHN KCP 결제창은 라이브러리가 아니라 그 회사 서버와 통신하는 서비스 SDK 라
* 자체 호스팅할 수 없다. 대신 **주입 직전에** 호스트를 확인해, 설정·응답이 어떤
* 경로로든 다른 주소를 지시하면 결제를 진행하지 않는다(fail-closed).
*
* PG사가 SDK 호스트를 바꾸면 이 상수와 plugin.json 을 **함께** 갱신한다 —
* 둘이 어긋나면 테스트가 실패한다.
*/
export const KNOWN_SDK_HOSTS: readonly string[] = ['testpay.kcp.co.kr', 'pay.kcp.co.kr'];
/**
* 번역 문자열을 얻습니다.
*
* @param key 번역 키 (플러그인 네임스페이스 이하)
* @param fallback 번역 엔진 부재 시 사용할 문구
* @returns 번역된 문자열
*/
function t(key: string, fallback: string): string {
const translate = (window as any)?.G7Core?.t;
if (typeof translate !== 'function') {
return fallback;
}
const full = `sirsoft-pay_nhnkcp.${key}`;
const result = translate(full);
return typeof result === 'string' && result !== full ? result : fallback;
}
/**
* SDK URL 이 신뢰 호스트인지 확인하고, 아니면 예외를 던집니다.
*
* @param url 주입할 SDK URL
* @throws Error 미신뢰 호스트이거나 https 가 아닌 경우
*/
export function assertTrustedSdkUrl(url: string): void {
let parsed: URL | null = null;
try {
parsed = new URL(url);
} catch {
parsed = null;
}
if (
parsed === null
|| parsed.protocol !== 'https:'
|| !KNOWN_SDK_HOSTS.includes(parsed.hostname.toLowerCase())
) {
throw new Error(
t('payment.error.sdk_url_untrusted', '결제 모듈 주소가 올바르지 않아 결제를 진행할 수 없습니다.')
);
}
}
function unsupportedCurrencyMessage(currency?: string): string {
const normalized = normalizeCurrency(currency);
const isKo = ((typeof document !== 'undefined' ? document.documentElement.lang : '') || '')
.toLowerCase()
.startsWith('ko');
return isKo
? `NHN KCP는 KRW 결제만 지원합니다. (${normalized})`
: `NHN KCP supports KRW payments only. (${normalized})`;
}
export function buildKcpTaxFields(pgPaymentData: PgPaymentData): Record<string, string> {
const paymentAmount = Math.max(0, Math.round(Number(pgPaymentData.amount ?? 0)));
const taxFreeAmt = Math.min(
Math.max(0, Math.round(Number(pgPaymentData.tax_free_amount ?? 0))),
paymentAmount,
);
if (taxFreeAmt <= 0) {
return {};
}
const taxTotalAmt = Math.max(0, Math.round(Number(pgPaymentData.tax_amount ?? 0)));
const originalVatAmt = Math.max(0, Math.round(Number(pgPaymentData.vat_amount ?? 0)));
const taxablePaymentAmt = Math.max(0, paymentAmount - taxFreeAmt);
const vatAmt = taxablePaymentAmt > 0 && taxTotalAmt > 0 && originalVatAmt > 0
? Math.min(taxablePaymentAmt, Math.round(taxablePaymentAmt * (originalVatAmt / taxTotalAmt)))
: 0;
const supplyAmt = taxablePaymentAmt - vatAmt;
return {
tax_flag: 'TG03',
comm_tax_mny: String(supplyAmt),
comm_vat_mny: String(vatAmt),
comm_free_mny: String(taxFreeAmt),
};
}
interface ClientConfig {
client_id: string;
easy_pay_client_id?: string;
sdk_url: string;
callback_urls: {
callback: string;
close_report?: string;
};
vbank_expire_days?: number;
use_escrow?: boolean;
escrow_client_id?: string;
}
// 사용자가 결제창을 직접 닫은 경우 - 에러 모달 없이 조용히 처리
class KcpCancelledError extends Error {
constructor(msg?: string) {
super(msg ?? '결제가 취소되었습니다.');
this.name = 'KcpCancelledError';
}
}
// KCP pay_method 비트마스크 변환 (PC: payplus_web.jsp 규격)
const KCP_PAY_METHOD: Record<string, string> = {
card: '100000000000', // 신용카드
bank_transfer: '010000000000', // 계좌이체
virtual_account: '001000000000', // 가상계좌
mobile: '000010000000', // 휴대폰결제
bank: '010000000000',
vbank: '001000000000',
phone: '000010000000',
};
// KCP 간편결제 direct 파라미터 (PC/모바일 공통)
const KCP_EASY_PAY_DIRECT: Record<string, Record<string, string>> = {
nhnkcp_payco: { payco_direct: 'Y' },
nhnkcp_naverpay: { naverpay_direct: 'Y' },
nhnkcp_naverpay_point: { naverpay_direct: 'Y', naverpay_point_direct: 'Y' },
nhnkcp_kakaopay: { kakaopay_direct: 'Y' },
nhnkcp_applepay: { applepay_direct: 'Y' },
};
export function buildKcpEasyPayReturnFields(paymentMethod: string, isEasyPay: boolean): Record<string, string> {
const methodKey = paymentMethod.toLowerCase();
if (!isEasyPay || !Object.prototype.hasOwnProperty.call(KCP_EASY_PAY_DIRECT, methodKey)) {
return {};
}
return {
param_opt_1: methodKey,
nhnkcp_easy_pay_method: methodKey,
};
}
function isKcpPaymentFrameClosed(iframe: HTMLIFrameElement): boolean {
if (!iframe.isConnected) {
return true;
}
const style = window.getComputedStyle(iframe);
if (style.display === 'none' || style.visibility === 'hidden') {
return true;
}
const rect = iframe.getBoundingClientRect();
return rect.width <= 0 || rect.height <= 0;
}
export function watchKcpPaymentFrameClosure(
iframe: HTMLIFrameElement,
onClose: () => void,
): () => void {
let stopped = false;
let intervalId: number | undefined;
let observer: MutationObserver | undefined;
const stop = () => {
stopped = true;
if (observer) {
observer.disconnect();
observer = undefined;
}
if (intervalId !== undefined) {
window.clearInterval(intervalId);
intervalId = undefined;
}
};
const checkClosed = () => {
if (stopped || !isKcpPaymentFrameClosed(iframe)) {
return;
}
stop();
onClose();
};
const observeTarget = document.body ?? document.documentElement;
if (observeTarget && typeof MutationObserver !== 'undefined') {
observer = new MutationObserver(checkClosed);
observer.observe(observeTarget, {
attributes: true,
attributeFilter: ['class', 'hidden', 'style'],
childList: true,
subtree: true,
});
}
intervalId = window.setInterval(checkClosed, 300);
return stop;
}
/**
* 모바일 기기 여부 판별 (3단계 fallback)
*
* 1) User Agent Client Hints — 브라우저가 직접 판단 (Chrome/Edge 90+)
* 2) UA 문자열 파싱
* 3) iPadOS 등 데스크탑 UA를 보내는 터치 기기 판별 (maxTouchPoints > 1)
*/
function isMobileDevice(): boolean {
if (typeof navigator === 'undefined') return false;
if (isIosMobileDevice()) return true;
const nav = navigator as Navigator & { userAgentData?: { mobile: boolean } };
if (nav.userAgentData?.mobile !== undefined) {
return nav.userAgentData.mobile;
}
const ua = (navigator.userAgent || '').toLowerCase();
if (/android|iphone|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(ua)) {
return true;
}
// iPadOS, Android 태블릿 등 — 터치스크린 노트북은 maxTouchPoints=1이므로 >1 조건 필요
const touchPoints = (navigator as Navigator & { maxTouchPoints?: number }).maxTouchPoints ?? 0;
if (touchPoints > 1 && !ua.includes('windows') && !ua.includes('macintosh')) {
return true;
}
return false;
}
/**
* NHN KCP 결제창 호출 핸들러
*
* - 모바일: SOAP approval_key 획득 → form POST(페이지 전환) → 기존 authCallback 처리
* - PC: payplus_web.jsp SDK를 iframe 내 동기 로드 → KCP_Pay_Execute() → 콜백
*/
export async function requestPaymentHandler(action: any, _context?: any): Promise<void> {
const { pgPaymentData, paymentMethod: paramPaymentMethod } = (action.params || {}) as RequestPaymentParams;
if (!pgPaymentData) {
console.error('[sirsoft-pay_nhnkcp] pgPaymentData is required');
return;
}
// 서버가 저장한 결제수단(payment_method)이 SSoT — 확장 수단이면 확장 ID 가 그대로 온다.
// params.paymentMethod 는 인터셉터 시절의 잔재이나 직접 호출 경로 호환을 위해 우선 존중한다.
const paymentMethod = paramPaymentMethod
?? pgPaymentData.payment_method
?? pgPaymentData.pay_method
?? 'card';
const isEasyPay = typeof paymentMethod === 'string' && paymentMethod.startsWith('nhnkcp_');
const G7Core = (window as any).G7Core;
let closeReportContext: PaymentCloseReportContext | null = null;
try {
if (!isSupportedKcpCurrency(pgPaymentData.currency)) {
const message = unsupportedCurrencyMessage(pgPaymentData.currency);
G7Core?.state?.setLocal?.({ paymentErrorMessage: message, isSubmittingOrder: false, paymentMethod });
G7Core?.modal?.open?.('nhnkcp_payment_error_modal');
return;
}
if (isNhnKcpApplePayMethod(paymentMethod) && !isIosMobileDevice()) {
const message = applePayUnsupportedMessage();
G7Core?.state?.setLocal?.({
paymentErrorMessage: message,
isSubmittingOrder: false,
paymentMethod,
});
G7Core?.modal?.open?.('nhnkcp_payment_error_modal');
return;
}
// 1. Client Config API 호출
const configJson = await G7Core.api.get('/modules/sirsoft-ecommerce/payments/client-config/nhnkcp');
if (!configJson.data) {
console.error('[sirsoft-pay_nhnkcp] Failed to fetch client config', configJson);
return;
}
const config: ClientConfig = configJson.data;
closeReportContext = {
closeReportUrl: config.callback_urls.close_report,
oid: pgPaymentData.order_number,
price: Number(pgPaymentData.amount),
buyer_email: pgPaymentData.customer_email ?? '',
buyer_phone: pgPaymentData.customer_phone ?? '',
payment_method: paymentMethod,
};
const callbackUrl = window.location.origin + config.callback_urls.callback;
await preparePaymentRetry(closeReportContext);
// 결제창은 전체 페이지 이동으로 열리고 돌아오므로, 실패 화면에서 서버에 보고할 때 쓸
// 구매자 정보를 미리 남겨 둔다. 브라우저 리턴 콜백은 인증이 없어 주문 상태를 바꾸지 않고,
// 소유권을 대조하는 close-report 만이 정당한 결제 실패를 기록할 수 있다.
rememberPendingClose(closeReportContext);
if (isMobileDevice()) {
await handleMobilePayment(G7Core, pgPaymentData, paymentMethod, isEasyPay, callbackUrl);
} else {
await handlePcPayment(config, pgPaymentData, paymentMethod, isEasyPay, callbackUrl);
}
} catch (error: unknown) {
if (error instanceof KcpCancelledError) {
if (closeReportContext) {
await reportPaymentWindowClosed(closeReportContext, error.message || 'kcp-window-closed');
}
G7Core?.state?.setLocal?.({ isSubmittingOrder: false, paymentMethod });
return;
}
console.error('[sirsoft-pay_nhnkcp] requestPayment error', error);
// axios error 의 response.data 에서 사용자 친화적 메시지 추출.
// G7Core.api 는 axios 기반이라 4xx/5xx 응답은 자동 reject 됨 — generic
// "Request failed with status code 422" 가 아니라 Laravel ValidationException
// 의 message / errors 필드를 우선 사용.
const anyErr = error as { response?: { data?: { message?: string; error?: string; errors?: Record<string, string[]> } }; message?: string };
const responseData = anyErr?.response?.data;
const firstFieldError = responseData?.errors
? Object.values(responseData.errors)[0]?.[0]
: undefined;
const errorMessage =
responseData?.error
?? responseData?.message
?? firstFieldError
?? (error instanceof Error ? error.message : 'Unknown error');
G7Core?.state?.setLocal?.({ paymentErrorMessage: errorMessage, isSubmittingOrder: false, paymentMethod });
G7Core?.modal?.open?.('nhnkcp_payment_error_modal');
}
}
/**
* 모바일 결제 흐름
*
* 1) 서버에서 SOAP으로 approval_key + pay_url 획득
* 2) 전체 form fields를 받아 브라우저가 pay_url 로 POST 전환 (페이지 이동)
* 3) KCP가 결제 완료 후 Ret_URL(authCallback)로 redirect → 기존 서버 처리
*/
async function handleMobilePayment(
G7Core: any,
pgPaymentData: PgPaymentData,
paymentMethod: string,
isEasyPay: boolean,
callbackUrl: string,
): Promise<void> {
const approvalJson = await G7Core.api.post('/plugins/sirsoft-pay_nhnkcp/mobile/approval-key', {
order_number: pgPaymentData.order_number,
amount: pgPaymentData.amount,
good_name: pgPaymentData.order_name,
pay_method: paymentMethod,
buyr_name: pgPaymentData.customer_name ?? '',
buyr_mail: pgPaymentData.customer_email ?? '',
buyr_tel1: pgPaymentData.customer_phone ?? '',
ret_url: callbackUrl,
currency: normalizeCurrency(pgPaymentData.currency),
});
if (!approvalJson.success || !approvalJson.data) {
// 서버 응답의 에러 메시지 우선순위:
// 1. ResponseHelper::error 의 'error' 필드 (커스텀)
// 2. Laravel ValidationException 의 'message' 필드 (422 응답 표준)
// 3. 'errors' 객체의 첫 번째 메시지 (필드별 validation 메시지)
// 4. fallback
const errors = approvalJson.errors as Record<string, string[]> | undefined;
const firstFieldError = errors ? Object.values(errors)[0]?.[0] : undefined;
throw new Error(
approvalJson.error
?? approvalJson.message
?? firstFieldError
?? 'KCP 모바일 승인키 획득 실패',
);
}
const { pay_url, fields } = approvalJson.data as { pay_url: string; fields: Record<string, string> };
// 페이지 전환 form 생성 및 제출
const form = document.createElement('form');
form.method = 'POST';
form.action = pay_url;
form.acceptCharset = 'euc-kr'; // KCP 모바일은 EUC-KR 인코딩 필요
form.style.display = 'none';
for (const [name, value] of Object.entries(fields)) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = name;
input.value = String(value);
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
// 페이지가 pay_url 로 이동하므로 Promise는 자연스럽게 중단됨
await new Promise<never>(() => {});
}
/**
* PC 결제 흐름
*
* Chrome 비동기 document.write() 차단을 우회하기 위해 iframe 내에서
* document.open/write/close 로 SDK를 동기 로드한 뒤 KCP_Pay_Execute() 호출.
*/
async function handlePcPayment(
config: ClientConfig,
pgPaymentData: PgPaymentData,
paymentMethod: string,
isEasyPay: boolean,
callbackUrl: string,
): Promise<void> {
// 간편결제는 pay_method 항상 "100000000000" (카드 비트마스크), direct 파라미터로 수단 지정
const payMethod = isEasyPay
? '100000000000'
: (KCP_PAY_METHOD[pgPaymentData.pay_method ?? 'card'] ?? '100000000000');
// PAYCO 전용 테스트 site_cd (S6729), 그 외 간편결제·에스크로·일반은 각자 site_cd 사용
const siteCd = paymentMethod === 'nhnkcp_payco'
? (config.easy_pay_client_id ?? config.client_id)
: (config.use_escrow && !isEasyPay ? (config.escrow_client_id ?? config.client_id) : config.client_id);
const fields: Record<string, string> = {
site_cd: siteCd,
ordr_idxx: pgPaymentData.order_number,
good_name: pgPaymentData.order_name,
good_mny: String(pgPaymentData.amount),
buyr_name: pgPaymentData.customer_name ?? '',
buyr_mail: pgPaymentData.customer_email ?? '',
buyr_tel1: pgPaymentData.customer_phone ?? '',
pay_method: payMethod,
Ret_URL: callbackUrl,
};
Object.assign(fields, buildKcpTaxFields(pgPaymentData));
Object.assign(fields, buildKcpEasyPayReturnFields(paymentMethod, isEasyPay));
// 가상계좌 전용 파라미터
if (payMethod === '001000000000') {
fields['vcnt_expire_term'] = String(config.vbank_expire_days ?? 3);
fields['disp_tax_yn'] = 'N';
}
// 에스크로 결제 파라미터 (간편결제 제외)
if (config.use_escrow && !isEasyPay) {
fields['escw_used'] = 'Y';
fields['pay_mod'] = 'O';
}
// 간편결제: GNU5 규격과 동일하게 모든 direct 파라미터를 기본값("" 또는 "A")으로 초기화한 뒤
// 선택된 수단만 "Y"로 덮어씀 — 초기값이 없으면 KCP가 이전 요청값을 재사용할 수 있음
if (isEasyPay) {
// GNU5 규격: 모든 direct 파라미터를 기본값으로 초기화 후 선택된 수단만 Y로 덮어씀
fields['payco_direct'] = '';
fields['naverpay_direct'] = 'A';
fields['kakaopay_direct'] = 'A';
fields['applepay_direct'] = 'A';
const directOverride = KCP_EASY_PAY_DIRECT[paymentMethod];
if (directOverride) {
Object.assign(fields, directOverride);
}
}
const hiddenInputs = Object.entries(fields)
.map(([n, v]) => `<input type="hidden" name="${n}" value="${v.replace(/"/g, '&quot;')}">`)
.join('');
// SDK 주입 직전 출처 확인 — 아래 iframe `document.write` 는 코어 로더를 쓸 수 없으므로
// (동기 실행 순서가 KCP_Pay_Execute 호출 계약이다) 검증만 선행한다. 실패 시 결제를
// 진행하지 않는다(fail-closed).
assertTrustedSdkUrl(config.sdk_url);
await new Promise<void>((resolve, reject) => {
// 기존 요소 정리
document.getElementById('kcp-sdk-iframe')?.remove();
document.getElementById('kcp-dim-overlay')?.remove();
// 메인 창에 반투명 dim 오버레이 (결제창 뒤 배경)
const overlay = document.createElement('div');
overlay.id = 'kcp-dim-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);z-index:99998;';
document.body.appendChild(overlay);
// 투명 iframe - KCP 결제창이 내부에 렌더링됨
const iframe = document.createElement('iframe');
iframe.id = 'kcp-sdk-iframe';
iframe.setAttribute('allowtransparency', 'true');
iframe.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;border:0;z-index:99999;background:transparent;';
document.body.appendChild(iframe);
const iframeWin = iframe.contentWindow as any;
let settled = false;
let sdkLoadTimer: number | undefined;
let stopCloseWatcher: (() => void) | undefined;
const cleanup = () => {
stopCloseWatcher?.();
stopCloseWatcher = undefined;
if (sdkLoadTimer !== undefined) {
clearTimeout(sdkLoadTimer);
sdkLoadTimer = undefined;
}
iframe.remove();
overlay.remove();
};
const rejectOnce = (error: Error) => {
if (settled) {
return;
}
settled = true;
cleanup();
reject(error);
};
const markCompletingPayment = () => {
settled = true;
stopCloseWatcher?.();
stopCloseWatcher = undefined;
if (sdkLoadTimer !== undefined) {
clearTimeout(sdkLoadTimer);
sdkLoadTimer = undefined;
}
};
stopCloseWatcher = watchKcpPaymentFrameClosure(iframe, () => {
rejectOnce(new KcpCancelledError('kcp-payment-frame-closed'));
});
// 결제 완료 콜백 - KCP가 결제 후 호출
iframeWin.m_Completepayment = function (form: HTMLFormElement) {
const resCode = (form.elements as any)['res_cd']?.value ?? '';
const resMsg = (form.elements as any)['res_msg']?.value ?? '';
if (resCode === '0000') {
markCompletingPayment();
// GNU5 패턴(GetField): KCP 결과를 order_info에 병합 후 order_info를 POST.
// KakaoPay 등 direct 결제에서 KCP가 ordr_idxx 없는 새 폼을 전달하므로
// form을 그대로 제출하면 서버 검증 실패(invalid_params).
// order_info(우리 주문 필드 포함)를 기준으로 병합해야 한다.
const orderForm = iframeWin.document.forms.namedItem('order_info') as HTMLFormElement | null;
const targetForm = (orderForm && orderForm !== form) ? orderForm : form;
if (orderForm && orderForm !== form) {
for (let i = 0; i < form.elements.length; i++) {
const el = form.elements[i] as HTMLInputElement;
if (!el.name) continue;
let dest = orderForm.elements.namedItem(el.name) as HTMLInputElement | null;
if (!dest) {
dest = iframeWin.document.createElement('input') as HTMLInputElement;
dest.type = 'hidden';
dest.name = el.name;
orderForm.appendChild(dest);
}
dest.value = el.value;
}
}
targetForm.action = callbackUrl;
targetForm.method = 'POST';
targetForm.target = '_top';
iframeWin.document.body.appendChild(targetForm);
targetForm.submit();
} else {
// 취소 또는 오류
const isCancelled = resCode === '' || resCode === '7777' || resMsg.includes('취소');
rejectOnce(isCancelled ? new KcpCancelledError(resMsg) : new Error(`KCP 오류 [${resCode}]: ${resMsg}`));
}
};
iframeWin.__kcpFail = (err: Error) => {
rejectOnce(err);
};
// SDK 동기 로드 + KCP_Pay_Execute 호출
const iframeDoc = (iframe.contentDocument || iframeWin.document) as Document;
iframeDoc.open();
iframeDoc.write(`<!DOCTYPE html><html><head>
<script src="${config.sdk_url}"><\/script>
</head><body style="margin:0;padding:0;background:transparent;">
<form name="order_info">${hiddenInputs}</form>
<script>
try {
if (typeof KCP_Pay_Execute === 'function') {
KCP_Pay_Execute(document.forms['order_info']);
window.__kcpReady && window.__kcpReady();
} else {
window.__kcpFail && window.__kcpFail(new Error('KCP_Pay_Execute not defined'));
}
} catch(e) {
window.__kcpFail && window.__kcpFail(e);
}
<\/script>
</body></html>`);
iframeDoc.close();
// SDK 로드 실패 대비 타임아웃 — 결제창이 열리면(__kcpReady) 즉시 해제됨
sdkLoadTimer = window.setTimeout(() => {
rejectOnce(new Error('KCP SDK load timeout'));
}, 15000);
iframeWin.__kcpReady = () => {
if (sdkLoadTimer !== undefined) {
clearTimeout(sdkLoadTimer);
sdkLoadTimer = undefined;
}
};
});
}