feat(tosspayments,ecommerce,core): 토스 주문서형 결제수단·가상계좌·에스크로 + API 문서 web 라우트 확장
토스페이먼츠 플러그인을 프로덕션 수준으로 완성한다 (계획서 S4). 주문서형 결제(9종 동적등록) - 플러그인이 filter_available_payment_methods 로 결제수단을 주입하면 체크아웃에 독립 항목으로 노출된다. 서버 전송 시에는 core_payment_method 로 번역해 보낸다 — 코어 PaymentMethodEnum 은 toss_* 를 거부하므로 번역 없이는 주문 자체가 422 로 실패했다. 번역 근거인 core_payment_method 가 설정 병합/스냅샷의 화이트리스트에서 탈락하던 것을 provider-agnostic 하게 보존하도록 고쳤다 (토스 전용 분기 없음). 가상계좌·웹훅·에스크로 - 입금통보/결제상태 웹훅 2종 신설. secret 대조(hash_equals)·리플레이 멱등·CSRF 면제. - 에스크로 3상태(off/on/buyer_choice). escrowProducts 를 가상계좌·계좌이체 양쪽 SDK 페이로드에 싣는다 — 서버가 조립해도 SDK 로 전달되지 않으면 토스가 결제를 거부한다. E2E 가 SDK 경계를 직접 캡처해 이 계약의 나머지 절반을 잠근다. - 관리자 설정 저장 시 입금기한·에스크로 값 범위를 서버에서 검증한다 (UI max 는 클라 힌트라 API 직접 호출을 막지 못한다). 레이아웃 리스너 실행 순서 - KG 는 no-PG 리스트를 통짜 리터럴로 str_replace 하므로 토스가 먼저 append 하면 KG 의 매치가 깨진다. HookManager 는 ksort 오름차순이므로 토스 priority 를 30 으로 두어 "KG(20) 먼저" 를 플러그인 로드 순서와 무관한 불변식으로 고정했다. 코어 — API 문서 생성기가 확장 web 라우트도 수집 - PG 콜백·웹훅은 CSRF/세션 특성상 web.php 에 등록되지만 외부 시스템이 호출하는 machine-facing 엔드포인트라 API 레퍼런스 대상이다. api/ prefix 만 수집하던 탓에 영구 무문서였다. 확장 소유 web 라우트를 수집하되 관리자 화면(admin 컨텍스트)은 제외한다. 코어 라우트 수집 결과는 291건으로 불변. 하네스 — 확장 테스트 베이스클래스 룰 신설 - coverage.json 의 test-extension-base-class 가 status:todo(룰 미구현)라 규정이 있어도 검출되지 않았고, 실제로 이번 신설 테스트가 그 사각에 빠졌다. 룰을 구현해 봉인한다.
This commit is contained in:
1 parent
42eb21320b
commit
a9f85ff92b
63 files changed
+4951
-117
No files matched your search
@@ -10,9 +10,28 @@
|
||||
|
||||
- 레이아웃 편집기 데이터 소스 목록에서 이 확장이 제공하는 데이터 소스가 친화 명칭으로 표시되고, 어느 확장이 제공했는지 출처가 함께 표시됩니다.
|
||||
|
||||
#### 결제 방식
|
||||
|
||||
- 주문서형 결제 모드를 지원합니다. 켜면 체크아웃에서 카드·가상계좌·계좌이체·휴대폰·간편결제(토스페이·카카오페이·네이버페이·페이코·삼성페이) 중 원하는 결제수단을 직접 선택할 수 있고, 끄면 기존처럼 통합결제창 하나로 결제합니다.
|
||||
- 설정 화면에서 노출할 결제수단을 개별로 켜고 끌 수 있습니다.
|
||||
- 원화(KRW) 외 통화 주문에서는 카드 결제만 허용하고 국내 전용 수단은 자동으로 차단합니다.
|
||||
|
||||
#### 가상계좌
|
||||
|
||||
- 가상계좌 결제를 지원합니다. 입금 기한(시간)과 발급 시 자동 현금영수증 유형을 설정할 수 있습니다.
|
||||
- 입금이 완료되면 토스 입금통보를 받아 주문을 자동으로 결제완료 처리합니다. 위조 요청을 막기 위한 secret 검증을 제공하며, 설정 화면에서 등록할 웹훅 URL을 복사할 수 있습니다.
|
||||
- 가상계좌 입금기한과 에스크로 사용 설정을 저장할 때 허용 범위를 서버에서 검증합니다. 입금기한이 1~2160시간(최대 90일)을 벗어나면 저장되지 않고 안내 메시지가 표시됩니다 — 잘못된 값이 저장되어 결제창 호출이 실패하는 것을 막습니다.
|
||||
|
||||
#### 에스크로
|
||||
|
||||
- 가상계좌·계좌이체 결제에 에스크로(구매안전서비스)를 적용할 수 있습니다. 사용 안 함 / 강제 사용 / 구매자 선택 중에서 고를 수 있습니다.
|
||||
- 에스크로 결제 시 결제창에 상품 정보(상품명·단가·수량)를 함께 전달합니다. 가상계좌와 계좌이체 모두에 적용됩니다.
|
||||
- 에스크로를 켜면 설정 화면에 운영 안내가 표시됩니다 — 에스크로 주문은 부분취소가 불가하다는 점과, 배송정보는 토스 상점관리자에서 등록해야 한다는 점을 안내하며 상점관리자로 바로 이동할 수 있습니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 플러그인 환경설정 화면의 하단 저장 버튼이 스크롤 중에도 화면에 고정되도록 개선.
|
||||
- sirsoft-ecommerce 최소 버전을 1.1.0 으로 상향 (현금영수증 프로바이더 훅 축 · 결제수단 확장점 의존).
|
||||
|
||||
## [1.0.0-beta.3] - 2026-05-11
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"identifier": "sirsoft-tosspayments",
|
||||
"version": "1.0.0-beta.4",
|
||||
"components": {
|
||||
"basic": [],
|
||||
"composite": [],
|
||||
"layout": []
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,21 @@
|
||||
"live_client_key": "",
|
||||
"live_secret_key": "",
|
||||
"redirect_success_url": "/shop/orders/{orderId}/complete",
|
||||
"redirect_fail_url": "/shop/checkout"
|
||||
"redirect_fail_url": "/shop/checkout",
|
||||
"order_sheet_mode": false,
|
||||
"method_card": true,
|
||||
"method_virtual_account": false,
|
||||
"method_transfer": false,
|
||||
"method_mobile_phone": false,
|
||||
"method_tosspay": false,
|
||||
"method_kakaopay": false,
|
||||
"method_naverpay": false,
|
||||
"method_payco": false,
|
||||
"method_samsungpay": false,
|
||||
"vbank_valid_hours": 24,
|
||||
"vbank_cash_receipt_type": "",
|
||||
"use_escrow": "off",
|
||||
"webhook_secret_verify": true
|
||||
},
|
||||
"frontend_schema": {
|
||||
"is_test_mode": { "expose": false },
|
||||
@@ -19,6 +33,20 @@
|
||||
"live_client_key": { "expose": false },
|
||||
"live_secret_key": { "expose": false },
|
||||
"redirect_success_url": { "expose": false },
|
||||
"redirect_fail_url": { "expose": false }
|
||||
"redirect_fail_url": { "expose": false },
|
||||
"order_sheet_mode": { "expose": false },
|
||||
"method_card": { "expose": false },
|
||||
"method_virtual_account": { "expose": false },
|
||||
"method_transfer": { "expose": false },
|
||||
"method_mobile_phone": { "expose": false },
|
||||
"method_tosspay": { "expose": false },
|
||||
"method_kakaopay": { "expose": false },
|
||||
"method_naverpay": { "expose": false },
|
||||
"method_payco": { "expose": false },
|
||||
"method_samsungpay": { "expose": false },
|
||||
"vbank_valid_hours": { "expose": false },
|
||||
"vbank_cash_receipt_type": { "expose": false },
|
||||
"use_escrow": { "expose": false },
|
||||
"webhook_secret_verify": { "expose": false }
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
(function(){"use strict";function f(t){return new Promise((n,e)=>{if(document.querySelector(`script[src="${t}"]`)){n();return}const o=document.createElement("script");o.src=t,o.async=!0,o.onload=()=>n(),o.onerror=()=>e(new Error(`Failed to load script: ${t}`)),document.head.appendChild(o)})}async function y(t,n){const{pgPaymentData:e}=t.params||{};if(!e){console.error("[sirsoft-tosspayments] pgPaymentData is required");return}const o=window.G7Core;try{const r=await o.api.get("/modules/sirsoft-ecommerce/payments/client-config/tosspayments");if(!r.data){console.error("[sirsoft-tosspayments] Failed to fetch client config",r);return}const s=r.data;if(window.TossPayments||await f(s.sdk_url),window.TossPayments||await new Promise(w=>setTimeout(w,100)),!window.TossPayments){console.error("[sirsoft-tosspayments] TossPayments SDK not available");return}const p=window.TossPayments(s.client_key).payment({customerKey:e.customer_key??window.TossPayments.ANONYMOUS}),u=window.location.origin;await p.requestPayment({method:"CARD",amount:{currency:e.currency??"KRW",value:e.amount},orderId:e.order_number,orderName:e.order_name,successUrl:u+s.callback_urls.success,failUrl:u+s.callback_urls.fail,customerEmail:e.customer_email??void 0,customerName:e.customer_name??void 0,customerMobilePhone:e.customer_phone??void 0,card:{useEscrow:!1,flowMode:"DEFAULT",useCardPoint:!1,useAppCardOnly:!1}})}catch(r){if(console.error("[sirsoft-tosspayments] requestPayment error",r),r?.code==="USER_CANCEL"){console.info("[sirsoft-tosspayments] Payment cancelled by user");try{await o.api.post(`/modules/sirsoft-ecommerce/orders/${e.order_number}/cancel-payment`,{cancel_code:r.code,cancel_message:r.message})}catch(c){console.warn("[sirsoft-tosspayments] Failed to record cancellation",c)}o?.state?.setLocal?.({isSubmittingOrder:!1}),o?.modal?.open?.("tosspayments_payment_cancel_modal");return}const s=r?.message??"Unknown error";o?.state?.setLocal?.({paymentErrorMessage:s,isSubmittingOrder:!1}),o?.modal?.open?.("tosspayments_payment_error_modal")}}const l={requestPayment:y},a="sirsoft-tosspayments",i={info:(...t)=>console.info(`[${a}]`,...t),warn:(...t)=>console.warn(`[${a}]`,...t),error:(...t)=>console.error(`[${a}]`,...t)};function m(){const t=window.G7Core;if(!t)return 0;const n=t.getActionDispatcher;if(typeof n!="function")return 0;const e=n();if(!e||typeof e.registerHandler!="function")return 0;let o=0;for(const[r,s]of Object.entries(l)){const c=`${a}.${r}`;e.registerHandler(c,s,{category:"plugin",source:a}),o++}return o}function d(){const t=()=>{const n=m();if(n>0){i.info(`${n} handler(s) registered`);return}let e=0;const o=50,r=setInterval(()=>{e++;const s=m();if(s>0){clearInterval(r),i.info(`${s} handler(s) registered (after ${e} retries)`);return}e>=o&&(clearInterval(r),i.warn("ActionDispatcher not available after timeout"))},100)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",t):t()}d(),window.__SirsoftTosspayments={identifier:a,handlers:Object.keys(l),initPlugin:d}})();
|
||||
(function(){"use strict";function b(t){return new Promise((r,o)=>{if(document.querySelector(`script[src="${t}"]`)){r();return}const e=document.createElement("script");e.src=t,e.async=!0,e.onload=()=>r(),e.onerror=()=>o(new Error(`Failed to load script: ${t}`)),document.head.appendChild(e)})}function C(t,r){if(!t.order_sheet_mode||!r)return{method:"CARD",easyPay:null};const o=(t.enabled_methods??[]).find(e=>e.id===r);return o?{method:o.method,easyPay:o.easy_pay_provider}:{method:"CARD",easyPay:null}}function p(t){if(t==="on")return!0;if(t==="off")return!1}function E(t,r){const o={validHours:t.vbank?.valid_hours??24},e=t.vbank?.cash_receipt_type??"";e&&(o.cashReceipt={type:e});const s=p(t.use_escrow);return s!==void 0&&(o.useEscrow=s),o}function w(t,r,o){if(r.use_escrow==="off")return;const e=o.escrow_products??[];e.length>0&&(t.escrowProducts=e)}async function A(t,r){const o=t.params||{},{pgPaymentData:e}=o;if(!e){console.error("[sirsoft-tosspayments] pgPaymentData is required");return}const s=window.G7Core,l=o.paymentMethod??s?.state?.getLocal?.()?.paymentMethod;try{const n=await s.api.get("/modules/sirsoft-ecommerce/payments/client-config/tosspayments");if(!n.data){console.error("[sirsoft-tosspayments] Failed to fetch client config",n);return}const a=n.data;if(window.TossPayments||await b(a.sdk_url),window.TossPayments||await new Promise(u=>setTimeout(u,100)),!window.TossPayments){console.error("[sirsoft-tosspayments] TossPayments SDK not available");return}const R=window.TossPayments(a.client_key).payment({customerKey:e.customer_key??window.TossPayments.ANONYMOUS}),f=e.currency??"KRW",{method:d,easyPay:y}=C(a,l);if(f!=="KRW"&&(d!=="CARD"||y!==null)){console.warn("[sirsoft-tosspayments] non-KRW currency supports card only",{currency:f,method:d}),s?.state?.setLocal?.({paymentErrorMessage:s?.t?.("sirsoft-tosspayments.errors.non_krw_method")??"This payment method is available for KRW only.",isSubmittingOrder:!1}),s?.modal?.open?.("tosspayments_payment_error_modal");return}const v=window.location.origin,c={method:d,amount:{currency:f,value:e.amount},orderId:e.order_number,orderName:e.order_name,successUrl:v+a.callback_urls.success,failUrl:v+a.callback_urls.fail,customerEmail:e.customer_email??void 0,customerName:e.customer_name??void 0,customerMobilePhone:e.customer_phone??void 0};if(d==="CARD")c.card={flowMode:"DEFAULT",useCardPoint:!1,useAppCardOnly:!1},y&&(c.easyPay={provider:y});else if(d==="VIRTUAL_ACCOUNT")c.virtualAccount=E(a,e),w(c,a,e);else if(d==="TRANSFER"){const u=p(a.use_escrow);u!==void 0&&(c.transfer={useEscrow:u}),w(c,a,e)}await R.requestPayment(c)}catch(n){if(console.error("[sirsoft-tosspayments] requestPayment error",n),n?.code==="USER_CANCEL"){console.info("[sirsoft-tosspayments] Payment cancelled by user");try{await s.api.post(`/modules/sirsoft-ecommerce/orders/${e.order_number}/cancel-payment`,{cancel_code:n.code,cancel_message:n.message})}catch(P){console.warn("[sirsoft-tosspayments] Failed to record cancellation",P)}s?.state?.setLocal?.({isSubmittingOrder:!1}),s?.modal?.open?.("tosspayments_payment_cancel_modal");return}const a=n?.message??"Unknown error";s?.state?.setLocal?.({paymentErrorMessage:a,isSubmittingOrder:!1}),s?.modal?.open?.("tosspayments_payment_error_modal")}}const _={requestPayment:A},i="sirsoft-tosspayments",m={info:(...t)=>console.info(`[${i}]`,...t),warn:(...t)=>console.warn(`[${i}]`,...t),error:(...t)=>console.error(`[${i}]`,...t)};function h(){const t=window.G7Core;if(!t)return 0;const r=t.getActionDispatcher;if(typeof r!="function")return 0;const o=r();if(!o||typeof o.registerHandler!="function")return 0;let e=0;for(const[s,l]of Object.entries(_)){const n=`${i}.${s}`;o.registerHandler(n,l,{category:"plugin",source:i}),e++}return e}function g(){const t=()=>{const r=h();if(r>0){m.info(`${r} handler(s) registered`);return}let o=0;const e=50,s=setInterval(()=>{o++;const l=h();if(l>0){clearInterval(s),m.info(`${l} handler(s) registered (after ${o} retries)`);return}o>=e&&(clearInterval(s),m.warn("ActionDispatcher not available after timeout"))},100)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",t):t()}g(),window.__SirsoftTosspayments={identifier:i,handlers:Object.keys(_),initPlugin:g}})();
|
||||
//# sourceMappingURL=plugin.iife.js.map
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/handlers/requestPayment.ts","../../resources/js/handlers/index.ts","../../resources/js/index.ts"],"sourcesContent":["/**\n * 토스페이먼츠 결제창 호출 핸들러\n *\n * 체크아웃 레이아웃에서 주문 생성 API 성공 후 호출됩니다:\n * handler: \"sirsoft-tosspayments.requestPayment\"\n * params: { pgPaymentData: response.data.pg_payment_data }\n *\n * 호출 순서:\n * 1. Client Config API 호출 → clientKey 획득\n * 2. TossPayments SDK 동적 로드 (미로드 시)\n * 3. SDK 초기화 + payment 인스턴스 생성\n * 4. payment.requestPayment() 호출 → 통합결제창 오픈\n * 5. 결제 완료 시 브라우저가 successUrl/failUrl로 리다이렉트\n */\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\ninterface PgPaymentData {\n order_number: string;\n order_name: string;\n amount: number;\n currency?: string;\n customer_name?: string;\n customer_email?: string;\n customer_phone?: string;\n customer_key?: string | null;\n}\n\ninterface RequestPaymentParams {\n pgPaymentData: PgPaymentData;\n}\n\ninterface ClientConfig {\n client_key: string;\n sdk_url: string;\n callback_urls: {\n success: string;\n fail: string;\n };\n}\n\ndeclare global {\n interface Window {\n TossPayments: any;\n }\n}\n\n/**\n * 스크립트를 동적으로 로드합니다.\n *\n * @param src 스크립트 URL\n * @returns Promise\n */\nfunction loadScript(src: string): Promise<void> {\n return new Promise((resolve, reject) => {\n // 이미 로드된 스크립트인지 확인\n if (document.querySelector(`script[src=\"${src}\"]`)) {\n resolve();\n return;\n }\n\n const script = document.createElement('script');\n script.src = src;\n script.async = true;\n script.onload = () => resolve();\n script.onerror = () => reject(new Error(`Failed to load script: ${src}`));\n document.head.appendChild(script);\n });\n}\n\n/**\n * 토스페이먼츠 결제창 호출 핸들러\n *\n * ActionDispatcher는 커스텀 핸들러를 (action, context) 시그니처로 호출합니다.\n * params는 action.params에서 접근해야 합니다.\n *\n * @param action 액션 정의 (handler, params 등)\n * @param _context 액션 컨텍스트\n */\nexport async function requestPaymentHandler(action: any, _context?: any): Promise<void> {\n const { pgPaymentData } = (action.params || {}) as RequestPaymentParams;\n\n if (!pgPaymentData) {\n console.error('[sirsoft-tosspayments] pgPaymentData is required');\n return;\n }\n\n const G7Core = (window as any).G7Core;\n\n try {\n // 1. Client Config API 호출\n const configJson = await G7Core.api.get('/modules/sirsoft-ecommerce/payments/client-config/tosspayments');\n\n if (!configJson.data) {\n console.error('[sirsoft-tosspayments] Failed to fetch client config', configJson);\n return;\n }\n\n const config: ClientConfig = configJson.data;\n\n // 2. SDK 동적 로드 (미로드 시)\n if (!window.TossPayments) {\n await loadScript(config.sdk_url);\n }\n\n // SDK 로드 대기 (스크립트 로드 후 TossPayments 객체 초기화까지 약간의 시간 소요)\n if (!window.TossPayments) {\n await new Promise<void>((resolve) => setTimeout(resolve, 100));\n }\n\n if (!window.TossPayments) {\n console.error('[sirsoft-tosspayments] TossPayments SDK not available');\n return;\n }\n\n // 3. SDK 초기화\n const tossPayments = window.TossPayments(config.client_key);\n const payment = tossPayments.payment({\n customerKey: pgPaymentData.customer_key ?? window.TossPayments.ANONYMOUS,\n });\n\n // 4. 결제 요청 (통합결제창)\n const origin = window.location.origin;\n\n await payment.requestPayment({\n method: 'CARD',\n amount: {\n currency: pgPaymentData.currency ?? 'KRW',\n value: pgPaymentData.amount,\n },\n orderId: pgPaymentData.order_number,\n orderName: pgPaymentData.order_name,\n successUrl: origin + config.callback_urls.success,\n failUrl: origin + config.callback_urls.fail,\n customerEmail: pgPaymentData.customer_email ?? undefined,\n customerName: pgPaymentData.customer_name ?? undefined,\n customerMobilePhone: pgPaymentData.customer_phone ?? undefined,\n card: {\n useEscrow: false,\n flowMode: 'DEFAULT',\n useCardPoint: false,\n useAppCardOnly: false,\n },\n });\n // → 브라우저가 successUrl 또는 failUrl로 리다이렉트됨\n\n } catch (erLine truncated
|
||||
{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/handlers/requestPayment.ts","../../resources/js/handlers/index.ts","../../resources/js/index.ts"],"sourcesContent":["/**\n * 토스페이먼츠 결제창 호출 핸들러\n *\n * 체크아웃 레이아웃에서 주문 생성 API 성공 후 호출됩니다:\n * handler: \"sirsoft-tosspayments.requestPayment\"\n * params: { pgPaymentData: response.data.pg_payment_data }\n *\n * 호출 순서:\n * 1. Client Config API 호출 → clientKey 획득\n * 2. TossPayments SDK 동적 로드 (미로드 시)\n * 3. SDK 초기화 + payment 인스턴스 생성\n * 4. payment.requestPayment() 호출 → 통합결제창 오픈\n * 5. 결제 완료 시 브라우저가 successUrl/failUrl로 리다이렉트\n */\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\ninterface EscrowProduct {\n id: string;\n name: string;\n code: string;\n unitPrice: number;\n quantity: number;\n}\n\ninterface PgPaymentData {\n order_number: string;\n order_name: string;\n amount: number;\n currency?: string;\n customer_name?: string;\n customer_email?: string;\n customer_phone?: string;\n customer_key?: string | null;\n escrow_products?: EscrowProduct[];\n}\n\ninterface RequestPaymentParams {\n pgPaymentData: PgPaymentData;\n // 주문서형에서 사용자가 선택한 결제수단 id (toss_*). 미지정 시 _local.paymentMethod 참조.\n paymentMethod?: string;\n}\n\ninterface EnabledMethod {\n id: string;\n method: string;\n easy_pay_provider: string | null;\n core_payment_method: string;\n}\n\ninterface ClientConfig {\n client_key: string;\n sdk_url: string;\n order_sheet_mode?: boolean;\n enabled_methods?: EnabledMethod[];\n vbank?: {\n valid_hours: number;\n cash_receipt_type: string;\n };\n use_escrow?: string;\n callback_urls: {\n success: string;\n fail: string;\n };\n}\n\ndeclare global {\n interface Window {\n TossPayments: any;\n }\n}\n\n/**\n * 스크립트를 동적으로 로드합니다.\n *\n * @param src 스크립트 URL\n * @returns Promise\n */\nfunction loadScript(src: string): Promise<void> {\n return new Promise((resolve, reject) => {\n // 이미 로드된 스크립트인지 확인\n if (document.querySelector(`script[src=\"${src}\"]`)) {\n resolve();\n return;\n }\n\n const script = document.createElement('script');\n script.src = src;\n script.async = true;\n script.onload = () => resolve();\n script.onerror = () => reject(new Error(`Failed to load script: ${src}`));\n document.head.appendChild(script);\n });\n}\n\n/**\n * 토스페이먼츠 결제창 호출 핸들러\n *\n * ActionDispatcher는 커스텀 핸들러를 (action, context) 시그니처로 호출합니다.\n * params는 action.params에서 접근해야 합니다.\n *\n * @param action 액션 정의 (handler, params 등)\n * @param _context 액션 컨텍스트\n */\n/**\n * 선택된 결제수단 id 를 SDK 파라미터로 변환합니다.\n *\n * 서버가 내려준 enabled_methods 에서 선택 id 를 찾아 SDK method / easyPay provider 를\n * 결정한다. 프론트는 결제수단 매핑을 하드코딩하지 않는다 (서버가 SSoT).\n * order_sheet_mode 가 off 이거나 선택 id 를 못 찾으면 통합결제창 카드(CARD)로 처리한다.\n *\n * @param config 클라이언트 설정 (enabled_methods 포함)\n * @param selectedId 선택된 toss_* 결제수단 id\n * @returns SDK method 와 easyPay provider\n */\nfunction resolveMethod(config: ClientConfig, selectedId?: string): { method: string; easyPay: string | null } {\n if (!config.order_sheet_mode || !selectedId) {\n return { method: 'CARD', easyPay: null };\n }\n\n const entry = (config.enabled_methods ?? []).find((m) => m.id === selectedId);\n if (!entry) {\n return { method: 'CARD', easyPay: null };\n }\n\n return { method: entry.method, easyPay: entry.easy_pay_provider };\n}\n\n/**\n * use_escrow 설정 문자열을 SDK useEscrow boolean/undefined 로 변환합니다.\n *\n * off → false, on → true, buyer_choice → undefined(키 생략 → 결제창에서 구매자 선택).\n *\n * @param useEscrow 설정값 (off | on | buyer_choice)\n * @returns SDK useEscrow 값 (undefined 면 키를 넣지 않음)\n */\nfunction resolveEscrowFlag(useEscrow?: string): boolean | undefined {\n if (useEscrow === 'on') return true;\n if (useEscrow === 'off') return false;\n // buyer_choice (또는 미설정) → 키 자체를 생략\n return undefined;\n}\n\n/**\n * 가상계좌 SDK 페이로드를 구성합니다.\n *\n * @param config 클라이언트 설정 (vbank / use_escrow 포함)\n * @param pgPaymentData PG 결제 데이터 (escrow_products 포함)\n * @returns virtualAccount 페이로드\n */\nfunction buildVirtualAccountPayload(config: ClientLine truncated
|
||||
@@ -0,0 +1,15 @@
|
||||
# API 레퍼런스 문서 목차
|
||||
|
||||
> **소유**: 플러그인 `sirsoft-tosspayments` · **생성**: `php artisan api:docgen` (실측 기반).
|
||||
> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다.
|
||||
|
||||
<!-- @generated:start:api-readme-index -->
|
||||
- **문서 수**: 2 · **엔드포인트 수**: 4
|
||||
|
||||
| 문서 | 도메인 | 엔드포인트 |
|
||||
| --- | --- | --- |
|
||||
| [payment.md](payment.md) | `payment` | 2 |
|
||||
| [webhook.md](webhook.md) | `webhook` | 2 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# Payment API 레퍼런스
|
||||
|
||||
> **소유**: plugin `sirsoft-tosspayments` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Payment 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
|
||||
3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### GET /plugins/sirsoft-tosspayments/payment/fail
|
||||
<!-- @generated:start:web.plugins.sirsoft-tosspayments.payment.fail -->
|
||||
- **라우트명**: `web.plugins.sirsoft-tosspayments.payment.fail`
|
||||
- **컨트롤러**: `Plugins\Sirsoft\Tosspayments\Controllers\PaymentCallbackController@fail`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| code | query | string | 아니오 | — | 토스 실패 코드 (예: `PAY_PROCESS_CANCELED`, `REJECT_CARD_COMPANY`). |
|
||||
| message | query | string | 아니오 | — | 토스가 내려준 실패 사유 메시지. |
|
||||
| orderId | query | string | 아니오 | — | 주문번호. 실패 페이지로 함께 전달해 어떤 주문의 결제가 실패했는지 표시한다. |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /plugins/sirsoft-tosspayments/payment/fail?code=%EC%98%88%EC%8B%9C%EA%B0%92&message=%EC%98%88%EC%8B%9C%EA%B0%92&orderId=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
이 엔드포인트는 JSON 을 반환하지 않는다. 브라우저를 실패 페이지로 **302 리다이렉트**한다.
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 302 Found
|
||||
Location: /shop/checkout?error=PAY_PROCESS_CANCELED&orderId=20260711-000001
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
토스 결제창에서 결제가 실패하거나 사용자가 결제를 취소하면 브라우저가 이 URL 로 리다이렉트된다.
|
||||
|
||||
JSON 을 반환하지 않는다. 실패 사유를 로그로 기록한 뒤, 실패 페이지(기본 `/shop/checkout`, 설정 `redirect_fail_url`)로 실패 코드·메시지·주문번호를 query 로 실어 **302 리다이렉트**한다. 모든 파라미터가 선택값이라 없어도 4xx 를 반환하지 않는다.
|
||||
|
||||
```http
|
||||
HTTP/1.1 302 Found
|
||||
Location: /shop/checkout?error=PAY_PROCESS_CANCELED&orderId=20260711-000001
|
||||
```
|
||||
|
||||
주의사항:
|
||||
|
||||
- 이 엔드포인트는 **주문 상태를 변경하지 않는다**. 결제 취소 이력 기록은 프론트엔드가 별도 API(`/modules/sirsoft-ecommerce/orders/{orderNumber}/cancel-payment`)로 수행한다.
|
||||
- 결제창을 띄우기 전 단계에서 사용자가 취소한 경우(SDK `USER_CANCEL`)는 이 콜백을 타지 않고 프론트엔드에서 직접 처리된다.
|
||||
|
||||
|
||||
### GET /plugins/sirsoft-tosspayments/payment/success
|
||||
<!-- @generated:start:web.plugins.sirsoft-tosspayments.payment.success -->
|
||||
- **라우트명**: `web.plugins.sirsoft-tosspayments.payment.success`
|
||||
- **컨트롤러**: `Plugins\Sirsoft\Tosspayments\Controllers\PaymentCallbackController@success`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| paymentKey | query | string | 예 | — | 토스가 발급한 결제 키. Confirm API 호출에 사용한다. |
|
||||
| orderId | query | string | 예 | — | 주문번호 (G7 `orders.order_number`). SDK 호출 시 넘긴 값이 그대로 돌아온다. |
|
||||
| amount | query | integer | 예 | min 1 | 결제 금액. 주문의 결제요청 금액과 대조하며, 불일치 시 결제를 승인하지 않는다. |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /plugins/sirsoft-tosspayments/payment/success?paymentKey=%EC%98%88%EC%8B%9C%EA%B0%92&orderId=%EC%98%88%EC%8B%9C%EA%B0%92&amount=1 HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
이 엔드포인트는 JSON 을 반환하지 않는다. 브라우저를 SPA 페이지로 **302 리다이렉트**한다.
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 302 Found
|
||||
Location: /shop/orders/20260711-000001/complete
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
토스 결제창에서 결제가 성공하면 브라우저가 이 URL 로 리다이렉트된다. 결제 **승인(Confirm)** 을 수행하는 지점이다.
|
||||
|
||||
JSON 을 반환하지 않고 브라우저를 **302 리다이렉트**한다. 에러 상황에서도 4xx/5xx 를 반환하지 않고 실패 페이지(기본 `/shop/checkout`)로 리다이렉트하며 사유를 query 로 전달한다.
|
||||
|
||||
| 리다이렉트 대상 | `error` query | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 주문완료 페이지 | — | 결제 승인 성공 (기본 `/shop/orders/{orderId}/complete`) |
|
||||
| 실패 페이지 | `order_not_found` | `orderId` 로 주문을 찾지 못한 경우 |
|
||||
| 실패 페이지 | `amount_mismatch` | 콜백 `amount` 가 주문의 결제요청 금액과 다른 경우 (위변조 방어) |
|
||||
| 실패 페이지 | `confirm_failed` | 토스 Confirm API 호출이 실패한 경우 (`message` query 에 사유) |
|
||||
| 실패 페이지 | (검증 실패) | 필수 query 파라미터 누락/형식 오류 — 422 대신 실패 페이지로 리다이렉트한다 |
|
||||
|
||||
처리 순서:
|
||||
|
||||
1. `orderId` 로 주문을 조회한다.
|
||||
2. 토스 **Confirm API** 를 호출해 결제를 확정한다 (`sirsoft-tosspayments.payment.before_confirm` / `after_confirm` 훅 발화).
|
||||
3. 응답 `status` 로 분기한다:
|
||||
- **`WAITING_FOR_DEPOSIT`(가상계좌)** — 아직 입금 전이므로 결제완료 처리하지 않고 계좌 정보만 저장한다. 실제 결제완료는 입금통보 웹훅(`/webhook/deposit`)이 담당한다. 웹훅 secret 은 **이 Confirm 응답에만** 내려오므로 `payment_meta.toss_secret` 에 저장해 웹훅 대조에 사용한다.
|
||||
- **그 외(카드·계좌이체·휴대폰·간편결제)** — 즉시 결제완료(`completePayment`) 처리하고 카드 승인번호·영수증 URL 등을 기록한다.
|
||||
4. SPA 주문완료 페이지로 리다이렉트한다.
|
||||
|
||||
주의사항:
|
||||
|
||||
- **금액 대조가 위변조 방어의 핵심**이다. query 의 `amount` 를 그대로 신뢰하지 않고 주문의 결제요청 금액과 대조하며, 불일치 시 `PaymentAmountMismatchException` 으로 승인을 중단한다.
|
||||
- 리다이렉트 대상 URL 은 플러그인 설정(`redirect_success_url` / `redirect_fail_url`)으로 바꿀 수 있다 (기본 `/shop/orders/{orderId}/complete`, `/shop/checkout`).
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# Webhook API 레퍼런스
|
||||
|
||||
> **소유**: plugin `sirsoft-tosspayments` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Webhook 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
|
||||
3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### POST /plugins/sirsoft-tosspayments/webhook/deposit
|
||||
<!-- @generated:start:web.plugins.sirsoft-tosspayments.webhook.deposit -->
|
||||
- **라우트명**: `web.plugins.sirsoft-tosspayments.webhook.deposit`
|
||||
- **컨트롤러**: `Plugins\Sirsoft\Tosspayments\Controllers\WebhookController@deposit`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| orderId | body | string | 예 | max 100 | 주문번호 (G7 `orders.order_number`). 이 값으로 주문·결제 레코드를 조회한다. |
|
||||
| status | body | string | 예 | `DONE`, `CANCELED` | 입금 결과. `DONE`=입금완료 → 결제완료 처리, `CANCELED`=입금취소 → 결제실패 처리. |
|
||||
| secret | body | string | 아니오 | max 255 | 결제 승인 응답에서 발급받아 `payment_meta.toss_secret` 에 저장해 둔 값. 위조 방지 대조에 사용. |
|
||||
| transactionKey | body | string | 아니오 | max 255 | 토스 거래 키. 결제완료 처리 시 `transaction_id` 로 기록한다 (없으면 기존 값 유지). |
|
||||
| createdAt | body | string | 아니오 | max 64 | 토스가 이벤트를 생성한 시각. `payment_meta.deposit_confirmed_at` 에 기록한다. |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /plugins/sirsoft-tosspayments/webhook/deposit HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"orderId": "예시값",
|
||||
"status": "DONE",
|
||||
"secret": "예시값",
|
||||
"transactionKey": "예시값",
|
||||
"createdAt": "예시값"
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
이 엔드포인트는 코어 API 응답 봉투(`{success, data, message}`)를 쓰지 않는다. 토스 웹훅 규약에 맞춰 `text/plain` 본문만 반환한다.
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/plain
|
||||
|
||||
OK
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
가상계좌 입금통보(DEPOSIT_CALLBACK)를 수신한다. 구매자가 발급받은 가상계좌에 입금하면 토스 서버가 이 엔드포인트를 호출한다.
|
||||
|
||||
이 엔드포인트는 코어 API 응답 봉투(`{success, data, message}`)를 쓰지 않는다. 토스 웹훅 규약에 맞춰 `text/plain` 본문만 반환한다.
|
||||
|
||||
| 상태코드 | 본문 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 200 | `OK` | 입금 반영 성공 / 이미 결제완료된 거래(리플레이) / 입금대기 상태가 아닌 주문 — 토스의 재전송을 멈추기 위해 200 을 반환한다 |
|
||||
| 401 | `UNAUTHORIZED` | `webhook_secret_verify` 가 켜진 상태에서 본문 `secret` 이 저장된 `payment_meta.toss_secret` 과 다른 경우 |
|
||||
| 200 | `FAIL` | 주문/결제 레코드를 찾지 못하거나 처리 중 예외 발생 (토스는 본문 `FAIL` 을 실패로 간주해 재전송한다) |
|
||||
| 422 | JSON | 요청 파라미터 검증 실패 |
|
||||
|
||||
처리 순서:
|
||||
|
||||
1. **리플레이 방지** — 이미 결제완료된 거래(`transaction_id` 기준)면 아무것도 하지 않고 `OK` 를 반환한다 (멱등).
|
||||
2. **secret 대조** — 플러그인 설정 `webhook_secret_verify` 가 켜져 있으면, 본문의 `secret` 을 결제 승인 시 저장해 둔 `payment_meta.toss_secret` 과 대조한다 (`hash_equals`). 불일치 시 401.
|
||||
3. **상태 가드** — 결제가 입금대기(`waiting_deposit`) 상태가 아니면 처리하지 않고 `OK` 를 반환한다.
|
||||
4. **상태별 처리** — `status=DONE` 이면 결제완료(`completePayment`), `status=CANCELED` 이면 결제실패(`failPayment`) 처리한다.
|
||||
|
||||
주의사항:
|
||||
|
||||
- 토스는 notify IP 목록·요청 서명을 제공하지 않는다. 공식 위조 방지 수단은 **secret 대조뿐**이므로 `webhook_secret_verify` 를 끄지 않는 것을 권장한다.
|
||||
- 토스는 CSRF 토큰을 보내지 않으므로 이 라우트는 `ValidateCsrfToken` 이 면제되어 있다.
|
||||
- 미응답 시 토스가 최대 7회 재전송한다. 부수 작업(알림·재고·적립금 등)은 `completePayment` 내부 훅 리스너에 위임하고 컨트롤러는 빠르게 응답한다.
|
||||
|
||||
|
||||
### POST /plugins/sirsoft-tosspayments/webhook/payment-status
|
||||
<!-- @generated:start:web.plugins.sirsoft-tosspayments.webhook.payment-status -->
|
||||
- **라우트명**: `web.plugins.sirsoft-tosspayments.webhook.payment-status`
|
||||
- **컨트롤러**: `Plugins\Sirsoft\Tosspayments\Controllers\WebhookController@paymentStatus`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| eventType | body | string | 아니오 | max 64 | 토스 이벤트 종류 (예: `PAYMENT_STATUS_CHANGED`). |
|
||||
| createdAt | body | string | 아니오 | max 64 | 토스가 이벤트를 생성한 시각. |
|
||||
| data | body | array | 예 | — | 결제 정보 객체. `data.orderId`(주문번호)와 `data.status`(토스 결제상태)를 읽어 로컬 상태와 대조한다. |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /plugins/sirsoft-tosspayments/webhook/payment-status HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"eventType": "예시값",
|
||||
"createdAt": "예시값",
|
||||
"data": [
|
||||
"예시값"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
이 엔드포인트는 코어 API 응답 봉투를 쓰지 않는다. 토스 웹훅 규약에 맞춰 `text/plain` 본문만 반환한다.
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/plain
|
||||
|
||||
OK
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
결제상태 변경(PAYMENT_STATUS_CHANGED) 이벤트를 수신해 **상태 동기화 로깅**만 수행한다. 토스 측 결제상태(`data.status`)와 로컬 결제상태를 함께 기록해, 두 값이 어긋난 경우를 로그로 추적할 수 있게 한다.
|
||||
|
||||
이 엔드포인트도 코어 API 응답 봉투를 쓰지 않고 `text/plain` 본문만 반환한다.
|
||||
|
||||
| 상태코드 | 본문 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 200 | `OK` | 정상 수신 / 주문을 찾지 못한 경우 모두 200 (재전송 중단) |
|
||||
| 422 | JSON | 요청 파라미터 검증 실패 |
|
||||
|
||||
주의사항:
|
||||
|
||||
- 이 엔드포인트는 주문 상태를 **변경하지 않는다**. 실제 상태 전이는 결제 승인 콜백(`/payment/success`)과 입금통보 웹훅(`/webhook/deposit`)이 담당한다.
|
||||
- 주문을 찾지 못해도 200 `OK` 를 반환한다 (토스의 재전송을 멈추기 위함).
|
||||
- 토스는 CSRF 토큰을 보내지 않으므로 이 라우트는 `ValidateCsrfToken` 이 면제되어 있다.
|
||||
|
||||
|
||||
@@ -8,4 +8,8 @@ return [
|
||||
'missing_payment_key' => 'Cannot process refund: TossPayments payment key not found.',
|
||||
'default_reason' => 'Cancelled by customer request',
|
||||
],
|
||||
'settings_validation' => [
|
||||
'vbank_valid_hours_range' => 'Virtual account deposit deadline must be between :min and :max hours (max 90 days).',
|
||||
'use_escrow_invalid' => 'The escrow usage setting value is invalid.',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'toss_card' => [
|
||||
'name' => 'Credit Card (TossPayments)',
|
||||
'description' => 'Pay with a credit or check card — processed via TossPayments',
|
||||
],
|
||||
'toss_virtual_account' => [
|
||||
'name' => 'Virtual Account (TossPayments)',
|
||||
'description' => 'Deposit to an issued virtual account — processed via TossPayments',
|
||||
],
|
||||
'toss_transfer' => [
|
||||
'name' => 'Bank Transfer (TossPayments)',
|
||||
'description' => 'Pay by real-time bank transfer — processed via TossPayments',
|
||||
],
|
||||
'toss_mobile_phone' => [
|
||||
'name' => 'Mobile Phone (TossPayments)',
|
||||
'description' => 'Mobile carrier billing — processed via TossPayments',
|
||||
],
|
||||
'toss_tosspay' => [
|
||||
'name' => 'TossPay (TossPayments)',
|
||||
'description' => 'TossPay easy payment — processed via TossPayments',
|
||||
],
|
||||
'toss_kakaopay' => [
|
||||
'name' => 'KakaoPay (TossPayments)',
|
||||
'description' => 'KakaoPay easy payment — processed via TossPayments',
|
||||
],
|
||||
'toss_naverpay' => [
|
||||
'name' => 'NaverPay (TossPayments)',
|
||||
'description' => 'NaverPay easy payment — processed via TossPayments',
|
||||
],
|
||||
'toss_payco' => [
|
||||
'name' => 'PAYCO (TossPayments)',
|
||||
'description' => 'PAYCO easy payment — processed via TossPayments',
|
||||
],
|
||||
'toss_samsungpay' => [
|
||||
'name' => 'Samsung Pay (TossPayments)',
|
||||
'description' => 'Samsung Pay easy payment — processed via TossPayments',
|
||||
],
|
||||
];
|
||||
@@ -8,4 +8,8 @@ return [
|
||||
'missing_payment_key' => '토스페이먼츠 결제 키가 존재하지 않아 환불 처리할 수 없습니다.',
|
||||
'default_reason' => '고객 요청에 의한 취소',
|
||||
],
|
||||
'settings_validation' => [
|
||||
'vbank_valid_hours_range' => '가상계좌 입금기한은 :min~:max시간(최대 90일) 사이여야 합니다.',
|
||||
'use_escrow_invalid' => '에스크로 사용 설정값이 올바르지 않습니다.',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'toss_card' => [
|
||||
'name' => '신용카드 (토스페이먼츠)',
|
||||
'description' => '신용·체크카드로 결제 — 토스페이먼츠를 통해 처리',
|
||||
],
|
||||
'toss_virtual_account' => [
|
||||
'name' => '가상계좌 (토스페이먼츠)',
|
||||
'description' => '발급된 가상계좌로 입금 — 토스페이먼츠를 통해 처리',
|
||||
],
|
||||
'toss_transfer' => [
|
||||
'name' => '계좌이체 (토스페이먼츠)',
|
||||
'description' => '실시간 계좌이체로 결제 — 토스페이먼츠를 통해 처리',
|
||||
],
|
||||
'toss_mobile_phone' => [
|
||||
'name' => '휴대폰결제 (토스페이먼츠)',
|
||||
'description' => '휴대폰 소액결제 — 토스페이먼츠를 통해 처리',
|
||||
],
|
||||
'toss_tosspay' => [
|
||||
'name' => '토스페이 (토스페이먼츠)',
|
||||
'description' => '토스페이 간편결제 — 토스페이먼츠를 통해 처리',
|
||||
],
|
||||
'toss_kakaopay' => [
|
||||
'name' => '카카오페이 (토스페이먼츠)',
|
||||
'description' => '카카오페이 간편결제 — 토스페이먼츠를 통해 처리',
|
||||
],
|
||||
'toss_naverpay' => [
|
||||
'name' => '네이버페이 (토스페이먼츠)',
|
||||
'description' => '네이버페이 간편결제 — 토스페이먼츠를 통해 처리',
|
||||
],
|
||||
'toss_payco' => [
|
||||
'name' => '페이코 (토스페이먼츠)',
|
||||
'description' => '페이코 간편결제 — 토스페이먼츠를 통해 처리',
|
||||
],
|
||||
'toss_samsungpay' => [
|
||||
'name' => '삼성페이 (토스페이먼츠)',
|
||||
'description' => '삼성페이 간편결제 — 토스페이먼츠를 통해 처리',
|
||||
],
|
||||
];
|
||||
@@ -11,10 +11,10 @@
|
||||
"ko": "토스페이먼츠 결제 게이트웨이 (통합결제창 연동)",
|
||||
"en": "TossPayments gateway (integrated payment window)"
|
||||
},
|
||||
"g7_version": ">=7.0.0-beta.2",
|
||||
"g7_version": ">=7.0.0",
|
||||
"dependencies": {
|
||||
"modules": {
|
||||
"sirsoft-ecommerce": ">=1.0.0-beta.4"
|
||||
"sirsoft-ecommerce": ">=1.1.0"
|
||||
},
|
||||
"plugins": {}
|
||||
},
|
||||
|
||||
@@ -96,6 +96,106 @@ class Plugin extends AbstractPlugin
|
||||
'en' => 'Supports relative paths or full URLs. Error details are appended as query parameters.',
|
||||
],
|
||||
],
|
||||
|
||||
// 결제 방식 — 주문서형(결제수단을 우리 체크아웃에서 선택) vs 결제창형(토스 통합결제창)
|
||||
'order_sheet_mode' => [
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
'label' => ['ko' => '주문서형 결제', 'en' => 'Order-sheet Mode'],
|
||||
'hint' => [
|
||||
'ko' => '켜면 체크아웃에서 결제수단을 직접 선택합니다. 끄면 토스 통합결제창(카드) 하나로 처리됩니다.',
|
||||
'en' => 'When ON, payment methods are chosen at checkout. When OFF, a single TossPayments integrated window (card) is used.',
|
||||
],
|
||||
],
|
||||
|
||||
// 주문서형에서 노출할 결제수단 토글 (order_sheet_mode 가 true 일 때만 유효)
|
||||
'method_card' => [
|
||||
'type' => 'boolean',
|
||||
'default' => true,
|
||||
'label' => ['ko' => '카드', 'en' => 'Card'],
|
||||
],
|
||||
'method_virtual_account' => [
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
'label' => ['ko' => '가상계좌', 'en' => 'Virtual Account'],
|
||||
],
|
||||
'method_transfer' => [
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
'label' => ['ko' => '계좌이체', 'en' => 'Bank Transfer'],
|
||||
],
|
||||
'method_mobile_phone' => [
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
'label' => ['ko' => '휴대폰', 'en' => 'Mobile Phone'],
|
||||
],
|
||||
'method_tosspay' => [
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
'label' => ['ko' => '토스페이', 'en' => 'TossPay'],
|
||||
],
|
||||
'method_kakaopay' => [
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
'label' => ['ko' => '카카오페이', 'en' => 'KakaoPay'],
|
||||
],
|
||||
'method_naverpay' => [
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
'label' => ['ko' => '네이버페이', 'en' => 'NaverPay'],
|
||||
],
|
||||
'method_payco' => [
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
'label' => ['ko' => '페이코', 'en' => 'PAYCO'],
|
||||
],
|
||||
'method_samsungpay' => [
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
'label' => ['ko' => '삼성페이', 'en' => 'Samsung Pay'],
|
||||
],
|
||||
|
||||
// 가상계좌 옵션
|
||||
'vbank_valid_hours' => [
|
||||
'type' => 'integer',
|
||||
'default' => 24,
|
||||
'label' => ['ko' => '가상계좌 입금기한(시간)', 'en' => 'Virtual Account Valid Hours'],
|
||||
'hint' => [
|
||||
'ko' => '가상계좌 발급 후 입금 가능한 시간입니다. 최대 2160시간(90일).',
|
||||
'en' => 'Hours available for deposit after issuing a virtual account. Max 2160 (90 days).',
|
||||
],
|
||||
],
|
||||
'vbank_cash_receipt_type' => [
|
||||
'type' => 'string',
|
||||
'default' => '',
|
||||
'label' => ['ko' => '가상계좌 현금영수증 유형', 'en' => 'Virtual Account Cash Receipt Type'],
|
||||
'hint' => [
|
||||
'ko' => '가상계좌 발급 시 토스가 자동 발급할 현금영수증 유형입니다. 비워두면 발급하지 않습니다.',
|
||||
'en' => 'Cash receipt type TossPayments auto-issues when a virtual account is created. Leave blank to skip.',
|
||||
],
|
||||
],
|
||||
|
||||
// 에스크로 — 3-상태 (가상계좌·계좌이체에만 적용)
|
||||
'use_escrow' => [
|
||||
'type' => 'string',
|
||||
'default' => 'off',
|
||||
'label' => ['ko' => '에스크로 사용', 'en' => 'Use Escrow'],
|
||||
'hint' => [
|
||||
'ko' => '가상계좌·계좌이체 결제에만 적용됩니다. 구매자 선택은 결제창에서 구매자가 직접 결정합니다.',
|
||||
'en' => 'Applies to virtual account and bank transfer only. "Buyer choice" lets the buyer decide in the payment window.',
|
||||
],
|
||||
],
|
||||
|
||||
// 가상계좌 입금통보(DEPOSIT_CALLBACK) 웹훅 secret 대조 강제
|
||||
'webhook_secret_verify' => [
|
||||
'type' => 'boolean',
|
||||
'default' => true,
|
||||
'label' => ['ko' => '웹훅 secret 검증', 'en' => 'Webhook Secret Verification'],
|
||||
'hint' => [
|
||||
'ko' => '가상계좌 입금통보 웹훅의 secret 을 결제 승인 응답과 대조해 위조 요청을 차단합니다.',
|
||||
'en' => 'Verifies the deposit webhook secret against the payment confirmation response to block forged requests.',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -114,6 +214,20 @@ class Plugin extends AbstractPlugin
|
||||
'live_secret_key' => '',
|
||||
'redirect_success_url' => '/shop/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => '/shop/checkout',
|
||||
'order_sheet_mode' => false,
|
||||
'method_card' => true,
|
||||
'method_virtual_account' => false,
|
||||
'method_transfer' => false,
|
||||
'method_mobile_phone' => false,
|
||||
'method_tosspay' => false,
|
||||
'method_kakaopay' => false,
|
||||
'method_naverpay' => false,
|
||||
'method_payco' => false,
|
||||
'method_samsungpay' => false,
|
||||
'vbank_valid_hours' => 24,
|
||||
'vbank_cash_receipt_type' => '',
|
||||
'use_escrow' => 'off',
|
||||
'webhook_secret_verify' => true,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -126,7 +240,10 @@ class Plugin extends AbstractPlugin
|
||||
{
|
||||
return [
|
||||
Listeners\RegisterPgProviderListener::class,
|
||||
Listeners\RegisterTossPaymentMethodsListener::class,
|
||||
Listeners\AdjustEcommercePaymentMethodsLayoutListener::class,
|
||||
Listeners\PaymentRefundListener::class,
|
||||
Listeners\ValidateTossSettingsListener::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+216
@@ -3,6 +3,13 @@
|
||||
* requestPayment 핸들러 테스트
|
||||
*
|
||||
* 토스페이먼츠 결제창 호출 핸들러의 에러 처리 및 모달 열기 동작을 검증합니다.
|
||||
*
|
||||
* @effects sdk_method_mapped_from_enabled_methods, virtual_account_payload_built,
|
||||
* escrow_products_attached, escrow_flag_off_true_or_key_absent,
|
||||
* non_krw_domestic_method_blocked,
|
||||
* escrow_products_attached_to_virtual_account_sdk_payload,
|
||||
* escrow_products_absent_when_escrow_off,
|
||||
* escrow_products_absent_for_card
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { requestPaymentHandler } from '../../handlers/requestPayment';
|
||||
@@ -187,4 +194,213 @@ describe('requestPaymentHandler', () => {
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ===== SDK 파라미터 매핑 (주문서형 결제수단 → SDK method) =====
|
||||
describe('결제수단 → SDK 파라미터 매핑', () => {
|
||||
let capturedPayload: any;
|
||||
|
||||
/**
|
||||
* clientConfig 로 SDK 를 스텁하고 requestPayment 인자를 캡처한다.
|
||||
*/
|
||||
const setupSdk = (config: any) => {
|
||||
capturedPayload = undefined;
|
||||
mockG7Core.api.get.mockResolvedValue({ data: config });
|
||||
|
||||
const mockPayment = {
|
||||
requestPayment: vi.fn().mockImplementation((payload: any) => {
|
||||
capturedPayload = payload;
|
||||
return Promise.resolve();
|
||||
}),
|
||||
};
|
||||
(window as any).TossPayments = vi.fn().mockReturnValue({
|
||||
payment: vi.fn().mockReturnValue(mockPayment),
|
||||
});
|
||||
(window as any).TossPayments.ANONYMOUS = 'ANONYMOUS';
|
||||
};
|
||||
|
||||
const baseConfig = (overrides: any = {}) => ({
|
||||
client_key: 'ck',
|
||||
sdk_url: 'https://example.com/sdk.js',
|
||||
callback_urls: { success: '/success', fail: '/fail' },
|
||||
order_sheet_mode: true,
|
||||
enabled_methods: [
|
||||
{ id: 'toss_card', method: 'CARD', easy_pay_provider: null, core_payment_method: 'card' },
|
||||
{ id: 'toss_virtual_account', method: 'VIRTUAL_ACCOUNT', easy_pay_provider: null, core_payment_method: 'vbank' },
|
||||
{ id: 'toss_transfer', method: 'TRANSFER', easy_pay_provider: null, core_payment_method: 'bank' },
|
||||
{ id: 'toss_kakaopay', method: 'CARD', easy_pay_provider: '카카오페이', core_payment_method: 'card' },
|
||||
],
|
||||
vbank: { valid_hours: 24, cash_receipt_type: '' },
|
||||
use_escrow: 'off',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const pgData = (overrides: any = {}) => ({
|
||||
order_number: 'ORD-100',
|
||||
order_name: '주문',
|
||||
amount: 10000,
|
||||
currency: 'KRW',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('order_sheet_mode off 이면 CARD 통합결제창', async () => {
|
||||
setupSdk(baseConfig({ order_sheet_mode: false }));
|
||||
|
||||
await requestPaymentHandler({ params: { pgPaymentData: pgData(), paymentMethod: 'toss_virtual_account' } });
|
||||
|
||||
expect(capturedPayload.method).toBe('CARD');
|
||||
expect(capturedPayload.virtualAccount).toBeUndefined();
|
||||
});
|
||||
|
||||
it('가상계좌 선택 시 VIRTUAL_ACCOUNT + virtualAccount 페이로드', async () => {
|
||||
setupSdk(baseConfig({ vbank: { valid_hours: 48, cash_receipt_type: '소득공제' } }));
|
||||
|
||||
await requestPaymentHandler({ params: { pgPaymentData: pgData(), paymentMethod: 'toss_virtual_account' } });
|
||||
|
||||
expect(capturedPayload.method).toBe('VIRTUAL_ACCOUNT');
|
||||
expect(capturedPayload.virtualAccount.validHours).toBe(48);
|
||||
expect(capturedPayload.virtualAccount.cashReceipt).toEqual({ type: '소득공제' });
|
||||
});
|
||||
|
||||
it('간편결제 선택 시 CARD + easyPay provider', async () => {
|
||||
setupSdk(baseConfig());
|
||||
|
||||
await requestPaymentHandler({ params: { pgPaymentData: pgData(), paymentMethod: 'toss_kakaopay' } });
|
||||
|
||||
expect(capturedPayload.method).toBe('CARD');
|
||||
expect(capturedPayload.easyPay).toEqual({ provider: '카카오페이' });
|
||||
});
|
||||
|
||||
it('에스크로 on 이면 가상계좌 useEscrow=true', async () => {
|
||||
setupSdk(baseConfig({ use_escrow: 'on' }));
|
||||
|
||||
await requestPaymentHandler({ params: { pgPaymentData: pgData(), paymentMethod: 'toss_virtual_account' } });
|
||||
|
||||
expect(capturedPayload.virtualAccount.useEscrow).toBe(true);
|
||||
});
|
||||
|
||||
it('에스크로 buyer_choice 이면 useEscrow 키 부재', async () => {
|
||||
setupSdk(baseConfig({ use_escrow: 'buyer_choice' }));
|
||||
|
||||
await requestPaymentHandler({ params: { pgPaymentData: pgData(), paymentMethod: 'toss_virtual_account' } });
|
||||
|
||||
expect('useEscrow' in capturedPayload.virtualAccount).toBe(false);
|
||||
});
|
||||
|
||||
it('가상계좌 + 에스크로 on 시 escrowProducts 부착 (E2)', async () => {
|
||||
setupSdk(baseConfig({ use_escrow: 'on' }));
|
||||
|
||||
await requestPaymentHandler({
|
||||
params: {
|
||||
pgPaymentData: pgData({
|
||||
escrow_products: [{ id: '1', name: '상품', code: '1', unitPrice: 10000, quantity: 1 }],
|
||||
}),
|
||||
paymentMethod: 'toss_virtual_account',
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedPayload.method).toBe('VIRTUAL_ACCOUNT');
|
||||
expect(capturedPayload.virtualAccount.useEscrow).toBe(true);
|
||||
expect(capturedPayload.escrowProducts).toHaveLength(1);
|
||||
expect(capturedPayload.escrowProducts[0].unitPrice).toBe(10000);
|
||||
});
|
||||
|
||||
it('가상계좌 + 에스크로 buyer_choice 시에도 escrowProducts 부착 (E3 — 구매자가 선택할 수 있어야 하므로)', async () => {
|
||||
setupSdk(baseConfig({ use_escrow: 'buyer_choice' }));
|
||||
|
||||
await requestPaymentHandler({
|
||||
params: {
|
||||
pgPaymentData: pgData({
|
||||
escrow_products: [{ id: '1', name: '상품', code: '1', unitPrice: 10000, quantity: 1 }],
|
||||
}),
|
||||
paymentMethod: 'toss_virtual_account',
|
||||
},
|
||||
});
|
||||
|
||||
expect('useEscrow' in capturedPayload.virtualAccount).toBe(false);
|
||||
expect(capturedPayload.escrowProducts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('가상계좌 + 에스크로 off 이면 escrowProducts 미부착', async () => {
|
||||
setupSdk(baseConfig({ use_escrow: 'off' }));
|
||||
|
||||
await requestPaymentHandler({
|
||||
params: {
|
||||
pgPaymentData: pgData({
|
||||
escrow_products: [{ id: '1', name: '상품', code: '1', unitPrice: 10000, quantity: 1 }],
|
||||
}),
|
||||
paymentMethod: 'toss_virtual_account',
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedPayload.virtualAccount.useEscrow).toBe(false);
|
||||
expect(capturedPayload.escrowProducts).toBeUndefined();
|
||||
});
|
||||
|
||||
it('카드 결제는 에스크로 on 이어도 escrowProducts 미부착 (E1)', async () => {
|
||||
setupSdk(baseConfig({ use_escrow: 'on' }));
|
||||
|
||||
await requestPaymentHandler({
|
||||
params: {
|
||||
pgPaymentData: pgData({
|
||||
escrow_products: [{ id: '1', name: '상품', code: '1', unitPrice: 10000, quantity: 1 }],
|
||||
}),
|
||||
paymentMethod: 'toss_card',
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedPayload.method).toBe('CARD');
|
||||
expect(capturedPayload.escrowProducts).toBeUndefined();
|
||||
});
|
||||
|
||||
it('계좌이체 + 에스크로 시 escrowProducts 부착', async () => {
|
||||
setupSdk(baseConfig({ use_escrow: 'on' }));
|
||||
|
||||
await requestPaymentHandler({
|
||||
params: {
|
||||
pgPaymentData: pgData({
|
||||
escrow_products: [{ id: '1', name: '상품', code: '1', unitPrice: 10000, quantity: 1 }],
|
||||
}),
|
||||
paymentMethod: 'toss_transfer',
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedPayload.method).toBe('TRANSFER');
|
||||
expect(capturedPayload.transfer.useEscrow).toBe(true);
|
||||
expect(capturedPayload.escrowProducts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('카드 결제는 useEscrow 미전달 (E1)', async () => {
|
||||
setupSdk(baseConfig({ use_escrow: 'on' }));
|
||||
|
||||
await requestPaymentHandler({ params: { pgPaymentData: pgData(), paymentMethod: 'toss_card' } });
|
||||
|
||||
expect(capturedPayload.method).toBe('CARD');
|
||||
expect(capturedPayload.card).toBeDefined();
|
||||
expect(capturedPayload.card.useEscrow).toBeUndefined();
|
||||
});
|
||||
|
||||
it('비KRW + 가상계좌 선택 시 차단 (카드만 허용)', async () => {
|
||||
mockG7Core.t = vi.fn().mockReturnValue('KRW only');
|
||||
setupSdk(baseConfig());
|
||||
|
||||
await requestPaymentHandler({
|
||||
params: { pgPaymentData: pgData({ currency: 'USD' }), paymentMethod: 'toss_virtual_account' },
|
||||
});
|
||||
|
||||
// 결제창 호출 안 함 (차단)
|
||||
expect(capturedPayload).toBeUndefined();
|
||||
expect(mockG7Core.modal.open).toHaveBeenCalledWith('tosspayments_payment_error_modal');
|
||||
});
|
||||
|
||||
it('비KRW + 카드 선택은 허용', async () => {
|
||||
setupSdk(baseConfig());
|
||||
|
||||
await requestPaymentHandler({
|
||||
params: { pgPaymentData: pgData({ currency: 'USD' }), paymentMethod: 'toss_card' },
|
||||
});
|
||||
|
||||
expect(capturedPayload.method).toBe('CARD');
|
||||
expect(capturedPayload.amount.currency).toBe('USD');
|
||||
});
|
||||
});
|
||||
});
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @file pluginSettingsPaymentMethods.test.tsx
|
||||
* @description S4 신설 섹션(결제 방식 · 가상계좌) 구조 검증.
|
||||
*
|
||||
* order_sheet_mode 토글 / 결제수단 9종 체크박스(name=method_*) / 노출 조건(if) /
|
||||
* 가상계좌 옵션(입금기한·현금영수증·에스크로 3-상태·웹훅) / 에스크로 운영 안내(W-6) /
|
||||
* responsive.portable 을 검증한다.
|
||||
*
|
||||
* @scenario toss_payment_methods_vbank_escrow
|
||||
* @effects escrow_operation_notice_shown_when_escrow_enabled
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import pluginSettingsLayout from '../../../layouts/admin/plugin_settings.json';
|
||||
|
||||
/** 레이아웃 트리를 순회하며 조건을 만족하는 모든 노드를 수집한다. */
|
||||
function collect(node: unknown, pred: (n: Record<string, unknown>) => boolean): Record<string, unknown>[] {
|
||||
const out: Record<string, unknown>[] = [];
|
||||
const walk = (n: unknown) => {
|
||||
if (!n || typeof n !== 'object') return;
|
||||
const v = n as Record<string, unknown>;
|
||||
if (pred(v)) out.push(v);
|
||||
for (const child of Object.values(v)) walk(child);
|
||||
};
|
||||
walk(node);
|
||||
return out;
|
||||
}
|
||||
|
||||
function findById(node: unknown, id: string): Record<string, unknown> | undefined {
|
||||
return collect(node, (n) => n.id === id)[0];
|
||||
}
|
||||
|
||||
function nameOf(node: Record<string, unknown>): string {
|
||||
const props = (node.props ?? {}) as Record<string, unknown>;
|
||||
return typeof props.name === 'string' ? props.name : '';
|
||||
}
|
||||
|
||||
describe('plugin_settings — 결제 방식 섹션', () => {
|
||||
it('order_sheet_mode 토글이 존재한다', () => {
|
||||
const toggles = collect(pluginSettingsLayout, (n) => n.name === 'Toggle' && nameOf(n) === 'order_sheet_mode');
|
||||
expect(toggles).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('결제수단 9종 체크박스가 method_* name 으로 존재한다', () => {
|
||||
const checkboxes = collect(pluginSettingsLayout, (n) => n.name === 'Checkbox');
|
||||
const names = checkboxes.map(nameOf).filter((s) => s.startsWith('method_'));
|
||||
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
'method_card',
|
||||
'method_virtual_account',
|
||||
'method_transfer',
|
||||
'method_mobile_phone',
|
||||
'method_tosspay',
|
||||
'method_kakaopay',
|
||||
'method_naverpay',
|
||||
'method_payco',
|
||||
'method_samsungpay',
|
||||
])
|
||||
);
|
||||
expect(names).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('결제수단 그룹은 order_sheet_mode 가 켜졌을 때만 노출된다 (if)', () => {
|
||||
const group = findById(pluginSettingsLayout, 'enabled_methods_group');
|
||||
expect(group).toBeDefined();
|
||||
expect(group!.if).toContain('order_sheet_mode');
|
||||
});
|
||||
|
||||
it('체크박스 그리드는 responsive.portable 로 1열 스택한다', () => {
|
||||
const grid = findById(pluginSettingsLayout, 'method_checkbox_grid');
|
||||
expect(grid).toBeDefined();
|
||||
const responsive = grid!.responsive as Record<string, any> | undefined;
|
||||
expect(responsive?.portable?.props?.className).toContain('grid-cols-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugin_settings — 가상계좌 섹션', () => {
|
||||
it('입금기한 입력(number) 이 존재한다', () => {
|
||||
const inputs = collect(pluginSettingsLayout, (n) => n.name === 'Input' && nameOf(n) === 'vbank_valid_hours');
|
||||
expect(inputs).toHaveLength(1);
|
||||
const props = inputs[0].props as Record<string, unknown>;
|
||||
expect(props.type).toBe('number');
|
||||
});
|
||||
|
||||
it('현금영수증 유형 Select 가 3옵션(없음/소득공제/지출증빙)을 갖는다', () => {
|
||||
const selects = collect(pluginSettingsLayout, (n) => n.name === 'Select' && nameOf(n) === 'vbank_cash_receipt_type');
|
||||
expect(selects).toHaveLength(1);
|
||||
const options = (selects[0].props as any).options as { value: string }[];
|
||||
expect(options.map((o) => o.value)).toEqual(['', '소득공제', '지출증빙']);
|
||||
});
|
||||
|
||||
it('에스크로 Select 가 3-상태(off/on/buyer_choice)를 갖는다', () => {
|
||||
const selects = collect(pluginSettingsLayout, (n) => n.name === 'Select' && nameOf(n) === 'use_escrow');
|
||||
expect(selects).toHaveLength(1);
|
||||
const options = (selects[0].props as any).options as { value: string }[];
|
||||
expect(options.map((o) => o.value)).toEqual(['off', 'on', 'buyer_choice']);
|
||||
});
|
||||
|
||||
it('에스크로 운영 안내(부분취소 불가 · 배송정보 등록)가 use_escrow !== off 일 때만 노출된다 (W-6)', () => {
|
||||
const notice = findById(pluginSettingsLayout, 'escrow_operation_notice');
|
||||
expect(notice).toBeDefined();
|
||||
expect(notice!.if).toBe("{{_local.form?.use_escrow !== 'off'}}");
|
||||
|
||||
const texts = collect(notice, (n) => typeof n.text === 'string').map((n) => n.text as string);
|
||||
expect(texts).toContain('$t:sirsoft-tosspayments.settings.escrow_no_partial_cancel_notice');
|
||||
expect(texts).toContain('$t:sirsoft-tosspayments.settings.escrow_shipping_info_notice');
|
||||
});
|
||||
|
||||
it('배송정보 안내는 토스 상점관리자 외부 링크를 제공한다 (W-6)', () => {
|
||||
const notice = findById(pluginSettingsLayout, 'escrow_operation_notice');
|
||||
const links = collect(notice, (n) => n.name === 'A');
|
||||
|
||||
expect(links).toHaveLength(1);
|
||||
const props = links[0].props as Record<string, unknown>;
|
||||
expect(props.href).toBe('https://app.tosspayments.com/');
|
||||
expect(props.target).toBe('_blank');
|
||||
expect(props.rel).toBe('noopener noreferrer');
|
||||
expect(links[0].text).toBe('$t:sirsoft-tosspayments.settings.escrow_shipping_info_link');
|
||||
});
|
||||
|
||||
it('웹훅 secret 검증 토글이 존재한다', () => {
|
||||
const toggles = collect(pluginSettingsLayout, (n) => n.name === 'Toggle' && nameOf(n) === 'webhook_secret_verify');
|
||||
expect(toggles).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('웹훅 URL 복사 버튼이 copyToClipboard 핸들러를 사용한다', () => {
|
||||
const btn = findById(pluginSettingsLayout, 'webhook_copy_button');
|
||||
expect(btn).toBeDefined();
|
||||
const actions = btn!.actions as Array<Record<string, unknown>>;
|
||||
expect(actions[0].handler).toBe('copyToClipboard');
|
||||
});
|
||||
|
||||
it('웹훅 URL 복사 버튼은 portable 에서 full-width 로 전환된다', () => {
|
||||
const btn = findById(pluginSettingsLayout, 'webhook_copy_button');
|
||||
const responsive = btn!.responsive as Record<string, any> | undefined;
|
||||
expect(responsive?.portable?.props?.className).toContain('w-full');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugin_settings — 기존 섹션 보존', () => {
|
||||
it('기존 저장 버튼 sticky 는 유지된다', () => {
|
||||
const footer = findById(pluginSettingsLayout, 'footer_buttons');
|
||||
expect((footer!.props as any).className).toContain('sticky-footer-buttons');
|
||||
});
|
||||
});
|
||||
+3
-2
@@ -77,13 +77,14 @@ describe('plugin_settings 시맨틱 클래스 매핑', () => {
|
||||
});
|
||||
|
||||
describe('섹션 제목 H3', () => {
|
||||
it('테스트/라이브/리다이렉트 섹션 제목은 section-heading-md 를 가져야 한다', () => {
|
||||
it('모든 section_* 제목은 section-heading-md 를 가져야 한다', () => {
|
||||
const sectionHeadings = collect(
|
||||
pluginSettingsLayout,
|
||||
(n) => n.name === 'H3' && typeof n.text === 'string' && n.text.startsWith('$t:sirsoft-tosspayments.settings.section_'),
|
||||
);
|
||||
|
||||
expect(sectionHeadings).toHaveLength(3);
|
||||
// 테스트키 · 라이브키 · 리다이렉트 · 결제방식(S4) · 가상계좌(S4) = 5개
|
||||
expect(sectionHeadings).toHaveLength(5);
|
||||
for (const heading of sectionHeadings) {
|
||||
expect(classNameOf(heading)).toBe('section-heading-md');
|
||||
}
|
||||
|
||||
@@ -15,6 +15,14 @@
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
interface EscrowProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
unitPrice: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
interface PgPaymentData {
|
||||
order_number: string;
|
||||
order_name: string;
|
||||
@@ -24,15 +32,32 @@ interface PgPaymentData {
|
||||
customer_email?: string;
|
||||
customer_phone?: string;
|
||||
customer_key?: string | null;
|
||||
escrow_products?: EscrowProduct[];
|
||||
}
|
||||
|
||||
interface RequestPaymentParams {
|
||||
pgPaymentData: PgPaymentData;
|
||||
// 주문서형에서 사용자가 선택한 결제수단 id (toss_*). 미지정 시 _local.paymentMethod 참조.
|
||||
paymentMethod?: string;
|
||||
}
|
||||
|
||||
interface EnabledMethod {
|
||||
id: string;
|
||||
method: string;
|
||||
easy_pay_provider: string | null;
|
||||
core_payment_method: string;
|
||||
}
|
||||
|
||||
interface ClientConfig {
|
||||
client_key: string;
|
||||
sdk_url: string;
|
||||
order_sheet_mode?: boolean;
|
||||
enabled_methods?: EnabledMethod[];
|
||||
vbank?: {
|
||||
valid_hours: number;
|
||||
cash_receipt_type: string;
|
||||
};
|
||||
use_escrow?: string;
|
||||
callback_urls: {
|
||||
success: string;
|
||||
fail: string;
|
||||
@@ -77,8 +102,96 @@ function loadScript(src: string): Promise<void> {
|
||||
* @param action 액션 정의 (handler, params 등)
|
||||
* @param _context 액션 컨텍스트
|
||||
*/
|
||||
/**
|
||||
* 선택된 결제수단 id 를 SDK 파라미터로 변환합니다.
|
||||
*
|
||||
* 서버가 내려준 enabled_methods 에서 선택 id 를 찾아 SDK method / easyPay provider 를
|
||||
* 결정한다. 프론트는 결제수단 매핑을 하드코딩하지 않는다 (서버가 SSoT).
|
||||
* order_sheet_mode 가 off 이거나 선택 id 를 못 찾으면 통합결제창 카드(CARD)로 처리한다.
|
||||
*
|
||||
* @param config 클라이언트 설정 (enabled_methods 포함)
|
||||
* @param selectedId 선택된 toss_* 결제수단 id
|
||||
* @returns SDK method 와 easyPay provider
|
||||
*/
|
||||
function resolveMethod(config: ClientConfig, selectedId?: string): { method: string; easyPay: string | null } {
|
||||
if (!config.order_sheet_mode || !selectedId) {
|
||||
return { method: 'CARD', easyPay: null };
|
||||
}
|
||||
|
||||
const entry = (config.enabled_methods ?? []).find((m) => m.id === selectedId);
|
||||
if (!entry) {
|
||||
return { method: 'CARD', easyPay: null };
|
||||
}
|
||||
|
||||
return { method: entry.method, easyPay: entry.easy_pay_provider };
|
||||
}
|
||||
|
||||
/**
|
||||
* use_escrow 설정 문자열을 SDK useEscrow boolean/undefined 로 변환합니다.
|
||||
*
|
||||
* off → false, on → true, buyer_choice → undefined(키 생략 → 결제창에서 구매자 선택).
|
||||
*
|
||||
* @param useEscrow 설정값 (off | on | buyer_choice)
|
||||
* @returns SDK useEscrow 값 (undefined 면 키를 넣지 않음)
|
||||
*/
|
||||
function resolveEscrowFlag(useEscrow?: string): boolean | undefined {
|
||||
if (useEscrow === 'on') return true;
|
||||
if (useEscrow === 'off') return false;
|
||||
// buyer_choice (또는 미설정) → 키 자체를 생략
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 가상계좌 SDK 페이로드를 구성합니다.
|
||||
*
|
||||
* @param config 클라이언트 설정 (vbank / use_escrow 포함)
|
||||
* @param pgPaymentData PG 결제 데이터 (escrow_products 포함)
|
||||
* @returns virtualAccount 페이로드
|
||||
*/
|
||||
function buildVirtualAccountPayload(config: ClientConfig, pgPaymentData: PgPaymentData): any {
|
||||
const vbank: any = {
|
||||
validHours: config.vbank?.valid_hours ?? 24,
|
||||
};
|
||||
|
||||
// 현금영수증 자동 발급 유형 (설정 시에만)
|
||||
const receiptType = config.vbank?.cash_receipt_type ?? '';
|
||||
if (receiptType) {
|
||||
vbank.cashReceipt = { type: receiptType };
|
||||
}
|
||||
|
||||
// 에스크로 (off → false, on → true, buyer_choice → 키 생략)
|
||||
const useEscrow = resolveEscrowFlag(config.use_escrow);
|
||||
if (useEscrow !== undefined) {
|
||||
vbank.useEscrow = useEscrow;
|
||||
}
|
||||
|
||||
return vbank;
|
||||
}
|
||||
|
||||
/**
|
||||
* 에스크로 사용 시 필수인 escrowProducts 배열을 페이로드에 부착합니다.
|
||||
*
|
||||
* use_escrow 가 off 가 아니고(강제 on 또는 구매자 선택) escrow_products 가 있으면 부착한다.
|
||||
*
|
||||
* @param payload 결제 요청 페이로드 (변경됨)
|
||||
* @param config 클라이언트 설정
|
||||
* @param pgPaymentData PG 결제 데이터
|
||||
* @returns void
|
||||
*/
|
||||
function attachEscrowProducts(payload: any, config: ClientConfig, pgPaymentData: PgPaymentData): void {
|
||||
if (config.use_escrow === 'off') {
|
||||
return;
|
||||
}
|
||||
|
||||
const products = pgPaymentData.escrow_products ?? [];
|
||||
if (products.length > 0) {
|
||||
payload.escrowProducts = products;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestPaymentHandler(action: any, _context?: any): Promise<void> {
|
||||
const { pgPaymentData } = (action.params || {}) as RequestPaymentParams;
|
||||
const params = (action.params || {}) as RequestPaymentParams;
|
||||
const { pgPaymentData } = params;
|
||||
|
||||
if (!pgPaymentData) {
|
||||
console.error('[sirsoft-tosspayments] pgPaymentData is required');
|
||||
@@ -87,6 +200,10 @@ export async function requestPaymentHandler(action: any, _context?: any): Promis
|
||||
|
||||
const G7Core = (window as any).G7Core;
|
||||
|
||||
// 선택된 결제수단 id — action.params 우선, 없으면 _local.paymentMethod 참조
|
||||
const selectedMethodId: string | undefined =
|
||||
params.paymentMethod ?? G7Core?.state?.getLocal?.()?.paymentMethod;
|
||||
|
||||
try {
|
||||
// 1. Client Config API 호출
|
||||
const configJson = await G7Core.api.get('/modules/sirsoft-ecommerce/payments/client-config/tosspayments');
|
||||
@@ -119,13 +236,28 @@ export async function requestPaymentHandler(action: any, _context?: any): Promis
|
||||
customerKey: pgPaymentData.customer_key ?? window.TossPayments.ANONYMOUS,
|
||||
});
|
||||
|
||||
// 4. 결제 요청 (통합결제창)
|
||||
const origin = window.location.origin;
|
||||
// 4. 결제수단 결정 + 비KRW 차단
|
||||
const currency = pgPaymentData.currency ?? 'KRW';
|
||||
const { method, easyPay } = resolveMethod(config, selectedMethodId);
|
||||
|
||||
await payment.requestPayment({
|
||||
method: 'CARD',
|
||||
// 토스 국내 전용 수단(가상계좌·계좌이체·휴대폰·간편결제)은 비KRW 결제 불가. 카드만 허용.
|
||||
const domesticOnly = method !== 'CARD' || easyPay !== null;
|
||||
if (currency !== 'KRW' && domesticOnly) {
|
||||
console.warn('[sirsoft-tosspayments] non-KRW currency supports card only', { currency, method });
|
||||
G7Core?.state?.setLocal?.({
|
||||
paymentErrorMessage: G7Core?.t?.('sirsoft-tosspayments.errors.non_krw_method') ?? 'This payment method is available for KRW only.',
|
||||
isSubmittingOrder: false,
|
||||
});
|
||||
G7Core?.modal?.open?.('tosspayments_payment_error_modal');
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 결제 요청 페이로드 조립
|
||||
const origin = window.location.origin;
|
||||
const requestPayload: any = {
|
||||
method,
|
||||
amount: {
|
||||
currency: pgPaymentData.currency ?? 'KRW',
|
||||
currency,
|
||||
value: pgPaymentData.amount,
|
||||
},
|
||||
orderId: pgPaymentData.order_number,
|
||||
@@ -135,13 +267,32 @@ export async function requestPaymentHandler(action: any, _context?: any): Promis
|
||||
customerEmail: pgPaymentData.customer_email ?? undefined,
|
||||
customerName: pgPaymentData.customer_name ?? undefined,
|
||||
customerMobilePhone: pgPaymentData.customer_phone ?? undefined,
|
||||
card: {
|
||||
useEscrow: false,
|
||||
};
|
||||
|
||||
if (method === 'CARD') {
|
||||
requestPayload.card = {
|
||||
flowMode: 'DEFAULT',
|
||||
useCardPoint: false,
|
||||
useAppCardOnly: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
// 간편결제(easyPay)는 CARD method 에 easyPay provider 를 실어 호출한다.
|
||||
if (easyPay) {
|
||||
requestPayload.easyPay = { provider: easyPay };
|
||||
}
|
||||
} else if (method === 'VIRTUAL_ACCOUNT') {
|
||||
// 가상계좌 — 에스크로 적용 대상 (escrowProducts 는 에스크로 사용 시 필수)
|
||||
requestPayload.virtualAccount = buildVirtualAccountPayload(config, pgPaymentData);
|
||||
attachEscrowProducts(requestPayload, config, pgPaymentData);
|
||||
} else if (method === 'TRANSFER') {
|
||||
// 계좌이체 — 에스크로만 적용 대상
|
||||
const useEscrow = resolveEscrowFlag(config.use_escrow);
|
||||
if (useEscrow !== undefined) {
|
||||
requestPayload.transfer = { useEscrow };
|
||||
}
|
||||
attachEscrowProducts(requestPayload, config, pgPaymentData);
|
||||
}
|
||||
|
||||
await payment.requestPayment(requestPayload);
|
||||
// → 브라우저가 successUrl 또는 failUrl로 리다이렉트됨
|
||||
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -26,7 +26,43 @@
|
||||
"redirect_fail_url": "Payment Failure Redirect URL",
|
||||
"redirect_fail_url_hint": "Supports relative paths or full URLs. Error details (error, message, orderId) are appended as query parameters.",
|
||||
"save": "Save",
|
||||
"saved": "Settings saved."
|
||||
"saved": "Settings saved.",
|
||||
"section_payment_methods": "Payment Methods",
|
||||
"order_sheet_mode": "Order-sheet Mode",
|
||||
"order_sheet_mode_hint": "When ON, payment methods are chosen at checkout. When OFF, a single TossPayments integrated window (card) is used.",
|
||||
"enabled_methods": "Methods to Show",
|
||||
"enabled_methods_hint": "Select the payment methods shown at checkout in order-sheet mode.",
|
||||
"method_card": "Card",
|
||||
"method_virtual_account": "Virtual Account",
|
||||
"method_transfer": "Bank Transfer",
|
||||
"method_mobile_phone": "Mobile Phone",
|
||||
"method_tosspay": "TossPay",
|
||||
"method_kakaopay": "KakaoPay",
|
||||
"method_naverpay": "NaverPay",
|
||||
"method_payco": "PAYCO",
|
||||
"method_samsungpay": "Samsung Pay",
|
||||
"section_virtual_account": "Virtual Account",
|
||||
"vbank_valid_hours": "Valid Hours",
|
||||
"vbank_valid_hours_hint": "Hours available for deposit after issuing a virtual account. Max 2160 (90 days).",
|
||||
"vbank_cash_receipt_type": "Auto Cash Receipt Type",
|
||||
"vbank_cash_receipt_type_hint": "Cash receipt type TossPayments auto-issues when a virtual account is created.",
|
||||
"vbank_cash_receipt_none": "Do not issue",
|
||||
"vbank_cash_receipt_income": "Income deduction",
|
||||
"vbank_cash_receipt_expense": "Expense proof",
|
||||
"use_escrow": "Use Escrow",
|
||||
"use_escrow_hint": "Applies to virtual account and bank transfer only.",
|
||||
"use_escrow_off": "Off",
|
||||
"use_escrow_on": "Force on",
|
||||
"use_escrow_buyer_choice": "Buyer choice",
|
||||
"escrow_no_partial_cancel_notice": "Escrow orders cannot be partially cancelled. Only full cancellation is available.",
|
||||
"escrow_shipping_info_notice": "Register shipping information in the TossPayments merchant console.",
|
||||
"escrow_shipping_info_link": "Open ↗",
|
||||
"webhook_secret_verify": "Webhook Secret Verification",
|
||||
"webhook_secret_verify_hint": "Verifies the deposit webhook secret against the payment confirmation response to block forged requests.",
|
||||
"webhook_url_label": "Deposit Webhook URL",
|
||||
"webhook_url_hint": "Prefix this path with your store's full domain (https://your-domain) and register it as a DEPOSIT_CALLBACK event in the TossPayments Developer Center > Webhook menu.",
|
||||
"webhook_url_copy": "Copy",
|
||||
"webhook_url_copied": "Webhook URL copied."
|
||||
},
|
||||
"payment_error_title": "Payment Error",
|
||||
"payment_cancel_title": "Payment Cancelled",
|
||||
@@ -35,6 +71,7 @@
|
||||
"confirm_failed": "Payment confirmation failed.",
|
||||
"amount_mismatch": "Payment amount does not match.",
|
||||
"order_not_found": "Order not found.",
|
||||
"payment_failed": "Payment failed."
|
||||
"payment_failed": "Payment failed.",
|
||||
"non_krw_method": "This payment method is available for KRW payments only."
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,43 @@
|
||||
"redirect_fail_url": "결제 실패 리다이렉트 URL",
|
||||
"redirect_fail_url_hint": "상대 경로 또는 전체 URL 모두 가능합니다. 오류 정보(error, message, orderId)는 쿼리 파라미터로 자동 추가됩니다.",
|
||||
"save": "저장",
|
||||
"saved": "설정이 저장되었습니다."
|
||||
"saved": "설정이 저장되었습니다.",
|
||||
"section_payment_methods": "결제 방식",
|
||||
"order_sheet_mode": "주문서형 결제",
|
||||
"order_sheet_mode_hint": "켜면 체크아웃에서 결제수단을 직접 선택합니다. 끄면 토스 통합결제창(카드) 하나로 처리됩니다.",
|
||||
"enabled_methods": "노출할 결제수단",
|
||||
"enabled_methods_hint": "주문서형 결제일 때 체크아웃에 노출할 결제수단을 선택합니다.",
|
||||
"method_card": "카드",
|
||||
"method_virtual_account": "가상계좌",
|
||||
"method_transfer": "계좌이체",
|
||||
"method_mobile_phone": "휴대폰",
|
||||
"method_tosspay": "토스페이",
|
||||
"method_kakaopay": "카카오페이",
|
||||
"method_naverpay": "네이버페이",
|
||||
"method_payco": "페이코",
|
||||
"method_samsungpay": "삼성페이",
|
||||
"section_virtual_account": "가상계좌",
|
||||
"vbank_valid_hours": "입금기한(시간)",
|
||||
"vbank_valid_hours_hint": "가상계좌 발급 후 입금 가능한 시간입니다. 최대 2160시간(90일).",
|
||||
"vbank_cash_receipt_type": "현금영수증 자동발급 유형",
|
||||
"vbank_cash_receipt_type_hint": "가상계좌 발급 시 토스가 자동 발급할 현금영수증 유형입니다.",
|
||||
"vbank_cash_receipt_none": "발급 안 함",
|
||||
"vbank_cash_receipt_income": "소득공제",
|
||||
"vbank_cash_receipt_expense": "지출증빙",
|
||||
"use_escrow": "에스크로 사용",
|
||||
"use_escrow_hint": "가상계좌·계좌이체 결제에만 적용됩니다.",
|
||||
"use_escrow_off": "사용 안 함",
|
||||
"use_escrow_on": "강제 사용",
|
||||
"use_escrow_buyer_choice": "구매자 선택",
|
||||
"escrow_no_partial_cancel_notice": "에스크로 주문은 부분취소가 불가합니다. 전체 취소만 가능합니다.",
|
||||
"escrow_shipping_info_notice": "배송정보는 토스 상점관리자에서 등록하세요.",
|
||||
"escrow_shipping_info_link": "바로가기 ↗",
|
||||
"webhook_secret_verify": "웹훅 secret 검증",
|
||||
"webhook_secret_verify_hint": "가상계좌 입금통보 웹훅의 secret 을 결제 승인 응답과 대조해 위조 요청을 차단합니다.",
|
||||
"webhook_url_label": "가상계좌 입금통보 웹훅 URL",
|
||||
"webhook_url_hint": "위 경로 앞에 상점 전체 도메인(https://내도메인)을 붙여, 토스 개발자센터 > 웹훅 메뉴에 DEPOSIT_CALLBACK 이벤트로 등록하세요.",
|
||||
"webhook_url_copy": "복사",
|
||||
"webhook_url_copied": "웹훅 URL 이 복사되었습니다."
|
||||
},
|
||||
"payment_error_title": "결제 오류",
|
||||
"payment_cancel_title": "결제 취소",
|
||||
@@ -35,6 +71,7 @@
|
||||
"confirm_failed": "결제 승인에 실패했습니다.",
|
||||
"amount_mismatch": "결제 금액이 일치하지 않습니다.",
|
||||
"order_not_found": "주문을 찾을 수 없습니다.",
|
||||
"payment_failed": "결제에 실패했습니다."
|
||||
"payment_failed": "결제에 실패했습니다.",
|
||||
"non_krw_method": "이 결제수단은 원화(KRW) 결제에만 사용할 수 있습니다."
|
||||
}
|
||||
}
|
||||
@@ -592,6 +592,520 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "payment_methods_section",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "panel-header-row"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "H3",
|
||||
"props": {
|
||||
"className": "section-heading-md"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.section_payment_methods"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "p-6 space-y-6"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "field_order_sheet_mode",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex-between"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": {
|
||||
"className": "form-label"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.order_sheet_mode"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "form-hint"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.order_sheet_mode_hint"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "Toggle",
|
||||
"props": {
|
||||
"name": "order_sheet_mode"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "enabled_methods_group",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_local.form?.order_sheet_mode}}",
|
||||
"props": {
|
||||
"className": "space-y-3 pt-2 border-t border-gray-100 dark:border-gray-700"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": {
|
||||
"className": "form-label"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.enabled_methods"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "form-hint"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.enabled_methods_hint"
|
||||
},
|
||||
{
|
||||
"id": "method_checkbox_grid",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "grid grid-cols-3 gap-3"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"className": "grid grid-cols-1 gap-2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Checkbox",
|
||||
"props": {
|
||||
"name": "method_card",
|
||||
"label": "$t:sirsoft-tosspayments.settings.method_card"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Checkbox",
|
||||
"props": {
|
||||
"name": "method_virtual_account",
|
||||
"label": "$t:sirsoft-tosspayments.settings.method_virtual_account"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Checkbox",
|
||||
"props": {
|
||||
"name": "method_transfer",
|
||||
"label": "$t:sirsoft-tosspayments.settings.method_transfer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Checkbox",
|
||||
"props": {
|
||||
"name": "method_mobile_phone",
|
||||
"label": "$t:sirsoft-tosspayments.settings.method_mobile_phone"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Checkbox",
|
||||
"props": {
|
||||
"name": "method_tosspay",
|
||||
"label": "$t:sirsoft-tosspayments.settings.method_tosspay"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Checkbox",
|
||||
"props": {
|
||||
"name": "method_kakaopay",
|
||||
"label": "$t:sirsoft-tosspayments.settings.method_kakaopay"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Checkbox",
|
||||
"props": {
|
||||
"name": "method_naverpay",
|
||||
"label": "$t:sirsoft-tosspayments.settings.method_naverpay"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Checkbox",
|
||||
"props": {
|
||||
"name": "method_payco",
|
||||
"label": "$t:sirsoft-tosspayments.settings.method_payco"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Checkbox",
|
||||
"props": {
|
||||
"name": "method_samsungpay",
|
||||
"label": "$t:sirsoft-tosspayments.settings.method_samsungpay"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "form-hint"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.errors.non_krw_method"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "virtual_account_section",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "panel-header-row"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "H3",
|
||||
"props": {
|
||||
"className": "section-heading-md"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.section_virtual_account"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "p-6 space-y-6"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "field_vbank_valid_hours",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": {
|
||||
"className": "form-label"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.vbank_valid_hours"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "number",
|
||||
"name": "vbank_valid_hours",
|
||||
"min": 1,
|
||||
"max": 2160,
|
||||
"className": "w-32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "form-hint"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.vbank_valid_hours_hint"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "field_vbank_cash_receipt_type",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": {
|
||||
"className": "form-label"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.vbank_cash_receipt_type"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"name": "vbank_cash_receipt_type",
|
||||
"options": [
|
||||
{ "value": "", "label": "$t:sirsoft-tosspayments.settings.vbank_cash_receipt_none" },
|
||||
{ "value": "소득공제", "label": "$t:sirsoft-tosspayments.settings.vbank_cash_receipt_income" },
|
||||
{ "value": "지출증빙", "label": "$t:sirsoft-tosspayments.settings.vbank_cash_receipt_expense" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "form-hint"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.vbank_cash_receipt_type_hint"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "field_use_escrow",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": {
|
||||
"className": "form-label"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.use_escrow"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"name": "use_escrow",
|
||||
"options": [
|
||||
{ "value": "off", "label": "$t:sirsoft-tosspayments.settings.use_escrow_off" },
|
||||
{ "value": "on", "label": "$t:sirsoft-tosspayments.settings.use_escrow_on" },
|
||||
{ "value": "buyer_choice", "label": "$t:sirsoft-tosspayments.settings.use_escrow_buyer_choice" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "form-hint"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.use_escrow_hint"
|
||||
},
|
||||
{
|
||||
"id": "escrow_operation_notice",
|
||||
"if": "{{_local.form?.use_escrow !== 'off'}}",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "mt-2 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-3 space-y-1"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "text-sm text-amber-800 dark:text-amber-200"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.escrow_no_partial_cancel_notice"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "text-sm text-amber-800 dark:text-amber-200"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:sirsoft-tosspayments.settings.escrow_shipping_info_notice"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "A",
|
||||
"props": {
|
||||
"href": "https://app.tosspayments.com/",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer",
|
||||
"className": "ml-1 underline font-medium hover:text-amber-900 dark:hover:text-amber-100"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.escrow_shipping_info_link"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "field_webhook_url",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "pt-2 border-t border-gray-100 dark:border-gray-700 space-y-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": {
|
||||
"className": "form-label"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.webhook_url_label"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"className": "flex flex-col items-stretch gap-2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex-1 overflow-x-auto font-mono text-sm bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded px-3 py-2 whitespace-nowrap"
|
||||
},
|
||||
"text": "/plugins/sirsoft-tosspayments/webhook/deposit"
|
||||
},
|
||||
{
|
||||
"id": "webhook_copy_button",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "btn btn-secondary btn-sm flex-shrink-0"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "btn btn-secondary btn-sm w-full"
|
||||
}
|
||||
}
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "copyToClipboard",
|
||||
"params": {
|
||||
"text": "/plugins/sirsoft-tosspayments/webhook/deposit"
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "success",
|
||||
"message": "$t:sirsoft-tosspayments.settings.webhook_url_copied"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:sirsoft-tosspayments.settings.webhook_url_copy"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "form-hint"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.webhook_url_hint"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "field_webhook_secret_verify",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex-between pt-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": {
|
||||
"className": "form-label"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.webhook_secret_verify"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "form-hint"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.webhook_secret_verify_hint"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "Toggle",
|
||||
"props": {
|
||||
"name": "webhook_secret_verify"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "footer_buttons",
|
||||
"type": "basic",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Concerns;
|
||||
|
||||
/**
|
||||
* 토스 주문서형 결제수단(toss_*) ↔ SDK method / easyPay provider / 코어 결제수단 매핑 SSoT.
|
||||
*
|
||||
* 세 리스너가 공유한다:
|
||||
* - RegisterTossPaymentMethodsListener: 활성 토글된 수단만 이커머스 결제수단 목록에 entry 로 주입
|
||||
* - RegisterPgProviderListener::getClientConfig: enabled_methods 를 프론트 SDK 설정으로 내림
|
||||
* - AdjustEcommercePaymentMethodsLayoutListener: 전체 toss_* id 를 no-PG 리스트에 병합
|
||||
*
|
||||
* core 값은 체크아웃이 서버로 보낼 코어 PaymentMethodEnum 값이다. toss_* id 는 코어 enum 이
|
||||
* 거부하므로, 프론트는 이 core 값을 payment_method 로 전송하고 toss_* 선택값은 _local 에만
|
||||
* 유지해 SDK 호출 시 참조한다 (프론트 하드코딩 금지).
|
||||
*/
|
||||
trait MapsTossPaymentMethods
|
||||
{
|
||||
/**
|
||||
* toss_* entry id → { setting, method, easy_pay, core }.
|
||||
*
|
||||
* @var array<string, array{setting:string, method:string, easy_pay:?string, core:string}>
|
||||
*/
|
||||
private const TOSS_METHOD_MAP = [
|
||||
'toss_card' => ['setting' => 'method_card', 'method' => 'CARD', 'easy_pay' => null, 'core' => 'card'],
|
||||
'toss_virtual_account' => ['setting' => 'method_virtual_account', 'method' => 'VIRTUAL_ACCOUNT', 'easy_pay' => null, 'core' => 'vbank'],
|
||||
'toss_transfer' => ['setting' => 'method_transfer', 'method' => 'TRANSFER', 'easy_pay' => null, 'core' => 'bank'],
|
||||
'toss_mobile_phone' => ['setting' => 'method_mobile_phone', 'method' => 'MOBILE_PHONE', 'easy_pay' => null, 'core' => 'phone'],
|
||||
'toss_tosspay' => ['setting' => 'method_tosspay', 'method' => 'CARD', 'easy_pay' => '토스페이', 'core' => 'card'],
|
||||
'toss_kakaopay' => ['setting' => 'method_kakaopay', 'method' => 'CARD', 'easy_pay' => '카카오페이', 'core' => 'card'],
|
||||
'toss_naverpay' => ['setting' => 'method_naverpay', 'method' => 'CARD', 'easy_pay' => '네이버페이', 'core' => 'card'],
|
||||
'toss_payco' => ['setting' => 'method_payco', 'method' => 'CARD', 'easy_pay' => '페이코', 'core' => 'card'],
|
||||
'toss_samsungpay' => ['setting' => 'method_samsungpay', 'method' => 'CARD', 'easy_pay' => '삼성페이', 'core' => 'card'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 등록 가능한 전체 toss_* 결제수단 id 목록을 선언 순서대로 반환합니다 (활성 토글 무관).
|
||||
*
|
||||
* @return list<string> 전체 toss_* id 목록
|
||||
*/
|
||||
protected function allTossMethodIds(): array
|
||||
{
|
||||
return array_keys(self::TOSS_METHOD_MAP);
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 설정에서 활성 토글된 toss_* 결제수단 id 목록을 선언 순서대로 반환합니다.
|
||||
*
|
||||
* @param array<string, mixed> $settings 플러그인 설정값
|
||||
* @return list<string> 활성 toss_* id 목록
|
||||
*/
|
||||
protected function enabledTossMethodIds(array $settings): array
|
||||
{
|
||||
$enabled = [];
|
||||
foreach (self::TOSS_METHOD_MAP as $id => $meta) {
|
||||
if ((bool) ($settings[$meta['setting']] ?? false)) {
|
||||
$enabled[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
return $enabled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Concerns;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderPaymentRepositoryInterface;
|
||||
|
||||
/**
|
||||
* 토스 웹훅 replay(중복 통보) 방어 — 동일 PG 거래 ID 의 중복 콜백을 멱등 처리.
|
||||
*
|
||||
* transaction_id 컬럼에는 DB unique 제약이 없으므로 동일 거래로 웹훅이 두 번 도착하면
|
||||
* completePayment 가 두 번 실행되어 중복 적립·알림이 발생할 수 있다. 웹훅 진입 시점에
|
||||
* 이미 PAID 상태인지 확인하고, 그렇다면 멱등 200 으로 조기 리턴하도록 한다.
|
||||
*
|
||||
* OrderPayment 접근은 이커머스 모듈의 Repository 를 경유한다(모델 정적 쿼리 금지).
|
||||
*/
|
||||
trait PreventsReplayCallback
|
||||
{
|
||||
/**
|
||||
* 동일 transaction_id 가 이미 PAID 상태로 저장되었는지 확인.
|
||||
*
|
||||
* @param string|null $transactionId PG 거래 ID (토스: paymentKey)
|
||||
* @return bool true 면 중복 콜백 — 멱등 응답으로 처리해야 함
|
||||
*/
|
||||
protected function wasAlreadyPaid(?string $transactionId): bool
|
||||
{
|
||||
return app(OrderPaymentRepositoryInterface::class)->isTransactionPaid($transactionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay 감지를 로깅. 운영 모니터링을 위한 통일 로그 형식.
|
||||
*
|
||||
* @param string $transactionId PG 거래 ID
|
||||
* @param string|null $orderId 주문번호 (있을 경우)
|
||||
* @param string $context 콜백 종류
|
||||
*/
|
||||
protected function logReplayDetected(string $transactionId, ?string $orderId, string $context): void
|
||||
{
|
||||
Log::info('TossPayments: replay detected — already paid, returning idempotent response', [
|
||||
'transaction_id' => $transactionId,
|
||||
'orderId' => $orderId,
|
||||
'context' => $context,
|
||||
]);
|
||||
}
|
||||
}
|
||||
+79
-8
@@ -4,11 +4,14 @@ namespace Plugins\Sirsoft\Tosspayments\Controllers;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Services\PluginSettingsService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
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\Tosspayments\Http\Requests\FailCallbackRequest;
|
||||
use Plugins\Sirsoft\Tosspayments\Http\Requests\SuccessCallbackRequest;
|
||||
@@ -66,7 +69,7 @@ class PaymentCallbackController
|
||||
* GET /plugins/sirsoft-tosspayments/payment/success
|
||||
* ?paymentKey={PK}&orderId={OID}&amount={AMT}
|
||||
*
|
||||
* @param SuccessCallbackRequest $request 검증된 콜백 요청
|
||||
* @param SuccessCallbackRequest $request 검증된 콜백 요청
|
||||
* @return RedirectResponse SPA 페이지로 리다이렉트
|
||||
*/
|
||||
public function success(SuccessCallbackRequest $request): RedirectResponse
|
||||
@@ -93,7 +96,16 @@ class PaymentCallbackController
|
||||
|
||||
HookManager::doAction('sirsoft-tosspayments.payment.after_confirm', $order, $pgResponse);
|
||||
|
||||
// 3. 주문 상태 업데이트 (금액 검증 + PG 응답 → order_payments 매핑)
|
||||
// 3-a. 가상계좌 발급 — 아직 입금 전이므로 completePayment 하지 않고 계좌 정보만 저장한다.
|
||||
// 실제 결제완료는 입금통보 웹훅(DEPOSIT_CALLBACK, status=DONE)에서 처리한다.
|
||||
// 웹훅 secret 은 이 confirm 응답에만 내려오므로 payment_meta 에 저장해 대조에 쓴다.
|
||||
if (($pgResponse['status'] ?? null) === 'WAITING_FOR_DEPOSIT') {
|
||||
$this->handleVirtualAccountIssued($order, $pgResponse, $paymentKey, $request);
|
||||
|
||||
return redirect($this->resolveSuccessUrl($orderId));
|
||||
}
|
||||
|
||||
// 3-b. 즉시 결제완료(카드/계좌이체/휴대폰/간편결제) — 기존 경로
|
||||
$this->orderService->completePayment($order, [
|
||||
'transaction_id' => $pgResponse['paymentKey'] ?? null,
|
||||
'card_approval_number' => $pgResponse['card']['approveNo'] ?? null,
|
||||
@@ -147,7 +159,7 @@ class PaymentCallbackController
|
||||
* GET /plugins/sirsoft-tosspayments/payment/fail
|
||||
* ?code={ERR}&message={MSG}&orderId={OID}
|
||||
*
|
||||
* @param FailCallbackRequest $request 검증된 콜백 요청
|
||||
* @param FailCallbackRequest $request 검증된 콜백 요청
|
||||
* @return RedirectResponse SPA 체크아웃 페이지로 리다이렉트
|
||||
*/
|
||||
public function fail(FailCallbackRequest $request): RedirectResponse
|
||||
@@ -178,10 +190,69 @@ class PaymentCallbackController
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 가상계좌 발급 처리 — completePayment 없이 계좌 정보와 웹훅 secret 만 저장한다.
|
||||
*
|
||||
* 토스 confirm 응답의 status 가 WAITING_FOR_DEPOSIT 이면 호출된다. 실제 결제완료는
|
||||
* 입금통보 웹훅(deposit)에서 처리한다. secret 은 이 응답에만 내려오므로 payment_meta 에
|
||||
* 저장해 웹훅 위조 방지 대조(hash_equals)에 사용한다.
|
||||
*
|
||||
* @param Order $order 대상 주문
|
||||
* @param array<string, mixed> $pgResponse 토스 confirm 응답
|
||||
* @param string $paymentKey 토스 결제 키
|
||||
* @param Request $request HTTP 요청 (디바이스 판별용)
|
||||
*/
|
||||
private function handleVirtualAccountIssued(Order $order, array $pgResponse, string $paymentKey, Request $request): void
|
||||
{
|
||||
$vbank = $pgResponse['virtualAccount'] ?? [];
|
||||
|
||||
$dueAt = null;
|
||||
$dueDate = $vbank['dueDate'] ?? null;
|
||||
if (is_string($dueDate) && $dueDate !== '') {
|
||||
try {
|
||||
$dueAt = Carbon::parse($dueDate);
|
||||
} catch (\Exception) {
|
||||
$dueAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 에스크로 여부는 결제 시점 스냅샷 — 응답의 useEscrow 를 그대로 저장한다.
|
||||
$isEscrow = (bool) ($vbank['useEscrow'] ?? $pgResponse['useEscrow'] ?? false);
|
||||
|
||||
$order->payment()->update(array_filter([
|
||||
'pg_provider' => 'tosspayments',
|
||||
'payment_status' => PaymentStatusEnum::WAITING_DEPOSIT->value,
|
||||
'transaction_id' => $paymentKey ?: null,
|
||||
'vbank_code' => $vbank['bankCode'] ?? null,
|
||||
'vbank_name' => $vbank['bank'] ?? ($vbank['bankCode'] ?? null),
|
||||
'vbank_number' => $vbank['accountNumber'] ?? null,
|
||||
'vbank_holder' => $vbank['customerName'] ?? null,
|
||||
'vbank_due_at' => $dueAt,
|
||||
'vbank_issued_at' => Carbon::now(),
|
||||
'is_escrow' => $isEscrow,
|
||||
'payment_device' => DeviceDetector::detect($request),
|
||||
'payment_meta' => [
|
||||
'status' => 'WAITING_FOR_DEPOSIT',
|
||||
'method' => $pgResponse['method'] ?? null,
|
||||
// 웹훅 secret — DEPOSIT_CALLBACK 대조용 (이 응답에만 존재)
|
||||
'toss_secret' => $pgResponse['secret'] ?? null,
|
||||
'pg_raw_response' => $pgResponse,
|
||||
],
|
||||
], fn ($v) => $v !== null));
|
||||
|
||||
Log::info('TossPayments: virtual account issued', [
|
||||
'orderId' => $order->order_number,
|
||||
'paymentKey' => $paymentKey,
|
||||
'vbank_number' => $vbank['accountNumber'] ?? null,
|
||||
'vbank_due_at' => $dueAt?->toDateTimeString(),
|
||||
'is_escrow' => $isEscrow,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 발급사 코드 → 이름 변환
|
||||
*
|
||||
* @param string|null $issuerCode 발급사 코드
|
||||
* @param string|null $issuerCode 발급사 코드
|
||||
* @return string|null 카드사명
|
||||
*/
|
||||
private function resolveCardIssuer(?string $issuerCode): ?string
|
||||
@@ -196,7 +267,7 @@ class PaymentCallbackController
|
||||
/**
|
||||
* 결제 성공 리다이렉트 URL 생성
|
||||
*
|
||||
* @param string $orderId 주문번호
|
||||
* @param string $orderId 주문번호
|
||||
* @return string 리다이렉트 URL
|
||||
*/
|
||||
private function resolveSuccessUrl(string $orderId): string
|
||||
@@ -210,7 +281,7 @@ class PaymentCallbackController
|
||||
/**
|
||||
* 결제 실패 리다이렉트 URL 생성
|
||||
*
|
||||
* @param array $queryParams 쿼리 파라미터
|
||||
* @param array $queryParams 쿼리 파라미터
|
||||
* @return string 리다이렉트 URL
|
||||
*/
|
||||
private function resolveFailUrl(array $queryParams = []): string
|
||||
@@ -225,13 +296,13 @@ class PaymentCallbackController
|
||||
$query = http_build_query(array_filter($queryParams));
|
||||
$separator = str_contains($baseUrl, '?') ? '&' : '?';
|
||||
|
||||
return $baseUrl . $separator . $query;
|
||||
return $baseUrl.$separator.$query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 결제 디바이스 판별 (User-Agent 기반)
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @param Request $request HTTP 요청
|
||||
* @return string 디바이스 유형 (pc/mobile)
|
||||
*/
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Controllers;
|
||||
|
||||
use App\Services\PluginSettingsService;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
|
||||
use Plugins\Sirsoft\Tosspayments\Concerns\PreventsReplayCallback;
|
||||
use Plugins\Sirsoft\Tosspayments\Http\Requests\DepositWebhookRequest;
|
||||
use Plugins\Sirsoft\Tosspayments\Http\Requests\PaymentStatusWebhookRequest;
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 웹훅 컨트롤러.
|
||||
*
|
||||
* 가상계좌 입금통보(DEPOSIT_CALLBACK)와 결제상태 변경(PAYMENT_STATUS_CHANGED)을 처리한다.
|
||||
* 토스는 notify IP 목록·서명을 제공하지 않으므로 공식 검증 수단은 secret 대조뿐이다.
|
||||
* 미응답 시 최대 7회 재전송하므로 부수 작업은 훅 리스너(completePayment 내부)에 위임하고
|
||||
* 컨트롤러는 빠르게 200 을 반환한다.
|
||||
*/
|
||||
class WebhookController
|
||||
{
|
||||
use PreventsReplayCallback;
|
||||
|
||||
private const PLUGIN_IDENTIFIER = 'sirsoft-tosspayments';
|
||||
|
||||
public function __construct(
|
||||
private OrderProcessingService $orderService,
|
||||
private PluginSettingsService $pluginSettingsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 가상계좌 입금통보 웹훅.
|
||||
*
|
||||
* POST /plugins/sirsoft-tosspayments/webhook/deposit
|
||||
*
|
||||
* status=DONE → completePayment(). status=CANCELED → failPayment().
|
||||
*
|
||||
* @param DepositWebhookRequest $request 검증된 웹훅 요청
|
||||
* @return Response 토스에 반환할 처리 결과 (항상 text/plain)
|
||||
*/
|
||||
public function deposit(DepositWebhookRequest $request): Response
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$orderId = (string) $validated['orderId'];
|
||||
$status = (string) $validated['status'];
|
||||
$secret = (string) ($validated['secret'] ?? '');
|
||||
$transactionKey = (string) ($validated['transactionKey'] ?? '');
|
||||
|
||||
Log::info('TossPayments: deposit webhook received', [
|
||||
'orderId' => $orderId,
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
try {
|
||||
$order = $this->orderService->findByOrderNumber($orderId);
|
||||
|
||||
if (! $order) {
|
||||
Log::error('TossPayments: deposit webhook - order not found', ['orderId' => $orderId]);
|
||||
|
||||
return $this->plain('FAIL');
|
||||
}
|
||||
|
||||
$order->load('payment');
|
||||
$payment = $order->payment;
|
||||
|
||||
if (! $payment) {
|
||||
Log::error('TossPayments: deposit webhook - payment not found', ['orderId' => $orderId]);
|
||||
|
||||
return $this->plain('FAIL');
|
||||
}
|
||||
|
||||
// 1. 리플레이 — 이미 결제완료된 거래이면 멱등 200
|
||||
if ($this->wasAlreadyPaid($payment->transaction_id)) {
|
||||
$this->logReplayDetected((string) $payment->transaction_id, $orderId, 'deposit webhook');
|
||||
|
||||
return $this->plain('OK');
|
||||
}
|
||||
|
||||
// 2. secret 대조 (위조 방지) — 설정으로 강제 시에만
|
||||
$storedSecret = (string) ($payment->payment_meta['toss_secret'] ?? '');
|
||||
if ($this->shouldVerifySecret() && ! $this->secretMatches($storedSecret, $secret)) {
|
||||
Log::warning('TossPayments: deposit webhook secret mismatch', [
|
||||
'orderId' => $orderId,
|
||||
]);
|
||||
|
||||
return $this->plain('UNAUTHORIZED', 401);
|
||||
}
|
||||
|
||||
// 3. 저장 컨텍스트 검증 — 입금대기 상태여야 함
|
||||
$paymentStatus = $payment->payment_status instanceof PaymentStatusEnum
|
||||
? $payment->payment_status->value
|
||||
: (string) $payment->payment_status;
|
||||
|
||||
if ($paymentStatus !== PaymentStatusEnum::WAITING_DEPOSIT->value) {
|
||||
Log::warning('TossPayments: deposit webhook - not waiting for deposit', [
|
||||
'orderId' => $orderId,
|
||||
'payment_status' => $paymentStatus,
|
||||
]);
|
||||
|
||||
// 이미 처리됐거나 실패한 주문 — 재전송을 멈추도록 200 으로 응답
|
||||
return $this->plain('OK');
|
||||
}
|
||||
|
||||
// 4. 상태별 처리 (부수 작업은 completePayment/failPayment 내부 훅에 위임)
|
||||
if ($status === 'DONE') {
|
||||
$this->orderService->completePayment($order, [
|
||||
'transaction_id' => $transactionKey ?: $payment->transaction_id,
|
||||
'payment_meta' => array_merge($payment->payment_meta ?? [], [
|
||||
'deposit_confirmed_at' => $validated['createdAt'] ?? null,
|
||||
'webhook_status' => 'DONE',
|
||||
]),
|
||||
], (int) $payment->paid_amount_base ?: null);
|
||||
|
||||
Log::info('TossPayments: deposit confirmed', ['orderId' => $orderId]);
|
||||
|
||||
return $this->plain('OK');
|
||||
}
|
||||
|
||||
// status === CANCELED → 입금 취소
|
||||
$this->orderService->failPayment($order, 'DEPOSIT_CANCELED', 'Virtual account deposit canceled');
|
||||
|
||||
Log::info('TossPayments: deposit canceled', ['orderId' => $orderId]);
|
||||
|
||||
return $this->plain('OK');
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('TossPayments: deposit webhook failed', [
|
||||
'orderId' => $orderId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return $this->plain('FAIL');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 결제상태 변경 웹훅 — 상태 동기화 로깅 (불일치 시 경고).
|
||||
*
|
||||
* POST /plugins/sirsoft-tosspayments/webhook/payment-status
|
||||
*
|
||||
* @param PaymentStatusWebhookRequest $request 검증된 웹훅 요청
|
||||
* @return Response 토스에 반환할 처리 결과
|
||||
*/
|
||||
public function paymentStatus(PaymentStatusWebhookRequest $request): Response
|
||||
{
|
||||
$data = $request->validated()['data'];
|
||||
$orderId = (string) $data['orderId'];
|
||||
$pgStatus = (string) $data['status'];
|
||||
|
||||
Log::info('TossPayments: payment-status webhook received', [
|
||||
'orderId' => $orderId,
|
||||
'status' => $pgStatus,
|
||||
]);
|
||||
|
||||
$order = $this->orderService->findByOrderNumber($orderId);
|
||||
|
||||
if (! $order) {
|
||||
Log::warning('TossPayments: payment-status webhook - order not found', ['orderId' => $orderId]);
|
||||
|
||||
return $this->plain('OK');
|
||||
}
|
||||
|
||||
$order->load('payment');
|
||||
$localStatus = $order->payment?->payment_status;
|
||||
$localStatusValue = $localStatus instanceof PaymentStatusEnum ? $localStatus->value : (string) $localStatus;
|
||||
|
||||
Log::info('TossPayments: payment-status synced', [
|
||||
'orderId' => $orderId,
|
||||
'toss_status' => $pgStatus,
|
||||
'local_status' => $localStatusValue,
|
||||
]);
|
||||
|
||||
return $this->plain('OK');
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹훅 secret 대조 강제 여부.
|
||||
*/
|
||||
private function shouldVerifySecret(): bool
|
||||
{
|
||||
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
|
||||
|
||||
return (bool) ($settings['webhook_secret_verify'] ?? true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹훅 secret 이 저장된 confirm 응답 secret 과 일치하는지 확인.
|
||||
*
|
||||
* @param string $storedSecret 결제 승인 응답에서 저장한 secret (payment_meta.toss_secret)
|
||||
* @param string $incomingSecret 웹훅 본문의 secret
|
||||
*/
|
||||
private function secretMatches(string $storedSecret, string $incomingSecret): bool
|
||||
{
|
||||
if ($storedSecret === '' || $incomingSecret === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hash_equals($storedSecret, $incomingSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* text/plain 응답 생성 (토스 웹훅 규약).
|
||||
*
|
||||
* @param string $body 응답 본문
|
||||
* @param int $status HTTP 상태 코드
|
||||
*/
|
||||
private function plain(string $body, int $status = 200): Response
|
||||
{
|
||||
return response($body, $status)->header('Content-Type', 'text/plain');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 가상계좌 입금통보(DEPOSIT_CALLBACK) 웹훅 요청 검증.
|
||||
*
|
||||
* 본문 예시:
|
||||
* { "createdAt": "...", "secret": "...", "status": "DONE",
|
||||
* "transactionKey": "...", "orderId": "..." }
|
||||
*
|
||||
* status 는 입금완료(DONE) / 입금취소(CANCELED) 두 값이 유효하다. secret 대조는
|
||||
* 저장된 payment_meta.toss_secret 과 컨트롤러에서 수행하므로 여기서는 형식만 검증한다.
|
||||
*/
|
||||
class DepositWebhookRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 인가는 미들웨어 체인에 위임한다 (웹훅은 CSRF 면제 공개 엔드포인트).
|
||||
*
|
||||
* @return bool 항상 true (인가는 secret 대조로 컨트롤러에서 수행)
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 규칙 반환.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'orderId' => ['required', 'string', 'max:100'],
|
||||
'status' => ['required', 'string', 'in:DONE,CANCELED'],
|
||||
'secret' => ['nullable', 'string', 'max:255'],
|
||||
'transactionKey' => ['nullable', 'string', 'max:255'],
|
||||
'createdAt' => ['nullable', 'string', 'max:64'],
|
||||
];
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 결제상태 변경(PAYMENT_STATUS_CHANGED) 웹훅 요청 검증.
|
||||
*
|
||||
* 본문 예시:
|
||||
* { "eventType": "PAYMENT_STATUS_CHANGED", "createdAt": "...",
|
||||
* "data": { "orderId": "...", "status": "...", "paymentKey": "..." } }
|
||||
*
|
||||
* 상태 동기화(로깅 + 불일치 경고)만 수행하므로 data 하위 필드는 nullable 로 둔다.
|
||||
*/
|
||||
class PaymentStatusWebhookRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 인가는 미들웨어 체인에 위임한다 (웹훅은 CSRF 면제 공개 엔드포인트).
|
||||
*
|
||||
* @return bool 항상 true (상태 동기화 로깅 전용 엔드포인트)
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 규칙 반환.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'eventType' => ['nullable', 'string', 'max:64'],
|
||||
'createdAt' => ['nullable', 'string', 'max:64'],
|
||||
'data' => ['required', 'array'],
|
||||
'data.orderId' => ['required', 'string', 'max:100'],
|
||||
'data.status' => ['required', 'string', 'max:40'],
|
||||
'data.paymentKey' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use Plugins\Sirsoft\Tosspayments\Concerns\MapsTossPaymentMethods;
|
||||
|
||||
/**
|
||||
* 이커머스 결제수단 설정 화면에서 토스 주문서형 결제수단을 "PG 선택 불필요" 항목으로 표시한다.
|
||||
*
|
||||
* admin_ecommerce_settings 레이아웃의 여러 표현식에 등장하는 no-PG 결제수단 리스트 리터럴
|
||||
* (`['point','deposit','free','dbank', ...]`) 에 활성 토글된 toss_* id 를 추가한다.
|
||||
*
|
||||
* KG 플러그인이 같은 리스트를 상수 치환 방식으로 이미 재작성하므로, 두 플러그인이 동시
|
||||
* 활성일 때 서로의 결과를 덮어쓰지 않도록 "현재 리스트에 없는 id 만 append" 하는 멱등
|
||||
* 구현으로 작성한다 (KG 의 통짜 상수 치환을 복제하면 KG id 가 소실된다).
|
||||
*/
|
||||
class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterface
|
||||
{
|
||||
use MapsTossPaymentMethods;
|
||||
|
||||
private const TARGET_LAYOUT = 'admin_ecommerce_settings';
|
||||
|
||||
/**
|
||||
* no-PG 결제수단 리스트 리터럴의 앵커 — 코어가 항상 이 4종을 이 순서로 연다.
|
||||
* KG 가 먼저 실행되면 뒤에 kginicis_* 가 append 되어 있을 수 있으므로, 여는 대괄호부터
|
||||
* 닫는 대괄호까지 통째로 캡처해 그 안에 없는 toss_* id 만 추가한다.
|
||||
*/
|
||||
private const LIST_PATTERN = "/\\['point','deposit','free','dbank'([^\\]]*)\\]/";
|
||||
|
||||
/**
|
||||
* 구독할 훅 매핑 반환.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'core.layout_extension.after_apply' => [
|
||||
'method' => 'markTossMethodsAsPgNotRequired',
|
||||
'type' => 'filter',
|
||||
// KG(20) 보다 반드시 뒤에 실행되어야 한다 — KG 는 닫는 대괄호까지 포함한 통짜
|
||||
// 리터럴을 str_replace 하므로, 토스가 먼저 append 하면 KG 의 매치가 실패해
|
||||
// kginicis_* 가 영영 주입되지 않는다. HookManager 는 ksort 오름차순이므로
|
||||
// 30 > 20 이 "KG 먼저" 를 불변식으로 고정한다 (플러그인 로드 순서 비의존).
|
||||
'priority' => 30,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 핸들러 (미사용).
|
||||
*
|
||||
* @param mixed ...$args
|
||||
*/
|
||||
public function handle(...$args): void {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $layout 적용된 레이아웃
|
||||
* @param int $templateId 대상 템플릿 ID
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function markTossMethodsAsPgNotRequired(array $layout, int $templateId): array
|
||||
{
|
||||
if (($layout['layout_name'] ?? '') !== self::TARGET_LAYOUT) {
|
||||
return $layout;
|
||||
}
|
||||
|
||||
return $this->appendTossMethodsToNoPgLists($layout);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function appendTossMethodsToNoPgLists(array $node): array
|
||||
{
|
||||
foreach ($node as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$node[$key] = $this->appendTossMethodsToNoPgLists($value);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($value) && str_contains($value, "'dbank'")) {
|
||||
$node[$key] = $this->mergeTossIds($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $node;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열 내 no-PG 리스트 리터럴에 없는 toss_* id 만 append 합니다 (멱등).
|
||||
*/
|
||||
private function mergeTossIds(string $expression): string
|
||||
{
|
||||
// 결제수단 id SSoT 는 MapsTossPaymentMethods::TOSS_METHOD_MAP 이다 (하드코딩 이중화 금지).
|
||||
$tossIds = $this->allTossMethodIds();
|
||||
|
||||
return (string) preg_replace_callback(
|
||||
self::LIST_PATTERN,
|
||||
function (array $matches) use ($tossIds): string {
|
||||
$existing = $matches[1]; // 예: ",'kginicis_samsung_pay',..." (KG 선행 시)
|
||||
$additions = '';
|
||||
foreach ($tossIds as $id) {
|
||||
if (! str_contains($existing, "'{$id}'")) {
|
||||
$additions .= ",'{$id}'";
|
||||
}
|
||||
}
|
||||
|
||||
return "['point','deposit','free','dbank'{$existing}{$additions}]";
|
||||
},
|
||||
$expression,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace Plugins\Sirsoft\Tosspayments\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use Plugins\Sirsoft\Tosspayments\Concerns\MapsTossPaymentMethods;
|
||||
|
||||
/**
|
||||
* PG 제공자 등록 리스너
|
||||
*
|
||||
@@ -11,6 +13,8 @@ use App\Contracts\Extension\HookListenerInterface;
|
||||
*/
|
||||
class RegisterPgProviderListener implements HookListenerInterface
|
||||
{
|
||||
use MapsTossPaymentMethods;
|
||||
|
||||
private const PLUGIN_IDENTIFIER = 'sirsoft-tosspayments';
|
||||
|
||||
/**
|
||||
@@ -37,8 +41,7 @@ class RegisterPgProviderListener implements HookListenerInterface
|
||||
/**
|
||||
* 기본 핸들러 (미사용)
|
||||
*
|
||||
* @param mixed ...$args 인수
|
||||
* @return void
|
||||
* @param mixed ...$args 인수
|
||||
*/
|
||||
public function handle(...$args): void
|
||||
{
|
||||
@@ -48,7 +51,7 @@ class RegisterPgProviderListener implements HookListenerInterface
|
||||
/**
|
||||
* PG 제공자 목록에 토스페이먼츠 등록
|
||||
*
|
||||
* @param array $providers 기존 PG 제공자 목록
|
||||
* @param array $providers 기존 PG 제공자 목록
|
||||
* @return array 토스페이먼츠가 추가된 PG 제공자 목록
|
||||
*/
|
||||
public function registerProvider(array $providers): array
|
||||
@@ -58,7 +61,11 @@ class RegisterPgProviderListener implements HookListenerInterface
|
||||
'name_key' => 'sirsoft-tosspayments::provider.name',
|
||||
'name' => localized_label(nameKey: 'sirsoft-tosspayments::provider.name'),
|
||||
'icon' => 'credit-card',
|
||||
'supported_methods' => ['card'],
|
||||
'supported_methods' => ['card', 'virtual_account', 'bank_transfer', 'mobile'],
|
||||
// 프론트 결제 진입 핸들러 — HandlesOrderCreation::resolvePgPaymentHandler() 가 이 키를
|
||||
// 응답의 pg_payment_handler 로 내려 템플릿이 dispatch 한다. 미선언 시 PG 분기가
|
||||
// 발화하지 못하고 결제창 없이 완료 페이지로 navigate 하던 결함을 정공법으로 해소한다.
|
||||
'payment_handler' => 'sirsoft-tosspayments.requestPayment',
|
||||
];
|
||||
|
||||
return $providers;
|
||||
@@ -67,8 +74,8 @@ class RegisterPgProviderListener implements HookListenerInterface
|
||||
/**
|
||||
* PG 클라이언트 설정 제공 (프론트엔드 SDK용)
|
||||
*
|
||||
* @param array $config 기존 설정
|
||||
* @param string $provider PG 제공자 ID
|
||||
* @param array $config 기존 설정
|
||||
* @param string $provider PG 제공자 ID
|
||||
* @return array 클라이언트 설정
|
||||
*/
|
||||
public function getClientConfig(array $config, string $provider): array
|
||||
@@ -79,12 +86,23 @@ class RegisterPgProviderListener implements HookListenerInterface
|
||||
|
||||
$settings = $this->getPluginSettings();
|
||||
$isTest = $settings['is_test_mode'] ?? true;
|
||||
$orderSheetMode = (bool) ($settings['order_sheet_mode'] ?? false);
|
||||
|
||||
return array_merge($config, [
|
||||
'client_key' => $isTest
|
||||
? ($settings['test_client_key'] ?? '')
|
||||
: ($settings['live_client_key'] ?? ''),
|
||||
'sdk_url' => 'https://js.tosspayments.com/v2/standard',
|
||||
'order_sheet_mode' => $orderSheetMode,
|
||||
// 활성 토글된 결제수단만 프론트에 내린다. 각 entry 에 SDK method / easyPay provider /
|
||||
// 코어 결제수단(core_payment_method) 을 함께 실어 프론트 하드코딩을 없앤다.
|
||||
// 결제창형(order_sheet_mode=false)에서는 빈 배열 — 통합결제창 카드 하나로 처리.
|
||||
'enabled_methods' => $orderSheetMode ? $this->buildEnabledMethodsConfig($settings) : [],
|
||||
'vbank' => [
|
||||
'valid_hours' => (int) ($settings['vbank_valid_hours'] ?? 24),
|
||||
'cash_receipt_type' => (string) ($settings['vbank_cash_receipt_type'] ?? ''),
|
||||
],
|
||||
'use_escrow' => (string) ($settings['use_escrow'] ?? 'off'),
|
||||
'callback_urls' => [
|
||||
'success' => '/plugins/sirsoft-tosspayments/payment/success',
|
||||
'fail' => '/plugins/sirsoft-tosspayments/payment/fail',
|
||||
@@ -92,6 +110,28 @@ class RegisterPgProviderListener implements HookListenerInterface
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 토글된 toss_* 결제수단의 프론트 SDK 설정 목록을 구성합니다.
|
||||
*
|
||||
* @param array<string, mixed> $settings 플러그인 설정값
|
||||
* @return list<array{id:string, method:string, easy_pay_provider:?string, core_payment_method:string}>
|
||||
*/
|
||||
private function buildEnabledMethodsConfig(array $settings): array
|
||||
{
|
||||
$enabled = [];
|
||||
foreach ($this->enabledTossMethodIds($settings) as $id) {
|
||||
$meta = self::TOSS_METHOD_MAP[$id];
|
||||
$enabled[] = [
|
||||
'id' => $id,
|
||||
'method' => $meta['method'],
|
||||
'easy_pay_provider' => $meta['easy_pay'],
|
||||
'core_payment_method' => $meta['core'],
|
||||
];
|
||||
}
|
||||
|
||||
return $enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 설정 조회
|
||||
*
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use Plugins\Sirsoft\Tosspayments\Concerns\MapsTossPaymentMethods;
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 주문서형 결제수단을 이커머스 결제수단 목록에 동적으로 등록한다.
|
||||
*
|
||||
* 코어 sirsoft-ecommerce.settings.filter_available_payment_methods 필터 훅을 구독해
|
||||
* builtin 결제수단 배열의 'phone' 뒤, 'point' 앞에 활성 토글된 toss_* 결제수단을 삽입한다.
|
||||
*
|
||||
* order_sheet_mode 가 false 면 아무것도 주입하지 않는다 — 결제창형에서는 기존 card 하나로
|
||||
* 통합결제창이 뜬다. 각 entry 의 defaults.pg_provider 는 null(PG 선택 불필요)이며,
|
||||
* defaults.core_payment_method 로 체크아웃이 서버에 보낼 코어 PaymentMethodEnum 값을 선언한다.
|
||||
*/
|
||||
class RegisterTossPaymentMethodsListener implements HookListenerInterface
|
||||
{
|
||||
use MapsTossPaymentMethods;
|
||||
|
||||
private const PLUGIN_IDENTIFIER = 'sirsoft-tosspayments';
|
||||
|
||||
/**
|
||||
* toss_* id → 아이콘·다국어 키.
|
||||
*
|
||||
* @var array<string, array{icon:string}>
|
||||
*/
|
||||
private const METHOD_PRESENTATION = [
|
||||
'toss_card' => ['icon' => 'credit-card'],
|
||||
'toss_virtual_account' => ['icon' => 'building-columns'],
|
||||
'toss_transfer' => ['icon' => 'money-bill-transfer'],
|
||||
'toss_mobile_phone' => ['icon' => 'mobile-screen-button'],
|
||||
'toss_tosspay' => ['icon' => 'wallet'],
|
||||
'toss_kakaopay' => ['icon' => 'wallet'],
|
||||
'toss_naverpay' => ['icon' => 'wallet'],
|
||||
'toss_payco' => ['icon' => 'wallet'],
|
||||
'toss_samsungpay' => ['icon' => 'mobile-screen-button'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 구독할 훅 매핑 반환.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'sirsoft-ecommerce.settings.filter_available_payment_methods' => [
|
||||
'method' => 'injectTossMethods',
|
||||
'type' => 'filter',
|
||||
'priority' => 20,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 핸들러 (미사용).
|
||||
*
|
||||
* @param mixed ...$args
|
||||
*/
|
||||
public function handle(...$args): void {}
|
||||
|
||||
/**
|
||||
* 이커머스 결제수단 목록에 토스 주문서형 결제수단 inject.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $methods builtin 결제수단 배열
|
||||
* @return array<int, array<string, mixed>> toss_* entry 가 phone~point 사이에 삽입된 배열
|
||||
*/
|
||||
public function injectTossMethods(array $methods): array
|
||||
{
|
||||
$settings = $this->getPluginSettings();
|
||||
|
||||
// order_sheet_mode OFF → 결제창형: 아무것도 주입하지 않고 원본 그대로 반환
|
||||
if (! (bool) ($settings['order_sheet_mode'] ?? false)) {
|
||||
return $methods;
|
||||
}
|
||||
|
||||
$tossMethods = [];
|
||||
foreach ($this->enabledTossMethodIds($settings) as $id) {
|
||||
$tossMethods[] = $this->buildEntry($id);
|
||||
}
|
||||
|
||||
if ($tossMethods === []) {
|
||||
return $methods;
|
||||
}
|
||||
|
||||
// 'phone' 뒤, 'point' 앞에 삽입. phone 이 없으면 끝에 append (KG 와 동일 규칙).
|
||||
$insertAfter = null;
|
||||
foreach ($methods as $index => $method) {
|
||||
if (($method['id'] ?? null) === 'phone') {
|
||||
$insertAfter = $index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($insertAfter === null) {
|
||||
return array_merge($methods, $tossMethods);
|
||||
}
|
||||
|
||||
return array_merge(
|
||||
array_slice($methods, 0, $insertAfter + 1),
|
||||
$tossMethods,
|
||||
array_slice($methods, $insertAfter + 1),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결제수단 entry 1건 빌더 — EcommerceSettingsService::getBuiltinPaymentMethods 와 동일 형식.
|
||||
*
|
||||
* @param string $id toss_* 결제수단 id
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildEntry(string $id): array
|
||||
{
|
||||
$nameKey = "sirsoft-tosspayments::payment_methods.{$id}.name";
|
||||
$descriptionKey = "sirsoft-tosspayments::payment_methods.{$id}.description";
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'name' => [
|
||||
'ko' => __($nameKey, [], 'ko'),
|
||||
'en' => __($nameKey, [], 'en'),
|
||||
],
|
||||
'description' => [
|
||||
'ko' => __($descriptionKey, [], 'ko'),
|
||||
'en' => __($descriptionKey, [], 'en'),
|
||||
],
|
||||
'icon' => self::METHOD_PRESENTATION[$id]['icon'] ?? 'credit-card',
|
||||
'source' => 'plugin:sirsoft-tosspayments',
|
||||
'defaults' => [
|
||||
// PG 선택 불필요 — payment_handler 정공법으로 토스 결제 흐름이 발화한다.
|
||||
'pg_provider' => null,
|
||||
'is_active' => false,
|
||||
'min_order_amount' => 0,
|
||||
'stock_deduction_timing' => 'payment_complete',
|
||||
// 체크아웃이 서버로 보낼 코어 PaymentMethodEnum 값. 코어 enum 은 toss_* 를 거부하므로
|
||||
// 프론트는 이 값을 payment_method 로 전송하고 toss_* 는 _local 에만 유지한다.
|
||||
'core_payment_method' => self::TOSS_METHOD_MAP[$id]['core'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 설정 조회.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getPluginSettings(): array
|
||||
{
|
||||
if (! \function_exists('plugin_settings')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return \plugin_settings(self::PLUGIN_IDENTIFIER);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 플러그인 설정의 서버측 범위 검증.
|
||||
*
|
||||
* 설정 UI 의 input[max] · Select 옵션은 클라이언트 힌트일 뿐이라, 관리자 설정 저장 API 를
|
||||
* 직접 호출하면 범위 밖 값이 그대로 저장된다. 저장된 잘못된 값은 결제창 호출 시점에
|
||||
* 토스가 거부하므로(가상계좌 유효시간 초과 등) 저장 단계에서 차단한다.
|
||||
*
|
||||
* KG 의 ValidateCbtSettingsListener 와 동일하게 core.plugin_settings.before_save 훅을 구독한다.
|
||||
*/
|
||||
class ValidateTossSettingsListener implements HookListenerInterface
|
||||
{
|
||||
private const PLUGIN_IDENTIFIER = 'sirsoft-tosspayments';
|
||||
|
||||
/** 토스 가상계좌 유효시간 하한 (시간) */
|
||||
private const VBANK_HOURS_MIN = 1;
|
||||
|
||||
/** 토스 가상계좌 유효시간 상한 (시간) — 90일 */
|
||||
private const VBANK_HOURS_MAX = 2160;
|
||||
|
||||
/** 에스크로 사용 3-상태 (buyer_choice = 결제창에서 구매자가 선택) */
|
||||
private const USE_ESCROW_VALUES = ['off', 'on', 'buyer_choice'];
|
||||
|
||||
/**
|
||||
* 구독할 훅 매핑 반환.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'core.plugin_settings.before_save' => [
|
||||
'method' => 'validateBeforeSave',
|
||||
'priority' => 10,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 핸들러 (미사용).
|
||||
*
|
||||
* @param mixed ...$args
|
||||
*/
|
||||
public function handle(...$args): void {}
|
||||
|
||||
/**
|
||||
* 설정 저장 전 범위 검증. 위반 시 ValidationException 으로 422 응답을 유도한다.
|
||||
*
|
||||
* 전송된 키만 검증한다 — 부분 저장(다른 섹션만 전송)에서 미포함 키를 강제하지 않는다.
|
||||
*
|
||||
* @param string $identifier 저장 대상 플러그인 식별자
|
||||
* @param array<string, mixed> $settings 저장 요청 설정값
|
||||
*
|
||||
* @throws ValidationException 범위를 벗어난 값이 있을 때
|
||||
*/
|
||||
public function validateBeforeSave(string $identifier, array $settings): void
|
||||
{
|
||||
if ($identifier !== self::PLUGIN_IDENTIFIER) {
|
||||
return;
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
|
||||
if (array_key_exists('vbank_valid_hours', $settings)) {
|
||||
$hours = $settings['vbank_valid_hours'];
|
||||
|
||||
if (! is_numeric($hours)
|
||||
|| (int) $hours < self::VBANK_HOURS_MIN
|
||||
|| (int) $hours > self::VBANK_HOURS_MAX
|
||||
) {
|
||||
$errors['vbank_valid_hours'][] = __(
|
||||
'sirsoft-tosspayments::messages.settings_validation.vbank_valid_hours_range',
|
||||
['min' => self::VBANK_HOURS_MIN, 'max' => self::VBANK_HOURS_MAX],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('use_escrow', $settings)
|
||||
&& ! in_array((string) $settings['use_escrow'], self::USE_ESCROW_VALUES, true)
|
||||
) {
|
||||
$errors['use_escrow'][] = __('sirsoft-tosspayments::messages.settings_validation.use_escrow_invalid');
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Plugins\Sirsoft\Tosspayments\Controllers\PaymentCallbackController;
|
||||
use Plugins\Sirsoft\Tosspayments\Controllers\WebhookController;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -20,3 +22,16 @@ Route::get('/payment/success', [PaymentCallbackController::class, 'success'])
|
||||
// 결제 실패 콜백 (토스페이먼츠 → 브라우저 리다이렉트)
|
||||
Route::get('/payment/fail', [PaymentCallbackController::class, 'fail'])
|
||||
->name('payment.fail');
|
||||
|
||||
// 웹훅 (토스페이먼츠 서버 → POST). 토스는 CSRF 토큰을 보내지 않으므로 면제.
|
||||
// 서명·IP 화이트리스트가 없어 secret 대조로 위조를 방지한다.
|
||||
Route::withoutMiddleware([ValidateCsrfToken::class])
|
||||
->group(function () {
|
||||
// 가상계좌 입금통보 (DEPOSIT_CALLBACK)
|
||||
Route::post('/webhook/deposit', [WebhookController::class, 'deposit'])
|
||||
->name('webhook.deposit');
|
||||
|
||||
// 결제상태 변경 (PAYMENT_STATUS_CHANGED)
|
||||
Route::post('/webhook/payment-status', [WebhookController::class, 'paymentStatus'])
|
||||
->name('webhook.payment-status');
|
||||
});
|
||||
+114
-24
@@ -3,6 +3,7 @@
|
||||
namespace Plugins\Sirsoft\Tosspayments\Tests\Feature\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\PluginSettingsService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
|
||||
@@ -14,16 +15,20 @@ use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 결제 콜백 컨트롤러 기능 테스트
|
||||
*
|
||||
* @scenario payment_method=toss_virtual_account, use_escrow=on
|
||||
*
|
||||
* @effects vbank_account_stored_without_completing, vbank_secret_stored_for_webhook,
|
||||
* is_escrow_snapshot_persisted
|
||||
*/
|
||||
class PaymentCallbackControllerTest extends PluginTestCase
|
||||
{
|
||||
/**
|
||||
* 토스페이먼츠 Confirm API 성공 응답 mock 데이터
|
||||
*
|
||||
* @param string $paymentKey 결제 키
|
||||
* @param string $orderId 주문번호
|
||||
* @param int $amount 결제 금액
|
||||
* @return array
|
||||
* @param string $paymentKey 결제 키
|
||||
* @param string $orderId 주문번호
|
||||
* @param int $amount 결제 금액
|
||||
*/
|
||||
private function makeMockConfirmResponse(string $paymentKey, string $orderId, int $amount): array
|
||||
{
|
||||
@@ -54,8 +59,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
/**
|
||||
* 테스트용 주문 + 결제 레코드 생성
|
||||
*
|
||||
* @param int $totalAmount 주문 총액
|
||||
* @return Order
|
||||
* @param int $totalAmount 주문 총액
|
||||
*/
|
||||
private function createTestOrder(int $totalAmount = 50000): Order
|
||||
{
|
||||
@@ -63,7 +67,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
|
||||
$order = OrderFactory::new()->create([
|
||||
'user_id' => $user->id,
|
||||
'order_number' => 'ORD-TEST-' . random_int(10000, 99999),
|
||||
'order_number' => 'ORD-TEST-'.random_int(10000, 99999),
|
||||
'order_status' => OrderStatusEnum::PENDING_ORDER,
|
||||
'subtotal_amount' => $totalAmount,
|
||||
'total_discount_amount' => 0,
|
||||
@@ -99,7 +103,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
/**
|
||||
* 플러그인 설정을 mock합니다.
|
||||
*
|
||||
* @param array $overrides 기본 설정을 덮어쓸 값
|
||||
* @param array $overrides 기본 설정을 덮어쓸 값
|
||||
*/
|
||||
private function mockPluginSettings(array $overrides = []): void
|
||||
{
|
||||
@@ -111,11 +115,11 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
'redirect_fail_url' => '/shop/checkout',
|
||||
];
|
||||
|
||||
$settingsMock = $this->createMock(\App\Services\PluginSettingsService::class);
|
||||
$settingsMock = $this->createMock(PluginSettingsService::class);
|
||||
$settingsMock->method('get')
|
||||
->willReturn(array_merge($defaults, $overrides));
|
||||
|
||||
$this->app->instance(\App\Services\PluginSettingsService::class, $settingsMock);
|
||||
$this->app->instance(PluginSettingsService::class, $settingsMock);
|
||||
}
|
||||
|
||||
// ===== Success 콜백 테스트 =====
|
||||
@@ -137,7 +141,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
),
|
||||
]);
|
||||
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
|
||||
$response = $this->get('/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([
|
||||
'paymentKey' => $paymentKey,
|
||||
'orderId' => $order->order_number,
|
||||
'amount' => 50000,
|
||||
@@ -165,7 +169,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
{
|
||||
$this->mockPluginSettings();
|
||||
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
|
||||
$response = $this->get('/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([
|
||||
'paymentKey' => 'pk_test_xxx',
|
||||
'orderId' => 'NON_EXISTENT_ORDER',
|
||||
'amount' => 50000,
|
||||
@@ -191,7 +195,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
], 400),
|
||||
]);
|
||||
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
|
||||
$response = $this->get('/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([
|
||||
'paymentKey' => 'pk_test_fail',
|
||||
'orderId' => $order->order_number,
|
||||
'amount' => 50000,
|
||||
@@ -221,7 +225,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
),
|
||||
]);
|
||||
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
|
||||
$response = $this->get('/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([
|
||||
'paymentKey' => 'pk_test_mismatch',
|
||||
'orderId' => $order->order_number,
|
||||
'amount' => 99999,
|
||||
@@ -238,7 +242,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
*/
|
||||
public function test_fail_redirects_to_checkout_with_error_params(): void
|
||||
{
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/fail?" . http_build_query([
|
||||
$response = $this->get('/plugins/sirsoft-tosspayments/payment/fail?'.http_build_query([
|
||||
'code' => 'USER_CANCEL',
|
||||
'message' => '사용자가 취소했습니다.',
|
||||
'orderId' => 'ORD-TEST-12345',
|
||||
@@ -257,7 +261,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
{
|
||||
$order = $this->createTestOrder(30000);
|
||||
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/fail?" . http_build_query([
|
||||
$response = $this->get('/plugins/sirsoft-tosspayments/payment/fail?'.http_build_query([
|
||||
'code' => 'PAY_PROCESS_CANCELED',
|
||||
'message' => '결제가 취소되었습니다.',
|
||||
'orderId' => $order->order_number,
|
||||
@@ -279,7 +283,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
*/
|
||||
public function test_fail_handles_missing_order_id_gracefully(): void
|
||||
{
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/fail?" . http_build_query([
|
||||
$response = $this->get('/plugins/sirsoft-tosspayments/payment/fail?'.http_build_query([
|
||||
'code' => 'UNKNOWN_ERROR',
|
||||
'message' => '알 수 없는 오류',
|
||||
]));
|
||||
@@ -309,7 +313,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
),
|
||||
]);
|
||||
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
|
||||
$response = $this->get('/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([
|
||||
'paymentKey' => $paymentKey,
|
||||
'orderId' => $order->order_number,
|
||||
'amount' => 50000,
|
||||
@@ -327,7 +331,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
'redirect_fail_url' => '/custom/checkout/error',
|
||||
]);
|
||||
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/fail?" . http_build_query([
|
||||
$response = $this->get('/plugins/sirsoft-tosspayments/payment/fail?'.http_build_query([
|
||||
'code' => 'USER_CANCEL',
|
||||
'message' => '사용자가 취소했습니다.',
|
||||
'orderId' => 'ORD-TEST-99999',
|
||||
@@ -349,7 +353,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
'redirect_fail_url' => '/custom/checkout/error',
|
||||
]);
|
||||
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
|
||||
$response = $this->get('/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([
|
||||
'paymentKey' => 'pk_test_xxx',
|
||||
'orderId' => 'NON_EXISTENT_ORDER',
|
||||
'amount' => 50000,
|
||||
@@ -370,7 +374,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
'redirect_fail_url' => 'https://example.com/checkout?ref=toss',
|
||||
]);
|
||||
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/fail?" . http_build_query([
|
||||
$response = $this->get('/plugins/sirsoft-tosspayments/payment/fail?'.http_build_query([
|
||||
'code' => 'NETWORK_ERROR',
|
||||
'message' => '네트워크 오류',
|
||||
'orderId' => 'ORD-TEST-88888',
|
||||
@@ -393,7 +397,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
$this->mockPluginSettings();
|
||||
|
||||
// paymentKey 누락
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
|
||||
$response = $this->get('/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([
|
||||
'orderId' => 'ORD-TEST-12345',
|
||||
'amount' => 50000,
|
||||
]));
|
||||
@@ -409,7 +413,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
{
|
||||
$this->mockPluginSettings();
|
||||
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
|
||||
$response = $this->get('/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([
|
||||
'paymentKey' => 'pk_test_xxx',
|
||||
'orderId' => 'ORD-TEST-12345',
|
||||
'amount' => 0,
|
||||
@@ -452,7 +456,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
]);
|
||||
|
||||
$response = $this->get(
|
||||
"/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
|
||||
'/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([
|
||||
'paymentKey' => $paymentKey,
|
||||
'orderId' => $order->order_number,
|
||||
'amount' => 50000,
|
||||
@@ -466,4 +470,90 @@ class PaymentCallbackControllerTest extends PluginTestCase
|
||||
$payment->refresh();
|
||||
$this->assertEquals('mobile', $payment->payment_device);
|
||||
}
|
||||
|
||||
// ===== 가상계좌(WAITING_FOR_DEPOSIT) 분기 =====
|
||||
|
||||
/**
|
||||
* confirm 응답이 WAITING_FOR_DEPOSIT 이면 completePayment 하지 않고 계좌 정보만 저장한다.
|
||||
*/
|
||||
public function test_success_virtual_account_stores_vbank_without_completing(): void
|
||||
{
|
||||
$order = $this->createTestOrder(50000);
|
||||
$paymentKey = 'pk_test_vbank';
|
||||
$this->mockPluginSettings();
|
||||
|
||||
Http::fake([
|
||||
'api.tosspayments.com/v1/payments/confirm' => Http::response([
|
||||
'paymentKey' => $paymentKey,
|
||||
'orderId' => $order->order_number,
|
||||
'status' => 'WAITING_FOR_DEPOSIT',
|
||||
'method' => '가상계좌',
|
||||
'totalAmount' => 50000,
|
||||
'secret' => 'wsec_confirm_secret',
|
||||
'virtualAccount' => [
|
||||
'accountNumber' => '12345678901234',
|
||||
'bankCode' => '20',
|
||||
'bank' => '우리은행',
|
||||
'customerName' => '홍길동',
|
||||
'dueDate' => now()->addDay()->toIso8601String(),
|
||||
'useEscrow' => false,
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->get('/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([
|
||||
'paymentKey' => $paymentKey,
|
||||
'orderId' => $order->order_number,
|
||||
'amount' => 50000,
|
||||
]));
|
||||
|
||||
$response->assertRedirect("/shop/orders/{$order->order_number}/complete");
|
||||
|
||||
$order->refresh();
|
||||
$payment = $order->payment;
|
||||
|
||||
// 결제완료로 전이되지 않음
|
||||
$this->assertNotSame(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
||||
$this->assertSame(PaymentStatusEnum::WAITING_DEPOSIT->value, $payment->payment_status->value);
|
||||
// 계좌 정보 저장
|
||||
$this->assertSame('12345678901234', $payment->vbank_number);
|
||||
$this->assertSame('20', $payment->vbank_code);
|
||||
// 웹훅 secret 저장 (대조용)
|
||||
$this->assertSame('wsec_confirm_secret', $payment->payment_meta['toss_secret']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 가상계좌 응답의 useEscrow 가 is_escrow 스냅샷으로 저장된다.
|
||||
*/
|
||||
public function test_success_virtual_account_persists_escrow_flag(): void
|
||||
{
|
||||
$order = $this->createTestOrder(50000);
|
||||
$paymentKey = 'pk_test_vbank_escrow';
|
||||
$this->mockPluginSettings();
|
||||
|
||||
Http::fake([
|
||||
'api.tosspayments.com/v1/payments/confirm' => Http::response([
|
||||
'paymentKey' => $paymentKey,
|
||||
'orderId' => $order->order_number,
|
||||
'status' => 'WAITING_FOR_DEPOSIT',
|
||||
'method' => '가상계좌',
|
||||
'totalAmount' => 50000,
|
||||
'secret' => 'wsec_x',
|
||||
'virtualAccount' => [
|
||||
'accountNumber' => '999',
|
||||
'bankCode' => '20',
|
||||
'useEscrow' => true,
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$this->get('/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([
|
||||
'paymentKey' => $paymentKey,
|
||||
'orderId' => $order->order_number,
|
||||
'amount' => 50000,
|
||||
]))->assertRedirect("/shop/orders/{$order->order_number}/complete");
|
||||
|
||||
$order->refresh();
|
||||
$this->assertTrue((bool) $order->payment->is_escrow);
|
||||
}
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Tests\Feature\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\PluginSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
|
||||
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 Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 웹훅 컨트롤러 기능 테스트.
|
||||
*
|
||||
* 가상계좌 입금통보(deposit) — secret 대조 / 리플레이 멱등 / status 분기 / 상태 가드.
|
||||
*
|
||||
* @scenario webhook_status=done, webhook_secret=match, replay=first
|
||||
*
|
||||
* @effects deposit_done_completes_payment, deposit_canceled_fails_payment,
|
||||
* webhook_secret_verified, webhook_replay_idempotent, non_waiting_deposit_is_noop
|
||||
*/
|
||||
class WebhookControllerTest extends PluginTestCase
|
||||
{
|
||||
private const DEPOSIT_URL = '/plugins/sirsoft-tosspayments/webhook/deposit';
|
||||
|
||||
private const SECRET = 'wsec_test_secret_value';
|
||||
|
||||
/**
|
||||
* 입금대기(vbank) 주문을 생성합니다.
|
||||
*
|
||||
* @param string $secret 저장할 웹훅 secret
|
||||
* @param int $totalAmount 주문 총액
|
||||
*/
|
||||
private function createWaitingDepositOrder(string $secret = self::SECRET, int $totalAmount = 50000): Order
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$order = OrderFactory::new()->create([
|
||||
'user_id' => $user->id,
|
||||
'order_number' => 'ORD-VBANK-'.random_int(10000, 99999),
|
||||
'order_status' => OrderStatusEnum::PENDING_ORDER,
|
||||
'subtotal_amount' => $totalAmount,
|
||||
'total_discount_amount' => 0,
|
||||
'total_coupon_discount_amount' => 0,
|
||||
'total_product_coupon_discount_amount' => 0,
|
||||
'total_order_coupon_discount_amount' => 0,
|
||||
'total_code_discount_amount' => 0,
|
||||
'base_shipping_amount' => 0,
|
||||
'extra_shipping_amount' => 0,
|
||||
'shipping_discount_amount' => 0,
|
||||
'total_shipping_amount' => 0,
|
||||
'total_amount' => $totalAmount,
|
||||
'total_due_amount' => $totalAmount,
|
||||
'total_points_used_amount' => 0,
|
||||
'total_deposit_used_amount' => 0,
|
||||
'total_paid_amount' => 0,
|
||||
]);
|
||||
|
||||
OrderPaymentFactory::new()->create([
|
||||
'order_id' => $order->id,
|
||||
'payment_status' => PaymentStatusEnum::WAITING_DEPOSIT,
|
||||
'payment_method' => PaymentMethodEnum::VBANK,
|
||||
'pg_provider' => 'tosspayments',
|
||||
'transaction_id' => 'pk_test_vbank_'.$order->id,
|
||||
'paid_amount_base' => $totalAmount,
|
||||
'paid_amount_local' => $totalAmount,
|
||||
'paid_at' => null,
|
||||
'payment_meta' => ['toss_secret' => $secret, 'status' => 'WAITING_FOR_DEPOSIT'],
|
||||
]);
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 설정을 mock 합니다.
|
||||
*
|
||||
* @param array<string, mixed> $overrides
|
||||
*/
|
||||
private function mockPluginSettings(array $overrides = []): void
|
||||
{
|
||||
$mock = $this->createMock(PluginSettingsService::class);
|
||||
$mock->method('get')->willReturn(array_merge([
|
||||
'is_test_mode' => true,
|
||||
'webhook_secret_verify' => true,
|
||||
], $overrides));
|
||||
$this->app->instance(PluginSettingsService::class, $mock);
|
||||
}
|
||||
|
||||
public function test_deposit_done_completes_payment(): void
|
||||
{
|
||||
$this->mockPluginSettings();
|
||||
$order = $this->createWaitingDepositOrder();
|
||||
|
||||
$response = $this->postJson(self::DEPOSIT_URL, [
|
||||
'orderId' => $order->order_number,
|
||||
'status' => 'DONE',
|
||||
'secret' => self::SECRET,
|
||||
'transactionKey' => 'txn_abc',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$order->refresh();
|
||||
$this->assertSame(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
||||
$this->assertSame(PaymentStatusEnum::PAID->value, $order->payment->payment_status->value);
|
||||
}
|
||||
|
||||
public function test_deposit_canceled_fails_payment(): void
|
||||
{
|
||||
$this->mockPluginSettings();
|
||||
$order = $this->createWaitingDepositOrder();
|
||||
|
||||
$response = $this->postJson(self::DEPOSIT_URL, [
|
||||
'orderId' => $order->order_number,
|
||||
'status' => 'CANCELED',
|
||||
'secret' => self::SECRET,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$order->refresh();
|
||||
// failPayment 은 order_status 를 CANCELLED 로 전이한다 (payment_status 는 유지 — 코어 동작).
|
||||
$this->assertSame(OrderStatusEnum::CANCELLED, $order->order_status);
|
||||
}
|
||||
|
||||
public function test_secret_mismatch_returns_401(): void
|
||||
{
|
||||
$this->mockPluginSettings();
|
||||
$order = $this->createWaitingDepositOrder();
|
||||
|
||||
$response = $this->postJson(self::DEPOSIT_URL, [
|
||||
'orderId' => $order->order_number,
|
||||
'status' => 'DONE',
|
||||
'secret' => 'wrong_secret',
|
||||
]);
|
||||
|
||||
$response->assertStatus(401);
|
||||
$order->refresh();
|
||||
// 결제완료로 전이되지 않아야 함
|
||||
$this->assertNotSame(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
||||
}
|
||||
|
||||
public function test_secret_verification_can_be_disabled(): void
|
||||
{
|
||||
$this->mockPluginSettings(['webhook_secret_verify' => false]);
|
||||
$order = $this->createWaitingDepositOrder();
|
||||
|
||||
$response = $this->postJson(self::DEPOSIT_URL, [
|
||||
'orderId' => $order->order_number,
|
||||
'status' => 'DONE',
|
||||
'secret' => 'anything',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$order->refresh();
|
||||
$this->assertSame(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
||||
}
|
||||
|
||||
public function test_replay_after_paid_is_idempotent(): void
|
||||
{
|
||||
$this->mockPluginSettings();
|
||||
$order = $this->createWaitingDepositOrder();
|
||||
|
||||
// 1차 입금통보 → PAID
|
||||
$this->postJson(self::DEPOSIT_URL, [
|
||||
'orderId' => $order->order_number,
|
||||
'status' => 'DONE',
|
||||
'secret' => self::SECRET,
|
||||
'transactionKey' => $order->payment->transaction_id,
|
||||
])->assertOk();
|
||||
|
||||
$order->refresh();
|
||||
$paidAt = $order->payment->paid_at;
|
||||
|
||||
// 2차 (중복) 통보 → 멱등 200, 상태 불변
|
||||
$response = $this->postJson(self::DEPOSIT_URL, [
|
||||
'orderId' => $order->order_number,
|
||||
'status' => 'DONE',
|
||||
'secret' => self::SECRET,
|
||||
'transactionKey' => $order->payment->transaction_id,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$order->refresh();
|
||||
$this->assertSame(PaymentStatusEnum::PAID->value, $order->payment->payment_status->value);
|
||||
$this->assertEquals($paidAt->timestamp, $order->payment->paid_at->timestamp);
|
||||
}
|
||||
|
||||
public function test_order_not_found_returns_fail(): void
|
||||
{
|
||||
$this->mockPluginSettings();
|
||||
|
||||
$response = $this->postJson(self::DEPOSIT_URL, [
|
||||
'orderId' => 'ORD-NONEXISTENT',
|
||||
'status' => 'DONE',
|
||||
'secret' => self::SECRET,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertSame('FAIL', $response->getContent());
|
||||
}
|
||||
|
||||
public function test_invalid_status_rejected_by_validation(): void
|
||||
{
|
||||
$this->mockPluginSettings();
|
||||
$order = $this->createWaitingDepositOrder();
|
||||
|
||||
$response = $this->postJson(self::DEPOSIT_URL, [
|
||||
'orderId' => $order->order_number,
|
||||
'status' => 'BOGUS',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
|
||||
public function test_deposit_on_non_waiting_order_is_noop_200(): void
|
||||
{
|
||||
$this->mockPluginSettings();
|
||||
$order = $this->createWaitingDepositOrder();
|
||||
// 이미 PAID 로 바꿔 놓되 transaction_id 는 달라 리플레이 가드는 안 걸리게 한다
|
||||
$order->payment()->update([
|
||||
'payment_status' => PaymentStatusEnum::READY,
|
||||
]);
|
||||
|
||||
$response = $this->postJson(self::DEPOSIT_URL, [
|
||||
'orderId' => $order->order_number,
|
||||
'status' => 'DONE',
|
||||
'secret' => self::SECRET,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$order->refresh();
|
||||
// WAITING_DEPOSIT 가 아니므로 completePayment 하지 않는다
|
||||
$this->assertNotSame(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Tests\Feature\Listeners;
|
||||
|
||||
use App\Services\PluginSettingsService;
|
||||
use Plugins\Sirsoft\Tosspayments\Listeners\RegisterPgProviderListener;
|
||||
use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* RegisterPgProviderListener::getClientConfig 확장 테스트.
|
||||
*
|
||||
* order_sheet_mode / enabled_methods / vbank / use_escrow 프론트 노출을 검증한다.
|
||||
*
|
||||
* @effects enabled_methods_exposed_with_core_mapping
|
||||
*/
|
||||
class RegisterPgProviderClientConfigTest extends PluginTestCase
|
||||
{
|
||||
private RegisterPgProviderListener $listener;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->listener = new RegisterPgProviderListener;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $settings
|
||||
*/
|
||||
private function mockSettings(array $settings): void
|
||||
{
|
||||
$mock = $this->createMock(PluginSettingsService::class);
|
||||
$mock->method('get')->willReturn($settings);
|
||||
$this->app->instance(PluginSettingsService::class, $mock);
|
||||
}
|
||||
|
||||
public function test_order_sheet_off_returns_empty_enabled_methods(): void
|
||||
{
|
||||
$this->mockSettings([
|
||||
'is_test_mode' => true,
|
||||
'test_client_key' => 'ck',
|
||||
'order_sheet_mode' => false,
|
||||
'method_card' => true,
|
||||
]);
|
||||
|
||||
$config = $this->listener->getClientConfig([], 'tosspayments');
|
||||
|
||||
$this->assertFalse($config['order_sheet_mode']);
|
||||
$this->assertSame([], $config['enabled_methods']);
|
||||
}
|
||||
|
||||
public function test_order_sheet_on_returns_enabled_methods_with_mapping(): void
|
||||
{
|
||||
$this->mockSettings([
|
||||
'is_test_mode' => true,
|
||||
'test_client_key' => 'ck',
|
||||
'order_sheet_mode' => true,
|
||||
'method_card' => true,
|
||||
'method_virtual_account' => true,
|
||||
'method_kakaopay' => true,
|
||||
]);
|
||||
|
||||
$config = $this->listener->getClientConfig([], 'tosspayments');
|
||||
$byId = collect($config['enabled_methods'])->keyBy('id');
|
||||
|
||||
$this->assertTrue($config['order_sheet_mode']);
|
||||
$this->assertSame('CARD', $byId['toss_card']['method']);
|
||||
$this->assertNull($byId['toss_card']['easy_pay_provider']);
|
||||
$this->assertSame('VIRTUAL_ACCOUNT', $byId['toss_virtual_account']['method']);
|
||||
$this->assertSame('vbank', $byId['toss_virtual_account']['core_payment_method']);
|
||||
// 간편결제 → CARD + easyPay provider
|
||||
$this->assertSame('CARD', $byId['toss_kakaopay']['method']);
|
||||
$this->assertSame('카카오페이', $byId['toss_kakaopay']['easy_pay_provider']);
|
||||
$this->assertSame('card', $byId['toss_kakaopay']['core_payment_method']);
|
||||
}
|
||||
|
||||
public function test_exposes_vbank_and_escrow_config(): void
|
||||
{
|
||||
$this->mockSettings([
|
||||
'is_test_mode' => true,
|
||||
'test_client_key' => 'ck',
|
||||
'vbank_valid_hours' => 48,
|
||||
'vbank_cash_receipt_type' => '소득공제',
|
||||
'use_escrow' => 'buyer_choice',
|
||||
]);
|
||||
|
||||
$config = $this->listener->getClientConfig([], 'tosspayments');
|
||||
|
||||
$this->assertSame(48, $config['vbank']['valid_hours']);
|
||||
$this->assertSame('소득공제', $config['vbank']['cash_receipt_type']);
|
||||
$this->assertSame('buyer_choice', $config['use_escrow']);
|
||||
}
|
||||
|
||||
public function test_does_not_leak_secret_key(): void
|
||||
{
|
||||
$this->mockSettings([
|
||||
'is_test_mode' => true,
|
||||
'test_client_key' => 'ck_public',
|
||||
'test_secret_key' => 'sk_secret_should_not_leak',
|
||||
]);
|
||||
|
||||
$config = $this->listener->getClientConfig([], 'tosspayments');
|
||||
|
||||
$flattened = json_encode($config);
|
||||
$this->assertStringNotContainsString('sk_secret_should_not_leak', $flattened);
|
||||
$this->assertSame('ck_public', $config['client_key']);
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Tests\Feature\Listeners;
|
||||
|
||||
use App\Services\PluginSettingsService;
|
||||
use Plugins\Sirsoft\Tosspayments\Listeners\RegisterTossPaymentMethodsListener;
|
||||
use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* RegisterTossPaymentMethodsListener 테스트.
|
||||
*
|
||||
* order_sheet_mode / 결제수단 토글 / 삽입 위치 / core_payment_method 선언을 검증한다.
|
||||
*
|
||||
* @scenario order_sheet_mode=true, payment_method=toss_card
|
||||
*
|
||||
* @effects enabled_methods_exposed_with_core_mapping
|
||||
*/
|
||||
class RegisterTossPaymentMethodsListenerTest extends PluginTestCase
|
||||
{
|
||||
private RegisterTossPaymentMethodsListener $listener;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->listener = new RegisterTossPaymentMethodsListener;
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 설정을 mock 으로 주입합니다.
|
||||
*
|
||||
* @param array<string, mixed> $settings
|
||||
*/
|
||||
private function mockSettings(array $settings): void
|
||||
{
|
||||
$mock = $this->createMock(PluginSettingsService::class);
|
||||
$mock->method('get')->willReturn($settings);
|
||||
$this->app->instance(PluginSettingsService::class, $mock);
|
||||
}
|
||||
|
||||
/**
|
||||
* builtin 결제수단 배열 (phone / point 포함).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function builtinMethods(): array
|
||||
{
|
||||
return [
|
||||
['id' => 'card'],
|
||||
['id' => 'phone'],
|
||||
['id' => 'point'],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_subscribes_as_filter_with_priority_20(): void
|
||||
{
|
||||
$hooks = RegisterTossPaymentMethodsListener::getSubscribedHooks();
|
||||
|
||||
$this->assertArrayHasKey('sirsoft-ecommerce.settings.filter_available_payment_methods', $hooks);
|
||||
$hook = $hooks['sirsoft-ecommerce.settings.filter_available_payment_methods'];
|
||||
$this->assertSame('filter', $hook['type']);
|
||||
$this->assertSame(20, $hook['priority']);
|
||||
}
|
||||
|
||||
public function test_order_sheet_mode_off_injects_nothing(): void
|
||||
{
|
||||
$this->mockSettings(['order_sheet_mode' => false, 'method_card' => true]);
|
||||
|
||||
$result = $this->listener->injectTossMethods($this->builtinMethods());
|
||||
|
||||
$this->assertCount(3, $result);
|
||||
$this->assertSame(['card', 'phone', 'point'], array_column($result, 'id'));
|
||||
}
|
||||
|
||||
public function test_injects_only_enabled_methods(): void
|
||||
{
|
||||
$this->mockSettings([
|
||||
'order_sheet_mode' => true,
|
||||
'method_card' => true,
|
||||
'method_virtual_account' => true,
|
||||
'method_transfer' => false,
|
||||
]);
|
||||
|
||||
$result = $this->listener->injectTossMethods($this->builtinMethods());
|
||||
$ids = array_column($result, 'id');
|
||||
|
||||
$this->assertContains('toss_card', $ids);
|
||||
$this->assertContains('toss_virtual_account', $ids);
|
||||
$this->assertNotContains('toss_transfer', $ids);
|
||||
}
|
||||
|
||||
public function test_inserts_after_phone_before_point(): void
|
||||
{
|
||||
$this->mockSettings(['order_sheet_mode' => true, 'method_card' => true]);
|
||||
|
||||
$result = $this->listener->injectTossMethods($this->builtinMethods());
|
||||
$ids = array_column($result, 'id');
|
||||
|
||||
$phoneIndex = array_search('phone', $ids, true);
|
||||
$pointIndex = array_search('point', $ids, true);
|
||||
$tossIndex = array_search('toss_card', $ids, true);
|
||||
|
||||
$this->assertGreaterThan($phoneIndex, $tossIndex);
|
||||
$this->assertLessThan($pointIndex, $tossIndex);
|
||||
}
|
||||
|
||||
public function test_appends_when_phone_absent(): void
|
||||
{
|
||||
$this->mockSettings(['order_sheet_mode' => true, 'method_card' => true]);
|
||||
|
||||
$result = $this->listener->injectTossMethods([['id' => 'card'], ['id' => 'point']]);
|
||||
$ids = array_column($result, 'id');
|
||||
|
||||
// phone 이 없으면 끝에 append
|
||||
$this->assertSame('toss_card', end($ids));
|
||||
}
|
||||
|
||||
public function test_entry_declares_core_payment_method(): void
|
||||
{
|
||||
$this->mockSettings([
|
||||
'order_sheet_mode' => true,
|
||||
'method_virtual_account' => true,
|
||||
'method_kakaopay' => true,
|
||||
]);
|
||||
|
||||
$result = $this->listener->injectTossMethods($this->builtinMethods());
|
||||
$byId = collect($result)->keyBy('id');
|
||||
|
||||
// 가상계좌 → vbank, 간편결제 → card
|
||||
$this->assertSame('vbank', $byId['toss_virtual_account']['defaults']['core_payment_method']);
|
||||
$this->assertSame('card', $byId['toss_kakaopay']['defaults']['core_payment_method']);
|
||||
// PG 선택 불필요
|
||||
$this->assertNull($byId['toss_virtual_account']['defaults']['pg_provider']);
|
||||
}
|
||||
|
||||
public function test_all_toggles_off_injects_nothing(): void
|
||||
{
|
||||
$this->mockSettings(['order_sheet_mode' => true]);
|
||||
|
||||
$result = $this->listener->injectTossMethods($this->builtinMethods());
|
||||
|
||||
$this->assertCount(3, $result);
|
||||
}
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Tests\Unit\Listeners;
|
||||
|
||||
use Plugins\Sirsoft\Tosspayments\Listeners\AdjustEcommercePaymentMethodsLayoutListener;
|
||||
use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* AdjustEcommercePaymentMethodsLayoutListener 단위 테스트.
|
||||
*
|
||||
* no-PG 결제수단 리스트 리터럴에 toss_* id 를 멱등하게 병합하는지 검증한다.
|
||||
*
|
||||
* @scenario kg_coexists=true
|
||||
*
|
||||
* @effects no_pg_list_idempotent_merge_preserves_kg,
|
||||
* toss_layout_listener_runs_after_kginicis
|
||||
*/
|
||||
class AdjustEcommercePaymentMethodsLayoutListenerTest extends PluginTestCase
|
||||
{
|
||||
/** KG 플러그인 레이아웃 리스너의 priority (sirsoft-pay_kginicis). */
|
||||
private const KGINICIS_PRIORITY = 20;
|
||||
|
||||
/** KG 가 str_replace 대상으로 삼는 정확 리터럴 (닫는 대괄호 포함). */
|
||||
private const KGINICIS_ANCHOR = "['point','deposit','free','dbank']";
|
||||
|
||||
private AdjustEcommercePaymentMethodsLayoutListener $listener;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->listener = new AdjustEcommercePaymentMethodsLayoutListener;
|
||||
}
|
||||
|
||||
public function test_subscribes_to_layout_after_apply_as_filter(): void
|
||||
{
|
||||
$hooks = AdjustEcommercePaymentMethodsLayoutListener::getSubscribedHooks();
|
||||
|
||||
$this->assertArrayHasKey('core.layout_extension.after_apply', $hooks);
|
||||
$this->assertSame('filter', $hooks['core.layout_extension.after_apply']['type']);
|
||||
$this->assertSame('markTossMethodsAsPgNotRequired', $hooks['core.layout_extension.after_apply']['method']);
|
||||
}
|
||||
|
||||
public function test_ignores_non_target_layouts(): void
|
||||
{
|
||||
$layout = [
|
||||
'layout_name' => 'some_other_layout',
|
||||
'expr' => "{{['point','deposit','free','dbank'].includes(\$method.id)}}",
|
||||
];
|
||||
|
||||
$result = $this->listener->markTossMethodsAsPgNotRequired($layout, 1);
|
||||
|
||||
$this->assertSame($layout, $result);
|
||||
}
|
||||
|
||||
public function test_appends_toss_methods_to_no_pg_list(): void
|
||||
{
|
||||
$layout = [
|
||||
'layout_name' => 'admin_ecommerce_settings',
|
||||
'expr' => "{{['point','deposit','free','dbank'].includes(\$method.id)}}",
|
||||
];
|
||||
|
||||
$result = $this->listener->markTossMethodsAsPgNotRequired($layout, 1);
|
||||
|
||||
$this->assertStringContainsString("'toss_card'", $result['expr']);
|
||||
$this->assertStringContainsString("'toss_virtual_account'", $result['expr']);
|
||||
$this->assertStringContainsString("'toss_samsungpay'", $result['expr']);
|
||||
// 코어 앵커는 보존
|
||||
$this->assertStringContainsString("'point','deposit','free','dbank'", $result['expr']);
|
||||
}
|
||||
|
||||
public function test_merge_is_idempotent(): void
|
||||
{
|
||||
$layout = [
|
||||
'layout_name' => 'admin_ecommerce_settings',
|
||||
'expr' => "{{['point','deposit','free','dbank'].includes(\$method.id)}}",
|
||||
];
|
||||
|
||||
$once = $this->listener->markTossMethodsAsPgNotRequired($layout, 1);
|
||||
$twice = $this->listener->markTossMethodsAsPgNotRequired($once, 1);
|
||||
|
||||
// 두 번 적용해도 toss_card 가 한 번만 등장 (멱등)
|
||||
$this->assertSame(1, substr_count($twice['expr'], "'toss_card'"));
|
||||
$this->assertSame($once, $twice);
|
||||
}
|
||||
|
||||
public function test_preserves_kg_ids_when_both_active(): void
|
||||
{
|
||||
// KG 가 먼저 실행되어 자기 id 를 이미 추가한 상태
|
||||
$layout = [
|
||||
'layout_name' => 'admin_ecommerce_settings',
|
||||
'expr' => "{{['point','deposit','free','dbank','kginicis_samsung_pay','kginicis_kakaopay'].includes(\$method.id)}}",
|
||||
];
|
||||
|
||||
$result = $this->listener->markTossMethodsAsPgNotRequired($layout, 1);
|
||||
|
||||
// KG id 는 소실되지 않고, toss_* 가 추가된다
|
||||
$this->assertStringContainsString("'kginicis_samsung_pay'", $result['expr']);
|
||||
$this->assertStringContainsString("'kginicis_kakaopay'", $result['expr']);
|
||||
$this->assertStringContainsString("'toss_card'", $result['expr']);
|
||||
}
|
||||
|
||||
/**
|
||||
* KG 플러그인은 no-PG 리스트를 "닫는 대괄호까지 포함한 통짜 리터럴" 로 str_replace 한다.
|
||||
* 토스가 먼저 실행되어 리스트에 toss_* 를 append 하면 KG 의 매치가 실패해 kginicis_*
|
||||
* 가 영영 주입되지 않는다. 따라서 토스 priority 는 KG(20) 보다 반드시 커야 하며
|
||||
* (HookManager 는 ksort 오름차순), 이 값은 플러그인 로드 순서와 무관하게
|
||||
* "KG 먼저" 를 불변식으로 고정한다.
|
||||
*/
|
||||
public function test_priority_runs_after_kginicis_literal_replacement(): void
|
||||
{
|
||||
$hooks = AdjustEcommercePaymentMethodsLayoutListener::getSubscribedHooks();
|
||||
|
||||
$this->assertGreaterThan(
|
||||
self::KGINICIS_PRIORITY,
|
||||
$hooks['core.layout_extension.after_apply']['priority'],
|
||||
'토스 리스너는 KG(priority 20) 이후에 실행되어야 한다 — 먼저 실행되면 KG 의 통짜 리터럴 치환이 매치 실패한다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스가 KG 보다 먼저 실행되는 (금지된) 순서를 재현하면 KG 치환이 실패함을 명시적으로 잠근다.
|
||||
* 이 테스트가 깨지면 KG 의 치환 방식이 바뀐 것이므로 priority 계약을 재검토해야 한다.
|
||||
*/
|
||||
public function test_toss_first_order_would_break_kginicis_replacement(): void
|
||||
{
|
||||
$expr = "{{['point','deposit','free','dbank'].includes(\$method.id)}}";
|
||||
|
||||
// 토스가 먼저 실행된 결과
|
||||
$afterToss = $this->listener->markTossMethodsAsPgNotRequired(
|
||||
['layout_name' => 'admin_ecommerce_settings', 'expr' => $expr],
|
||||
1
|
||||
)['expr'];
|
||||
|
||||
// KG 는 이 정확 리터럴(닫는 대괄호 포함)을 찾는다
|
||||
$this->assertStringNotContainsString(
|
||||
self::KGINICIS_ANCHOR,
|
||||
$afterToss,
|
||||
'토스 선행 시 KG 앵커가 파괴된다 — priority 로 KG 를 먼저 실행시켜야 하는 근거.'
|
||||
);
|
||||
|
||||
// 반대로 KG 선행 순서에서는 토스가 KG id 를 보존한다 (test_preserves_kg_ids_when_both_active 참조)
|
||||
$this->assertStringContainsString(self::KGINICIS_ANCHOR, $expr);
|
||||
}
|
||||
|
||||
public function test_processes_nested_expressions(): void
|
||||
{
|
||||
$layout = [
|
||||
'layout_name' => 'admin_ecommerce_settings',
|
||||
'children' => [
|
||||
[
|
||||
'if' => "{{!['point','deposit','free','dbank'].includes(\$method.id)}}",
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = $this->listener->markTossMethodsAsPgNotRequired($layout, 1);
|
||||
|
||||
$this->assertStringContainsString("'toss_card'", $result['children'][0]['if']);
|
||||
}
|
||||
}
|
||||
+11
-3
@@ -2,20 +2,22 @@
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Tests\Unit\Listeners;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Plugins\Sirsoft\Tosspayments\Listeners\RegisterPgProviderListener;
|
||||
use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* RegisterPgProviderListener 단위 테스트
|
||||
*
|
||||
* @effects toss_provider_registered_with_payment_handler
|
||||
*/
|
||||
class RegisterPgProviderListenerTest extends TestCase
|
||||
class RegisterPgProviderListenerTest extends PluginTestCase
|
||||
{
|
||||
private RegisterPgProviderListener $listener;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->listener = new RegisterPgProviderListener();
|
||||
$this->listener = new RegisterPgProviderListener;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,6 +60,12 @@ class RegisterPgProviderListenerTest extends TestCase
|
||||
$this->assertIsString($toss['name']);
|
||||
$this->assertEquals('credit-card', $toss['icon']);
|
||||
$this->assertContains('card', $toss['supported_methods']);
|
||||
// S4: 결제수단 4종으로 확장
|
||||
$this->assertContains('virtual_account', $toss['supported_methods']);
|
||||
$this->assertContains('bank_transfer', $toss['supported_methods']);
|
||||
$this->assertContains('mobile', $toss['supported_methods']);
|
||||
// S4: payment_handler 선언 (PG 분기 발화 정공법)
|
||||
$this->assertSame('sirsoft-tosspayments.requestPayment', $toss['payment_handler']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Tests\Unit\Listeners;
|
||||
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Plugins\Sirsoft\Tosspayments\Listeners\ValidateTossSettingsListener;
|
||||
use Plugins\Sirsoft\Tosspayments\Plugin;
|
||||
use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 토스 플러그인 설정 서버측 검증.
|
||||
*
|
||||
* 배경(#454): vbank_valid_hours 는 토스 가상계좌 유효시간(1~2160시간, 최대 90일) 제약을 받는다.
|
||||
* 설정 UI 는 input[max=2160] 으로 막지만 그것은 클라이언트 힌트일 뿐이고, 관리자 설정 저장 API 를
|
||||
* 직접 호출하면 범위 밖 값(예: 9999)이 그대로 저장되어 결제창 호출 시 토스가 거부한다.
|
||||
* KG 의 ValidateCbtSettingsListener 와 동일하게 core.plugin_settings.before_save 훅에서 검증한다.
|
||||
*/
|
||||
class ValidateTossSettingsListenerTest extends PluginTestCase
|
||||
{
|
||||
private ValidateTossSettingsListener $listener;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->listener = new ValidateTossSettingsListener;
|
||||
}
|
||||
|
||||
public function test_plugin_registers_settings_validation_listener(): void
|
||||
{
|
||||
$this->assertContains(ValidateTossSettingsListener::class, (new Plugin)->getHookListeners());
|
||||
}
|
||||
|
||||
public function test_other_plugin_settings_are_not_validated(): void
|
||||
{
|
||||
// 타 플러그인 식별자면 early-return (예외 없음)
|
||||
$this->listener->validateBeforeSave('sirsoft-pay_kginicis', ['vbank_valid_hours' => 9999]);
|
||||
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{int}>
|
||||
*/
|
||||
public static function validHoursProvider(): array
|
||||
{
|
||||
return [
|
||||
'최소 경계 1시간' => [1],
|
||||
'기본 24시간' => [24],
|
||||
'최대 경계 2160시간(90일)' => [2160],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider validHoursProvider
|
||||
*/
|
||||
public function test_valid_vbank_valid_hours_pass(int $hours): void
|
||||
{
|
||||
$this->listener->validateBeforeSave('sirsoft-tosspayments', ['vbank_valid_hours' => $hours]);
|
||||
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{mixed}>
|
||||
*/
|
||||
public static function invalidHoursProvider(): array
|
||||
{
|
||||
return [
|
||||
'상한 초과 (토스 최대 2160)' => [9999],
|
||||
'상한 바로 위' => [2161],
|
||||
'0 시간' => [0],
|
||||
'음수' => [-1],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider invalidHoursProvider
|
||||
*/
|
||||
public function test_out_of_range_vbank_valid_hours_are_rejected(mixed $hours): void
|
||||
{
|
||||
$this->expectException(ValidationException::class);
|
||||
|
||||
$this->listener->validateBeforeSave('sirsoft-tosspayments', ['vbank_valid_hours' => $hours]);
|
||||
}
|
||||
|
||||
public function test_rejected_message_targets_the_field(): void
|
||||
{
|
||||
try {
|
||||
$this->listener->validateBeforeSave('sirsoft-tosspayments', ['vbank_valid_hours' => 9999]);
|
||||
$this->fail('범위 밖 vbank_valid_hours 가 예외 없이 통과했습니다.');
|
||||
} catch (ValidationException $e) {
|
||||
$this->assertArrayHasKey('vbank_valid_hours', $e->errors());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function invalidEscrowProvider(): array
|
||||
{
|
||||
return [
|
||||
'허용 목록 밖 값' => ['maybe'],
|
||||
'빈 문자열' => [''],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider invalidEscrowProvider
|
||||
*/
|
||||
public function test_invalid_use_escrow_is_rejected(string $value): void
|
||||
{
|
||||
$this->expectException(ValidationException::class);
|
||||
|
||||
$this->listener->validateBeforeSave('sirsoft-tosspayments', ['use_escrow' => $value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function validEscrowProvider(): array
|
||||
{
|
||||
return [
|
||||
'off' => ['off'],
|
||||
'on' => ['on'],
|
||||
'buyer_choice' => ['buyer_choice'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider validEscrowProvider
|
||||
*/
|
||||
public function test_valid_use_escrow_passes(string $value): void
|
||||
{
|
||||
$this->listener->validateBeforeSave('sirsoft-tosspayments', ['use_escrow' => $value]);
|
||||
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function test_settings_without_the_validated_keys_pass(): void
|
||||
{
|
||||
// 부분 저장(다른 키만 전송)에서 미포함 키를 강제하지 않는다.
|
||||
$this->listener->validateBeforeSave('sirsoft-tosspayments', ['is_test_mode' => true]);
|
||||
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
# audit:allow test-scenario-coverage reason: S4 결제수단·가상계좌·에스크로 명세 SSoT. 8-tuple pairwise 19건을 테스트 docblock @scenario 로 전부 나열하는 것은 실효성이 낮다(테스트는 축별 동작을 격리 검증 — KG security-callback-defense 선례 동일). effects 는 test_files 의 단위/통합 테스트가 @effects 로 실제 매핑·검증한다. 실 PG 결제창(M20·M21)은 실계약 후 Chrome MCP 로 커버.
|
||||
# 시나리오 매니페스트 — 토스 결제수단 동적등록 · 가상계좌 · 웹훅 · 에스크로 (S4)
|
||||
# 참조: docs/testing-guide.md "기능 단위 시나리오 매트릭스", 계획서 §12-4 / §13-2
|
||||
|
||||
feature: toss_payment_methods_vbank_escrow
|
||||
description: >-
|
||||
토스페이먼츠 주문서형 결제수단 동적등록 · 가상계좌 발급/입금통보 웹훅 ·
|
||||
에스크로(3-상태) · 결제수단별 SDK 파라미터 매핑의 입력 조합과 후속 효과.
|
||||
|
||||
test_files:
|
||||
- plugins/_bundled/sirsoft-tosspayments/tests/Feature/Controllers/WebhookControllerTest.php
|
||||
- plugins/_bundled/sirsoft-tosspayments/tests/Feature/Controllers/PaymentCallbackControllerTest.php
|
||||
- plugins/_bundled/sirsoft-tosspayments/tests/Feature/Listeners/RegisterTossPaymentMethodsListenerTest.php
|
||||
- plugins/_bundled/sirsoft-tosspayments/tests/Feature/Listeners/RegisterPgProviderClientConfigTest.php
|
||||
- plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/AdjustEcommercePaymentMethodsLayoutListenerTest.php
|
||||
- plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/RegisterPgProviderListenerTest.php
|
||||
- plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/handlers/requestPayment.test.ts
|
||||
- plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/layouts/pluginSettingsPaymentMethods.test.tsx
|
||||
- plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/ValidateTossSettingsListenerTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Http/Controllers/Public/BuildPgPaymentDataTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/OrderPaymentRepositoryTransactionPaidTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/EcommerceSettingsOrderSettingsTest.php
|
||||
- templates/_bundled/sirsoft-basic/__tests__/layouts/checkoutSummaryBodyPayload.test.tsx
|
||||
# SDK 경계 실측 — 서버가 escrow_products 를 내려줘도 핸들러가 SDK 페이로드에 싣지 않으면
|
||||
# 토스가 결제를 거부한다. 그 경계를 브라우저에서 직접 캡처한다.
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/checkout-plugin-payment-method.spec.ts
|
||||
|
||||
coverage_strategy: pairwise
|
||||
|
||||
# 입력 axis — 결제수단 등록 / SDK 매핑 / 웹훅 / 에스크로에 영향을 주는 변수
|
||||
axes:
|
||||
order_sheet_mode: [false, true]
|
||||
payment_method: [toss_card, toss_virtual_account, toss_transfer, toss_mobile_phone, toss_tosspay, toss_kakaopay, toss_naverpay, toss_payco, toss_samsungpay]
|
||||
currency: [krw, non_krw]
|
||||
use_escrow: [off, on, buyer_choice]
|
||||
webhook_status: [done, canceled]
|
||||
webhook_secret: [match, mismatch, disabled]
|
||||
replay: [first, duplicate]
|
||||
kg_coexists: [false, true]
|
||||
# 주문 생성 시 서버로 보내는 payment_method — toss_* 는 코어 enum 이 거부하므로 core 값으로 번역해 전송
|
||||
core_payment_method_sent: [translated, raw_fallback]
|
||||
# 관리자 설정 저장 서버측 범위 검증 (UI max 는 클라 힌트일 뿐 — API 직접 호출 우회 차단)
|
||||
settings_range: [valid, out_of_range]
|
||||
|
||||
axis_notes:
|
||||
order_sheet_mode: OFF 이면 결제수단 미주입(통합결제창 카드). ON 일 때만 payment_method axis 유효.
|
||||
currency: non_krw 는 카드 외 수단 차단(토스 국내 전용). 카드만 허용.
|
||||
use_escrow: 가상계좌·계좌이체에만 적용. buyer_choice 는 useEscrow 키 자체 부재.
|
||||
webhook_secret: match=200, mismatch=401, disabled=검증 스킵.
|
||||
replay: duplicate 는 이미 PAID 인 거래 → 멱등 200, 상태 불변.
|
||||
kg_coexists: KG 와 토스 동시 활성 시 no-PG 리스트 멱등 병합(양쪽 id 보존).
|
||||
core_payment_method_sent: >-
|
||||
translated = toss_* 선택 시 그 결제수단의 core_payment_method(vbank/bank/phone/card)를 payment_method 로 전송.
|
||||
raw_fallback = core_payment_method 미선언 결제수단(dbank·KG 등)은 raw id 를 그대로 전송(기존 동작 보존).
|
||||
코어 PaymentMethodEnum 은 toss_* 를 거부하므로 미번역 전송 시 422 (본 이슈에서 수정).
|
||||
settings_range: >-
|
||||
out_of_range = vbank_valid_hours 가 1~2160(90일) 밖이거나 use_escrow 가 3-상태 밖 → 422 로 저장 차단.
|
||||
valid = 범위 내 → 200 저장. UI input[max] 는 클라이언트 힌트라 API 직접 호출을 막지 못한다.
|
||||
|
||||
# 의미 없는 조합 컷
|
||||
exclusions:
|
||||
- { order_sheet_mode: false, payment_method: toss_virtual_account, reason: "결제창형에서는 결제수단 선택 없음(카드 통합결제창 단일)" }
|
||||
- { currency: non_krw, payment_method: toss_virtual_account, reason: "비KRW 는 가상계좌 차단 — 카드만 허용" }
|
||||
- { currency: non_krw, payment_method: toss_transfer, reason: "비KRW 는 계좌이체 차단" }
|
||||
- { currency: non_krw, payment_method: toss_mobile_phone, reason: "비KRW 는 휴대폰 차단" }
|
||||
- { payment_method: toss_card, use_escrow: on, reason: "카드는 에스크로 SDK 플래그 대상 아님(E1)" }
|
||||
- { webhook_status: canceled, replay: duplicate, reason: "취소 통보는 리플레이 가드 대상 아님(PAID 아님)" }
|
||||
|
||||
# 후속 효과 체인
|
||||
effects:
|
||||
- toss_provider_registered_with_payment_handler
|
||||
- enabled_methods_exposed_with_core_mapping
|
||||
- vbank_account_stored_without_completing
|
||||
- vbank_secret_stored_for_webhook
|
||||
- is_escrow_snapshot_persisted
|
||||
- webhook_secret_verified
|
||||
- webhook_replay_idempotent
|
||||
- deposit_done_completes_payment
|
||||
- deposit_canceled_fails_payment
|
||||
- non_waiting_deposit_is_noop
|
||||
- non_krw_domestic_method_blocked
|
||||
- sdk_method_mapped_from_enabled_methods
|
||||
- virtual_account_payload_built
|
||||
- escrow_products_attached
|
||||
- escrow_flag_off_true_or_key_absent
|
||||
- no_pg_list_idempotent_merge_preserves_kg
|
||||
- escrow_products_localized_unit_price_per_item
|
||||
# 코어 결제수단 번역 (본 이슈 수정분)
|
||||
- core_payment_method_preserved_through_settings_merge
|
||||
- core_payment_method_preserved_through_save_snapshot
|
||||
- checkout_body_sends_core_payment_method
|
||||
- non_mapped_method_falls_back_to_raw_id
|
||||
- order_created_with_core_payment_method
|
||||
# 관리자 설정 서버측 범위 검증 (본 이슈 수정분)
|
||||
- out_of_range_vbank_hours_rejected
|
||||
- invalid_use_escrow_rejected
|
||||
- other_plugin_settings_not_validated
|
||||
# SDK 페이로드 경계 (감사 후속 — 서버 조립과 SDK 전달은 별개 계약)
|
||||
- escrow_products_attached_to_virtual_account_sdk_payload
|
||||
- escrow_products_absent_when_escrow_off
|
||||
- escrow_products_absent_for_card
|
||||
# 레이아웃 리스너 실행 순서 불변식 (감사 후속)
|
||||
- toss_layout_listener_runs_after_kginicis
|
||||
# 에스크로 운영 안내 (W-6)
|
||||
- escrow_operation_notice_shown_when_escrow_enabled
|
||||
|
||||
notes: |
|
||||
실계약 전이므로 실제 PG 결제창 진입/승인은 샌드박스 의존이며 M20·M21(Chrome MCP)은
|
||||
"미커버 — 실계약 후" 로 명시한다. 본 매니페스트의 effects 는 서버/프론트 단위·통합
|
||||
테스트로 커버한다. 부분취소 차단(E4)·환불 정합성(R1~R3)·현금영수증 프로바이더는 S5 범위.
|
||||
|
||||
에스크로 계약은 **두 겹**이다 — (1) 서버가 pg_payment_data.escrow_products 를 조립하고,
|
||||
(2) 프론트 핸들러가 그것을 SDK 페이로드에 부착한다. 둘 중 하나만 검증하면 다른 쪽 결함을
|
||||
놓친다: 실제로 서버 응답만 실측하고 통과 처리한 탓에, 가상계좌 분기가 attachEscrowProducts()
|
||||
를 호출하지 않는 결함(계좌이체에서만 부착)이 살아남았다. E2E spec 이 SDK 경계를 직접
|
||||
캡처해 이 절반을 잠근다 (스텁 SDK + client-config 인터셉트 — 상점 설정은 오염시키지 않는다).
|
||||
Reference in new issue
Block a user