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:
HeuJung committed 2026-08-06 12:46:48 +09:00
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,
];
}
@@ -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');
});
});
});
@@ -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');
});
});
@@ -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,
]);
}
}
@@ -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'],
];
}
}
@@ -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'],
];
}
}
@@ -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;
}
/**
* 플러그인 설정 조회
*
@@ -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');
});
@@ -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);
}
}
@@ -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);
}
}
@@ -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']);
}
}
@@ -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);
}
}
@@ -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']);
}
}
@@ -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']);
}
/**
@@ -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);
}
}
@@ -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 인터셉트 — 상점 설정은 오염시키지 않는다).