feat(ecommerce,pay): 확장 결제수단(간편결제) 1급화
PG 플러그인이 등록하는 간편결제(네이버페이 등)를 코어가 정식 결제수단으로 인식하도록 결제수단 카탈로그를 SSoT 로 전환했다. 확장 결제수단이 pg_provider=null 로 등록돼 서버가 "PG 결제가 아닌 주문"으로 오인, 결제 실패 주문에 관리자 신규주문 알림이 발송되고 TempOrder 가 즉시 삭제돼 재결제가 막히던 두 결함을 하나의 원인(능력 해석을 enum 이 답하지 못함)으로 근본 해결. - PaymentMethodResolver 신규: 능력(PG 필요/라벨/환불수단/PG 고정) 해석을 카탈로그 SSoT 로 통일, enum case 없는 확장 ID 도 동일 판정 - 프론트 결제수단 위장(card) 제거, provider-agnostic 결제 진입(pg_payment_handler) - 관리자 주문설정 결제수단 목록 PG 고정 3분기 UI (pg_locked/needs_pg) - 저장된 pg_provider=null 자가치유(read-time merge) + 백필 업그레이드 스텝 - 사용자 주문완료 화면 즉시 결제완료 헤더를 입금대기 여집합으로 판정(basic 1.0.3) - 전 계층 회귀 테스트: 훅 발화 스파이(관리자 알림 미발화/PG 환불 훅 진입), 주문 응답 계약(requires_pg_payment/pg_payment_handler)+TempOrder 보존, 관리자 3분기 렌더, 확장 E2E 인프라
This commit is contained in:
1 parent
10fe28a16d
commit
e896a47d4f
107 files changed
+4002
-1778
No files matched your search
@@ -4,6 +4,20 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.1] - 2026-07-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- 삼성페이·네이버페이·카카오페이·L.pay 등 KG 이니시스 간편결제로 주문할 때, 결제가 실패했는데도 관리자에게 "신규 주문 접수" 알림이 발송되던 문제를 수정했습니다.
|
||||
- 간편결제로 결제하다 실패한 뒤 다시 결제하려 하면 "임시주문을 찾을 수 없습니다" 오류가 뜨며 재결제가 불가능하던 문제를 수정했습니다. 이제 결제 실패 후에도 주문서가 유지됩니다.
|
||||
- 간편결제 주문을 취소할 때 카드 취소가 함께 이뤄지지 않을 수 있던 문제를 수정했습니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 간편결제가 정식 결제수단으로 취급되어 주문 내역에 실제로 선택한 결제수단(예: 네이버페이)이 그대로 기록됩니다.
|
||||
- 쇼핑몰 환경설정 > 주문설정의 결제수단 목록에서 간편결제가 "PG 고정 · KG이니시스"로 표시됩니다. 간편결제는 KG 이니시스 전용이므로 다른 결제대행사로 바꿀 수 없습니다.
|
||||
- 결제창을 띄우기 위해 브라우저 요청을 가로채 결제수단을 바꿔치기하던 내부 처리를 제거했습니다. 결제 진행 방식이 표준화되어 주문서 입력값이 초기화되는 등의 부작용이 사라집니다.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "plugins/sirsoft-pay_kginicis",
|
||||
"description": "KG Inicis PG Plugin for G7 platform",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"type": "library",
|
||||
"authors": [
|
||||
{
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
(function(){"use strict";const $="kginicis_pay_form_",D="__sirsoftKginicisReloadStandardPaySdk",b="__sirsoftKginicisMobilePaymentReturnPending";function he(){if(typeof document>"u")return 0;const e=document.querySelectorAll(`form[id^="${$}"]`);return e.forEach(t=>t.remove()),e.length}function gt(){typeof window>"u"||(window[D]=!0)}function _t(){if(typeof window>"u")return!1;const e=window,t=e[D]===!0;return delete e[D],t}function wt(e){typeof window>"u"||typeof document>"u"||(window.INIStdPay=void 0,document.querySelectorAll(`script[src="${e}"], script[src*="/INIStdPay_third-party.js"]`).forEach(t=>t.remove()))}function bt(e=Date.now()){if(!(typeof window>"u"))try{window.sessionStorage.setItem(b,String(e))}catch{window[b]=e}}function B(){if(!(typeof window>"u")){try{window.sessionStorage.removeItem(b)}catch{}delete window[b]}}function ge(e=Date.now()){if(typeof window>"u")return!1;let t=null;try{const n=window.sessionStorage.getItem(b);t=n?Number(n):null}catch{t=null}if(!Number.isFinite(t)){const n=window[b];t=typeof n=="number"?n:Number(n)}return!Number.isFinite(t)||e-t>18e5?(B(),!1):!0}const j="sirsoft-pay_kginicis",kt=j,Pt="payment-window-closed",_e="__sirsoftKginicisPaymentCloseListenerInstalled",Y="__sirsoftKginicisActiveStandardPaymentCloseContext",we=20,xt=100,U={info:(...e)=>console.info(`[${j}]`,...e),warn:(...e)=>console.warn(`[${j}]`,...e)};function Ct(e){if(!e||typeof e!="object")return!1;const t=e;return t.source===kt&&t.type===Pt}function H(){return/\/shop\/checkout\/?$/.test(window.location.pathname)}function A(){return window}function Et(){const e=A()[Y];return!e||typeof e!="object"?null:e}function vt(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}function St(e){e.closeReportUrl&&(A()[Y]={...e,reported:!1})}function be(){delete A()[Y]}function ke(){be()}async function It(e="payment-window-closed"){const t=Et();if(!t||t.reported||!H())return;t.reported=!0;const n={oid:t.oid,price:t.price,buyer_email:t.buyer_email??"",buyer_phone:t.buyer_phone??"",payment_method:t.payment_method??"",reason:e};try{const r=window.G7Core?.api;typeof r?.post=="function"?await r.post(t.closeReportUrl,n):await fetch(vt(t.closeReportUrl),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n),keepalive:!0})}catch(r){U.warn("failed to report KG payment window close",{reason:e,error:r})}finally{be()}}function Pe(e="payment-window-closed",t=!0){if(!H())return!1;const r=window.G7Core?.state?.setLocal;return typeof r!="function"?(t&&U.warn("G7Core.state.setLocal not available while resetting payment submit state"),!1):(he(),gt(),r({isSubmittingOrder:!1}),U.info("checkout submit state reset after KG payment close",{reason:e}),!0)}function At(e,t=!1){let n=0;const r=()=>{if(n++,!(t&&!ge())){if(Pe(e,n>=we)){t&&B();return}!H()||n>=we||window.setTimeout(r,xt)}};r()}function T(e){ge()&&At(e,!0)}function Tt(){if(typeof window>"u")return;const e=A();e[_e]||(window.addEventListener("message",t=>{t.origin===window.location.origin&&Ct(t.data)&&(It(t.data.reason),Pe(t.data.reason))}),window.addEventListener("pagehide",()=>{ke()}),window.addEventListener("beforeunload",()=>{ke()}),window.addEventListener("pageshow",t=>{T(t.persisted?"mobile-payment-bfcache-return":"mobile-payment-page-show")}),window.addEventListener("focus",()=>{T("mobile-payment-window-focus")}),document.addEventListener("visibilitychange",()=>{document.visibilityState==="visible"&&T("mobile-payment-visibility-return")}),e[_e]=!0,T("mobile-payment-listener-installed"))}function xe(){if(typeof navigator>"u")return!1;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(e.userAgent||"").toLowerCase(),n=((e.userAgentData?.platform??e.platform)||"").toLowerCase();if(/android|iphone|ipad|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||/iphone|ipad|ipod|ios/.test(n))return!0;const r=e.maxTouchPoints??0;return/macintosh|mac os x/.test(t)&&r>1}function Rt(e){return new Promise((t,n)=>{if(document.querySelector(`script[src="${e}"]`)){t();return}const r=document.createElement("script");r.src=e,r.async=!0,r.onload=()=>t(),r.onerror=()=>n(new Error(`Failed to load script: ${e}`)),document.head.appendChild(r)})}function Ce(e,t){if(!t.startsWith("kginicis_"))return e;const n=new URL(e,window.location.origin);return n.searchParams.set("selectedPaymentMethod",t),n.toString()}function Nt(e){const t=(e??"").trim().toUpperCase();return t===""||t==="WON"?"KRW":t}function Ee(e,t,n="utf-8",r){const i=document.createElement("form");r&&(i.id=r),i.method="POST",i.action=e,i.acceptCharset=n,i.style.display="none";for(const[o,a]of Object.entries(t)){const s=document.createElement("input");s.type="hidden",s.name=o,s.value=a,i.appendChild(s)}document.body.appendChild(i),i.submit()}const Mt={card:"Card",vbank:"VBank",bank:"DirectBank",phone:"HPP",kginicis_samsung_pay:"onlyssp",kginicis_naverpay:"onlynaverpay",kginicis_lLine truncated
|
||||
(function(){"use strict";const L="kginicis_pay_form_",M="__sirsoftKginicisReloadStandardPaySdk",w="__sirsoftKginicisMobilePaymentReturnPending";function de(){if(typeof document>"u")return 0;const e=document.querySelectorAll(`form[id^="${L}"]`);return e.forEach(t=>t.remove()),e.length}function at(){typeof window>"u"||(window[M]=!0)}function st(){if(typeof window>"u")return!1;const e=window,t=e[M]===!0;return delete e[M],t}function ct(e){typeof window>"u"||typeof document>"u"||(window.INIStdPay=void 0,document.querySelectorAll(`script[src="${e}"], script[src*="/INIStdPay_third-party.js"]`).forEach(t=>t.remove()))}function lt(e=Date.now()){if(!(typeof window>"u"))try{window.sessionStorage.setItem(w,String(e))}catch{window[w]=e}}function K(){if(!(typeof window>"u")){try{window.sessionStorage.removeItem(w)}catch{}delete window[w]}}function ue(e=Date.now()){if(typeof window>"u")return!1;let t=null;try{const n=window.sessionStorage.getItem(w);t=n?Number(n):null}catch{t=null}if(!Number.isFinite(t)){const n=window[w];t=typeof n=="number"?n:Number(n)}return!Number.isFinite(t)||e-t>18e5?(K(),!1):!0}const O="sirsoft-pay_kginicis",dt=O,ut="payment-window-closed",fe="__sirsoftKginicisPaymentCloseListenerInstalled",G="__sirsoftKginicisActiveStandardPaymentCloseContext",me=20,ft=100,$={info:(...e)=>console.info(`[${O}]`,...e),warn:(...e)=>console.warn(`[${O}]`,...e)};function mt(e){if(!e||typeof e!="object")return!1;const t=e;return t.source===dt&&t.type===ut}function D(){return/\/shop\/checkout\/?$/.test(window.location.pathname)}function E(){return window}function pt(){const e=E()[G];return!e||typeof e!="object"?null:e}function yt(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}function ht(e){e.closeReportUrl&&(E()[G]={...e,reported:!1})}function pe(){delete E()[G]}function ye(){pe()}async function gt(e="payment-window-closed"){const t=pt();if(!t||t.reported||!D())return;t.reported=!0;const n={oid:t.oid,price:t.price,buyer_email:t.buyer_email??"",buyer_phone:t.buyer_phone??"",payment_method:t.payment_method??"",reason:e};try{const i=window.G7Core?.api;typeof i?.post=="function"?await i.post(t.closeReportUrl,n):await fetch(yt(t.closeReportUrl),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n),keepalive:!0})}catch(i){$.warn("failed to report KG payment window close",{reason:e,error:i})}finally{pe()}}function he(e="payment-window-closed",t=!0){if(!D())return!1;const i=window.G7Core?.state?.setLocal;return typeof i!="function"?(t&&$.warn("G7Core.state.setLocal not available while resetting payment submit state"),!1):(de(),at(),i({isSubmittingOrder:!1}),$.info("checkout submit state reset after KG payment close",{reason:e}),!0)}function _t(e,t=!1){let n=0;const i=()=>{if(n++,!(t&&!ue())){if(he(e,n>=me)){t&&K();return}!D()||n>=me||window.setTimeout(i,ft)}};i()}function v(e){ue()&&_t(e,!0)}function wt(){if(typeof window>"u")return;const e=E();e[fe]||(window.addEventListener("message",t=>{t.origin===window.location.origin&&mt(t.data)&&(gt(t.data.reason),he(t.data.reason))}),window.addEventListener("pagehide",()=>{ye()}),window.addEventListener("beforeunload",()=>{ye()}),window.addEventListener("pageshow",t=>{v(t.persisted?"mobile-payment-bfcache-return":"mobile-payment-page-show")}),window.addEventListener("focus",()=>{v("mobile-payment-window-focus")}),document.addEventListener("visibilitychange",()=>{document.visibilityState==="visible"&&v("mobile-payment-visibility-return")}),e[fe]=!0,v("mobile-payment-listener-installed"))}function ge(){if(typeof navigator>"u")return!1;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(e.userAgent||"").toLowerCase(),n=((e.userAgentData?.platform??e.platform)||"").toLowerCase();if(/android|iphone|ipad|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||/iphone|ipad|ipod|ios/.test(n))return!0;const i=e.maxTouchPoints??0;return/macintosh|mac os x/.test(t)&&i>1}function bt(e){return new Promise((t,n)=>{if(document.querySelector(`script[src="${e}"]`)){t();return}const i=document.createElement("script");i.src=e,i.async=!0,i.onload=()=>t(),i.onerror=()=>n(new Error(`Failed to load script: ${e}`)),document.head.appendChild(i)})}function _e(e,t){if(!t.startsWith("kginicis_"))return e;const n=new URL(e,window.location.origin);return n.searchParams.set("selectedPaymentMethod",t),n.toString()}function kt(e){const t=(e??"").trim().toUpperCase();return t===""||t==="WON"?"KRW":t}function we(e,t,n="utf-8",i){const r=document.createElement("form");i&&(r.id=i),r.method="POST",r.action=e,r.acceptCharset=n,r.style.display="none";for(const[o,a]of Object.entries(t)){const s=document.createElement("input");s.type="hidden",s.name=o,s.value=a,r.appendChild(s)}document.body.appendChild(r),r.submit()}const Pt={card:"Card",vbank:"VBank",bank:"DirectBank",phone:"HPP",kginicis_samsung_pay:"onlyssp",kginicis_naverpay:"onlynaverpay",kginicis_lLine truncated
|
||||
<div class="row">
|
||||
<dt>${S(o.label)}</dt>
|
||||
<dd>${S(o.value)}</dd>
|
||||
<dt>${x(o.label)}</dt>
|
||||
<dd>${x(o.value)}</dd>
|
||||
</div>
|
||||
`).join("");t.document.open(),t.document.write(`<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${S(n)}</title>
|
||||
<title>${x(n)}</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
@@ -81,10 +81,11 @@
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>${S(n)}</h1>
|
||||
${r?`<p class="notice">${S(r)}</p>`:""}
|
||||
<dl>${i}</dl>
|
||||
<h1>${x(n)}</h1>
|
||||
${i?`<p class="notice">${x(i)}</p>`:""}
|
||||
<dl>${r}</dl>
|
||||
<div class="actions"><button type="button" onclick="window.print()">인쇄</button></div>
|
||||
</main>
|
||||
</body>
|
||||
</html>`),t.document.close()}function S(e){return e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}const F="sirsoft-pay_kginicis",Le="__kginicisOrderShowInjectorInstalled",W="kginicis-mp-receipt-row",Ke=/^(?:\/mypage\/orders\/([^/]+)|\/shop\/guest\/orders\/([^/]+))$/,q=new Map;function on(e){try{const n=window.G7Core?.getState,o=n?.()?.currentDataContext?.order?.data;return o&&(o.order_number===e||o.id!==void 0&&String(o.id)===e)?o:null}catch{return null}}function an(){const e=document.getElementById("order_payment_info_panel");if(e)return Array.from(e.children).find(r=>r.className?.includes("space-y"))??e;const t=Array.from(document.querySelectorAll("h3")).find(r=>{const i=r.textContent?.trim()??"";return i.includes("결제 정보")||/^Payment Information$/i.test(i)});if(!t)return null;const n=t.parentElement?.parentElement;return n?Array.from(n.children).find(r=>r.className?.includes("space-y"))??n:null}function sn(e,t){if(!t)return!1;const n=Array.from(e.querySelectorAll("div"));for(const r of n){const i=Array.from(r.children).filter(s=>s instanceof HTMLElement&&s.tagName==="SPAN");if(i.length<2)continue;const o=i[0].textContent?.trim();if(o!=="결제 방법"&&o!=="결제수단"&&o!=="Payment Method")continue;const a=i[i.length-1];return a.textContent?.trim()===t?(r.dataset.kginicisPaymentMethodRow="true",!0):(a.textContent=t,a.dataset.kginicisPaymentMethodPatched="true",r.dataset.kginicisPaymentMethodRow="true",!0)}return!1}function cn(e,t){const n=document.createElement("div");n.id=W,n.className="flex items-center justify-between",n.dataset.kginicisReceiptRow="mypage-order";const r=document.createElement("span");r.className="text-gray-500 dark:text-gray-400 text-sm",r.textContent=nn(t);const i=document.createElement("button");return i.type="button",i.dataset.kginicisReceiptButton="mypage-order",i.className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50",i.textContent=M(t),i.addEventListener("click",async()=>{i.disabled=!0,i.textContent="로딩 중...";const o=await N(e);i.disabled=!1,i.textContent=M(o??t),v(o)&&Me(o)}),n.appendChild(r),n.appendChild(i),n}async function dn(e,t){const n=on(e),r=n?.payment;if(r&&(r.pg_provider!=="kginicis"||!r.transaction_id))return!0;const i=n?.order_number??(t?e:null);if(!i)return!1;if(!t&&n&&!r)return!0;const o=an();if(!o)return!1;const{status:a,info:s}=await Ne(i);if(!t&&a===404)return!0;const c=r?.payment_status==="paid",l=s?.receipt_type==="cbt_confirmation";if(r&&!c&&!l)return!0;if(!r&&!v(s))return!1;const d=sn(o,s?.payment_method_display_label);return!document.getElementById(W)&&v(s)&&(o.appendChild(cn(i,s)),console.info(`[${F}] receipt button injected on mypage order show`)),d&&console.info(`[${F}] payment method display patched on mypage order show`),!!(document.getElementById(W)||d)}function ln(e,t){if(q.has(e))return;let n=0;const r=window.setInterval(()=>{n++,dn(e,t).then(i=>{(i||n>=30)&&(window.clearInterval(r),q.delete(e))})},400);q.set(e,r)}function un(){const e=location.pathname.match(Ke);if(e){const t=e[1],n=e[2],r=t??n;r&&ln(r,n!==void 0)}}function fn(){if(typeof window>"u")return;const e=window;if(e[Le])return;e[Le]=!0,console.info(`[${F}] mypage order show injector installed`);let t=null;const n=(s=1500)=>{t!==null&&window.clearTimeout(t),t=window.setTimeout(()=>{t=null,un()},s)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>n()):n();const r=history.pushState.bind(history);history.pushState=(...s)=>{r(...s),n(600)};const i=history.replaceState.bind(history);history.replaceState=(...s)=>{i(...s),n(600)},window.addEventListener("popstate",()=>n(500));const o=document.getElementById("app")??document.body;new MutationObserver(()=>{Ke.test(location.pathname)&&n(250)}).observe(o,{childList:!0,subtree:!0})}const z="sirsoft-pay_kginicis",Oe="__kginicisAdminPaymentDisplayInjectorInstalled",mn=/^\/admin\/ecommerce\/orders\/([^/]+)$/;function pn(){return localStorage.getItem("auth_token")}async function yn(e){const t=pn();if(!t)return null;try{const n=await fetch(`/api/plugins/${z}/admin/orders/${e}/transaction-status`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});if(!n.ok)return null;const r=await n.json();return("data"in r?r.data:r)??null}catch{return null}}function hn(e){return Array.from(e.children).filter(t=>t instanceof HTMLElement)}function gn(e,t){let n=!1;const r=Array.from(e.querySelectorAll("div"));for(const i of r){const o=hn(i),a=o.findIndex(u=>u.textContent?.trim()==="결제수단");if(a<0)continue;const s=o.slice(a+1).find(u=>u.textContent?.trim()&&u.textContent.trim()!=="결제수단");if(!s)continue;const c=o.slice(a+2).find(u=>/^\(.+\)$/.test(u.textContent?.trim()??"")),l=c?.textContent?.trim().replace(/^\(|\)$/g,"")??"",d=l&&t.endsWith(")")?t.replace(/\)$/,`, ${l})`):t;s.textContent?.trim()!==d&&(s.textContent=d,s.dataset.kginicisPaymentMethodPatched="true"),i.dataLine truncated
|
||||
</html>`),t.document.close()}function x(e){return e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}const B="sirsoft-pay_kginicis",Ee="__kginicisOrderShowInjectorInstalled",j="kginicis-mp-receipt-row",ve=/^(?:\/mypage\/orders\/([^/]+)|\/shop\/guest\/orders\/([^/]+))$/,Y=new Map;function Yt(e){try{const n=window.G7Core?.getState,o=n?.()?.currentDataContext?.order?.data;return o&&(o.order_number===e||o.id!==void 0&&String(o.id)===e)?o:null}catch{return null}}function Ut(){const e=document.getElementById("order_payment_info_panel");if(e)return Array.from(e.children).find(i=>i.className?.includes("space-y"))??e;const t=Array.from(document.querySelectorAll("h3")).find(i=>{const r=i.textContent?.trim()??"";return r.includes("결제 정보")||/^Payment Information$/i.test(r)});if(!t)return null;const n=t.parentElement?.parentElement;return n?Array.from(n.children).find(i=>i.className?.includes("space-y"))??n:null}function Ht(e,t){if(!t)return!1;const n=Array.from(e.querySelectorAll("div"));for(const i of n){const r=Array.from(i.children).filter(s=>s instanceof HTMLElement&&s.tagName==="SPAN");if(r.length<2)continue;const o=r[0].textContent?.trim();if(o!=="결제 방법"&&o!=="결제수단"&&o!=="Payment Method")continue;const a=r[r.length-1];return a.textContent?.trim()===t?(i.dataset.kginicisPaymentMethodRow="true",!0):(a.textContent=t,a.dataset.kginicisPaymentMethodPatched="true",i.dataset.kginicisPaymentMethodRow="true",!0)}return!1}function Jt(e,t){const n=document.createElement("div");n.id=j,n.className="flex items-center justify-between",n.dataset.kginicisReceiptRow="mypage-order";const i=document.createElement("span");i.className="text-gray-500 dark:text-gray-400 text-sm",i.textContent=Bt(t);const r=document.createElement("button");return r.type="button",r.dataset.kginicisReceiptButton="mypage-order",r.className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50",r.textContent=I(t),r.addEventListener("click",async()=>{r.disabled=!0,r.textContent="로딩 중...";const o=await S(e);r.disabled=!1,r.textContent=I(o??t),P(o)&&Ce(o)}),n.appendChild(i),n.appendChild(r),n}async function Ft(e,t){const n=Yt(e),i=n?.payment;if(i&&(i.pg_provider!=="kginicis"||!i.transaction_id))return!0;const r=n?.order_number??(t?e:null);if(!r)return!1;if(!t&&n&&!i)return!0;const o=Ut();if(!o)return!1;const{status:a,info:s}=await xe(r);if(!t&&a===404)return!0;const c=i?.payment_status==="paid",u=s?.receipt_type==="cbt_confirmation";if(i&&!c&&!u)return!0;if(!i&&!P(s))return!1;const l=Ht(o,s?.payment_method_display_label);return!document.getElementById(j)&&P(s)&&(o.appendChild(Jt(r,s)),console.info(`[${B}] receipt button injected on mypage order show`)),l&&console.info(`[${B}] payment method display patched on mypage order show`),!!(document.getElementById(j)||l)}function Wt(e,t){if(Y.has(e))return;let n=0;const i=window.setInterval(()=>{n++,Ft(e,t).then(r=>{(r||n>=30)&&(window.clearInterval(i),Y.delete(e))})},400);Y.set(e,i)}function qt(){const e=location.pathname.match(ve);if(e){const t=e[1],n=e[2],i=t??n;i&&Wt(i,n!==void 0)}}function zt(){if(typeof window>"u")return;const e=window;if(e[Ee])return;e[Ee]=!0,console.info(`[${B}] mypage order show injector installed`);let t=null;const n=(s=1500)=>{t!==null&&window.clearTimeout(t),t=window.setTimeout(()=>{t=null,qt()},s)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>n()):n();const i=history.pushState.bind(history);history.pushState=(...s)=>{i(...s),n(600)};const r=history.replaceState.bind(history);history.replaceState=(...s)=>{r(...s),n(600)},window.addEventListener("popstate",()=>n(500));const o=document.getElementById("app")??document.body;new MutationObserver(()=>{ve.test(location.pathname)&&n(250)}).observe(o,{childList:!0,subtree:!0})}const U="sirsoft-pay_kginicis",Se="__kginicisAdminPaymentDisplayInjectorInstalled",Vt=/^\/admin\/ecommerce\/orders\/([^/]+)$/;function Xt(){return localStorage.getItem("auth_token")}async function Zt(e){const t=Xt();if(!t)return null;try{const n=await fetch(`/api/plugins/${U}/admin/orders/${e}/transaction-status`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});if(!n.ok)return null;const i=await n.json();return("data"in i?i.data:i)??null}catch{return null}}function Qt(e){return Array.from(e.children).filter(t=>t instanceof HTMLElement)}function en(e,t){let n=!1;const i=Array.from(e.querySelectorAll("div"));for(const r of i){const o=Qt(r),a=o.findIndex(d=>d.textContent?.trim()==="결제수단");if(a<0)continue;const s=o.slice(a+1).find(d=>d.textContent?.trim()&&d.textContent.trim()!=="결제수단");if(!s)continue;const c=o.slice(a+2).find(d=>/^\(.+\)$/.test(d.textContent?.trim()??"")),u=c?.textContent?.trim().replace(/^\(|\)$/g,"")??"",l=u&&t.endsWith(")")?t.replace(/\)$/,`, ${u})`):t;s.textContent?.trim()!==l&&(s.textContent=l,s.dataset.kginicisPaymentMethodPatched="true"),r.dataLine truncated
|
||||
//# sourceMappingURL=plugin.iife.js.map
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-pay_kginicis",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@g7/sirsoft-pay_kginicis",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"devDependencies": {
|
||||
"jsdom": "^27.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-pay_kginicis",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "KG 이니시스",
|
||||
"en": "KG Inicis"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_kginicis",
|
||||
"github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_kginicis/blob/main/CHANGELOG.md",
|
||||
|
||||
+76
-95
@@ -1,127 +1,108 @@
|
||||
/**
|
||||
* 주문 응답 인터셉터 회귀 테스트
|
||||
* 주문 생성 인터셉터 테스트 — 이슈 #475
|
||||
*
|
||||
* KG 결제창을 띄운 뒤 체크아웃 템플릿의 fallback navigate 가 현재 경로를 다시
|
||||
* 렌더링하면 배송지/주문자 입력 상태가 초기화된다. 인터셉터가 그 자기 경로
|
||||
* navigate 1회만 차단하는지 검증한다.
|
||||
* 확장 결제수단이 1급 시민이 되면서 인터셉터의 우회가 전부 제거됐다:
|
||||
* - payment_method 가 'kginicis_*' 이면 'card' 로 위장 (서버 검증이 확장 ID 를 422 로 막음)
|
||||
* - 응답의 requires_pg_payment / redirect_url 변조 (navigate-to-self 강제)
|
||||
* - pg_payment_data 재구성
|
||||
* - navigate-to-self 로 인한 체크아웃 재렌더(입력값 소실)를 막는 navigate suppressor
|
||||
*
|
||||
* 위장이 서버로 하여금 간편결제 주문을 "PG 결제가 아닌 주문" 으로 오인하게 만들어
|
||||
* (a) 결제 실패 시 관리자 알림 오발송 (b) 임시주문 삭제 → 재결제 불가 를 일으켰다.
|
||||
*
|
||||
* 이제 결제창 진입은 서버 응답의 pg_payment_handler 를 템플릿이 dispatch 하는 경로로
|
||||
* 처리된다. 템플릿의 PG 분기는 navigate 를 하지 않으므로 suppressor 도 불필요하다.
|
||||
* 원본 결제수단(예: 'kginicis_lpay')은 서버가 pg_payment_data.payment_method 로 내려준다.
|
||||
*
|
||||
* 본 테스트는 인터셉터가 fetch / 라우터를 일절 건드리지 않음(no-op)을 회귀 방지로 고정한다.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { requestPaymentHandler } from '../handlers/requestPayment';
|
||||
import { installOrderResponseInterceptor } from '../orderResponseInterceptor';
|
||||
|
||||
vi.mock('../handlers/requestPayment', () => ({
|
||||
requestPaymentHandler: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
interface MutatedOrderResponse {
|
||||
data?: {
|
||||
redirect_url?: string;
|
||||
requires_pg_payment?: boolean;
|
||||
};
|
||||
}
|
||||
const ORDER_CREATE_URL = '/api/modules/sirsoft-ecommerce/user/orders';
|
||||
|
||||
function windowRecord(): Record<string, unknown> {
|
||||
return window as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function installTemplateRouter(navigate: ReturnType<typeof vi.fn>): { navigate: (path: string, options?: unknown) => unknown } {
|
||||
const router = { navigate };
|
||||
windowRecord()['__templateApp'] = { getRouter: () => router };
|
||||
return router;
|
||||
}
|
||||
|
||||
function mockOrderCreateFetch(): ReturnType<typeof vi.fn> {
|
||||
const responseBody = {
|
||||
success: true,
|
||||
data: {
|
||||
order: { order_number: 'ORD-001' },
|
||||
redirect_url: '/shop/orders/ORD-001/complete',
|
||||
requires_pg_payment: true,
|
||||
pg_provider: 'sirsoft-kginicis',
|
||||
pg_payment_data: {
|
||||
order_number: 'ORD-001',
|
||||
order_name: '테스트 주문',
|
||||
amount: 10000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(responseBody), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
window.fetch = fetchSpy as unknown as typeof fetch;
|
||||
return fetchSpy;
|
||||
}
|
||||
|
||||
async function createOrder(): Promise<MutatedOrderResponse> {
|
||||
const response = await window.fetch('/api/modules/sirsoft-ecommerce/user/orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ payment_method: 'card' }),
|
||||
});
|
||||
|
||||
return (await response.json()) as MutatedOrderResponse;
|
||||
}
|
||||
|
||||
describe('installOrderResponseInterceptor', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState({}, '', '/shop/checkout');
|
||||
vi.mocked(requestPaymentHandler).mockClear();
|
||||
window.history.replaceState({}, '', '/shop/checkout');
|
||||
vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
const w = windowRecord();
|
||||
const suppressor = w['__sirsoftKginicisNavigateSuppressor'] as { restore?: () => void } | undefined;
|
||||
suppressor?.restore?.();
|
||||
|
||||
delete w['__sirsoftKginicisInterceptorInstalled'];
|
||||
delete w['__sirsoftPgOriginalFetch'];
|
||||
delete w['__sirsoftKginicisNavigateSuppressor'];
|
||||
delete w['__templateApp'];
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('KG 주문 생성 응답 후 체크아웃 자기 경로 navigate 1회를 차단한다', async () => {
|
||||
const originalNavigate = vi.fn();
|
||||
const router = installTemplateRouter(originalNavigate);
|
||||
mockOrderCreateFetch();
|
||||
it('window.fetch 를 래핑하지 않는다 (no-op)', () => {
|
||||
const originalFetch = vi.fn();
|
||||
window.fetch = originalFetch as unknown as typeof fetch;
|
||||
|
||||
installOrderResponseInterceptor();
|
||||
|
||||
const body = await createOrder();
|
||||
|
||||
expect(requestPaymentHandler).toHaveBeenCalledWith({
|
||||
params: {
|
||||
pgPaymentData: expect.objectContaining({ order_number: 'ORD-001' }),
|
||||
paymentMethod: 'card',
|
||||
},
|
||||
});
|
||||
expect(body.data?.requires_pg_payment).toBe(false);
|
||||
expect(body.data?.redirect_url).toBe('/shop/checkout');
|
||||
|
||||
router.navigate(body.data?.redirect_url ?? '');
|
||||
|
||||
expect(originalNavigate).not.toHaveBeenCalled();
|
||||
|
||||
router.navigate('/shop/orders/ORD-001/complete');
|
||||
|
||||
expect(originalNavigate).toHaveBeenCalledWith('/shop/orders/ORD-001/complete');
|
||||
expect(window.fetch).toBe(originalFetch);
|
||||
});
|
||||
|
||||
it('현재 체크아웃 경로가 아닌 navigate 는 차단하지 않는다', async () => {
|
||||
const originalNavigate = vi.fn();
|
||||
const router = installTemplateRouter(originalNavigate);
|
||||
mockOrderCreateFetch();
|
||||
it('간편결제 payment_method 를 card 로 위장하지 않고 원본 그대로 전송한다', async () => {
|
||||
let sentBody = '';
|
||||
window.fetch = vi.fn().mockImplementation(async (_input: unknown, init?: RequestInit) => {
|
||||
sentBody = String(init?.body ?? '');
|
||||
return new Response('{}', { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
installOrderResponseInterceptor();
|
||||
|
||||
await createOrder();
|
||||
await window.fetch(ORDER_CREATE_URL, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ payment_method: 'kginicis_lpay' }),
|
||||
});
|
||||
|
||||
router.navigate('/shop/cart', { replace: true });
|
||||
// 위장이 부활하면 서버가 PG 결제 주문으로 인식하지 못해 #475 가 재발한다.
|
||||
expect(JSON.parse(sentBody).payment_method).toBe('kginicis_lpay');
|
||||
});
|
||||
|
||||
expect(originalNavigate).toHaveBeenCalledWith('/shop/cart', { replace: true });
|
||||
it('주문 생성 응답을 변조하지 않는다', async () => {
|
||||
const serverBody = {
|
||||
success: true,
|
||||
data: {
|
||||
order: { order_number: 'ORD-1' },
|
||||
redirect_url: '/shop/orders/ORD-1/complete',
|
||||
requires_pg_payment: true,
|
||||
pg_provider: 'sirsoft-kginicis',
|
||||
pg_payment_handler: 'sirsoft-pay_kginicis.requestPayment',
|
||||
},
|
||||
};
|
||||
window.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(serverBody), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
installOrderResponseInterceptor();
|
||||
|
||||
const response = await window.fetch(ORDER_CREATE_URL, { method: 'POST', body: '{}' });
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.data.requires_pg_payment).toBe(true);
|
||||
expect(body.data.redirect_url).toBe('/shop/orders/ORD-1/complete');
|
||||
expect(body.data.pg_payment_handler).toBe('sirsoft-pay_kginicis.requestPayment');
|
||||
});
|
||||
|
||||
it('라우터의 navigate 를 가로채지 않는다 (suppressor 제거)', () => {
|
||||
const navigate = vi.fn();
|
||||
windowRecord()['__templateApp'] = {
|
||||
getRouter: () => ({ navigate }),
|
||||
};
|
||||
|
||||
installOrderResponseInterceptor();
|
||||
|
||||
const router = (windowRecord()['__templateApp'] as { getRouter: () => { navigate: unknown } }).getRouter();
|
||||
|
||||
// suppressor 가 부활하면 navigate 가 패치된 함수로 교체된다.
|
||||
expect(router.navigate).toBe(navigate);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,12 @@ interface PgPaymentData {
|
||||
order_name: string;
|
||||
amount: number;
|
||||
currency?: string;
|
||||
/**
|
||||
* 서버가 저장한 결제수단 ID — 확장 수단이면 확장 ID 그대로(예: 'kginicis_lpay').
|
||||
* 이 값이 gopaymethod / CBT paymethod 매핑의 입력이 된다. 확장 결제수단이 1급 시민이
|
||||
* 되면서 서버가 원본 수단을 알고 내려주므로, 프론트 인터셉터가 따로 전달할 필요가 없다(#475).
|
||||
*/
|
||||
payment_method?: string;
|
||||
customer_name?: string;
|
||||
customer_email?: string;
|
||||
customer_phone?: string;
|
||||
@@ -616,7 +622,11 @@ export async function requestPaymentHandler(action: any, _context?: any): Promis
|
||||
}
|
||||
|
||||
const localState = window.__templateApp?.globalState?._local;
|
||||
const paymentMethod = paramPaymentMethod ?? localState?.paymentMethod ?? 'card';
|
||||
// 서버가 저장한 결제수단(payment_method)이 SSoT — 확장 수단이면 확장 ID 가 그대로 온다(#475).
|
||||
const paymentMethod = paramPaymentMethod
|
||||
?? pgPaymentData.payment_method
|
||||
?? localState?.paymentMethod
|
||||
?? 'card';
|
||||
|
||||
const G7Core = (window as any).G7Core;
|
||||
|
||||
|
||||
@@ -1,348 +1,46 @@
|
||||
/**
|
||||
* 주문 생성 API 응답 인터셉터
|
||||
* 주문 생성 응답 인터셉터 — 제거됨 (no-op)
|
||||
*
|
||||
* 체크아웃 템플릿(_checkout_summary.json)에는 'sirsoft-tosspayments' 분기만
|
||||
* 정의되어 있어서, 'sirsoft-kginicis' PG는 navigate 기본 분기로 떨어져
|
||||
* /shop/orders/{order_number}/complete 로 이동해버림 (결제창 미노출).
|
||||
* 과거에는 이 인터셉터가 다음 우회를 수행했다:
|
||||
*
|
||||
* 코어/템플릿 수정 없이 이 문제를 우회하기 위해 plugin loading 시점에
|
||||
* window.fetch 를 래핑해 다음을 수행:
|
||||
* 1. 요청 body 의 `payment_method` 가 'kginicis_*' 이면 'card' 로 위장
|
||||
* (서버 검증이 확장 ID 를 422 로 막았기 때문)
|
||||
* 2. 응답의 `requires_pg_payment` 를 false 로, `redirect_url` 을 self 로 변조해
|
||||
* 템플릿 navigate 를 무력화하고 결제창을 직접 띄움
|
||||
* 3. `pg_payment_data` 를 order 응답에서 직접 재구성
|
||||
* 4. navigate-to-self 로 인한 체크아웃 재렌더(입력값 소실)를 막기 위해
|
||||
* 라우터의 navigate 를 1회 가로채는 suppressor 설치
|
||||
*
|
||||
* 1. POST /api/modules/sirsoft-ecommerce/user/orders 응답을 가로챈다
|
||||
* 2. data.pg_provider === 'sirsoft-kginicis' 이면 requestPayment 핸들러를 직접 호출하여 결제창 띄움
|
||||
* 3. data.redirect_url 을 현재 URL로 교체하고 requires_pg_payment를 false로 변경
|
||||
* 4. 템플릿 fallback 분기의 navigate-to-self 1회를 차단해 체크아웃 입력 상태 보존
|
||||
* 이 우회는 두 개의 심각한 결함을 낳았다 — 서버가 간편결제 주문을 "PG 결제가 아닌 주문"
|
||||
* 으로 오인해 (a) 결제 실패했는데 관리자에게 신규주문 알림이 발송되고 (b) 임시주문이
|
||||
* 즉시 삭제되어 재결제가 불가능해졌다.
|
||||
*
|
||||
* 결과: 체크아웃 페이지에 머문 채 PG 팝업이 뜨고, PG 콜백이 정식 complete 페이지로 redirect.
|
||||
* 이제 코어가 확장 결제수단을 1급 시민으로 처리한다:
|
||||
* - 서버 검증이 결제수단 카탈로그를 화이트리스트로 사용 (확장 ID 통과)
|
||||
* - 플러그인이 `pg_provider` 를 자기 PG 로 고정 선언 → PG 결제 주문으로 정상 판정
|
||||
* - provider 가 선언한 `payment_handler` 가 응답의 `pg_payment_handler` 로 내려가고,
|
||||
* 템플릿이 그 핸들러를 직접 dispatch 한다 (navigate 분기 자체가 실행되지 않음)
|
||||
* - 원본 결제수단(예: 'kginicis_lpay')은 서버가 `pg_payment_data.payment_method` 로
|
||||
* 내려주므로 gopaymethod / CBT paymethod 매핑이 그대로 동작한다
|
||||
*
|
||||
* 템플릿이 PG 분기에서 navigate 를 하지 않으므로 navigate suppressor 도 불필요하다.
|
||||
*
|
||||
* 파일과 export 는 호출부(index.ts) 호환을 위해 유지하되 동작은 no-op 이다.
|
||||
*
|
||||
* @see https://github.com/gnuboard/dev-g7/issues/475
|
||||
*/
|
||||
|
||||
import { requestPaymentHandler } from './handlers/requestPayment';
|
||||
|
||||
const ORDER_CREATE_PATH = '/api/modules/sirsoft-ecommerce/user/orders';
|
||||
const TARGET_PG_PROVIDER = 'sirsoft-kginicis';
|
||||
const PLUGIN_IDENTIFIER = 'sirsoft-pay_kginicis';
|
||||
const NAVIGATE_SUPPRESSOR_KEY = '__sirsoftKginicisNavigateSuppressor';
|
||||
|
||||
const logger = {
|
||||
info: (...args: unknown[]) => console.info(`[${PLUGIN_IDENTIFIER}]`, ...args),
|
||||
warn: (...args: unknown[]) => console.warn(`[${PLUGIN_IDENTIFIER}]`, ...args),
|
||||
error: (...args: unknown[]) => console.error(`[${PLUGIN_IDENTIFIER}]`, ...args),
|
||||
};
|
||||
|
||||
interface OrderCreateResponseBody {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
data?: {
|
||||
order?: { order_number?: string };
|
||||
redirect_url?: string;
|
||||
requires_pg_payment?: boolean;
|
||||
pg_provider?: string;
|
||||
pg_payment_data?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
interface RouterLike {
|
||||
navigate: (path: string, options?: unknown) => unknown;
|
||||
}
|
||||
|
||||
interface NavigateSuppressorState {
|
||||
restore: () => void;
|
||||
timer: number;
|
||||
}
|
||||
|
||||
function extractPaymentMethodFromBody(body: string): string | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as Record<string, unknown>;
|
||||
return parsed['payment_method'] as string | undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* KG 이니시스 전용 결제수단 선택 시 _local.paymentMethod 가
|
||||
* 'kginicis_lpay', 'kginicis_japan_paypay' 등의 PG 식별자로 설정된다. 그러나 코어 PaymentMethodEnum 은
|
||||
* card/vbank/bank/phone 등 일반 결제수단만 허용하므로 백엔드 validation 에서
|
||||
* 'kginicis_*' 가 거부된다.
|
||||
* 주문 생성 인터셉터 — 더 이상 fetch 를 래핑하지 않는다.
|
||||
*
|
||||
* 본 헬퍼는 요청 body 의 payment_method 가 'kginicis_*' 형태이면 'card' 로
|
||||
* 치환한 새 body 를 반환한다 — UI/local state 는 PG 식별자를 그대로 유지해
|
||||
* requestPayment 핸들러의 gopaymethod/CBT paymethod 매핑이 정상 동작한다.
|
||||
*
|
||||
* gnuboard5 shop/inicis/lpay_form.1.php 의 P_INI_PAYMENT/gopaymethod 패턴과
|
||||
* 동일한 의미: '결제수단 = 신용카드, 간편결제 변종 = gopaymethod=LPAY'.
|
||||
* 결제창 진입은 코어 응답의 `pg_payment_handler` 를 템플릿이 dispatch 하는 경로로 처리된다.
|
||||
*/
|
||||
function rewriteEasyPayInBody(body: string): { rewritten: string; originalMethod: string | undefined } {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as Record<string, unknown>;
|
||||
const pm = parsed['payment_method'];
|
||||
if (typeof pm === 'string' && pm.startsWith('kginicis_')) {
|
||||
const original = pm;
|
||||
parsed['payment_method'] = 'card';
|
||||
return { rewritten: JSON.stringify(parsed), originalMethod: original };
|
||||
}
|
||||
return { rewritten: body, originalMethod: typeof pm === 'string' ? pm : undefined };
|
||||
} catch {
|
||||
return { rewritten: body, originalMethod: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
function extractUrl(input: RequestInfo | URL): string {
|
||||
if (typeof input === 'string') return input;
|
||||
if (input instanceof URL) return input.toString();
|
||||
if (typeof Request !== 'undefined' && input instanceof Request) return input.url;
|
||||
return String(input);
|
||||
}
|
||||
|
||||
function extractMethod(input: RequestInfo | URL, init?: RequestInit): string {
|
||||
if (init?.method) return init.method.toUpperCase();
|
||||
if (typeof Request !== 'undefined' && input instanceof Request) return input.method.toUpperCase();
|
||||
return 'GET';
|
||||
}
|
||||
|
||||
function isTargetEndpoint(url: string, method: string): boolean {
|
||||
if (method !== 'POST') return false;
|
||||
// 쿼리스트링/해시 제거 후 경로만 비교
|
||||
const path = url.split('?')[0].split('#')[0];
|
||||
return path === ORDER_CREATE_PATH || path.endsWith(ORDER_CREATE_PATH);
|
||||
}
|
||||
|
||||
function buildNoOpRedirectUrl(): string {
|
||||
return window.location.pathname + window.location.search + window.location.hash;
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
try {
|
||||
const url = new URL(path, window.location.origin);
|
||||
return url.pathname + url.search + url.hash;
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 체크아웃 템플릿은 KG 결제창을 띄운 뒤에도 fallback navigate 를 실행한다.
|
||||
* 현재 라우터는 같은 경로로 이동해도 route 를 다시 렌더링하므로 shipping/orderer
|
||||
* local state 가 초기화될 수 있다. 코어/템플릿을 건드리지 않고 이 플러그인에서
|
||||
* 현재 경로로 향하는 다음 navigate 1회만 소모한다.
|
||||
*/
|
||||
function suppressNextSamePathNavigate(targetPath: string): void {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const templateApp = w['__templateApp'] as { getRouter?: () => unknown } | undefined;
|
||||
const router = templateApp?.getRouter?.() as RouterLike | undefined;
|
||||
|
||||
if (!router || typeof router.navigate !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = w[NAVIGATE_SUPPRESSOR_KEY] as NavigateSuppressorState | undefined;
|
||||
existing?.restore();
|
||||
|
||||
const originalNavigate = router.navigate.bind(router);
|
||||
const normalizedTarget = normalizePath(targetPath);
|
||||
let restored = false;
|
||||
let consumed = false;
|
||||
let timer = 0;
|
||||
|
||||
const restore = (): void => {
|
||||
if (restored) {
|
||||
return;
|
||||
}
|
||||
|
||||
restored = true;
|
||||
if (router.navigate === patchedNavigate) {
|
||||
router.navigate = originalNavigate;
|
||||
}
|
||||
window.clearTimeout(timer);
|
||||
|
||||
const current = w[NAVIGATE_SUPPRESSOR_KEY] as NavigateSuppressorState | undefined;
|
||||
if (current?.restore === restore) {
|
||||
delete w[NAVIGATE_SUPPRESSOR_KEY];
|
||||
}
|
||||
};
|
||||
|
||||
const patchedNavigate: RouterLike['navigate'] = (path, options) => {
|
||||
const normalizedPath = normalizePath(path);
|
||||
|
||||
if (!consumed && normalizedPath === normalizedTarget) {
|
||||
consumed = true;
|
||||
logger.info('suppressed checkout self-navigation after PG popup', { path: normalizedPath });
|
||||
restore();
|
||||
return;
|
||||
}
|
||||
|
||||
restore();
|
||||
return originalNavigate(path, options);
|
||||
};
|
||||
|
||||
timer = window.setTimeout(restore, 3000);
|
||||
router.navigate = patchedNavigate;
|
||||
w[NAVIGATE_SUPPRESSOR_KEY] = { restore, timer };
|
||||
}
|
||||
|
||||
/**
|
||||
* 응답 본문을 mutate한 새 Response 객체 생성
|
||||
*
|
||||
* 원본 Response 의 status/headers 는 보존하고 본문만 재구성.
|
||||
*/
|
||||
function mutateResponse(originalResponse: Response, mutatedBody: OrderCreateResponseBody): Response {
|
||||
const json = JSON.stringify(mutatedBody);
|
||||
return new Response(json, {
|
||||
status: originalResponse.status,
|
||||
statusText: originalResponse.statusText,
|
||||
headers: originalResponse.headers,
|
||||
});
|
||||
}
|
||||
|
||||
export function installOrderResponseInterceptor(): void {
|
||||
if (typeof window === 'undefined' || typeof window.fetch !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 중복 설치 방지 — HMR / 다중 IIFE 로드 시
|
||||
const flag = '__sirsoftKginicisInterceptorInstalled' as const;
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
if (w[flag]) {
|
||||
return;
|
||||
}
|
||||
w[flag] = true;
|
||||
|
||||
// 최초 설치 플러그인이 원본 브라우저 fetch를 보존 (다른 PG 인터셉터가 쌓이기 전)
|
||||
const ORIGINAL_FETCH_KEY = '__sirsoftPgOriginalFetch';
|
||||
if (!w[ORIGINAL_FETCH_KEY]) {
|
||||
w[ORIGINAL_FETCH_KEY] = window.fetch.bind(window);
|
||||
}
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
|
||||
window.fetch = async function patchedFetch(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit
|
||||
): Promise<Response> {
|
||||
const url = extractUrl(input);
|
||||
const method = extractMethod(input, init);
|
||||
|
||||
if (!isTargetEndpoint(url, method)) {
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
|
||||
// 요청 body에서 payment_method 추출 (vbank, bank, phone 등 비카드 결제수단 감지)
|
||||
// 동시에 'kginicis_*' 간편결제 식별자는 'card' 로 치환하여 backend enum 검증을 통과시킨다.
|
||||
let paymentMethod: string | undefined;
|
||||
let mutatedInit: RequestInit | undefined = init;
|
||||
if (init?.body && typeof init.body === 'string') {
|
||||
const { rewritten, originalMethod } = rewriteEasyPayInBody(init.body);
|
||||
paymentMethod = originalMethod;
|
||||
if (rewritten !== init.body) {
|
||||
logger.info('rewrote request payment_method', { from: originalMethod, to: 'card' });
|
||||
mutatedInit = { ...init, body: rewritten };
|
||||
}
|
||||
}
|
||||
// G7Core 로컬 상태 fallback
|
||||
if (!paymentMethod) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
paymentMethod = ((window as any).G7Core)?.state?.getLocal?.()?.paymentMethod as string | undefined;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const response = await originalFetch(input, mutatedInit);
|
||||
|
||||
// 본문은 한 번만 읽을 수 있으므로 클론
|
||||
let cloned: Response;
|
||||
try {
|
||||
cloned = response.clone();
|
||||
} catch {
|
||||
return response;
|
||||
}
|
||||
|
||||
let body: OrderCreateResponseBody | null = null;
|
||||
try {
|
||||
body = (await cloned.json()) as OrderCreateResponseBody;
|
||||
} catch {
|
||||
// 비-JSON 응답이면 그대로 통과
|
||||
return response;
|
||||
}
|
||||
|
||||
const data = body?.data;
|
||||
if (!data) return response;
|
||||
|
||||
// 'kginicis_*' 결제수단 선택 시 기본 PG 가 다른 PG
|
||||
// 라도 KG 이니시스 결제창을 강제로 열어야 한다. 이때 backend 가 requires_pg_payment
|
||||
// 를 false 로 응답하거나 pg_provider 를 다른 PG 로 응답해도 KG 흐름으로 강제.
|
||||
const isKginicisEasyPay = typeof paymentMethod === 'string' && paymentMethod.startsWith('kginicis_');
|
||||
|
||||
const requiresPg = data.requires_pg_payment === true;
|
||||
const isKginicis = data.pg_provider === TARGET_PG_PROVIDER;
|
||||
|
||||
// 일반 결제: requires_pg_payment 가 true + pg_provider 가 KG 이니시스 → 처리
|
||||
// 간편결제(kginicis_*): pg_provider / requires_pg_payment 무관 → 강제 처리
|
||||
if (!isKginicisEasyPay && (!requiresPg || !isKginicis)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// pg_payment_data: backend 응답에 포함되거나, 간편결제 + 기본 PG 미설정/다른 PG 시
|
||||
// order 데이터에서 직접 구성 (NicePay 의 동일 패턴 차용)
|
||||
let pgPaymentData = data.pg_payment_data as Record<string, unknown> | undefined;
|
||||
if (!pgPaymentData && isKginicisEasyPay) {
|
||||
const orderData = (data as unknown as { order?: Record<string, unknown> }).order;
|
||||
if (orderData) {
|
||||
const options = orderData.options as Array<Record<string, unknown>> | undefined;
|
||||
const firstName = (options?.[0]?.product_name as string | undefined) ?? String(orderData.order_number ?? '');
|
||||
const orderName = (options?.length ?? 0) > 1
|
||||
? `${firstName} 외 ${(options?.length ?? 0) - 1}건`
|
||||
: firstName;
|
||||
pgPaymentData = {
|
||||
order_number: orderData.order_number,
|
||||
order_name: orderName,
|
||||
amount: Math.floor(Number(orderData.total_amount ?? 0)),
|
||||
currency: String(
|
||||
orderData.currency
|
||||
?? orderData.currency_code
|
||||
?? (paymentMethod?.startsWith('kginicis_japan_') ? 'JPY' : 'KRW')
|
||||
),
|
||||
customer_name: orderData.orderer_name ?? null,
|
||||
customer_email: orderData.orderer_email ?? null,
|
||||
customer_phone: String(orderData.orderer_phone ?? '').replace(/[^0-9]/g, ''),
|
||||
};
|
||||
logger.info('pg_payment_data constructed from order (기본 PG 미설정 또는 다른 PG)', {
|
||||
order_number: pgPaymentData.order_number,
|
||||
amount: pgPaymentData.amount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!pgPaymentData) {
|
||||
logger.warn('kginicis order detected but pg_payment_data missing');
|
||||
return response;
|
||||
}
|
||||
|
||||
logger.info('intercepted order create response — opening PG popup');
|
||||
|
||||
// 1) 결제창 호출 (비동기 — 팝업이 뜨도록 fire-and-forget)
|
||||
// 실패 시 requestPaymentHandler 내부에서 isSubmittingOrder=false 처리됨
|
||||
void requestPaymentHandler({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
params: { pgPaymentData: pgPaymentData as any, paymentMethod },
|
||||
});
|
||||
|
||||
// 2) 응답 mutate — 템플릿의 navigate fallback 을 무력화
|
||||
// - requires_pg_payment: false (혹시 다른 곳에서 참조해도 안전)
|
||||
// - redirect_url: 현재 URL (다음 navigate-to-self 1회는 플러그인에서 차단)
|
||||
const redirectUrl = buildNoOpRedirectUrl();
|
||||
suppressNextSamePathNavigate(redirectUrl);
|
||||
|
||||
const mutatedBody: OrderCreateResponseBody = {
|
||||
...body,
|
||||
data: {
|
||||
...data,
|
||||
requires_pg_payment: false,
|
||||
redirect_url: redirectUrl,
|
||||
},
|
||||
};
|
||||
|
||||
return mutateResponse(response, mutatedBody);
|
||||
};
|
||||
|
||||
logger.info('order response interceptor installed');
|
||||
logger.info('order interceptor is a no-op — payment entry is dispatched via pg_payment_handler');
|
||||
}
|
||||
+16
-37
@@ -7,7 +7,10 @@ namespace Plugins\Sirsoft\PayKginicis\Listeners;
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
|
||||
/**
|
||||
* 이커머스 결제수단 설정 화면에서 KG 이니시스 간편결제를 PG 선택 불필요 항목으로 표시한다.
|
||||
* 이커머스 결제수단 설정 화면에 KG 이니시스 테스트모드 경고를 주입한다.
|
||||
*
|
||||
* 간편결제의 PG 표시(PG 고정 배지)는 코어 레이아웃이 결제수단 카탈로그의 pg_locked /
|
||||
* needs_pg 를 읽어 직접 처리하므로 이 리스너가 관여하지 않는다(#475).
|
||||
*/
|
||||
class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterface
|
||||
{
|
||||
@@ -24,10 +27,6 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
|
||||
private const TEST_MODE_CONDITION = 'kginicis_test_mode_status.data?.is_test_mode === true';
|
||||
|
||||
private const CORE_NO_PG_METHODS = "['point','deposit','free','dbank']";
|
||||
|
||||
private const KGINICIS_NO_PG_METHODS = "['point','deposit','free','dbank','kginicis_samsung_pay','kginicis_naverpay','kginicis_lpay','kginicis_kakaopay','kginicis_japan_paypay','kginicis_japan_cvs']";
|
||||
|
||||
/**
|
||||
* 이 리스너가 구독하는 훅 정의를 반환합니다.
|
||||
*
|
||||
@@ -37,7 +36,7 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
{
|
||||
return [
|
||||
'core.layout_extension.after_apply' => [
|
||||
'method' => 'markEasyPayMethodsAsPgNotRequired',
|
||||
'method' => 'adjustPaymentMethodsLayout',
|
||||
'type' => 'filter',
|
||||
'priority' => 20,
|
||||
],
|
||||
@@ -52,50 +51,30 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
public function handle(...$args): void {}
|
||||
|
||||
/**
|
||||
* 이커머스 결제수단 설정 레이아웃에 KG 이니시스 간편결제를 PG 선택 불필요 항목으로 반영합니다.
|
||||
* 이커머스 결제수단 설정 레이아웃에 테스트모드 경고를 주입합니다.
|
||||
*
|
||||
* 과거에는 코어 레이아웃의 "PG 불필요 결제수단" 하드코딩 배열
|
||||
* (`['point','deposit','free','dbank']`)을 문자열 치환해 간편결제를 그 목록에 끼워 넣어
|
||||
* PG 선택 셀렉트를 숨겼다. 그러나 간편결제는 "PG 불필요" 가 아니라 "PG 고정" 이며,
|
||||
* 이 표시 왜곡은 실제로 서버가 간편결제를 PG 없는 주문으로 오인하던 결함과 짝을 이뤘다(#475).
|
||||
*
|
||||
* 이제 결제수단 카탈로그가 `pg_locked` / `needs_pg` 를 내려주고 코어 레이아웃이 그 값으로
|
||||
* 직접 3분기(PG 고정 배지 / PG 선택 / PG 불필요)하므로 문자열 치환이 불필요하다.
|
||||
* (플러그인 간 누적 치환 충돌 위험도 함께 사라진다.)
|
||||
*
|
||||
* @param array<string, mixed> $layout 대상 레이아웃 정의
|
||||
* @param int $templateId 레이아웃이 속한 템플릿 ID
|
||||
* @return array<string, mixed> 보정된 레이아웃 정의
|
||||
*/
|
||||
public function markEasyPayMethodsAsPgNotRequired(array $layout, int $templateId): array
|
||||
public function adjustPaymentMethodsLayout(array $layout, int $templateId): array
|
||||
{
|
||||
if (($layout['layout_name'] ?? '') !== self::TARGET_LAYOUT) {
|
||||
return $layout;
|
||||
}
|
||||
|
||||
$layout = $this->replaceNoPgMethodExpressions($layout);
|
||||
|
||||
return $this->ensureTestModeWarning($layout);
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 노드를 재귀 순회하며 코어의 PG 불필요 결제수단 목록을 이니시스 목록으로 치환합니다.
|
||||
*
|
||||
* @param array<string, mixed> $node 순회 대상 노드
|
||||
* @return array<string, mixed> 치환이 반영된 노드
|
||||
*/
|
||||
private function replaceNoPgMethodExpressions(array $node): array
|
||||
{
|
||||
foreach ($node as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$node[$key] = $this->replaceNoPgMethodExpressions($value);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($value) && str_contains($value, self::CORE_NO_PG_METHODS)) {
|
||||
$node[$key] = str_replace(
|
||||
self::CORE_NO_PG_METHODS,
|
||||
self::KGINICIS_NO_PG_METHODS,
|
||||
$value
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $node;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트 모드 경고에 필요한 데이터 소스와 안내 노드를 레이아웃에 보장합니다.
|
||||
*
|
||||
|
||||
+14
-10
@@ -25,6 +25,11 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
{
|
||||
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_kginicis';
|
||||
|
||||
/**
|
||||
* 이 플러그인이 제공하는 PG 식별자 (RegisterPgProviderListener 의 provider id 와 일치).
|
||||
*/
|
||||
private const PG_PROVIDER_ID = 'kginicis';
|
||||
|
||||
private const DOMESTIC_EASY_PAY_SETTINGS = [
|
||||
'kginicis_samsung_pay' => 'easy_pay_samsung_pay',
|
||||
'kginicis_naverpay' => 'easy_pay_naverpay',
|
||||
@@ -59,7 +64,7 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
* 이커머스 결제수단 목록에 KG 이니시스 전용 결제수단 inject.
|
||||
*
|
||||
* @param array $methods builtin 결제수단 배열 (코어 EcommerceSettingsService::getBuiltinPaymentMethods)
|
||||
* @return array KG 이니시스 entry 가 phone~point 사이에 삽입된 배열
|
||||
* @return array KG 이니시스 entry 가 phone~point 사이에 삽입된 배열
|
||||
*/
|
||||
public function injectEasyPayMethods(array $methods): array
|
||||
{
|
||||
@@ -153,8 +158,6 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
|
||||
/**
|
||||
* 간편결제 버튼에 브랜드 중심 설명을 사용할지 반환합니다.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function usesBrandButton(): bool
|
||||
{
|
||||
@@ -163,9 +166,6 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
|
||||
/**
|
||||
* 기존 플러그인 설정의 국내 간편결제 활성값을 읽습니다.
|
||||
*
|
||||
* @param string $id
|
||||
* @return bool
|
||||
*/
|
||||
private function domesticMethodEnabled(string $id): bool
|
||||
{
|
||||
@@ -178,7 +178,6 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
* KG 이니시스 플러그인 boolean 설정을 조회합니다.
|
||||
*
|
||||
* @param string $key 설정 키
|
||||
* @return bool
|
||||
*/
|
||||
private function settingEnabled(string $key): bool
|
||||
{
|
||||
@@ -207,12 +206,17 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
'icon' => $icon,
|
||||
'source' => 'plugin:sirsoft-pay_kginicis',
|
||||
'defaults' => [
|
||||
// PG 선택 불필요 — orderResponseInterceptor 가 prefix 'kginicis_' 를 인식해
|
||||
// 기본 PG 사 설정과 무관하게 KG 이니시스 결제 흐름으로 강제.
|
||||
'pg_provider' => null,
|
||||
// 간편결제는 KG 이니시스 결제창을 통해서만 처리되므로 PG 를 자기 자신으로 고정한다.
|
||||
// null 로 두면 코어가 PG 없는 결제수단으로 오인해 결제 실패 주문에 관리자 알림이
|
||||
// 발송되고 임시주문이 삭제되어 재결제가 막힌다(#475).
|
||||
'pg_provider' => self::PG_PROVIDER_ID,
|
||||
'pg_locked' => true,
|
||||
'needs_pg' => true,
|
||||
'refund_method' => 'pg',
|
||||
'is_active' => $isActive,
|
||||
'min_order_amount' => 0,
|
||||
'stock_deduction_timing' => 'payment_complete',
|
||||
'mileage_deduction_timing' => 'payment_complete',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
+38
-50
@@ -26,16 +26,11 @@ class RegisterPgProviderListener implements HookListenerInterface
|
||||
'kginicis_kakaopay' => 'easy_pay_kakaopay',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
* getSubscribedHooks
|
||||
|
||||
*
|
||||
|
||||
* @return array
|
||||
|
||||
*/
|
||||
|
||||
/**
|
||||
* 이 리스너가 구독하는 훅 정의를 반환합니다.
|
||||
*
|
||||
* @return array 훅 이름 => 구독 설정
|
||||
*/
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
@@ -73,25 +68,22 @@ class RegisterPgProviderListener implements HookListenerInterface
|
||||
'name' => localized_label(nameKey: 'sirsoft-pay_kginicis::provider.name'),
|
||||
'icon' => 'credit-card',
|
||||
'supported_methods' => ['card', 'bank_transfer', 'virtual_account', 'mobile'],
|
||||
// 코어의 provider-agnostic 결제 진입 dispatch — 주문 응답의 pg_payment_handler 로
|
||||
// 내려가 템플릿이 그대로 호출한다. 미선언 시 프론트가 응답을 변조해 결제창을
|
||||
// 직접 띄우는 우회가 필요해진다(#475).
|
||||
'payment_handler' => 'sirsoft-pay_kginicis.requestPayment',
|
||||
];
|
||||
|
||||
return $providers;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
* getClientConfig
|
||||
|
||||
*
|
||||
|
||||
* @param array $config
|
||||
|
||||
* @param string $provider
|
||||
|
||||
* @return array
|
||||
|
||||
*/
|
||||
|
||||
/**
|
||||
* KG 이니시스 결제창 진입에 필요한 클라이언트 설정을 반환합니다.
|
||||
*
|
||||
* @param array $config 기존 클라이언트 설정
|
||||
* @param string $provider 요청된 PG provider id
|
||||
* @return array KG 이니시스 설정이 병합된 클라이언트 설정 (다른 PG 면 원본 그대로)
|
||||
*/
|
||||
public function getClientConfig(array $config, string $provider): array
|
||||
{
|
||||
if ($provider !== 'kginicis') {
|
||||
@@ -111,32 +103,32 @@ class RegisterPgProviderListener implements HookListenerInterface
|
||||
? 'https://stgstdpay.inicis.com/stdjs/INIStdPay.js'
|
||||
: 'https://stdpay.inicis.com/stdjs/INIStdPay.js',
|
||||
'callback_urls' => [
|
||||
'signature' => '/plugins/sirsoft-pay_kginicis/payment/signature',
|
||||
'close_report' => '/plugins/sirsoft-pay_kginicis/payment/close-report',
|
||||
'callback' => '/plugins/sirsoft-pay_kginicis/payment/callback',
|
||||
'close' => '/plugins/sirsoft-pay_kginicis/payment/close',
|
||||
'cbt_checkout_token' => '/plugins/sirsoft-pay_kginicis/payment/cbt/checkout-token',
|
||||
'cbt_hash_data' => '/plugins/sirsoft-pay_kginicis/payment/cbt/hash-data',
|
||||
'cbt_callback' => '/plugins/sirsoft-pay_kginicis/payment/cbt/callback',
|
||||
'cbt_cvs_notify' => '/plugins/sirsoft-pay_kginicis/payment/cbt/cvs-notify',
|
||||
'cbt_auth_url' => $isTest ? self::CBT_AUTH_URL_TEST : self::CBT_AUTH_URL_LIVE,
|
||||
'mobile_signature' => '/plugins/sirsoft-pay_kginicis/payment/mobile/signature',
|
||||
'mobile_callback' => '/plugins/sirsoft-pay_kginicis/payment/mobile/callback',
|
||||
'signature' => '/plugins/sirsoft-pay_kginicis/payment/signature',
|
||||
'close_report' => '/plugins/sirsoft-pay_kginicis/payment/close-report',
|
||||
'callback' => '/plugins/sirsoft-pay_kginicis/payment/callback',
|
||||
'close' => '/plugins/sirsoft-pay_kginicis/payment/close',
|
||||
'cbt_checkout_token' => '/plugins/sirsoft-pay_kginicis/payment/cbt/checkout-token',
|
||||
'cbt_hash_data' => '/plugins/sirsoft-pay_kginicis/payment/cbt/hash-data',
|
||||
'cbt_callback' => '/plugins/sirsoft-pay_kginicis/payment/cbt/callback',
|
||||
'cbt_cvs_notify' => '/plugins/sirsoft-pay_kginicis/payment/cbt/cvs-notify',
|
||||
'cbt_auth_url' => $isTest ? self::CBT_AUTH_URL_TEST : self::CBT_AUTH_URL_LIVE,
|
||||
'mobile_signature' => '/plugins/sirsoft-pay_kginicis/payment/mobile/signature',
|
||||
'mobile_callback' => '/plugins/sirsoft-pay_kginicis/payment/mobile/callback',
|
||||
'mobile_vbank_notify' => '/plugins/sirsoft-pay_kginicis/payment/mobile/vbank-notify',
|
||||
],
|
||||
'japan_enabled' => $settings['japan_enabled'] ?? false,
|
||||
'japan_enabled' => $settings['japan_enabled'] ?? false,
|
||||
'japan_restrict_jpy_payment_methods' => (bool) ($settings['japan_restrict_jpy_payment_methods'] ?? false),
|
||||
'japan_configured' => $this->isJapanConfigured($settings, $isTest),
|
||||
'standard_configured' => $this->isStandardConfigured($settings, $isTest),
|
||||
'mobile_configured' => $this->isMobileConfigured($settings, $isTest),
|
||||
'use_escrow' => $settings['use_escrow'] ?? false,
|
||||
'japan_mid' => $isTest
|
||||
'japan_configured' => $this->isJapanConfigured($settings, $isTest),
|
||||
'standard_configured' => $this->isStandardConfigured($settings, $isTest),
|
||||
'mobile_configured' => $this->isMobileConfigured($settings, $isTest),
|
||||
'use_escrow' => $settings['use_escrow'] ?? false,
|
||||
'japan_mid' => $isTest
|
||||
? KgInicisApiService::JAPAN_TEST_MID
|
||||
: ($settings['live_japan_mid'] ?? ''),
|
||||
'cbt_extra_data' => $this->buildCbtExtraData($settings),
|
||||
'use_credit_point' => (bool) ($settings['use_credit_point'] ?? false),
|
||||
'easy_pay_show_brand_button' => (bool) ($settings['easy_pay_show_brand_button'] ?? false),
|
||||
'easy_pay_enabled_methods' => $this->enabledDomesticEasyPayMethods($settings),
|
||||
'cbt_extra_data' => $this->buildCbtExtraData($settings),
|
||||
'use_credit_point' => (bool) ($settings['use_credit_point'] ?? false),
|
||||
'easy_pay_show_brand_button' => (bool) ($settings['easy_pay_show_brand_button'] ?? false),
|
||||
'easy_pay_enabled_methods' => $this->enabledDomesticEasyPayMethods($settings),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -196,8 +188,6 @@ class RegisterPgProviderListener implements HookListenerInterface
|
||||
* 현재 모드의 표준결제 MID/signKey 준비 여부를 반환합니다.
|
||||
*
|
||||
* @param array<string, mixed> $settings
|
||||
* @param bool $isTest
|
||||
* @return bool
|
||||
*/
|
||||
private function isStandardConfigured(array $settings, bool $isTest): bool
|
||||
{
|
||||
@@ -214,8 +204,6 @@ class RegisterPgProviderListener implements HookListenerInterface
|
||||
* 현재 모드의 모바일결제 MID/hashKey 준비 여부를 반환합니다.
|
||||
*
|
||||
* @param array<string, mixed> $settings
|
||||
* @param bool $isTest
|
||||
* @return bool
|
||||
*/
|
||||
private function isMobileConfigured(array $settings, bool $isTest): bool
|
||||
{
|
||||
@@ -259,7 +247,7 @@ class RegisterPgProviderListener implements HookListenerInterface
|
||||
return '';
|
||||
}
|
||||
|
||||
return str_starts_with($suffix, self::LIVE_MID_PREFIX) ? $suffix : self::LIVE_MID_PREFIX . $suffix;
|
||||
return str_starts_with($suffix, self::LIVE_MID_PREFIX) ? $suffix : self::LIVE_MID_PREFIX.$suffix;
|
||||
}
|
||||
|
||||
private function getPluginSettings(): array
|
||||
|
||||
+32
-27
@@ -7,6 +7,17 @@ namespace Plugins\Sirsoft\PayKginicis\Tests\Unit\Listeners;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Plugins\Sirsoft\PayKginicis\Listeners\AdjustEcommercePaymentMethodsLayoutListener;
|
||||
|
||||
/**
|
||||
* 이커머스 결제수단 설정 레이아웃 보정 리스너 테스트 — 이슈 #475
|
||||
*
|
||||
* 과거에는 이 리스너가 코어 레이아웃의 "PG 불필요 결제수단" 하드코딩 배열을 문자열 치환해
|
||||
* 간편결제를 그 목록에 끼워 넣었다(= 관리자 화면에 "PG 불필요" 로 표시). 그러나 간편결제는
|
||||
* PG 가 불필요한 것이 아니라 특정 PG 에 고정된 수단이며, 이 표시 왜곡은 서버가 간편결제를
|
||||
* PG 없는 주문으로 오인하던 결함과 짝을 이뤘다.
|
||||
*
|
||||
* 이제 결제수단 카탈로그가 pg_locked / needs_pg 를 내려주고 코어 레이아웃이 그 값으로 직접
|
||||
* 분기하므로, 이 리스너의 책임은 테스트모드 경고 주입 하나만 남았다.
|
||||
*/
|
||||
class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
|
||||
{
|
||||
public function test_subscribes_to_layout_after_apply_as_filter(): void
|
||||
@@ -18,14 +29,17 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
|
||||
$hooks['core.layout_extension.after_apply']['type'] ?? null
|
||||
);
|
||||
$this->assertSame(
|
||||
'markEasyPayMethodsAsPgNotRequired',
|
||||
'adjustPaymentMethodsLayout',
|
||||
$hooks['core.layout_extension.after_apply']['method'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public function test_marks_kginicis_easy_pay_methods_as_pg_not_required_in_admin_ecommerce_settings(): void
|
||||
public function test_does_not_string_substitute_pg_branch_expressions(): void
|
||||
{
|
||||
$listener = new AdjustEcommercePaymentMethodsLayoutListener();
|
||||
// 코어 레이아웃은 이제 pg_locked / needs_pg 로 직접 분기한다.
|
||||
// 리스너가 표현식을 치환하면 코어의 분기 로직을 훼손하고,
|
||||
// 플러그인이 여럿 설치되면 서로의 치환 결과 위에 누적 적용되어 충돌한다.
|
||||
$listener = new AdjustEcommercePaymentMethodsLayoutListener;
|
||||
|
||||
$layout = [
|
||||
'layout_name' => 'admin_ecommerce_settings',
|
||||
@@ -33,34 +47,26 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
|
||||
[
|
||||
'type' => 'basic',
|
||||
'name' => 'Select',
|
||||
'if' => "{{!['point','deposit','free','dbank'].includes(\$method.id) && (_local.form?.available_pg_providers ?? []).length > 0}}",
|
||||
],
|
||||
[
|
||||
'type' => 'composite',
|
||||
'name' => 'Toggle',
|
||||
'props' => [
|
||||
'disabled' => "{{_computed.isReadOnly || !['point','deposit','free','dbank'].includes(\$method.id) && !\$method.pg_provider && !_local.form?.order_settings?.default_pg_provider}}",
|
||||
],
|
||||
'if' => '{{!$method.pg_locked && $method.needs_pg && (_local.form?.available_pg_providers ?? []).length > 0}}',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = $listener->markEasyPayMethodsAsPgNotRequired($layout, 1);
|
||||
$result = $listener->adjustPaymentMethodsLayout($layout, 1);
|
||||
$json = json_encode($result, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$this->assertIsString($json);
|
||||
$this->assertStringContainsString('kginicis_samsung_pay', $json);
|
||||
$this->assertStringContainsString('kginicis_naverpay', $json);
|
||||
$this->assertStringContainsString('kginicis_lpay', $json);
|
||||
$this->assertStringContainsString('kginicis_kakaopay', $json);
|
||||
$this->assertStringContainsString('kginicis_japan_paypay', $json);
|
||||
$this->assertStringContainsString('kginicis_japan_cvs', $json);
|
||||
$this->assertStringNotContainsString("['point','deposit','free','dbank'].includes", $json);
|
||||
// 코어 분기 표현식이 그대로 보존되어야 한다.
|
||||
$this->assertStringContainsString('$method.pg_locked', $json);
|
||||
$this->assertStringContainsString('$method.needs_pg', $json);
|
||||
// 간편결제 ID 를 표현식에 끼워 넣지 않는다.
|
||||
$this->assertStringNotContainsString('kginicis_naverpay', $json);
|
||||
$this->assertStringNotContainsString('kginicis_lpay', $json);
|
||||
}
|
||||
|
||||
public function test_adds_test_mode_warning_to_order_settings_tab(): void
|
||||
{
|
||||
$listener = new AdjustEcommercePaymentMethodsLayoutListener();
|
||||
$listener = new AdjustEcommercePaymentMethodsLayoutListener;
|
||||
|
||||
$layout = [
|
||||
'layout_name' => 'admin_ecommerce_settings',
|
||||
@@ -75,8 +81,9 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
|
||||
],
|
||||
];
|
||||
|
||||
$result = $listener->markEasyPayMethodsAsPgNotRequired($layout, 1);
|
||||
$result = $listener->markEasyPayMethodsAsPgNotRequired($result, 1);
|
||||
// 멱등성: 두 번 적용해도 경고 노드가 중복되지 않아야 한다.
|
||||
$result = $listener->adjustPaymentMethodsLayout($layout, 1);
|
||||
$result = $listener->adjustPaymentMethodsLayout($result, 1);
|
||||
$json = json_encode($result, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$this->assertIsString($json);
|
||||
@@ -99,17 +106,15 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
|
||||
|
||||
public function test_leaves_other_layouts_unchanged(): void
|
||||
{
|
||||
$listener = new AdjustEcommercePaymentMethodsLayoutListener();
|
||||
$listener = new AdjustEcommercePaymentMethodsLayoutListener;
|
||||
|
||||
$layout = [
|
||||
'layout_name' => 'shop/checkout',
|
||||
'components' => [
|
||||
[
|
||||
'if' => "{{!['point','deposit','free','dbank'].includes(\$method.id)}}",
|
||||
],
|
||||
['if' => '{{$method.needs_pg}}'],
|
||||
],
|
||||
];
|
||||
|
||||
$this->assertSame($layout, $listener->markEasyPayMethodsAsPgNotRequired($layout, 1));
|
||||
$this->assertSame($layout, $listener->adjustPaymentMethodsLayout($layout, 1));
|
||||
}
|
||||
}
|
||||
+21
-8
@@ -20,7 +20,7 @@ class RegisterEasyPayMethodsListenerTest extends PluginTestCase
|
||||
'japan_enabled' => true,
|
||||
]);
|
||||
|
||||
$listener = new RegisterEasyPayMethodsListener();
|
||||
$listener = new RegisterEasyPayMethodsListener;
|
||||
|
||||
$methods = $listener->injectEasyPayMethods([
|
||||
['id' => 'card'],
|
||||
@@ -43,7 +43,7 @@ class RegisterEasyPayMethodsListenerTest extends PluginTestCase
|
||||
], array_column($methods, 'id'));
|
||||
}
|
||||
|
||||
public function test_easy_pay_methods_do_not_require_pg_provider_in_saved_defaults(): void
|
||||
public function test_easy_pay_methods_are_locked_to_own_pg_provider(): void
|
||||
{
|
||||
$this->mockSettings([
|
||||
'easy_pay_samsung_pay' => false,
|
||||
@@ -53,7 +53,7 @@ class RegisterEasyPayMethodsListenerTest extends PluginTestCase
|
||||
'japan_enabled' => true,
|
||||
]);
|
||||
|
||||
$listener = new RegisterEasyPayMethodsListener();
|
||||
$listener = new RegisterEasyPayMethodsListener;
|
||||
|
||||
$methods = $listener->injectEasyPayMethods([
|
||||
['id' => 'phone'],
|
||||
@@ -67,10 +67,23 @@ class RegisterEasyPayMethodsListenerTest extends PluginTestCase
|
||||
|
||||
$this->assertCount(6, $easyPayMethods);
|
||||
|
||||
// 간편결제는 KG 이니시스 결제창을 통해서만 처리되므로 PG 를 자기 자신으로 고정 선언한다.
|
||||
//
|
||||
// 과거에는 pg_provider 를 null 로 두었고(= "PG 없는 결제수단"), 그 결과 서버가
|
||||
// 간편결제 주문을 PG 결제가 아닌 주문으로 오인해 (a) 결제 실패했는데 관리자에게
|
||||
// 신규주문 알림이 발송되고 (b) 임시주문이 즉시 삭제되어 재결제가 불가능해졌다(#475).
|
||||
foreach ($easyPayMethods as $method) {
|
||||
$this->assertArrayHasKey('defaults', $method);
|
||||
$this->assertNull($method['defaults']['pg_provider'] ?? null);
|
||||
|
||||
// PG 고정 — null 이면 코어가 PG 없는 주문으로 오인한다.
|
||||
$this->assertSame('kginicis', $method['defaults']['pg_provider'] ?? null);
|
||||
$this->assertTrue($method['defaults']['pg_locked'] ?? false);
|
||||
$this->assertTrue($method['defaults']['needs_pg'] ?? false);
|
||||
$this->assertSame('pg', $method['defaults']['refund_method'] ?? null);
|
||||
|
||||
$this->assertFalse($method['defaults']['is_active'] ?? true);
|
||||
$this->assertSame('payment_complete', $method['defaults']['stock_deduction_timing'] ?? null);
|
||||
$this->assertSame('payment_complete', $method['defaults']['mileage_deduction_timing'] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +96,7 @@ class RegisterEasyPayMethodsListenerTest extends PluginTestCase
|
||||
|
||||
// 테스트처럼 플러그인 설정이 아직 주입되지 않은 fallback 환경에서는
|
||||
// 기존 긴 설명을 유지한다. 브랜드 버튼 설정이 켜진 경우는 아래 테스트에서 별도 검증.
|
||||
$listener = new RegisterEasyPayMethodsListener();
|
||||
$listener = new RegisterEasyPayMethodsListener;
|
||||
|
||||
$methods = $listener->injectEasyPayMethods([
|
||||
['id' => 'phone'],
|
||||
@@ -103,7 +116,7 @@ class RegisterEasyPayMethodsListenerTest extends PluginTestCase
|
||||
'easy_pay_show_brand_button' => true,
|
||||
]);
|
||||
|
||||
$listener = new RegisterEasyPayMethodsListener();
|
||||
$listener = new RegisterEasyPayMethodsListener;
|
||||
|
||||
$methods = $listener->injectEasyPayMethods([
|
||||
['id' => 'phone'],
|
||||
@@ -128,7 +141,7 @@ class RegisterEasyPayMethodsListenerTest extends PluginTestCase
|
||||
'japan_enabled' => false,
|
||||
]);
|
||||
|
||||
$listener = new RegisterEasyPayMethodsListener();
|
||||
$listener = new RegisterEasyPayMethodsListener;
|
||||
|
||||
$methods = $listener->injectEasyPayMethods([
|
||||
['id' => 'phone'],
|
||||
@@ -161,7 +174,7 @@ class RegisterEasyPayMethodsListenerTest extends PluginTestCase
|
||||
'japan_enabled' => false,
|
||||
]);
|
||||
|
||||
$listener = new RegisterEasyPayMethodsListener();
|
||||
$listener = new RegisterEasyPayMethodsListener;
|
||||
|
||||
$methods = collect($listener->injectEasyPayMethods([
|
||||
['id' => 'phone'],
|
||||
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Tests\Unit\Upgrades;
|
||||
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Upgrades\Data\Ext\Plugins\SirsoftPayKginicis\V1_0_1\Migrations\BackfillEasyPayPgProvider;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
require_once dirname(__DIR__, 3).'/upgrades/data/1.0.1/migrations/BackfillEasyPayPgProvider.php';
|
||||
|
||||
/**
|
||||
* 저장된 주문설정의 KG 이니시스 간편결제 PG 고정 백필 테스트 — 이슈 #475
|
||||
*
|
||||
* 기존 설치 환경의 order_settings.json 에는 간편결제가 `pg_provider: null` 로 영속화되어
|
||||
* 있다. 서버가 이 값을 보고 간편결제 주문을 "PG 결제가 아닌 주문" 으로 오인해 결제 실패
|
||||
* 시 관리자 알림 오발송 + 임시주문 삭제(재결제 불가) 를 일으켰다.
|
||||
*/
|
||||
class BackfillEasyPayPgProviderTest extends TestCase
|
||||
{
|
||||
private string $settingsPath;
|
||||
|
||||
private bool $hadOriginalSettings = false;
|
||||
|
||||
private ?string $originalSettings = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->settingsPath = storage_path('app/modules/sirsoft-ecommerce/settings/order_settings.json');
|
||||
$this->hadOriginalSettings = File::exists($this->settingsPath);
|
||||
$this->originalSettings = $this->hadOriginalSettings ? File::get($this->settingsPath) : null;
|
||||
|
||||
File::ensureDirectoryExists(dirname($this->settingsPath));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if ($this->hadOriginalSettings && $this->originalSettings !== null) {
|
||||
File::put($this->settingsPath, $this->originalSettings);
|
||||
} else {
|
||||
File::delete($this->settingsPath);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario method_kind=extension, capability_declared=declared, capability=pg_locked
|
||||
*
|
||||
* @effects upgrade_step_backfills_settings_file, saved_null_pg_provider_self_healed
|
||||
*/
|
||||
public function test_backfills_pg_declaration_for_kginicis_easy_pay_methods(): void
|
||||
{
|
||||
// 결함 시절의 저장 상태 재현: pg_provider=null, 능력 키 부재
|
||||
$this->writeSettings([
|
||||
['id' => 'card', 'pg_provider' => 'kginicis'],
|
||||
['id' => 'kginicis_naverpay', 'pg_provider' => null, 'is_active' => true],
|
||||
['id' => 'kginicis_lpay', 'pg_provider' => null, 'is_active' => true],
|
||||
]);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$methods = $this->readMethods();
|
||||
|
||||
foreach (['kginicis_naverpay', 'kginicis_lpay'] as $id) {
|
||||
$method = $methods[$id];
|
||||
$this->assertSame('kginicis', $method['pg_provider'], "{$id} 의 PG 가 고정되어야 한다");
|
||||
$this->assertTrue($method['pg_locked']);
|
||||
$this->assertTrue($method['needs_pg']);
|
||||
$this->assertSame('pg', $method['refund_method']);
|
||||
// 관리자가 설정한 값은 보존한다.
|
||||
$this->assertTrue($method['is_active']);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_does_not_touch_other_plugins_or_builtin_methods(): void
|
||||
{
|
||||
// 각 플러그인은 자기 접두사 수단만 백필한다 (다른 PG 의 수단을 건드리면 안 된다).
|
||||
$this->writeSettings([
|
||||
['id' => 'card', 'pg_provider' => 'kginicis'],
|
||||
['id' => 'dbank', 'pg_provider' => ''],
|
||||
['id' => 'nhnkcp_naverpay', 'pg_provider' => null],
|
||||
['id' => 'kginicis_naverpay', 'pg_provider' => null],
|
||||
]);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$methods = $this->readMethods();
|
||||
|
||||
$this->assertSame('kginicis', $methods['card']['pg_provider']);
|
||||
$this->assertArrayNotHasKey('pg_locked', $methods['card']);
|
||||
|
||||
$this->assertSame('', $methods['dbank']['pg_provider']);
|
||||
$this->assertArrayNotHasKey('pg_locked', $methods['dbank']);
|
||||
|
||||
// 다른 PG 플러그인의 수단은 그 플러그인의 스텝이 처리한다.
|
||||
$this->assertNull($methods['nhnkcp_naverpay']['pg_provider']);
|
||||
$this->assertArrayNotHasKey('pg_locked', $methods['nhnkcp_naverpay']);
|
||||
|
||||
$this->assertSame('kginicis', $methods['kginicis_naverpay']['pg_provider']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario method_kind=extension, capability_declared=declared, capability=pg_locked
|
||||
*
|
||||
* @effects upgrade_step_backfills_settings_file
|
||||
*/
|
||||
public function test_is_idempotent(): void
|
||||
{
|
||||
$this->writeSettings([
|
||||
['id' => 'kginicis_naverpay', 'pg_provider' => null],
|
||||
]);
|
||||
|
||||
$this->runMigration();
|
||||
$first = File::get($this->settingsPath);
|
||||
|
||||
$this->runMigration();
|
||||
$second = File::get($this->settingsPath);
|
||||
|
||||
$this->assertSame($first, $second, '재실행해도 결과가 달라지지 않아야 한다');
|
||||
}
|
||||
|
||||
public function test_skips_when_settings_file_is_missing(): void
|
||||
{
|
||||
File::delete($this->settingsPath);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertFalse(File::exists($this->settingsPath));
|
||||
}
|
||||
|
||||
public function test_skips_when_settings_json_is_malformed(): void
|
||||
{
|
||||
File::put($this->settingsPath, '{ not valid json');
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
// 손상된 파일을 덮어써서 더 망가뜨리지 않는다.
|
||||
$this->assertSame('{ not valid json', File::get($this->settingsPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $paymentMethods
|
||||
*/
|
||||
private function writeSettings(array $paymentMethods): void
|
||||
{
|
||||
File::put($this->settingsPath, json_encode([
|
||||
'payment_methods' => $paymentMethods,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>> 결제수단 ID => 설정
|
||||
*/
|
||||
private function readMethods(): array
|
||||
{
|
||||
$settings = json_decode(File::get($this->settingsPath), true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
$keyed = [];
|
||||
foreach ($settings['payment_methods'] as $method) {
|
||||
$keyed[$method['id']] = $method;
|
||||
}
|
||||
|
||||
return $keyed;
|
||||
}
|
||||
|
||||
private function runMigration(): void
|
||||
{
|
||||
(new BackfillEasyPayPgProvider)->run(new UpgradeContext(
|
||||
fromVersion: '1.0.0',
|
||||
toVersion: '1.0.1',
|
||||
currentStep: '1.0.1',
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayKginicis\Upgrades;
|
||||
|
||||
use App\Extension\AbstractUpgradeStep;
|
||||
|
||||
/**
|
||||
* v1.0.1 업그레이드 스텝
|
||||
*
|
||||
* 저장된 이커머스 주문설정의 KG 이니시스 간편결제 항목에 PG 고정 선언을 백필한다.
|
||||
*
|
||||
* 모든 비즈니스 로직은 data/1.0.1/migrations/ 로 격리(AbstractUpgradeStep 규약).
|
||||
*
|
||||
* @upgrade-path B
|
||||
*/
|
||||
class Upgrade_1_0_1 extends AbstractUpgradeStep {}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Plugins\SirsoftPayKginicis\V1_0_1\Migrations;
|
||||
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 저장된 이커머스 주문설정의 KG 이니시스 간편결제 항목에 PG 고정 선언을 백필한다.
|
||||
*
|
||||
* 배경 (이슈 #475):
|
||||
*
|
||||
* 간편결제 수단은 과거 `pg_provider: null` 로 등록되어 저장 파일에 그대로 영속화됐다.
|
||||
* 서버는 이 값을 보고 간편결제 주문을 "PG 결제가 아닌 주문" 으로 오인했고, 그 결과
|
||||
* (a) 결제가 실패했는데 관리자에게 신규주문 알림이 발송되고 (b) 임시주문이 즉시 삭제되어
|
||||
* 재결제가 불가능해졌다.
|
||||
*
|
||||
* 현재 코드는 결제수단 카탈로그를 병합할 때 능력 선언(pg_provider / pg_locked / needs_pg /
|
||||
* refund_method)을 플러그인 정의에서 가져오므로, 저장 파일에 남은 낡은 값이 있어도 런타임
|
||||
* 동작은 이미 정상이다(자가 치유). 본 마이그레이션은 저장 파일 자체를 현재 선언과 일치시켜
|
||||
* 설정 화면과 파일 내용이 어긋나 보이지 않게 한다.
|
||||
*
|
||||
* V-1 안전 격리 (docs/extension/upgrade-step-guide.md §13):
|
||||
* - 파일 시스템 + FilePermissionHelper 만 사용 (이전 버전에도 존재하던 표면)
|
||||
* - Service / Manager / Repository 컨테이너 해석 없음
|
||||
*/
|
||||
final class BackfillEasyPayPgProvider implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 이커머스 모듈의 주문설정 저장 경로.
|
||||
*/
|
||||
private const SETTINGS_PATH = 'app/modules/sirsoft-ecommerce/settings/order_settings.json';
|
||||
|
||||
/**
|
||||
* 이 플러그인이 등록하는 간편결제 수단의 ID 접두사.
|
||||
*/
|
||||
private const METHOD_PREFIX = 'kginicis_';
|
||||
|
||||
/**
|
||||
* 이 플러그인이 제공하는 PG 식별자.
|
||||
*/
|
||||
private const PG_PROVIDER_ID = 'kginicis';
|
||||
|
||||
/**
|
||||
* 마이그레이션 식별자 (로그용).
|
||||
*
|
||||
* @return string 사람이 읽을 수 있는 짧은 식별자
|
||||
*/
|
||||
public function name(): string
|
||||
{
|
||||
return 'BackfillEasyPayPgProvider';
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 주문설정의 간편결제 항목에 PG 고정 선언을 백필한다. idempotent.
|
||||
*
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트 (로거 등)
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$path = storage_path(self::SETTINGS_PATH);
|
||||
|
||||
if (! File::exists($path)) {
|
||||
$context->logger->info('[kginicis] 이커머스 주문설정 파일 없음 — 기본값으로 동작하므로 skip');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$settings = json_decode(File::get($path), true);
|
||||
if (! is_array($settings) || ! is_array($settings['payment_methods'] ?? null)) {
|
||||
$context->logger->warning('[kginicis] 주문설정 JSON 형식 비정상 — 간편결제 PG 백필 skip');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$updated = 0;
|
||||
foreach ($settings['payment_methods'] as $index => $method) {
|
||||
if (! is_array($method)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = (string) ($method['id'] ?? '');
|
||||
if (! str_starts_with($id, self::METHOD_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$before = [
|
||||
$method['pg_provider'] ?? null,
|
||||
$method['pg_locked'] ?? null,
|
||||
$method['needs_pg'] ?? null,
|
||||
$method['refund_method'] ?? null,
|
||||
];
|
||||
|
||||
$method['pg_provider'] = self::PG_PROVIDER_ID;
|
||||
$method['pg_locked'] = true;
|
||||
$method['needs_pg'] = true;
|
||||
$method['refund_method'] = 'pg';
|
||||
|
||||
$after = [
|
||||
$method['pg_provider'],
|
||||
$method['pg_locked'],
|
||||
$method['needs_pg'],
|
||||
$method['refund_method'],
|
||||
];
|
||||
|
||||
if ($before !== $after) {
|
||||
$updated++;
|
||||
}
|
||||
|
||||
$settings['payment_methods'][$index] = $method;
|
||||
}
|
||||
|
||||
if ($updated === 0) {
|
||||
$context->logger->info('[kginicis] 간편결제 PG 선언이 이미 최신 — 변경 없음');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
File::put($path, json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||||
FilePermissionHelper::inheritOwnershipFromParent($path);
|
||||
|
||||
$context->logger->info('[kginicis] 간편결제 PG 고정 선언 백필 완료', [
|
||||
'pg_provider' => self::PG_PROVIDER_ID,
|
||||
'updated_methods' => $updated,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,15 @@
|
||||
- PC 결제창 close-report와 승인 콜백이 경쟁할 때 결제 성공 주문이 실패 처리로 덮이지 않도록 주문번호 단위 콜백 락과 최신 결제 상태 가드를 적용했습니다.
|
||||
- NHN KCP 부분취소 요청에서 `rem_mny`를 취소 후 잔액이 아닌 현재 부분취소 가능 금액으로 전달해 큰 금액 부분취소가 거절될 수 있던 문제를 수정했습니다.
|
||||
- 이커머스 주문설정 화면에서 NHN KCP 플러그인이 테스트 모드일 때 운영 전환 경고와 설정 이동 버튼을 표시하도록 보강했습니다.
|
||||
- PAYCO·네이버페이·카카오페이·애플페이 등 NHN KCP 간편결제로 주문할 때, 결제가 실패했는데도 관리자에게 "신규 주문 접수" 알림이 발송되던 문제를 수정했습니다.
|
||||
- 간편결제로 결제하다 실패한 뒤 다시 결제하려 하면 "임시주문을 찾을 수 없습니다" 오류가 뜨며 재결제가 불가능하던 문제를 수정했습니다. 이제 결제 실패 후에도 주문서가 유지됩니다.
|
||||
- 간편결제 주문을 취소할 때 카드 취소가 함께 이뤄지지 않을 수 있던 문제를 수정했습니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 간편결제가 정식 결제수단으로 취급되어 주문 내역에 실제로 선택한 결제수단(예: 네이버페이)이 그대로 기록됩니다.
|
||||
- 쇼핑몰 환경설정 > 주문설정의 결제수단 목록에서 간편결제가 "PG 고정 · NHN KCP"로 표시됩니다. 간편결제는 NHN KCP 전용이므로 다른 결제대행사로 바꿀 수 없습니다.
|
||||
- 결제창을 띄우기 위해 브라우저 요청을 가로채 결제수단을 바꿔치기하던 내부 처리를 제거했습니다. 결제 진행 방식이 표준화됩니다.
|
||||
|
||||
## [1.0.0-beta.5] - 2026-06-23
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
(function(){"use strict";function tt(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}function nt(e){return e.trim().slice(0,160)}function rt(e){if(e)return e.replace(/\/payment\/close-report$/,"/payment/retry")}async function ce(e,t,n=!1){const r=window.G7Core?.api;if(typeof r?.post=="function"){await r.post(e,t);return}const a=await fetch(tt(e),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t),keepalive:n});if(!a.ok){let o=`NHN KCP payment request failed (${a.status})`;try{const i=await a.json(),s=Array.isArray(i?.errors?.message)?i.errors.message[0]:i?.error??i?.message;typeof s=="string"&&s.trim()!==""&&(o=s)}catch{}throw new Error(o)}}async function at(e){const t=rt(e.closeReportUrl);t&&await ce(t,{oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??""})}async function ot(e,t="kcp-window-closed"){if(!e.closeReportUrl)return;const n={oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??"",reason:nt(t)||"kcp-window-closed"};try{await ce(e.closeReportUrl,n,!0)}catch(r){console.warn("[sirsoft-pay_nhnkcp] failed to report payment window close",r)}}const it="nhnkcp_applepay";function M(e){return e===it}function S(){if(typeof navigator>"u")return!1;const e=navigator,t=(e.userAgent||"").toLowerCase(),n=((e.userAgentData?.platform??e.platform)||"").toLowerCase(),r=e.maxTouchPoints??0;return/iphone|ipad|ipod/.test(t)||/iphone|ipad|ipod|ios/.test(n)?!0:/macintosh|mac os x/.test(t)&&r>1}function H(){return((typeof document<"u"?document.documentElement.lang:"")||"").toLowerCase().startsWith("ko")?"애플페이는 IOS 기기에 모바일결제만 가능합니다.":"Apple Pay is available only for mobile payments on iOS devices."}function B(e){return(e??"KRW").trim().toUpperCase()||"KRW"}function st(e){return B(e)==="KRW"}function ct(e){const t=B(e);return((typeof document<"u"?document.documentElement.lang:"")||"").toLowerCase().startsWith("ko")?`NHN KCP는 KRW 결제만 지원합니다. (${t})`:`NHN KCP supports KRW payments only. (${t})`}function dt(e){const t=Math.max(0,Math.round(Number(e.amount??0))),n=Math.min(Math.max(0,Math.round(Number(e.tax_free_amount??0))),t);if(n<=0)return{};const r=Math.max(0,Math.round(Number(e.tax_amount??0))),a=Math.max(0,Math.round(Number(e.vat_amount??0))),o=Math.max(0,t-n),i=o>0&&r>0&&a>0?Math.min(o,Math.round(o*(a/r))):0,s=o-i;return{tax_flag:"TG03",comm_tax_mny:String(s),comm_vat_mny:String(i),comm_free_mny:String(n)}}class D extends Error{constructor(t){super(t??"결제가 취소되었습니다."),this.name="KcpCancelledError"}}const lt={card:"100000000000",bank_transfer:"010000000000",virtual_account:"001000000000",mobile:"000010000000",bank:"010000000000",vbank:"001000000000",phone:"000010000000"},de={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"}};function ut(e,t){const n=e.toLowerCase();return!t||!Object.prototype.hasOwnProperty.call(de,n)?{}:{param_opt_1:n,nhnkcp_easy_pay_method:n}}function pt(e){if(!e.isConnected)return!0;const t=window.getComputedStyle(e);if(t.display==="none"||t.visibility==="hidden")return!0;const n=e.getBoundingClientRect();return n.width<=0||n.height<=0}function mt(e,t){let n=!1,r,a;const o=()=>{n=!0,a&&(a.disconnect(),a=void 0),r!==void 0&&(window.clearInterval(r),r=void 0)},i=()=>{n||!pt(e)||(o(),t())},s=document.body??document.documentElement;return s&&typeof MutationObserver<"u"&&(a=new MutationObserver(i),a.observe(s,{attributes:!0,attributeFilter:["class","hidden","style"],childList:!0,subtree:!0})),r=window.setInterval(i,300),o}function yt(){if(typeof navigator>"u")return!1;if(S())return!0;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(navigator.userAgent||"").toLowerCase();return!!(/android|iphone|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||(navigator.maxTouchPoints??0)>1&&!t.includes("windows")&&!t.includes("macintosh"))}async function le(e,t){const{pgPaymentData:n,paymentMethod:r}=e.params||{};if(!n){console.error("[sirsoft-pay_nhnkcp] pgPaymentData is required");return}const a=r??n.pay_method??"card",o=typeof a=="string"&&a.startsWith("nhnkcp_"),i=window.G7Core;let s=null;try{if(!st(n.currency)){const u=ct(n.currency);i?.state?.setLocal?.({paymentErrorMessage:u,isSubmittingOrder:!1,paymentMethod:a}),i?.modal?.open?.("nhnkcp_payment_error_modal");return}if(M(a)&&!S()){const u=H();i?.state?.setLocal?.({paymentErrorMessage:u,isSubmittingOrder:!1,paymentMethod:a}),i?.modal?.open?.("nhnkcp_payment_error_modal");return}const c=await i.api.get("/modules/sirsoft-ecommerce/payments/client-config/nhnkcp");if(!c.data){console.error("[sirsoft-pay_nhnkcpLine truncated
|
||||
(function(){"use strict";function Je(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}function Qe(e){return e.trim().slice(0,160)}function Xe(e){if(e)return e.replace(/\/payment\/close-report$/,"/payment/retry")}async function oe(e,t,n=!1){const r=window.G7Core?.api;if(typeof r?.post=="function"){await r.post(e,t);return}const a=await fetch(Je(e),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t),keepalive:n});if(!a.ok){let o=`NHN KCP payment request failed (${a.status})`;try{const i=await a.json(),s=Array.isArray(i?.errors?.message)?i.errors.message[0]:i?.error??i?.message;typeof s=="string"&&s.trim()!==""&&(o=s)}catch{}throw new Error(o)}}async function Ze(e){const t=Xe(e.closeReportUrl);t&&await oe(t,{oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??""})}async function et(e,t="kcp-window-closed"){if(!e.closeReportUrl)return;const n={oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??"",reason:Qe(t)||"kcp-window-closed"};try{await oe(e.closeReportUrl,n,!0)}catch(r){console.warn("[sirsoft-pay_nhnkcp] failed to report payment window close",r)}}const tt="nhnkcp_applepay";function N(e){return e===tt}function P(){if(typeof navigator>"u")return!1;const e=navigator,t=(e.userAgent||"").toLowerCase(),n=((e.userAgentData?.platform??e.platform)||"").toLowerCase(),r=e.maxTouchPoints??0;return/iphone|ipad|ipod/.test(t)||/iphone|ipad|ipod|ios/.test(n)?!0:/macintosh|mac os x/.test(t)&&r>1}function M(){return((typeof document<"u"?document.documentElement.lang:"")||"").toLowerCase().startsWith("ko")?"애플페이는 IOS 기기에 모바일결제만 가능합니다.":"Apple Pay is available only for mobile payments on iOS devices."}function T(e){return(e??"KRW").trim().toUpperCase()||"KRW"}function nt(e){return T(e)==="KRW"}function rt(e){const t=T(e);return((typeof document<"u"?document.documentElement.lang:"")||"").toLowerCase().startsWith("ko")?`NHN KCP는 KRW 결제만 지원합니다. (${t})`:`NHN KCP supports KRW payments only. (${t})`}function at(e){const t=Math.max(0,Math.round(Number(e.amount??0))),n=Math.min(Math.max(0,Math.round(Number(e.tax_free_amount??0))),t);if(n<=0)return{};const r=Math.max(0,Math.round(Number(e.tax_amount??0))),a=Math.max(0,Math.round(Number(e.vat_amount??0))),o=Math.max(0,t-n),i=o>0&&r>0&&a>0?Math.min(o,Math.round(o*(a/r))):0,s=o-i;return{tax_flag:"TG03",comm_tax_mny:String(s),comm_vat_mny:String(i),comm_free_mny:String(n)}}class $ extends Error{constructor(t){super(t??"결제가 취소되었습니다."),this.name="KcpCancelledError"}}const ot={card:"100000000000",bank_transfer:"010000000000",virtual_account:"001000000000",mobile:"000010000000",bank:"010000000000",vbank:"001000000000",phone:"000010000000"},ie={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"}};function it(e,t){const n=e.toLowerCase();return!t||!Object.prototype.hasOwnProperty.call(ie,n)?{}:{param_opt_1:n,nhnkcp_easy_pay_method:n}}function st(e){if(!e.isConnected)return!0;const t=window.getComputedStyle(e);if(t.display==="none"||t.visibility==="hidden")return!0;const n=e.getBoundingClientRect();return n.width<=0||n.height<=0}function ct(e,t){let n=!1,r,a;const o=()=>{n=!0,a&&(a.disconnect(),a=void 0),r!==void 0&&(window.clearInterval(r),r=void 0)},i=()=>{n||!st(e)||(o(),t())},s=document.body??document.documentElement;return s&&typeof MutationObserver<"u"&&(a=new MutationObserver(i),a.observe(s,{attributes:!0,attributeFilter:["class","hidden","style"],childList:!0,subtree:!0})),r=window.setInterval(i,300),o}function dt(){if(typeof navigator>"u")return!1;if(P())return!0;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(navigator.userAgent||"").toLowerCase();return!!(/android|iphone|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||(navigator.maxTouchPoints??0)>1&&!t.includes("windows")&&!t.includes("macintosh"))}async function lt(e,t){const{pgPaymentData:n,paymentMethod:r}=e.params||{};if(!n){console.error("[sirsoft-pay_nhnkcp] pgPaymentData is required");return}const a=r??n.payment_method??n.pay_method??"card",o=typeof a=="string"&&a.startsWith("nhnkcp_"),i=window.G7Core;let s=null;try{if(!nt(n.currency)){const u=rt(n.currency);i?.state?.setLocal?.({paymentErrorMessage:u,isSubmittingOrder:!1,paymentMethod:a}),i?.modal?.open?.("nhnkcp_payment_error_modal");return}if(N(a)&&!P()){const u=M();i?.state?.setLocal?.({paymentErrorMessage:u,isSubmittingOrder:!1,paymentMethod:a}),i?.modal?.open?.("nhnkcp_payment_error_modal");return}const c=await i.api.get("/modules/sirsoft-ecommerce/payments/client-config/nhnkcp");if(!c.data){console.error("[Line truncated
|
||||
<script src="${e.sdk_url}"><\/script>
|
||||
</head><body style="margin:0;padding:0;background:transparent;">
|
||||
<form name="order_info">${c}</form>
|
||||
@@ -14,4 +14,5 @@ try {
|
||||
window.__kcpFail && window.__kcpFail(e);
|
||||
}
|
||||
<\/script>
|
||||
</body></html>`),C.close(),h=window.setTimeout(()=>{v(new Error("KCP SDK load timeout"))},15e3),y.__kcpReady=()=>{h!==void 0&&(clearTimeout(h),h=void 0)}})}const _t={nhnkcp_payco:"PAYCO",nhnkcp_naverpay:"네이버페이",nhnkcp_naverpay_point:"네이버페이 포인트",nhnkcp_kakaopay:"카카오페이",nhnkcp_applepay:"Apple Pay"},gt="0 0 0 2px #ffffff, 0 0 0 5px rgba(0,0,0,0.55)";function ue(){return document.querySelector(".nhnkcp-easy-pay-btns")??null}function pe(){const e=ue();e&&e.querySelectorAll("button").forEach(t=>{t.style.boxShadow="",t.style.outline=""})}function wt(e){const t=ue();if(!t)return;const n=_t[e];t.querySelectorAll("button").forEach(r=>{r.textContent?.trim()===n?(r.style.boxShadow=gt,r.style.outline="none"):(r.style.boxShadow="",r.style.outline="")})}function Pt(e){const t=e.params?.paymentMethod;if(!t)return;const n=window.G7Core;if(M(t)&&!S()){const a=H();n?.state?.setLocal?.({paymentErrorMessage:a,isSubmittingOrder:!1}),n?.modal?.open?.("nhnkcp_payment_error_modal"),pe();return}const r=typeof t=="string"&&t.startsWith("nhnkcp_");n?.state?.setLocal?.({paymentMethod:t,serverPaymentMethod:r?"card":t}),r?wt(t):pe()}function kt(e){const t=document.createElement("textarea");t.value=e,t.setAttribute("readonly","readonly"),t.style.position="fixed",t.style.left="-9999px",t.style.top="0",document.body.appendChild(t),t.select();try{document.execCommand("copy")}finally{t.remove()}}async function bt(e){const t=String(e.params?.text??"");if(t!==""){if(navigator.clipboard&&typeof navigator.clipboard.writeText=="function"){await navigator.clipboard.writeText(t);return}kt(t)}}const me={requestPayment:le,setPaymentMethod:Pt,copyToClipboard:bt},ye="/api/modules/sirsoft-ecommerce/user/orders",Ct="sirsoft-nhnkcp",j="sirsoft-pay_nhnkcp",fe="nhnkcp_",b={info:(...e)=>console.info(`[${j}]`,...e),warn:(...e)=>console.warn(`[${j}]`,...e),error:(...e)=>console.error(`[${j}]`,...e)};function Nt(e){return typeof e=="string"?e:e instanceof URL?e.toString():typeof Request<"u"&&e instanceof Request?e.url:String(e)}function xt(e,t){return t?.method?t.method.toUpperCase():typeof Request<"u"&&e instanceof Request?e.method.toUpperCase():"GET"}function vt(e,t){if(t!=="POST")return!1;const n=e.split("?")[0].split("#")[0];return n===ye||n.endsWith(ye)}function Et(){return window.location.pathname+window.location.search+window.location.hash}function At(e,t){const n=JSON.stringify(t);return new Response(n,{status:e.status,statusText:e.statusText,headers:e.headers})}function St(e){return new Response(JSON.stringify({success:!1,message:e,error:e,errors:{payment_method:[e]}}),{status:422,statusText:"Unprocessable Content",headers:{"Content-Type":"application/json"}})}function he(e){try{return JSON.parse(e).payment_method}catch{return}}function Kt(e,t){try{const n=JSON.parse(e);return n.payment_method=t,JSON.stringify(n)}catch{return e}}function It(){if(typeof window>"u"||typeof window.fetch!="function")return;const e="__sirsoftNhnkcpInterceptorInstalled",t=window;if(t[e])return;t[e]=!0;const n="__sirsoftPgOriginalFetch";t[n]||(t[n]=window.fetch.bind(window));const r=t[n],a=window.fetch.bind(window);window.fetch=async function(i,s){const c=Nt(i),d=xt(i,s);if(!vt(c,d))return a(i,s);let l,u=s;if(s?.body&&typeof s.body=="string"){const p=he(s.body);typeof p=="string"&&p.startsWith(fe)&&(l=p,u={...s,body:Kt(s.body,"card")},b.info(`easy pay detected: replacing payment_method '${p}' → 'card'`))}if(!l)try{const p=window.G7Core?.state?.getLocal?.()?.paymentMethod;typeof p=="string"&&p.startsWith(fe)&&(l=p,b.info(`easy pay detected from local state: '${p}'`))}catch{}if(M(l)&&!S()){const p=H();return b.warn(p),St(p)}const y=await(l?r:a)(i,u);let x;try{x=y.clone()}catch{return y}let h=null;try{h=await x.json()}catch{return y}const f=h?.data;if(!f)return y;const se=f.requires_pg_payment===!0,v=f.pg_provider===Ct;if(!l&&(!se||!v)||l&&!f.order&&!f.pg_payment_data)return y;let w=f.pg_payment_data;if(!w&&l&&f.order){const p=f.order,g=p.options,k=g?.[0]?.product_name??String(p.order_number??""),I=(g?.length??0)>1?`${k} 외 ${(g?.length??0)-1}건`:k;w={order_number:p.order_number,order_name:I,amount:Math.floor(Number(p.total_due_amount??p.total_amount??0)),currency:"KRW",customer_name:p.orderer_name??null,customer_email:p.orderer_email??null,customer_phone:String(p.orderer_phone??"").replace(/[^0-9]/g,"")},b.info("pg_payment_data constructed from order (기본 PG가 타 PG)",{order_number:w.order_number,amount:w.amount})}if(!w)return b.warn("nhnkcp order detected but pg_payment_data missing"),y;b.info("intercepted order create response — opening PG popup");const C=f.order?{tax_amount:f.order.total_tax_amount??0,vat_amount:f.order.total_vat_amount??0,tax_free_amount:f.order.total_tax_free_amount??0}:{},_=!l&&u?.body&&typeof u.body=="string"?(()=>{const p=he(u.body);return p?{...w,pay_method:p,...C}:{...w,...C}})():{...w,...C};le({params:{pgPaymentData:_,paymentMethod:l}});const E={...h,data:{...f,requires_pg_payment:!1,redirect_url:Et()}};Line truncated
|
||||
</body></html>`),ne.close(),f=window.setTimeout(()=>{S(new Error("KCP SDK load timeout"))},15e3),y.__kcpReady=()=>{f!==void 0&&(clearTimeout(f),f=void 0)}})}const mt={nhnkcp_payco:"PAYCO",nhnkcp_naverpay:"네이버페이",nhnkcp_naverpay_point:"네이버페이 포인트",nhnkcp_kakaopay:"카카오페이",nhnkcp_applepay:"Apple Pay"},yt="0 0 0 2px #ffffff, 0 0 0 5px rgba(0,0,0,0.55)";function se(){return document.querySelector(".nhnkcp-easy-pay-btns")??null}function ce(){const e=se();e&&e.querySelectorAll("button").forEach(t=>{t.style.boxShadow="",t.style.outline=""})}function ft(e){const t=se();if(!t)return;const n=mt[e];t.querySelectorAll("button").forEach(r=>{r.textContent?.trim()===n?(r.style.boxShadow=yt,r.style.outline="none"):(r.style.boxShadow="",r.style.outline="")})}function ht(e){const t=e.params?.paymentMethod;if(!t)return;const n=window.G7Core;if(N(t)&&!P()){const a=M();n?.state?.setLocal?.({paymentErrorMessage:a,isSubmittingOrder:!1}),n?.modal?.open?.("nhnkcp_payment_error_modal"),ce();return}const r=typeof t=="string"&&t.startsWith("nhnkcp_");n?.state?.setLocal?.({paymentMethod:t}),r?ft(t):ce()}function gt(e){const t=document.createElement("textarea");t.value=e,t.setAttribute("readonly","readonly"),t.style.position="fixed",t.style.left="-9999px",t.style.top="0",document.body.appendChild(t),t.select();try{document.execCommand("copy")}finally{t.remove()}}async function _t(e){const t=String(e.params?.text??"");if(t!==""){if(navigator.clipboard&&typeof navigator.clipboard.writeText=="function"){await navigator.clipboard.writeText(t);return}gt(t)}}const de={requestPayment:lt,setPaymentMethod:ht,copyToClipboard:_t},le="/api/modules/sirsoft-ecommerce/user/orders",R="sirsoft-pay_nhnkcp",ue={info:(...e)=>console.info(`[${R}]`,...e),warn:(...e)=>console.warn(`[${R}]`,...e),error:(...e)=>console.error(`[${R}]`,...e)};function wt(e){return typeof e=="string"?e:e instanceof URL?e.toString():typeof Request<"u"&&e instanceof Request?e.url:String(e)}function Pt(e,t){return t?.method?t.method.toUpperCase():typeof Request<"u"&&e instanceof Request?e.method.toUpperCase():"GET"}function kt(e,t){if(t!=="POST")return!1;const n=e.split("?")[0].split("#")[0];return n===le||n.endsWith(le)}function bt(e){try{return JSON.parse(e).payment_method}catch{return}}function Ct(e){return new Response(JSON.stringify({success:!1,message:e,error:e,errors:{payment_method:[e]}}),{status:422,statusText:"Unprocessable Content",headers:{"Content-Type":"application/json"}})}function Nt(){if(typeof window>"u"||typeof window.fetch!="function")return;const e="__sirsoftNhnkcpInterceptorInstalled",t=window;if(t[e])return;t[e]=!0;const n=window.fetch.bind(window);window.fetch=async function(a,o){const i=wt(a),s=Pt(a,o);if(!kt(i,s))return n(a,o);let c;if(o?.body&&typeof o.body=="string"&&(c=bt(o.body)),N(c)&&!P()){const d=M();return ue.warn(d),Ct(d)}return n(a,o)},ue.info("order request interceptor installed (Apple Pay device guard)")}const H="sirsoft-pay_nhnkcp",pe="__kcpOcReceiptInjectorInstalled",me="kcp-oc-receipt-btn",xt=/^\/shop\/orders\/([^/]+)\/complete$/;function ye(){return localStorage.getItem("auth_token")}async function vt(e){const t=ye();if(!t)return null;try{const n=await fetch(`/api/modules/sirsoft-ecommerce/user/orders/${e}`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});return n.ok?(await n.json())?.data?.payment??null:null}catch{return null}}async function L(e){const t=ye();if(!t)return null;try{const n=await fetch(`/api/plugins/${H}/user/orders/${e}/receipt`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});return n.ok?await n.json():null}catch{return null}}function fe(e,t,n){const r=document.createElement("button");return r.type="button",r.className=t,r.textContent=e,r.addEventListener("click",()=>n(r)),r}function Et(e){if(!e)return!1;const t=Array.from(document.querySelectorAll("div"));for(const n of t){const r=Array.from(n.children).filter(i=>i instanceof HTMLElement&&i.tagName==="SPAN");if(r.length<2)continue;const a=r[0].textContent?.trim();if(a!=="결제 방법"&&a!=="결제수단"&&a!=="결제 방식")continue;const o=r[r.length-1];return o.textContent?.trim()===e?(n.dataset.nhnkcpPaymentMethodRow="true",!0):(o.textContent=e,o.dataset.nhnkcpPaymentMethodPatched="true",n.dataset.nhnkcpPaymentMethodRow="true",!0)}return!1}async function At(e){if(document.getElementById(me))return;const t=await vt(e);if(!t||t.pg_provider!=="nhnkcp"||!t.transaction_id||!t.paid_at)return;const n=Array.from(document.querySelectorAll('button[type="button"]')).find(c=>c.className.includes("bg-blue-600"));if(!n?.parentElement)return;const r=await L(e);Et(r?.payment_method_display_label);const a=n.parentElement,o=fe("영수증 조회",n.className.replace(/bg-blue-\d+/g,"bg-green-600").replace(/hover:bg-blue-\d+/g,"hover:bg-green-700"),async c=>{c.disabled=!0,c.textContent="로딩 중...";const d=await L(e);if(c.disabled=!1,c.textContent="영수증 조회",d){const l=window.KcpReceiptPopup;l&&new l({url:d.rLine truncated
|
||||
//# sourceMappingURL=plugin.iife.js.map
|
||||
+74
-107
@@ -1,158 +1,125 @@
|
||||
/**
|
||||
* 주문 생성 인터셉터 테스트 — 이슈 #475
|
||||
*
|
||||
* 확장 결제수단이 1급 시민이 되면서 인터셉터의 우회(요청 위장 · 응답 변조 · pgPaymentData
|
||||
* 재구성)가 모두 제거됐다. 남은 책임은 서버가 알 수 없는 클라이언트 디바이스 조건 하나뿐:
|
||||
* Apple Pay 는 iOS 모바일에서만 동작한다.
|
||||
*
|
||||
* 회귀 방지 대상:
|
||||
* - payment_method 를 'card' 로 위장하면 서버가 간편결제 주문을 "PG 결제가 아닌 주문"
|
||||
* 으로 오인해 관리자 알림 오발송 + 임시주문 삭제(재결제 불가)가 재발한다.
|
||||
* - 응답의 requires_pg_payment / redirect_url 을 변조하면 템플릿의
|
||||
* pg_payment_handler dispatch 분기가 발화하지 않는다.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { requestPaymentHandler } from '../handlers/requestPayment';
|
||||
import { installOrderResponseInterceptor } from '../orderResponseInterceptor';
|
||||
|
||||
vi.mock('../handlers/requestPayment', () => ({
|
||||
requestPaymentHandler: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
interface MutatedOrderResponse {
|
||||
data?: {
|
||||
redirect_url?: string;
|
||||
requires_pg_payment?: boolean;
|
||||
};
|
||||
}
|
||||
const ORDER_CREATE_URL = '/api/modules/sirsoft-ecommerce/user/orders';
|
||||
|
||||
function windowRecord(): Record<string, unknown> {
|
||||
return window as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 서버 주문 생성 응답(정상 PG 결제 주문) */
|
||||
function orderCreateResponse(): Response {
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
order: { order_number: 'ORD-1' },
|
||||
redirect_url: '/shop/orders/ORD-1/complete',
|
||||
requires_pg_payment: true,
|
||||
pg_provider: 'sirsoft-nhnkcp',
|
||||
pg_payment_handler: 'sirsoft-pay_nhnkcp.requestPayment',
|
||||
pg_payment_data: { order_number: 'ORD-1', amount: 1000, payment_method: 'nhnkcp_naverpay' },
|
||||
},
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
describe('installOrderResponseInterceptor', () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.lang = 'ko';
|
||||
window.history.pushState({}, '', '/shop/checkout');
|
||||
vi.mocked(requestPaymentHandler).mockClear();
|
||||
vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
const w = windowRecord();
|
||||
delete w['__sirsoftNhnkcpInterceptorInstalled'];
|
||||
delete w['__sirsoftPgOriginalFetch'];
|
||||
delete w['G7Core'];
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('NHN KCP 간편결제는 주문 생성 요청을 card로 전송하고 원래 결제수단으로 KCP 결제창을 호출한다', async () => {
|
||||
it('간편결제 payment_method 를 card 로 위장하지 않고 원본 그대로 전송한다', async () => {
|
||||
let sentBody = '';
|
||||
window.fetch = vi.fn().mockImplementation(async (_input, init?: RequestInit) => {
|
||||
sentBody = String(init?.body ?? '');
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
order: {
|
||||
order_number: 'ORD-100',
|
||||
total_amount: 15000,
|
||||
total_due_amount: 14000,
|
||||
orderer_name: 'Tester',
|
||||
orderer_email: 'tester@example.test',
|
||||
orderer_phone: '010-1234-5678',
|
||||
options: [{ product_name: '테스트 상품' }],
|
||||
},
|
||||
redirect_url: '/shop/orders/ORD-100/complete',
|
||||
requires_pg_payment: true,
|
||||
pg_provider: 'sirsoft-kginicis',
|
||||
},
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
return orderCreateResponse();
|
||||
});
|
||||
|
||||
installOrderResponseInterceptor();
|
||||
|
||||
const response = await window.fetch('/api/modules/sirsoft-ecommerce/user/orders', {
|
||||
await window.fetch(ORDER_CREATE_URL, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ payment_method: 'nhnkcp_naverpay' }),
|
||||
});
|
||||
const body = (await response.json()) as MutatedOrderResponse;
|
||||
|
||||
expect(JSON.parse(sentBody)).toMatchObject({ payment_method: 'card' });
|
||||
expect(requestPaymentHandler).toHaveBeenCalledWith({
|
||||
params: {
|
||||
pgPaymentData: expect.objectContaining({
|
||||
order_number: 'ORD-100',
|
||||
order_name: '테스트 상품',
|
||||
amount: 14000,
|
||||
customer_phone: '01012345678',
|
||||
}),
|
||||
paymentMethod: 'nhnkcp_naverpay',
|
||||
},
|
||||
});
|
||||
expect(body.data?.requires_pg_payment).toBe(false);
|
||||
expect(body.data?.redirect_url).toBe('/shop/checkout');
|
||||
// 위장이 부활하면 서버가 PG 결제 주문으로 인식하지 못해 #475 가 재발한다.
|
||||
expect(JSON.parse(sentBody).payment_method).toBe('nhnkcp_naverpay');
|
||||
expect(sentBody).not.toContain('"payment_method":"card"');
|
||||
});
|
||||
|
||||
it('iOS 모바일 기기가 아닌 Apple Pay 주문 생성 요청은 서버 전송 전에 차단한다', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
window.fetch = fetchSpy as unknown as typeof fetch;
|
||||
it('서버 응답을 변조하지 않는다 (requires_pg_payment / redirect_url 보존)', async () => {
|
||||
window.fetch = vi.fn().mockResolvedValue(orderCreateResponse());
|
||||
|
||||
installOrderResponseInterceptor();
|
||||
|
||||
const response = await window.fetch('/api/modules/sirsoft-ecommerce/user/orders', {
|
||||
const response = await window.fetch(ORDER_CREATE_URL, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ payment_method: 'nhnkcp_naverpay' }),
|
||||
});
|
||||
const body = await response.json();
|
||||
|
||||
// 응답을 변조하면 템플릿의 pg_payment_handler dispatch 분기가 발화하지 않는다.
|
||||
expect(body.data.requires_pg_payment).toBe(true);
|
||||
expect(body.data.redirect_url).toBe('/shop/orders/ORD-1/complete');
|
||||
expect(body.data.pg_payment_handler).toBe('sirsoft-pay_nhnkcp.requestPayment');
|
||||
});
|
||||
|
||||
it('iOS 모바일 기기가 아닌 Apple Pay 주문은 서버 전송 전에 차단한다', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(orderCreateResponse());
|
||||
window.fetch = fetchSpy;
|
||||
|
||||
// 데스크톱 UA (iOS 아님)
|
||||
vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
||||
);
|
||||
|
||||
installOrderResponseInterceptor();
|
||||
|
||||
const response = await window.fetch(ORDER_CREATE_URL, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ payment_method: 'nhnkcp_applepay' }),
|
||||
});
|
||||
const body = await response.json() as {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
errors?: { payment_method?: string[] };
|
||||
};
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(response.status).toBe(422);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toBe('애플페이는 IOS 기기에 모바일결제만 가능합니다.');
|
||||
expect(body.errors?.payment_method?.[0]).toBe('애플페이는 IOS 기기에 모바일결제만 가능합니다.');
|
||||
expect(requestPaymentHandler).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.errors.payment_method).toBeDefined();
|
||||
});
|
||||
|
||||
it('일반 NHN KCP 주문 응답도 결제창을 호출하고 fallback 이동을 막는다', async () => {
|
||||
window.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
order: { order_number: 'ORD-101' },
|
||||
redirect_url: '/shop/orders/ORD-101/complete',
|
||||
requires_pg_payment: true,
|
||||
pg_provider: 'sirsoft-nhnkcp',
|
||||
pg_payment_data: {
|
||||
order_number: 'ORD-101',
|
||||
order_name: '일반 주문',
|
||||
amount: 20000,
|
||||
},
|
||||
},
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
) as unknown as typeof fetch;
|
||||
it('주문 생성 외의 요청은 그대로 통과시킨다', async () => {
|
||||
const originalResponse = new Response('{}', { status: 200 });
|
||||
const fetchSpy = vi.fn().mockResolvedValue(originalResponse);
|
||||
window.fetch = fetchSpy;
|
||||
|
||||
installOrderResponseInterceptor();
|
||||
|
||||
const response = await window.fetch('/api/modules/sirsoft-ecommerce/user/orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ payment_method: 'card' }),
|
||||
});
|
||||
const body = (await response.json()) as MutatedOrderResponse;
|
||||
const response = await window.fetch('/api/modules/sirsoft-ecommerce/checkout', { method: 'GET' });
|
||||
|
||||
expect(requestPaymentHandler).toHaveBeenCalledWith({
|
||||
params: {
|
||||
pgPaymentData: expect.objectContaining({
|
||||
order_number: 'ORD-101',
|
||||
pay_method: 'card',
|
||||
}),
|
||||
paymentMethod: undefined,
|
||||
},
|
||||
});
|
||||
expect(body.data?.requires_pg_payment).toBe(false);
|
||||
expect(body.data?.redirect_url).toBe('/shop/checkout');
|
||||
expect(response).toBe(originalResponse);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -17,10 +17,15 @@ interface PgPaymentData {
|
||||
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;
|
||||
/** 복합과세 분할용 — orderResponseInterceptor가 data.order 에서 주입 */
|
||||
/** 복합과세 분할용 */
|
||||
tax_amount?: number;
|
||||
vat_amount?: number;
|
||||
tax_free_amount?: number;
|
||||
@@ -235,7 +240,12 @@ export async function requestPaymentHandler(action: any, _context?: any): Promis
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentMethod = paramPaymentMethod ?? pgPaymentData.pay_method ?? 'card';
|
||||
// 서버가 저장한 결제수단(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;
|
||||
|
||||
@@ -62,11 +62,11 @@ export function setPaymentMethodHandler(action: any): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// 확장 결제수단은 1급 시민 — 선택한 수단 ID 를 그대로 서버에 보낸다.
|
||||
// (과거에는 서버 검증이 확장 ID 를 422 로 막아 'card' 로 정규화한 serverPaymentMethod 를
|
||||
// 함께 저장했으나, 소비처가 없는 죽은 코드였고 위장 자체가 결함의 원인이었다 — #475)
|
||||
const isEasyPay = typeof paymentMethod === 'string' && paymentMethod.startsWith('nhnkcp_');
|
||||
G7Core?.state?.setLocal?.({
|
||||
paymentMethod,
|
||||
serverPaymentMethod: isEasyPay ? 'card' : paymentMethod,
|
||||
});
|
||||
G7Core?.state?.setLocal?.({ paymentMethod });
|
||||
|
||||
if (isEasyPay) {
|
||||
updateEasyPayButtonStyles(paymentMethod);
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
/**
|
||||
* 주문 생성 API 응답 인터셉터
|
||||
* 주문 생성 요청 인터셉터 (Apple Pay 디바이스 가드 전용)
|
||||
*
|
||||
* 체크아웃 템플릿(_checkout_summary.json)에는 'sirsoft-tosspayments' 분기만
|
||||
* 정의되어 있어서, 'sirsoft-nhnkcp' PG는 navigate 기본 분기로 떨어져
|
||||
* /shop/orders/{order_number}/complete 로 이동해버림 (결제창 미노출).
|
||||
* 과거에는 이 인터셉터가 다음을 우회 목적으로 수행했다:
|
||||
*
|
||||
* 코어/템플릿 수정 없이 이 문제를 우회하기 위해 plugin loading 시점에
|
||||
* window.fetch 를 래핑해 다음을 수행:
|
||||
* 1. 요청 body 의 `payment_method` 를 'card' 로 위장 (서버 검증이 확장 ID 를 422 로 막았기 때문)
|
||||
* 2. 응답의 `requires_pg_payment` 를 false 로, `redirect_url` 을 self 로 변조
|
||||
* 3. `pg_payment_data` 를 직접 재구성해 결제창을 강제로 띄움
|
||||
*
|
||||
* 1. POST /api/modules/sirsoft-ecommerce/user/orders 요청 body에서
|
||||
* nhnkcp_* 간편결제 수단을 'card'로 교체 후 서버 전송
|
||||
* 2. 응답에서 data.pg_provider === 'sirsoft-nhnkcp' 이면
|
||||
* requestPayment 핸들러를 직접 호출하여 결제창 띄움
|
||||
* 3. data.redirect_url 을 현재 URL로 교체하고 requires_pg_payment를 false로 변경
|
||||
* → 템플릿 fallback 분기의 navigate 가 navigate-to-self 가 되어 실질적 이동 없음
|
||||
* 이 우회는 두 개의 심각한 결함을 낳았다 — 서버가 간편결제 주문을 "PG 결제가 아닌 주문"
|
||||
* 으로 오인해 (a) 결제 실패했는데 관리자에게 신규주문 알림이 발송되고 (b) 임시주문이
|
||||
* 즉시 삭제되어 재결제가 불가능해졌다.
|
||||
*
|
||||
* 결과: 체크아웃 페이지에 머문 채 PG 팝업이 뜨고, PG 콜백이 정식 complete 페이지로 redirect.
|
||||
* 이제 코어가 확장 결제수단을 1급 시민으로 받는다:
|
||||
* - 서버 검증이 결제수단 카탈로그를 화이트리스트로 사용 (확장 ID 통과)
|
||||
* - PG 플러그인이 `pg_provider` 를 자기 PG 로 고정 선언 → PG 결제 주문으로 정상 판정
|
||||
* - provider 가 선언한 `payment_handler` 가 응답의 `pg_payment_handler` 로 내려가
|
||||
* 템플릿이 결제 진입 핸들러를 그대로 dispatch
|
||||
*
|
||||
* 따라서 위장·응답변조·pgPaymentData 재구성은 모두 불필요해졌고 제거했다.
|
||||
* 남은 책임은 서버가 알 수 없는 **클라이언트 디바이스 조건** 하나뿐이다:
|
||||
* Apple Pay 는 iOS 모바일에서만 동작하므로 그 외 환경에서는 요청 전에 차단한다.
|
||||
*
|
||||
* @see https://github.com/gnuboard/dev-g7/issues/475
|
||||
*/
|
||||
|
||||
import { requestPaymentHandler } from './handlers/requestPayment';
|
||||
import {
|
||||
applePayUnsupportedMessage,
|
||||
isIosMobileDevice,
|
||||
@@ -26,9 +31,7 @@ import {
|
||||
} from './support/applePayDevice';
|
||||
|
||||
const ORDER_CREATE_PATH = '/api/modules/sirsoft-ecommerce/user/orders';
|
||||
const TARGET_PG_PROVIDER = 'sirsoft-nhnkcp';
|
||||
const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
|
||||
const EASY_PAY_PREFIX = 'nhnkcp_';
|
||||
|
||||
const logger = {
|
||||
info: (...args: unknown[]) => console.info(`[${PLUGIN_IDENTIFIER}]`, ...args),
|
||||
@@ -36,23 +39,6 @@ const logger = {
|
||||
error: (...args: unknown[]) => console.error(`[${PLUGIN_IDENTIFIER}]`, ...args),
|
||||
};
|
||||
|
||||
interface OrderCreateResponseBody {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
data?: {
|
||||
order?: {
|
||||
order_number?: string;
|
||||
total_tax_amount?: number;
|
||||
total_vat_amount?: number;
|
||||
total_tax_free_amount?: number;
|
||||
};
|
||||
redirect_url?: string;
|
||||
requires_pg_payment?: boolean;
|
||||
pg_provider?: string;
|
||||
pg_payment_data?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
function extractUrl(input: RequestInfo | URL): string {
|
||||
if (typeof input === 'string') return input;
|
||||
if (input instanceof URL) return input.toString();
|
||||
@@ -72,19 +58,18 @@ function isTargetEndpoint(url: string, method: string): boolean {
|
||||
return path === ORDER_CREATE_PATH || path.endsWith(ORDER_CREATE_PATH);
|
||||
}
|
||||
|
||||
function buildNoOpRedirectUrl(): string {
|
||||
return window.location.pathname + window.location.search + window.location.hash;
|
||||
}
|
||||
|
||||
function mutateResponse(originalResponse: Response, mutatedBody: OrderCreateResponseBody): Response {
|
||||
const json = JSON.stringify(mutatedBody);
|
||||
return new Response(json, {
|
||||
status: originalResponse.status,
|
||||
statusText: originalResponse.statusText,
|
||||
headers: originalResponse.headers,
|
||||
});
|
||||
function extractPaymentMethodFromBody(body: string): string | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as Record<string, unknown>;
|
||||
return parsed['payment_method'] as string | undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apple Pay 미지원 디바이스 차단 응답 (서버 검증과 동일한 422 형식).
|
||||
*/
|
||||
function buildPaymentMethodBlockedResponse(message: string): Response {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
@@ -102,25 +87,6 @@ function buildPaymentMethodBlockedResponse(message: string): Response {
|
||||
});
|
||||
}
|
||||
|
||||
function extractPaymentMethodFromBody(body: string): string | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as Record<string, unknown>;
|
||||
return parsed['payment_method'] as string | undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function replacePaymentMethodInBody(body: string, replacement: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as Record<string, unknown>;
|
||||
parsed['payment_method'] = replacement;
|
||||
return JSON.stringify(parsed);
|
||||
} catch {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
export function installOrderResponseInterceptor(): void {
|
||||
if (typeof window === 'undefined' || typeof window.fetch !== 'function') {
|
||||
return;
|
||||
@@ -133,14 +99,6 @@ export function installOrderResponseInterceptor(): void {
|
||||
}
|
||||
w[flag] = true;
|
||||
|
||||
// 최초 설치 플러그인이 원본 브라우저 fetch를 보존 (다른 PG 인터셉터가 쌓이기 전)
|
||||
const ORIGINAL_FETCH_KEY = '__sirsoftPgOriginalFetch';
|
||||
if (!w[ORIGINAL_FETCH_KEY]) {
|
||||
w[ORIGINAL_FETCH_KEY] = window.fetch.bind(window);
|
||||
}
|
||||
// easy pay 요청 시 다른 PG 인터셉터(KG 이니시스 등)를 완전히 우회하는 원본 fetch
|
||||
const browserFetch = w[ORIGINAL_FETCH_KEY] as typeof fetch;
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
|
||||
window.fetch = async function patchedFetch(
|
||||
@@ -150,146 +108,27 @@ export function installOrderResponseInterceptor(): void {
|
||||
const url = extractUrl(input);
|
||||
const method = extractMethod(input, init);
|
||||
|
||||
// 주문 생성 엔드포인트가 아니면 바로 통과
|
||||
if (!isTargetEndpoint(url, method)) {
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
|
||||
// 요청 body에서 payment_method 추출 (nhnkcp_* 간편결제 감지)
|
||||
let originalPaymentMethod: string | undefined;
|
||||
let modifiedInit = init;
|
||||
|
||||
// Apple Pay 디바이스 가드 — 서버는 요청자의 디바이스를 알 수 없으므로
|
||||
// 클라이언트에서만 판정 가능한 유일한 조건이다.
|
||||
let paymentMethod: string | undefined;
|
||||
if (init?.body && typeof init.body === 'string') {
|
||||
const pm = extractPaymentMethodFromBody(init.body);
|
||||
if (typeof pm === 'string' && pm.startsWith(EASY_PAY_PREFIX)) {
|
||||
originalPaymentMethod = pm;
|
||||
// nhnkcp_* → 'card'로 교체해 서버 전송 (서버 ValidationEnum에 없는 값 방지)
|
||||
modifiedInit = { ...init, body: replacePaymentMethodInBody(init.body, 'card') };
|
||||
logger.info(`easy pay detected: replacing payment_method '${pm}' → 'card'`);
|
||||
}
|
||||
paymentMethod = extractPaymentMethodFromBody(init.body);
|
||||
}
|
||||
|
||||
// G7Core 로컬 상태 fallback — 간편결제 버튼 클릭 시 serverPaymentMethod='card'로
|
||||
// 서버에 전달되어 요청 body에서 감지 불가인 경우 대비
|
||||
if (!originalPaymentMethod) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const localPm = ((window as any).G7Core)?.state?.getLocal?.()?.paymentMethod;
|
||||
if (typeof localPm === 'string' && localPm.startsWith(EASY_PAY_PREFIX)) {
|
||||
originalPaymentMethod = localPm;
|
||||
logger.info(`easy pay detected from local state: '${localPm}'`);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (isNhnKcpApplePayMethod(originalPaymentMethod) && !isIosMobileDevice()) {
|
||||
if (isNhnKcpApplePayMethod(paymentMethod) && !isIosMobileDevice()) {
|
||||
const message = applePayUnsupportedMessage();
|
||||
logger.warn(message);
|
||||
return buildPaymentMethodBlockedResponse(message);
|
||||
}
|
||||
|
||||
// easy pay일 때 browserFetch(원본)를 사용해 KG 이니시스 등 다른 PG 인터셉터 우회
|
||||
// 기본 PG가 KG 이니시스여도 NHN KCP 결제창이 열리도록 함
|
||||
const fetchFn = originalPaymentMethod ? browserFetch : originalFetch;
|
||||
const response = await fetchFn(input, modifiedInit);
|
||||
|
||||
let cloned: Response;
|
||||
try {
|
||||
cloned = response.clone();
|
||||
} catch {
|
||||
return response;
|
||||
}
|
||||
|
||||
let body: OrderCreateResponseBody | null = null;
|
||||
try {
|
||||
body = (await cloned.json()) as OrderCreateResponseBody;
|
||||
} catch {
|
||||
return response;
|
||||
}
|
||||
|
||||
const data = body?.data;
|
||||
if (!data) return response;
|
||||
|
||||
const requiresPg = data.requires_pg_payment === true;
|
||||
const isNhnkcp = data.pg_provider === TARGET_PG_PROVIDER;
|
||||
|
||||
// 일반 결제: pg_provider가 nhnkcp이고 requires_pg_payment=true인 경우에만 처리
|
||||
// 간편결제: pg_provider 무관하게 NHN KCP 처리 (기본 PG가 KG 이니시스여도 KCP 결제창 띄움)
|
||||
if (!originalPaymentMethod && (!requiresPg || !isNhnkcp)) {
|
||||
return response;
|
||||
}
|
||||
if (originalPaymentMethod && !data.order && !data.pg_payment_data) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// pg_payment_data: 서버 응답에 포함되거나,
|
||||
// 간편결제 + 기본 PG가 타 PG인 경우 data.order에서 직접 구성
|
||||
let pgPaymentData = data.pg_payment_data;
|
||||
if (!pgPaymentData && originalPaymentMethod && data.order) {
|
||||
const orderData = data.order as Record<string, unknown>;
|
||||
const options = orderData.options as Array<Record<string, unknown>> | undefined;
|
||||
const firstName = (options?.[0]?.product_name as string | undefined) ?? String(orderData.order_number ?? '');
|
||||
const orderName = (options?.length ?? 0) > 1
|
||||
? `${firstName} 외 ${(options?.length ?? 0) - 1}건`
|
||||
: firstName;
|
||||
pgPaymentData = {
|
||||
order_number: orderData.order_number,
|
||||
order_name: orderName,
|
||||
amount: Math.floor(Number(orderData.total_due_amount ?? orderData.total_amount ?? 0)),
|
||||
currency: 'KRW',
|
||||
customer_name: orderData.orderer_name ?? null,
|
||||
customer_email: orderData.orderer_email ?? null,
|
||||
customer_phone: String(orderData.orderer_phone ?? '').replace(/[^0-9]/g, ''),
|
||||
};
|
||||
logger.info('pg_payment_data constructed from order (기본 PG가 타 PG)', {
|
||||
order_number: pgPaymentData.order_number,
|
||||
amount: pgPaymentData.amount,
|
||||
});
|
||||
}
|
||||
|
||||
if (!pgPaymentData) {
|
||||
logger.warn('nhnkcp order detected but pg_payment_data missing');
|
||||
return response;
|
||||
}
|
||||
|
||||
logger.info('intercepted order create response — opening PG popup');
|
||||
|
||||
// 주문 세금 정보 추출 — PC 결제창의 복합과세 분할 필드 전달용
|
||||
const orderTax = data.order
|
||||
? {
|
||||
tax_amount: data.order.total_tax_amount ?? 0,
|
||||
vat_amount: data.order.total_vat_amount ?? 0,
|
||||
tax_free_amount: data.order.total_tax_free_amount ?? 0,
|
||||
}
|
||||
: {};
|
||||
|
||||
// 간편결제가 아닌 경우 요청 body의 pay_method를 pgPaymentData에 주입
|
||||
const enrichedPgPaymentData = (!originalPaymentMethod && modifiedInit?.body && typeof modifiedInit.body === 'string')
|
||||
? (() => {
|
||||
const pm = extractPaymentMethodFromBody(modifiedInit!.body as string);
|
||||
return pm ? { ...pgPaymentData, pay_method: pm, ...orderTax } : { ...pgPaymentData, ...orderTax };
|
||||
})()
|
||||
: { ...pgPaymentData, ...orderTax };
|
||||
|
||||
void requestPaymentHandler({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
params: {
|
||||
pgPaymentData: enrichedPgPaymentData as any,
|
||||
paymentMethod: originalPaymentMethod,
|
||||
},
|
||||
});
|
||||
|
||||
const mutatedBody: OrderCreateResponseBody = {
|
||||
...body,
|
||||
data: {
|
||||
...data,
|
||||
requires_pg_payment: false,
|
||||
redirect_url: buildNoOpRedirectUrl(),
|
||||
},
|
||||
};
|
||||
|
||||
return mutateResponse(response, mutatedBody);
|
||||
// 그 외에는 요청/응답을 일절 변조하지 않는다 — 코어가 확장 결제수단을
|
||||
// 1급 시민으로 처리하고, 결제창 진입은 응답의 pg_payment_handler 로 dispatch 된다.
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
|
||||
logger.info('order response interceptor installed');
|
||||
logger.info('order request interceptor installed (Apple Pay device guard)');
|
||||
}
|
||||
@@ -17,13 +17,13 @@ use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException;
|
||||
use Modules\Sirsoft\Ecommerce\Helpers\DeviceDetector;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Concerns\PreventsReplayCallback;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Concerns\IssuesReceiptCookie;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Concerns\PreventsReplayCallback;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Concerns\RecordsPaymentWindowClosure;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Concerns\ResolvesEasyPayDisplay;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Concerns\SanitizesPgResponse;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Concerns\SerializesPaymentCallbacks;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Concerns\SendsKcpNotifyResponse;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Concerns\SerializesPaymentCallbacks;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Http\Requests\AuthCallbackRequest;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Http\Requests\VbankNotifyRequest;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Services\NhnKcpApiService;
|
||||
@@ -37,13 +37,13 @@ use Plugins\Sirsoft\PayNhnkcp\Services\NhnKcpApiService;
|
||||
*/
|
||||
class PaymentCallbackController
|
||||
{
|
||||
use PreventsReplayCallback;
|
||||
use IssuesReceiptCookie;
|
||||
use PreventsReplayCallback;
|
||||
use RecordsPaymentWindowClosure;
|
||||
use ResolvesEasyPayDisplay;
|
||||
use SanitizesPgResponse;
|
||||
use SerializesPaymentCallbacks;
|
||||
use SendsKcpNotifyResponse;
|
||||
use SerializesPaymentCallbacks;
|
||||
|
||||
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
|
||||
|
||||
@@ -265,7 +265,7 @@ class PaymentCallbackController
|
||||
// 가상계좌: 계좌 발급 완료 처리 (실제 입금은 vbankNotify에서 처리)
|
||||
// KCP 콜백의 use_pay_method=VCNT 또는 주문의 payment_method=vbank 로 감지
|
||||
$isVbank = ($validated['use_pay_method'] ?? '') === 'VCNT'
|
||||
|| in_array($order->payment?->payment_method?->value, ['vbank', 'virtual_account'], true);
|
||||
|| (bool) $order->payment?->isVirtualAccount();
|
||||
if ($isVbank) {
|
||||
return $this->handleVbankIssued($validated, $order, $encData, $encInfo, $ordrIdxx, $custIp, $request);
|
||||
}
|
||||
@@ -767,7 +767,7 @@ class PaymentCallbackController
|
||||
return 'payment_not_found';
|
||||
}
|
||||
|
||||
if (! in_array($payment->payment_method?->value, ['vbank', 'virtual_account'], true)) {
|
||||
if (! $payment->isVirtualAccount()) {
|
||||
return 'payment_method_not_vbank';
|
||||
}
|
||||
|
||||
|
||||
+7
-7
@@ -69,7 +69,7 @@ class UserVbankMockDepositController
|
||||
return response()->json(['available' => false]);
|
||||
}
|
||||
|
||||
$isVbank = in_array($payment->payment_method?->value, ['vbank', 'virtual_account'], true);
|
||||
$isVbank = $payment->isVirtualAccount();
|
||||
|
||||
// 가상계좌 계좌번호가 발급됐고 아직 입금완료가 아닌 상태
|
||||
// (handleVbankIssued는 payment_status를 ready로 유지하므로 isPaid 역확인)
|
||||
@@ -83,12 +83,12 @@ class UserVbankMockDepositController
|
||||
?? (is_array($meta) ? ($meta['tno'] ?? '') : '');
|
||||
|
||||
return response()->json([
|
||||
'available' => true,
|
||||
'trade_no' => $tradeNo,
|
||||
'account_no' => $payment->vbank_number,
|
||||
'notify_url' => url(self::VBANK_NOTIFY_PATH),
|
||||
'mock_url' => self::KCP_TEST_MOCK_URL,
|
||||
'is_admin_view' => $isAdmin,
|
||||
'available' => true,
|
||||
'trade_no' => $tradeNo,
|
||||
'account_no' => $payment->vbank_number,
|
||||
'notify_url' => url(self::VBANK_NOTIFY_PATH),
|
||||
'mock_url' => self::KCP_TEST_MOCK_URL,
|
||||
'is_admin_view' => $isAdmin,
|
||||
]);
|
||||
}
|
||||
}
|
||||
+16
-71
@@ -7,7 +7,10 @@ namespace Plugins\Sirsoft\PayNhnkcp\Listeners;
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
|
||||
/**
|
||||
* 이커머스 결제수단 설정 화면에서 NHN KCP 간편결제를 PG 선택 불필요 항목으로 표시한다.
|
||||
* 이커머스 결제수단 설정 화면에 NHN KCP 테스트모드 경고를 주입한다.
|
||||
*
|
||||
* 간편결제의 PG 표시(PG 고정 배지)는 코어 레이아웃이 결제수단 카탈로그의 pg_locked /
|
||||
* needs_pg 를 읽어 직접 처리하므로 이 리스너가 관여하지 않는다(#475).
|
||||
*/
|
||||
class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterface
|
||||
{
|
||||
@@ -24,16 +27,6 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
|
||||
private const TEST_MODE_CONDITION = 'nhnkcp_test_mode_status.data?.is_test_mode === true';
|
||||
|
||||
private const CORE_NO_PG_METHODS = ['point', 'deposit', 'free', 'dbank'];
|
||||
|
||||
private const NHNKCP_NO_PG_METHODS = [
|
||||
'nhnkcp_payco',
|
||||
'nhnkcp_naverpay',
|
||||
'nhnkcp_naverpay_point',
|
||||
'nhnkcp_kakaopay',
|
||||
'nhnkcp_applepay',
|
||||
];
|
||||
|
||||
/**
|
||||
* 이 리스너가 구독하는 훅 정의를 반환합니다.
|
||||
*
|
||||
@@ -43,7 +36,7 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
{
|
||||
return [
|
||||
'core.layout_extension.after_apply' => [
|
||||
'method' => 'markEasyPayMethodsAsPgNotRequired',
|
||||
'method' => 'adjustPaymentMethodsLayout',
|
||||
'type' => 'filter',
|
||||
'priority' => 30,
|
||||
],
|
||||
@@ -58,78 +51,30 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
public function handle(...$args): void {}
|
||||
|
||||
/**
|
||||
* 이커머스 결제수단 설정 레이아웃에 NHN KCP 간편결제를 PG 선택 불필요 항목으로 반영합니다.
|
||||
* 이커머스 결제수단 설정 레이아웃에 테스트모드 경고를 주입합니다.
|
||||
*
|
||||
* 과거에는 코어 레이아웃의 "PG 불필요 결제수단" 하드코딩 배열 표현식을 정규식으로 찾아
|
||||
* 간편결제를 그 목록에 덧붙여 PG 선택 셀렉트를 숨겼다. 그러나 간편결제는 "PG 불필요" 가
|
||||
* 아니라 "PG 고정" 이며, 이 표시 왜곡은 서버가 간편결제를 PG 없는 주문으로 오인하던
|
||||
* 결함과 짝을 이뤘다(#475). 정규식 치환은 플러그인이 여럿 설치되면 서로의 결과 위에
|
||||
* 누적 적용되어 충돌할 위험도 있었다.
|
||||
*
|
||||
* 이제 결제수단 카탈로그가 `pg_locked` / `needs_pg` 를 내려주고 코어 레이아웃이 그 값으로
|
||||
* 직접 3분기(PG 고정 배지 / PG 선택 / PG 불필요)하므로 치환이 불필요하다.
|
||||
*
|
||||
* @param array<string, mixed> $layout 대상 레이아웃 정의
|
||||
* @param int $templateId 레이아웃이 속한 템플릿 ID
|
||||
* @return array<string, mixed> 보정된 레이아웃 정의
|
||||
*/
|
||||
public function markEasyPayMethodsAsPgNotRequired(array $layout, int $templateId): array
|
||||
public function adjustPaymentMethodsLayout(array $layout, int $templateId): array
|
||||
{
|
||||
if (($layout['layout_name'] ?? '') !== self::TARGET_LAYOUT) {
|
||||
return $layout;
|
||||
}
|
||||
|
||||
$layout = $this->replaceNoPgMethodExpressions($layout);
|
||||
|
||||
return $this->ensureTestModeWarning($layout);
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 노드를 재귀 순회하며 PG 불필요 결제수단 표현식에 NHN KCP 간편결제를 추가합니다.
|
||||
*
|
||||
* @param array<string, mixed> $node 순회 대상 노드
|
||||
* @return array<string, mixed> 치환이 반영된 노드
|
||||
*/
|
||||
private function replaceNoPgMethodExpressions(array $node): array
|
||||
{
|
||||
foreach ($node as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$node[$key] = $this->replaceNoPgMethodExpressions($value);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$node[$key] = $this->addNhnKcpMethodsToNoPgArray($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $node;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 PG 불필요 결제수단 배열 표현식을 찾아 NHN KCP 간편결제 수단을 덧붙입니다.
|
||||
*
|
||||
* @param string $expression 대상 표현식
|
||||
* @return string NHN KCP 수단이 추가된 표현식 (대상이 아니면 원본 그대로)
|
||||
*/
|
||||
private function addNhnKcpMethodsToNoPgArray(string $expression): string
|
||||
{
|
||||
return preg_replace_callback(
|
||||
'/\[((?:\'[^\']+\'\s*,?\s*)+)\]\.includes\(\$method\.id\)/',
|
||||
function (array $matches): string {
|
||||
preg_match_all("/'([^']+)'/", $matches[1], $tokenMatches);
|
||||
$ids = $tokenMatches[1] ?? [];
|
||||
|
||||
foreach (self::CORE_NO_PG_METHODS as $coreId) {
|
||||
if (! in_array($coreId, $ids, true)) {
|
||||
return $matches[0];
|
||||
}
|
||||
}
|
||||
|
||||
foreach (self::NHNKCP_NO_PG_METHODS as $methodId) {
|
||||
if (! in_array($methodId, $ids, true)) {
|
||||
$ids[] = $methodId;
|
||||
}
|
||||
}
|
||||
|
||||
return "['".implode("','", $ids)."'].includes(\$method.id)";
|
||||
},
|
||||
$expression
|
||||
) ?? $expression;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트 모드 경고에 필요한 데이터 소스와 안내 노드를 레이아웃에 보장합니다.
|
||||
*
|
||||
|
||||
+17
-5
@@ -15,6 +15,11 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
{
|
||||
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
|
||||
|
||||
/**
|
||||
* 이 플러그인이 제공하는 PG 식별자 (RegisterPgProviderListener 의 provider id 와 일치).
|
||||
*/
|
||||
private const PG_PROVIDER_ID = 'nhnkcp';
|
||||
|
||||
private const EASY_PAY_METHODS = [
|
||||
[
|
||||
'id' => 'nhnkcp_payco',
|
||||
@@ -63,12 +68,12 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed ...$args
|
||||
* @param mixed ...$args
|
||||
*/
|
||||
public function handle(...$args): void {}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $methods
|
||||
* @param array<int, array<string, mixed>> $methods
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function injectEasyPayMethods(array $methods): array
|
||||
@@ -102,7 +107,7 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $methods
|
||||
* @param array<int, array<string, mixed>> $methods
|
||||
*/
|
||||
private function resolveInsertionIndex(array $methods): ?int
|
||||
{
|
||||
@@ -143,12 +148,19 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
'en' => __($descriptionKey, [], 'en'),
|
||||
],
|
||||
'icon' => $icon,
|
||||
'source' => 'plugin:' . self::PLUGIN_IDENTIFIER,
|
||||
'source' => 'plugin:'.self::PLUGIN_IDENTIFIER,
|
||||
'defaults' => [
|
||||
'pg_provider' => null,
|
||||
// 간편결제는 NHN KCP 결제창을 통해서만 처리되므로 PG 를 자기 자신으로 고정한다.
|
||||
// null 로 두면 코어가 PG 없는 결제수단으로 오인해 결제 실패 주문에 관리자 알림이
|
||||
// 발송되고 임시주문이 삭제되어 재결제가 막힌다(#475).
|
||||
'pg_provider' => self::PG_PROVIDER_ID,
|
||||
'pg_locked' => true,
|
||||
'needs_pg' => true,
|
||||
'refund_method' => 'pg',
|
||||
'is_active' => false,
|
||||
'min_order_amount' => 0,
|
||||
'stock_deduction_timing' => 'payment_complete',
|
||||
'mileage_deduction_timing' => 'payment_complete',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -54,6 +54,10 @@ class RegisterPgProviderListener implements HookListenerInterface
|
||||
'name' => localized_label(nameKey: 'sirsoft-pay_nhnkcp::provider.name'),
|
||||
'icon' => 'credit-card',
|
||||
'supported_methods' => ['card', 'bank_transfer', 'virtual_account', 'mobile'],
|
||||
// 코어의 provider-agnostic 결제 진입 dispatch — 주문 응답의 pg_payment_handler 로
|
||||
// 내려가 템플릿이 그대로 호출한다. 미선언 시 프론트가 응답을 변조해 결제창을
|
||||
// 직접 띄우는 우회가 필요해진다(#475).
|
||||
'payment_handler' => 'sirsoft-pay_nhnkcp.requestPayment',
|
||||
];
|
||||
|
||||
return $providers;
|
||||
@@ -81,7 +85,7 @@ class RegisterPgProviderListener implements HookListenerInterface
|
||||
$liveSuffix = $settings['live_site_cd'] ?? '';
|
||||
$liveSiteCd = str_starts_with($liveSuffix, self::LIVE_SITE_CD_PREFIX)
|
||||
? $liveSuffix
|
||||
: self::LIVE_SITE_CD_PREFIX . $liveSuffix;
|
||||
: self::LIVE_SITE_CD_PREFIX.$liveSuffix;
|
||||
|
||||
$siteCd = $isTest
|
||||
? ($settings['test_site_cd'] ?? 'T0000')
|
||||
@@ -92,11 +96,11 @@ class RegisterPgProviderListener implements HookListenerInterface
|
||||
: 'https://pay.kcp.co.kr/plugin/payplus_web.jsp';
|
||||
|
||||
$easyPayKeys = [
|
||||
'PAYCO' => 'easy_pay_payco',
|
||||
'NAVERPAY' => 'easy_pay_naverpay',
|
||||
'PAYCO' => 'easy_pay_payco',
|
||||
'NAVERPAY' => 'easy_pay_naverpay',
|
||||
'NAVERPAY POINT' => 'easy_pay_naverpay_point',
|
||||
'KAKAOPAY' => 'easy_pay_kakaopay',
|
||||
'APPLEPAY' => 'easy_pay_applepay',
|
||||
'KAKAOPAY' => 'easy_pay_kakaopay',
|
||||
'APPLEPAY' => 'easy_pay_applepay',
|
||||
];
|
||||
$enabledEasyPays = array_values(array_keys(array_filter($easyPayKeys, fn ($key) => (bool) ($settings[$key] ?? false))));
|
||||
|
||||
|
||||
+19
-24
@@ -18,14 +18,17 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
|
||||
$hooks['core.layout_extension.after_apply']['type'] ?? null
|
||||
);
|
||||
$this->assertSame(
|
||||
'markEasyPayMethodsAsPgNotRequired',
|
||||
'adjustPaymentMethodsLayout',
|
||||
$hooks['core.layout_extension.after_apply']['method'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public function test_marks_nhnkcp_easy_pay_methods_as_pg_not_required_in_admin_ecommerce_settings(): void
|
||||
public function test_does_not_string_substitute_pg_branch_expressions(): void
|
||||
{
|
||||
$listener = new AdjustEcommercePaymentMethodsLayoutListener();
|
||||
// 코어 레이아웃은 이제 pg_locked / needs_pg 로 직접 분기한다(#475).
|
||||
// 리스너가 표현식을 치환하면 코어의 분기 로직을 훼손하고, 플러그인이 여럿 설치되면
|
||||
// 서로의 치환 결과 위에 누적 적용되어 충돌한다.
|
||||
$listener = new AdjustEcommercePaymentMethodsLayoutListener;
|
||||
|
||||
$layout = [
|
||||
'layout_name' => 'admin_ecommerce_settings',
|
||||
@@ -33,34 +36,26 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
|
||||
[
|
||||
'type' => 'basic',
|
||||
'name' => 'Select',
|
||||
'if' => "{{!['point','deposit','free','dbank'].includes(\$method.id) && (_local.form?.available_pg_providers ?? []).length > 0}}",
|
||||
],
|
||||
[
|
||||
'type' => 'composite',
|
||||
'name' => 'Toggle',
|
||||
'props' => [
|
||||
'disabled' => "{{_computed.isReadOnly || !['point','deposit','free','dbank','kginicis_naverpay'].includes(\$method.id) && !\$method.pg_provider && !_local.form?.order_settings?.default_pg_provider}}",
|
||||
],
|
||||
'if' => '{{!$method.pg_locked && $method.needs_pg && (_local.form?.available_pg_providers ?? []).length > 0}}',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = $listener->markEasyPayMethodsAsPgNotRequired($layout, 1);
|
||||
$result = $listener->adjustPaymentMethodsLayout($layout, 1);
|
||||
$json = json_encode($result, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$this->assertIsString($json);
|
||||
$this->assertStringContainsString('kginicis_naverpay', $json);
|
||||
$this->assertStringContainsString('nhnkcp_payco', $json);
|
||||
$this->assertStringContainsString('nhnkcp_naverpay', $json);
|
||||
$this->assertStringContainsString('nhnkcp_naverpay_point', $json);
|
||||
$this->assertStringContainsString('nhnkcp_kakaopay', $json);
|
||||
$this->assertStringContainsString('nhnkcp_applepay', $json);
|
||||
$this->assertStringNotContainsString("['point','deposit','free','dbank'].includes", $json);
|
||||
// 코어 분기 표현식이 그대로 보존되어야 한다.
|
||||
$this->assertStringContainsString('$method.pg_locked', $json);
|
||||
$this->assertStringContainsString('$method.needs_pg', $json);
|
||||
// 간편결제 ID 를 표현식에 끼워 넣지 않는다.
|
||||
$this->assertStringNotContainsString('nhnkcp_naverpay', $json);
|
||||
$this->assertStringNotContainsString('nhnkcp_payco', $json);
|
||||
}
|
||||
|
||||
public function test_adds_test_mode_warning_to_order_settings_tab(): void
|
||||
{
|
||||
$listener = new AdjustEcommercePaymentMethodsLayoutListener();
|
||||
$listener = new AdjustEcommercePaymentMethodsLayoutListener;
|
||||
|
||||
$layout = [
|
||||
'layout_name' => 'admin_ecommerce_settings',
|
||||
@@ -75,8 +70,8 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
|
||||
],
|
||||
];
|
||||
|
||||
$result = $listener->markEasyPayMethodsAsPgNotRequired($layout, 1);
|
||||
$result = $listener->markEasyPayMethodsAsPgNotRequired($result, 1);
|
||||
$result = $listener->adjustPaymentMethodsLayout($layout, 1);
|
||||
$result = $listener->adjustPaymentMethodsLayout($result, 1);
|
||||
$json = json_encode($result, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$this->assertIsString($json);
|
||||
@@ -99,7 +94,7 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
|
||||
|
||||
public function test_leaves_other_layouts_unchanged(): void
|
||||
{
|
||||
$listener = new AdjustEcommercePaymentMethodsLayoutListener();
|
||||
$listener = new AdjustEcommercePaymentMethodsLayoutListener;
|
||||
|
||||
$layout = [
|
||||
'layout_name' => 'shop/checkout',
|
||||
@@ -110,6 +105,6 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
|
||||
],
|
||||
];
|
||||
|
||||
$this->assertSame($layout, $listener->markEasyPayMethodsAsPgNotRequired($layout, 1));
|
||||
$this->assertSame($layout, $listener->adjustPaymentMethodsLayout($layout, 1));
|
||||
}
|
||||
}
|
||||
+19
-7
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNhnkcp\Tests\Unit\Listeners;
|
||||
|
||||
use Plugins\Sirsoft\PayNhnkcp\Listeners\RegisterEasyPayMethodsListener;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Listeners\AdjustEcommercePaymentMethodsLayoutListener;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Listeners\RegisterEasyPayMethodsListener;
|
||||
use Plugins\Sirsoft\PayNhnkcp\Plugin;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -23,7 +23,7 @@ class RegisterEasyPayMethodsListenerTest extends TestCase
|
||||
|
||||
public function test_injects_easy_pay_methods_after_phone_and_existing_easy_pay_methods(): void
|
||||
{
|
||||
$listener = new RegisterEasyPayMethodsListener();
|
||||
$listener = new RegisterEasyPayMethodsListener;
|
||||
|
||||
$methods = $listener->injectEasyPayMethods([
|
||||
['id' => 'card'],
|
||||
@@ -51,15 +51,20 @@ class RegisterEasyPayMethodsListenerTest extends TestCase
|
||||
|
||||
public function test_plugin_registers_easy_pay_hook_listeners(): void
|
||||
{
|
||||
$listeners = (new Plugin())->getHookListeners();
|
||||
$listeners = (new Plugin)->getHookListeners();
|
||||
|
||||
$this->assertContains(RegisterEasyPayMethodsListener::class, $listeners);
|
||||
$this->assertContains(AdjustEcommercePaymentMethodsLayoutListener::class, $listeners);
|
||||
}
|
||||
|
||||
public function test_easy_pay_methods_do_not_require_pg_provider_in_saved_defaults(): void
|
||||
public function test_easy_pay_methods_are_locked_to_own_pg_provider(): void
|
||||
{
|
||||
$listener = new RegisterEasyPayMethodsListener();
|
||||
// 간편결제는 NHN KCP 결제창을 통해서만 처리되므로 PG 를 자기 자신으로 고정 선언한다.
|
||||
//
|
||||
// 과거에는 pg_provider 를 null 로 두었고(= "PG 없는 결제수단"), 그 결과 서버가
|
||||
// 간편결제 주문을 PG 결제가 아닌 주문으로 오인해 (a) 결제 실패했는데 관리자에게
|
||||
// 신규주문 알림이 발송되고 (b) 임시주문이 즉시 삭제되어 재결제가 불가능해졌다(#475).
|
||||
$listener = new RegisterEasyPayMethodsListener;
|
||||
|
||||
$methods = $listener->injectEasyPayMethods([
|
||||
['id' => 'phone'],
|
||||
@@ -75,15 +80,22 @@ class RegisterEasyPayMethodsListenerTest extends TestCase
|
||||
|
||||
foreach ($easyPayMethods as $method) {
|
||||
$this->assertArrayHasKey('defaults', $method);
|
||||
$this->assertNull($method['defaults']['pg_provider'] ?? null);
|
||||
|
||||
// PG 고정 — null 이면 코어가 PG 없는 주문으로 오인한다.
|
||||
$this->assertSame('nhnkcp', $method['defaults']['pg_provider'] ?? null);
|
||||
$this->assertTrue($method['defaults']['pg_locked'] ?? false);
|
||||
$this->assertTrue($method['defaults']['needs_pg'] ?? false);
|
||||
$this->assertSame('pg', $method['defaults']['refund_method'] ?? null);
|
||||
|
||||
$this->assertFalse($method['defaults']['is_active'] ?? true);
|
||||
$this->assertSame('payment_complete', $method['defaults']['stock_deduction_timing'] ?? null);
|
||||
$this->assertSame('payment_complete', $method['defaults']['mileage_deduction_timing'] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_easy_pay_method_labels_match_admin_payment_method_names(): void
|
||||
{
|
||||
$listener = new RegisterEasyPayMethodsListener();
|
||||
$listener = new RegisterEasyPayMethodsListener;
|
||||
|
||||
$methods = $listener->injectEasyPayMethods([
|
||||
['id' => 'phone'],
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNhnkcp\Tests\Unit\Upgrades;
|
||||
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Upgrades\Data\Ext\Plugins\SirsoftPayNhnkcp\V1_0_0_beta_4\Migrations\BackfillEasyPayPgProvider;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
require_once dirname(__DIR__, 3).'/upgrades/data/1.0.0-beta.4/migrations/BackfillEasyPayPgProvider.php';
|
||||
|
||||
/**
|
||||
* 저장된 주문설정의 NHN KCP 간편결제 PG 고정 백필 테스트 — 이슈 #475
|
||||
*
|
||||
* 기존 설치 환경의 order_settings.json 에는 간편결제가 `pg_provider: null` 로 영속화되어
|
||||
* 있다. 서버가 이 값을 보고 간편결제 주문을 "PG 결제가 아닌 주문" 으로 오인해 결제 실패
|
||||
* 시 관리자 알림 오발송 + 임시주문 삭제(재결제 불가) 를 일으켰다.
|
||||
*/
|
||||
class BackfillEasyPayPgProviderTest extends TestCase
|
||||
{
|
||||
private string $settingsPath;
|
||||
|
||||
private bool $hadOriginalSettings = false;
|
||||
|
||||
private ?string $originalSettings = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->settingsPath = storage_path('app/modules/sirsoft-ecommerce/settings/order_settings.json');
|
||||
$this->hadOriginalSettings = File::exists($this->settingsPath);
|
||||
$this->originalSettings = $this->hadOriginalSettings ? File::get($this->settingsPath) : null;
|
||||
|
||||
File::ensureDirectoryExists(dirname($this->settingsPath));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if ($this->hadOriginalSettings && $this->originalSettings !== null) {
|
||||
File::put($this->settingsPath, $this->originalSettings);
|
||||
} else {
|
||||
File::delete($this->settingsPath);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario method_kind=extension, capability_declared=declared, capability=pg_locked
|
||||
*
|
||||
* @effects upgrade_step_backfills_settings_file, saved_null_pg_provider_self_healed
|
||||
*/
|
||||
public function test_backfills_pg_declaration_for_nhnkcp_easy_pay_methods(): void
|
||||
{
|
||||
$this->writeSettings([
|
||||
['id' => 'card', 'pg_provider' => 'nhnkcp'],
|
||||
['id' => 'nhnkcp_naverpay', 'pg_provider' => null, 'is_active' => true],
|
||||
['id' => 'nhnkcp_applepay', 'pg_provider' => null, 'is_active' => true],
|
||||
]);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$methods = $this->readMethods();
|
||||
|
||||
foreach (['nhnkcp_naverpay', 'nhnkcp_applepay'] as $id) {
|
||||
$method = $methods[$id];
|
||||
$this->assertSame('nhnkcp', $method['pg_provider'], "{$id} 의 PG 가 고정되어야 한다");
|
||||
$this->assertTrue($method['pg_locked']);
|
||||
$this->assertTrue($method['needs_pg']);
|
||||
$this->assertSame('pg', $method['refund_method']);
|
||||
$this->assertTrue($method['is_active']);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_does_not_touch_other_plugins_or_builtin_methods(): void
|
||||
{
|
||||
$this->writeSettings([
|
||||
['id' => 'card', 'pg_provider' => 'nhnkcp'],
|
||||
['id' => 'dbank', 'pg_provider' => ''],
|
||||
['id' => 'kginicis_naverpay', 'pg_provider' => null],
|
||||
['id' => 'nhnkcp_naverpay', 'pg_provider' => null],
|
||||
]);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$methods = $this->readMethods();
|
||||
|
||||
$this->assertSame('nhnkcp', $methods['card']['pg_provider']);
|
||||
$this->assertArrayNotHasKey('pg_locked', $methods['card']);
|
||||
|
||||
$this->assertSame('', $methods['dbank']['pg_provider']);
|
||||
$this->assertArrayNotHasKey('pg_locked', $methods['dbank']);
|
||||
|
||||
$this->assertNull($methods['kginicis_naverpay']['pg_provider']);
|
||||
$this->assertArrayNotHasKey('pg_locked', $methods['kginicis_naverpay']);
|
||||
|
||||
$this->assertSame('nhnkcp', $methods['nhnkcp_naverpay']['pg_provider']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario method_kind=extension, capability_declared=declared, capability=pg_locked
|
||||
*
|
||||
* @effects upgrade_step_backfills_settings_file
|
||||
*/
|
||||
public function test_is_idempotent(): void
|
||||
{
|
||||
$this->writeSettings([
|
||||
['id' => 'nhnkcp_naverpay', 'pg_provider' => null],
|
||||
]);
|
||||
|
||||
$this->runMigration();
|
||||
$first = File::get($this->settingsPath);
|
||||
|
||||
$this->runMigration();
|
||||
$second = File::get($this->settingsPath);
|
||||
|
||||
$this->assertSame($first, $second, '재실행해도 결과가 달라지지 않아야 한다');
|
||||
}
|
||||
|
||||
public function test_skips_when_settings_file_is_missing(): void
|
||||
{
|
||||
File::delete($this->settingsPath);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertFalse(File::exists($this->settingsPath));
|
||||
}
|
||||
|
||||
public function test_skips_when_settings_json_is_malformed(): void
|
||||
{
|
||||
File::put($this->settingsPath, '{ not valid json');
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertSame('{ not valid json', File::get($this->settingsPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $paymentMethods
|
||||
*/
|
||||
private function writeSettings(array $paymentMethods): void
|
||||
{
|
||||
File::put($this->settingsPath, json_encode([
|
||||
'payment_methods' => $paymentMethods,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>> 결제수단 ID => 설정
|
||||
*/
|
||||
private function readMethods(): array
|
||||
{
|
||||
$settings = json_decode(File::get($this->settingsPath), true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
$keyed = [];
|
||||
foreach ($settings['payment_methods'] as $method) {
|
||||
$keyed[$method['id']] = $method;
|
||||
}
|
||||
|
||||
return $keyed;
|
||||
}
|
||||
|
||||
private function runMigration(): void
|
||||
{
|
||||
(new BackfillEasyPayPgProvider)->run(new UpgradeContext(
|
||||
fromVersion: '1.0.0-beta.3',
|
||||
toVersion: '1.0.0-beta.4',
|
||||
currentStep: '1.0.0-beta.4',
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNhnkcp\Upgrades;
|
||||
|
||||
use App\Extension\AbstractUpgradeStep;
|
||||
|
||||
/**
|
||||
* v1.0.0-beta.4 업그레이드 스텝
|
||||
*
|
||||
* 저장된 이커머스 주문설정의 NHN KCP 간편결제 항목에 PG 고정 선언을 백필한다.
|
||||
*
|
||||
* 모든 비즈니스 로직은 data/1.0.0-beta.4/migrations/ 로 격리(AbstractUpgradeStep 규약).
|
||||
*
|
||||
* @upgrade-path B
|
||||
*/
|
||||
class Upgrade_1_0_0_beta_4 extends AbstractUpgradeStep {}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Plugins\SirsoftPayNhnkcp\V1_0_0_beta_4\Migrations;
|
||||
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 저장된 이커머스 주문설정의 NHN KCP 간편결제 항목에 PG 고정 선언을 백필한다.
|
||||
*
|
||||
* 배경 (이슈 #475):
|
||||
*
|
||||
* 간편결제 수단은 과거 `pg_provider: null` 로 등록되어 저장 파일에 그대로 영속화됐다.
|
||||
* 서버는 이 값을 보고 간편결제 주문을 "PG 결제가 아닌 주문" 으로 오인했고, 그 결과
|
||||
* (a) 결제가 실패했는데 관리자에게 신규주문 알림이 발송되고 (b) 임시주문이 즉시 삭제되어
|
||||
* 재결제가 불가능해졌다.
|
||||
*
|
||||
* 현재 코드는 결제수단 카탈로그를 병합할 때 능력 선언(pg_provider / pg_locked / needs_pg /
|
||||
* refund_method)을 플러그인 정의에서 가져오므로, 저장 파일에 남은 낡은 값이 있어도 런타임
|
||||
* 동작은 이미 정상이다(자가 치유). 본 마이그레이션은 저장 파일 자체를 현재 선언과 일치시켜
|
||||
* 설정 화면과 파일 내용이 어긋나 보이지 않게 한다.
|
||||
*
|
||||
* V-1 안전 격리 (docs/extension/upgrade-step-guide.md §13):
|
||||
* - 파일 시스템 + FilePermissionHelper 만 사용 (이전 버전에도 존재하던 표면)
|
||||
* - Service / Manager / Repository 컨테이너 해석 없음
|
||||
*/
|
||||
final class BackfillEasyPayPgProvider implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 이커머스 모듈의 주문설정 저장 경로.
|
||||
*/
|
||||
private const SETTINGS_PATH = 'app/modules/sirsoft-ecommerce/settings/order_settings.json';
|
||||
|
||||
/**
|
||||
* 이 플러그인이 등록하는 간편결제 수단의 ID 접두사.
|
||||
*/
|
||||
private const METHOD_PREFIX = 'nhnkcp_';
|
||||
|
||||
/**
|
||||
* 이 플러그인이 제공하는 PG 식별자.
|
||||
*/
|
||||
private const PG_PROVIDER_ID = 'nhnkcp';
|
||||
|
||||
/**
|
||||
* 마이그레이션 식별자 (로그용).
|
||||
*
|
||||
* @return string 사람이 읽을 수 있는 짧은 식별자
|
||||
*/
|
||||
public function name(): string
|
||||
{
|
||||
return 'BackfillEasyPayPgProvider';
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 주문설정의 간편결제 항목에 PG 고정 선언을 백필한다. idempotent.
|
||||
*
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트 (로거 등)
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$path = storage_path(self::SETTINGS_PATH);
|
||||
|
||||
if (! File::exists($path)) {
|
||||
$context->logger->info('[nhnkcp] 이커머스 주문설정 파일 없음 — 기본값으로 동작하므로 skip');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$settings = json_decode(File::get($path), true);
|
||||
if (! is_array($settings) || ! is_array($settings['payment_methods'] ?? null)) {
|
||||
$context->logger->warning('[nhnkcp] 주문설정 JSON 형식 비정상 — 간편결제 PG 백필 skip');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$updated = 0;
|
||||
foreach ($settings['payment_methods'] as $index => $method) {
|
||||
if (! is_array($method)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = (string) ($method['id'] ?? '');
|
||||
if (! str_starts_with($id, self::METHOD_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$before = [
|
||||
$method['pg_provider'] ?? null,
|
||||
$method['pg_locked'] ?? null,
|
||||
$method['needs_pg'] ?? null,
|
||||
$method['refund_method'] ?? null,
|
||||
];
|
||||
|
||||
$method['pg_provider'] = self::PG_PROVIDER_ID;
|
||||
$method['pg_locked'] = true;
|
||||
$method['needs_pg'] = true;
|
||||
$method['refund_method'] = 'pg';
|
||||
|
||||
$after = [
|
||||
$method['pg_provider'],
|
||||
$method['pg_locked'],
|
||||
$method['needs_pg'],
|
||||
$method['refund_method'],
|
||||
];
|
||||
|
||||
if ($before !== $after) {
|
||||
$updated++;
|
||||
}
|
||||
|
||||
$settings['payment_methods'][$index] = $method;
|
||||
}
|
||||
|
||||
if ($updated === 0) {
|
||||
$context->logger->info('[nhnkcp] 간편결제 PG 선언이 이미 최신 — 변경 없음');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
File::put($path, json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||||
FilePermissionHelper::inheritOwnershipFromParent($path);
|
||||
|
||||
$context->logger->info('[nhnkcp] 간편결제 PG 고정 선언 백필 완료', [
|
||||
'pg_provider' => self::PG_PROVIDER_ID,
|
||||
'updated_methods' => $updated,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,16 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 플러그인이 활성화되어 있으면 홈, 게시판, 로그인 등 결제와 무관한 모든 페이지에서 주문서 조회 요청이 한 번씩 불필요하게 발생하던 문제를 수정했습니다. 장바구니가 없는 비회원에게는 이 요청이 매번 실패해 브라우저 오류로 기록됐습니다. 이제 결제 페이지에서만 요청합니다.
|
||||
- 플러그인이 활성화되어 있으면 홈, 게시판, 로그인 등 결제와 무관한 모든 페이지에서 주문서 조회 요청이 한 번씩 불필요하게 발생하던 문제를 수정했습니다. 장바구니가 없는 비회원에게는 이 요청이 매번 실패해 브라우저 오류로 기록됐습니다. 이제 이 요청 자체가 발생하지 않습니다.
|
||||
- 네이버페이·카카오페이·삼성페이·애플페이 등 나이스페이먼츠 간편결제로 주문할 때, 결제가 실패했는데도 관리자에게 "신규 주문 접수" 알림이 발송되던 문제를 수정했습니다.
|
||||
- 간편결제로 결제하다 실패한 뒤 다시 결제하려 하면 "임시주문을 찾을 수 없습니다" 오류가 뜨며 재결제가 불가능하던 문제를 수정했습니다. 이제 결제 실패 후에도 주문서가 유지됩니다.
|
||||
- 간편결제 주문을 취소할 때 카드 취소가 함께 이뤄지지 않을 수 있던 문제를 수정했습니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 간편결제가 정식 결제수단으로 취급되어 주문 내역에 실제로 선택한 결제수단(예: 네이버페이)이 그대로 기록됩니다.
|
||||
- 쇼핑몰 환경설정 > 주문설정의 결제수단 목록에서 간편결제가 "PG 고정 · 나이스페이먼츠"로 표시됩니다. 간편결제는 나이스페이먼츠 전용이므로 다른 결제대행사로 바꿀 수 없습니다.
|
||||
- 결제창을 띄우기 위해 브라우저 요청을 가로채 결제수단을 바꿔치기하던 내부 처리를 제거했습니다. 결제 진행 방식이 표준화됩니다.
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
(function(){"use strict";function $e(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}function De(e){return e.trim().slice(0,160)}async function Be(e,t="nicepay-window-closed"){if(!e.closeReportUrl)return;const n={oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??"",reason:De(t)||"nicepay-window-closed"};try{const a=window.G7Core?.api;if(typeof a?.post=="function"){await a.post(e.closeReportUrl,n);return}await fetch($e(e.closeReportUrl),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n),keepalive:!0})}catch(a){console.warn("[sirsoft-pay_nicepayments] failed to report payment window close",a)}}function Ge(e){return new Promise((t,n)=>{if(document.querySelector(`script[src="${e}"]`)){t();return}const a=document.createElement("script");a.src=e,a.onload=()=>t(),a.onerror=()=>n(new Error(`Failed to load script: ${e}`)),document.head.appendChild(a)})}function je(e,t){const n=document.createElement("form");n.id="nicepayForm",n.method="post",n.action=e,n.acceptCharset="utf-8";for(const[a,o]of Object.entries(t)){const r=document.createElement("input");r.type="hidden",r.name=a,r.value=o,n.appendChild(r)}return document.body.appendChild(n),n}const qe="/plugins/sirsoft-pay_nicepayments/payment/callback",Fe="https://web.nicepay.co.kr/v3/v3Payment.jsp";function He(){if(typeof navigator>"u")return!1;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(navigator.userAgent||"").toLowerCase();return!!(/android|iphone|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||(navigator.maxTouchPoints??0)>1&&!t.includes("windows")&&!t.includes("macintosh"))}async function J(e,t){const{pgPaymentData:n,paymentMethod:a}=e.params??{},o=window.__templateApp?.globalState?._local,r=a??o?.paymentMethod??"card";if(!n){console.error("[sirsoft-pay_nicepayments] pgPaymentData is required");return}const i=window.G7Core;try{const s=await i.api.get("/modules/sirsoft-ecommerce/payments/client-config/nicepayments");if(!s.data){console.error("[sirsoft-pay_nicepayments] Failed to fetch client config",s);return}const l=s.data,u=He();if(!u&&(await Ge(l.sdk_url),typeof window.goPay!="function")){i?.toast?.error?.("나이스페이먼츠 SDK를 불러올 수 없습니다. 잠시 후 다시 시도해주세요."),i?.state?.setLocal?.({isSubmittingOrder:!1,paymentMethod:r});return}const b=window.location.origin+l.sign_data_url,m=typeof localStorage<"u"?localStorage.getItem("auth_token"):null,p={"Content-Type":"application/json","X-Requested-With":"XMLHttpRequest",Accept:"application/json"};m&&(p.Authorization=`Bearer ${m}`);const P=await fetch(b,{method:"POST",credentials:"include",headers:p,body:JSON.stringify({amt:n.amount,moid:n.order_number,buyer_email:n.customer_email??"",buyer_phone:n.customer_phone??""})});if(!P.ok)throw new Error("SignData 생성에 실패했습니다.");const g=await P.json(),I=window.location.origin+qe,f=typeof r=="string"&&r.startsWith("nicepay_"),N=f?"CARD":{card:"CARD",vbank:"VBANK",bank:"BANK",phone:"CELLPHONE"}[r??"card"]??"CARD",d={PayMethod:N,GoodsName:n.order_name,Amt:String(n.amount),MID:g.mid,Moid:n.order_number,BuyerName:n.customer_name??"",BuyerEmail:n.customer_email??"",BuyerTel:n.customer_phone??"",ReturnURL:I,EdiDate:g.ediDate,SignData:g.signData,CharSet:"utf-8",GoodsCl:"1",DirectShowOpt:"",DirectEasyPay:"",NicepayReserved:"",EasyPayMethod:"",MallReserved:"",MallReserved1:""};N==="CELLPHONE"&&(d.GoodsCl=n.goods_cl??"1");const c=l.useEscrow&&!f;if(d.TransType=c?"1":"0",f)switch(d.DirectShowOpt="CARD",d.MallReserved=`nicepay_easy_pay_method=${encodeURIComponent(r)}`,d.MallReserved1=r,r){case"nicepay_naverpay":d.DirectEasyPay="E020",d.EasyPayMethod="E020=CARD";break;case"nicepay_kakaopay":d.NicepayReserved="DirectKakao=Y";break;case"nicepay_samsungpay":d.DirectEasyPay="E021";break;case"nicepay_applepay":d.DirectEasyPay="E025";break;case"nicepay_payco":d.NicepayReserved="DirectPayco=Y";break;case"nicepay_skpay":d.NicepayReserved="DirectPay11=Y";break;case"nicepay_ssgpay":d.DirectEasyPay="E007";break;case"nicepay_lpay":d.DirectEasyPay="E018";break}try{const w=(await i.api.get(`/modules/sirsoft-ecommerce/user/orders/${n.order_number}`))?.data;if(w){const T=Number(w.total_tax_amount??0),E=Number(w.total_vat_amount??0),Oe=Number(w.total_tax_free_amount??0);(T>0||E>0||Oe>0)&&(d.TaxAmt=String(T),d.VatAmt=String(E),d.TaxFreeAmt=String(Oe))}}catch{}const y=je(I,d);if(u){y.action=Fe,y.acceptCharset="euc-kr";const S=y.querySelector('input[name="CharSet"]');S&&(S.value="euc-kr"),y.submit();return}window.nicepaySubmit=()=>{y.submit()};let k=!1;const z=new Set(document.body.children),Re=(S,w)=>{if(k)return;k=!0;const T=[S,w].filter(Boolean).join(": ")||"nicepay-window-closed";Be({closeReportUrl:l.close_report_url,oid:n.order_number,price:Number(n.amount),buyer_email:n.customer_email??"Line truncated
|
||||
(function(){"use strict";function Ne(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}function ke(e){return e.trim().slice(0,160)}async function Se(e,t="nicepay-window-closed"){if(!e.closeReportUrl)return;const n={oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??"",reason:ke(t)||"nicepay-window-closed"};try{const a=window.G7Core?.api;if(typeof a?.post=="function"){await a.post(e.closeReportUrl,n);return}await fetch(Ne(e.closeReportUrl),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n),keepalive:!0})}catch(a){console.warn("[sirsoft-pay_nicepayments] failed to report payment window close",a)}}function Ce(e){return new Promise((t,n)=>{if(document.querySelector(`script[src="${e}"]`)){t();return}const a=document.createElement("script");a.src=e,a.onload=()=>t(),a.onerror=()=>n(new Error(`Failed to load script: ${e}`)),document.head.appendChild(a)})}function xe(e,t){const n=document.createElement("form");n.id="nicepayForm",n.method="post",n.action=e,n.acceptCharset="utf-8";for(const[a,o]of Object.entries(t)){const r=document.createElement("input");r.type="hidden",r.name=a,r.value=o,n.appendChild(r)}return document.body.appendChild(n),n}const Ee="/plugins/sirsoft-pay_nicepayments/payment/callback",Ae="https://web.nicepay.co.kr/v3/v3Payment.jsp";function ve(){if(typeof navigator>"u")return!1;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(navigator.userAgent||"").toLowerCase();return!!(/android|iphone|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||(navigator.maxTouchPoints??0)>1&&!t.includes("windows")&&!t.includes("macintosh"))}async function Le(e,t){const{pgPaymentData:n,paymentMethod:a}=e.params??{},o=window.__templateApp?.globalState?._local,r=a??n?.payment_method??o?.paymentMethod??"card";if(!n){console.error("[sirsoft-pay_nicepayments] pgPaymentData is required");return}const i=window.G7Core;try{const s=await i.api.get("/modules/sirsoft-ecommerce/payments/client-config/nicepayments");if(!s.data){console.error("[sirsoft-pay_nicepayments] Failed to fetch client config",s);return}const l=s.data,d=ve();if(!d&&(await Ce(l.sdk_url),typeof window.goPay!="function")){i?.toast?.error?.("나이스페이먼츠 SDK를 불러올 수 없습니다. 잠시 후 다시 시도해주세요."),i?.state?.setLocal?.({isSubmittingOrder:!1,paymentMethod:r});return}const b=window.location.origin+l.sign_data_url,y=typeof localStorage<"u"?localStorage.getItem("auth_token"):null,me={"Content-Type":"application/json","X-Requested-With":"XMLHttpRequest",Accept:"application/json"};y&&(me.Authorization=`Bearer ${y}`);const fe=await fetch(b,{method:"POST",credentials:"include",headers:me,body:JSON.stringify({amt:n.amount,moid:n.order_number,buyer_email:n.customer_email??"",buyer_phone:n.customer_phone??""})});if(!fe.ok)throw new Error("SignData 생성에 실패했습니다.");const R=await fe.json(),he=window.location.origin+Ee,D=typeof r=="string"&&r.startsWith("nicepay_"),ge=D?"CARD":{card:"CARD",vbank:"VBANK",bank:"BANK",phone:"CELLPHONE"}[r??"card"]??"CARD",c={PayMethod:ge,GoodsName:n.order_name,Amt:String(n.amount),MID:R.mid,Moid:n.order_number,BuyerName:n.customer_name??"",BuyerEmail:n.customer_email??"",BuyerTel:n.customer_phone??"",ReturnURL:he,EdiDate:R.ediDate,SignData:R.signData,CharSet:"utf-8",GoodsCl:"1",DirectShowOpt:"",DirectEasyPay:"",NicepayReserved:"",EasyPayMethod:"",MallReserved:"",MallReserved1:""};ge==="CELLPHONE"&&(c.GoodsCl=n.goods_cl??"1");const Jt=l.useEscrow&&!D;if(c.TransType=Jt?"1":"0",D)switch(c.DirectShowOpt="CARD",c.MallReserved=`nicepay_easy_pay_method=${encodeURIComponent(r)}`,c.MallReserved1=r,r){case"nicepay_naverpay":c.DirectEasyPay="E020",c.EasyPayMethod="E020=CARD";break;case"nicepay_kakaopay":c.NicepayReserved="DirectKakao=Y";break;case"nicepay_samsungpay":c.DirectEasyPay="E021";break;case"nicepay_applepay":c.DirectEasyPay="E025";break;case"nicepay_payco":c.NicepayReserved="DirectPayco=Y";break;case"nicepay_skpay":c.NicepayReserved="DirectPay11=Y";break;case"nicepay_ssgpay":c.DirectEasyPay="E007";break;case"nicepay_lpay":c.DirectEasyPay="E018";break}try{const p=(await i.api.get(`/modules/sirsoft-ecommerce/user/orders/${n.order_number}`))?.data;if(p){const N=Number(p.total_tax_amount??0),h=Number(p.total_vat_amount??0),be=Number(p.total_tax_free_amount??0);(N>0||h>0||be>0)&&(c.TaxAmt=String(N),c.VatAmt=String(h),c.TaxFreeAmt=String(be))}}catch{}const u=xe(he,c);if(d){u.action=Ae,u.acceptCharset="euc-kr";const f=u.querySelector('input[name="CharSet"]');f&&(f.value="euc-kr"),u.submit();return}window.nicepaySubmit=()=>{u.submit()};let Pe=!1;const Xt=new Set(document.body.children),we=(f,p)=>{if(Pe)return;Pe=!0;const N=[f,p].filter(Boolean).join(": ")||"nicepay-window-closed";Se({closeReportUrl:l.close_report_url,oid:n.order_number,price:Number(n.aLine truncated
|
||||
//# sourceMappingURL=plugin.iife.js.map
|
||||
+29
-37
@@ -1,9 +1,17 @@
|
||||
/**
|
||||
* setPaymentMethod 핸들러 테스트
|
||||
*
|
||||
* 결제수단 선택 시 _local 상태에 paymentMethod / serverPaymentMethod 를 분리 저장하는
|
||||
* 동작을 검증합니다. 간편결제(`nicepay_*`)는 G7 내부적으로 카드로 취급되어
|
||||
* `serverPaymentMethod = 'card'` 가 되어야 합니다.
|
||||
* 결제수단 선택 시 _local 상태에 선택한 결제수단 ID 를 **그대로** 저장하는 동작을 검증합니다.
|
||||
*
|
||||
* 과거에는 간편결제(`nicepay_*`)를 서버로 보낼 때 'card' 로 위장해야 했기 때문에
|
||||
* `serverPaymentMethod` 를 함께 저장했습니다. 서버 검증(PaymentMethodEnum)이 확장 ID 를
|
||||
* 422 로 막았기 때문입니다. 그 위장이 서버로 하여금 간편결제 주문을 "PG 결제가 아닌 주문"
|
||||
* 으로 오인하게 만들어, 결제 실패 시 관리자 알림 오발송 + 임시주문 삭제(재결제 불가)를
|
||||
* 일으켰습니다.
|
||||
*
|
||||
* 이제 확장 결제수단이 1급 시민이므로 위장이 없고, `serverPaymentMethod` 도 제거됐습니다.
|
||||
*
|
||||
* @see https://github.com/gnuboard/dev-g7/issues/475
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { setPaymentMethodHandler } from '../../handlers/setPaymentMethod';
|
||||
@@ -27,50 +35,34 @@ describe('setPaymentMethodHandler', () => {
|
||||
expect(setLocalSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('일반 결제수단(card)은 paymentMethod와 serverPaymentMethod에 동일 값 저장', () => {
|
||||
it('일반 결제수단(card)은 그대로 저장', () => {
|
||||
setPaymentMethodHandler({ params: { paymentMethod: 'card' } });
|
||||
|
||||
expect(setLocalSpy).toHaveBeenCalledWith({
|
||||
paymentMethod: 'card',
|
||||
serverPaymentMethod: 'card',
|
||||
});
|
||||
expect(setLocalSpy).toHaveBeenCalledWith({ paymentMethod: 'card' });
|
||||
});
|
||||
|
||||
it('가상계좌(vbank)도 그대로 분리 저장', () => {
|
||||
it('가상계좌(vbank)도 그대로 저장', () => {
|
||||
setPaymentMethodHandler({ params: { paymentMethod: 'vbank' } });
|
||||
|
||||
expect(setLocalSpy).toHaveBeenCalledWith({
|
||||
paymentMethod: 'vbank',
|
||||
serverPaymentMethod: 'vbank',
|
||||
});
|
||||
expect(setLocalSpy).toHaveBeenCalledWith({ paymentMethod: 'vbank' });
|
||||
});
|
||||
|
||||
describe('간편결제 (nicepay_*)', () => {
|
||||
it('네이버페이는 serverPaymentMethod=card 로 정규화', () => {
|
||||
describe('간편결제 (nicepay_*) — 확장 ID 를 위장하지 않는다', () => {
|
||||
it.each([
|
||||
['네이버페이', 'nicepay_naverpay'],
|
||||
['카카오페이', 'nicepay_kakaopay'],
|
||||
['애플페이', 'nicepay_applepay'],
|
||||
])('%s 는 확장 ID 그대로 저장된다', (_label, method) => {
|
||||
setPaymentMethodHandler({ params: { paymentMethod: method } });
|
||||
|
||||
expect(setLocalSpy).toHaveBeenCalledWith({ paymentMethod: method });
|
||||
});
|
||||
|
||||
it('serverPaymentMethod 를 더 이상 저장하지 않는다 (card 위장 제거)', () => {
|
||||
setPaymentMethodHandler({ params: { paymentMethod: 'nicepay_naverpay' } });
|
||||
|
||||
expect(setLocalSpy).toHaveBeenCalledWith({
|
||||
paymentMethod: 'nicepay_naverpay',
|
||||
serverPaymentMethod: 'card',
|
||||
});
|
||||
});
|
||||
|
||||
it('카카오페이도 serverPaymentMethod=card', () => {
|
||||
setPaymentMethodHandler({ params: { paymentMethod: 'nicepay_kakaopay' } });
|
||||
|
||||
expect(setLocalSpy).toHaveBeenCalledWith({
|
||||
paymentMethod: 'nicepay_kakaopay',
|
||||
serverPaymentMethod: 'card',
|
||||
});
|
||||
});
|
||||
|
||||
it('애플페이도 serverPaymentMethod=card', () => {
|
||||
setPaymentMethodHandler({ params: { paymentMethod: 'nicepay_applepay' } });
|
||||
|
||||
expect(setLocalSpy).toHaveBeenCalledWith({
|
||||
paymentMethod: 'nicepay_applepay',
|
||||
serverPaymentMethod: 'card',
|
||||
});
|
||||
const saved = setLocalSpy.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(saved).not.toHaveProperty('serverPaymentMethod');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+77
-154
@@ -1,181 +1,104 @@
|
||||
/**
|
||||
* 주문 생성 인터셉터 테스트 — 이슈 #475
|
||||
*
|
||||
* 확장 결제수단이 1급 시민이 되면서 인터셉터의 우회가 전부 제거됐다:
|
||||
* - payment_method 를 'card' 로 위장 (서버 검증이 확장 ID 를 422 로 막았기 때문)
|
||||
* - 응답의 requires_pg_payment / redirect_url 변조 (navigate-to-self 강제)
|
||||
* - pg_payment_data 재구성
|
||||
* - navigate-to-self 로 인한 "주문서 없음" 다이얼로그를 막기 위한 GET checkout 캐싱
|
||||
*
|
||||
* 위장이 서버로 하여금 간편결제 주문을 "PG 결제가 아닌 주문" 으로 오인하게 만들어
|
||||
* (a) 결제 실패 시 관리자 알림 오발송 (b) 임시주문 삭제 → 재결제 불가 를 일으켰다.
|
||||
*
|
||||
* 이제 결제창 진입은 서버 응답의 pg_payment_handler 를 템플릿이 dispatch 하는 경로로
|
||||
* 처리되고, 템플릿의 PG 분기는 navigate 를 하지 않으므로 캐싱도 불필요하다.
|
||||
*
|
||||
* 본 테스트는 인터셉터가 fetch 를 일절 건드리지 않음(no-op)을 회귀 방지로 고정한다.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { installOrderResponseInterceptor } from '../orderResponseInterceptor';
|
||||
import { requestPaymentHandler } from '../handlers/requestPayment';
|
||||
|
||||
vi.mock('../handlers/requestPayment', () => ({
|
||||
requestPaymentHandler: vi.fn(),
|
||||
}));
|
||||
|
||||
const requestPaymentMock = vi.mocked(requestPaymentHandler);
|
||||
|
||||
function resetNicepayWindowFlags(): void {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
delete w.__sirsoftNicepayFetchInterceptorInstalled;
|
||||
delete w.__sirsoftPgOriginalFetch;
|
||||
delete w.__sirsoftNicepayPaymentInProgress;
|
||||
delete w.__sirsoftNicepayCheckoutCache;
|
||||
delete w.__sirsoftNicepayAxiosCheckoutInterceptorInstalled;
|
||||
}
|
||||
|
||||
/** 현재 경로를 지정한다 (jsdom 은 history API 로만 pathname 변경 가능) */
|
||||
function setPathname(pathname: string): void {
|
||||
window.history.replaceState({}, '', pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
* G7Core.api 스파이를 설치하고 get 스파이를 반환한다.
|
||||
*
|
||||
* 캐시 프리페치는 tryInstallAxiosCheckoutInterceptor 안에 있고, 이 함수는
|
||||
* `G7Core.api.client`(Axios)가 준비될 때까지 재시도하다가 준비되면 프리페치한다.
|
||||
* 따라서 client(=interceptors 를 가진 객체)를 함께 제공해야 실제 경로를 탄다.
|
||||
*/
|
||||
function installCoreApiSpy(): ReturnType<typeof vi.fn> {
|
||||
const getSpy = vi.fn().mockResolvedValue({});
|
||||
const axiosClient = {
|
||||
interceptors: {
|
||||
request: { use: vi.fn() },
|
||||
response: { use: vi.fn() },
|
||||
},
|
||||
};
|
||||
(window as unknown as Record<string, unknown>).G7Core = {
|
||||
api: { get: getSpy, client: axiosClient },
|
||||
};
|
||||
|
||||
return getSpy;
|
||||
}
|
||||
|
||||
function buildOrderResponse(order: Record<string, unknown>): Response {
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
order,
|
||||
requires_pg_payment: false,
|
||||
redirect_url: '/shop/orders/ORD-NICE-001/complete',
|
||||
},
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const ORDER_CREATE_URL = '/api/modules/sirsoft-ecommerce/user/orders';
|
||||
const CHECKOUT_URL = '/api/modules/sirsoft-ecommerce/checkout';
|
||||
|
||||
describe('installOrderResponseInterceptor', () => {
|
||||
let originalFetch: typeof window.fetch;
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = window.fetch;
|
||||
fetchSpy = vi.fn();
|
||||
(window as unknown as Record<string, unknown>).fetch = fetchSpy;
|
||||
resetNicepayWindowFlags();
|
||||
requestPaymentMock.mockClear();
|
||||
window.history.replaceState({}, '', '/shop/checkout');
|
||||
vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.fetch = originalFetch;
|
||||
resetNicepayWindowFlags();
|
||||
delete (window as unknown as Record<string, unknown>).G7Core;
|
||||
setPathname('/');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('체크아웃 캐시 프리페치 라우트 가드', () => {
|
||||
// 플러그인 번들은 모든 페이지에 로드된다. 캐시 프리페치는 체크아웃 페이지의
|
||||
// navigate-to-self 재초기화를 위한 것이므로, 다른 라우트에서 발화하면
|
||||
// 비회원에게 무의미한 422(cart_key_required) 왕복만 발생시킨다.
|
||||
it.each([
|
||||
['홈', '/'],
|
||||
['게시판', '/board'],
|
||||
['로그인', '/login'],
|
||||
['상품 상세', '/shop/products/1'],
|
||||
['장바구니', '/shop/cart'],
|
||||
])('%s 에서는 체크아웃을 프리페치하지 않는다', async (_label, pathname) => {
|
||||
setPathname(pathname);
|
||||
const getSpy = installCoreApiSpy();
|
||||
|
||||
installOrderResponseInterceptor();
|
||||
await vi.waitFor(() => expect(true).toBe(true));
|
||||
|
||||
expect(getSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('체크아웃 페이지에서는 캐시 프리페치가 동작한다', async () => {
|
||||
setPathname('/shop/checkout');
|
||||
const getSpy = installCoreApiSpy();
|
||||
|
||||
installOrderResponseInterceptor();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getSpy).toHaveBeenCalledWith('/modules/sirsoft-ecommerce/checkout');
|
||||
});
|
||||
});
|
||||
|
||||
it('이미 캐시가 있으면 체크아웃 페이지에서도 재요청하지 않는다', async () => {
|
||||
setPathname('/shop/checkout');
|
||||
(window as unknown as Record<string, unknown>).__sirsoftNicepayCheckoutCache =
|
||||
JSON.stringify({ data: {} });
|
||||
const getSpy = installCoreApiSpy();
|
||||
|
||||
installOrderResponseInterceptor();
|
||||
await vi.waitFor(() => expect(true).toBe(true));
|
||||
|
||||
expect(getSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('간편결제 fallback pg_payment_data 금액은 total_due_amount를 우선 사용', async () => {
|
||||
fetchSpy.mockResolvedValue(buildOrderResponse({
|
||||
order_number: 'ORD-NICE-001',
|
||||
total_amount: '57000.00',
|
||||
total_due_amount: '53000.00',
|
||||
options: [{ product_name: '테스트 상품' }],
|
||||
orderer_name: '홍길동',
|
||||
orderer_email: 'buyer@example.com',
|
||||
orderer_phone: '010-1234-5678',
|
||||
user_id: 7,
|
||||
}));
|
||||
it('window.fetch 를 래핑하지 않는다 (no-op)', () => {
|
||||
const originalFetch = vi.fn();
|
||||
window.fetch = originalFetch as unknown as typeof fetch;
|
||||
|
||||
installOrderResponseInterceptor();
|
||||
|
||||
await window.fetch('/api/modules/sirsoft-ecommerce/user/orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ payment_method: 'nicepay_kakaopay' }),
|
||||
});
|
||||
|
||||
expect(requestPaymentMock).toHaveBeenCalledWith({
|
||||
params: {
|
||||
pgPaymentData: expect.objectContaining({
|
||||
order_number: 'ORD-NICE-001',
|
||||
order_name: '테스트 상품',
|
||||
amount: 53000,
|
||||
customer_phone: '01012345678',
|
||||
}),
|
||||
paymentMethod: 'nicepay_kakaopay',
|
||||
},
|
||||
});
|
||||
// 래핑하면 다른 함수 참조가 된다 — 동일 참조여야 한다.
|
||||
expect(window.fetch).toBe(originalFetch);
|
||||
});
|
||||
|
||||
it('total_due_amount가 없는 구형 응답은 total_amount로 fallback', async () => {
|
||||
fetchSpy.mockResolvedValue(buildOrderResponse({
|
||||
order_number: 'ORD-NICE-002',
|
||||
total_amount: '57000.00',
|
||||
options: [{ product_name: '테스트 상품' }],
|
||||
}));
|
||||
it('주문 생성 요청의 payment_method 를 위장하지 않는다', async () => {
|
||||
let sentBody = '';
|
||||
window.fetch = vi.fn().mockImplementation(async (_input: unknown, init?: RequestInit) => {
|
||||
sentBody = String(init?.body ?? '');
|
||||
return new Response('{}', { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
installOrderResponseInterceptor();
|
||||
|
||||
await window.fetch('/api/modules/sirsoft-ecommerce/user/orders', {
|
||||
await window.fetch(ORDER_CREATE_URL, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ payment_method: 'nicepay_naverpay' }),
|
||||
});
|
||||
|
||||
expect(requestPaymentMock).toHaveBeenCalledWith({
|
||||
params: {
|
||||
pgPaymentData: expect.objectContaining({
|
||||
order_number: 'ORD-NICE-002',
|
||||
amount: 57000,
|
||||
}),
|
||||
paymentMethod: 'nicepay_naverpay',
|
||||
expect(JSON.parse(sentBody).payment_method).toBe('nicepay_naverpay');
|
||||
});
|
||||
|
||||
it('주문 생성 응답을 변조하지 않는다', async () => {
|
||||
const serverBody = {
|
||||
success: true,
|
||||
data: {
|
||||
order: { order_number: 'ORD-1' },
|
||||
redirect_url: '/shop/orders/ORD-1/complete',
|
||||
requires_pg_payment: true,
|
||||
pg_provider: 'sirsoft-nicepayments',
|
||||
pg_payment_handler: 'sirsoft-pay_nicepayments.requestPayment',
|
||||
},
|
||||
});
|
||||
};
|
||||
window.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(serverBody), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
installOrderResponseInterceptor();
|
||||
|
||||
const response = await window.fetch(ORDER_CREATE_URL, { method: 'POST', body: '{}' });
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.data.requires_pg_payment).toBe(true);
|
||||
expect(body.data.redirect_url).toBe('/shop/orders/ORD-1/complete');
|
||||
expect(body.data.pg_payment_handler).toBe('sirsoft-pay_nicepayments.requestPayment');
|
||||
});
|
||||
|
||||
it('GET checkout 응답을 캐싱하거나 가로채지 않는다', async () => {
|
||||
const checkoutResponse = new Response('{"data":{}}', { status: 200 });
|
||||
const fetchSpy = vi.fn().mockResolvedValue(checkoutResponse);
|
||||
window.fetch = fetchSpy as unknown as typeof fetch;
|
||||
|
||||
installOrderResponseInterceptor();
|
||||
|
||||
const first = await window.fetch(CHECKOUT_URL, { method: 'GET' });
|
||||
const second = await window.fetch(CHECKOUT_URL, { method: 'GET' });
|
||||
|
||||
// 캐시 서빙이 부활하면 두 번째 호출이 네트워크로 나가지 않는다.
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
expect(first).toBe(checkoutResponse);
|
||||
expect(second).toBe(checkoutResponse);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,11 @@ interface PgPaymentData {
|
||||
order_name: string;
|
||||
amount: number;
|
||||
currency?: string;
|
||||
/**
|
||||
* 서버가 저장한 결제수단 ID — 확장 수단이면 확장 ID 그대로(예: 'nicepay_naverpay').
|
||||
* 확장 결제수단이 1급 시민이 되면서 서버가 원본 수단을 알고 내려준다(#475).
|
||||
*/
|
||||
payment_method?: string;
|
||||
customer_name?: string;
|
||||
customer_email?: string;
|
||||
customer_phone?: string;
|
||||
@@ -175,7 +180,11 @@ export async function requestPaymentHandler(action: PaymentAction, _context?: un
|
||||
const { pgPaymentData, paymentMethod: paramPaymentMethod } = action.params ?? {};
|
||||
|
||||
const localState = window.__templateApp?.globalState?._local;
|
||||
const paymentMethod = paramPaymentMethod ?? localState?.paymentMethod ?? 'card';
|
||||
// 서버가 저장한 결제수단(payment_method)이 SSoT — 확장 수단이면 확장 ID 가 그대로 온다(#475).
|
||||
const paymentMethod = paramPaymentMethod
|
||||
?? pgPaymentData?.payment_method
|
||||
?? localState?.paymentMethod
|
||||
?? 'card';
|
||||
|
||||
if (!pgPaymentData) {
|
||||
console.error('[sirsoft-pay_nicepayments] pgPaymentData is required');
|
||||
|
||||
@@ -55,11 +55,11 @@ export function setPaymentMethodHandler(action: any): void {
|
||||
const paymentMethod = action.params?.paymentMethod;
|
||||
if (!paymentMethod) return;
|
||||
|
||||
// 확장 결제수단은 1급 시민 — 선택한 수단 ID 를 그대로 서버에 보낸다.
|
||||
// (과거에는 서버 검증이 확장 ID 를 422 로 막아 'card' 로 정규화한 serverPaymentMethod 를
|
||||
// 함께 저장했으나, 소비처가 없는 죽은 코드였고 위장 자체가 결함의 원인이었다 — #475)
|
||||
const isEasyPay = typeof paymentMethod === 'string' && paymentMethod.indexOf('nicepay_') === 0;
|
||||
(window as any).G7Core?.state?.setLocal?.({
|
||||
paymentMethod,
|
||||
serverPaymentMethod: isEasyPay ? 'card' : paymentMethod,
|
||||
});
|
||||
(window as any).G7Core?.state?.setLocal?.({ paymentMethod });
|
||||
|
||||
if (isEasyPay) {
|
||||
updateEasyPayButtonStyles(paymentMethod);
|
||||
|
||||
+32
-325
@@ -1,342 +1,49 @@
|
||||
/**
|
||||
* 주문 생성 API 요청/응답 인터셉터
|
||||
* 주문 생성 요청/응답 인터셉터 — 제거됨 (no-op)
|
||||
*
|
||||
* G7Core 템플릿 엔진의 apiCall 핸들러는 window.fetch 를 직접 사용하므로
|
||||
* window.fetch 를 래핑하는 방식으로 동작한다.
|
||||
* 과거에는 이 인터셉터가 다음 우회를 수행했다:
|
||||
*
|
||||
* 1. POST /api/modules/sirsoft-ecommerce/user/orders 요청을 가로챈다
|
||||
* 2. payment_method 가 'nicepay_*' 이면 'card' 로 교체해 서버에 전송
|
||||
* (서버의 PaymentMethodEnum 에 nicepay_* 가 없어서 422 발생 방지)
|
||||
* 3. 응답에서 pg_provider === 'sirsoft-nicepayments' 이면 requestPayment 핸들러를 직접 호출
|
||||
* 4. 원래 nicepay 방식(paymentMethod)을 requestPayment 에 전달해 올바른 결제창 호출
|
||||
* 5. requires_pg_payment → false, redirect_url → 현재 URL 로 교체
|
||||
* → 템플릿 조건 분기가 !requires_pg_payment 쪽으로 떨어져 navigate-to-self 가 됨
|
||||
* 1. 요청 body 의 `payment_method` 를 'card' 로 위장 (서버 검증이 확장 ID 를 422 로 막았기 때문)
|
||||
* 2. 응답의 `requires_pg_payment` 를 false 로, `redirect_url` 을 self 로 변조해
|
||||
* 템플릿의 navigate 를 navigate-to-self 로 무력화하고 결제창을 직접 띄움
|
||||
* 3. `pg_payment_data` 를 order 응답에서 직접 재구성
|
||||
* 4. navigate-to-self 로 체크아웃이 재초기화될 때 뜨는 "주문서 없음" 다이얼로그를
|
||||
* 억제하기 위한 GET checkout 응답 캐싱 (fetch + axios 양쪽)
|
||||
*
|
||||
* 이 우회는 두 개의 심각한 결함을 낳았다 — 서버가 간편결제 주문을 "PG 결제가 아닌 주문"
|
||||
* 으로 오인해 (a) 결제 실패했는데 관리자에게 신규주문 알림이 발송되고 (b) 임시주문이
|
||||
* 즉시 삭제되어 재결제가 불가능해졌다.
|
||||
*
|
||||
* 이제 코어가 확장 결제수단을 1급 시민으로 처리한다:
|
||||
* - 서버 검증이 결제수단 카탈로그를 화이트리스트로 사용 (확장 ID 통과)
|
||||
* - 플러그인이 `pg_provider` 를 자기 PG 로 고정 선언 → PG 결제 주문으로 정상 판정
|
||||
* - provider 가 선언한 `payment_handler` 가 응답의 `pg_payment_handler` 로 내려가고,
|
||||
* 템플릿이 그 핸들러를 직접 dispatch 한다 (navigate 분기 자체가 실행되지 않음)
|
||||
*
|
||||
* 템플릿이 PG 분기에서 navigate 를 하지 않으므로 navigate-to-self 도, 그로 인한
|
||||
* 체크아웃 재초기화도 발생하지 않는다. 따라서 GET checkout 캐싱도 불필요하다.
|
||||
*
|
||||
* 파일과 export 는 호출부(index.ts) 호환을 위해 유지하되 동작은 no-op 이다.
|
||||
*
|
||||
* @see https://github.com/gnuboard/dev-g7/issues/475
|
||||
*/
|
||||
|
||||
import { requestPaymentHandler } from './handlers/requestPayment';
|
||||
|
||||
const ORDER_CREATE_PATH = '/user/orders';
|
||||
const TARGET_PG_PROVIDER = 'sirsoft-nicepayments';
|
||||
const PLUGIN_IDENTIFIER = 'sirsoft-pay_nicepayments';
|
||||
const FETCH_FLAG = '__sirsoftNicepayFetchInterceptorInstalled';
|
||||
const CHECKOUT_ENDPOINT_PATH = '/api/modules/sirsoft-ecommerce/checkout';
|
||||
const PAYMENT_IN_PROGRESS_FLAG = '__sirsoftNicepayPaymentInProgress';
|
||||
const CHECKOUT_CACHE_KEY = '__sirsoftNicepayCheckoutCache';
|
||||
|
||||
/** 체크아웃 페이지 경로 (캐시 프리페치를 이 경로에서만 수행) */
|
||||
const CHECKOUT_ROUTE_RE = /^\/shop\/checkout\/?$/;
|
||||
|
||||
const logger = {
|
||||
info: (...args: unknown[]) => console.info(`[${PLUGIN_IDENTIFIER}]`, ...args),
|
||||
warn: (...args: unknown[]) => console.warn(`[${PLUGIN_IDENTIFIER}]`, ...args),
|
||||
error: (...args: unknown[]) => console.error(`[${PLUGIN_IDENTIFIER}]`, ...args),
|
||||
};
|
||||
|
||||
function buildNoOpRedirectUrl(): string {
|
||||
return window.location.pathname + window.location.search + window.location.hash;
|
||||
}
|
||||
|
||||
function isTargetOrderEndpoint(url: string, method: string): boolean {
|
||||
if (method.toUpperCase() !== 'POST') return false;
|
||||
const path = (url ?? '').split('?')[0].split('#')[0];
|
||||
return path === ORDER_CREATE_PATH || path.endsWith(ORDER_CREATE_PATH);
|
||||
}
|
||||
|
||||
function isCheckoutEndpoint(url: string, method: string): boolean {
|
||||
if (method.toUpperCase() !== 'GET') return false;
|
||||
const path = (url ?? '').split('?')[0].split('#')[0];
|
||||
return path === CHECKOUT_ENDPOINT_PATH || path.endsWith(CHECKOUT_ENDPOINT_PATH);
|
||||
}
|
||||
|
||||
/** 현재 브라우저 경로가 체크아웃 페이지인지 판정한다. */
|
||||
function isCheckoutRoute(): boolean {
|
||||
return CHECKOUT_ROUTE_RE.test(window.location.pathname);
|
||||
}
|
||||
|
||||
function extractPaymentMethodFromBody(body: unknown): string | undefined {
|
||||
if (!body) return undefined;
|
||||
let obj: Record<string, unknown> | null = null;
|
||||
if (typeof body === 'string') {
|
||||
try { obj = JSON.parse(body) as Record<string, unknown>; } catch { return undefined; }
|
||||
} else if (typeof body === 'object') {
|
||||
obj = body as Record<string, unknown>;
|
||||
}
|
||||
if (!obj) return undefined;
|
||||
const v = obj['payment_method'];
|
||||
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
||||
}
|
||||
|
||||
function replacePaymentMethodInBody(body: string, newMethod: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as Record<string, unknown>;
|
||||
if ('payment_method' in parsed) {
|
||||
return JSON.stringify({ ...parsed, payment_method: newMethod });
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
return body;
|
||||
}
|
||||
|
||||
function resolveOrderPaymentAmount(orderData: Record<string, unknown>): number {
|
||||
const candidates = [orderData.total_due_amount, orderData.total_amount];
|
||||
for (const candidate of candidates) {
|
||||
const amount = Number(candidate ?? 0);
|
||||
if (Number.isFinite(amount) && amount > 0) {
|
||||
return Math.floor(amount);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주문 생성 인터셉터 — 더 이상 fetch 를 래핑하지 않는다.
|
||||
*
|
||||
* 결제창 진입은 코어 응답의 `pg_payment_handler` 를 템플릿이 dispatch 하는 경로로 처리된다.
|
||||
*/
|
||||
export function installOrderResponseInterceptor(): void {
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
if (w[FETCH_FLAG]) return;
|
||||
w[FETCH_FLAG] = true;
|
||||
|
||||
// 최초 설치 플러그인이 원본 브라우저 fetch를 보존 (다른 PG 인터셉터가 쌓이기 전)
|
||||
// easy pay 요청 시 이 fetch를 사용해 다른 PG 인터셉터(NHN KCP 등)의 간섭을 차단한다
|
||||
const ORIGINAL_FETCH_KEY = '__sirsoftPgOriginalFetch';
|
||||
if (!w[ORIGINAL_FETCH_KEY]) {
|
||||
w[ORIGINAL_FETCH_KEY] = window.fetch.bind(window);
|
||||
}
|
||||
const browserFetch = w[ORIGINAL_FETCH_KEY] as typeof fetch;
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
|
||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: (input as Request).url;
|
||||
const method = init?.method ?? (input instanceof Request ? input.method : 'GET');
|
||||
|
||||
// GET checkout 캐싱 — 결제 진행 중 페이지 재초기화 시 "주문서 없음" 다이얼로그 억제
|
||||
if (isCheckoutEndpoint(url, method)) {
|
||||
if (w[PAYMENT_IN_PROGRESS_FLAG] && w[CHECKOUT_CACHE_KEY]) {
|
||||
logger.info('payment in progress — serving cached checkout response');
|
||||
return new Response(w[CHECKOUT_CACHE_KEY] as string, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const checkoutResp = await originalFetch(input, init);
|
||||
if (checkoutResp.ok) {
|
||||
const text = await checkoutResp.text();
|
||||
w[CHECKOUT_CACHE_KEY] = text;
|
||||
return new Response(text, {
|
||||
status: checkoutResp.status,
|
||||
statusText: checkoutResp.statusText,
|
||||
headers: checkoutResp.headers,
|
||||
});
|
||||
}
|
||||
return checkoutResp;
|
||||
}
|
||||
|
||||
if (!isTargetOrderEndpoint(url, method)) {
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
|
||||
// 요청 body에서 payment_method 추출. nicepay_* (간편결제) 만 'card' 로 교체.
|
||||
// 일반 결제수단(card / vbank / bank / phone)도 그대로 보존해 requestPaymentHandler
|
||||
// 호출 시 정확한 결제수단으로 결제창을 연다 (이전엔 nicepay_* 만 추적해서
|
||||
// 가상계좌/계좌이체/휴대폰 선택 시 'card' 로 폴백되는 회귀가 있었음).
|
||||
let originalPaymentMethod: string | undefined;
|
||||
let isEasyPay = false;
|
||||
let modifiedInit = init;
|
||||
|
||||
if (init?.body && typeof init.body === 'string') {
|
||||
const pm = extractPaymentMethodFromBody(init.body);
|
||||
if (typeof pm === 'string' && pm.length > 0) {
|
||||
originalPaymentMethod = pm;
|
||||
if (pm.startsWith('nicepay_')) {
|
||||
isEasyPay = true;
|
||||
modifiedInit = { ...init, body: replacePaymentMethodInBody(init.body, 'card') };
|
||||
logger.info(`easy pay detected: replacing payment_method '${pm}' → 'card'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// easy pay일 때는 browserFetch(원본)를 사용해 NHN KCP 등 다른 PG 인터셉터를 우회한다.
|
||||
// "타 PG와 사용가능함"이 ON이고 기본 PG가 NHN KCP일 때 NHN KCP 결제창이 먼저 열리는 것을 방지.
|
||||
const fetchFn = isEasyPay ? browserFetch : originalFetch;
|
||||
const response = await fetchFn(input, modifiedInit);
|
||||
|
||||
// 2xx 응답만 처리 (4xx/5xx 오류 응답은 그대로 통과)
|
||||
if (!response.ok) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// 응답 파싱 (clone으로 원본 스트림 보호)
|
||||
// 서버 응답 구조: { success, message, data: { order, requires_pg_payment, pg_provider, pg_payment_data, redirect_url } }
|
||||
let envelope: Record<string, unknown> | null = null;
|
||||
try {
|
||||
envelope = (await response.clone().json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return response;
|
||||
}
|
||||
|
||||
const responseData = (envelope?.data ?? envelope) as Record<string, unknown> | null;
|
||||
|
||||
// 일반 결제: PG 결제가 필요 없으면 통과
|
||||
// 간편결제(nicepay_*): requires_pg_payment=false여도 계속 처리
|
||||
// → 기본 PG가 미설정이면 백엔드가 requires_pg_payment:false를 반환하지만
|
||||
// 간편결제는 나이스페이먼츠 결제창을 열어야 하므로 통과시킴 (취약점 방어)
|
||||
if (!responseData?.requires_pg_payment && !isEasyPay) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// easy pay: pg_provider 무관하게 나이스페이 처리
|
||||
// 일반 결제: pg_provider가 나이스페이인 경우에만 처리 (나이스페이가 기본 PG일 때)
|
||||
const isNicepayPg = responseData.pg_provider === TARGET_PG_PROVIDER;
|
||||
|
||||
if (!isEasyPay && !isNicepayPg) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// pg_payment_data: 백엔드 응답에 포함되거나,
|
||||
// 간편결제 + 기본 PG 미설정 시 order 데이터에서 직접 구성
|
||||
let pgPaymentData = responseData.pg_payment_data as Record<string, unknown> | undefined;
|
||||
if (!pgPaymentData && isEasyPay) {
|
||||
const orderData = responseData.order as Record<string, unknown> | undefined;
|
||||
if (orderData) {
|
||||
const options = orderData.options as Array<Record<string, unknown>> | undefined;
|
||||
const firstName = (options?.[0]?.product_name as string | undefined) ?? String(orderData.order_number ?? '');
|
||||
const orderName = (options?.length ?? 0) > 1
|
||||
? `${firstName} 외 ${(options?.length ?? 0) - 1}건`
|
||||
: firstName;
|
||||
pgPaymentData = {
|
||||
order_number: orderData.order_number,
|
||||
order_name: orderName,
|
||||
// PG 청구액 SSoT는 total_due_amount. 구형 응답 호환만 total_amount로 fallback.
|
||||
amount: resolveOrderPaymentAmount(orderData),
|
||||
currency: 'KRW',
|
||||
customer_name: orderData.orderer_name ?? null,
|
||||
customer_email: orderData.orderer_email ?? null,
|
||||
customer_phone: String(orderData.orderer_phone ?? '').replace(/[^0-9]/g, ''),
|
||||
customer_key: orderData.user_id ?? null,
|
||||
};
|
||||
logger.info('pg_payment_data constructed from order (기본 PG 미설정)', {
|
||||
order_number: pgPaymentData.order_number,
|
||||
amount: pgPaymentData.amount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!pgPaymentData) {
|
||||
logger.warn('nicepayments order detected but pg_payment_data missing');
|
||||
return response;
|
||||
}
|
||||
|
||||
const paymentMethod = originalPaymentMethod ?? 'card';
|
||||
|
||||
logger.info('intercepted order create response — opening PG popup', { paymentMethod });
|
||||
|
||||
void requestPaymentHandler({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
params: { pgPaymentData: pgPaymentData as any, paymentMethod },
|
||||
});
|
||||
|
||||
// 결제 진행 중 플래그 설정 — GET checkout 캐시 서빙으로 "주문서 없음" 다이얼로그 억제
|
||||
w[PAYMENT_IN_PROGRESS_FLAG] = true;
|
||||
|
||||
// 결제창(팝업)이 열려 있는 동안 checkout 페이지에 머물도록
|
||||
// redirect_url을 항상 현재 URL(navigate-to-self)로 교체한다.
|
||||
// → 템플릿 fallback navigate가 무력화되어 "결제 완료" 페이지로 이동하지 않음
|
||||
// → 실제 결제 완료 후 requestPaymentHandler 콜백이 complete 페이지로 이동시킴
|
||||
// navigate-to-self로 checkout 재초기화 시 GET checkout 404 → "주문서 없음" 다이얼로그가
|
||||
// 뜰 수 있으나, PAYMENT_IN_PROGRESS_FLAG + 캐시 서빙으로 억제된다.
|
||||
|
||||
// envelope.data 안에 있는 경우와 최상위에 있는 경우 모두 처리
|
||||
const modifiedData = { ...responseData, requires_pg_payment: false, redirect_url: buildNoOpRedirectUrl() };
|
||||
const modifiedBody = envelope?.data
|
||||
? { ...envelope, data: modifiedData }
|
||||
: modifiedData;
|
||||
|
||||
return new Response(JSON.stringify(modifiedBody), {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
};
|
||||
|
||||
logger.info('fetch order interceptor installed');
|
||||
|
||||
// Axios 인터셉터 설치 — GET checkout 캐싱 및 결제 중 캐시 서빙
|
||||
// G7Core.api.client(Axios)가 초기화된 후 설치해야 하므로 비동기로 재시도
|
||||
tryInstallAxiosCheckoutInterceptor(w, 15);
|
||||
logger.info('order interceptor is a no-op — payment entry is dispatched via pg_payment_handler');
|
||||
}
|
||||
|
||||
const AXIOS_CHECKOUT_FLAG = '__sirsoftNicepayAxiosCheckoutInterceptorInstalled';
|
||||
|
||||
function tryInstallAxiosCheckoutInterceptor(w: Record<string, unknown>, retries: number): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const axiosClient = ((window as any)?.G7Core?.api?.client) as any;
|
||||
if (!axiosClient?.interceptors) {
|
||||
if (retries > 0) {
|
||||
setTimeout(() => tryInstallAxiosCheckoutInterceptor(w, retries - 1), 300);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (w[AXIOS_CHECKOUT_FLAG]) return;
|
||||
w[AXIOS_CHECKOUT_FLAG] = true;
|
||||
|
||||
// 응답 인터셉터: GET checkout 성공 시 캐시 저장
|
||||
// Axios config.url은 baseURL을 포함하지 않으므로 (config.baseURL ?? '') + config.url 로 완성
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
axiosClient.interceptors.response.use((response: any) => {
|
||||
const method = (response?.config?.method ?? '').toLowerCase();
|
||||
const url = String((response?.config?.baseURL ?? '') + (response?.config?.url ?? ''));
|
||||
if (method === 'get' && isCheckoutEndpoint(url, 'GET') && response?.status === 200) {
|
||||
w[CHECKOUT_CACHE_KEY] = JSON.stringify(response.data);
|
||||
logger.info('checkout response cached (axios)');
|
||||
}
|
||||
return response;
|
||||
});
|
||||
|
||||
// 요청 인터셉터: 결제 진행 중 GET checkout을 캐시에서 반환
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
axiosClient.interceptors.request.use((config: any) => {
|
||||
const method = (config?.method ?? '').toLowerCase();
|
||||
const url = String((config?.baseURL ?? '') + (config?.url ?? ''));
|
||||
if (method === 'get' && isCheckoutEndpoint(url, 'GET') && w[PAYMENT_IN_PROGRESS_FLAG] && w[CHECKOUT_CACHE_KEY]) {
|
||||
logger.info('payment in progress — serving cached checkout response (axios)');
|
||||
// adapter를 Promise.resolve로 교체해 실제 요청 없이 캐시 반환
|
||||
config.adapter = () => Promise.resolve({
|
||||
data: JSON.parse(w[CHECKOUT_CACHE_KEY] as string),
|
||||
status: 200,
|
||||
statusText: 'OK (cached)',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
config,
|
||||
request: {},
|
||||
});
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
logger.info('axios checkout interceptor installed');
|
||||
|
||||
// 인터셉터 설치 직후 GET checkout 즉시 캐싱 — navigate-to-self 재초기화 대비
|
||||
//
|
||||
// 체크아웃 페이지에서만 수행한다. 플러그인 번들은 모든 페이지에 로드되므로 경로를
|
||||
// 가리지 않으면 홈/게시판/로그인 등 어디서나 이 요청이 나가고, 장바구니가 없는
|
||||
// 비회원은 매 페이지 422(cart_key_required)를 받는다. 캐시는 체크아웃 페이지의
|
||||
// navigate-to-self 재초기화 대비용이므로 다른 경로에서는 만들 이유가 없다.
|
||||
if (isCheckoutRoute() && !w[CHECKOUT_CACHE_KEY]) {
|
||||
void (async () => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const coreApi = ((window as any)?.G7Core?.api) as { get?: (url: string) => Promise<unknown> } | undefined;
|
||||
await coreApi?.get?.('/modules/sirsoft-ecommerce/checkout');
|
||||
// 응답은 Axios 응답 인터셉터가 자동으로 캐싱함
|
||||
} catch { /* 체크아웃 세션 없으면 실패 — 무시 */ }
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Axios 인터셉터 방식 — apiCall 핸들러가 window.fetch 를 사용하므로 동작하지 않음. installOrderResponseInterceptor 를 사용할 것. */
|
||||
/** @deprecated Axios 인터셉터 방식 — 사용하지 않는다. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function installAxiosOrderInterceptor(_axiosClient: any): void {
|
||||
// no-op
|
||||
|
||||
+2
-3
@@ -30,8 +30,7 @@ class AdminVbankNotificationController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OrderProcessingService $orderService
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 가상계좌 입금통보 이력 조회
|
||||
@@ -55,7 +54,7 @@ class AdminVbankNotificationController
|
||||
}
|
||||
|
||||
$payment = $order->payment;
|
||||
if (! $payment || $payment->payment_method?->value !== 'vbank') {
|
||||
if (! $payment || ! $payment->isVirtualAccount()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => ['notifications' => [], 'summary' => null],
|
||||
|
||||
+1
-2
@@ -12,7 +12,6 @@ use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
@@ -182,7 +181,7 @@ class AdminVbankRefundController extends AdminBaseController
|
||||
private function isRefundableVbankPayment(OrderPayment $payment, string $tid): bool
|
||||
{
|
||||
return $payment->pg_provider === 'nicepayments'
|
||||
&& $payment->payment_method === PaymentMethodEnum::VBANK
|
||||
&& $payment->isVirtualAccount()
|
||||
&& $payment->payment_status === PaymentStatusEnum::PAID
|
||||
&& $payment->paid_at !== null
|
||||
&& $payment->vbank_number !== null
|
||||
|
||||
+4
-4
@@ -8,7 +8,6 @@ use App\Contracts\Extension\CacheInterface;
|
||||
use App\Extension\HookManager;
|
||||
use App\Services\PluginSettingsService;
|
||||
use Carbon\Carbon;
|
||||
use InvalidArgumentException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -16,6 +15,7 @@ use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use InvalidArgumentException;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException;
|
||||
@@ -23,8 +23,8 @@ use Modules\Sirsoft\Ecommerce\Helpers\DeviceDetector;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
|
||||
use Plugins\Sirsoft\PayNicepayments\Concerns\PreventsReplayCallback;
|
||||
use Plugins\Sirsoft\PayNicepayments\Concerns\IssuesReceiptCookie;
|
||||
use Plugins\Sirsoft\PayNicepayments\Concerns\PreventsReplayCallback;
|
||||
use Plugins\Sirsoft\PayNicepayments\Concerns\RecordsPaymentWindowClosure;
|
||||
use Plugins\Sirsoft\PayNicepayments\Concerns\ResolvesEasyPayDisplay;
|
||||
use Plugins\Sirsoft\PayNicepayments\Concerns\SanitizesPgResponse;
|
||||
@@ -42,8 +42,8 @@ use Plugins\Sirsoft\PayNicepayments\Support\UrlHelper;
|
||||
*/
|
||||
class PaymentCallbackController
|
||||
{
|
||||
use PreventsReplayCallback;
|
||||
use IssuesReceiptCookie;
|
||||
use PreventsReplayCallback;
|
||||
use RecordsPaymentWindowClosure;
|
||||
use ResolvesEasyPayDisplay;
|
||||
use SanitizesPgResponse;
|
||||
@@ -875,7 +875,7 @@ class PaymentCallbackController
|
||||
string $tid,
|
||||
int $amount,
|
||||
): ?string {
|
||||
if (! in_array($payment->payment_method?->value, ['vbank', 'virtual_account'], true)) {
|
||||
if (! $payment->isVirtualAccount()) {
|
||||
return 'payment_method_not_vbank';
|
||||
}
|
||||
|
||||
|
||||
+16
-74
@@ -7,7 +7,10 @@ namespace Plugins\Sirsoft\PayNicepayments\Listeners;
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
|
||||
/**
|
||||
* 이커머스 결제수단 설정 화면에서 나이스페이먼츠 간편결제를 PG 선택 불필요 항목으로 표시한다.
|
||||
* 이커머스 결제수단 설정 화면에 나이스페이먼츠 테스트모드 경고를 주입한다.
|
||||
*
|
||||
* 간편결제의 PG 표시(PG 고정 배지)는 코어 레이아웃이 결제수단 카탈로그의 pg_locked /
|
||||
* needs_pg 를 읽어 직접 처리하므로 이 리스너가 관여하지 않는다(#475).
|
||||
*/
|
||||
class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterface
|
||||
{
|
||||
@@ -24,19 +27,6 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
|
||||
private const TEST_MODE_CONDITION = 'nicepay_test_mode_status.data?.is_test_mode === true';
|
||||
|
||||
private const CORE_NO_PG_METHODS = ['point', 'deposit', 'free', 'dbank'];
|
||||
|
||||
private const NICEPAY_NO_PG_METHODS = [
|
||||
'nicepay_naverpay',
|
||||
'nicepay_kakaopay',
|
||||
'nicepay_samsungpay',
|
||||
'nicepay_applepay',
|
||||
'nicepay_payco',
|
||||
'nicepay_skpay',
|
||||
'nicepay_ssgpay',
|
||||
'nicepay_lpay',
|
||||
];
|
||||
|
||||
/**
|
||||
* 이 리스너가 구독하는 훅 정의를 반환합니다.
|
||||
*
|
||||
@@ -46,7 +36,7 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
{
|
||||
return [
|
||||
'core.layout_extension.after_apply' => [
|
||||
'method' => 'markEasyPayMethodsAsPgNotRequired',
|
||||
'method' => 'adjustPaymentMethodsLayout',
|
||||
'type' => 'filter',
|
||||
'priority' => 40,
|
||||
],
|
||||
@@ -59,78 +49,30 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
|
||||
public function handle(...$args): void {}
|
||||
|
||||
/**
|
||||
* 이커머스 결제수단 설정 레이아웃에 나이스페이먼츠 간편결제를 PG 선택 불필요 항목으로 반영합니다.
|
||||
* 이커머스 결제수단 설정 레이아웃에 테스트모드 경고를 주입합니다.
|
||||
*
|
||||
* 과거에는 코어 레이아웃의 "PG 불필요 결제수단" 하드코딩 배열 표현식을 정규식으로 찾아
|
||||
* 간편결제를 그 목록에 덧붙여 PG 선택 셀렉트를 숨겼다. 그러나 간편결제는 "PG 불필요" 가
|
||||
* 아니라 "PG 고정" 이며, 이 표시 왜곡은 서버가 간편결제를 PG 없는 주문으로 오인하던
|
||||
* 결함과 짝을 이뤘다(#475). 정규식 치환은 플러그인이 여럿 설치되면 서로의 결과 위에
|
||||
* 누적 적용되어 충돌할 위험도 있었다.
|
||||
*
|
||||
* 이제 결제수단 카탈로그가 `pg_locked` / `needs_pg` 를 내려주고 코어 레이아웃이 그 값으로
|
||||
* 직접 3분기(PG 고정 배지 / PG 선택 / PG 불필요)하므로 치환이 불필요하다.
|
||||
*
|
||||
* @param array<string, mixed> $layout 대상 레이아웃 정의
|
||||
* @param int $templateId 레이아웃이 속한 템플릿 ID
|
||||
* @return array<string, mixed> 보정된 레이아웃 정의
|
||||
*/
|
||||
public function markEasyPayMethodsAsPgNotRequired(array $layout, int $templateId): array
|
||||
public function adjustPaymentMethodsLayout(array $layout, int $templateId): array
|
||||
{
|
||||
if (($layout['layout_name'] ?? '') !== self::TARGET_LAYOUT) {
|
||||
return $layout;
|
||||
}
|
||||
|
||||
$layout = $this->replaceNoPgMethodExpressions($layout);
|
||||
|
||||
return $this->ensureTestModeWarning($layout);
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 노드를 재귀 순회하며 PG 불필요 결제수단 표현식에 나이스페이먼츠 간편결제를 추가합니다.
|
||||
*
|
||||
* @param array<string, mixed> $node 순회 대상 노드
|
||||
* @return array<string, mixed> 치환이 반영된 노드
|
||||
*/
|
||||
private function replaceNoPgMethodExpressions(array $node): array
|
||||
{
|
||||
foreach ($node as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$node[$key] = $this->replaceNoPgMethodExpressions($value);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$node[$key] = $this->addNicepayMethodsToNoPgArray($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $node;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 PG 불필요 결제수단 배열 표현식을 찾아 나이스페이먼츠 간편결제 수단을 덧붙입니다.
|
||||
*
|
||||
* @param string $expression 대상 표현식
|
||||
* @return string 나이스페이먼츠 수단이 추가된 표현식 (대상이 아니면 원본 그대로)
|
||||
*/
|
||||
private function addNicepayMethodsToNoPgArray(string $expression): string
|
||||
{
|
||||
return preg_replace_callback(
|
||||
'/\[((?:\'[^\']+\'\s*,?\s*)+)\]\.includes\(\$method\.id\)/',
|
||||
function (array $matches): string {
|
||||
preg_match_all("/'([^']+)'/", $matches[1], $tokenMatches);
|
||||
$ids = $tokenMatches[1] ?? [];
|
||||
|
||||
foreach (self::CORE_NO_PG_METHODS as $coreId) {
|
||||
if (! in_array($coreId, $ids, true)) {
|
||||
return $matches[0];
|
||||
}
|
||||
}
|
||||
|
||||
foreach (self::NICEPAY_NO_PG_METHODS as $methodId) {
|
||||
if (! in_array($methodId, $ids, true)) {
|
||||
$ids[] = $methodId;
|
||||
}
|
||||
}
|
||||
|
||||
return "['".implode("','", $ids)."'].includes(\$method.id)";
|
||||
},
|
||||
$expression
|
||||
) ?? $expression;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트 모드 경고에 필요한 데이터 소스와 안내 노드를 레이아웃에 보장합니다.
|
||||
*
|
||||
|
||||
+17
-5
@@ -15,6 +15,11 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
{
|
||||
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nicepayments';
|
||||
|
||||
/**
|
||||
* 이 플러그인이 제공하는 PG 식별자 (RegisterPgProviderListener 의 provider id 와 일치).
|
||||
*/
|
||||
private const PG_PROVIDER_ID = 'nicepayments';
|
||||
|
||||
private const EASY_PAY_METHODS = [
|
||||
[
|
||||
'id' => 'nicepay_naverpay',
|
||||
@@ -81,12 +86,12 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed ...$args
|
||||
* @param mixed ...$args
|
||||
*/
|
||||
public function handle(...$args): void {}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $methods
|
||||
* @param array<int, array<string, mixed>> $methods
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function injectEasyPayMethods(array $methods): array
|
||||
@@ -120,7 +125,7 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $methods
|
||||
* @param array<int, array<string, mixed>> $methods
|
||||
*/
|
||||
private function resolveInsertionIndex(array $methods): ?int
|
||||
{
|
||||
@@ -165,12 +170,19 @@ class RegisterEasyPayMethodsListener implements HookListenerInterface
|
||||
'en' => __($descriptionKey, [], 'en'),
|
||||
],
|
||||
'icon' => $icon,
|
||||
'source' => 'plugin:' . self::PLUGIN_IDENTIFIER,
|
||||
'source' => 'plugin:'.self::PLUGIN_IDENTIFIER,
|
||||
'defaults' => [
|
||||
'pg_provider' => null,
|
||||
// 간편결제는 나이스페이먼츠 결제창을 통해서만 처리되므로 PG 를 자기 자신으로 고정한다.
|
||||
// null 로 두면 코어가 PG 없는 결제수단으로 오인해 결제 실패 주문에 관리자 알림이
|
||||
// 발송되고 임시주문이 삭제되어 재결제가 막힌다(#475).
|
||||
'pg_provider' => self::PG_PROVIDER_ID,
|
||||
'pg_locked' => true,
|
||||
'needs_pg' => true,
|
||||
'refund_method' => 'pg',
|
||||
'is_active' => false,
|
||||
'min_order_amount' => 0,
|
||||
'stock_deduction_timing' => 'payment_complete',
|
||||
'mileage_deduction_timing' => 'payment_complete',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
+5
-1
@@ -52,6 +52,10 @@ class RegisterPgProviderListener implements HookListenerInterface
|
||||
'name' => localized_label(nameKey: 'sirsoft-pay_nicepayments::provider.name'),
|
||||
'icon' => 'credit-card',
|
||||
'supported_methods' => ['card', 'bank_transfer', 'virtual_account', 'mobile'],
|
||||
// 코어의 provider-agnostic 결제 진입 dispatch — 주문 응답의 pg_payment_handler 로
|
||||
// 내려가 템플릿이 그대로 호출한다. 미선언 시 프론트가 응답을 변조해 결제창을
|
||||
// 직접 띄우는 우회가 필요해진다(#475).
|
||||
'payment_handler' => 'sirsoft-pay_nicepayments.requestPayment',
|
||||
];
|
||||
|
||||
return $providers;
|
||||
@@ -125,6 +129,6 @@ class RegisterPgProviderListener implements HookListenerInterface
|
||||
return '';
|
||||
}
|
||||
|
||||
return str_starts_with($suffix, 'SR') ? $suffix : 'SR' . $suffix;
|
||||
return str_starts_with($suffix, 'SR') ? $suffix : 'SR'.$suffix;
|
||||
}
|
||||
}
|
||||
+20
-28
@@ -18,14 +18,18 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
|
||||
$hooks['core.layout_extension.after_apply']['type'] ?? null
|
||||
);
|
||||
$this->assertSame(
|
||||
'markEasyPayMethodsAsPgNotRequired',
|
||||
'adjustPaymentMethodsLayout',
|
||||
$hooks['core.layout_extension.after_apply']['method'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public function test_marks_nicepay_easy_pay_methods_as_pg_not_required_in_admin_ecommerce_settings(): void
|
||||
public function test_does_not_string_substitute_pg_branch_expressions(): void
|
||||
{
|
||||
$listener = new AdjustEcommercePaymentMethodsLayoutListener();
|
||||
// 코어 레이아웃은 이제 pg_locked / needs_pg 로 직접 분기한다(#475).
|
||||
// 리스너가 표현식을 치환하면 코어의 분기 로직을 훼손하고, 플러그인이 여럿 설치되면
|
||||
// 서로의 치환 결과 위에 누적 적용되어 충돌한다 (과거 이 테스트의 fixture 에
|
||||
// kginicis / nhnkcp 의 ID 가 이미 섞여 있던 것이 그 증거다).
|
||||
$listener = new AdjustEcommercePaymentMethodsLayoutListener;
|
||||
|
||||
$layout = [
|
||||
'layout_name' => 'admin_ecommerce_settings',
|
||||
@@ -33,38 +37,26 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
|
||||
[
|
||||
'type' => 'basic',
|
||||
'name' => 'Select',
|
||||
'if' => "{{!['point','deposit','free','dbank'].includes(\$method.id) && (_local.form?.available_pg_providers ?? []).length > 0}}",
|
||||
],
|
||||
[
|
||||
'type' => 'composite',
|
||||
'name' => 'Toggle',
|
||||
'props' => [
|
||||
'disabled' => "{{_computed.isReadOnly || !['point','deposit','free','dbank','kginicis_naverpay','nhnkcp_payco'].includes(\$method.id) && !\$method.pg_provider && !_local.form?.order_settings?.default_pg_provider}}",
|
||||
],
|
||||
'if' => '{{!$method.pg_locked && $method.needs_pg && (_local.form?.available_pg_providers ?? []).length > 0}}',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = $listener->markEasyPayMethodsAsPgNotRequired($layout, 1);
|
||||
$result = $listener->adjustPaymentMethodsLayout($layout, 1);
|
||||
$json = json_encode($result, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$this->assertIsString($json);
|
||||
$this->assertStringContainsString('kginicis_naverpay', $json);
|
||||
$this->assertStringContainsString('nhnkcp_payco', $json);
|
||||
$this->assertStringContainsString('nicepay_naverpay', $json);
|
||||
$this->assertStringContainsString('nicepay_kakaopay', $json);
|
||||
$this->assertStringContainsString('nicepay_samsungpay', $json);
|
||||
$this->assertStringContainsString('nicepay_applepay', $json);
|
||||
$this->assertStringContainsString('nicepay_payco', $json);
|
||||
$this->assertStringContainsString('nicepay_skpay', $json);
|
||||
$this->assertStringContainsString('nicepay_ssgpay', $json);
|
||||
$this->assertStringContainsString('nicepay_lpay', $json);
|
||||
$this->assertStringNotContainsString("['point','deposit','free','dbank'].includes", $json);
|
||||
// 코어 분기 표현식이 그대로 보존되어야 한다.
|
||||
$this->assertStringContainsString('$method.pg_locked', $json);
|
||||
$this->assertStringContainsString('$method.needs_pg', $json);
|
||||
// 간편결제 ID 를 표현식에 끼워 넣지 않는다.
|
||||
$this->assertStringNotContainsString('nicepay_naverpay', $json);
|
||||
$this->assertStringNotContainsString('nicepay_kakaopay', $json);
|
||||
}
|
||||
|
||||
public function test_adds_test_mode_warning_to_order_settings_tab(): void
|
||||
{
|
||||
$listener = new AdjustEcommercePaymentMethodsLayoutListener();
|
||||
$listener = new AdjustEcommercePaymentMethodsLayoutListener;
|
||||
|
||||
$layout = [
|
||||
'layout_name' => 'admin_ecommerce_settings',
|
||||
@@ -79,8 +71,8 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
|
||||
],
|
||||
];
|
||||
|
||||
$result = $listener->markEasyPayMethodsAsPgNotRequired($layout, 1);
|
||||
$result = $listener->markEasyPayMethodsAsPgNotRequired($result, 1);
|
||||
$result = $listener->adjustPaymentMethodsLayout($layout, 1);
|
||||
$result = $listener->adjustPaymentMethodsLayout($result, 1);
|
||||
$json = json_encode($result, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$this->assertIsString($json);
|
||||
@@ -103,7 +95,7 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
|
||||
|
||||
public function test_leaves_other_layouts_unchanged(): void
|
||||
{
|
||||
$listener = new AdjustEcommercePaymentMethodsLayoutListener();
|
||||
$listener = new AdjustEcommercePaymentMethodsLayoutListener;
|
||||
|
||||
$layout = [
|
||||
'layout_name' => 'shop/checkout',
|
||||
@@ -114,6 +106,6 @@ class AdjustEcommercePaymentMethodsLayoutListenerTest extends TestCase
|
||||
],
|
||||
];
|
||||
|
||||
$this->assertSame($layout, $listener->markEasyPayMethodsAsPgNotRequired($layout, 1));
|
||||
$this->assertSame($layout, $listener->adjustPaymentMethodsLayout($layout, 1));
|
||||
}
|
||||
}
|
||||
+21
-9
@@ -23,7 +23,7 @@ class RegisterEasyPayMethodsListenerTest extends TestCase
|
||||
|
||||
public function test_injects_easy_pay_methods_after_existing_pg_easy_pay_methods(): void
|
||||
{
|
||||
$listener = new RegisterEasyPayMethodsListener();
|
||||
$listener = new RegisterEasyPayMethodsListener;
|
||||
|
||||
$methods = $listener->injectEasyPayMethods([
|
||||
['id' => 'card'],
|
||||
@@ -54,15 +54,20 @@ class RegisterEasyPayMethodsListenerTest extends TestCase
|
||||
|
||||
public function test_plugin_registers_easy_pay_hook_listeners(): void
|
||||
{
|
||||
$listeners = (new Plugin())->getHookListeners();
|
||||
$listeners = (new Plugin)->getHookListeners();
|
||||
|
||||
$this->assertContains(RegisterEasyPayMethodsListener::class, $listeners);
|
||||
$this->assertContains(AdjustEcommercePaymentMethodsLayoutListener::class, $listeners);
|
||||
}
|
||||
|
||||
public function test_easy_pay_methods_do_not_require_pg_provider_in_saved_defaults(): void
|
||||
public function test_easy_pay_methods_are_locked_to_own_pg_provider(): void
|
||||
{
|
||||
$listener = new RegisterEasyPayMethodsListener();
|
||||
// 간편결제는 나이스페이먼츠 결제창을 통해서만 처리되므로 PG 를 자기 자신으로 고정 선언한다.
|
||||
//
|
||||
// 과거에는 pg_provider 를 null 로 두었고(= "PG 없는 결제수단"), 그 결과 서버가
|
||||
// 간편결제 주문을 PG 결제가 아닌 주문으로 오인해 (a) 결제 실패했는데 관리자에게
|
||||
// 신규주문 알림이 발송되고 (b) 임시주문이 즉시 삭제되어 재결제가 불가능해졌다(#475).
|
||||
$listener = new RegisterEasyPayMethodsListener;
|
||||
|
||||
$methods = $listener->injectEasyPayMethods([
|
||||
['id' => 'phone'],
|
||||
@@ -78,15 +83,22 @@ class RegisterEasyPayMethodsListenerTest extends TestCase
|
||||
|
||||
foreach ($easyPayMethods as $method) {
|
||||
$this->assertArrayHasKey('defaults', $method);
|
||||
$this->assertNull($method['defaults']['pg_provider'] ?? null);
|
||||
|
||||
// PG 고정 — null 이면 코어가 PG 없는 주문으로 오인한다.
|
||||
$this->assertSame('nicepayments', $method['defaults']['pg_provider'] ?? null);
|
||||
$this->assertTrue($method['defaults']['pg_locked'] ?? false);
|
||||
$this->assertTrue($method['defaults']['needs_pg'] ?? false);
|
||||
$this->assertSame('pg', $method['defaults']['refund_method'] ?? null);
|
||||
|
||||
$this->assertFalse($method['defaults']['is_active'] ?? true);
|
||||
$this->assertSame('payment_complete', $method['defaults']['stock_deduction_timing'] ?? null);
|
||||
$this->assertSame('payment_complete', $method['defaults']['mileage_deduction_timing'] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_easy_pay_method_labels_match_admin_payment_method_names(): void
|
||||
{
|
||||
$listener = new RegisterEasyPayMethodsListener();
|
||||
$listener = new RegisterEasyPayMethodsListener;
|
||||
|
||||
$methods = $listener->injectEasyPayMethods([
|
||||
['id' => 'phone'],
|
||||
@@ -97,14 +109,14 @@ class RegisterEasyPayMethodsListenerTest extends TestCase
|
||||
$kakaopay = collect($methods)->firstWhere('id', 'nicepay_kakaopay');
|
||||
$payco = collect($methods)->firstWhere('id', 'nicepay_payco');
|
||||
|
||||
$this->assertSame('네이' . "\u{200B}" . '버페이 (나이스페이먼츠)', $naverpay['name']['ko'] ?? null);
|
||||
$this->assertSame('카카' . "\u{200B}" . '오페이로 결제 (나이스페이먼츠)', $kakaopay['description']['ko'] ?? null);
|
||||
$this->assertSame('네이'."\u{200B}".'버페이 (나이스페이먼츠)', $naverpay['name']['ko'] ?? null);
|
||||
$this->assertSame('카카'."\u{200B}".'오페이로 결제 (나이스페이먼츠)', $kakaopay['description']['ko'] ?? null);
|
||||
$this->assertSame('PAYCO (NicePayments)', $payco['name']['en'] ?? null);
|
||||
}
|
||||
|
||||
public function test_easy_pay_method_labels_avoid_other_pg_brand_matchers(): void
|
||||
{
|
||||
$listener = new RegisterEasyPayMethodsListener();
|
||||
$listener = new RegisterEasyPayMethodsListener;
|
||||
|
||||
$methods = $listener->injectEasyPayMethods([
|
||||
['id' => 'phone'],
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNicepayments\Tests\Unit\Upgrades;
|
||||
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Upgrades\Data\Ext\Plugins\SirsoftPayNicepayments\V1_0_0_beta_4\Migrations\BackfillEasyPayPgProvider;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
require_once dirname(__DIR__, 3).'/upgrades/data/1.0.0-beta.4/migrations/BackfillEasyPayPgProvider.php';
|
||||
|
||||
/**
|
||||
* 저장된 주문설정의 나이스페이먼츠 간편결제 PG 고정 백필 테스트 — 이슈 #475
|
||||
*
|
||||
* 기존 설치 환경의 order_settings.json 에는 간편결제가 `pg_provider: null` 로 영속화되어
|
||||
* 있다. 서버가 이 값을 보고 간편결제 주문을 "PG 결제가 아닌 주문" 으로 오인해 결제 실패
|
||||
* 시 관리자 알림 오발송 + 임시주문 삭제(재결제 불가) 를 일으켰다.
|
||||
*/
|
||||
class BackfillEasyPayPgProviderTest extends TestCase
|
||||
{
|
||||
private string $settingsPath;
|
||||
|
||||
private bool $hadOriginalSettings = false;
|
||||
|
||||
private ?string $originalSettings = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->settingsPath = storage_path('app/modules/sirsoft-ecommerce/settings/order_settings.json');
|
||||
$this->hadOriginalSettings = File::exists($this->settingsPath);
|
||||
$this->originalSettings = $this->hadOriginalSettings ? File::get($this->settingsPath) : null;
|
||||
|
||||
File::ensureDirectoryExists(dirname($this->settingsPath));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if ($this->hadOriginalSettings && $this->originalSettings !== null) {
|
||||
File::put($this->settingsPath, $this->originalSettings);
|
||||
} else {
|
||||
File::delete($this->settingsPath);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario method_kind=extension, capability_declared=declared, capability=pg_locked
|
||||
*
|
||||
* @effects upgrade_step_backfills_settings_file, saved_null_pg_provider_self_healed
|
||||
*/
|
||||
public function test_backfills_pg_declaration_for_nicepay_easy_pay_methods(): void
|
||||
{
|
||||
$this->writeSettings([
|
||||
['id' => 'card', 'pg_provider' => 'nicepayments'],
|
||||
['id' => 'nicepay_naverpay', 'pg_provider' => null, 'is_active' => true],
|
||||
['id' => 'nicepay_kakaopay', 'pg_provider' => null, 'is_active' => true],
|
||||
]);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$methods = $this->readMethods();
|
||||
|
||||
foreach (['nicepay_naverpay', 'nicepay_kakaopay'] as $id) {
|
||||
$method = $methods[$id];
|
||||
$this->assertSame('nicepayments', $method['pg_provider'], "{$id} 의 PG 가 고정되어야 한다");
|
||||
$this->assertTrue($method['pg_locked']);
|
||||
$this->assertTrue($method['needs_pg']);
|
||||
$this->assertSame('pg', $method['refund_method']);
|
||||
$this->assertTrue($method['is_active']);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_does_not_touch_other_plugins_or_builtin_methods(): void
|
||||
{
|
||||
$this->writeSettings([
|
||||
['id' => 'card', 'pg_provider' => 'nicepayments'],
|
||||
['id' => 'dbank', 'pg_provider' => ''],
|
||||
['id' => 'nhnkcp_naverpay', 'pg_provider' => null],
|
||||
['id' => 'nicepay_naverpay', 'pg_provider' => null],
|
||||
]);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$methods = $this->readMethods();
|
||||
|
||||
$this->assertSame('nicepayments', $methods['card']['pg_provider']);
|
||||
$this->assertArrayNotHasKey('pg_locked', $methods['card']);
|
||||
|
||||
$this->assertSame('', $methods['dbank']['pg_provider']);
|
||||
$this->assertArrayNotHasKey('pg_locked', $methods['dbank']);
|
||||
|
||||
$this->assertNull($methods['nhnkcp_naverpay']['pg_provider']);
|
||||
$this->assertArrayNotHasKey('pg_locked', $methods['nhnkcp_naverpay']);
|
||||
|
||||
$this->assertSame('nicepayments', $methods['nicepay_naverpay']['pg_provider']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario method_kind=extension, capability_declared=declared, capability=pg_locked
|
||||
*
|
||||
* @effects upgrade_step_backfills_settings_file
|
||||
*/
|
||||
public function test_is_idempotent(): void
|
||||
{
|
||||
$this->writeSettings([
|
||||
['id' => 'nicepay_naverpay', 'pg_provider' => null],
|
||||
]);
|
||||
|
||||
$this->runMigration();
|
||||
$first = File::get($this->settingsPath);
|
||||
|
||||
$this->runMigration();
|
||||
$second = File::get($this->settingsPath);
|
||||
|
||||
$this->assertSame($first, $second, '재실행해도 결과가 달라지지 않아야 한다');
|
||||
}
|
||||
|
||||
public function test_skips_when_settings_file_is_missing(): void
|
||||
{
|
||||
File::delete($this->settingsPath);
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertFalse(File::exists($this->settingsPath));
|
||||
}
|
||||
|
||||
public function test_skips_when_settings_json_is_malformed(): void
|
||||
{
|
||||
File::put($this->settingsPath, '{ not valid json');
|
||||
|
||||
$this->runMigration();
|
||||
|
||||
$this->assertSame('{ not valid json', File::get($this->settingsPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $paymentMethods
|
||||
*/
|
||||
private function writeSettings(array $paymentMethods): void
|
||||
{
|
||||
File::put($this->settingsPath, json_encode([
|
||||
'payment_methods' => $paymentMethods,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>> 결제수단 ID => 설정
|
||||
*/
|
||||
private function readMethods(): array
|
||||
{
|
||||
$settings = json_decode(File::get($this->settingsPath), true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
$keyed = [];
|
||||
foreach ($settings['payment_methods'] as $method) {
|
||||
$keyed[$method['id']] = $method;
|
||||
}
|
||||
|
||||
return $keyed;
|
||||
}
|
||||
|
||||
private function runMigration(): void
|
||||
{
|
||||
(new BackfillEasyPayPgProvider)->run(new UpgradeContext(
|
||||
fromVersion: '1.0.0-beta.3',
|
||||
toVersion: '1.0.0-beta.4',
|
||||
currentStep: '1.0.0-beta.4',
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\PayNicepayments\Upgrades;
|
||||
|
||||
use App\Extension\AbstractUpgradeStep;
|
||||
|
||||
/**
|
||||
* v1.0.0-beta.4 업그레이드 스텝
|
||||
*
|
||||
* 저장된 이커머스 주문설정의 나이스페이먼츠 간편결제 항목에 PG 고정 선언을 백필한다.
|
||||
*
|
||||
* 모든 비즈니스 로직은 data/1.0.0-beta.4/migrations/ 로 격리(AbstractUpgradeStep 규약).
|
||||
*
|
||||
* @upgrade-path B
|
||||
*/
|
||||
class Upgrade_1_0_0_beta_4 extends AbstractUpgradeStep {}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Plugins\SirsoftPayNicepayments\V1_0_0_beta_4\Migrations;
|
||||
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 저장된 이커머스 주문설정의 나이스페이먼츠 간편결제 항목에 PG 고정 선언을 백필한다.
|
||||
*
|
||||
* 배경 (이슈 #475):
|
||||
*
|
||||
* 간편결제 수단은 과거 `pg_provider: null` 로 등록되어 저장 파일에 그대로 영속화됐다.
|
||||
* 서버는 이 값을 보고 간편결제 주문을 "PG 결제가 아닌 주문" 으로 오인했고, 그 결과
|
||||
* (a) 결제가 실패했는데 관리자에게 신규주문 알림이 발송되고 (b) 임시주문이 즉시 삭제되어
|
||||
* 재결제가 불가능해졌다.
|
||||
*
|
||||
* 현재 코드는 결제수단 카탈로그를 병합할 때 능력 선언(pg_provider / pg_locked / needs_pg /
|
||||
* refund_method)을 플러그인 정의에서 가져오므로, 저장 파일에 남은 낡은 값이 있어도 런타임
|
||||
* 동작은 이미 정상이다(자가 치유). 본 마이그레이션은 저장 파일 자체를 현재 선언과 일치시켜
|
||||
* 설정 화면과 파일 내용이 어긋나 보이지 않게 한다.
|
||||
*
|
||||
* V-1 안전 격리 (docs/extension/upgrade-step-guide.md §13):
|
||||
* - 파일 시스템 + FilePermissionHelper 만 사용 (이전 버전에도 존재하던 표면)
|
||||
* - Service / Manager / Repository 컨테이너 해석 없음
|
||||
*/
|
||||
final class BackfillEasyPayPgProvider implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 이커머스 모듈의 주문설정 저장 경로.
|
||||
*/
|
||||
private const SETTINGS_PATH = 'app/modules/sirsoft-ecommerce/settings/order_settings.json';
|
||||
|
||||
/**
|
||||
* 이 플러그인이 등록하는 간편결제 수단의 ID 접두사.
|
||||
*/
|
||||
private const METHOD_PREFIX = 'nicepay_';
|
||||
|
||||
/**
|
||||
* 이 플러그인이 제공하는 PG 식별자.
|
||||
*/
|
||||
private const PG_PROVIDER_ID = 'nicepayments';
|
||||
|
||||
/**
|
||||
* 마이그레이션 식별자 (로그용).
|
||||
*
|
||||
* @return string 사람이 읽을 수 있는 짧은 식별자
|
||||
*/
|
||||
public function name(): string
|
||||
{
|
||||
return 'BackfillEasyPayPgProvider';
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 주문설정의 간편결제 항목에 PG 고정 선언을 백필한다. idempotent.
|
||||
*
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트 (로거 등)
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$path = storage_path(self::SETTINGS_PATH);
|
||||
|
||||
if (! File::exists($path)) {
|
||||
$context->logger->info('[nicepayments] 이커머스 주문설정 파일 없음 — 기본값으로 동작하므로 skip');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$settings = json_decode(File::get($path), true);
|
||||
if (! is_array($settings) || ! is_array($settings['payment_methods'] ?? null)) {
|
||||
$context->logger->warning('[nicepayments] 주문설정 JSON 형식 비정상 — 간편결제 PG 백필 skip');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$updated = 0;
|
||||
foreach ($settings['payment_methods'] as $index => $method) {
|
||||
if (! is_array($method)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = (string) ($method['id'] ?? '');
|
||||
if (! str_starts_with($id, self::METHOD_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$before = [
|
||||
$method['pg_provider'] ?? null,
|
||||
$method['pg_locked'] ?? null,
|
||||
$method['needs_pg'] ?? null,
|
||||
$method['refund_method'] ?? null,
|
||||
];
|
||||
|
||||
$method['pg_provider'] = self::PG_PROVIDER_ID;
|
||||
$method['pg_locked'] = true;
|
||||
$method['needs_pg'] = true;
|
||||
$method['refund_method'] = 'pg';
|
||||
|
||||
$after = [
|
||||
$method['pg_provider'],
|
||||
$method['pg_locked'],
|
||||
$method['needs_pg'],
|
||||
$method['refund_method'],
|
||||
];
|
||||
|
||||
if ($before !== $after) {
|
||||
$updated++;
|
||||
}
|
||||
|
||||
$settings['payment_methods'][$index] = $method;
|
||||
}
|
||||
|
||||
if ($updated === 0) {
|
||||
$context->logger->info('[nicepayments] 간편결제 PG 선언이 이미 최신 — 변경 없음');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
File::put($path, json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
|
||||
FilePermissionHelper::inheritOwnershipFromParent($path);
|
||||
|
||||
$context->logger->info('[nicepayments] 간편결제 PG 고정 선언 백필 완료', [
|
||||
'pg_provider' => self::PG_PROVIDER_ID,
|
||||
'updated_methods' => $updated,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user