Revert "공개 버전 출시를 위한 나이스페이먼츠·NHN KCP 결제 플러그인 번들 제거"

This reverts commit bac963b011f7b180c0d64e4f050131b5f05b85c1.
This commit is contained in:
HeuJung
2026-07-13 11:10:50 +09:00
parent a690fdd2c4
commit cd3f6ad2b7
230 changed files with 45766 additions and 0 deletions
@@ -0,0 +1,113 @@
# Changelog
이 프로젝트의 모든 주요 변경사항을 기록합니다.
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [Unreleased]
### Fixed
- 비회원 NHN KCP 주문완료/주문상세에서 매출전표 조회가 인증 사용자 전용 라우트에 막히지 않도록 게스트 주문 토큰과 결제 성공 후 임시 영수증 쿠키 접근을 지원했습니다.
- 관리자 주문상세의 NHN KCP 결제 정보 패널이 원승인 정보와 현재 취소/환불 상태를 함께 표시하도록 보강했습니다.
- PC 결제창 close-report와 승인 콜백이 경쟁할 때 결제 성공 주문이 실패 처리로 덮이지 않도록 주문번호 단위 콜백 락과 최신 결제 상태 가드를 적용했습니다.
- NHN KCP 부분취소 요청에서 `rem_mny`를 취소 후 잔액이 아닌 현재 부분취소 가능 금액으로 전달해 큰 금액 부분취소가 거절될 수 있던 문제를 수정했습니다.
- 이커머스 주문설정 화면에서 NHN KCP 플러그인이 테스트 모드일 때 운영 전환 경고와 설정 이동 버튼을 표시하도록 보강했습니다.
## [1.0.0-beta.5] - 2026-06-23
### Fixed
- 간편결제 주문의 원 결제수단이 사용자 주문완료/주문상세, 관리자 주문목록/주문상세에서 신용카드로만 보이지 않도록 표시 라벨과 관리자 목록 표시 맵을 보강했습니다.
- 동일 주문의 NHN KCP 승인/가상계좌 콜백이 동시에 도착해도 결제완료 훅과 주문 부작용이 중복 실행되지 않도록 주문번호 단위 콜백 락을 추가했습니다.
- 다통화 주문의 환율 스냅샷이 누락되거나 0인 경우 close-report, 재시도 API, 모바일 승인키, 승인 콜백에서 500 오류로 이어지지 않고 결제 통화 설정 오류로 명시 처리되도록 보강했습니다.
- 결제창 닫힘, 인증 취소, 승인 실패, 금액 불일치 등 NHN KCP 실패 경로에서 주문 실패 상태와 결제 레코드의 PG별 실패 메타가 함께 기록되도록 보강했습니다.
- 결제 실패/취소 후 같은 주문번호로 결제창을 다시 열 때 안전한 실패 주문만 `pending_order` / `ready` 상태로 복구하는 재시도 준비 API를 추가했습니다.
- PC/모바일 결제창 호출 직전에 재시도 준비 API를 호출해 사용자가 결제 실패 후 동일 주문 결제를 다시 시도할 수 있도록 보강했습니다.
## [1.0.0-beta.4] - 2026-06-03
### Added
- 이커머스 주문설정 결제수단 목록에 PAYCO, 네이버페이, 네이버페이 포인트, 카카오페이, Apple Pay 간편결제를 추가했습니다.
- 레이아웃 편집기에서 이 플러그인의 설정 화면을 테스트 모드/운영 모드 상태로 전환해 미리볼 수 있도록 했습니다.
- 레이아웃 편집기 데이터 소스 목록에서 이 확장이 제공하는 데이터 소스가 친화 명칭으로 표시되고, 어느 확장이 제공했는지 출처가 함께 표시됩니다.
### Security
- 가상계좌 입금통보와 에스크로 공통통보가 주문번호만으로 처리되지 않도록 저장된 KCP 거래번호, site_cd, 계좌번호, 금액을 교차 검증하도록 보강했습니다.
- 모바일 승인키 발급 시 주문 소유자와 결제금액을 검증해 타인 주문번호 또는 변조 금액으로 승인키가 발급되지 않도록 차단했습니다.
- 무서명 브라우저 실패 콜백만으로 진행 중 주문이 취소/실패 상태로 바뀌지 않도록 차단했습니다.
- NHN KCP가 지원하지 않는 비-KRW 주문이 결제창/승인 콜백으로 진행되지 않도록 서버와 프론트 가드를 추가했습니다.
- KCP CLI 디버그 로그와 에스크로 배송등록 PG 응답 저장에서 비밀키, 카드번호, 수령자 개인정보 등 원문 민감값이 남지 않도록 축소했습니다.
- 에스크로 공통통보 replay 시 동일 payload가 내부 hook을 중복 발행하지 않도록 보강했습니다.
### Fixed
- NHN KCP 간편결제 성공 시 원 결제수단이 주문 결제 메타와 간편결제 PG 필드에 보존되도록 보강했습니다.
- 이미 결제 완료된 주문의 카드 승인 콜백이 중복 도착하면 KCP 승인 API를 다시 호출하지 않고 멱등 성공 응답으로 처리하도록 보강했습니다.
- 플러그인 설정 화면에 NHN KCP 상점관리자의 결제취소 서버 IP 등록 필요 안내와 서버 IP 복사 기능을 추가해 운영 모드 결제취소 실패 원인을 사전에 확인할 수 있도록 했습니다.
- 관리자 주문목록과 주문상세 레이아웃이 리뉴얼되거나 플러그인 업데이트 과정에서 NHN KCP 테스트결제 배지, 테스트 모드 안내, 결제 조회/결제 정보 패널이 누락될 수 있던 문제를 수정했습니다.
- 복합과세 모바일/PC 결제 필드의 공급가액, 부가세, 비과세 합계가 할인/마일리지 반영 후 실결제액과 일치하도록 재배분했습니다.
- 에스크로 테스트 site_cd가 비어 있으면 빈 값이 아닌 일반 테스트 site_cd로 fallback되도록 보정했습니다.
- 환불 처리 시 결제 당시 KCP site_cd와 테스트/운영 모드를 복원하도록 보강했습니다.
- KCP webhook IP 가드가 설정 누락 시 테스트 모드로 우회되지 않고 차단되도록 변경했습니다.
- 이커머스 관리자 주문설정 결제수단 목록에서 NHN KCP 간편결제 항목이 브랜드 텍스트 배지로 표시되도록 보강했습니다.
- 체크아웃에서 주문설정으로 활성화한 NHN KCP 간편결제가 숨겨지거나 KG 이니시스 브랜드 문구로 잘못 보정되는 문제를 수정했습니다.
- 결제 취소/부분취소 시 KCP CLI 내부 필드 구분자가 안전성 검사에 막혀 취소 요청이 실패할 수 있던 문제를 수정했습니다.
- 활성 관리자 테마에 최신 시맨틱 CSS가 없어도 플러그인 설정 화면의 여백, 카드 경계, 하단 저장 영역이 깨지지 않도록 보강했습니다.
- NHN KCP PC 결제창을 사용자가 닫거나 취소 콜백이 들어온 경우 임시 주문이 `pending_order` / `ready` 상태로 남지 않고 결제실패/취소 상태로 기록되도록 보강했습니다.
- KCP PC 결제창 내부 취소 버튼이 외부 iframe을 제거하는 흐름에서도 `close-report`가 호출되도록 감시 로직을 추가했습니다.
### Changed
- 환경설정 / 주문 화면 결제 영역 외형을 sirsoft-admin_basic 표준 시맨틱과 정합 — 다른 화면과 같은 결로 통일.
- 관리자 화면 좌우 정렬 컨테이너(섹션 헤더 · 액션바 · 카드 상단부 등) 외형을 표준 시맨틱(.flex-between)으로 정리 — 시각 변경 없음, 향후 톤 조정이 한 곳에서 가능.
- 플러그인 환경설정 화면의 하단 저장 버튼이 스크롤 중에도 화면에 고정되도록 개선.
- 환경설정 화면의 가상계좌 입금 통보 URL · 에스크로 통보 URL 표시 입력의 외형을 관리자 표준 외형으로 정렬 — 글자가 옅은 회색으로 표시되어 "참조용 잠긴 정보" 임을 한눈에 구분.
- 플러그인 환경설정 화면의 폼 라벨 / 보조 설명 / 에러 메시지 시각 시맨틱을 sirsoft-admin_basic 표준 시맨틱과 정합 — 다른 관리자 화면과 같은 결로 통일.
- 플러그인 환경설정 화면 곳곳의 텍스트 톤 (보조 설명 · 라벨 · 본문 · 강조 · 작은 보조) 시각 시맨틱을 관리자 표준 시맨틱과 정합 — 같은 결의 글자 톤이 한 곳에서 일괄 조정 가능.
- 플러그인 환경설정 화면의 세로 정렬 컨테이너 / 입력 박스를 sirsoft-admin_basic 표준 시맨틱 (.row-stack / .input) 과 정합 — 입력 행 사이에 옅은 회색 구분선(1px) 이 자동 적용되어 행 묶음 가독성이 개선되고, 입력 박스 외형이 일관 표시되도록 정리.
## [1.0.0-beta.3] - 2026-05-20
### Fixed
- 결제창 종료 등으로 KCP 콜백의 `res_cd` 가 빈 값으로 들어온 경우 validation 오류가 아닌 사용자 취소 흐름으로 조용히 복귀하도록 수정했습니다.
### Security
- 결제 승인/가상계좌 통보 응답 원문 저장을 필요한 필드만 남기는 방식으로 축소 — PG 응답에 섞일 수 있는 카드번호, 구매자 연락처, 계좌 관련 민감 정보의 메타 저장 위험을 줄였습니다.
## [1.0.0-beta.2] - 2026-05-20
### Added
- PG 측 결제 취소가 확인된 시점에 활동 로그가 별도로 기록되도록 보강 — 운영자가 PG 응답 시각 / 취소 거래번호 등 사후 추적에 활용 가능.
### Fixed
- PC 결제 승인 시 간헐적으로 발생하던 "연동 모듈 호출 오류" 가 결제 직전 자동으로 자가 복구되도록 개선 — 플러그인 업데이트 직후 실행 권한 회귀로 인한 결제 실패를 차단합니다. 자동 복구가 불가능한 환경에서는 권한 조치 안내가 포함된 명확한 오류 메시지로 즉시 중단되어 운영자가 빠르게 대응할 수 있습니다.
- KCP CLI 바이너리가 저장소에 실행 가능 권한으로 기록되도록 정정 — 신규 설치 또는 플러그인 업데이트 직후에도 별도 권한 조치 없이 PC 결제가 바로 동작합니다. (Windows 환경은 영향 없음)
- 부분취소 시 PG 가 전액취소로 잘못 처리되어 거부될 수 있던 문제 수정 — 부분취소 금액과 원래 결제금액이 올바르게 전달되도록 정정.
### Security
- KCP CLI 호출 시 결제 메타가 평문으로 디스크에 누적되던 문제 차단 — 카드번호 등 민감 정보가 플러그인 내부 로그 파일에 더 이상 저장되지 않습니다. (PCI DSS 평문 저장 우려 해소 + 비표준 서버 배치에서의 직접 다운로드 위험 차단)
- 결제 콜백 중복 처리 방어 추가 — 동일 거래번호로 콜백이 두 번 도착해도 결제완료/마일리지 적립 등이 중복 실행되지 않도록 멱등 응답 처리
- 결제 처리 중 오류 발생 시 결제 자동 취소 추가 — 카드 승인 후 후속 처리(금액 검증 등) 실패 시 PG 측 결제도 자동 취소하여 사용자 환불 누락 방지
- Windows 환경의 결제 모듈 호출 시 입력값 안전성 강화 — 위험 문자 사전 거부로 잠재적 명령 주입 차단
## [1.0.0-beta.1] - 2026-04-22
### Changed
- 플러그인 식별자를 `sirsoft-pay-nhnkcp`에서 `sirsoft-pay_nhnkcp`로 변경 — G7 코어가 권장하는 `vendor-name` 2-part 명명 규칙에 맞추기 위함
- 환불 메시지를 한국어/영어 다국어로 분리 — 운영 언어에 따라 자동 노출
- PG 프로바이더 표시명을 다국어 키로 분리 — 활성 언어팩으로 자동 보강되어 다른 PG 플러그인과 동일한 컨벤션으로 정렬
### Added
- 오픈 베타 릴리즈
- `sirsoft-pay_nhnkcp.payment.before_cancel` / `after_cancel` 액션 훅 — 외부 소비자가 결제 취소 지점에 본인인증 등 확장 로직을 붙일 수 있도록 확장점 제공
- PG 도메인 전용 예외 도입 — 외부 소비자가 NHN KCP 도메인 오류만 선택적으로 처리할 수 있도록 개선
@@ -0,0 +1,47 @@
프로그램 명칭 : 그누보드7용 NHN KCP 플러그인 (sirsoft-pay-nhnkcp)
저작자 : (주)에스아이알소프트
----- MIT 라이선스 (한국어 번역) --------------------------------------------------------
MIT 라이선스
Copyright (c) 2026 (주)에스아이알소프트
이 소프트웨어와 관련 문서 파일(이하 "소프트웨어")의 복사본을 취득하는 모든 사람에게
소프트웨어를 제한 없이 사용, 복사, 수정, 병합, 출판, 배포, 서브라이선스 허여 및/또는
판매할 수 있는 권리를 무상으로 부여합니다. 다만, 소프트웨어를 제공받은 사람은 다음
조건을 따라야 합니다:
위 저작권 고지와 본 허가 고지는 소프트웨어의 모든 복사본 또는 상당 부분에 포함되어야
합니다.
소프트웨어는 "있는 그대로" 제공되며, 명시적이든 묵시적이든 어떠한 종류의 보증도 하지
않습니다. 여기에는 상품성, 특정 목적에의 적합성 및 비침해에 대한 보증이 포함되나 이에
국한되지 않습니다. 어떠한 경우에도 저작자 또는 저작권자는 소프트웨어나 소프트웨어의
사용 또는 기타 거래로 인해 발생하는 계약, 불법행위 또는 기타 청구, 손해 또는 기타
책임에 대해 책임을 지지 않습니다.
----- MIT License (English Original) --------------------------------------------------------
The MIT License (MIT)
Copyright (c) 2026 SIRSOFT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,249 @@
# NHN KCP Plugin for G7
NHN KCP Standard Pay 결제를 G7 `sirsoft-ecommerce` 모듈에 연결하는 결제 플러그인입니다.
PC 결제는 KCP `payplus_web.jsp` 결제창과 KCP CLI 승인 모듈을 사용하고, 모바일 결제는 SmartPhone Pay SOAP 승인키를 받은 뒤 KCP 모바일 결제창으로 이동합니다.
## 주요 기능
- 신용카드, 계좌이체, 가상계좌, 휴대폰결제 지원
- PC Standard Pay 결제창 연동
- 모바일 SmartPhone Pay 승인키 발급 및 모바일 결제창 연동
- PAYCO, 네이버페이, 네이버페이 포인트, 카카오페이, Apple Pay 간편결제 버튼 주입
- 가상계좌 발급, 입금통보, 테스트 모드 모의입금 처리
- 에스크로 결제, 에스크로 배송 등록, 공통통보 처리
- 결제 취소 및 부분취소 연동
- PG 측 결제 취소 확인 시점의 활동 로그 별도 기록 (PG 응답 시각·취소 거래번호 사후 추적)
- 주문 완료/마이페이지 영수증, 현금영수증 조회 버튼 주입
- 관리자 주문 상세의 KCP 거래 정보 표시
- 관리자 설정 화면의 KCP 실행 환경 점검
## 요구 사항
| 항목 | 내용 |
|------|------|
| G7 | `>= 7.0.0-beta.2` |
| 의존 모듈 | `sirsoft-ecommerce >= 1.0.0-beta.5` |
| PHP | `^8.2` |
| PC 결제 | PHP `exec()` 사용 가능, KCP CLI 바이너리, `pub.key` |
| 모바일 결제 | PHP SOAP 확장, KCP WSDL 파일 |
| 운영 환경 | HTTPS 도메인, 올바른 `APP_URL`, KCP 가맹점 계약 정보 |
`bin/` 디렉토리에는 아래 파일이 필요합니다.
```text
bin/pp_cli
bin/pp_cli_x64
bin/pp_cli_exe.exe
bin/pub.key
bin/KCPPaymentService.wsdl
bin/real_KCPPaymentService.wsdl
```
Linux 서버에서는 현재 OS 아키텍처에 맞는 CLI 파일에 실행 권한이 필요합니다.
```bash
chmod 755 plugins/sirsoft-pay_nhnkcp/bin/pp_cli
chmod 755 plugins/sirsoft-pay_nhnkcp/bin/pp_cli_x64
```
관리자 설정 화면의 시스템 점검 API가 실행 권한을 자동 복구할 수 있지만, 서버 권한 정책에 따라 직접 조치가 필요할 수 있습니다.
## 설치
플러그인을 G7 프로젝트의 플러그인 디렉토리에 배치합니다.
```text
plugins/sirsoft-pay_nhnkcp
```
프론트엔드 에셋을 수정한 경우 플러그인 디렉토리에서 빌드합니다.
```bash
npm install
npm run build
```
그다음 G7 관리자에서 플러그인을 활성화하고, 이커머스 결제 설정에서 PG 제공자를 `NHN KCP`로 선택합니다.
## 관리자 설정
관리자 플러그인 설정 화면에서 KCP 계약 정보를 입력합니다.
| 설정 | 설명 |
|------|------|
| 테스트 모드 | 활성화 시 KCP 테스트 환경을 사용합니다. |
| 테스트 사이트 코드 | 기본값은 `T0000`입니다. |
| 테스트 사이트 키 | KCP 테스트 site key입니다. |
| 라이브 사이트 코드 | 운영 site code입니다. `SR` prefix 없이 입력해도 플러그인이 자동 보정합니다. |
| 라이브 사이트 키 | 운영 site key입니다. 외부에 노출하지 마세요. |
| 결제 성공 URL | 기본값은 `/shop/orders/{orderId}/complete`입니다. |
| 결제 실패 URL | 기본값은 `/shop/checkout`입니다. |
| 가상계좌 입금 만료일 | 가상계좌 발급 후 입금 가능 기간입니다. |
| 에스크로 결제 사용 | 활성화 시 KCP 에스크로 결제 파라미터를 함께 전달합니다. |
| 에스크로 테스트 사이트 코드 | 테스트 에스크로 site code입니다. 기본 fallback은 `T0007`입니다. |
| 간편결제 | KCP 계약이 완료된 간편결제만 활성화하세요. |
| 타 PG와 사용가능함 | 다른 PG가 기본값이어도 KCP 간편결제 버튼을 체크아웃 화면에 표시합니다. |
PAYCO 테스트 결제는 내부 기본값으로 간편결제 테스트 site code `S6729`를 사용합니다.
## 콜백 및 통보 URL
KCP 가맹점 관리자에 아래 URL을 등록합니다. 도메인은 실제 운영 도메인으로 바꿔 입력하세요.
| 용도 | URL |
|------|-----|
| 결제 결과 Return URL | `https://your-domain.com/plugins/sirsoft-pay_nhnkcp/payment/callback` |
| 가상계좌 입금통보 URL | `https://your-domain.com/plugins/sirsoft-pay_nhnkcp/payment/vbank-notify` |
| 에스크로 공통통보 URL | `https://your-domain.com/plugins/sirsoft-pay_nhnkcp/payment/escrow-common-notify` |
결제 결과 Return URL은 브라우저가 POST하는 경로이므로 IP 제한을 적용하지 않습니다. 가상계좌 입금통보와 에스크로 공통통보는 KCP 서버가 직접 호출하므로 운영 모드에서 IP 화이트리스트를 적용합니다.
## IP 화이트리스트
운영 모드에서는 아래 IP에서 들어온 KCP 서버 통보만 허용합니다. 테스트 모드에서는 개발과 KCP testadmin 모의입금을 위해 IP 제한을 우회합니다.
| IP |
|----|
| `203.238.36.58` |
| `203.238.36.160` |
| `203.238.36.161` |
| `203.238.36.173` |
| `203.238.36.178` |
| `103.215.144.173` |
| `103.215.144.174` |
| `103.215.145.30` |
| `210.122.72.173` |
운영 전 KCP 가맹점 관리자와 최신 연동 가이드의 통보 서버 IP를 다시 확인하세요.
## 결제 흐름
### PC 결제
```text
체크아웃 주문 생성
→ 프론트엔드 핸들러가 payplus_web.jsp 결제창 실행
→ KCP가 /payment/callback 으로 enc_data, enc_info POST
→ 서버가 KCP CLI로 승인 확인
→ 주문 결제 완료 처리
→ 성공 URL로 리다이렉트
```
### 모바일 결제
```text
체크아웃 주문 생성
→ /api/plugins/sirsoft-pay_nhnkcp/mobile/approval-key 호출
→ 서버가 KCP SOAP approve 로 approval_key, pay_url 획득
→ 브라우저가 pay_url 로 form POST
→ KCP가 /payment/callback 으로 결과 POST
→ 주문 결제 완료 처리
→ 성공 URL로 리다이렉트
```
### 가상계좌
```text
결제창에서 가상계좌 발급
→ 주문 결제 정보에 은행, 계좌번호, 예금주, 만료일 저장
→ 주문은 입금대기 상태 유지
→ KCP가 /payment/vbank-notify 로 입금통보 POST
→ 입금 금액 검증 후 주문 결제 완료 처리
```
테스트 모드에서는 마이페이지 주문 상세에 KCP testadmin 모의입금 폼이 표시될 수 있습니다.
### 에스크로
에스크로를 활성화하면 결제 요청에 `escw_used=Y`, `pay_mod=O`를 전달합니다. 에스크로 결제 완료 후 관리자 주문 상세에서 운송장번호와 택배사를 입력해 KCP 배송 등록을 호출할 수 있습니다.
KCP 공통통보는 아래 이벤트를 처리합니다.
| tx_cd | 조건 | 처리 |
|-------|------|------|
| `TX02` | `cl_status=2` | 구매확인 훅 실행 |
| `TX02` | `cl_status=8` | 구매취소 훅 실행 |
| `TX02` | `cl_status=3` | 구매취소 확인 훅 실행 |
| `TX03` | - | 배송시작 훅 실행 |
### 결제 취소 / 부분취소
```text
관리자 주문 취소 요청 (cancel_pg=true)
→ 코어가 sirsoft-ecommerce.payment.refund 필터 훅 발화
→ PaymentRefundListener 가 KCP cancelPayment API 호출
· 전액취소: isPartial=false
· 부분취소: isPartial=true, totalAmt=원래 결제금액
→ 코어가 환불 레코드 생성 + 쿠폰 / 마일리지 / 재고 복원
→ CancelActivityLogListener 가 PG 응답 시각·취소 거래번호를 활동 로그에 기록
```
배송비가 포함된 주문은 전체취소 시 배송비도 함께 환불 레코드에 반영되고, 쿠폰이 적용된 주문은 실결제금액(쿠폰 차감 후) 이 PG cancelAmt 로 전달됩니다. 부분취소 시 쿠폰 최소 주문금액 조건을 더 이상 충족하지 못하면 코어가 취소 자체를 거부 (422) 하여 PG 호출이 발생하지 않습니다. KCP API 호출이 실패하면 주문 상태 변경이 롤백됩니다.
## API
### 사용자 API
| Method | Path | 설명 |
|--------|------|------|
| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/user/orders/{orderNumber}/receipt` | KCP 영수증, 현금영수증 URL 조회 |
| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/user/orders/{orderNumber}/vbank-mock-deposit-info` | 테스트 모드 가상계좌 모의입금 정보 조회 |
| `POST` | `/api/plugins/sirsoft-pay_nhnkcp/mobile/approval-key` | 모바일 결제 승인키 발급 |
### 관리자 API
| Method | Path | 설명 |
|--------|------|------|
| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/admin/vbank-notify-url` | 가상계좌/에스크로 통보 URL 조회 |
| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/admin/orders/test-mode-map` | 주문목록 테스트 모드 배지용 맵 조회 |
| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{orderNumber}/transaction-status` | 저장된 KCP 거래 정보 조회 |
| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{orderNumber}/escrow-delivery` | 에스크로 배송 등록 폼 데이터 조회 |
| `POST` | `/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{orderNumber}/escrow-delivery` | KCP 에스크로 배송 등록 |
| `GET` | `/api/plugins/sirsoft-pay_nhnkcp/admin/health` | KCP 실행 환경 점검 |
## 훅
다른 모듈이나 플러그인에서 아래 훅에 연결해 결제 흐름을 확장할 수 있습니다.
| 훅 | 타입 | 시점 |
|----|------|------|
| `sirsoft-pay_nhnkcp.payment.before_confirm` | action | KCP CLI 승인 확인 전 |
| `sirsoft-pay_nhnkcp.payment.after_confirm` | action | KCP CLI 승인 확인 후 |
| `sirsoft-pay_nhnkcp.payment.before_cancel` | action | KCP 취소 API 호출 전 |
| `sirsoft-pay_nhnkcp.payment.after_cancel` | action | KCP 취소 API 호출 후 |
| `sirsoft-pay_nhnkcp.escrow.purchase_confirmed` | action | 에스크로 구매확인 통보 수신 |
| `sirsoft-pay_nhnkcp.escrow.purchase_cancelled` | action | 에스크로 구매취소 통보 수신 |
| `sirsoft-pay_nhnkcp.escrow.denial_confirmed` | action | 에스크로 구매취소 확인 통보 수신 |
| `sirsoft-pay_nhnkcp.escrow.delivery_started` | action | 에스크로 배송시작 통보 수신 |
## 보안 및 운영 참고
- 운영 도메인의 `APP_URL`을 HTTPS 절대 URL로 정확히 설정하세요.
- 운영 site key는 외부에 노출하지 마세요.
- 운영 모드에서는 KCP 서버 통보 IP 화이트리스트가 적용됩니다.
- 결제 승인 후 서버 후속 처리에 실패하면 PG 잔존 승인을 자동 취소합니다.
- 동일 거래번호 콜백은 중복 처리하지 않도록 방어합니다.
- KCP CLI 호출 인자는 위험 문자와 제어문자를 사전에 거부합니다.
- 가상계좌와 에스크로 통보 URL은 KCP 가맹점 관리자에 반드시 등록해야 합니다.
- KCP 계약이 없는 결제수단이나 간편결제를 활성화하면 KCP 오류가 발생할 수 있습니다.
## 테스트
플러그인을 G7 프로젝트에 배치한 뒤 G7 루트에서 PHP 테스트를 실행합니다.
```bash
php artisan test plugins/sirsoft-pay_nhnkcp/tests
```
프론트엔드 테스트와 빌드는 플러그인 디렉토리에서 실행합니다.
```bash
npm install
npm run test:run
npm run build
```
## 라이선스
MIT
@@ -0,0 +1,142 @@
<?xml version="1.0" encoding="UTF-8"?>
<wsdl:definitions xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/" xmlns:mime="http://schemas.xmlsoap.org/wsdl/mime/" xmlns:http="http://schemas.xmlsoap.org/wsdl/http/" xmlns:soap12="http://schemas.xmlsoap.org/wsdl/soap12/" xmlns:ns1="http://org.apache.axis2/xsd" xmlns:wsaw="http://www.w3.org/2006/05/addressing/wsdl" xmlns:ax21="http://payment.domain.webpay.service.kcp.kr/xsd" xmlns:ns="http://webservice.act.webpay.service.kcp.kr" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" xmlns:ax22="http://domain.webpay.service.kcp.kr/xsd" targetNamespace="http://webservice.act.webpay.service.kcp.kr">
<wsdl:types>
<xs:schema xmlns:ax23="http://domain.webpay.service.kcp.kr/xsd" attributeFormDefault="qualified" elementFormDefault="qualified" targetNamespace="http://payment.domain.webpay.service.kcp.kr/xsd">
<xs:import namespace="http://domain.webpay.service.kcp.kr/xsd"/>
<xs:complexType name="ApproveReq">
<xs:sequence>
<xs:element minOccurs="0" name="accessCredentialType" nillable="true" type="ax22:AccessCredentialType"/>
<xs:element minOccurs="0" name="baseRequestType" nillable="true" type="ax22:BaseRequestType"/>
<xs:element minOccurs="0" name="escrow" type="xs:boolean"/>
<xs:element minOccurs="0" name="orderID" nillable="true" type="xs:string"/>
<xs:element minOccurs="0" name="paymentAmount" nillable="true" type="xs:string"/>
<xs:element minOccurs="0" name="paymentMethod" nillable="true" type="xs:string"/>
<xs:element minOccurs="0" name="productName" nillable="true" type="xs:string"/>
<xs:element minOccurs="0" name="returnUrl" nillable="true" type="xs:string"/>
<xs:element minOccurs="0" name="siteCode" nillable="true" type="xs:string"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ApproveRes">
<xs:sequence>
<xs:element minOccurs="0" name="approvalKey" nillable="true" type="xs:string"/>
<xs:element minOccurs="0" name="baseResponseType" nillable="true" type="ax22:BaseResponseType"/>
<xs:element minOccurs="0" name="payUrl" nillable="true" type="xs:string"/>
</xs:sequence>
</xs:complexType>
</xs:schema>
<xs:schema xmlns:ax24="http://payment.domain.webpay.service.kcp.kr/xsd" attributeFormDefault="qualified" elementFormDefault="qualified" targetNamespace="http://webservice.act.webpay.service.kcp.kr">
<xs:import namespace="http://payment.domain.webpay.service.kcp.kr/xsd"/>
<xs:element name="approve">
<xs:complexType>
<xs:sequence>
<xs:element minOccurs="0" name="req" nillable="true" type="ax24:ApproveReq"/>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="approveResponse">
<xs:complexType>
<xs:sequence>
<xs:element minOccurs="0" name="return" nillable="true" type="ax24:ApproveRes"/>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>
<xs:schema attributeFormDefault="qualified" elementFormDefault="qualified" targetNamespace="http://domain.webpay.service.kcp.kr/xsd">
<xs:complexType name="AccessCredentialType">
<xs:sequence>
<xs:element minOccurs="0" name="accessLicense" nillable="true" type="xs:string"/>
<xs:element minOccurs="0" name="signature" nillable="true" type="xs:string"/>
<xs:element minOccurs="0" name="timestamp" nillable="true" type="xs:string"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="BaseRequestType">
<xs:sequence>
<xs:element minOccurs="0" name="detailLevel" nillable="true" type="xs:string"/>
<xs:element minOccurs="0" name="requestApp" nillable="true" type="xs:string"/>
<xs:element minOccurs="0" name="requestID" nillable="true" type="xs:string"/>
<xs:element minOccurs="0" name="userAgent" nillable="true" type="xs:string"/>
<xs:element minOccurs="0" name="version" nillable="true" type="xs:string"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="BaseResponseType">
<xs:sequence>
<xs:element minOccurs="0" name="detailLevel" nillable="true" type="xs:string"/>
<xs:element minOccurs="0" name="error" nillable="true" type="ax22:ErrorType"/>
<xs:element minOccurs="0" name="messageID" nillable="true" type="xs:string"/>
<xs:element minOccurs="0" name="release" nillable="true" type="xs:string"/>
<xs:element minOccurs="0" name="requestID" nillable="true" type="xs:string"/>
<xs:element minOccurs="0" name="responseType" nillable="true" type="xs:string"/>
<xs:element minOccurs="0" name="timestamp" nillable="true" type="xs:string"/>
<xs:element minOccurs="0" name="version" nillable="true" type="xs:string"/>
<xs:element maxOccurs="unbounded" minOccurs="0" name="warningList" nillable="true" type="ax22:ErrorType"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ErrorType">
<xs:sequence>
<xs:element minOccurs="0" name="code" nillable="true" type="xs:string"/>
<xs:element minOccurs="0" name="detail" nillable="true" type="xs:string"/>
<xs:element minOccurs="0" name="message" nillable="true" type="xs:string"/>
</xs:sequence>
</xs:complexType>
</xs:schema>
</wsdl:types>
<wsdl:message name="approveRequest">
<wsdl:part name="parameters" element="ns:approve"/>
</wsdl:message>
<wsdl:message name="approveResponse">
<wsdl:part name="parameters" element="ns:approveResponse"/>
</wsdl:message>
<wsdl:portType name="PayServicePortType">
<wsdl:operation name="approve">
<wsdl:input message="ns:approveRequest" wsaw:Action="urn:approve"/>
<wsdl:output message="ns:approveResponse" wsaw:Action="urn:approveResponse"/>
</wsdl:operation>
</wsdl:portType>
<wsdl:binding name="PayServiceSoap11Binding" type="ns:PayServicePortType">
<soap:binding transport="http://schemas.xmlsoap.org/soap/http" style="document"/>
<wsdl:operation name="approve">
<soap:operation soapAction="urn:approve" style="document"/>
<wsdl:input>
<soap:body use="literal"/>
</wsdl:input>
<wsdl:output>
<soap:body use="literal"/>
</wsdl:output>
</wsdl:operation>
</wsdl:binding>
<wsdl:binding name="PayServiceSoap12Binding" type="ns:PayServicePortType">
<soap12:binding transport="http://schemas.xmlsoap.org/soap/http" style="document"/>
<wsdl:operation name="approve">
<soap12:operation soapAction="urn:approve" style="document"/>
<wsdl:input>
<soap12:body use="literal"/>
</wsdl:input>
<wsdl:output>
<soap12:body use="literal"/>
</wsdl:output>
</wsdl:operation>
</wsdl:binding>
<wsdl:binding name="PayServiceHttpBinding" type="ns:PayServicePortType">
<http:binding verb="POST"/>
<wsdl:operation name="approve">
<http:operation location="PayService/approve"/>
<wsdl:input>
<mime:content type="text/xml" part="approve"/>
</wsdl:input>
<wsdl:output>
<mime:content type="text/xml" part="approve"/>
</wsdl:output>
</wsdl:operation>
</wsdl:binding>
<wsdl:service name="PayService">
<wsdl:port name="PayServiceHttpSoap11Endpoint" binding="ns:PayServiceSoap11Binding">
<soap:address location="https://testsmpay.kcp.co.kr/services/KCPPaymentService"/>
</wsdl:port>
<wsdl:port name="PayServiceHttpSoap12Endpoint" binding="ns:PayServiceSoap12Binding">
<soap12:address location="https://testsmpay.kcp.co.kr/services/KCPPaymentService"/>
</wsdl:port>
<wsdl:port name="PayServiceHttpEndpoint" binding="ns:PayServiceHttpBinding">
<http:address location="https://testsmpay.kcp.co.kr/services/KCPPaymentService"/>
</wsdl:port>
</wsdl:service>
</wsdl:definitions>
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,5 @@
-----BEGIN RSA PUBLIC KEY-----
MIGJAoGBAMEn9niy+ZB7GGn9JOA9csyJo2vTpd3/ieHW8nCfiDf77YJvEtUmudCU
JXMNQ1uBuHq7+IzajoJhhvGXyOHVr9w+Oq/d8XZPPe7X2agh1WAMDV4mjQNaAncr
DU1qxlTFuAdKRkK7qkqyWSHHtq0HaW/zmBmcIoGn4RwY9y7oKFBpAgMBAAE=
-----END RSA PUBLIC KEY-----
@@ -0,0 +1,145 @@
<?xml version="1.0" encoding="UTF-8"?>
<wsdl:definitions xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/" xmlns:mime="http://schemas.xmlsoap.org/wsdl/mime/" xmlns:http="http://schemas.xmlsoap.org/wsdl/http/" xmlns:soap12="http://schemas.xmlsoap.org/wsdl/soap12/" xmlns:ns1="http://org.apache.axis2/xsd" xmlns:wsaw="http://www.w3.org/2006/05/addressing/wsdl" xmlns:ax21="http://payment.domain.webpay.service.kcp.kr/xsd" xmlns:ns="http://webservice.act.webpay.service.kcp.kr" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" xmlns:ax22="http://domain.webpay.service.kcp.kr/xsd" targetNamespace="http://webservice.act.webpay.service.kcp.kr">
<wsdl:documentation>
KCP Payment Service
</wsdl:documentation>
<wsdl:types>
<xs:schema xmlns:ax23="http://domain.webpay.service.kcp.kr/xsd" targetNamespace="http://payment.domain.webpay.service.kcp.kr/xsd" attributeFormDefault="qualified" elementFormDefault="qualified">
<xs:import namespace="http://domain.webpay.service.kcp.kr/xsd"/>
<xs:complexType name="ApproveReq">
<xs:sequence>
<xs:element name="accessCredentialType" minOccurs="0" type="ax22:AccessCredentialType" nillable="true"/>
<xs:element name="baseRequestType" minOccurs="0" type="ax22:BaseRequestType" nillable="true"/>
<xs:element name="escrow" minOccurs="0" type="xs:boolean"/>
<xs:element name="orderID" minOccurs="0" type="xs:string" nillable="true"/>
<xs:element name="paymentAmount" minOccurs="0" type="xs:string" nillable="true"/>
<xs:element name="paymentMethod" minOccurs="0" type="xs:string" nillable="true"/>
<xs:element name="productName" minOccurs="0" type="xs:string" nillable="true"/>
<xs:element name="returnUrl" minOccurs="0" type="xs:string" nillable="true"/>
<xs:element name="siteCode" minOccurs="0" type="xs:string" nillable="true"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ApproveRes">
<xs:sequence>
<xs:element name="approvalKey" minOccurs="0" type="xs:string" nillable="true"/>
<xs:element name="baseResponseType" minOccurs="0" type="ax22:BaseResponseType" nillable="true"/>
<xs:element name="payUrl" minOccurs="0" type="xs:string" nillable="true"/>
</xs:sequence>
</xs:complexType>
</xs:schema>
<xs:schema xmlns:ax24="http://payment.domain.webpay.service.kcp.kr/xsd" targetNamespace="http://webservice.act.webpay.service.kcp.kr" attributeFormDefault="qualified" elementFormDefault="qualified">
<xs:import namespace="http://payment.domain.webpay.service.kcp.kr/xsd"/>
<xs:element name="approve">
<xs:complexType>
<xs:sequence>
<xs:element name="req" minOccurs="0" type="ax24:ApproveReq" nillable="true"/>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="approveResponse">
<xs:complexType>
<xs:sequence>
<xs:element name="return" minOccurs="0" type="ax24:ApproveRes" nillable="true"/>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>
<xs:schema targetNamespace="http://domain.webpay.service.kcp.kr/xsd" attributeFormDefault="qualified" elementFormDefault="qualified">
<xs:complexType name="AccessCredentialType">
<xs:sequence>
<xs:element name="accessLicense" minOccurs="0" type="xs:string" nillable="true"/>
<xs:element name="signature" minOccurs="0" type="xs:string" nillable="true"/>
<xs:element name="timestamp" minOccurs="0" type="xs:string" nillable="true"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="BaseRequestType">
<xs:sequence>
<xs:element name="detailLevel" minOccurs="0" type="xs:string" nillable="true"/>
<xs:element name="requestApp" minOccurs="0" type="xs:string" nillable="true"/>
<xs:element name="requestID" minOccurs="0" type="xs:string" nillable="true"/>
<xs:element name="userAgent" minOccurs="0" type="xs:string" nillable="true"/>
<xs:element name="version" minOccurs="0" type="xs:string" nillable="true"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="BaseResponseType">
<xs:sequence>
<xs:element name="detailLevel" minOccurs="0" type="xs:string" nillable="true"/>
<xs:element name="error" minOccurs="0" type="ax22:ErrorType" nillable="true"/>
<xs:element name="messageID" minOccurs="0" type="xs:string" nillable="true"/>
<xs:element name="release" minOccurs="0" type="xs:string" nillable="true"/>
<xs:element name="requestID" minOccurs="0" type="xs:string" nillable="true"/>
<xs:element name="responseType" minOccurs="0" type="xs:string" nillable="true"/>
<xs:element name="timestamp" minOccurs="0" type="xs:string" nillable="true"/>
<xs:element name="version" minOccurs="0" type="xs:string" nillable="true"/>
<xs:element name="warningList" maxOccurs="unbounded" minOccurs="0" type="ax22:ErrorType" nillable="true"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ErrorType">
<xs:sequence>
<xs:element name="code" minOccurs="0" type="xs:string" nillable="true"/>
<xs:element name="detail" minOccurs="0" type="xs:string" nillable="true"/>
<xs:element name="message" minOccurs="0" type="xs:string" nillable="true"/>
</xs:sequence>
</xs:complexType>
</xs:schema>
</wsdl:types>
<wsdl:message name="approveRequest">
<wsdl:part name="parameters" element="ns:approve"/>
</wsdl:message>
<wsdl:message name="approveResponse">
<wsdl:part name="parameters" element="ns:approveResponse"/>
</wsdl:message>
<wsdl:portType name="KCPPaymentServicePortType">
<wsdl:operation name="approve">
<wsdl:input message="ns:approveRequest" wsaw:Action="urn:approve"/>
<wsdl:output message="ns:approveResponse" wsaw:Action="urn:approveResponse"/>
</wsdl:operation>
</wsdl:portType>
<wsdl:binding name="KCPPaymentServiceSoap11Binding" type="ns:KCPPaymentServicePortType">
<soap:binding transport="http://schemas.xmlsoap.org/soap/http" style="document"/>
<wsdl:operation name="approve">
<soap:operation soapAction="urn:approve" style="document"/>
<wsdl:input>
<soap:body use="literal"/>
</wsdl:input>
<wsdl:output>
<soap:body use="literal"/>
</wsdl:output>
</wsdl:operation>
</wsdl:binding>
<wsdl:binding name="KCPPaymentServiceSoap12Binding" type="ns:KCPPaymentServicePortType">
<soap12:binding transport="http://schemas.xmlsoap.org/soap/http" style="document"/>
<wsdl:operation name="approve">
<soap12:operation soapAction="urn:approve" style="document"/>
<wsdl:input>
<soap12:body use="literal"/>
</wsdl:input>
<wsdl:output>
<soap12:body use="literal"/>
</wsdl:output>
</wsdl:operation>
</wsdl:binding>
<wsdl:binding name="KCPPaymentServiceHttpBinding" type="ns:KCPPaymentServicePortType">
<http:binding verb="POST"/>
<wsdl:operation name="approve">
<http:operation location="KCPPaymentService/approve"/>
<wsdl:input>
<mime:content type="text/xml" part="approve"/>
</wsdl:input>
<wsdl:output>
<mime:content type="text/xml" part="approve"/>
</wsdl:output>
</wsdl:operation>
</wsdl:binding>
<wsdl:service name="KCPPaymentService">
<wsdl:port name="KCPPaymentServiceHttpSoap11Endpoint" binding="ns:KCPPaymentServiceSoap11Binding">
<soap:address location="https://smpay.kcp.co.kr/services/KCPPaymentService.KCPPaymentServiceHttpSoap11Endpoint/"/>
</wsdl:port>
<wsdl:port name="KCPPaymentServiceHttpSoap12Endpoint" binding="ns:KCPPaymentServiceSoap12Binding">
<soap12:address location="https://smpay.kcp.co.kr/services/KCPPaymentService.KCPPaymentServiceHttpSoap12Endpoint/"/>
</wsdl:port>
<wsdl:port name="KCPPaymentServiceHttpEndpoint" binding="ns:KCPPaymentServiceHttpBinding">
<http:address location="https://smpay.kcp.co.kr/services/KCPPaymentService.KCPPaymentServiceHttpEndpoint/"/>
</wsdl:port>
</wsdl:service>
</wsdl:definitions>
@@ -0,0 +1,20 @@
{
"name": "plugins/sirsoft-pay_nhnkcp",
"description": "NHN KCP PG Plugin for G7 platform",
"version": "1.0.0-beta.4",
"type": "library",
"authors": [
{
"name": "sirsoft",
"email": "contact@sirsoft.com"
}
],
"require": {
"php": "^8.2"
},
"autoload": {
"psr-4": {
"Plugins\\Sirsoft\\PayNhnkcp\\": ["src/", "./"]
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "88988d859f2433db8b07b8700f481c2d",
"packages": [],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "^8.2"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"
}
@@ -0,0 +1,24 @@
{
"_meta": {
"version": "1.0.0",
"description": "NHN KCP PG 플러그인 환경설정"
},
"defaults": {
"is_test_mode": true,
"test_site_cd": "T0000",
"test_site_key": "",
"live_site_cd": "",
"live_site_key": "",
"redirect_success_url": "/shop/orders/{orderId}/complete",
"redirect_fail_url": "/shop/checkout"
},
"frontend_schema": {
"is_test_mode": { "expose": false },
"test_site_cd": { "expose": false },
"test_site_key": { "expose": false },
"live_site_cd": { "expose": false },
"live_site_key": { "expose": false },
"redirect_success_url": { "expose": false },
"redirect_fail_url": { "expose": false }
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,119 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$comment": "레이아웃 편집기 스펙 — NHN KCP 결제 플러그인 도메인 sampleData. plugin_settings 레이아웃의 data_source 중 플러그인 고유 ID(vbank_info / health) 의 응답 shape 를 실제 바인딩(vbank_info.data.{notify_url,guide_steps,...} / health.data.{data.overall,checks.*}) 역추론해 작성. 공통 ID 'settings' 는 admin 템플릿 스펙 sampleData 가 커버(전역 고유 ID 병합 specMerge 템플릿 우선)하므로 본 스펙에 미포함.",
"pluginId": "sirsoft-pay_nhnkcp",
"version": "1.0.0",
"description": "NHN KCP 결제 플러그인 레이아웃 편집기 샘플 데이터 (가상계좌 입금통보 URL / 연동 상태 점검).",
"sampleData": {
"byDataSourceId": {
"vbank_info": {
"data": {
"title": "가상계좌 입금통보 URL",
"description": "아래 URL 을 PG 관리자에 등록하면 입금 완료 시 자동으로 주문이 처리됩니다.",
"notify_url": "https://example.com/api/plugins/sirsoft-pay_nhnkcp/vbank/notify",
"copyable": true,
"guide_url": "https://example.com/docs/vbank",
"guide_steps": [
"PG 관리자 페이지에 로그인합니다.",
"가상계좌 입금통보 URL 설정 메뉴로 이동합니다.",
"위 URL 을 붙여넣고 저장합니다."
]
}
},
"health": {
"data": {
"summary": {
"pc_payment_ready": true,
"mobile_payment_ready": false,
"mobile_vbank_only_ready": false,
"error_count": 1,
"warning_count": 0,
"fixed_count": 0
},
"checks": [
{
"id": "exec_function",
"category": "pc",
"label": "PHP exec() 함수",
"status": "ok",
"detail": "CLI 호출 가능"
},
{
"id": "bin_directory",
"category": "pc",
"label": "CLI 바이너리 디렉토리",
"status": "ok",
"detail": "디렉토리 존재"
},
{
"id": "cli_pp_cli_exe",
"category": "pc",
"label": "CLI 바이너리 (Windows) — pp_cli_exe.exe",
"status": "ok",
"detail": "Windows 바이너리 — 실행 권한 검사 생략"
},
{
"id": "pub_key",
"category": "pc",
"label": "KCP 공개키 (pub.key)",
"status": "ok",
"detail": "파일 존재"
},
{
"id": "soap_extension",
"category": "mobile",
"label": "PHP SOAP 확장",
"status": "error",
"detail": "모바일 결제 (SmartPhone Pay) 가 동작하지 않습니다.",
"remediation": "PHP SOAP 확장을 설치하고 PHP-FPM 을 재시작하세요:\nUbuntu/Debian: sudo apt install php8.x-soap && sudo systemctl restart php8.x-fpm\nRHEL/CentOS: sudo yum install php-soap && sudo systemctl restart php-fpm\n확인: php -m | grep soap"
},
{
"id": "wsdl_file",
"category": "mobile",
"label": "WSDL 파일",
"status": "ok",
"detail": "테스트/라이브 WSDL 모두 존재"
}
]
}
}
}
},
"states": {
"comment": "플러그인 설정 변종.",
"groups": [
{
"comment": "결제/인증 플러그인 설정. _local.form.is_test_mode 로 운영모드 경고(live_mode_warning)를 if 분기한다.",
"scope": {
"kind": "route",
"match": "*/admin/plugins/sirsoft-pay_nhnkcp/settings"
},
"items": [
{
"id": "test_mode_on",
"label": "$t:editor.state.test_mode_on",
"default": true,
"initialState": {
"local": {
"form": {
"is_test_mode": true
}
}
}
},
{
"id": "test_mode_off",
"label": "$t:editor.state.test_mode_off",
"initialState": {
"local": {
"form": {
"is_test_mode": false
}
}
}
}
]
}
]
}
}
@@ -0,0 +1,10 @@
<?php
return [
'action' => [
'cancel' => 'PG Payment Cancellation',
],
'description' => [
'payment_cancel' => 'NHN KCP confirmed payment cancellation (order :order_number, refunded :refund_amount KRW).',
],
];
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
return [
'refund' => [
'missing_tid' => 'Cannot process refund: transaction number (tno) is missing.',
'default_reason' => 'Buyer refund request',
'in_progress' => 'NHN KCP refund is already in progress.',
],
'errors' => [
'wsdl_missing' => 'KCP WSDL file not found: :file',
'approval_key_error' => 'KCP approval key error [:code]: :message',
'soap_error' => 'KCP SOAP integration error: :message',
'cli_binary_missing' => 'KCP CLI binary missing: :path',
'cli_binary_not_executable' => 'KCP CLI binary is not executable. Operator action required (sudo chmod 755 :path)',
],
];
@@ -0,0 +1,24 @@
<?php
return [
'payco' => [
'name' => 'PAYCO',
'description' => 'Pay with PAYCO (NHN KCP)',
],
'naverpay' => [
'name' => 'Naver Pay (Card)',
'description' => 'Pay by Naver Pay credit card (NHN KCP)',
],
'naverpay_point' => [
'name' => 'Naver Pay (Point)',
'description' => 'Pay with Naver Pay Money/Points (NHN KCP)',
],
'kakaopay' => [
'name' => 'Kakao Pay',
'description' => 'Pay with Kakao Pay (NHN KCP)',
],
'applepay' => [
'name' => 'Apple Pay',
'description' => 'Pay with Apple Pay (NHN KCP) · Apple Pay is available only for mobile payments on iOS devices.',
],
];
@@ -0,0 +1,13 @@
<?php
/**
* NHN KCP PG provider meta (English)
*
* Label referenced by name_key in the
* `sirsoft-ecommerce.payment.registered_pg_providers` hook payload.
* Resolved via the active language pack at runtime.
*/
return [
'name' => 'NHN KCP',
'description' => 'NHN KCP payment gateway (Standard Pay) — card / bank transfer / virtual account / mobile',
];
@@ -0,0 +1,10 @@
<?php
return [
'action' => [
'cancel' => 'PG 결제 취소',
],
'description' => [
'payment_cancel' => 'NHN KCP 결제 취소가 확인되었습니다 (주문 :order_number, 환불금액 :refund_amount원).',
],
];
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
return [
'refund' => [
'missing_tid' => '거래번호(tno)가 없어 환불을 진행할 수 없습니다.',
'default_reason' => '구매자 환불 요청',
'in_progress' => 'NHN KCP 환불이 이미 처리 중입니다.',
],
'errors' => [
'wsdl_missing' => 'KCP WSDL 파일이 없습니다: :file',
'approval_key_error' => 'KCP 승인키 오류 [:code]: :message',
'soap_error' => 'KCP SOAP 연동 오류: :message',
'cli_binary_missing' => 'KCP CLI 바이너리 누락: :path',
'cli_binary_not_executable' => 'KCP CLI 바이너리 실행 권한 부족 — 운영자 조치 필요 (sudo chmod 755 :path)',
],
];
@@ -0,0 +1,24 @@
<?php
return [
'payco' => [
'name' => 'PAYCO',
'description' => 'PAYCO로 결제 (NHN KCP)',
],
'naverpay' => [
'name' => '네이버페이 (카드)',
'description' => '네이버페이 신용카드로 결제 (NHN KCP)',
],
'naverpay_point' => [
'name' => '네이버페이 (포인트)',
'description' => '네이버페이 머니/포인트로 결제 (NHN KCP)',
],
'kakaopay' => [
'name' => '카카오페이',
'description' => '카카오페이로 결제 (NHN KCP)',
],
'applepay' => [
'name' => '애플페이',
'description' => '애플페이로 결제 (NHN KCP) · 애플페이는 IOS 기기에 모바일결제만 가능합니다.',
],
];
@@ -0,0 +1,12 @@
<?php
/**
* NHN KCP PG 프로바이더 메타 (Korean)
*
* `sirsoft-ecommerce.payment.registered_pg_providers` 훅 페이로드의
* name_key 가 가리키는 라벨. 활성 언어팩으로 자동 보강된다.
*/
return [
'name' => 'NHN KCP',
'description' => 'NHN KCP 결제 게이트웨이 (Standard Pay 연동) — 카드/계좌이체/가상계좌/휴대폰 결제 지원',
];
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
{
"name": "@g7/sirsoft-pay_nhnkcp",
"version": "1.0.0-beta.4",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
},
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
"vite": "^5.4.14",
"vitest": "^4.1.8"
}
}
@@ -0,0 +1,34 @@
{
"identifier": "sirsoft-pay_nhnkcp",
"vendor": "sirsoft",
"name": {
"ko": "NHN KCP",
"en": "NHN KCP"
},
"version": "1.0.0-beta.4",
"license": "MIT",
"github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_nhnkcp",
"github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-pay_nhnkcp/blob/main/CHANGELOG.md",
"description": {
"ko": "NHN KCP 결제 게이트웨이 (Standard Pay 연동)",
"en": "NHN KCP payment gateway (Standard Pay integration)"
},
"g7_version": ">=7.0.0-beta.2",
"dependencies": {
"modules": {
"sirsoft-ecommerce": ">=1.0.0-beta.5"
},
"plugins": {}
},
"assets": {
"js": {
"entry": "resources/js/index.ts",
"output": "dist/js/plugin.iife.js"
},
"handlers": true
},
"loading": {
"strategy": "global",
"priority": 100
}
}
@@ -0,0 +1,204 @@
<?php
namespace Plugins\Sirsoft\PayNhnkcp;
use App\Extension\AbstractPlugin;
/**
* NHN KCP PG 플러그인
*
* NHN KCP Standard Pay(카드/계좌이체/가상계좌/휴대폰) 연동을 제공합니다.
* sirsoft-ecommerce 모듈 전용 플러그인입니다.
*/
class Plugin extends AbstractPlugin
{
public function getMetadata(): array
{
return [
'author' => 'Sirsoft',
'license' => 'MIT',
'homepage' => 'https://sir.kr',
'keywords' => ['payment', 'nhnkcp', 'kcp', 'pg', 'card', 'ecommerce'],
];
}
public function getSettingsSchema(): array
{
return [
'is_test_mode' => [
'type' => 'boolean',
'default' => true,
'label' => ['ko' => '테스트 모드', 'en' => 'Test Mode'],
'hint' => [
'ko' => '테스트 모드에서는 실제 결제가 발생하지 않습니다.',
'en' => 'No real payments occur in test mode.',
],
],
'test_site_cd' => [
'type' => 'string',
'default' => 'T0000',
'label' => ['ko' => '테스트 사이트 코드 (site_cd)', 'en' => 'Test Site Code (site_cd)'],
'hint' => [
'ko' => 'KCP에서 발급받은 테스트 사이트 코드 (기본값: T0000)',
'en' => 'Test site code issued by KCP (default: T0000)',
],
],
'test_site_key' => [
'type' => 'string',
'default' => '',
'sensitive' => true,
'label' => ['ko' => '테스트 사이트 키 (site_key)', 'en' => 'Test Site Key (site_key)'],
'hint' => [
'ko' => 'KCP에서 발급받은 테스트 사이트 키',
'en' => 'Test site key issued by KCP',
],
],
'live_site_cd' => [
'type' => 'string',
'default' => '',
'label' => ['ko' => '라이브 사이트 코드 (site_cd)', 'en' => 'Live Site Code (site_cd)'],
'hint' => [
'ko' => 'KCP에서 발급받은 라이브 사이트 코드 (SR prefix 제외하고 입력)',
'en' => 'Live site code issued by KCP (enter without SR prefix)',
],
],
'live_site_key' => [
'type' => 'string',
'default' => '',
'sensitive' => true,
'label' => ['ko' => '라이브 사이트 키 (site_key)', 'en' => 'Live Site Key (site_key)'],
'hint' => [
'ko' => '외부에 노출되지 않도록 주의하세요.',
'en' => 'Keep this key secret.',
],
],
'redirect_success_url' => [
'type' => 'string',
'default' => '/shop/orders/{orderId}/complete',
'label' => ['ko' => '결제 성공 리다이렉트 URL', 'en' => 'Payment Success Redirect URL'],
'hint' => [
'ko' => '상대 경로(/shop/...) 또는 전체 URL(https://...) 모두 가능합니다. {orderId}는 주문번호로 자동 치환됩니다.',
'en' => 'Supports relative paths (/shop/...) or full URLs (https://...). {orderId} will be replaced with the actual order number.',
],
],
'redirect_fail_url' => [
'type' => 'string',
'default' => '/shop/checkout',
'label' => ['ko' => '결제 실패 리다이렉트 URL', 'en' => 'Payment Failure Redirect URL'],
'hint' => [
'ko' => '상대 경로 또는 전체 URL 모두 가능합니다. 오류 정보는 쿼리 파라미터로 자동 추가됩니다.',
'en' => 'Supports relative paths or full URLs. Error details are appended as query parameters.',
],
],
'use_escrow' => [
'type' => 'boolean',
'default' => false,
'label' => ['ko' => '에스크로 결제 활성화', 'en' => 'Enable Escrow Payment'],
'hint' => [
'ko' => 'KCP 에스크로 결제 사용 시 활성화. 별도 site_cd 가 필요합니다.',
'en' => 'Enable KCP escrow payment. A separate site_cd is required.',
],
],
'escrow_test_site_cd' => [
'type' => 'string',
'default' => '',
'label' => ['ko' => '테스트 에스크로 사이트 코드', 'en' => 'Test Escrow Site Code'],
'hint' => [
'ko' => 'KCP 에스크로 테스트 site_cd. 미입력 시 일반 test_site_cd 를 그대로 사용.',
'en' => 'KCP escrow test site_cd. Falls back to regular test_site_cd if empty.',
],
],
'vbank_expire_days' => [
'type' => 'integer',
'default' => 3,
'min' => 1,
'max' => 30,
'label' => ['ko' => '가상계좌 입금 만료(일)', 'en' => 'Virtual Account Expiry (days)'],
'hint' => [
'ko' => '가상계좌 발급 후 입금 가능 기간. 모바일 vcnt_expire_term 값으로 전달.',
'en' => 'Days the virtual account remains payable after issuance.',
],
],
'easy_pay_allow_with_other_pg' => ['type' => 'boolean', 'default' => false],
'easy_pay_payco' => ['type' => 'boolean', 'default' => false],
'easy_pay_naverpay' => ['type' => 'boolean', 'default' => false],
'easy_pay_naverpay_point' => ['type' => 'boolean', 'default' => false],
'easy_pay_kakaopay' => ['type' => 'boolean', 'default' => false],
'easy_pay_applepay' => ['type' => 'boolean', 'default' => false],
];
}
public function getConfigValues(): array
{
return [
'is_test_mode' => true,
'test_site_cd' => 'T0000',
'test_site_key' => '',
'live_site_cd' => '',
'live_site_key' => '',
'redirect_success_url' => '/shop/orders/{orderId}/complete',
'redirect_fail_url' => '/shop/checkout',
'use_escrow' => false,
'escrow_test_site_cd' => '',
'vbank_expire_days' => 3,
'easy_pay_allow_with_other_pg' => false,
'easy_pay_payco' => false,
'easy_pay_naverpay' => false,
'easy_pay_naverpay_point' => false,
'easy_pay_kakaopay' => false,
'easy_pay_applepay' => false,
];
}
public function getHookListeners(): array
{
return [
Listeners\RegisterPgProviderListener::class,
Listeners\PaymentRefundListener::class,
Listeners\CancelActivityLogListener::class,
Listeners\RegisterEasyPayMethodsListener::class,
Listeners\AdjustEcommercePaymentMethodsLayoutListener::class,
Listeners\EnsureAdminOrderListTestBadgeLayoutListener::class,
Listeners\EnsureAdminOrderDetailPaymentQueryLayoutListener::class,
Listeners\RestoreLayoutExtensionsAfterUpdateListener::class,
];
}
public function getHooks(): array
{
return [
[
'name' => 'sirsoft-pay_nhnkcp.payment.before_confirm',
'type' => 'action',
'description' => [
'ko' => 'KCP 결제 승인 확인 전',
'en' => 'Before KCP payment confirmation',
],
],
[
'name' => 'sirsoft-pay_nhnkcp.payment.after_confirm',
'type' => 'action',
'description' => [
'ko' => 'KCP 결제 승인 확인 완료 후',
'en' => 'After KCP payment confirmation completed',
],
],
[
'name' => 'sirsoft-pay_nhnkcp.payment.before_cancel',
'type' => 'action',
'description' => [
'ko' => 'KCP 결제 취소 API 호출 전 (본인인증 등 확장 지점)',
'en' => 'Before KCP cancel API call (extension point for re-auth, etc.)',
],
],
[
'name' => 'sirsoft-pay_nhnkcp.payment.after_cancel',
'type' => 'action',
'description' => [
'ko' => 'KCP 결제 취소 완료 후',
'en' => 'After KCP cancel completed',
],
],
];
}
}
@@ -0,0 +1,295 @@
{
"target_layout": "admin_ecommerce_order_list",
"priority": 200,
"data_sources": [
{
"id": "nhnkcp_test_map",
"label_key": "$t:sirsoft-pay_nhnkcp.editor.data_source.nhnkcp_test_map",
"type": "api",
"endpoint": "/api/plugins/sirsoft-pay_nhnkcp/admin/orders/test-mode-map",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"loading_strategy": "progressive"
},
{
"id": "nhnkcp_easy_pay_display_map",
"label_key": "$t:sirsoft-pay_nhnkcp.editor.data_source.nhnkcp_easy_pay_display_map",
"type": "api",
"endpoint": "/api/plugins/sirsoft-pay_nhnkcp/admin/orders/easy-pay-display-map",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"loading_strategy": "progressive"
},
{
"id": "nicepay_test_map",
"label_key": "$t:sirsoft-pay_nhnkcp.editor.data_source.nicepay_test_map",
"type": "api",
"endpoint": "/api/plugins/sirsoft-pay_nicepayments/admin/orders/test-mode-map",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"loading_strategy": "progressive",
"condition": "{{installed_plugins?.['sirsoft-pay_nicepayments']?.is_active === true}}"
}
],
"injections": [
{
"target_id": "order_datagrid",
"position": "inject_props",
"props": {
"columns": [
{
"field": "no",
"header": "NO",
"width": "60px",
"sortable": false,
"required": false,
"cellChildren": [
{ "type": "basic", "name": "Span", "text": "{{row.no}}" }
]
},
{
"field": "ordered_at",
"header": "$t:sirsoft-ecommerce.admin.order.column.order_date",
"width": "140px",
"sortable": true,
"required": true,
"cellChildren": [
{ "type": "basic", "name": "Span", "text": "{{row.ordered_at_formatted}}" }
]
},
{
"field": "order_number",
"header": "$t:sirsoft-ecommerce.admin.order.column.order_number",
"width": "140px",
"sortable": true,
"required": true,
"cellChildren": [
{
"type": "basic",
"name": "Span",
"props": { "className": "font-medium text-blue-600 dark:text-blue-400 cursor-pointer hover:underline" },
"text": "{{row.order_number}}",
"actions": [
{ "type": "click", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders/{{row.order_number}}" } }
]
}
]
},
{
"field": "product",
"header": "$t:sirsoft-ecommerce.admin.order.column.product",
"width": "auto",
"sortable": false,
"required": false,
"cellChildren": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex items-start gap-2" },
"children": [
{
"type": "basic",
"name": "Img",
"props": { "src": "{{row.first_option?.thumbnail_url}}", "alt": "{{row.first_option?.product_name}}", "className": "w-10 h-10 rounded object-cover flex-shrink-0 bg-gray-100 dark:bg-gray-700" },
"if": "{{row.first_option?.thumbnail_url}}"
},
{
"type": "basic",
"name": "Div",
"props": { "className": "space-y-0.5 min-w-0" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "font-medium" },
"children": [
{ "type": "basic", "name": "A", "props": { "className": "hover:underline hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-400 cursor-pointer", "href": "{{'\/admin\/ecommerce\/products\/' + row.first_option?.product_code + '\/edit'}}", "target": "_blank" }, "text": "{{row.first_option?.product_name}}", "if": "{{row.first_option?.product_code}}" },
{ "type": "basic", "name": "Span", "text": "{{row.first_option?.product_name}}", "if": "{{!row.first_option?.product_code}}" },
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-500 dark:text-gray-400 font-normal ml-1" }, "text": "$t:defer:sirsoft-ecommerce.admin.order.product.more|count={{row.options_count - 1}}", "if": "{{row.options_count > 1}}" }
]
},
{ "type": "basic", "name": "Div", "props": { "className": "text-tertiary" }, "text": "{{row.first_option?.product_option_name}}", "if": "{{row.first_option?.product_option_name}}" }
]
}
]
}
]
},
{
"field": "order_amount",
"header": "$t:sirsoft-ecommerce.admin.order.column.order_amount",
"width": "140px",
"sortable": true,
"required": true,
"cellChildren": [
{
"type": "basic",
"name": "Div",
"props": { "className": "row-stack" },
"children": [
{ "type": "basic", "name": "Span", "props": { "className": "font-medium" }, "text": "{{row.total_amount_formatted}}" },
{ "type": "basic", "name": "Span", "props": { "className": "text-xs text-gray-400 dark:text-gray-500" }, "text": "$t:defer:sirsoft-ecommerce.admin.order.column.shipping_fee_short|amount={{row.total_shipping_amount_formatted}}", "if": "{{row.total_shipping_amount > 0}}" },
{ "type": "basic", "name": "Span", "props": { "className": "text-xs text-green-600 dark:text-green-400" }, "text": "$t:defer:sirsoft-ecommerce.admin.order.column.paid_short|amount={{row.total_paid_amount_formatted}}", "if": "{{row.total_paid_amount > 0}}" },
{ "type": "basic", "name": "Span", "props": { "className": "text-xs text-red-600 dark:text-red-400" }, "text": "$t:defer:sirsoft-ecommerce.admin.order.column.unpaid_short|amount={{row.total_unpaid_amount_formatted}}", "if": "{{row.total_unpaid_amount > 0}}" }
]
}
]
},
{
"field": "order_status",
"header": "$t:sirsoft-ecommerce.admin.order.column.order_status",
"width": "100px",
"sortable": false,
"required": false,
"cellChildren": [
{ "type": "composite", "name": "StatusBadge", "props": { "status": "{{row.order_status_variant || 'default'}}", "label": "{{row.order_status_label}}", "showIcon": false } }
]
},
{
"field": "orderer",
"header": "$t:sirsoft-ecommerce.admin.order.column.orderer",
"width": "140px",
"sortable": false,
"required": false,
"cellChildren": [
{ "type": "basic", "name": "Span", "props": { "className": "text-gray-500 dark:text-gray-400" }, "text": "-", "if": "{{!row.address?.orderer_name && !row.user?.name}}" },
{
"type": "composite",
"name": "ActionMenu",
"if": "{{!!row.address?.orderer_name || !!row.user?.name}}",
"props": {
"position": "right",
"items": [
{ "id": "view_member", "label": "$t:sirsoft-ecommerce.admin.order.action.view_member", "iconName": "user", "if": "{{!!row.user?.uuid}}" },
{ "id": "search_by_orderer_member", "label": "$t:sirsoft-ecommerce.admin.order.action.search_by_orderer", "iconName": "magnifying-glass", "if": "{{!!row.user?.uuid}}" },
{ "id": "search_by_orderer_guest", "label": "$t:sirsoft-ecommerce.admin.order.action.search_by_orderer", "iconName": "magnifying-glass", "if": "{{!row.user?.uuid}}" }
]
},
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex-center gap-2 hover:bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700 rounded-lg px-1 py-0.5 transition-colors" },
"children": [
{ "type": "basic", "name": "Icon", "if": "{{!!row.user?.uuid}}", "props": { "name": "fa-regular fa-user", "className": "text-gray-400 dark:text-gray-500 flex-shrink-0" } },
{
"type": "basic",
"name": "Div",
"props": { "className": "min-w-0 flex-1" },
"children": [
{ "type": "basic", "name": "Span", "if": "{{!!row.user?.uuid}}", "props": { "className": "font-medium text-gray-900 dark:text-gray-100 truncate block" }, "text": "{{row.address?.orderer_name || row.user?.name}}" },
{ "type": "basic", "name": "Span", "if": "{{!row.user?.uuid}}", "props": { "className": "text-gray-600 dark:text-gray-400 truncate block" }, "text": "{{row.address?.orderer_name || row.user?.name}}" },
{ "type": "basic", "name": "Span", "comment": "첫구매 표시", "if": "{{row.is_first_order}}", "props": { "className": "text-xs text-emerald-500 dark:text-emerald-400" }, "text": "$t:sirsoft-ecommerce.admin.order.column.first_order" }
]
}
]
}
],
"actions": [
{
"event": "onItemClick",
"handler": "switch",
"value": "{{$args[0]}}",
"cases": {
"view_member": { "handler": "openWindow", "params": { "path": "/admin/users/{{row.user?.uuid}}" } },
"search_by_orderer_member": {
"handler": "sequence",
"actions": [
{ "handler": "setState", "params": { "target": "_local", "filter.ordererUuid": "{{row.user?.uuid}}", "filter.searchField": "all", "filter.searchKeyword": "", "ordererSearchResults": "{{[{ uuid: row.user?.uuid, name: row.user?.name || row.address?.orderer_name, email: row.user?.email || '' }]}}" } },
{ "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "query": { "orderer_uuid": "{{row.user?.uuid}}", "page": 1 }, "transition_overlay_target": "order_datagrid__body" } }
]
},
"search_by_orderer_guest": {
"handler": "sequence",
"actions": [
{ "handler": "setState", "params": { "target": "_local", "filter.searchField": "orderer_name", "filter.searchKeyword": "{{row.address?.orderer_name || row.user?.name}}" } },
{ "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "query": { "search_field": "orderer_name", "search_keyword": "{{row.address?.orderer_name || row.user?.name}}", "page": 1 }, "transition_overlay_target": "order_datagrid__body" } }
]
}
}
}
]
}
]
},
{
"field": "payment_method",
"header": "$t:sirsoft-ecommerce.admin.order.column.payment_method",
"width": "100px",
"sortable": false,
"required": false,
"cellChildren": [
{
"type": "basic",
"name": "Div",
"props": { "className": "row-stack" },
"children": [
{
"type": "basic",
"name": "Span",
"text": "{{nhnkcp_easy_pay_display_map.data?.[row.order_number]?.payment_method_display_label ?? row.payment?.payment_method_label}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{nhnkcp_test_map.data?.[row.order_number] === true}}",
"props": { "className": "text-xs font-medium text-orange-500 dark:text-orange-400 leading-tight" },
"text": "($t:sirsoft-pay_nhnkcp.admin.test_mode_badge)"
},
{
"type": "basic",
"name": "Span",
"if": "{{nicepay_test_map.data?.[row.order_number] === true}}",
"props": { "className": "text-xs font-medium text-orange-500 dark:text-orange-400 leading-tight" },
"text": "($t:sirsoft-pay_nicepayments.admin.test_mode_badge)"
}
]
}
]
},
{
"field": "delivery_method",
"header": "$t:sirsoft-ecommerce.admin.order.column.delivery_method",
"width": "100px",
"sortable": false,
"required": false,
"cellChildren": [
{ "type": "basic", "name": "Span", "text": "{{row.shipping?.shipping_type_label}}" }
]
},
{
"field": "country",
"header": "$t:sirsoft-ecommerce.admin.order.column.country",
"width": "80px",
"sortable": false,
"required": false,
"cellChildren": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex-center gap-2" },
"children": [
{ "type": "basic", "name": "Span", "props": { "className": "fi fi-{{row.address?.recipient_country_code?.toLowerCase()}} flex-shrink-0" }, "if": "{{row.address?.recipient_country_code}}" },
{ "type": "basic", "name": "Span", "text": "{{$localized(row.address?.recipient_country_name) ?? row.address?.recipient_country_code}}", "if": "{{row.address?.recipient_country_code}}" }
]
}
]
},
{
"field": "device",
"header": "$t:sirsoft-ecommerce.admin.order.column.device",
"width": "80px",
"sortable": false,
"required": false,
"cellChildren": [
{ "type": "basic", "name": "Span", "text": "{{row.order_device_label}}" }
]
}
]
}
}
]
}
@@ -0,0 +1,548 @@
{
"target_layout": "admin_ecommerce_order_detail",
"data_sources": [
{
"id": "kcp_status",
"label_key": "$t:sirsoft-pay_nhnkcp.editor.data_source.kcp_status",
"type": "api",
"endpoint": "/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{{route.orderNumber}}/transaction-status",
"method": "GET",
"auto_fetch": true,
"auth_required": true
},
{
"id": "kcp_escrow_delivery",
"label_key": "$t:sirsoft-pay_nhnkcp.editor.data_source.kcp_escrow_delivery",
"type": "api",
"endpoint": "/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{{route.orderNumber}}/escrow-delivery",
"method": "GET",
"auto_fetch": true,
"auth_required": true
}
],
"injections": [
{
"target_id": "sticky_header_container",
"position": "append_child",
"components": [
{
"type": "basic",
"name": "Div",
"if": "{{kcp_status.data?._is_test_mode === true}}",
"props": { "className": "mx-6 mb-3 px-3 py-2 bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-700 rounded-lg flex-center gap-2" },
"children": [
{
"type": "basic",
"name": "Icon",
"props": { "name": "flask-conical", "className": "w-4 h-4 text-orange-600 dark:text-orange-400 flex-shrink-0" }
},
{
"type": "basic",
"name": "Span",
"props": { "className": "text-sm font-semibold text-orange-800 dark:text-orange-300" },
"text": "$t:sirsoft-pay_nhnkcp.admin.test_mode_badge"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "text-sm text-orange-700 dark:text-orange-400" },
"text": "$t:sirsoft-pay_nhnkcp.admin.test_mode_order_notice"
}
]
}
]
},
{
"target_id": "tab_content_area",
"position": "append_child",
"components": [
{
"id": "kcp_info_panel",
"type": "basic",
"name": "Div",
"if": "{{!!kcp_status.data?.tno}}",
"props": { "className": "admin-card" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex-center gap-2 mb-4" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "w-1.5 h-4 bg-blue-500 rounded-full flex-shrink-0" }
},
{
"type": "basic",
"name": "H3",
"props": { "className": "text-sm font-semibold text-gray-800 dark:text-gray-200" },
"text": "$t:sirsoft-pay_nhnkcp.admin.order_info_title"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "text-hint hidden sm:inline" },
"text": "$t:sirsoft-pay_nhnkcp.admin.order_info_description"
},
{
"type": "basic",
"name": "Span",
"if": "{{kcp_status.data?.refund_status === 'completed' && (kcp_status.data?.payment_status === 'cancelled' || kcp_status.data?.payment_status === 'partial_cancelled')}}",
"props": { "className": "ml-auto inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300" },
"text": "$t:sirsoft-pay_nhnkcp.admin.pay_status_cancel_completed"
},
{
"type": "basic",
"name": "Span",
"if": "{{kcp_status.data?.payment_status === 'partial_cancelled' && kcp_status.data?.refund_status !== 'completed'}}",
"props": { "className": "ml-auto inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300" },
"text": "$t:sirsoft-pay_nhnkcp.admin.pay_status_partial_cancelled"
},
{
"type": "basic",
"name": "Span",
"if": "{{kcp_status.data?.payment_status === 'cancelled' && kcp_status.data?.refund_status !== 'completed'}}",
"props": { "className": "ml-auto inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300" },
"text": "$t:sirsoft-pay_nhnkcp.admin.pay_status_cancelled"
},
{
"type": "basic",
"name": "Span",
"if": "{{kcp_status.data?.payment_status === 'paid' && kcp_status.data?.res_cd === '0000'}}",
"props": { "className": "ml-auto inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300" },
"text": "$t:sirsoft-pay_nhnkcp.admin.pay_status_approved"
},
{
"type": "basic",
"name": "Span",
"if": "{{!(kcp_status.data?.refund_status === 'completed' && (kcp_status.data?.payment_status === 'cancelled' || kcp_status.data?.payment_status === 'partial_cancelled')) && kcp_status.data?.payment_status !== 'cancelled' && kcp_status.data?.payment_status !== 'partial_cancelled' && !(kcp_status.data?.payment_status === 'paid' && kcp_status.data?.res_cd === '0000')}}",
"props": { "className": "ml-auto inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300" },
"text": "$t:sirsoft-pay_nhnkcp.admin.pay_status_unknown"
},
{
"type": "basic",
"name": "Span",
"if": "{{kcp_escrow_delivery.data?.has_escrow_payment === true}}",
"props": { "className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300" },
"text": "$t:sirsoft-pay_nhnkcp.admin.escrow_badge"
}
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "grid grid-cols-2 gap-x-4 gap-y-2 text-xs" },
"children": [
{
"type": "basic",
"name": "Span",
"props": { "className": "text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-pay_nhnkcp.admin.result_payment_status"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "text-gray-900 dark:text-gray-100" },
"text": "{{kcp_status.data?.payment_status_label ?? '-'}}"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-pay_nhnkcp.admin.result_tno"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "font-mono text-gray-900 dark:text-gray-100 break-all" },
"text": "{{kcp_status.data?.tno ?? '-'}}"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-pay_nhnkcp.admin.result_app_no"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "font-mono text-gray-900 dark:text-gray-100" },
"text": "{{kcp_status.data?.app_no ?? '-'}}"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-pay_nhnkcp.admin.result_app_time"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "font-mono text-gray-900 dark:text-gray-100" },
"text": "{{kcp_status.data?.app_time ?? '-'}}"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-pay_nhnkcp.admin.result_pay_method"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "text-gray-900 dark:text-gray-100" },
"text": "{{kcp_status.data?.card_name ?? kcp_status.data?.bank_name ?? kcp_status.data?.use_pay_method ?? '-'}}"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-pay_nhnkcp.admin.result_res_cd"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "font-mono text-gray-900 dark:text-gray-100" },
"text": "{{kcp_status.data?.res_cd ?? '-'}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{(kcp_status.data?.cancelled_amount ?? 0) > 0}}",
"props": { "className": "text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-pay_nhnkcp.admin.result_cancelled_amount"
},
{
"type": "basic",
"name": "Span",
"if": "{{(kcp_status.data?.cancelled_amount ?? 0) > 0}}",
"props": { "className": "font-mono text-gray-900 dark:text-gray-100" },
"text": "{{kcp_status.data?.cancelled_amount_formatted ?? '-'}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{!!kcp_status.data?.cancelled_at}}",
"props": { "className": "text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-pay_nhnkcp.admin.result_cancelled_at"
},
{
"type": "basic",
"name": "Span",
"if": "{{!!kcp_status.data?.cancelled_at}}",
"props": { "className": "font-mono text-gray-900 dark:text-gray-100" },
"text": "{{kcp_status.data?.cancelled_at ?? '-'}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{!!kcp_status.data?.refund_status}}",
"props": { "className": "text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-pay_nhnkcp.admin.result_refund_status"
},
{
"type": "basic",
"name": "Span",
"if": "{{!!kcp_status.data?.refund_status}}",
"props": { "className": "text-gray-900 dark:text-gray-100" },
"text": "{{kcp_status.data?.refund_status_label ?? kcp_status.data?.refund_status ?? '-'}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{!!kcp_status.data?.refund_number}}",
"props": { "className": "text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-pay_nhnkcp.admin.result_refund_number"
},
{
"type": "basic",
"name": "Span",
"if": "{{!!kcp_status.data?.refund_number}}",
"props": { "className": "font-mono text-gray-900 dark:text-gray-100 break-all" },
"text": "{{kcp_status.data?.refund_number ?? '-'}}"
}
]
}
]
},
{
"id": "kcp_escrow_delivery_panel",
"type": "basic",
"name": "Div",
"if": "{{kcp_escrow_delivery.data?.has_escrow_payment === true}}",
"props": { "className": "admin-card mt-4" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex-center gap-2 mb-4" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "w-1.5 h-4 bg-purple-500 rounded-full flex-shrink-0" }
},
{
"type": "basic",
"name": "H3",
"props": { "className": "text-sm font-semibold text-gray-800 dark:text-gray-200" },
"text": "$t:sirsoft-pay_nhnkcp.admin.escrow_delivery_title"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "text-hint hidden sm:inline" },
"text": "$t:sirsoft-pay_nhnkcp.admin.escrow_delivery_description"
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{kcp_escrow_delivery.data?.registered_delivery}}",
"props": { "className": "mb-4 p-3 rounded-lg bg-purple-50 dark:bg-purple-900/20 border border-purple-100 dark:border-purple-700/50" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex-center gap-2 mb-2" },
"children": [
{
"type": "basic",
"name": "Icon",
"props": { "name": "truck", "className": "w-3.5 h-3.5 text-purple-600 dark:text-purple-400 flex-shrink-0" }
},
{
"type": "basic",
"name": "Span",
"props": { "className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300" },
"text": "$t:sirsoft-pay_nhnkcp.admin.escrow_delivery_already_registered"
}
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "grid grid-cols-2 gap-x-4 gap-y-1 text-xs" },
"children": [
{
"type": "basic",
"name": "Span",
"props": { "className": "text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-pay_nhnkcp.admin.escrow_delivery_registered_at"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "text-gray-900 dark:text-gray-100 font-mono" },
"text": "{{kcp_escrow_delivery.data.registered_delivery.registered_at ?? '-'}}"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-pay_nhnkcp.admin.escrow_delivery_invoice_info"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "text-gray-900 dark:text-gray-100 font-mono" },
"text": "{{kcp_escrow_delivery.data.registered_delivery.deli_numb ?? '-'}}"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "text-gray-500 dark:text-gray-400" },
"text": "$t:sirsoft-pay_nhnkcp.admin.escrow_delivery_courier_info"
},
{
"type": "basic",
"name": "Span",
"props": { "className": "text-gray-900 dark:text-gray-100" },
"text": "{{kcp_escrow_delivery.data.registered_delivery.courier_name ?? '-'}}"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "space-y-2" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "flex-center gap-2" },
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "text-tertiary w-20 flex-shrink-0" },
"text": "$t:sirsoft-pay_nhnkcp.admin.escrow_delivery_invoice_label"
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"placeholder": "$t:sirsoft-pay_nhnkcp.admin.escrow_delivery_invoice_placeholder",
"className": "flex-1 text-xs px-2 py-1.5 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 font-mono"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": { "target": "local", "_ked_deli_numb": "{{$event.target.value}}" }
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "flex-center gap-2" },
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "text-tertiary w-20 flex-shrink-0" },
"text": "$t:sirsoft-pay_nhnkcp.admin.escrow_delivery_courier_label"
},
{
"type": "basic",
"name": "Select",
"props": {
"className": "flex-1 text-xs px-2 py-1.5 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": { "target": "local", "_ked_deli_corp": "{{$event.target.value}}" }
}
],
"children": [
{ "type": "basic", "name": "Option", "props": { "value": "" }, "text": "$t:sirsoft-pay_nhnkcp.admin.escrow_delivery_courier_placeholder" },
{ "type": "basic", "name": "Option", "props": { "value": "04" }, "text": "CJ대한통운" },
{ "type": "basic", "name": "Option", "props": { "value": "05" }, "text": "한진택배" },
{ "type": "basic", "name": "Option", "props": { "value": "06" }, "text": "로젠택배" },
{ "type": "basic", "name": "Option", "props": { "value": "08" }, "text": "롯데택배" },
{ "type": "basic", "name": "Option", "props": { "value": "09" }, "text": "우체국택배" },
{ "type": "basic", "name": "Option", "props": { "value": "11" }, "text": "경동택배" },
{ "type": "basic", "name": "Option", "props": { "value": "13" }, "text": "일양로지스" },
{ "type": "basic", "name": "Option", "props": { "value": "14" }, "text": "합동택배" },
{ "type": "basic", "name": "Option", "props": { "value": "20" }, "text": "드림택배" },
{ "type": "basic", "name": "Option", "props": { "value": "23" }, "text": "천일택배" },
{ "type": "basic", "name": "Option", "props": { "value": "26" }, "text": "건영택배" },
{ "type": "basic", "name": "Option", "props": { "value": "40" }, "text": "기타" }
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "flex justify-end" },
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"disabled": "{{_local._ked_registering}}",
"className": "flex-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-purple-600 hover:bg-purple-700 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": { "target": "local", "_ked_registering": true, "_ked_error": null }
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{{route.orderNumber}}/escrow-delivery",
"params": {
"method": "POST",
"body": {
"deli_numb": "{{_local._ked_deli_numb ?? ''}}",
"deli_corp": "{{_local._ked_deli_corp ?? ''}}"
}
},
"onSuccess": [
{
"handler": "setState",
"params": { "target": "local", "_ked_registering": false }
},
{
"handler": "toast",
"params": { "type": "success", "message": "$t:sirsoft-pay_nhnkcp.admin.escrow_delivery_success" }
},
{
"handler": "refetchDataSource",
"params": { "dataSourceId": "kcp_escrow_delivery" }
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "local",
"_ked_registering": false,
"_ked_error": "{{response?.errors?.deli_numb?.[0] ?? response?.errors?.deli_corp?.[0] ?? response?.errors?.message?.[0] ?? '$t:sirsoft-pay_nhnkcp.admin.escrow_delivery_error'}}"
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{_local._ked_registering}}",
"props": { "name": "spinner", "className": "w-3 h-3 animate-spin" }
},
{
"type": "basic",
"name": "Span",
"if": "{{_local._ked_registering}}",
"text": "$t:sirsoft-pay_nhnkcp.admin.escrow_delivery_registering"
},
{
"type": "basic",
"name": "Span",
"if": "{{!_local._ked_registering && !kcp_escrow_delivery.data?.registered_delivery}}",
"text": "$t:sirsoft-pay_nhnkcp.admin.escrow_delivery_register_button"
},
{
"type": "basic",
"name": "Span",
"if": "{{!_local._ked_registering && kcp_escrow_delivery.data?.registered_delivery}}",
"text": "$t:sirsoft-pay_nhnkcp.admin.escrow_delivery_change_button"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{_local._ked_error}}",
"props": { "className": "text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 rounded px-2 py-1.5" },
"text": "{{_local._ked_error}}"
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,97 @@
{
"target_layout": "shop/checkout",
"data_sources": [
{
"id": "nhnkcp_easy_pay_config",
"label_key": "$t:sirsoft-pay_nhnkcp.editor.data_source.nhnkcp_easy_pay_config",
"type": "api",
"endpoint": "/api/modules/sirsoft-ecommerce/payments/client-config/nhnkcp",
"method": "GET",
"auto_fetch": true
}
],
"modals": [
{
"id": "nhnkcp_payment_error_modal",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:sirsoft-pay_nhnkcp.payment_error.title",
"size": "sm",
"showCloseButton": true
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "text-center py-4"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "exclamation-circle",
"className": "text-red-500 text-4xl mb-4"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-gray-700 dark:text-gray-300"
},
"text": "$t:sirsoft-pay_nhnkcp.payment_error.message"
},
{
"type": "basic",
"name": "P",
"if": "{{_local.paymentErrorMessage}}",
"props": {
"className": "text-sm text-red-600 dark:text-red-400 mt-2"
},
"text": "{{_local.paymentErrorMessage}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-center mt-6"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
},
"text": "$t:common.confirm",
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"paymentErrorMessage": null
}
},
{
"handler": "closeModal"
}
]
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,313 @@
{
"target_layout": "shop/order_complete",
"injections": [
{
"target_id": "order_complete_payment_summary",
"position": "append_child",
"components": [
{
"type": "basic",
"name": "Div",
"if": "{{orderData?.data?.payment?.pg_provider === 'nhnkcp' && orderData?.data?.payment?.vbank_name}}",
"props": {
"className": "pt-4 mt-2 border-t border-gray-200 dark:border-gray-700 space-y-3"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm font-semibold text-gray-800 dark:text-gray-200"
},
"text": "$t:sirsoft-pay_nhnkcp.user.vbank_title"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-1.5"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between text-sm"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-pay_nhnkcp.user.vbank_bank_name"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary font-medium"
},
"text": "{{orderData?.data?.payment?.vbank_name}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between text-sm"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-pay_nhnkcp.user.vbank_account"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary font-medium font-mono tracking-wide"
},
"text": "{{orderData?.data?.payment?.vbank_number}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between text-sm"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-pay_nhnkcp.user.vbank_holder"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary font-medium"
},
"text": "{{orderData?.data?.payment?.vbank_holder}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between text-sm"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-pay_nhnkcp.user.vbank_due"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary font-medium"
},
"text": "{{orderData?.data?.payment?.due_date_formatted}}"
}
]
}
]
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-amber-600 dark:text-amber-400"
},
"text": "$t:sirsoft-pay_nhnkcp.user.vbank_notice"
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{orderData?.data?.payment?.pg_provider === 'nhnkcp' && orderData?.data?.payment?.transaction_id && !orderData?.data?.payment?.vbank_name}}",
"props": {
"className": "pt-4 mt-2 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-secondary text-sm"
},
"text": "$t:sirsoft-pay_nhnkcp.user.receipt_label"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-3"
},
"children": [
{
"type": "basic",
"name": "Button",
"if": "{{!_local._kcpOcReceiptLoaded}}",
"props": {
"type": "button",
"className": "inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50",
"disabled": "{{_local._kcpOcReceiptLoading === true}}"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"_kcpOcReceiptLoading": true
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-pay_nhnkcp/user/orders/{{route.id}}/receipt",
"params": {
"method": "GET"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "local",
"_kcpOcReceiptLoading": false,
"_kcpOcReceiptLoaded": true,
"_kcpOcReceiptUrl": "{{$response.receipt_url}}",
"_kcpOcCashReceiptUrl": "{{$response.cash_receipt_url}}"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "local",
"_kcpOcReceiptLoading": false
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{_local._kcpOcReceiptLoading === true}}",
"props": {
"name": "spinner",
"className": "w-3.5 h-3.5 animate-spin"
}
},
{
"type": "basic",
"name": "Icon",
"if": "{{!_local._kcpOcReceiptLoading}}",
"props": {
"name": "file-text",
"className": "w-3.5 h-3.5"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-pay_nhnkcp.user.receipt_view"
}
]
},
{
"type": "basic",
"name": "A",
"if": "{{_local._kcpOcReceiptLoaded && _local._kcpOcReceiptUrl}}",
"props": {
"href": "{{_local._kcpOcReceiptUrl}}",
"target": "_blank",
"rel": "noopener noreferrer",
"className": "inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "file-text",
"className": "w-3.5 h-3.5"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-pay_nhnkcp.user.receipt_view"
}
]
},
{
"type": "basic",
"name": "A",
"if": "{{_local._kcpOcReceiptLoaded && _local._kcpOcCashReceiptUrl}}",
"props": {
"href": "{{_local._kcpOcCashReceiptUrl}}",
"target": "_blank",
"rel": "noopener noreferrer",
"className": "inline-flex items-center gap-1 text-sm text-green-600 dark:text-green-400 hover:underline"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "receipt",
"className": "w-3.5 h-3.5"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-pay_nhnkcp.user.cash_receipt_view"
}
]
}
]
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,374 @@
{
"target_layout": "mypage/orders/show",
"injections": [
{
"target_id": "order_payment_info_panel",
"position": "append_child",
"components": [
{
"type": "basic",
"name": "Div",
"if": "{{order.data.payment?.pg_provider === 'nhnkcp' && order.data.payment?.is_escrow}}",
"props": {
"className": "pt-2 mt-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "shield-check",
"className": "w-3 h-3"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-pay_nhnkcp.user.escrow_badge"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{order.data.payment?.pg_provider === 'nhnkcp' && order.data.payment?.vbank_name}}",
"props": {
"className": "pt-4 mt-2 border-t border-gray-200 dark:border-gray-700 space-y-3"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm font-semibold text-gray-800 dark:text-gray-200"
},
"text": "$t:sirsoft-pay_nhnkcp.user.vbank_title"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "space-y-1.5"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between text-sm"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-pay_nhnkcp.user.vbank_bank_name"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary font-medium"
},
"text": "{{order.data.payment?.vbank_name}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between text-sm"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-pay_nhnkcp.user.vbank_account"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary font-medium font-mono tracking-wide"
},
"text": "{{order.data.payment?.vbank_number}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between text-sm"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-pay_nhnkcp.user.vbank_holder"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary font-medium"
},
"text": "{{order.data.payment?.vbank_holder}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between text-sm"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-pay_nhnkcp.user.vbank_due"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary font-medium"
},
"text": "{{order.data.payment?.due_date_formatted}}"
}
]
}
]
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-amber-600 dark:text-amber-400"
},
"text": "$t:sirsoft-pay_nhnkcp.user.vbank_notice"
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{order.data.payment?.pg_provider === 'nhnkcp' && (order.data.total_vat_amount > 0 || order.data.total_tax_free_amount > 0)}}",
"props": {
"className": "pt-4 mt-2 border-t border-gray-200 dark:border-gray-700 space-y-1.5"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm font-semibold text-gray-800 dark:text-gray-200 mb-1"
},
"text": "$t:sirsoft-pay_nhnkcp.user.tax_breakdown_title"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between text-sm"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-pay_nhnkcp.user.supply_amount"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary font-medium"
},
"text": "{{order.data.total_taxable_supply_amount_formatted}}"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between text-sm"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-pay_nhnkcp.user.vat_amount"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary font-medium"
},
"text": "{{order.data.total_vat_amount_formatted}}"
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{order.data.total_tax_free_amount > 0}}",
"props": {
"className": "flex-between text-sm"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-gray-500 dark:text-gray-400"
},
"text": "$t:sirsoft-pay_nhnkcp.user.tax_free_amount"
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-primary font-medium"
},
"text": "{{order.data.total_tax_free_amount_formatted}}"
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"if": "{{order.data.payment?.pg_provider === 'nhnkcp' && order.data.payment?.transaction_id && order.data.payment?.paid_at}}",
"props": {
"id": "kcp-mp-receipt-row",
"className": "pt-4 mt-2 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-label-subtle"
},
"text": "$t:sirsoft-pay_nhnkcp.user.receipt_label"
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50",
"disabled": "{{_local._kcpReceiptLoading === true}}"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"_kcpReceiptLoading": true
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-pay_nhnkcp/user/orders/{{route.order_number}}/receipt",
"params": {
"method": "GET"
}
},
{
"handler": "callExternal",
"params": {
"constructor": "KcpReceiptPopup",
"args": {
"url": "{{$prev.receipt_url}}",
"cash_url": "{{$prev.cash_receipt_url}}"
}
}
},
{
"handler": "setState",
"params": {
"target": "local",
"_kcpReceiptLoading": false
}
}
]
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{_local._kcpReceiptLoading === true}}",
"props": {
"name": "spinner",
"className": "w-3.5 h-3.5 animate-spin"
}
},
{
"type": "basic",
"name": "Icon",
"if": "{{!_local._kcpReceiptLoading}}",
"props": {
"name": "file-text",
"className": "w-3.5 h-3.5"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-pay_nhnkcp.user.receipt_view"
}
]
}
]
}
]
}
]
}
]
}
@@ -0,0 +1,38 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
patchAdminApplePayNotice,
resetAdminApplePayNoticeInjectorForTests,
} from '../adminApplePayNoticeInjector';
describe('adminApplePayNoticeInjector', () => {
beforeEach(() => {
document.documentElement.lang = 'ko';
window.history.pushState({}, '', '/admin/ecommerce/settings?tab=order_settings');
document.body.innerHTML = `
<section>
<div>
<span>애플페이</span>
</div>
</section>
`;
});
afterEach(() => {
resetAdminApplePayNoticeInjectorForTests();
document.body.innerHTML = '';
});
it('관리자 주문설정의 애플페이 항목에 iOS 모바일 결제 안내를 추가한다', () => {
expect(patchAdminApplePayNotice()).toBe(true);
expect(document.body.textContent).toContain('애플페이는 IOS 기기에 모바일결제만 가능합니다.');
expect(document.querySelectorAll('[data-nhnkcp-admin-apple-pay-notice="true"]')).toHaveLength(1);
});
it('주문설정 화면이 아니면 안내를 추가하지 않는다', () => {
window.history.pushState({}, '', '/admin/ecommerce/settings?tab=shipping');
expect(patchAdminApplePayNotice()).toBe(false);
expect(document.body.textContent).not.toContain('애플페이는 IOS 기기에 모바일결제만 가능합니다.');
});
});
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
resetAdminPaymentMethodBrandInjectorForTests,
syncRenderedAdminPaymentMethodBrands,
} from '../adminPaymentMethodBrandInjector';
function desktopItem(testId: string, title: string, description: string): string {
return `
<div class="flex-center border rounded-lg p-3 gap-4" data-test-item="${testId}">
<div data-drag-handle="true"><svg data-drag-icon="true"></svg></div>
<svg data-original-icon="true"></svg>
<div class="flex-1 min-w-0">
<div class="flex-center gap-2">
<span class="font-medium text-gray-900 dark:text-gray-100">${title}</span>
</div>
<span class="text-label-subtle">${description}</span>
</div>
<div class="row-stack">
<span>PG사</span>
<select></select>
</div>
</div>
`;
}
function mobileItem(testId: string, title: string): string {
return `
<div class="excel-card" data-test-item="${testId}">
<div class="excel-card-header">
<div class="flex-center gap-3 flex-1 min-w-0">
<div data-drag-handle="true"><svg data-drag-icon="true"></svg></div>
<svg data-original-icon="true"></svg>
<span class="font-medium text-gray-900 dark:text-gray-100 truncate">${title}</span>
</div>
</div>
<div class="excel-card-body">
<div class="row-stack"><label>PG사</label><select></select></div>
</div>
</div>
`;
}
describe('adminPaymentMethodBrandInjector', () => {
beforeEach(() => {
document.documentElement.lang = 'ko';
window.history.pushState({}, '', '/admin/ecommerce/settings?tab=order_settings');
document.body.innerHTML = `
${desktopItem('payco', 'PAYCO', 'PAYCO로 결제 (NHN KCP)')}
${mobileItem('kakaopay', '카카오페이')}
${desktopItem('nice-payco', 'PAYCO (나이스페이먼츠)', 'PAYCO로 결제 (나이스페이먼츠)')}
${desktopItem('card', '신용카드', '신용카드로 결제')}
`;
});
afterEach(() => {
resetAdminPaymentMethodBrandInjectorForTests();
document.body.innerHTML = '';
});
it('NHN KCP 간편결제 행의 기본 아이콘을 브랜드 텍스트 배지로 바꾼다', () => {
expect(syncRenderedAdminPaymentMethodBrands()).toBe(true);
const payco = document.querySelector<HTMLElement>('[data-test-item="payco"]');
const kakaoPay = document.querySelector<HTMLElement>('[data-test-item="kakaopay"]');
const nicePayco = document.querySelector<HTMLElement>('[data-test-item="nice-payco"]');
const card = document.querySelector<HTMLElement>('[data-test-item="card"]');
expect(payco?.dataset.nhnkcpAdminPaymentMethod).toBe('nhnkcp_payco');
expect(payco?.querySelector('[data-nhnkcp-admin-payment-brand-mark="true"]')?.textContent).toBe('P');
expect(payco?.querySelector('[data-original-icon="true"]')).toBeNull();
expect(payco?.querySelector('[data-drag-icon="true"]')).not.toBeNull();
expect(kakaoPay?.dataset.nhnkcpAdminPaymentMethod).toBe('nhnkcp_kakaopay');
expect(kakaoPay?.querySelector('[data-nhnkcp-admin-payment-brand-mark="true"]')?.textContent).toBe('K');
expect(nicePayco?.dataset.nhnkcpAdminPaymentMethod).toBeUndefined();
expect(nicePayco?.querySelector('[data-original-icon="true"]')).not.toBeNull();
expect(card?.querySelector('[data-original-icon="true"]')).not.toBeNull();
});
it('주문설정 화면이 아니면 관리자 결제수단 아이콘을 바꾸지 않는다', () => {
window.history.pushState({}, '', '/admin/ecommerce/settings?tab=shipping');
expect(syncRenderedAdminPaymentMethodBrands()).toBe(false);
expect(document.querySelector('[data-nhnkcp-admin-payment-brand-mark="true"]')).toBeNull();
});
});
@@ -0,0 +1,226 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
resetCheckoutEasyPayInjectorForTests,
syncRenderedCheckoutEasyPayMethods,
} from '../checkoutEasyPayInjector';
function paymentButton(label: string, description: string): string {
return `
<button type="button">
<div class="flex items-center gap-3">
<i class="fas fa-wallet" role="img"></i>
<div>
<p>${label}</p>
<p>${description}</p>
</div>
</div>
</button>
`;
}
function renderPaymentButtons(): void {
document.body.innerHTML = `
${paymentButton('Naver Pay (KG Inicis)', 'Pay with Naver Pay')}
${paymentButton('Kakao Pay (KG Inicis)', 'Pay with Kakao Pay')}
${paymentButton('PAYCO (NHN KCP)', 'Pay with PAYCO')}
${paymentButton('Naver Pay (NHN KCP)', 'Pay with Naver Pay')}
${paymentButton('Naver Pay Point (NHN KCP)', 'Pay with Naver Pay points')}
${paymentButton('Kakao Pay (NHN KCP)', 'Pay with Kakao Pay')}
${paymentButton('Apple Pay (NHN KCP)', 'Pay with Apple Pay')}
${paymentButton('Credit Card', 'Pay securely with credit card')}
`;
}
function visibleButtonTexts(): string[] {
return Array.from(document.querySelectorAll<HTMLButtonElement>('button'))
.filter((button) => !button.hidden && button.style.display !== 'none')
.map((button) => plainText(button));
}
function plainText(element: Element | null | undefined): string {
return (element?.textContent ?? '').replace(/\u200B/g, '').replace(/\s+/g, ' ').trim();
}
function mockIosDevice(): void {
vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
);
vi.spyOn(window.navigator, 'platform', 'get').mockReturnValue('iPhone');
}
describe('checkoutEasyPayInjector', () => {
beforeEach(() => {
document.documentElement.lang = 'en';
window.history.pushState({}, '', '/shop/checkout');
renderPaymentButtons();
});
afterEach(() => {
resetCheckoutEasyPayInjectorForTests();
document.body.innerHTML = '';
vi.restoreAllMocks();
});
it('주문설정에서 렌더링된 NHN KCP 간편결제는 KCP 플러그인 설정과 무관하게 유지한다', async () => {
mockIosDevice();
await syncRenderedCheckoutEasyPayMethods();
expect(visibleButtonTexts()).toEqual([
'Naver Pay (KG Inicis) Pay with Naver Pay',
'Kakao Pay (KG Inicis) Pay with Kakao Pay',
'P PAYCO Pay with PAYCO (NHN KCP)',
'N Naver Pay (Card) Pay by Naver Pay credit card (NHN KCP)',
'NP Naver Pay (Point) Pay with Naver Pay Money/Points (NHN KCP)',
'K Kakao Pay Pay with Kakao Pay (NHN KCP)',
'A Apple Pay Pay with Apple Pay (NHN KCP)',
'Credit Card Pay securely with credit card',
]);
expect(document.querySelectorAll('[data-nhnkcp-easy-pay-hidden="true"]')).toHaveLength(0);
});
it('한국어 체크아웃에서 요청한 NHN KCP 간편결제 문구와 왼쪽 정렬을 적용한다', async () => {
mockIosDevice();
document.documentElement.lang = 'ko';
document.body.innerHTML = `
${paymentButton('PAYCO (NHN KCP)', 'PAYCO로 결제')}
${paymentButton('네이버페이 (NHN KCP)', '네이버페이로 결제')}
${paymentButton('네이버페이 포인트 (NHN KCP)', '네이버페이 포인트로 결제')}
${paymentButton('카카오페이 (NHN KCP)', '카카오페이로 결제')}
${paymentButton('Apple Pay (NHN KCP)', 'Apple Pay로 결제')}
`;
await syncRenderedCheckoutEasyPayMethods();
expect(visibleButtonTexts()).toEqual([
'P PAYCO PAYCO로 결제 (NHN KCP)',
'N 네이버페이 (카드) 네이버페이 신용카드로 결제 (NHN KCP)',
'NP 네이버페이 (포인트) 네이버페이 머니/포인트로 결제 (NHN KCP)',
'K 카카오페이 카카오페이로 결제 (NHN KCP)',
'A 애플페이 애플페이로 결제 (NHN KCP)',
]);
const paycoButton = document.querySelector<HTMLButtonElement>('[data-nhnkcp-easy-pay-method="nhnkcp_payco"]');
const appleButton = document.querySelector<HTMLButtonElement>('[data-nhnkcp-easy-pay-method="nhnkcp_applepay"]');
const paycoRow = paycoButton?.querySelector<HTMLElement>('.flex.items-center');
const appleRow = appleButton?.querySelector<HTMLElement>('.flex.items-center');
expect(paycoButton?.style.textAlign).toBe('left');
expect(appleButton?.style.textAlign).toBe('left');
expect(paycoRow?.style.justifyContent).toBe('flex-start');
expect(appleRow?.style.justifyContent).toBe('flex-start');
expect(paycoButton?.querySelectorAll('p')[0].style.textAlign).toBe('left');
expect(appleButton?.querySelectorAll('p')[0].style.textAlign).toBe('left');
});
it('KG 이니시스 브랜드 보정기가 다시 잡지 않도록 충돌되는 원문 매칭을 피한다', async () => {
await syncRenderedCheckoutEasyPayMethods();
const naverButton = document.querySelector<HTMLButtonElement>('[data-nhnkcp-easy-pay-method="nhnkcp_naverpay"]');
const kakaoButton = document.querySelector<HTMLButtonElement>('[data-nhnkcp-easy-pay-method="nhnkcp_kakaopay"]');
expect(naverButton?.textContent).not.toContain('Naver Pay');
expect(kakaoButton?.textContent).not.toContain('Kakao Pay');
expect((naverButton?.textContent ?? '').replace(/\u200B/g, '')).toContain('Naver Pay (Card)');
expect((kakaoButton?.textContent ?? '').replace(/\u200B/g, '')).toContain('Kakao Pay');
});
it('KG 브랜드 문구로 바뀐 버튼을 NHN KCP 문구로 복구한다', async () => {
await syncRenderedCheckoutEasyPayMethods();
const naverButton = document.querySelector<HTMLButtonElement>('[data-nhnkcp-easy-pay-method="nhnkcp_naverpay"]');
const kakaoButton = document.querySelector<HTMLButtonElement>('[data-nhnkcp-easy-pay-method="nhnkcp_kakaopay"]');
expect(naverButton).not.toBeNull();
expect(kakaoButton).not.toBeNull();
if (naverButton && kakaoButton) {
naverButton.dataset.kginicisBrandPaymentMethod = 'kginicis_naverpay';
naverButton.querySelectorAll('p')[0].textContent = 'Naver Pay';
naverButton.querySelectorAll('p')[1].textContent = 'Pay with Naver Pay (KG Inicis)';
kakaoButton.dataset.kginicisBrandPaymentMethod = 'kginicis_kakaopay';
kakaoButton.querySelectorAll('p')[0].textContent = 'Kakao Pay';
kakaoButton.querySelectorAll('p')[1].textContent = 'Pay with Kakao Pay (KG Inicis)';
const kgMark = document.createElement('span');
kgMark.dataset.kginicisBrandPaymentMark = 'true';
kgMark.textContent = 'KakaoPay';
kakaoButton.querySelector('.flex.items-center')?.prepend(kgMark);
}
await syncRenderedCheckoutEasyPayMethods();
expect(naverButton?.dataset.kginicisBrandPaymentMethod).toBeUndefined();
expect(plainText(naverButton)).toContain('Naver Pay (Card)');
expect(plainText(naverButton)).toContain('Pay by Naver Pay credit card (NHN KCP)');
expect(naverButton?.textContent).not.toContain('KG Inicis');
expect(kakaoButton?.dataset.kginicisBrandPaymentMethod).toBeUndefined();
expect(plainText(kakaoButton)).toContain('Kakao Pay');
expect(plainText(kakaoButton)).toContain('Pay with Kakao Pay (NHN KCP)');
expect(kakaoButton?.textContent).not.toContain('KG Inicis');
expect(kakaoButton?.querySelector('[data-kginicis-brand-payment-mark="true"]')).toBeNull();
});
it('iOS 모바일 기기에서는 Apple Pay도 주문설정에서 렌더링된 경우 숨기지 않는다', async () => {
mockIosDevice();
await syncRenderedCheckoutEasyPayMethods();
const appleButton = document.querySelector<HTMLButtonElement>('[data-nhnkcp-easy-pay-method="nhnkcp_applepay"]');
expect(appleButton).not.toBeNull();
expect(appleButton?.hidden).toBe(false);
expect(appleButton?.style.display).not.toBe('none');
expect(appleButton?.textContent).toContain('Pay with Apple Pay (NHN KCP)');
});
it('iOS 모바일 기기가 아니면 Apple Pay 버튼을 숨긴다', async () => {
await syncRenderedCheckoutEasyPayMethods();
const appleButton = document.querySelector<HTMLButtonElement>('[data-nhnkcp-easy-pay-method="nhnkcp_applepay"]');
expect(appleButton).not.toBeNull();
expect(appleButton?.hidden).toBe(true);
expect(appleButton?.disabled).toBe(true);
expect(appleButton?.style.display).toBe('none');
expect(visibleButtonTexts()).not.toContain('A Apple Pay Pay with Apple Pay (NHN KCP)');
});
it('KG 보정기가 먼저 NHN KCP Naver/Kakao 버튼을 KG 버튼으로 바꿔도 중복 버튼을 회수한다', async () => {
document.body.innerHTML = `
${paymentButton('NPay Naver Pay', 'Pay with Naver Pay (KG Inicis)')}
${paymentButton('KakaoPay Kakao Pay', 'Pay with Kakao Pay (KG Inicis)')}
${paymentButton('NPay Naver Pay', 'Pay with Naver Pay (KG Inicis)')}
${paymentButton('NPay Naver Pay', 'Pay with Naver Pay (KG Inicis)')}
${paymentButton('KakaoPay Kakao Pay', 'Pay with Kakao Pay (KG Inicis)')}
`;
const buttons = document.querySelectorAll<HTMLButtonElement>('button');
buttons[0].dataset.kginicisBrandPaymentMethod = 'kginicis_naverpay';
buttons[1].dataset.kginicisBrandPaymentMethod = 'kginicis_kakaopay';
buttons[2].dataset.kginicisBrandPaymentMethod = 'kginicis_naverpay';
buttons[3].dataset.kginicisBrandPaymentMethod = 'kginicis_naverpay';
buttons[4].dataset.kginicisBrandPaymentMethod = 'kginicis_kakaopay';
await syncRenderedCheckoutEasyPayMethods();
expect(buttons[0].hidden).toBe(false);
expect(buttons[1].hidden).toBe(false);
expect(buttons[2].dataset.nhnkcpEasyPayMethod).toBe('nhnkcp_naverpay');
expect(buttons[2].hidden).toBe(false);
expect(plainText(buttons[2])).toContain('Naver Pay (Card)');
expect(plainText(buttons[2])).toContain('Pay by Naver Pay credit card (NHN KCP)');
expect(buttons[2].textContent).not.toContain('KG Inicis');
expect(buttons[3].dataset.nhnkcpEasyPayMethod).toBe('nhnkcp_naverpay_point');
expect(buttons[3].hidden).toBe(false);
expect(plainText(buttons[3])).toContain('Naver Pay (Point)');
expect(plainText(buttons[3])).toContain('Pay with Naver Pay Money/Points (NHN KCP)');
expect(buttons[3].textContent).not.toContain('KG Inicis');
expect(buttons[4].dataset.nhnkcpEasyPayMethod).toBe('nhnkcp_kakaopay');
expect(buttons[4].hidden).toBe(false);
expect(plainText(buttons[4])).toContain('Kakao Pay');
expect(plainText(buttons[4])).toContain('Pay with Kakao Pay (NHN KCP)');
expect(buttons[4].textContent).not.toContain('KG Inicis');
});
});
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { copyToClipboardHandler } from '../../handlers/copyToClipboard';
describe('copyToClipboardHandler', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('전달받은 text를 브라우저 클립보드에 복사한다', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
await copyToClipboardHandler({
params: {
text: '114.207.113.206',
},
});
expect(writeText).toHaveBeenCalledWith('114.207.113.206');
});
it('text가 비어 있으면 클립보드를 호출하지 않는다', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
await copyToClipboardHandler({
params: {
text: '',
},
});
expect(writeText).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,225 @@
/**
* requestPayment 핸들러 테스트
*
* NHN KCP 결제창 호출 핸들러의 입력 검증 및 에러 경로 동작을 검증합니다.
* iframe SDK 로드 / KCP_Pay_Execute 호출 등 외부 부수효과 의존 흐름은
* tests/scenarios 매니페스트(통합 시나리오)에서 다루며, 본 단위 테스트는
* "초기 가드 + catch 블록 정상 호출" 두 축에 집중합니다.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
buildKcpEasyPayReturnFields,
buildKcpTaxFields,
isSupportedKcpCurrency,
requestPaymentHandler,
watchKcpPaymentFrameClosure,
} from '../../handlers/requestPayment';
const PG_PAYMENT = {
order_number: 'ORD-001',
order_name: 'Test Order',
amount: 10000,
pay_method: 'card',
};
describe('requestPaymentHandler', () => {
let apiGet: ReturnType<typeof vi.fn>;
let setLocalSpy: ReturnType<typeof vi.fn>;
let modalOpenSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
apiGet = vi.fn();
setLocalSpy = vi.fn();
modalOpenSpy = vi.fn();
document.documentElement.lang = 'ko';
(window as Record<string, unknown>).G7Core = {
api: { get: apiGet },
state: { setLocal: setLocalSpy },
modal: { open: modalOpenSpy },
toast: { error: vi.fn() },
};
});
afterEach(() => {
delete (window as Record<string, unknown>).G7Core;
vi.useRealTimers();
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('pgPaymentData가 없으면 console.error 후 조기 반환', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await requestPaymentHandler({ params: {} });
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('pgPaymentData is required')
);
expect(apiGet).not.toHaveBeenCalled();
expect(setLocalSpy).not.toHaveBeenCalled();
});
it('iOS 모바일 기기가 아니면 Apple Pay 결제 요청을 client config 호출 전 차단', async () => {
await requestPaymentHandler({
params: {
pgPaymentData: PG_PAYMENT,
paymentMethod: 'nhnkcp_applepay',
},
});
expect(apiGet).not.toHaveBeenCalled();
expect(setLocalSpy).toHaveBeenCalledWith(
expect.objectContaining({
paymentErrorMessage: '애플페이는 IOS 기기에 모바일결제만 가능합니다.',
isSubmittingOrder: false,
paymentMethod: 'nhnkcp_applepay',
}),
);
expect(modalOpenSpy).toHaveBeenCalledWith('nhnkcp_payment_error_modal');
});
it('KRW가 아닌 통화는 client config 호출 전 차단', async () => {
await requestPaymentHandler({
params: {
pgPaymentData: { ...PG_PAYMENT, currency: 'USD' },
},
});
expect(apiGet).not.toHaveBeenCalled();
expect(setLocalSpy).toHaveBeenCalledWith(
expect.objectContaining({
paymentErrorMessage: 'NHN KCP는 KRW 결제만 지원합니다. (USD)',
isSubmittingOrder: false,
paymentMethod: 'card',
}),
);
expect(modalOpenSpy).toHaveBeenCalledWith('nhnkcp_payment_error_modal');
});
it('client config 응답에 data 가 없으면 console.error 후 조기 반환', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
apiGet.mockResolvedValue({}); // data 누락
await requestPaymentHandler({ params: { pgPaymentData: PG_PAYMENT } });
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to fetch client config'),
expect.anything()
);
expect(setLocalSpy).not.toHaveBeenCalled();
});
it('client config API 자체가 throw 하면 catch 블록에서 setLocal 복구', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
apiGet.mockRejectedValue(new Error('Network error'));
await requestPaymentHandler({ params: { pgPaymentData: PG_PAYMENT } });
// catch 블록은 paymentMethod를 pgPaymentData.pay_method 또는 'card' 로 setLocal 복구
expect(setLocalSpy).toHaveBeenCalledWith(
expect.objectContaining({
isSubmittingOrder: false,
paymentMethod: 'card',
})
);
});
it('pay_method 가 vbank 면 catch 시 그 값으로 복구', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
apiGet.mockRejectedValue(new Error('boom'));
await requestPaymentHandler({
params: {
pgPaymentData: { ...PG_PAYMENT, pay_method: 'vbank' },
},
});
expect(setLocalSpy).toHaveBeenCalledWith(
expect.objectContaining({
isSubmittingOrder: false,
paymentMethod: 'vbank',
})
);
});
it('KCP 결제 iframe 이 제거되면 닫힘 콜백을 한 번만 실행', async () => {
vi.useFakeTimers();
const iframe = document.createElement('iframe');
Object.defineProperty(iframe, 'getBoundingClientRect', {
value: () => ({ width: 100, height: 100 }),
});
document.body.appendChild(iframe);
const onClose = vi.fn();
const stop = watchKcpPaymentFrameClosure(iframe, onClose);
iframe.remove();
await vi.runOnlyPendingTimersAsync();
expect(onClose).toHaveBeenCalledTimes(1);
document.body.appendChild(iframe);
iframe.remove();
await vi.runOnlyPendingTimersAsync();
expect(onClose).toHaveBeenCalledTimes(1);
stop();
vi.useRealTimers();
});
it('KCP 결제 iframe 감시를 중지하면 제거되어도 닫힘 콜백을 실행하지 않음', async () => {
vi.useFakeTimers();
const iframe = document.createElement('iframe');
Object.defineProperty(iframe, 'getBoundingClientRect', {
value: () => ({ width: 100, height: 100 }),
});
document.body.appendChild(iframe);
const onClose = vi.fn();
const stop = watchKcpPaymentFrameClosure(iframe, onClose);
stop();
iframe.remove();
await vi.runOnlyPendingTimersAsync();
expect(onClose).not.toHaveBeenCalled();
vi.useRealTimers();
});
it('복합과세 분할 필드를 실결제액 기준으로 재배분', () => {
const fields = buildKcpTaxFields({
...PG_PAYMENT,
amount: 19000,
tax_amount: 11000,
vat_amount: 1000,
tax_free_amount: 10000,
});
expect(fields).toEqual({
tax_flag: 'TG03',
comm_tax_mny: '8182',
comm_vat_mny: '818',
comm_free_mny: '10000',
});
expect(
Number(fields.comm_tax_mny) + Number(fields.comm_vat_mny) + Number(fields.comm_free_mny),
).toBe(19000);
});
it('KCP 통화 가드는 빈 값과 KRW만 허용', () => {
expect(isSupportedKcpCurrency(undefined)).toBe(true);
expect(isSupportedKcpCurrency('KRW')).toBe(true);
expect(isSupportedKcpCurrency('usd')).toBe(false);
});
it('간편결제 원 결제수단을 KCP callback 반환 필드에 싣는다', () => {
expect(buildKcpEasyPayReturnFields('nhnkcp_naverpay', true)).toEqual({
param_opt_1: 'nhnkcp_naverpay',
nhnkcp_easy_pay_method: 'nhnkcp_naverpay',
});
expect(buildKcpEasyPayReturnFields('card', false)).toEqual({});
expect(buildKcpEasyPayReturnFields('nhnkcp_unknown', true)).toEqual({});
});
});
@@ -0,0 +1,153 @@
/**
* @file pluginSettingsSticky.test.tsx
* @description sirsoft-pay_nhnkcp 플러그인 환경설정 화면 하단 저장 버튼 sticky 고정 테스트
*
* 플러그인 환경설정(plugin_settings.json)의 하단 저장/취소 버튼 영역이
* 긴 콘텐츠를 스크롤하는 동안에도 화면 하단에 고정되도록 sticky 클래스가
* 적용되어 있는지 검증한다.
*/
import { describe, it, expect } from 'vitest';
import pluginSettingsLayout from '../../../layouts/admin/plugin_settings.json';
/** 레이아웃 트리에서 주어진 id 의 노드를 찾는다. */
function findById(node: unknown, id: string): Record<string, unknown> | undefined {
if (!node || typeof node !== 'object') {
return undefined;
}
const value = node as Record<string, unknown>;
if (value.id === id) {
return value;
}
for (const child of Object.values(value)) {
const found = findById(child, id);
if (found) {
return found;
}
}
return undefined;
}
function classNameOf(node: Record<string, unknown> | undefined): string {
const props = (node?.props ?? {}) as Record<string, unknown>;
return typeof props.className === 'string' ? props.className : '';
}
function collectClassNames(node: unknown, classNames: string[] = []): string[] {
if (!node || typeof node !== 'object') {
return classNames;
}
const value = node as Record<string, unknown>;
const props = (value.props ?? {}) as Record<string, unknown>;
if (typeof props.className === 'string') {
classNames.push(props.className);
}
for (const child of Object.values(value)) {
collectClassNames(child, classNames);
}
return classNames;
}
function collectIds(node: unknown, ids: string[] = []): string[] {
if (!node || typeof node !== 'object') {
return ids;
}
const value = node as Record<string, unknown>;
if (typeof value.id === 'string') {
ids.push(value.id);
}
for (const child of Object.values(value)) {
collectIds(child, ids);
}
return ids;
}
describe('plugin_settings 하단 버튼 sticky 고정', () => {
it('가상계좌 입금통보 URL 등록 경로를 설정 화면에 노출해야 한다', () => {
const section = findById(pluginSettingsLayout, 'vbank_notify_section');
expect(section).toBeDefined();
const serialized = JSON.stringify(section);
expect(serialized).toContain('sirsoft-pay_nhnkcp.settings.section_vbank_notify');
expect(serialized).toContain('sirsoft-pay_nhnkcp.settings.vbank_notify_hint');
expect(serialized).toContain('sirsoft-pay_nhnkcp.settings.vbank_notify_path');
});
it('결제취소 서버 IP 등록 안내를 설정 화면에 노출해야 한다', () => {
const notice = findById(pluginSettingsLayout, 'cancel_server_ip_notice');
expect(notice).toBeDefined();
const className = classNameOf(notice);
expect(className).toContain('border-amber-200');
expect(className).toContain('dark:border-amber-700');
const serialized = JSON.stringify(notice);
expect(serialized).toContain('sirsoft-pay_nhnkcp.settings.cancel_server_ip_title');
expect(serialized).toContain('sirsoft-pay_nhnkcp.settings.cancel_server_ip_body');
expect(serialized).toContain('sirsoft-pay_nhnkcp.settings.cancel_server_ip_path');
expect(serialized).toContain('sirsoft-pay_nhnkcp.settings.cancel_server_ip_effect');
expect(serialized).toContain('sirsoft-pay_nhnkcp.settings.cancel_server_ip_value_label');
expect(serialized).toContain('health.data.summary.cancel_server_ip.address');
expect(serialized).toContain('sirsoft-pay_nhnkcp.copyToClipboard');
expect(serialized).toContain('https://partner.kcp.co.kr/');
expect(serialized).not.toContain('https://admin8.kcp.co.kr');
const ids = collectIds(pluginSettingsLayout);
expect(ids.indexOf('cancel_server_ip_notice')).toBeGreaterThan(-1);
expect(ids.indexOf('vbank_notify_section')).toBeGreaterThan(-1);
expect(ids.indexOf('cancel_server_ip_notice')).toBeLessThan(ids.indexOf('vbank_notify_section'));
});
it('footer_buttons 에 sticky bottom 고정 클래스가 존재해야 한다', () => {
const footer = findById(pluginSettingsLayout, 'footer_buttons');
expect(footer).toBeDefined();
const className = classNameOf(footer);
expect(className).toContain('sticky');
expect(className).toContain('bottom-0');
expect(className).toContain('z-10');
expect(className).toContain('border-gray-200');
});
it('health_check_card 가 기본 검정 border 대신 관리자 카드 톤을 명시해야 한다', () => {
const card = findById(pluginSettingsLayout, 'health_check_card');
expect(card).toBeDefined();
const className = classNameOf(card);
expect(className).toContain('shadow-sm');
expect(className).toContain('border-gray-200');
expect(className).toContain('dark:border-gray-700');
});
it('health_check_card 내부 구분선도 기본 검정 border 로 떨어지지 않아야 한다', () => {
const card = findById(pluginSettingsLayout, 'health_check_card');
expect(card).toBeDefined();
const classNames = collectClassNames(card);
expect(classNames).not.toContain('flex items-center justify-between p-4 border-b dark:border-gray-700');
expect(classNames).not.toContain('grid grid-cols-1 sm:grid-cols-3 gap-3 p-4 border-b dark:border-gray-700');
});
it('활성 테마의 신규 시맨틱 CSS 없이도 설정 화면 기본 레이아웃이 적용되어야 한다', () => {
const root = findById(pluginSettingsLayout, 'plugin_settings_content');
expect(root).toBeDefined();
const rootClassName = classNameOf(root);
expect(rootClassName).toContain('p-4');
expect(rootClassName).toContain('sm:p-6');
expect(rootClassName).toContain('lg:p-8');
expect(rootClassName).toContain('min-h-screen');
expect(rootClassName).toContain('bg-gray-50');
expect(rootClassName).toContain('dark:bg-gray-900');
const classNames = collectClassNames(pluginSettingsLayout);
for (const className of classNames) {
expect(className).not.toContain('admin-page-content-responsive');
expect(className).not.toContain('flex-between');
expect(className).not.toContain('sticky-footer-buttons');
expect(className).not.toContain('row-stack');
}
});
});
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it } from 'vitest';
import { patchAdminPaymentMethodDisplay } from '../adminOrderPaymentDisplayInjector';
import { patchMypagePaymentMethodDisplay } from '../mypageOrderShowInjector';
describe('NHN KCP order payment display injectors', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('마이페이지 주문 상세의 결제 방법 행을 간편결제 표시로 바꾼다', () => {
const container = document.createElement('div');
container.innerHTML = `
<div class="flex items-center justify-between">
<span class="text-gray-500">결제 방법</span>
<span class="text-gray-900">신용카드</span>
</div>
`;
expect(patchMypagePaymentMethodDisplay(container, '네이버페이 (신용카드)')).toBe(true);
expect(container.textContent?.replace(/\s+/g, '')).toContain('결제방법네이버페이(신용카드)');
expect(container.querySelector('[data-nhnkcp-payment-method-patched="true"]')).not.toBeNull();
});
it('관리자 주문 상세의 결제수단 행과 상단 배지를 간편결제 표시로 바꾼다', () => {
const root = document.createElement('div');
root.innerHTML = `
<span class="inline-flex rounded-full font-medium">신용카드</span>
<div>
<span class="text-xs block">결제수단</span>
<span class="text-sm font-semibold block">신용카드</span>
<span class="text-xs text-gray-500">(일시불)</span>
</div>
`;
expect(patchAdminPaymentMethodDisplay(root, {
_pay_method_label: '카카오페이 (신용카드)',
_base_pay_method_label: '신용카드',
_embedded_pg_provider_label: '카카오페이',
})).toBe(true);
const text = root.textContent?.replace(/\s+/g, '');
expect(text).toContain('카카오페이결제수단카카오페이(신용카드,일시불)');
});
});
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { requestPaymentHandler } from '../handlers/requestPayment';
import { installOrderResponseInterceptor } from '../orderResponseInterceptor';
vi.mock('../handlers/requestPayment', () => ({
requestPaymentHandler: vi.fn().mockResolvedValue(undefined),
}));
interface MutatedOrderResponse {
data?: {
redirect_url?: string;
requires_pg_payment?: boolean;
};
}
function windowRecord(): Record<string, unknown> {
return window as unknown as Record<string, unknown>;
}
describe('installOrderResponseInterceptor', () => {
beforeEach(() => {
document.documentElement.lang = 'ko';
window.history.pushState({}, '', '/shop/checkout');
vi.mocked(requestPaymentHandler).mockClear();
vi.spyOn(console, 'info').mockImplementation(() => {});
});
afterEach(() => {
const w = windowRecord();
delete w['__sirsoftNhnkcpInterceptorInstalled'];
delete w['__sirsoftPgOriginalFetch'];
delete w['G7Core'];
document.body.innerHTML = '';
vi.restoreAllMocks();
});
it('NHN KCP 간편결제는 주문 생성 요청을 card로 전송하고 원래 결제수단으로 KCP 결제창을 호출한다', async () => {
let sentBody = '';
window.fetch = vi.fn().mockImplementation(async (_input, init?: RequestInit) => {
sentBody = String(init?.body ?? '');
return new Response(JSON.stringify({
success: true,
data: {
order: {
order_number: 'ORD-100',
total_amount: 15000,
total_due_amount: 14000,
orderer_name: 'Tester',
orderer_email: 'tester@example.test',
orderer_phone: '010-1234-5678',
options: [{ product_name: '테스트 상품' }],
},
redirect_url: '/shop/orders/ORD-100/complete',
requires_pg_payment: true,
pg_provider: 'sirsoft-kginicis',
},
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}) as unknown as typeof fetch;
installOrderResponseInterceptor();
const response = await window.fetch('/api/modules/sirsoft-ecommerce/user/orders', {
method: 'POST',
body: JSON.stringify({ payment_method: 'nhnkcp_naverpay' }),
});
const body = (await response.json()) as MutatedOrderResponse;
expect(JSON.parse(sentBody)).toMatchObject({ payment_method: 'card' });
expect(requestPaymentHandler).toHaveBeenCalledWith({
params: {
pgPaymentData: expect.objectContaining({
order_number: 'ORD-100',
order_name: '테스트 상품',
amount: 14000,
customer_phone: '01012345678',
}),
paymentMethod: 'nhnkcp_naverpay',
},
});
expect(body.data?.requires_pg_payment).toBe(false);
expect(body.data?.redirect_url).toBe('/shop/checkout');
});
it('iOS 모바일 기기가 아닌 Apple Pay 주문 생성 요청은 서버 전송 전에 차단한다', async () => {
const fetchSpy = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
window.fetch = fetchSpy as unknown as typeof fetch;
installOrderResponseInterceptor();
const response = await window.fetch('/api/modules/sirsoft-ecommerce/user/orders', {
method: 'POST',
body: JSON.stringify({ payment_method: 'nhnkcp_applepay' }),
});
const body = await response.json() as {
success?: boolean;
error?: string;
errors?: { payment_method?: string[] };
};
expect(fetchSpy).not.toHaveBeenCalled();
expect(response.status).toBe(422);
expect(body.success).toBe(false);
expect(body.error).toBe('애플페이는 IOS 기기에 모바일결제만 가능합니다.');
expect(body.errors?.payment_method?.[0]).toBe('애플페이는 IOS 기기에 모바일결제만 가능합니다.');
expect(requestPaymentHandler).not.toHaveBeenCalled();
});
it('일반 NHN KCP 주문 응답도 결제창을 호출하고 fallback 이동을 막는다', async () => {
window.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({
success: true,
data: {
order: { order_number: 'ORD-101' },
redirect_url: '/shop/orders/ORD-101/complete',
requires_pg_payment: true,
pg_provider: 'sirsoft-nhnkcp',
pg_payment_data: {
order_number: 'ORD-101',
order_name: '일반 주문',
amount: 20000,
},
},
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
) as unknown as typeof fetch;
installOrderResponseInterceptor();
const response = await window.fetch('/api/modules/sirsoft-ecommerce/user/orders', {
method: 'POST',
body: JSON.stringify({ payment_method: 'card' }),
});
const body = (await response.json()) as MutatedOrderResponse;
expect(requestPaymentHandler).toHaveBeenCalledWith({
params: {
pgPaymentData: expect.objectContaining({
order_number: 'ORD-101',
pay_method: 'card',
}),
paymentMethod: undefined,
},
});
expect(body.data?.requires_pg_payment).toBe(false);
expect(body.data?.redirect_url).toBe('/shop/checkout');
});
});
@@ -0,0 +1,92 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { preparePaymentRetry, reportPaymentWindowClosed } from '../paymentCloseReport';
function windowRecord(): Record<string, any> {
return window as unknown as Record<string, any>;
}
describe('paymentCloseReport', () => {
afterEach(() => {
delete windowRecord().G7Core;
vi.restoreAllMocks();
});
it('G7Core API로 NHN KCP 결제창 닫힘을 보고한다', async () => {
const apiPost = vi.fn().mockResolvedValue({ success: true });
windowRecord().G7Core = {
api: { post: apiPost },
};
await reportPaymentWindowClosed({
closeReportUrl: '/plugins/sirsoft-pay_nhnkcp/payment/close-report',
oid: 'ORD-KCP-CLOSE-001',
price: 10000,
buyer_email: 'buyer@example.com',
buyer_phone: '01012345678',
payment_method: 'card',
}, 'kcp-window-closed');
expect(apiPost).toHaveBeenCalledWith('/plugins/sirsoft-pay_nhnkcp/payment/close-report', {
oid: 'ORD-KCP-CLOSE-001',
price: 10000,
buyer_email: 'buyer@example.com',
buyer_phone: '01012345678',
payment_method: 'card',
reason: 'kcp-window-closed',
});
});
it('G7Core API가 없으면 /plugins 경로를 /api/plugins 경로로 변환해 fetch 한다', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{}'));
await reportPaymentWindowClosed({
closeReportUrl: '/plugins/sirsoft-pay_nhnkcp/payment/close-report',
oid: 'ORD-KCP-CLOSE-002',
price: 15000,
});
expect(fetchSpy).toHaveBeenCalledWith('/api/plugins/sirsoft-pay_nhnkcp/payment/close-report', expect.objectContaining({
method: 'POST',
keepalive: true,
}));
});
it('결제창을 열기 전 같은 주문 재시도 준비 API를 호출한다', async () => {
const apiPost = vi.fn().mockResolvedValue({ success: true });
windowRecord().G7Core = {
api: { post: apiPost },
};
await preparePaymentRetry({
closeReportUrl: '/plugins/sirsoft-pay_nhnkcp/payment/close-report',
oid: 'ORD-KCP-RETRY-001',
price: 20000,
buyer_email: 'buyer@example.com',
buyer_phone: '01012345678',
payment_method: 'nhnkcp_kakaopay',
});
expect(apiPost).toHaveBeenCalledWith('/plugins/sirsoft-pay_nhnkcp/payment/retry', {
oid: 'ORD-KCP-RETRY-001',
price: 20000,
buyer_email: 'buyer@example.com',
buyer_phone: '01012345678',
payment_method: 'nhnkcp_kakaopay',
});
});
it('재시도 준비 API가 실패하면 결제창을 열지 않도록 오류를 전파한다', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
errors: { message: ['Order is not retryable for NHN KCP payment.'] },
}), {
status: 409,
headers: { 'Content-Type': 'application/json' },
}));
await expect(preparePaymentRetry({
closeReportUrl: '/plugins/sirsoft-pay_nhnkcp/payment/close-report',
oid: 'ORD-KCP-RETRY-002',
price: 20000,
})).rejects.toThrow('Order is not retryable for NHN KCP payment.');
});
});
@@ -0,0 +1,142 @@
const PLUGIN_ID = 'sirsoft-pay_nhnkcp';
const FLAG = '__kcpAdminApplePayNoticeInjectorInstalled';
const ADMIN_SETTINGS_RE = /^\/admin\/ecommerce\/settings\/?$/;
const LISTENER_FLAG = '__kcpAdminApplePayNoticeListenerAttached';
const NOTICE_KO = '애플페이는 IOS 기기에 모바일결제만 가능합니다.';
const NOTICE_EN = 'Apple Pay is available only for mobile payments on iOS devices.';
let observer: MutationObserver | null = null;
let retryTimer: number | null = null;
let syncQueued = false;
function windowRecord(): Record<string, unknown> {
return window as unknown as Record<string, unknown>;
}
function isOrderSettingsPage(): boolean {
if (!ADMIN_SETTINGS_RE.test(window.location.pathname)) return false;
const tab = new URLSearchParams(window.location.search).get('tab');
return tab === null || tab === 'order_settings';
}
function noticeText(): string {
const lang = document.documentElement.lang || navigator.language || '';
return lang.toLowerCase().startsWith('ko') ? NOTICE_KO : NOTICE_EN;
}
function normalizedText(value: string | null | undefined): string {
return (value ?? '').replace(/\s+/g, ' ').trim();
}
function isApplePayLabel(element: HTMLElement): boolean {
const text = normalizedText(element.textContent);
return text === '애플페이' || text === 'Apple Pay';
}
function createNotice(): HTMLSpanElement {
const notice = document.createElement('span');
notice.dataset.nhnkcpAdminApplePayNotice = 'true';
notice.className = 'text-label-subtle';
notice.style.display = 'block';
notice.style.width = '100%';
notice.style.flexBasis = '100%';
notice.style.marginTop = '2px';
notice.style.fontSize = '12px';
notice.style.lineHeight = '1rem';
notice.textContent = noticeText();
return notice;
}
export function patchAdminApplePayNotice(root: ParentNode = document): boolean {
if (typeof window === 'undefined' || typeof document === 'undefined') return false;
if (!isOrderSettingsPage()) return false;
const bodyText = normalizedText(document.body?.innerText ?? document.body?.textContent);
if (bodyText.includes(noticeText())) return false;
const applePayLabel = Array.from(root.querySelectorAll<HTMLElement>('span, p, div'))
.find(isApplePayLabel);
if (!applePayLabel) return false;
applePayLabel.insertAdjacentElement('afterend', createNotice());
return true;
}
function queueSync(): void {
if (syncQueued) return;
syncQueued = true;
window.setTimeout(() => {
syncQueued = false;
patchAdminApplePayNotice();
}, 0);
}
function stopRetries(): void {
if (retryTimer === null) return;
window.clearInterval(retryTimer);
retryTimer = null;
}
function startSync(): void {
if (!isOrderSettingsPage()) return;
stopRetries();
patchAdminApplePayNotice();
let attempts = 0;
retryTimer = window.setInterval(() => {
attempts += 1;
if (patchAdminApplePayNotice() || attempts >= 50) {
stopRetries();
}
}, 200);
const body = document.body as HTMLElement & Record<string, unknown>;
if (body[LISTENER_FLAG]) return;
body[LISTENER_FLAG] = true;
observer = new MutationObserver(() => queueSync());
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
}
function onRouteChange(): void {
if (isOrderSettingsPage()) startSync();
}
export function installAdminApplePayNoticeInjector(): void {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
const w = windowRecord();
if (w[FLAG]) return;
w[FLAG] = true;
console.info(`[${PLUGIN_ID}] admin Apple Pay notice injector installed`);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => startSync());
} else {
startSync();
}
const origPush = history.pushState.bind(history);
history.pushState = (...args: Parameters<typeof history.pushState>) => {
origPush(...args);
window.setTimeout(() => onRouteChange(), 200);
};
window.addEventListener('popstate', () => window.setTimeout(() => onRouteChange(), 200));
}
export function resetAdminApplePayNoticeInjectorForTests(): void {
observer?.disconnect();
observer = null;
stopRetries();
syncQueued = false;
delete windowRecord()[FLAG];
if (document.body) {
delete ((document.body as HTMLElement & Record<string, unknown>)[LISTENER_FLAG]);
}
}
@@ -0,0 +1,163 @@
const PLUGIN_ID = 'sirsoft-pay_nhnkcp';
const FLAG = '__nhnkcpAdminPaymentDisplayInjectorInstalled';
const ADMIN_ORDER_SHOW_RE = /^\/admin\/ecommerce\/orders\/([^/]+)$/;
interface AdminTransactionStatus {
_pay_method_label?: string | null;
_base_pay_method_label?: string | null;
_embedded_pg_provider_label?: string | null;
}
function getToken(): string | null {
return localStorage.getItem('auth_token');
}
async function fetchAdminTransactionStatus(orderNumber: string): Promise<AdminTransactionStatus | null> {
const token = getToken();
if (!token) return null;
try {
const res = await fetch(`/api/plugins/${PLUGIN_ID}/admin/orders/${orderNumber}/transaction-status`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
if (!res.ok) return null;
const json = (await res.json()) as { data?: AdminTransactionStatus | null } | AdminTransactionStatus;
return ('data' in json ? json.data : json) ?? null;
} catch {
return null;
}
}
function directElementChildren(el: Element): HTMLElement[] {
return Array.from(el.children).filter(
(child): child is HTMLElement => child instanceof HTMLElement,
);
}
function patchLabeledPaymentMethod(root: Element, displayLabel: string): boolean {
let patched = false;
const rows = Array.from(root.querySelectorAll<HTMLElement>('div'));
for (const row of rows) {
const children = directElementChildren(row);
const labelIndex = children.findIndex(child => child.textContent?.trim() === '결제수단');
if (labelIndex < 0) continue;
const value = children
.slice(labelIndex + 1)
.find(child => child.textContent?.trim() && child.textContent.trim() !== '결제수단');
if (!value) continue;
const installment = children
.slice(labelIndex + 2)
.find(child => /^\(.+\)$/.test(child.textContent?.trim() ?? ''));
const installmentText = installment?.textContent?.trim().replace(/^\(|\)$/g, '') ?? '';
const nextDisplayLabel = installmentText && displayLabel.endsWith(')')
? displayLabel.replace(/\)$/, `, ${installmentText})`)
: displayLabel;
if (value.textContent?.trim() !== nextDisplayLabel) {
value.textContent = nextDisplayLabel;
value.dataset.nhnkcpPaymentMethodPatched = 'true';
}
row.dataset.nhnkcpPaymentMethodRow = 'admin-order';
if (installment && installmentText) {
installment.textContent = '';
installment.style.display = 'none';
installment.dataset.nhnkcpPaymentInstallmentHidden = 'true';
}
patched = true;
}
return patched;
}
function patchPaymentMethodBadge(root: Element, baseLabel: string, embeddedLabel: string): boolean {
const candidates = Array.from(root.querySelectorAll<HTMLElement>('span')).filter(
el => el.textContent?.trim() === baseLabel,
);
const badge = candidates.find(el => {
const className = typeof el.className === 'string' ? el.className : '';
return className.includes('rounded-full') || className.includes('font-medium');
});
if (!badge) return false;
badge.textContent = embeddedLabel;
badge.dataset.nhnkcpPaymentMethodPatched = 'true';
badge.dataset.nhnkcpPaymentMethodBadge = 'admin-order';
return true;
}
export function patchAdminPaymentMethodDisplay(
root: Element,
status: AdminTransactionStatus | null | undefined,
): boolean {
const displayLabel = status?._pay_method_label;
const embeddedLabel = status?._embedded_pg_provider_label;
const baseLabel = status?._base_pay_method_label;
if (!displayLabel || !embeddedLabel) return false;
const labeled = patchLabeledPaymentMethod(root, displayLabel);
const badge = baseLabel ? patchPaymentMethodBadge(root, baseLabel, embeddedLabel) : false;
return labeled || badge;
}
async function tryInject(orderNumber: string): Promise<boolean> {
const root = document.getElementById('section_payment_info') ?? document.getElementById('payment_info_card');
if (!root) return false;
const status = await fetchAdminTransactionStatus(orderNumber);
if (!status) return true;
const patched = patchAdminPaymentMethodDisplay(root, status);
if (patched) {
console.info(`[${PLUGIN_ID}] payment method display patched on admin order detail`);
}
return true;
}
function startPolling(orderNumber: string): void {
let attempts = 0;
const id = setInterval(() => {
attempts++;
void tryInject(orderNumber).then(done => {
if (done || attempts >= 30) clearInterval(id);
});
}, 400);
}
function onRouteChange(): void {
const match = location.pathname.match(ADMIN_ORDER_SHOW_RE);
if (match) startPolling(match[1]);
}
export function installAdminOrderPaymentDisplayInjector(): void {
if (typeof window === 'undefined') return;
const w = window as unknown as Record<string, unknown>;
if (w[FLAG]) return;
w[FLAG] = true;
console.info(`[${PLUGIN_ID}] admin order payment display injector installed`);
const schedule = (delay = 1500) => setTimeout(onRouteChange, delay);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => schedule());
} else {
schedule();
}
const origPush = history.pushState.bind(history);
history.pushState = (...args: Parameters<typeof history.pushState>) => {
origPush(...args);
schedule(600);
};
window.addEventListener('popstate', () => schedule(500));
}
@@ -0,0 +1,278 @@
const PLUGIN_ID = 'sirsoft-pay_nhnkcp';
const FLAG = '__nhnkcpAdminPaymentMethodBrandInjectorInstalled';
const LISTENER_FLAG = '__nhnkcpAdminPaymentMethodBrandSyncListenerAttached';
const ADMIN_SETTINGS_RE = /^\/admin\/ecommerce\/settings\/?$/;
const MARK_SELECTOR = '[data-nhnkcp-admin-payment-brand-mark="true"]';
const SYNC_RETRY_INTERVAL_MS = 200;
const SYNC_RETRY_ATTEMPTS = 120;
interface AdminPaymentBrandDefinition {
id: string;
labels: string[];
shortLabels: string[];
markLines: string[];
markClassName: string;
}
const ADMIN_PAYMENT_BRAND_DEFINITIONS: AdminPaymentBrandDefinition[] = [
{
id: 'nhnkcp_payco',
labels: ['PAYCO (NHN KCP)', 'PAYCO로 결제 (NHN KCP)', 'Pay with PAYCO (NHN KCP)'],
shortLabels: ['PAYCO'],
markLines: ['P'],
markClassName: 'bg-red-500 text-white',
},
{
id: 'nhnkcp_naverpay',
labels: ['네이버페이 (NHN KCP)', '네이버페이 신용카드로 결제 (NHN KCP)', 'Naver Pay (NHN KCP)', 'Pay by Naver Pay credit card (NHN KCP)'],
shortLabels: ['네이버페이 (카드)', 'Naver Pay (Card)'],
markLines: ['N'],
markClassName: 'bg-green-500 text-white',
},
{
id: 'nhnkcp_naverpay_point',
labels: ['네이버페이 포인트 (NHN KCP)', '네이버페이 머니/포인트로 결제 (NHN KCP)', 'Naver Pay Point (NHN KCP)', 'Pay with Naver Pay Money/Points (NHN KCP)'],
shortLabels: ['네이버페이 (포인트)', 'Naver Pay (Point)'],
markLines: ['NP'],
markClassName: 'bg-green-600 text-white',
},
{
id: 'nhnkcp_kakaopay',
labels: ['카카오페이 (NHN KCP)', '카카오페이로 결제 (NHN KCP)', 'Kakao Pay (NHN KCP)', 'Pay with Kakao Pay (NHN KCP)'],
shortLabels: ['카카오페이', 'Kakao Pay'],
markLines: ['K'],
markClassName: 'bg-yellow-400 text-gray-950',
},
{
id: 'nhnkcp_applepay',
labels: ['애플페이 (NHN KCP)', '애플페이로 결제 (NHN KCP)', 'Apple Pay (NHN KCP)', 'Pay with Apple Pay (NHN KCP)'],
shortLabels: ['애플페이', 'Apple Pay'],
markLines: ['A'],
markClassName: 'bg-gray-900 text-white',
},
];
let observer: MutationObserver | null = null;
let retryTimer: number | null = null;
let syncQueued = false;
function windowRecord(): Record<string, unknown> {
return window as unknown as Record<string, unknown>;
}
function isOrderSettingsPage(): boolean {
if (!ADMIN_SETTINGS_RE.test(window.location.pathname)) return false;
const tab = new URLSearchParams(window.location.search).get('tab');
return tab === null || tab === '' || tab === 'order_settings';
}
function classNameOf(element: Element): string {
return element instanceof HTMLElement ? element.className : '';
}
function comparableText(value: string | null | undefined): string {
return (value ?? '')
.replace(/\u200B/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function isPaymentMethodItem(element: HTMLElement): boolean {
const className = classNameOf(element);
return className.includes('excel-card')
|| (className.includes('flex-center') && className.includes('border') && className.includes('gap-4'));
}
function findTitleElement(item: HTMLElement): HTMLElement | null {
return item.querySelector<HTMLElement>('.font-medium');
}
function findDefinitionForItem(item: HTMLElement): AdminPaymentBrandDefinition | null {
const title = comparableText(findTitleElement(item)?.textContent);
const text = comparableText(item.textContent);
return ADMIN_PAYMENT_BRAND_DEFINITIONS.find((definition) => (
definition.shortLabels.some((label) => title === label)
|| definition.labels.some((label) => text.includes(label))
)) ?? null;
}
function findPaymentMethodItems(root: ParentNode): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>('div'))
.filter(isPaymentMethodItem);
}
function findPaymentIcon(item: HTMLElement): Element | null {
const icons = Array.from(item.querySelectorAll<Element>('svg, [data-icon], i'));
return icons.find((element) => (
!element.closest('[data-drag-handle]')
&& !element.closest(MARK_SELECTOR)
&& !element.closest('.row-stack, .excel-card-body, button, select, [role="switch"]')
)) ?? null;
}
function applyBrandMarkContent(mark: HTMLSpanElement, definition: AdminPaymentBrandDefinition): void {
mark.dataset.nhnkcpAdminPaymentBrandMark = 'true';
mark.dataset.nhnkcpAdminPaymentMethod = definition.id;
mark.setAttribute('aria-hidden', 'true');
mark.className = `inline-flex items-center justify-center rounded-lg font-bold ${definition.markClassName}`;
mark.style.width = '32px';
mark.style.height = '32px';
mark.style.flex = '0 0 32px';
mark.style.lineHeight = '1';
mark.style.fontSize = definition.markLines.join('').length > 2 ? '9px' : '12px';
const expectedText = definition.markLines.join('');
if (mark.textContent === expectedText) return;
mark.replaceChildren();
if (definition.markLines.length > 1) {
mark.style.flexDirection = 'column';
definition.markLines.forEach((line) => {
const lineElement = document.createElement('span');
lineElement.style.lineHeight = '1';
lineElement.textContent = line;
mark.appendChild(lineElement);
});
} else {
mark.style.removeProperty('flex-direction');
mark.textContent = definition.markLines[0] ?? '';
}
}
function createBrandMark(definition: AdminPaymentBrandDefinition): HTMLSpanElement {
const mark = document.createElement('span');
applyBrandMarkContent(mark, definition);
return mark;
}
function syncBrandMark(item: HTMLElement, definition: AdminPaymentBrandDefinition): boolean {
const existing = item.querySelector<HTMLElement>(MARK_SELECTOR);
if (existing instanceof HTMLSpanElement) {
item.dataset.nhnkcpAdminPaymentMethod = definition.id;
applyBrandMarkContent(existing, definition);
return true;
}
const mark = createBrandMark(definition);
const icon = findPaymentIcon(item);
if (icon && icon.parentElement) {
item.dataset.nhnkcpAdminPaymentMethod = definition.id;
icon.replaceWith(mark);
return true;
}
const title = findTitleElement(item);
if (title && title.parentElement) {
item.dataset.nhnkcpAdminPaymentMethod = definition.id;
title.parentElement.insertBefore(mark, title);
return true;
}
return false;
}
export function syncRenderedAdminPaymentMethodBrands(root: ParentNode = document): boolean {
if (typeof window === 'undefined' || typeof document === 'undefined') return false;
if (!isOrderSettingsPage()) return false;
let matched = false;
findPaymentMethodItems(root).forEach((item) => {
const definition = findDefinitionForItem(item);
if (!definition) return;
if (syncBrandMark(item, definition)) {
matched = true;
}
});
return matched;
}
function queueSync(): void {
if (syncQueued) return;
syncQueued = true;
window.setTimeout(() => {
syncQueued = false;
syncRenderedAdminPaymentMethodBrands();
}, 0);
}
function stopRetries(): void {
if (retryTimer === null) return;
window.clearInterval(retryTimer);
retryTimer = null;
}
function startSync(): void {
if (!isOrderSettingsPage()) return;
stopRetries();
syncRenderedAdminPaymentMethodBrands();
let attempts = 0;
retryTimer = window.setInterval(() => {
attempts += 1;
syncRenderedAdminPaymentMethodBrands();
if (attempts >= SYNC_RETRY_ATTEMPTS) {
stopRetries();
}
}, SYNC_RETRY_INTERVAL_MS);
const body = document.body as HTMLElement & Record<string, unknown>;
if (body[LISTENER_FLAG]) return;
body[LISTENER_FLAG] = true;
observer = new MutationObserver(() => queueSync());
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
}
function onRouteChange(): void {
if (isOrderSettingsPage()) {
startSync();
return;
}
stopRetries();
}
export function installAdminPaymentMethodBrandInjector(): void {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
const w = windowRecord();
if (w[FLAG]) return;
w[FLAG] = true;
console.info(`[${PLUGIN_ID}] admin payment method brand sync installed`);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => startSync());
} else {
startSync();
}
const origPush = history.pushState.bind(history);
history.pushState = (...args: Parameters<typeof history.pushState>) => {
origPush(...args);
window.setTimeout(() => onRouteChange(), 200);
};
window.addEventListener('popstate', () => window.setTimeout(() => onRouteChange(), 200));
}
export function resetAdminPaymentMethodBrandInjectorForTests(): void {
observer?.disconnect();
observer = null;
stopRetries();
syncQueued = false;
delete windowRecord()[FLAG];
if (document.body) {
delete ((document.body as HTMLElement & Record<string, unknown>)[LISTENER_FLAG]);
}
}
@@ -0,0 +1,429 @@
import {
isIosMobileDevice,
isNhnKcpApplePayMethod,
} from './support/applePayDevice';
const PLUGIN_ID = 'sirsoft-pay_nhnkcp';
const FLAG = '__kcpCheckoutEasyPayInjectorInstalled';
const CHECKOUT_RE = /^\/shop\/checkout\/?$/;
const LISTENER_FLAG = '__kcpCheckoutEasyPaySyncListenerAttached';
const SYNC_RETRY_INTERVAL_MS = 200;
const SYNC_RETRY_ATTEMPTS = 120;
interface EasyPayCopy {
heading: string;
description: string;
title: string;
}
interface EasyPayDefinition {
id: string;
configKey: string;
labels: string[];
ko: EasyPayCopy;
en: EasyPayCopy;
markText: string;
markClassName: string;
}
const EASY_PAY_DEFINITIONS: EasyPayDefinition[] = [
{
id: 'nhnkcp_payco',
configKey: 'PAYCO',
labels: ['PAYCO (NHN KCP)', 'PAYCO NHN KCP 간편결제', 'PAYCO로 결제 (NHN KCP)', 'Pay with PAYCO (NHN KCP)'],
ko: {
heading: 'PAYCO',
description: 'PAYCO로 결제 (NHN KCP)',
title: 'PAYCO로 결제 (NHN KCP)',
},
en: {
heading: 'PAYCO',
description: 'Pay with PAYCO (NHN KCP)',
title: 'Pay with PAYCO (NHN KCP)',
},
markText: 'P',
markClassName: 'bg-red-500 text-white',
},
{
id: 'nhnkcp_naverpay',
configKey: 'NAVERPAY',
labels: ['네이버페이 (NHN KCP)', '네이버페이 신용카드로 결제 (NHN KCP)', 'NaverPay NHN KCP 간편결제', 'Naver Pay (NHN KCP)', 'NaverPay (NHN KCP)', 'Pay by Naver Pay credit card (NHN KCP)'],
ko: {
heading: '네이버페이 (카드)',
description: '네이버페이 신용카드로 결제 (NHN KCP)',
title: '네이버페이 신용카드로 결제 (NHN KCP)',
},
en: {
heading: 'Naver Pay (Card)',
description: 'Pay by Naver Pay credit card (NHN KCP)',
title: 'Pay by Naver Pay credit card (NHN KCP)',
},
markText: 'N',
markClassName: 'bg-green-500 text-white',
},
{
id: 'nhnkcp_naverpay_point',
configKey: 'NAVERPAY POINT',
labels: ['네이버페이 포인트 (NHN KCP)', '네이버페이 머니/포인트로 결제 (NHN KCP)', 'NaverPay 포인트 NHN KCP 포인트 간편결제', 'Naver Pay Point (NHN KCP)', 'NaverPay Point (NHN KCP)', 'Pay with Naver Pay Money/Points (NHN KCP)'],
ko: {
heading: '네이버페이 (포인트)',
description: '네이버페이 머니/포인트로 결제 (NHN KCP)',
title: '네이버페이 머니/포인트로 결제 (NHN KCP)',
},
en: {
heading: 'Naver Pay (Point)',
description: 'Pay with Naver Pay Money/Points (NHN KCP)',
title: 'Pay with Naver Pay Money/Points (NHN KCP)',
},
markText: 'NP',
markClassName: 'bg-green-600 text-white',
},
{
id: 'nhnkcp_kakaopay',
configKey: 'KAKAOPAY',
labels: ['카카오페이 (NHN KCP)', '카카오페이로 결제 (NHN KCP)', 'KakaoPay NHN KCP 간편결제', 'Kakao Pay (NHN KCP)', 'KakaoPay (NHN KCP)', 'Pay with Kakao Pay (NHN KCP)'],
ko: {
heading: '카카오페이',
description: '카카오페이로 결제 (NHN KCP)',
title: '카카오페이로 결제 (NHN KCP)',
},
en: {
heading: 'Kakao Pay',
description: 'Pay with Kakao Pay (NHN KCP)',
title: 'Pay with Kakao Pay (NHN KCP)',
},
markText: 'K',
markClassName: 'bg-yellow-400 text-gray-950',
},
{
id: 'nhnkcp_applepay',
configKey: 'APPLEPAY',
labels: ['애플페이 (NHN KCP)', '애플페이로 결제 (NHN KCP)', 'Apple Pay NHN KCP 간편결제', 'Apple Pay (NHN KCP)', 'Pay with Apple Pay (NHN KCP)'],
ko: {
heading: '애플페이',
description: '애플페이로 결제 (NHN KCP)',
title: '애플페이로 결제 (NHN KCP)',
},
en: {
heading: 'Apple Pay',
description: 'Pay with Apple Pay (NHN KCP)',
title: 'Pay with Apple Pay (NHN KCP)',
},
markText: 'A',
markClassName: 'bg-gray-900 text-white',
},
];
let observer: MutationObserver | null = null;
let retryTimer: number | null = null;
let syncQueued = false;
function windowRecord(): Record<string, unknown> {
return window as unknown as Record<string, unknown>;
}
function isCheckoutPage(): boolean {
return CHECKOUT_RE.test(window.location.pathname);
}
function normalizedText(value: string | null | undefined): string {
return (value ?? '').replace(/\s+/g, ' ').trim();
}
function isKoreanPage(): boolean {
const lang = document.documentElement.lang || navigator.language || '';
return lang.toLowerCase().startsWith('ko');
}
function copyFor(definition: EasyPayDefinition): EasyPayCopy {
return isKoreanPage() ? definition.ko : definition.en;
}
function displayText(value: string): string {
return value
.replaceAll('네이버페이', '네이\u200B버페이')
.replaceAll('카카오페이', '카카\u200B오페이')
.replaceAll('Naver Pay', 'Naver\u200B Pay')
.replaceAll('Kakao Pay', 'Kakao\u200B Pay');
}
function findDefinitionById(id: string | undefined): EasyPayDefinition | null {
return EASY_PAY_DEFINITIONS.find((definition) => definition.id === id) ?? null;
}
function findDefinitionForButton(button: HTMLButtonElement): EasyPayDefinition | null {
const markedDefinition = findDefinitionById(button.dataset.nhnkcpEasyPayMethod);
if (markedDefinition) return markedDefinition;
const text = normalizedText(button.textContent);
return EASY_PAY_DEFINITIONS.find((definition) => (
definition.labels.some((label) => text.includes(label))
)) ?? null;
}
function findPaymentRow(button: HTMLButtonElement): HTMLElement | null {
return button.querySelector<HTMLElement>('.flex.items-center.gap-2, .flex.items-center.gap-3')
?? button.querySelector<HTMLElement>('.flex.items-center');
}
function alignPaymentTextLeft(button: HTMLButtonElement, heading?: HTMLElement, description?: HTMLElement): void {
button.style.textAlign = 'left';
const row = findPaymentRow(button);
if (row) {
row.style.justifyContent = 'flex-start';
row.style.textAlign = 'left';
row.style.width = '100%';
}
const textContainer = heading?.parentElement ?? description?.parentElement;
if (textContainer instanceof HTMLElement) {
textContainer.style.textAlign = 'left';
textContainer.style.minWidth = '0';
}
}
function createMark(definition: EasyPayDefinition): HTMLSpanElement {
const mark = document.createElement('span');
mark.dataset.nhnkcpEasyPayMark = 'true';
mark.dataset.nhnkcpEasyPayMethod = definition.id;
mark.setAttribute('aria-hidden', 'true');
mark.className = `inline-flex items-center justify-center rounded-lg text-xs font-bold ${definition.markClassName}`;
mark.style.width = '32px';
mark.style.height = '32px';
mark.style.flex = '0 0 32px';
mark.textContent = definition.markText;
return mark;
}
function removeKginicisBrandArtifacts(button: HTMLButtonElement): void {
delete button.dataset.kginicisBrandPaymentButton;
delete button.dataset.kginicisBrandPaymentMethod;
delete button.dataset.kginicisNaverpayBrandButton;
button.querySelectorAll<HTMLElement>('[data-kginicis-brand-payment-mark="true"], [data-kginicis-naverpay-mark="true"]').forEach((element) => {
element.remove();
});
button.querySelectorAll<HTMLElement>('[data-kginicis-brand-payment-heading], [data-kginicis-brand-payment-description], [data-kginicis-naverpay-heading], [data-kginicis-naverpay-description]').forEach((element) => {
delete element.dataset.kginicisBrandPaymentHeading;
delete element.dataset.kginicisBrandPaymentDescription;
delete element.dataset.kginicisNaverpayHeading;
delete element.dataset.kginicisNaverpayDescription;
});
}
function findPaymentIcon(button: HTMLButtonElement): Element | null {
return button.querySelector('[data-kginicis-brand-payment-mark="true"], [data-kginicis-naverpay-mark="true"]')
?? button.querySelector('[data-nhnkcp-easy-pay-mark="true"]')
?? button.querySelector('svg')
?? button.querySelector('i[class*="fa-"], i[role="img"], i');
}
function ensureMark(button: HTMLButtonElement, definition: EasyPayDefinition): void {
const existingMark = button.querySelector<HTMLElement>('[data-nhnkcp-easy-pay-mark="true"]');
if (existingMark) {
existingMark.dataset.nhnkcpEasyPayMethod = definition.id;
existingMark.className = `inline-flex items-center justify-center rounded-lg text-xs font-bold ${definition.markClassName}`;
existingMark.textContent = definition.markText;
return;
}
const mark = createMark(definition);
const icon = findPaymentIcon(button);
if (icon && icon.parentElement) {
icon.replaceWith(mark);
return;
}
const row = findPaymentRow(button);
row?.prepend(mark);
}
function updatePaymentText(button: HTMLButtonElement, definition: EasyPayDefinition): void {
const paragraphs = Array.from(button.querySelectorAll<HTMLParagraphElement>('p'));
const copy = copyFor(definition);
const headingText = displayText(copy.heading);
const descriptionText = displayText(copy.description);
const heading = paragraphs[0];
const description = paragraphs[1];
if (heading && heading.textContent !== headingText) {
heading.textContent = headingText;
}
if (heading) {
heading.setAttribute('aria-label', copy.heading);
heading.style.textAlign = 'left';
heading.style.whiteSpace = 'normal';
heading.style.wordBreak = 'keep-all';
heading.style.overflowWrap = 'anywhere';
}
if (description && description.textContent !== descriptionText) {
description.textContent = descriptionText;
}
if (description) {
description.style.textAlign = 'left';
description.style.fontSize = '12px';
description.style.lineHeight = '1rem';
description.style.whiteSpace = 'normal';
description.style.wordBreak = 'keep-all';
description.style.overflowWrap = 'anywhere';
}
alignPaymentTextLeft(button, heading, description);
button.title = copy.title;
}
function showButton(button: HTMLButtonElement, definition: EasyPayDefinition): void {
if (isNhnKcpApplePayMethod(definition.id) && !isIosMobileDevice()) {
button.hidden = true;
button.disabled = true;
button.style.display = 'none';
button.setAttribute('aria-hidden', 'true');
button.dataset.nhnkcpEasyPayMethod = definition.id;
button.dataset.nhnkcpEasyPayHidden = 'true';
delete button.dataset.nhnkcpEasyPayVisible;
return;
}
if (button.hidden) button.hidden = false;
if (button.disabled) button.disabled = false;
if (button.style.display === 'none') button.style.removeProperty('display');
button.removeAttribute('aria-hidden');
button.dataset.nhnkcpEasyPayMethod = definition.id;
button.dataset.nhnkcpEasyPayVisible = 'true';
delete button.dataset.nhnkcpEasyPayHidden;
removeKginicisBrandArtifacts(button);
ensureMark(button, definition);
updatePaymentText(button, definition);
}
function reconcileKginicisPatchedDuplicates(root: ParentNode): boolean {
const duplicateGroups: Record<string, string[]> = {
kginicis_naverpay: ['nhnkcp_naverpay', 'nhnkcp_naverpay_point'],
kginicis_kakaopay: ['nhnkcp_kakaopay'],
};
let changed = false;
Object.entries(duplicateGroups).forEach(([kginicisMethod, nhnkcpMethods]) => {
const buttons = Array.from(root.querySelectorAll<HTMLButtonElement>(`button[data-kginicis-brand-payment-method="${kginicisMethod}"]`))
.filter((button) => !button.dataset.nhnkcpEasyPayMethod);
buttons.slice(1).forEach((button, index) => {
const definition = findDefinitionById(nhnkcpMethods[index]);
if (!definition) return;
showButton(button, definition);
changed = true;
});
});
return changed;
}
export async function syncRenderedCheckoutEasyPayMethods(
root: ParentNode = document,
): Promise<boolean> {
if (typeof window === 'undefined' || typeof document === 'undefined') return false;
if (!isCheckoutPage()) return false;
let changed = false;
root.querySelectorAll<HTMLButtonElement>('button').forEach((button) => {
const definition = findDefinitionForButton(button);
if (!definition) return;
showButton(button, definition);
changed = true;
});
if (reconcileKginicisPatchedDuplicates(root)) {
changed = true;
}
return changed;
}
function queueSync(): void {
if (syncQueued) return;
syncQueued = true;
window.setTimeout(() => {
syncQueued = false;
void syncRenderedCheckoutEasyPayMethods();
}, 0);
}
function stopRetries(): void {
if (retryTimer === null) return;
window.clearInterval(retryTimer);
retryTimer = null;
}
function startSync(): void {
if (!isCheckoutPage()) return;
stopRetries();
void syncRenderedCheckoutEasyPayMethods();
let attempts = 0;
retryTimer = window.setInterval(() => {
attempts += 1;
void syncRenderedCheckoutEasyPayMethods();
if (attempts >= SYNC_RETRY_ATTEMPTS) {
stopRetries();
}
}, SYNC_RETRY_INTERVAL_MS);
const body = document.body as HTMLElement & Record<string, unknown>;
if (body[LISTENER_FLAG]) return;
body[LISTENER_FLAG] = true;
observer = new MutationObserver(() => queueSync());
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
}
function onRouteChange(): void {
if (isCheckoutPage()) startSync();
}
export function installCheckoutEasyPayInjector(): void {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
const w = windowRecord();
if (w[FLAG]) return;
w[FLAG] = true;
console.info(`[${PLUGIN_ID}] checkout easy pay method sync installed`);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => startSync());
} else {
startSync();
}
const origPush = history.pushState.bind(history);
history.pushState = (...args: Parameters<typeof history.pushState>) => {
origPush(...args);
window.setTimeout(() => onRouteChange(), 200);
};
window.addEventListener('popstate', () => window.setTimeout(() => onRouteChange(), 200));
}
export function resetCheckoutEasyPayInjectorForTests(): void {
observer?.disconnect();
observer = null;
stopRetries();
syncQueued = false;
delete windowRecord()[FLAG];
if (document.body) {
delete ((document.body as HTMLElement & Record<string, unknown>)[LISTENER_FLAG]);
}
}
@@ -0,0 +1,34 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
function fallbackCopy(text: string): void {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', 'readonly');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
} finally {
textarea.remove();
}
}
export async function copyToClipboardHandler(action: any): Promise<void> {
const text = String(action.params?.text ?? '');
if (text === '') {
return;
}
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(text);
return;
}
fallbackCopy(text);
}
@@ -0,0 +1,9 @@
import { requestPaymentHandler } from './requestPayment';
import { setPaymentMethodHandler } from './setPaymentMethod';
import { copyToClipboardHandler } from './copyToClipboard';
export const handlerMap: Record<string, (...args: unknown[]) => unknown> = {
requestPayment: requestPaymentHandler,
setPaymentMethod: setPaymentMethodHandler,
copyToClipboard: copyToClipboardHandler,
} as const;
@@ -0,0 +1,593 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
PaymentCloseReportContext,
preparePaymentRetry,
reportPaymentWindowClosed,
} from '../paymentCloseReport';
import {
applePayUnsupportedMessage,
isIosMobileDevice,
isNhnKcpApplePayMethod,
} from '../support/applePayDevice';
interface PgPaymentData {
order_number: string;
order_name: string;
amount: number;
currency?: string;
pay_method?: string;
customer_name?: string;
customer_email?: string;
customer_phone?: string;
/** 복합과세 분할용 — orderResponseInterceptor가 data.order 에서 주입 */
tax_amount?: number;
vat_amount?: number;
tax_free_amount?: number;
}
interface RequestPaymentParams {
pgPaymentData: PgPaymentData;
paymentMethod?: string;
}
function normalizeCurrency(currency?: string): string {
const normalized = (currency ?? 'KRW').trim().toUpperCase();
return normalized || 'KRW';
}
export function isSupportedKcpCurrency(currency?: string): boolean {
return normalizeCurrency(currency) === 'KRW';
}
function unsupportedCurrencyMessage(currency?: string): string {
const normalized = normalizeCurrency(currency);
const isKo = ((typeof document !== 'undefined' ? document.documentElement.lang : '') || '')
.toLowerCase()
.startsWith('ko');
return isKo
? `NHN KCP는 KRW 결제만 지원합니다. (${normalized})`
: `NHN KCP supports KRW payments only. (${normalized})`;
}
export function buildKcpTaxFields(pgPaymentData: PgPaymentData): Record<string, string> {
const paymentAmount = Math.max(0, Math.round(Number(pgPaymentData.amount ?? 0)));
const taxFreeAmt = Math.min(
Math.max(0, Math.round(Number(pgPaymentData.tax_free_amount ?? 0))),
paymentAmount,
);
if (taxFreeAmt <= 0) {
return {};
}
const taxTotalAmt = Math.max(0, Math.round(Number(pgPaymentData.tax_amount ?? 0)));
const originalVatAmt = Math.max(0, Math.round(Number(pgPaymentData.vat_amount ?? 0)));
const taxablePaymentAmt = Math.max(0, paymentAmount - taxFreeAmt);
const vatAmt = taxablePaymentAmt > 0 && taxTotalAmt > 0 && originalVatAmt > 0
? Math.min(taxablePaymentAmt, Math.round(taxablePaymentAmt * (originalVatAmt / taxTotalAmt)))
: 0;
const supplyAmt = taxablePaymentAmt - vatAmt;
return {
tax_flag: 'TG03',
comm_tax_mny: String(supplyAmt),
comm_vat_mny: String(vatAmt),
comm_free_mny: String(taxFreeAmt),
};
}
interface ClientConfig {
client_id: string;
easy_pay_client_id?: string;
sdk_url: string;
callback_urls: {
callback: string;
close_report?: string;
};
vbank_expire_days?: number;
use_escrow?: boolean;
escrow_client_id?: string;
}
// 사용자가 결제창을 직접 닫은 경우 - 에러 모달 없이 조용히 처리
class KcpCancelledError extends Error {
constructor(msg?: string) {
super(msg ?? '결제가 취소되었습니다.');
this.name = 'KcpCancelledError';
}
}
// KCP pay_method 비트마스크 변환 (PC: payplus_web.jsp 규격)
const KCP_PAY_METHOD: Record<string, string> = {
card: '100000000000', // 신용카드
bank_transfer: '010000000000', // 계좌이체
virtual_account: '001000000000', // 가상계좌
mobile: '000010000000', // 휴대폰결제
bank: '010000000000',
vbank: '001000000000',
phone: '000010000000',
};
// KCP 간편결제 direct 파라미터 (PC/모바일 공통)
const KCP_EASY_PAY_DIRECT: Record<string, Record<string, string>> = {
nhnkcp_payco: { payco_direct: 'Y' },
nhnkcp_naverpay: { naverpay_direct: 'Y' },
nhnkcp_naverpay_point: { naverpay_direct: 'Y', naverpay_point_direct: 'Y' },
nhnkcp_kakaopay: { kakaopay_direct: 'Y' },
nhnkcp_applepay: { applepay_direct: 'Y' },
};
export function buildKcpEasyPayReturnFields(paymentMethod: string, isEasyPay: boolean): Record<string, string> {
const methodKey = paymentMethod.toLowerCase();
if (!isEasyPay || !Object.prototype.hasOwnProperty.call(KCP_EASY_PAY_DIRECT, methodKey)) {
return {};
}
return {
param_opt_1: methodKey,
nhnkcp_easy_pay_method: methodKey,
};
}
function isKcpPaymentFrameClosed(iframe: HTMLIFrameElement): boolean {
if (!iframe.isConnected) {
return true;
}
const style = window.getComputedStyle(iframe);
if (style.display === 'none' || style.visibility === 'hidden') {
return true;
}
const rect = iframe.getBoundingClientRect();
return rect.width <= 0 || rect.height <= 0;
}
export function watchKcpPaymentFrameClosure(
iframe: HTMLIFrameElement,
onClose: () => void,
): () => void {
let stopped = false;
let intervalId: number | undefined;
let observer: MutationObserver | undefined;
const stop = () => {
stopped = true;
if (observer) {
observer.disconnect();
observer = undefined;
}
if (intervalId !== undefined) {
window.clearInterval(intervalId);
intervalId = undefined;
}
};
const checkClosed = () => {
if (stopped || !isKcpPaymentFrameClosed(iframe)) {
return;
}
stop();
onClose();
};
const observeTarget = document.body ?? document.documentElement;
if (observeTarget && typeof MutationObserver !== 'undefined') {
observer = new MutationObserver(checkClosed);
observer.observe(observeTarget, {
attributes: true,
attributeFilter: ['class', 'hidden', 'style'],
childList: true,
subtree: true,
});
}
intervalId = window.setInterval(checkClosed, 300);
return stop;
}
/**
* 모바일 기기 여부 판별 (3단계 fallback)
*
* 1) User Agent Client Hints — 브라우저가 직접 판단 (Chrome/Edge 90+)
* 2) UA 문자열 파싱
* 3) iPadOS 등 데스크탑 UA를 보내는 터치 기기 판별 (maxTouchPoints > 1)
*/
function isMobileDevice(): boolean {
if (typeof navigator === 'undefined') return false;
if (isIosMobileDevice()) return true;
const nav = navigator as Navigator & { userAgentData?: { mobile: boolean } };
if (nav.userAgentData?.mobile !== undefined) {
return nav.userAgentData.mobile;
}
const ua = (navigator.userAgent || '').toLowerCase();
if (/android|iphone|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(ua)) {
return true;
}
// iPadOS, Android 태블릿 등 — 터치스크린 노트북은 maxTouchPoints=1이므로 >1 조건 필요
const touchPoints = (navigator as Navigator & { maxTouchPoints?: number }).maxTouchPoints ?? 0;
if (touchPoints > 1 && !ua.includes('windows') && !ua.includes('macintosh')) {
return true;
}
return false;
}
/**
* NHN KCP 결제창 호출 핸들러
*
* - 모바일: SOAP approval_key 획득 → form POST(페이지 전환) → 기존 authCallback 처리
* - PC: payplus_web.jsp SDK를 iframe 내 동기 로드 → KCP_Pay_Execute() → 콜백
*/
export async function requestPaymentHandler(action: any, _context?: any): Promise<void> {
const { pgPaymentData, paymentMethod: paramPaymentMethod } = (action.params || {}) as RequestPaymentParams;
if (!pgPaymentData) {
console.error('[sirsoft-pay_nhnkcp] pgPaymentData is required');
return;
}
const paymentMethod = paramPaymentMethod ?? pgPaymentData.pay_method ?? 'card';
const isEasyPay = typeof paymentMethod === 'string' && paymentMethod.startsWith('nhnkcp_');
const G7Core = (window as any).G7Core;
let closeReportContext: PaymentCloseReportContext | null = null;
try {
if (!isSupportedKcpCurrency(pgPaymentData.currency)) {
const message = unsupportedCurrencyMessage(pgPaymentData.currency);
G7Core?.state?.setLocal?.({ paymentErrorMessage: message, isSubmittingOrder: false, paymentMethod });
G7Core?.modal?.open?.('nhnkcp_payment_error_modal');
return;
}
if (isNhnKcpApplePayMethod(paymentMethod) && !isIosMobileDevice()) {
const message = applePayUnsupportedMessage();
G7Core?.state?.setLocal?.({
paymentErrorMessage: message,
isSubmittingOrder: false,
paymentMethod,
});
G7Core?.modal?.open?.('nhnkcp_payment_error_modal');
return;
}
// 1. Client Config API 호출
const configJson = await G7Core.api.get('/modules/sirsoft-ecommerce/payments/client-config/nhnkcp');
if (!configJson.data) {
console.error('[sirsoft-pay_nhnkcp] Failed to fetch client config', configJson);
return;
}
const config: ClientConfig = configJson.data;
closeReportContext = {
closeReportUrl: config.callback_urls.close_report,
oid: pgPaymentData.order_number,
price: Number(pgPaymentData.amount),
buyer_email: pgPaymentData.customer_email ?? '',
buyer_phone: pgPaymentData.customer_phone ?? '',
payment_method: paymentMethod,
};
const callbackUrl = window.location.origin + config.callback_urls.callback;
await preparePaymentRetry(closeReportContext);
if (isMobileDevice()) {
await handleMobilePayment(G7Core, pgPaymentData, paymentMethod, isEasyPay, callbackUrl);
} else {
await handlePcPayment(config, pgPaymentData, paymentMethod, isEasyPay, callbackUrl);
}
} catch (error: unknown) {
if (error instanceof KcpCancelledError) {
if (closeReportContext) {
await reportPaymentWindowClosed(closeReportContext, error.message || 'kcp-window-closed');
}
G7Core?.state?.setLocal?.({ isSubmittingOrder: false, paymentMethod });
return;
}
console.error('[sirsoft-pay_nhnkcp] requestPayment error', error);
// axios error 의 response.data 에서 사용자 친화적 메시지 추출.
// G7Core.api 는 axios 기반이라 4xx/5xx 응답은 자동 reject 됨 — generic
// "Request failed with status code 422" 가 아니라 Laravel ValidationException
// 의 message / errors 필드를 우선 사용.
const anyErr = error as { response?: { data?: { message?: string; error?: string; errors?: Record<string, string[]> } }; message?: string };
const responseData = anyErr?.response?.data;
const firstFieldError = responseData?.errors
? Object.values(responseData.errors)[0]?.[0]
: undefined;
const errorMessage =
responseData?.error
?? responseData?.message
?? firstFieldError
?? (error instanceof Error ? error.message : 'Unknown error');
G7Core?.state?.setLocal?.({ paymentErrorMessage: errorMessage, isSubmittingOrder: false, paymentMethod });
G7Core?.modal?.open?.('nhnkcp_payment_error_modal');
}
}
/**
* 모바일 결제 흐름
*
* 1) 서버에서 SOAP으로 approval_key + pay_url 획득
* 2) 전체 form fields를 받아 브라우저가 pay_url 로 POST 전환 (페이지 이동)
* 3) KCP가 결제 완료 후 Ret_URL(authCallback)로 redirect → 기존 서버 처리
*/
async function handleMobilePayment(
G7Core: any,
pgPaymentData: PgPaymentData,
paymentMethod: string,
isEasyPay: boolean,
callbackUrl: string,
): Promise<void> {
const approvalJson = await G7Core.api.post('/plugins/sirsoft-pay_nhnkcp/mobile/approval-key', {
order_number: pgPaymentData.order_number,
amount: pgPaymentData.amount,
good_name: pgPaymentData.order_name,
pay_method: paymentMethod,
buyr_name: pgPaymentData.customer_name ?? '',
buyr_mail: pgPaymentData.customer_email ?? '',
buyr_tel1: pgPaymentData.customer_phone ?? '',
ret_url: callbackUrl,
currency: normalizeCurrency(pgPaymentData.currency),
});
if (!approvalJson.success || !approvalJson.data) {
// 서버 응답의 에러 메시지 우선순위:
// 1. ResponseHelper::error 의 'error' 필드 (커스텀)
// 2. Laravel ValidationException 의 'message' 필드 (422 응답 표준)
// 3. 'errors' 객체의 첫 번째 메시지 (필드별 validation 메시지)
// 4. fallback
const errors = approvalJson.errors as Record<string, string[]> | undefined;
const firstFieldError = errors ? Object.values(errors)[0]?.[0] : undefined;
throw new Error(
approvalJson.error
?? approvalJson.message
?? firstFieldError
?? 'KCP 모바일 승인키 획득 실패',
);
}
const { pay_url, fields } = approvalJson.data as { pay_url: string; fields: Record<string, string> };
// 페이지 전환 form 생성 및 제출
const form = document.createElement('form');
form.method = 'POST';
form.action = pay_url;
form.acceptCharset = 'euc-kr'; // KCP 모바일은 EUC-KR 인코딩 필요
form.style.display = 'none';
for (const [name, value] of Object.entries(fields)) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = name;
input.value = String(value);
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
// 페이지가 pay_url 로 이동하므로 Promise는 자연스럽게 중단됨
await new Promise<never>(() => {});
}
/**
* PC 결제 흐름
*
* Chrome 비동기 document.write() 차단을 우회하기 위해 iframe 내에서
* document.open/write/close 로 SDK를 동기 로드한 뒤 KCP_Pay_Execute() 호출.
*/
async function handlePcPayment(
config: ClientConfig,
pgPaymentData: PgPaymentData,
paymentMethod: string,
isEasyPay: boolean,
callbackUrl: string,
): Promise<void> {
// 간편결제는 pay_method 항상 "100000000000" (카드 비트마스크), direct 파라미터로 수단 지정
const payMethod = isEasyPay
? '100000000000'
: (KCP_PAY_METHOD[pgPaymentData.pay_method ?? 'card'] ?? '100000000000');
// PAYCO 전용 테스트 site_cd (S6729), 그 외 간편결제·에스크로·일반은 각자 site_cd 사용
const siteCd = paymentMethod === 'nhnkcp_payco'
? (config.easy_pay_client_id ?? config.client_id)
: (config.use_escrow && !isEasyPay ? (config.escrow_client_id ?? config.client_id) : config.client_id);
const fields: Record<string, string> = {
site_cd: siteCd,
ordr_idxx: pgPaymentData.order_number,
good_name: pgPaymentData.order_name,
good_mny: String(pgPaymentData.amount),
buyr_name: pgPaymentData.customer_name ?? '',
buyr_mail: pgPaymentData.customer_email ?? '',
buyr_tel1: pgPaymentData.customer_phone ?? '',
pay_method: payMethod,
Ret_URL: callbackUrl,
};
Object.assign(fields, buildKcpTaxFields(pgPaymentData));
Object.assign(fields, buildKcpEasyPayReturnFields(paymentMethod, isEasyPay));
// 가상계좌 전용 파라미터
if (payMethod === '001000000000') {
fields['vcnt_expire_term'] = String(config.vbank_expire_days ?? 3);
fields['disp_tax_yn'] = 'N';
}
// 에스크로 결제 파라미터 (간편결제 제외)
if (config.use_escrow && !isEasyPay) {
fields['escw_used'] = 'Y';
fields['pay_mod'] = 'O';
}
// 간편결제: GNU5 규격과 동일하게 모든 direct 파라미터를 기본값("" 또는 "A")으로 초기화한 뒤
// 선택된 수단만 "Y"로 덮어씀 — 초기값이 없으면 KCP가 이전 요청값을 재사용할 수 있음
if (isEasyPay) {
// GNU5 규격: 모든 direct 파라미터를 기본값으로 초기화 후 선택된 수단만 Y로 덮어씀
fields['payco_direct'] = '';
fields['naverpay_direct'] = 'A';
fields['kakaopay_direct'] = 'A';
fields['applepay_direct'] = 'A';
const directOverride = KCP_EASY_PAY_DIRECT[paymentMethod];
if (directOverride) {
Object.assign(fields, directOverride);
}
}
const hiddenInputs = Object.entries(fields)
.map(([n, v]) => `<input type="hidden" name="${n}" value="${v.replace(/"/g, '&quot;')}">`)
.join('');
await new Promise<void>((resolve, reject) => {
// 기존 요소 정리
document.getElementById('kcp-sdk-iframe')?.remove();
document.getElementById('kcp-dim-overlay')?.remove();
// 메인 창에 반투명 dim 오버레이 (결제창 뒤 배경)
const overlay = document.createElement('div');
overlay.id = 'kcp-dim-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);z-index:99998;';
document.body.appendChild(overlay);
// 투명 iframe - KCP 결제창이 내부에 렌더링됨
const iframe = document.createElement('iframe');
iframe.id = 'kcp-sdk-iframe';
iframe.setAttribute('allowtransparency', 'true');
iframe.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;border:0;z-index:99999;background:transparent;';
document.body.appendChild(iframe);
const iframeWin = iframe.contentWindow as any;
let settled = false;
let sdkLoadTimer: number | undefined;
let stopCloseWatcher: (() => void) | undefined;
const cleanup = () => {
stopCloseWatcher?.();
stopCloseWatcher = undefined;
if (sdkLoadTimer !== undefined) {
clearTimeout(sdkLoadTimer);
sdkLoadTimer = undefined;
}
iframe.remove();
overlay.remove();
};
const rejectOnce = (error: Error) => {
if (settled) {
return;
}
settled = true;
cleanup();
reject(error);
};
const markCompletingPayment = () => {
settled = true;
stopCloseWatcher?.();
stopCloseWatcher = undefined;
if (sdkLoadTimer !== undefined) {
clearTimeout(sdkLoadTimer);
sdkLoadTimer = undefined;
}
};
stopCloseWatcher = watchKcpPaymentFrameClosure(iframe, () => {
rejectOnce(new KcpCancelledError('kcp-payment-frame-closed'));
});
// 결제 완료 콜백 - KCP가 결제 후 호출
iframeWin.m_Completepayment = function (form: HTMLFormElement) {
const resCode = (form.elements as any)['res_cd']?.value ?? '';
const resMsg = (form.elements as any)['res_msg']?.value ?? '';
if (resCode === '0000') {
markCompletingPayment();
// GNU5 패턴(GetField): KCP 결과를 order_info에 병합 후 order_info를 POST.
// KakaoPay 등 direct 결제에서 KCP가 ordr_idxx 없는 새 폼을 전달하므로
// form을 그대로 제출하면 서버 검증 실패(invalid_params).
// order_info(우리 주문 필드 포함)를 기준으로 병합해야 한다.
const orderForm = iframeWin.document.forms.namedItem('order_info') as HTMLFormElement | null;
const targetForm = (orderForm && orderForm !== form) ? orderForm : form;
if (orderForm && orderForm !== form) {
for (let i = 0; i < form.elements.length; i++) {
const el = form.elements[i] as HTMLInputElement;
if (!el.name) continue;
let dest = orderForm.elements.namedItem(el.name) as HTMLInputElement | null;
if (!dest) {
dest = iframeWin.document.createElement('input') as HTMLInputElement;
dest.type = 'hidden';
dest.name = el.name;
orderForm.appendChild(dest);
}
dest.value = el.value;
}
}
targetForm.action = callbackUrl;
targetForm.method = 'POST';
targetForm.target = '_top';
iframeWin.document.body.appendChild(targetForm);
targetForm.submit();
} else {
// 취소 또는 오류
const isCancelled = resCode === '' || resCode === '7777' || resMsg.includes('취소');
rejectOnce(isCancelled ? new KcpCancelledError(resMsg) : new Error(`KCP 오류 [${resCode}]: ${resMsg}`));
}
};
iframeWin.__kcpFail = (err: Error) => {
rejectOnce(err);
};
// SDK 동기 로드 + KCP_Pay_Execute 호출
const iframeDoc = (iframe.contentDocument || iframeWin.document) as Document;
iframeDoc.open();
iframeDoc.write(`<!DOCTYPE html><html><head>
<script src="${config.sdk_url}"><\/script>
</head><body style="margin:0;padding:0;background:transparent;">
<form name="order_info">${hiddenInputs}</form>
<script>
try {
if (typeof KCP_Pay_Execute === 'function') {
KCP_Pay_Execute(document.forms['order_info']);
window.__kcpReady && window.__kcpReady();
} else {
window.__kcpFail && window.__kcpFail(new Error('KCP_Pay_Execute not defined'));
}
} catch(e) {
window.__kcpFail && window.__kcpFail(e);
}
<\/script>
</body></html>`);
iframeDoc.close();
// SDK 로드 실패 대비 타임아웃 — 결제창이 열리면(__kcpReady) 즉시 해제됨
sdkLoadTimer = window.setTimeout(() => {
rejectOnce(new Error('KCP SDK load timeout'));
}, 15000);
iframeWin.__kcpReady = () => {
if (sdkLoadTimer !== undefined) {
clearTimeout(sdkLoadTimer);
sdkLoadTimer = undefined;
}
};
});
}
@@ -0,0 +1,76 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
applePayUnsupportedMessage,
isIosMobileDevice,
isNhnKcpApplePayMethod,
} from '../support/applePayDevice';
const METHOD_TO_TEXT: Record<string, string> = {
nhnkcp_payco: 'PAYCO',
nhnkcp_naverpay: '네이버페이',
nhnkcp_naverpay_point: '네이버페이 포인트',
nhnkcp_kakaopay: '카카오페이',
nhnkcp_applepay: 'Apple Pay',
};
const SELECTED_SHADOW = '0 0 0 2px #ffffff, 0 0 0 5px rgba(0,0,0,0.55)';
function getEasyPayButtonContainer(): Element | null {
const section = document.querySelector('.nhnkcp-easy-pay-btns');
return section ?? null;
}
function clearEasyPayButtonStyles(): void {
const container = getEasyPayButtonContainer();
if (!container) return;
container.querySelectorAll<HTMLButtonElement>('button').forEach(btn => {
btn.style.boxShadow = '';
btn.style.outline = '';
});
}
function updateEasyPayButtonStyles(selectedMethod: string): void {
const container = getEasyPayButtonContainer();
if (!container) return;
const selectedText = METHOD_TO_TEXT[selectedMethod];
container.querySelectorAll<HTMLButtonElement>('button').forEach(btn => {
if (btn.textContent?.trim() === selectedText) {
btn.style.boxShadow = SELECTED_SHADOW;
btn.style.outline = 'none';
} else {
btn.style.boxShadow = '';
btn.style.outline = '';
}
});
}
export function setPaymentMethodHandler(action: any): void {
const paymentMethod = action.params?.paymentMethod;
if (!paymentMethod) return;
const G7Core = (window as any).G7Core;
if (isNhnKcpApplePayMethod(paymentMethod) && !isIosMobileDevice()) {
const message = applePayUnsupportedMessage();
G7Core?.state?.setLocal?.({
paymentErrorMessage: message,
isSubmittingOrder: false,
});
G7Core?.modal?.open?.('nhnkcp_payment_error_modal');
clearEasyPayButtonStyles();
return;
}
const isEasyPay = typeof paymentMethod === 'string' && paymentMethod.startsWith('nhnkcp_');
G7Core?.state?.setLocal?.({
paymentMethod,
serverPaymentMethod: isEasyPay ? 'card' : paymentMethod,
});
if (isEasyPay) {
updateEasyPayButtonStyles(paymentMethod);
} else {
clearEasyPayButtonStyles();
}
}
@@ -0,0 +1,111 @@
import { handlerMap } from './handlers';
import { installOrderResponseInterceptor } from './orderResponseInterceptor';
import { installOrderCompleteReceiptInjector } from './orderCompleteReceiptInjector';
import { installMypageOrderShowInjector } from './mypageOrderShowInjector';
import { installCheckoutEasyPayInjector } from './checkoutEasyPayInjector';
import { installAdminApplePayNoticeInjector } from './adminApplePayNoticeInjector';
import { installAdminPaymentMethodBrandInjector } from './adminPaymentMethodBrandInjector';
import { installAdminOrderPaymentDisplayInjector } from './adminOrderPaymentDisplayInjector';
class KcpReceiptPopup {
constructor(params: { url?: string; cash_url?: string }) {
const features = 'width=800,height=600,scrollbars=yes,resizable=yes,toolbar=no,menubar=no';
if (params.url) {
window.open(params.url, 'kcp_receipt', features);
}
if (params.cash_url) {
window.open(params.cash_url, 'kcp_cash_receipt', features);
}
}
}
(window as Record<string, unknown>).KcpReceiptPopup = KcpReceiptPopup;
const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
const logger = {
info: (...args: unknown[]) => console.info(`[${PLUGIN_IDENTIFIER}]`, ...args),
warn: (...args: unknown[]) => console.warn(`[${PLUGIN_IDENTIFIER}]`, ...args),
error: (...args: unknown[]) => console.error(`[${PLUGIN_IDENTIFIER}]`, ...args),
};
function registerHandlers(): number {
const g7Core = (window as Record<string, unknown>).G7Core as Record<string, unknown> | undefined;
if (!g7Core) {
return 0;
}
const getDispatcher = g7Core.getActionDispatcher as (() => Record<string, unknown>) | undefined;
if (typeof getDispatcher !== 'function') {
return 0;
}
const dispatcher = getDispatcher() as Record<string, unknown> | undefined;
if (!dispatcher || typeof dispatcher.registerHandler !== 'function') {
return 0;
}
let count = 0;
for (const [name, handler] of Object.entries(handlerMap)) {
const fullName = `${PLUGIN_IDENTIFIER}.${name}`;
dispatcher.registerHandler(fullName, handler, {
category: 'plugin',
source: PLUGIN_IDENTIFIER,
});
count++;
}
return count;
}
function initPlugin(): void {
const doInit = () => {
const count = registerHandlers();
if (count > 0) {
logger.info(`${count} handler(s) registered`);
return;
}
let retries = 0;
const maxRetries = 50;
const interval = setInterval(() => {
retries++;
const result = registerHandlers();
if (result > 0) {
clearInterval(interval);
logger.info(`${result} handler(s) registered (after ${retries} retries)`);
return;
}
if (retries >= maxRetries) {
clearInterval(interval);
logger.warn('ActionDispatcher not available after timeout');
}
}, 100);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', doInit);
} else {
doInit();
}
}
installOrderResponseInterceptor();
installOrderCompleteReceiptInjector();
installMypageOrderShowInjector();
installCheckoutEasyPayInjector();
installAdminApplePayNoticeInjector();
installAdminPaymentMethodBrandInjector();
installAdminOrderPaymentDisplayInjector();
initPlugin();
(window as Record<string, unknown>).__SirsoftNhnkcp = {
identifier: PLUGIN_IDENTIFIER,
handlers: Object.keys(handlerMap),
initPlugin,
};
@@ -0,0 +1,409 @@
const PLUGIN_ID = 'sirsoft-pay_nhnkcp';
const FLAG = '__kcpMpShowInjectorInstalled';
const VBANK_ID = 'kcp-mp-vbank-row';
const ROW_ID = 'kcp-mp-receipt-row';
const MOCK_DEPOSIT_ID = 'kcp-mp-mock-deposit';
const ORDER_SHOW_RE = /^\/mypage\/orders\/([^/]+)$/;
interface Payment {
pg_provider?: string;
payment_method?: string;
transaction_id?: string | null;
paid_at?: string | null;
vbank_name?: string;
vbank_number?: string;
vbank_holder?: string;
due_date_formatted?: string;
[key: string]: unknown;
}
interface OrderData {
order_number?: string;
total_amount_formatted?: string;
payment?: Payment;
}
function getOrderFromState(orderNumber: string): OrderData | null {
try {
const g7 = (window as unknown as Record<string, unknown>).G7Core as Record<string, unknown> | undefined;
const getState = g7?.getState as (() => Record<string, unknown>) | undefined;
const ctx = getState?.()?.currentDataContext as Record<string, unknown> | undefined;
const order = ctx?.order as { data?: OrderData } | undefined;
const data = order?.data;
if (!data || data.order_number !== orderNumber) return null;
return data;
} catch {
return null;
}
}
function getToken(): string | null {
return localStorage.getItem('auth_token');
}
interface ReceiptInfo {
receipt_url?: string;
cash_receipt_url?: string | null;
payment_method_display_label?: string | null;
}
async function fetchReceiptUrls(orderNumber: string): Promise<ReceiptInfo | null> {
const token = getToken();
if (!token) return null;
try {
const res = await fetch(`/api/plugins/${PLUGIN_ID}/user/orders/${orderNumber}/receipt`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
if (!res.ok) return null;
return (await res.json()) as ReceiptInfo;
} catch {
return null;
}
}
interface MockDepositInfo {
available: boolean;
trade_no?: string;
account_no?: string;
notify_url?: string;
mock_url?: string;
is_admin_view?: boolean;
}
async function fetchMockDepositInfo(orderNumber: string): Promise<MockDepositInfo | null> {
const token = getToken();
if (!token) return null;
try {
const res = await fetch(`/api/plugins/${PLUGIN_ID}/user/orders/${orderNumber}/vbank-mock-deposit-info`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
if (!res.ok) return null;
return (await res.json()) as MockDepositInfo;
} catch {
return null;
}
}
function buildMockDepositBlock(info: MockDepositInfo): HTMLElement {
const wrap = document.createElement('div');
wrap.id = MOCK_DEPOSIT_ID;
wrap.className = 'pt-4 mt-2 border-t border-dashed border-orange-300 dark:border-orange-700';
const header = document.createElement('div');
header.className = 'flex items-center gap-2 mb-3';
const badge = document.createElement('span');
badge.className = 'inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300 border border-orange-300 dark:border-orange-700';
badge.textContent = 'TEST';
const title = document.createElement('p');
title.className = 'text-sm font-semibold text-orange-700 dark:text-orange-300';
title.textContent = '모의입금처리';
header.appendChild(badge);
header.appendChild(title);
const form = document.createElement('form');
form.method = 'POST';
form.action = info.mock_url ?? '';
form.target = '_blank';
form.className = 'space-y-2';
const fields: Array<{ label: string; name: string; value: string; editable?: boolean }> = [
{ label: 'KCP 거래번호', name: 'e_trade_no', value: info.trade_no ?? '' },
{ label: '입금계좌', name: 'deposit_no', value: info.account_no ?? '' },
{ label: '입금자명', name: 'req_name', value: 'NHN KCP', editable: true },
{ label: '입금통보 URL', name: 'noti_url', value: info.notify_url ?? '' },
];
for (const f of fields) {
const row = document.createElement('div');
row.className = 'flex items-center gap-2 text-sm';
const lbl = document.createElement('span');
lbl.className = 'w-28 flex-shrink-0 text-gray-500 dark:text-gray-400 text-xs';
lbl.textContent = f.label;
const input = document.createElement('input');
input.type = 'text';
input.name = f.name;
input.value = f.value;
input.readOnly = !f.editable;
input.className = f.editable
? 'flex-1 px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono'
: 'flex-1 px-2 py-1 text-xs border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 font-mono';
row.appendChild(lbl);
row.appendChild(input);
form.appendChild(row);
}
const submitRow = document.createElement('div');
submitRow.className = 'pt-1';
const btn = document.createElement('button');
btn.type = 'submit';
btn.className = 'w-full py-1.5 text-xs font-semibold rounded border border-orange-400 dark:border-orange-600 bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 hover:bg-orange-100 dark:hover:bg-orange-900/50 transition-colors';
btn.textContent = '입금통보 테스트';
submitRow.appendChild(btn);
form.appendChild(submitRow);
wrap.appendChild(header);
wrap.appendChild(form);
if (info.is_admin_view) {
const adminNotice = document.createElement('p');
adminNotice.className = 'mt-2 text-xs text-center text-orange-500 dark:text-orange-400 font-medium';
adminNotice.textContent = '관리자에게만 출력되는 내용';
wrap.appendChild(adminNotice);
}
return wrap;
}
function findPaymentRowsContainer(): Element | null {
const panel = document.getElementById('order_payment_info_panel');
if (panel) {
const spaceY = Array.from(panel.children).find(el => el.className?.includes('space-y'));
return spaceY ?? panel;
}
// "결제 정보" 헤딩 탐색
const h3 = Array.from(document.querySelectorAll<HTMLElement>('h3')).find(
el => el.textContent?.includes('결제 정보'),
);
if (!h3) return null;
const panelDiv = h3.parentElement?.parentElement;
if (!panelDiv) return null;
const spaceY = Array.from(panelDiv.children).find(el => el.className?.includes('space-y'));
return spaceY ?? panelDiv;
}
// 라벨 + 값 행 생성 헬퍼
function makeInfoRow(label: string, value: string, valueClass = 'font-medium text-gray-900 dark:text-white'): HTMLElement {
const row = document.createElement('div');
row.className = 'flex items-center justify-between text-sm';
const lbl = document.createElement('span');
lbl.className = 'text-gray-500 dark:text-gray-400';
lbl.textContent = label;
const val = document.createElement('span');
val.className = valueClass;
val.textContent = value;
row.appendChild(lbl);
row.appendChild(val);
return row;
}
export function patchMypagePaymentMethodDisplay(container: Element, displayLabel: string | null | undefined): boolean {
if (!displayLabel) return false;
const rows = Array.from(container.querySelectorAll<HTMLElement>('div'));
for (const row of rows) {
const spans = Array.from(row.children).filter(
(child): child is HTMLElement => child instanceof HTMLElement && child.tagName === 'SPAN',
);
if (spans.length < 2) continue;
const label = spans[0].textContent?.trim();
if (label !== '결제 방법' && label !== '결제수단' && label !== 'Payment Method') continue;
const value = spans[spans.length - 1];
if (value.textContent?.trim() === displayLabel) {
row.dataset.nhnkcpPaymentMethodRow = 'true';
return true;
}
value.textContent = displayLabel;
value.dataset.nhnkcpPaymentMethodPatched = 'true';
row.dataset.nhnkcpPaymentMethodRow = 'true';
return true;
}
return false;
}
function buildVbankInfoBlock(payment: Payment, totalAmountFormatted: string): HTMLElement {
const wrap = document.createElement('div');
wrap.id = VBANK_ID;
wrap.className = 'pt-4 mt-2 border-t border-gray-200 dark:border-gray-700 space-y-3';
const title = document.createElement('p');
title.className = 'text-sm font-semibold text-gray-800 dark:text-gray-200';
title.textContent = '가상계좌 입금 안내';
const rows = document.createElement('div');
rows.className = 'space-y-1.5';
rows.appendChild(makeInfoRow('은행', payment.vbank_name ?? ''));
rows.appendChild(makeInfoRow('계좌번호', payment.vbank_number ?? '', 'font-medium text-gray-900 dark:text-white font-mono tracking-wide'));
rows.appendChild(makeInfoRow('예금주', payment.vbank_holder ?? ''));
rows.appendChild(makeInfoRow('입금 금액', totalAmountFormatted));
if (payment.due_date_formatted) {
rows.appendChild(makeInfoRow('입금 기한', payment.due_date_formatted));
}
const notice = document.createElement('p');
notice.className = 'text-xs text-amber-600 dark:text-amber-400';
notice.textContent = '입금 기한 내에 입금하지 않으시면 주문이 자동 취소됩니다.';
wrap.appendChild(title);
wrap.appendChild(rows);
wrap.appendChild(notice);
return wrap;
}
function buildReceiptRow(orderNumber: string): HTMLElement {
const row = document.createElement('div');
row.id = ROW_ID;
row.className = 'pt-4 mt-2 border-t border-gray-200 dark:border-gray-700';
const inner = document.createElement('div');
inner.className = 'flex items-center justify-between';
const label = document.createElement('span');
label.className = 'text-gray-500 dark:text-gray-400 text-sm';
label.textContent = '영수증';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50';
btn.textContent = '영수증 조회';
btn.addEventListener('click', async () => {
btn.disabled = true;
btn.textContent = '로딩 중...';
const data = await fetchReceiptUrls(orderNumber);
btn.disabled = false;
btn.textContent = '영수증 조회';
if (data) {
const KcpPopup = (window as unknown as Record<string, unknown>).KcpReceiptPopup as
(new (p: { url?: string; cash_url?: string }) => unknown) | undefined;
if (KcpPopup) {
new KcpPopup({ url: data.receipt_url, cash_url: data.cash_receipt_url });
}
}
});
inner.appendChild(label);
inner.appendChild(btn);
row.appendChild(inner);
return row;
}
async function tryInject(orderNumber: string): Promise<boolean> {
const orderData = getOrderFromState(orderNumber);
if (!orderData) {
// ctx.order가 null — 관리자가 타인 주문 열람 시 mypage API 404로 상태 없음.
// 컨테이너가 렌더링됐으면 모의입금 API만 직접 시도 후 종료.
if (!document.getElementById(MOCK_DEPOSIT_ID)) {
const container = findPaymentRowsContainer();
if (!container) return false; // 컨테이너 미렌더링 → 재시도
const info = await fetchMockDepositInfo(orderNumber);
if (info?.available) {
container.appendChild(buildMockDepositBlock(info));
console.info(`[${PLUGIN_ID}] mock deposit form injected on mypage order show`);
}
}
return true;
}
const { payment } = orderData;
if (!payment || payment.pg_provider !== 'nhnkcp') return true; // nhnkcp 아님
const needsVbank = payment.payment_method === 'vbank' && !!payment.vbank_name;
// 영수증 버튼은 결제완료(paid_at 채워짐) 시점에만 표시 — 가상계좌 입금대기 상태 차단
const needsReceipt = !!payment.transaction_id && !!payment.paid_at;
// 가상계좌 계좌번호가 발급된 경우 API로 모의입금 가능 여부 확인 (tno는 서버에서 fallback)
const mightNeedMock = payment.payment_method === 'vbank' && !!payment.vbank_number;
const vbankDone = !needsVbank || !!document.getElementById(VBANK_ID);
const receiptDone = !needsReceipt || !!document.getElementById(ROW_ID);
// 모의입금은 API 결과에 따라 달라지므로 DOM에 없으면 매번 체크
const mockDone = !mightNeedMock || !!document.getElementById(MOCK_DEPOSIT_ID);
if (vbankDone && receiptDone && mockDone) return true;
const container = findPaymentRowsContainer();
if (!container) return false; // DOM 미렌더링 → 재시도
const receiptInfo = needsReceipt ? await fetchReceiptUrls(orderNumber) : null;
const displayPatched = patchMypagePaymentMethodDisplay(
container,
receiptInfo?.payment_method_display_label,
);
if (!document.getElementById(VBANK_ID) && needsVbank) {
container.appendChild(buildVbankInfoBlock(payment, orderData.total_amount_formatted ?? ''));
console.info(`[${PLUGIN_ID}] vbank info injected on mypage order show`);
return false;
}
if (!document.getElementById(ROW_ID) && needsReceipt) {
container.appendChild(buildReceiptRow(orderNumber));
console.info(`[${PLUGIN_ID}] receipt button injected on mypage order show`);
return false;
}
if (displayPatched) {
console.info(`[${PLUGIN_ID}] payment method display patched on mypage order show`);
}
if (!document.getElementById(MOCK_DEPOSIT_ID) && mightNeedMock) {
const info = await fetchMockDepositInfo(orderNumber);
if (info?.available) {
container.appendChild(buildMockDepositBlock(info));
console.info(`[${PLUGIN_ID}] mock deposit form injected on mypage order show`);
} else {
// 테스트 모드 아님 or 조건 불충족 — 더 이상 시도 불필요
return true;
}
}
return true;
}
function startPolling(orderNumber: string): void {
let attempts = 0;
const id = setInterval(() => {
attempts++;
void tryInject(orderNumber).then(done => {
if (done || attempts >= 30) clearInterval(id);
});
}, 400);
}
function onRouteChange(): void {
const match = location.pathname.match(ORDER_SHOW_RE);
if (match) startPolling(match[1]);
}
export function installMypageOrderShowInjector(): void {
if (typeof window === 'undefined') return;
const w = window as unknown as Record<string, unknown>;
if (w[FLAG]) return;
w[FLAG] = true;
console.info(`[${PLUGIN_ID}] mypage order show injector installed`);
const schedule = (delay = 1500) => setTimeout(onRouteChange, delay);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => schedule());
} else {
schedule();
}
const origPush = history.pushState.bind(history);
history.pushState = (...args: Parameters<typeof history.pushState>) => {
origPush(...args);
schedule(600);
};
window.addEventListener('popstate', () => schedule(500));
}
@@ -0,0 +1,185 @@
const PLUGIN_ID = 'sirsoft-pay_nhnkcp';
const FLAG = '__kcpOcReceiptInjectorInstalled';
const BTN_ID = 'kcp-oc-receipt-btn';
const ORDER_COMPLETE_RE = /^\/shop\/orders\/([^/]+)\/complete$/;
type Payment = {
pg_provider: string;
transaction_id: string | null;
paid_at: string | null;
[key: string]: unknown;
};
function getToken(): string | null {
return localStorage.getItem('auth_token');
}
async function fetchPayment(orderNumber: string): Promise<Payment | null> {
const token = getToken();
if (!token) return null;
try {
const res = await fetch(`/api/modules/sirsoft-ecommerce/user/orders/${orderNumber}`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
if (!res.ok) return null;
const data = (await res.json()) as { data?: { payment?: Payment } };
return data?.data?.payment ?? null;
} catch {
return null;
}
}
interface ReceiptInfo {
receipt_url: string;
cash_receipt_url: string | null;
payment_method_display_label?: string | null;
}
async function fetchReceiptUrl(orderNumber: string): Promise<ReceiptInfo | null> {
const token = getToken();
if (!token) return null;
try {
const res = await fetch(`/api/plugins/${PLUGIN_ID}/user/orders/${orderNumber}/receipt`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
if (!res.ok) return null;
return (await res.json()) as ReceiptInfo;
} catch {
return null;
}
}
function makeBtn(text: string, classes: string, onClick: (btn: HTMLButtonElement) => void): HTMLButtonElement {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = classes;
btn.textContent = text;
btn.addEventListener('click', () => onClick(btn));
return btn;
}
function patchPaymentMethodDisplay(displayLabel: string | null | undefined): boolean {
if (!displayLabel) return false;
const rows = Array.from(document.querySelectorAll<HTMLElement>('div'));
for (const row of rows) {
const spans = Array.from(row.children).filter(
(child): child is HTMLElement => child instanceof HTMLElement && child.tagName === 'SPAN',
);
if (spans.length < 2) continue;
const label = spans[0].textContent?.trim();
if (label !== '결제 방법' && label !== '결제수단' && label !== '결제 방식') continue;
const value = spans[spans.length - 1];
if (value.textContent?.trim() === displayLabel) {
row.dataset.nhnkcpPaymentMethodRow = 'true';
return true;
}
value.textContent = displayLabel;
value.dataset.nhnkcpPaymentMethodPatched = 'true';
row.dataset.nhnkcpPaymentMethodRow = 'true';
return true;
}
return false;
}
// 주문완료 페이지 버튼 영역에 영수증 버튼 주입
async function injectOnOrderComplete(orderNumber: string): Promise<void> {
if (document.getElementById(BTN_ID)) return;
const payment = await fetchPayment(orderNumber);
// 입금완료(paid_at 채워짐) 시점에만 영수증 버튼 표시.
// 가상계좌 발급 시 transaction_id 는 채워지지만 paid_at 은 null 이므로 입금대기 상태에선 미노출.
if (!payment || payment.pg_provider !== 'nhnkcp' || !payment.transaction_id || !payment.paid_at) return;
// "주문 상세 보기" 버튼 찾기 (bg-blue-600)
const blueBtn = Array.from(document.querySelectorAll<HTMLButtonElement>('button[type="button"]'))
.find(b => b.className.includes('bg-blue-600'));
if (!blueBtn?.parentElement) return;
const receiptInfo = await fetchReceiptUrl(orderNumber);
patchPaymentMethodDisplay(receiptInfo?.payment_method_display_label);
const container = blueBtn.parentElement;
const receiptBtn = makeBtn('영수증 조회', blueBtn.className.replace(/bg-blue-\d+/g, 'bg-green-600').replace(/hover:bg-blue-\d+/g, 'hover:bg-green-700'), async (btn) => {
btn.disabled = true;
btn.textContent = '로딩 중...';
const data = await fetchReceiptUrl(orderNumber);
btn.disabled = false;
btn.textContent = '영수증 조회';
if (data) {
const KcpPopup = (window as unknown as Record<string, unknown>).KcpReceiptPopup as
(new (p: { url?: string; cash_url?: string }) => unknown) | undefined;
if (KcpPopup) new KcpPopup({ url: data.receipt_url ?? undefined });
}
});
receiptBtn.id = BTN_ID;
// "쇼핑 계속하기" 버튼 앞에 삽입
const lastBtn = container.lastElementChild;
container.insertBefore(receiptBtn, lastBtn);
// 현금영수증 버튼 (가상계좌/계좌이체)
const cashBtnId = 'kcp-oc-cash-receipt-btn';
if (!document.getElementById(cashBtnId) && (payment.payment_method === 'vbank' || payment.payment_method === 'bank_transfer')) {
const cashBtn = makeBtn('현금영수증 조회', receiptBtn.className.replace(/bg-green-\d+/g, 'bg-teal-600').replace(/hover:bg-green-\d+/g, 'hover:bg-teal-700'), async (btn) => {
btn.disabled = true;
btn.textContent = '로딩 중...';
const data = await fetchReceiptUrl(orderNumber);
btn.disabled = false;
btn.textContent = '현금영수증 조회';
if (data) {
const KcpPopup = (window as unknown as Record<string, unknown>).KcpReceiptPopup as
(new (p: { url?: string; cash_url?: string }) => unknown) | undefined;
if (KcpPopup) new KcpPopup({ cash_url: data.cash_receipt_url ?? undefined });
}
});
cashBtn.id = cashBtnId;
container.insertBefore(cashBtn, lastBtn);
}
console.info(`[${PLUGIN_ID}] receipt button injected on order complete page`);
}
function tryInject(): void {
const path = location.pathname;
const ocMatch = path.match(ORDER_COMPLETE_RE);
if (ocMatch) {
void injectOnOrderComplete(ocMatch[1]);
}
// 마이페이지 주문상세는 mypageOrderShowInjector가 처리
}
export function installOrderCompleteReceiptInjector(): void {
if (typeof window === 'undefined') return;
const w = window as Record<string, unknown>;
if (w[FLAG]) return;
w[FLAG] = true;
console.info(`[${PLUGIN_ID}] order complete receipt injector installed`);
const schedule = (delay = 1200) => setTimeout(tryInject, delay);
// 현재 페이지 즉시 처리
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => schedule());
} else {
schedule();
}
// SPA 네비게이션 감지
const origPush = history.pushState.bind(history);
history.pushState = (...args: Parameters<typeof history.pushState>) => {
origPush(...args);
schedule();
};
window.addEventListener('popstate', () => schedule(500));
}
@@ -0,0 +1,295 @@
/**
* 주문 생성 API 응답 인터셉터
*
* 체크아웃 템플릿(_checkout_summary.json)에는 'sirsoft-tosspayments' 분기만
* 정의되어 있어서, 'sirsoft-nhnkcp' PG는 navigate 기본 분기로 떨어져
* /shop/orders/{order_number}/complete 로 이동해버림 (결제창 미노출).
*
* 코어/템플릿 수정 없이 이 문제를 우회하기 위해 plugin loading 시점에
* window.fetch 를 래핑해 다음을 수행:
*
* 1. POST /api/modules/sirsoft-ecommerce/user/orders 요청 body에서
* nhnkcp_* 간편결제 수단을 'card'로 교체 후 서버 전송
* 2. 응답에서 data.pg_provider === 'sirsoft-nhnkcp' 이면
* requestPayment 핸들러를 직접 호출하여 결제창 띄움
* 3. data.redirect_url 을 현재 URL로 교체하고 requires_pg_payment를 false로 변경
* → 템플릿 fallback 분기의 navigate 가 navigate-to-self 가 되어 실질적 이동 없음
*
* 결과: 체크아웃 페이지에 머문 채 PG 팝업이 뜨고, PG 콜백이 정식 complete 페이지로 redirect.
*/
import { requestPaymentHandler } from './handlers/requestPayment';
import {
applePayUnsupportedMessage,
isIosMobileDevice,
isNhnKcpApplePayMethod,
} from './support/applePayDevice';
const ORDER_CREATE_PATH = '/api/modules/sirsoft-ecommerce/user/orders';
const TARGET_PG_PROVIDER = 'sirsoft-nhnkcp';
const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
const EASY_PAY_PREFIX = 'nhnkcp_';
const logger = {
info: (...args: unknown[]) => console.info(`[${PLUGIN_IDENTIFIER}]`, ...args),
warn: (...args: unknown[]) => console.warn(`[${PLUGIN_IDENTIFIER}]`, ...args),
error: (...args: unknown[]) => console.error(`[${PLUGIN_IDENTIFIER}]`, ...args),
};
interface OrderCreateResponseBody {
success?: boolean;
message?: string;
data?: {
order?: {
order_number?: string;
total_tax_amount?: number;
total_vat_amount?: number;
total_tax_free_amount?: number;
};
redirect_url?: string;
requires_pg_payment?: boolean;
pg_provider?: string;
pg_payment_data?: Record<string, unknown>;
};
}
function extractUrl(input: RequestInfo | URL): string {
if (typeof input === 'string') return input;
if (input instanceof URL) return input.toString();
if (typeof Request !== 'undefined' && input instanceof Request) return input.url;
return String(input);
}
function extractMethod(input: RequestInfo | URL, init?: RequestInit): string {
if (init?.method) return init.method.toUpperCase();
if (typeof Request !== 'undefined' && input instanceof Request) return input.method.toUpperCase();
return 'GET';
}
function isTargetEndpoint(url: string, method: string): boolean {
if (method !== 'POST') return false;
const path = url.split('?')[0].split('#')[0];
return path === ORDER_CREATE_PATH || path.endsWith(ORDER_CREATE_PATH);
}
function buildNoOpRedirectUrl(): string {
return window.location.pathname + window.location.search + window.location.hash;
}
function mutateResponse(originalResponse: Response, mutatedBody: OrderCreateResponseBody): Response {
const json = JSON.stringify(mutatedBody);
return new Response(json, {
status: originalResponse.status,
statusText: originalResponse.statusText,
headers: originalResponse.headers,
});
}
function buildPaymentMethodBlockedResponse(message: string): Response {
return new Response(JSON.stringify({
success: false,
message,
error: message,
errors: {
payment_method: [message],
},
}), {
status: 422,
statusText: 'Unprocessable Content',
headers: {
'Content-Type': 'application/json',
},
});
}
function extractPaymentMethodFromBody(body: string): string | undefined {
try {
const parsed = JSON.parse(body) as Record<string, unknown>;
return parsed['payment_method'] as string | undefined;
} catch {
return undefined;
}
}
function replacePaymentMethodInBody(body: string, replacement: string): string {
try {
const parsed = JSON.parse(body) as Record<string, unknown>;
parsed['payment_method'] = replacement;
return JSON.stringify(parsed);
} catch {
return body;
}
}
export function installOrderResponseInterceptor(): void {
if (typeof window === 'undefined' || typeof window.fetch !== 'function') {
return;
}
const flag = '__sirsoftNhnkcpInterceptorInstalled' as const;
const w = window as unknown as Record<string, unknown>;
if (w[flag]) {
return;
}
w[flag] = true;
// 최초 설치 플러그인이 원본 브라우저 fetch를 보존 (다른 PG 인터셉터가 쌓이기 전)
const ORIGINAL_FETCH_KEY = '__sirsoftPgOriginalFetch';
if (!w[ORIGINAL_FETCH_KEY]) {
w[ORIGINAL_FETCH_KEY] = window.fetch.bind(window);
}
// easy pay 요청 시 다른 PG 인터셉터(KG 이니시스 등)를 완전히 우회하는 원본 fetch
const browserFetch = w[ORIGINAL_FETCH_KEY] as typeof fetch;
const originalFetch = window.fetch.bind(window);
window.fetch = async function patchedFetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const url = extractUrl(input);
const method = extractMethod(input, init);
// 주문 생성 엔드포인트가 아니면 바로 통과
if (!isTargetEndpoint(url, method)) {
return originalFetch(input, init);
}
// 요청 body에서 payment_method 추출 (nhnkcp_* 간편결제 감지)
let originalPaymentMethod: string | undefined;
let modifiedInit = init;
if (init?.body && typeof init.body === 'string') {
const pm = extractPaymentMethodFromBody(init.body);
if (typeof pm === 'string' && pm.startsWith(EASY_PAY_PREFIX)) {
originalPaymentMethod = pm;
// nhnkcp_* → 'card'로 교체해 서버 전송 (서버 ValidationEnum에 없는 값 방지)
modifiedInit = { ...init, body: replacePaymentMethodInBody(init.body, 'card') };
logger.info(`easy pay detected: replacing payment_method '${pm}' → 'card'`);
}
}
// G7Core 로컬 상태 fallback — 간편결제 버튼 클릭 시 serverPaymentMethod='card'로
// 서버에 전달되어 요청 body에서 감지 불가인 경우 대비
if (!originalPaymentMethod) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const localPm = ((window as any).G7Core)?.state?.getLocal?.()?.paymentMethod;
if (typeof localPm === 'string' && localPm.startsWith(EASY_PAY_PREFIX)) {
originalPaymentMethod = localPm;
logger.info(`easy pay detected from local state: '${localPm}'`);
}
} catch { /* ignore */ }
}
if (isNhnKcpApplePayMethod(originalPaymentMethod) && !isIosMobileDevice()) {
const message = applePayUnsupportedMessage();
logger.warn(message);
return buildPaymentMethodBlockedResponse(message);
}
// easy pay일 때 browserFetch(원본)를 사용해 KG 이니시스 등 다른 PG 인터셉터 우회
// 기본 PG가 KG 이니시스여도 NHN KCP 결제창이 열리도록 함
const fetchFn = originalPaymentMethod ? browserFetch : originalFetch;
const response = await fetchFn(input, modifiedInit);
let cloned: Response;
try {
cloned = response.clone();
} catch {
return response;
}
let body: OrderCreateResponseBody | null = null;
try {
body = (await cloned.json()) as OrderCreateResponseBody;
} catch {
return response;
}
const data = body?.data;
if (!data) return response;
const requiresPg = data.requires_pg_payment === true;
const isNhnkcp = data.pg_provider === TARGET_PG_PROVIDER;
// 일반 결제: pg_provider가 nhnkcp이고 requires_pg_payment=true인 경우에만 처리
// 간편결제: pg_provider 무관하게 NHN KCP 처리 (기본 PG가 KG 이니시스여도 KCP 결제창 띄움)
if (!originalPaymentMethod && (!requiresPg || !isNhnkcp)) {
return response;
}
if (originalPaymentMethod && !data.order && !data.pg_payment_data) {
return response;
}
// pg_payment_data: 서버 응답에 포함되거나,
// 간편결제 + 기본 PG가 타 PG인 경우 data.order에서 직접 구성
let pgPaymentData = data.pg_payment_data;
if (!pgPaymentData && originalPaymentMethod && data.order) {
const orderData = data.order as Record<string, unknown>;
const options = orderData.options as Array<Record<string, unknown>> | undefined;
const firstName = (options?.[0]?.product_name as string | undefined) ?? String(orderData.order_number ?? '');
const orderName = (options?.length ?? 0) > 1
? `${firstName} 외 ${(options?.length ?? 0) - 1}건`
: firstName;
pgPaymentData = {
order_number: orderData.order_number,
order_name: orderName,
amount: Math.floor(Number(orderData.total_due_amount ?? orderData.total_amount ?? 0)),
currency: 'KRW',
customer_name: orderData.orderer_name ?? null,
customer_email: orderData.orderer_email ?? null,
customer_phone: String(orderData.orderer_phone ?? '').replace(/[^0-9]/g, ''),
};
logger.info('pg_payment_data constructed from order (기본 PG가 타 PG)', {
order_number: pgPaymentData.order_number,
amount: pgPaymentData.amount,
});
}
if (!pgPaymentData) {
logger.warn('nhnkcp order detected but pg_payment_data missing');
return response;
}
logger.info('intercepted order create response — opening PG popup');
// 주문 세금 정보 추출 — PC 결제창의 복합과세 분할 필드 전달용
const orderTax = data.order
? {
tax_amount: data.order.total_tax_amount ?? 0,
vat_amount: data.order.total_vat_amount ?? 0,
tax_free_amount: data.order.total_tax_free_amount ?? 0,
}
: {};
// 간편결제가 아닌 경우 요청 body의 pay_method를 pgPaymentData에 주입
const enrichedPgPaymentData = (!originalPaymentMethod && modifiedInit?.body && typeof modifiedInit.body === 'string')
? (() => {
const pm = extractPaymentMethodFromBody(modifiedInit!.body as string);
return pm ? { ...pgPaymentData, pay_method: pm, ...orderTax } : { ...pgPaymentData, ...orderTax };
})()
: { ...pgPaymentData, ...orderTax };
void requestPaymentHandler({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
params: {
pgPaymentData: enrichedPgPaymentData as any,
paymentMethod: originalPaymentMethod,
},
});
const mutatedBody: OrderCreateResponseBody = {
...body,
data: {
...data,
requires_pg_payment: false,
redirect_url: buildNoOpRedirectUrl(),
},
};
return mutateResponse(response, mutatedBody);
};
logger.info('order response interceptor installed');
}
@@ -0,0 +1,106 @@
export interface PaymentCloseReportContext {
closeReportUrl?: string;
oid: string;
price: number;
buyer_email?: string;
buyer_phone?: string;
payment_method?: string;
}
function resolveApiUrl(url: string): string {
if (/^https?:\/\//i.test(url) || url.startsWith('/api/')) {
return url;
}
if (url.startsWith('/plugins/')) {
return `/api${url}`;
}
if (url.startsWith('plugins/')) {
return `/api/${url}`;
}
return url;
}
function trimReason(reason: string): string {
return reason.trim().slice(0, 160);
}
function resolveRetryUrl(closeReportUrl?: string): string | undefined {
if (!closeReportUrl) {
return undefined;
}
return closeReportUrl.replace(/\/payment\/close-report$/, '/payment/retry');
}
async function postPaymentContext(url: string, payload: Record<string, unknown>, keepalive = false): Promise<void> {
const apiClient = ((window as any).G7Core)?.api;
if (typeof apiClient?.post === 'function') {
await apiClient.post(url, payload);
return;
}
const response = await fetch(resolveApiUrl(url), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive,
});
if (!response.ok) {
let message = `NHN KCP payment request failed (${response.status})`;
try {
const data = await response.json();
const responseMessage = Array.isArray(data?.errors?.message)
? data.errors.message[0]
: data?.error ?? data?.message;
if (typeof responseMessage === 'string' && responseMessage.trim() !== '') {
message = responseMessage;
}
} catch {
// JSON 응답이 아니면 HTTP 상태 메시지를 사용한다.
}
throw new Error(message);
}
}
export async function preparePaymentRetry(context: PaymentCloseReportContext): Promise<void> {
const retryUrl = resolveRetryUrl(context.closeReportUrl);
if (!retryUrl) {
return;
}
await postPaymentContext(retryUrl, {
oid: context.oid,
price: Number(context.price),
buyer_email: context.buyer_email ?? '',
buyer_phone: context.buyer_phone ?? '',
payment_method: context.payment_method ?? '',
});
}
export async function reportPaymentWindowClosed(
context: PaymentCloseReportContext,
reason = 'kcp-window-closed',
): Promise<void> {
if (!context.closeReportUrl) {
return;
}
const payload = {
oid: context.oid,
price: Number(context.price),
buyer_email: context.buyer_email ?? '',
buyer_phone: context.buyer_phone ?? '',
payment_method: context.payment_method ?? '',
reason: trimReason(reason) || 'kcp-window-closed',
};
try {
await postPaymentContext(context.closeReportUrl, payload, true);
} catch (error) {
console.warn('[sirsoft-pay_nhnkcp] failed to report payment window close', error);
}
}
@@ -0,0 +1,38 @@
export const NHNKCP_APPLEPAY_METHOD = 'nhnkcp_applepay';
export function isNhnKcpApplePayMethod(paymentMethod: unknown): boolean {
return paymentMethod === NHNKCP_APPLEPAY_METHOD;
}
export function isIosMobileDevice(): boolean {
if (typeof navigator === 'undefined') return false;
const nav = navigator as Navigator & {
userAgentData?: {
mobile?: boolean;
platform?: string;
};
maxTouchPoints?: number;
};
const ua = (nav.userAgent || '').toLowerCase();
const platform = ((nav.userAgentData?.platform ?? nav.platform) || '').toLowerCase();
const touchPoints = nav.maxTouchPoints ?? 0;
if (/iphone|ipad|ipod/.test(ua) || /iphone|ipad|ipod|ios/.test(platform)) {
return true;
}
return /macintosh|mac os x/.test(ua) && touchPoints > 1;
}
export function applePayUnsupportedMessage(): string {
const lang = typeof document !== 'undefined'
? document.documentElement.lang
: '';
if ((lang || '').toLowerCase().startsWith('ko')) {
return '애플페이는 IOS 기기에 모바일결제만 가능합니다.';
}
return 'Apple Pay is available only for mobile payments on iOS devices.';
}
@@ -0,0 +1,158 @@
{
"editor": {
"data_source": {
"health": "Health",
"kcp_escrow_delivery": "Escrow Delivery",
"kcp_status": "Payment Status",
"nhnkcp_test_mode_status": "Test Mode Status",
"nhnkcp_easy_pay_config": "Easy Pay Config",
"nhnkcp_easy_pay_display_map": "Easy Pay Display Map",
"nhnkcp_test_map": "Test Payment Map",
"nicepay_test_map": "Test Payment Map",
"settings": "Payment Settings",
"vbank_info": "Virtual Account Info"
}
},
"messages": {
"refund": {
"missing_tid": "Cannot process refund: transaction number (tno) is missing.",
"default_reason": "Admin cancellation"
},
"payment": {
"auth_failed": "KCP payment authentication failed.",
"order_not_found": "Order not found.",
"amount_mismatch": "Payment amount does not match.",
"confirm_failed": "KCP transaction inquiry failed."
}
},
"payment_error": {
"title": "Payment Error",
"message": "An error occurred while processing the payment."
},
"user": {
"test_mode_badge": "Test Payment",
"payment_type_label": "Payment Method",
"receipt_label": "Receipt",
"receipt_view": "View Receipt",
"cash_receipt_view": "View Cash Receipt",
"vbank_title": "Virtual Account Deposit Guide",
"vbank_bank_name": "Bank",
"vbank_account": "Account Number",
"vbank_holder": "Account Holder",
"vbank_due": "Deposit Deadline",
"vbank_notice": "Your order will be automatically cancelled if payment is not received by the deadline.",
"tax_breakdown_title": "Tax Breakdown",
"supply_amount": "Supply Amount",
"vat_amount": "VAT",
"tax_free_amount": "Tax-Free Amount",
"escrow_badge": "Escrow"
},
"admin": {
"test_mode_badge": "Test Payment",
"test_mode_order_notice": "This order was processed as a test payment.",
"test_mode_settings_warning_plugin": "NHN KCP",
"test_mode_settings_warning_title": "Warning: NHN KCP payments are currently set to test mode.",
"test_mode_settings_warning_body": "Test payments do not create real payments. If the shop is live, switch this plugin to live payment mode before operating.",
"test_mode_settings_warning_action": "Switch to live mode",
"order_info_title": "NHN KCP Payment Info",
"order_info_description": "Displays stored KCP original approval and current payment status.",
"pay_status_approved": "Approved",
"pay_status_cancelled": "Cancelled",
"pay_status_cancel_completed": "Cancellation Completed",
"pay_status_partial_cancelled": "Partially Cancelled",
"pay_status_unknown": "Unknown",
"result_payment_status": "Current Payment Status",
"result_tno": "Transaction No. (TNO)",
"result_app_no": "Original Approval No.",
"result_app_time": "Original Approval Time",
"result_pay_method": "Pay Method",
"result_res_cd": "Original Approval Result Code",
"result_cancelled_amount": "Cancelled Amount",
"result_cancelled_at": "Cancelled At",
"result_refund_status": "Refund Status",
"result_refund_number": "Refund No.",
"escrow_badge": "Escrow",
"escrow_delivery_title": "KCP Escrow Delivery Registration",
"escrow_delivery_description": "Register the tracking number with KCP after shipping.",
"escrow_delivery_invoice_label": "Tracking No.",
"escrow_delivery_invoice_placeholder": "Enter tracking number",
"escrow_delivery_courier_label": "Courier",
"escrow_delivery_courier_placeholder": "Select courier",
"escrow_delivery_already_registered": "Delivery Registered",
"escrow_delivery_registered_at": "Registered At",
"escrow_delivery_invoice_info": "Tracking No.",
"escrow_delivery_courier_info": "Courier",
"escrow_delivery_register_button": "Register",
"escrow_delivery_change_button": "Update",
"escrow_delivery_registering": "Registering...",
"escrow_delivery_success": "Delivery info registered with KCP.",
"escrow_delivery_error": "Failed to register delivery info."
},
"settings": {
"title": "NHN KCP Settings",
"description": "Manage NHN KCP payment plugin settings.",
"test_mode": "Test Mode",
"test_mode_hint": "No real payments occur in test mode.",
"live_mode_warning_title": "Live Mode Active",
"live_mode_warning_body": "Real payments will occur. Verify that your live site code and key are configured correctly.",
"cancel_server_ip_title": "Payment cancellation server IP registration required",
"cancel_server_ip_body": "NHN KCP allows live payment cancellations from the shop admin only when the shop server's outbound IP is registered in the KCP merchant admin.",
"cancel_server_ip_path": "NHN KCP Merchant Admin > Technology Management Center > Security Management > Server IP Settings (Payment)",
"cancel_server_ip_effect": "If the IP is not registered or the server IP changes, cancellation from this admin screen may fail and you may need to cancel directly in the KCP merchant admin.",
"cancel_server_ip_value_label": "Server IP to register",
"cancel_server_ip_unavailable": "Server IP needs verification",
"cancel_server_ip_copied": "Server IP copied to clipboard.",
"cancel_server_ip_link": "Open NHN KCP Merchant Admin",
"section_test_keys": "Test Credentials",
"test_site_cd": "Test Site Code (site_cd)",
"test_site_cd_hint": "Test site code issued by KCP (default: T0000)",
"test_site_key": "Test Site Key (site_key)",
"test_site_key_hint": "Test site key issued by KCP",
"section_live_keys": "Live Credentials",
"live_site_cd": "Live Site Code (site_cd)",
"live_site_cd_hint": "Live site code issued by KCP (enter without SR prefix)",
"live_site_cd_example": "e.g.: xxxxxx → full site_cd used will be SRxxxxxx",
"live_site_key": "Live Site Key (site_key)",
"live_site_key_hint": "Keep this key secret.",
"section_easy_pay": "Easy Pay Settings",
"section_easy_pay_hint": "Select the easy payment services to enable. Only activate services for which you have completed a KCP merchant agreement.",
"easy_pay_allow_with_other_pg": "Available with other PGs",
"easy_pay_allow_with_other_pg_hint": "When enabled, NHN KCP easy pay buttons will appear on the checkout page even when another PG is set as the default payment method.",
"easy_pay_payco": "PAYCO",
"easy_pay_naverpay": "NaverPay",
"easy_pay_naverpay_hint": "Not available in test mode",
"easy_pay_naverpay_point": "NaverPay Point",
"easy_pay_naverpay_point_hint": "Requires a NaverPay Point payment agreement with KCP. If you see error [DR02], disable this option and contact KCP.",
"easy_pay_kakaopay": "KakaoPay",
"easy_pay_kakaopay_hint": "Not available in test mode",
"easy_pay_applepay": "Apple Pay",
"easy_pay_applepay_hint": "Available on iOS Safari only",
"section_redirect": "Redirect URLs",
"redirect_success_url": "Payment Success Redirect URL",
"redirect_success_url_hint": "Supports relative paths or full URLs. {orderId} will be replaced with the actual order number.",
"redirect_fail_url": "Payment Failure Redirect URL",
"redirect_fail_url_hint": "Supports relative paths or full URLs. Error details are appended as query parameters.",
"section_vbank": "Virtual Account Settings",
"section_vbank_hint": "Settings for virtual account payment.",
"vbank_expire_days": "Deposit Validity Period",
"vbank_expire_days_hint": "Number of days the virtual account remains valid for deposit (1–7 days).",
"vbank_expire_days_unit": "days",
"section_vbank_notify": "Virtual Account Deposit Notification URL",
"vbank_notify_hint": "Register this URL as the deposit notification URL in the NHN KCP merchant admin to enable automatic virtual account payment processing.",
"vbank_notify_path": "Registration path: NHN KCP Merchant Admin > Technology Management Center > Webhook Management > Webhook URL Settings",
"vbank_notify_copied": "URL copied to clipboard.",
"copy": "Copy",
"section_escrow": "Escrow Settings",
"section_escrow_hint": "With escrow, payment is held until the buyer confirms receipt.",
"use_escrow": "Enable Escrow Payments",
"use_escrow_hint": "When enabled, escrow applies to all payments.",
"escrow_test_site_cd": "Escrow Test Site Code",
"escrow_test_site_cd_hint": "KCP escrow test site_cd (default: T0007)",
"section_escrow_notify": "Escrow Common Notification URL",
"escrow_common_notify_hint": "Register this URL as the common notification URL in the NHN KCP merchant admin to enable automatic escrow purchase confirmation/cancellation processing.",
"escrow_common_notify_copied": "URL copied to clipboard."
},
"easy_pay": {
"section_title": "Easy Pay"
}
}
@@ -0,0 +1,158 @@
{
"editor": {
"data_source": {
"health": "연동 상태",
"kcp_escrow_delivery": "에스크로 배송",
"kcp_status": "결제 상태",
"nhnkcp_test_mode_status": "테스트 모드 상태",
"nhnkcp_easy_pay_config": "간편결제 설정",
"nhnkcp_easy_pay_display_map": "간편결제 표시 맵",
"nhnkcp_test_map": "테스트 결제 맵",
"nicepay_test_map": "테스트 결제 맵",
"settings": "결제 설정",
"vbank_info": "가상계좌 정보"
}
},
"messages": {
"refund": {
"missing_tid": "결제 거래번호(tno)가 없습니다.",
"default_reason": "관리자 환불"
},
"payment": {
"auth_failed": "KCP 결제 인증에 실패했습니다.",
"order_not_found": "주문을 찾을 수 없습니다.",
"amount_mismatch": "결제 금액이 일치하지 않습니다.",
"confirm_failed": "KCP 거래 조회에 실패했습니다."
}
},
"payment_error": {
"title": "결제 오류",
"message": "결제 처리 중 오류가 발생했습니다."
},
"user": {
"test_mode_badge": "테스트 결제",
"payment_type_label": "결제 수단",
"receipt_label": "영수증",
"receipt_view": "영수증 조회",
"cash_receipt_view": "현금영수증 조회",
"vbank_title": "가상계좌 입금 안내",
"vbank_bank_name": "은행",
"vbank_account": "계좌번호",
"vbank_holder": "예금주",
"vbank_due": "입금 기한",
"vbank_notice": "입금 기한 내에 입금하지 않으시면 주문이 자동 취소됩니다.",
"tax_breakdown_title": "세금 상세",
"supply_amount": "공급가액",
"vat_amount": "부가세",
"tax_free_amount": "면세금액",
"escrow_badge": "에스크로"
},
"admin": {
"test_mode_badge": "테스트 결제",
"test_mode_order_notice": "이 주문은 테스트 결제로 처리되었습니다.",
"test_mode_settings_warning_plugin": "NHN KCP",
"test_mode_settings_warning_title": "(주의!) NHN KCP 결제 설정이 현재 테스트결제로 되어 있습니다.",
"test_mode_settings_warning_body": "테스트결제 시 실제 결제가 되지 않으므로, 쇼핑몰 운영 중이면 반드시 실결제로 설정하여 운영하세요.",
"test_mode_settings_warning_action": "실결제로 설정",
"order_info_title": "NHN KCP 결제 정보",
"order_info_description": "DB에 저장된 KCP 원승인 및 현재 결제 상태를 표시합니다.",
"pay_status_approved": "승인",
"pay_status_cancelled": "취소",
"pay_status_cancel_completed": "취소완료",
"pay_status_partial_cancelled": "부분취소",
"pay_status_unknown": "미확인",
"result_payment_status": "현재 결제상태",
"result_tno": "거래번호 (TNO)",
"result_app_no": "원승인번호",
"result_app_time": "원승인일시",
"result_pay_method": "결제수단",
"result_res_cd": "원승인 응답코드",
"result_cancelled_amount": "취소금액",
"result_cancelled_at": "취소일시",
"result_refund_status": "환불상태",
"result_refund_number": "환불번호",
"escrow_badge": "에스크로",
"escrow_delivery_title": "KCP 에스크로 배송 등록",
"escrow_delivery_description": "상품 발송 후 KCP에 운송장번호를 등록하세요.",
"escrow_delivery_invoice_label": "운송장번호",
"escrow_delivery_invoice_placeholder": "운송장번호 입력",
"escrow_delivery_courier_label": "택배사",
"escrow_delivery_courier_placeholder": "택배사 선택",
"escrow_delivery_already_registered": "배송등록 완료",
"escrow_delivery_registered_at": "등록일시",
"escrow_delivery_invoice_info": "운송장번호",
"escrow_delivery_courier_info": "택배사",
"escrow_delivery_register_button": "배송등록",
"escrow_delivery_change_button": "배송정보 수정",
"escrow_delivery_registering": "등록 중...",
"escrow_delivery_success": "배송정보가 KCP에 등록되었습니다.",
"escrow_delivery_error": "배송등록에 실패했습니다."
},
"settings": {
"title": "NHN KCP 설정",
"description": "NHN KCP 결제 플러그인 설정을 관리합니다.",
"test_mode": "테스트 모드",
"test_mode_hint": "테스트 모드에서는 실제 결제가 발생하지 않습니다.",
"live_mode_warning_title": "라이브 모드 활성화",
"live_mode_warning_body": "실제 결제가 발생합니다. 라이브 사이트 코드 및 키를 정확히 설정했는지 확인하세요.",
"cancel_server_ip_title": "결제취소 서버 IP 등록 필요",
"cancel_server_ip_body": "NHN KCP는 거래 취소 보안을 위해 쇼핑몰 서버의 외부 송신 IP가 상점관리자에 등록된 경우에만 쇼핑몰 관리자에서 실결제 취소를 허용합니다.",
"cancel_server_ip_path": "NHN KCP 상점관리자 > 기술관리센터 > 보안관리 > 서버 IP 설정(결제)",
"cancel_server_ip_effect": "IP가 등록되지 않았거나 서버 IP가 변경되면 이 관리자 화면에서 결제취소가 실패할 수 있으며, 이 경우 KCP 상점관리자에서 직접 취소해야 합니다.",
"cancel_server_ip_value_label": "등록할 서버 IP",
"cancel_server_ip_unavailable": "서버 IP 확인 필요",
"cancel_server_ip_copied": "서버 IP가 클립보드에 복사되었습니다.",
"cancel_server_ip_link": "NHN KCP 상점관리자 열기",
"section_test_keys": "테스트 인증 정보",
"test_site_cd": "테스트 사이트 코드 (site_cd)",
"test_site_cd_hint": "KCP에서 발급받은 테스트 사이트 코드 (기본값: T0000)",
"test_site_key": "테스트 사이트 키 (site_key)",
"test_site_key_hint": "KCP에서 발급받은 테스트 사이트 키",
"section_live_keys": "라이브 인증 정보",
"live_site_cd": "라이브 사이트 코드 (site_cd)",
"live_site_cd_hint": "KCP에서 발급받은 라이브 사이트 코드 (SR prefix 제외하고 입력)",
"live_site_cd_example": "예: xxxxxx → 실제 사용 site_cd는 SRxxxxxx",
"live_site_key": "라이브 사이트 키 (site_key)",
"live_site_key_hint": "외부에 노출되지 않도록 주의하세요.",
"section_easy_pay": "간편결제 설정",
"section_easy_pay_hint": "사용할 간편결제 서비스를 선택하세요. KCP 가맹점 계약이 완료된 서비스만 활성화하세요.",
"easy_pay_allow_with_other_pg": "타 PG와 사용가능함",
"easy_pay_allow_with_other_pg_hint": "활성화하면, 다른 PG사가 기본 결제수단으로 설정된 경우에도 체크아웃 페이지에 NHN KCP 간편결제 버튼이 표시됩니다.",
"easy_pay_payco": "PAYCO (페이코)",
"easy_pay_naverpay": "네이버페이",
"easy_pay_naverpay_hint": "테스트 모드에서 결제 불가",
"easy_pay_naverpay_point": "네이버페이 포인트결제",
"easy_pay_naverpay_point_hint": "KCP 가맹점에서 네이버페이 포인트결제 계약이 필요합니다. [DR02] 오류 발생 시 비활성화 후 KCP에 문의하세요.",
"easy_pay_kakaopay": "카카오페이",
"easy_pay_kakaopay_hint": "테스트 모드에서 결제 불가",
"easy_pay_applepay": "애플페이",
"easy_pay_applepay_hint": "iOS Safari에서만 사용 가능",
"section_redirect": "리다이렉트 URL",
"redirect_success_url": "결제 성공 리다이렉트 URL",
"redirect_success_url_hint": "상대 경로(/shop/...) 또는 전체 URL 모두 가능합니다. {orderId}는 주문번호로 자동 치환됩니다.",
"redirect_fail_url": "결제 실패 리다이렉트 URL",
"redirect_fail_url_hint": "상대 경로 또는 전체 URL 모두 가능합니다. 오류 정보는 쿼리 파라미터로 자동 추가됩니다.",
"section_vbank": "가상계좌 설정",
"section_vbank_hint": "가상계좌 결제 관련 설정입니다.",
"vbank_expire_days": "입금 유효기간",
"vbank_expire_days_hint": "가상계좌 발급 후 입금 가능한 기한 (1~7일).",
"vbank_expire_days_unit": "일",
"section_vbank_notify": "가상계좌 입금통보 URL",
"vbank_notify_hint": "NHN KCP 가맹점 어드민에서 이 URL을 입금통보 URL로 등록해야 가상계좌 결제가 자동으로 처리됩니다.",
"vbank_notify_path": "등록 경로: NHN KCP 상점관리자 > 기술관리센터 > 웹훅(Webhook) 관리 > 웹훅 URL 설정",
"vbank_notify_copied": "URL이 클립보드에 복사되었습니다.",
"copy": "복사",
"section_escrow": "에스크로 설정",
"section_escrow_hint": "에스크로 결제를 사용하면 구매자가 구매확인 후 판매자에게 결제금액이 정산됩니다.",
"use_escrow": "에스크로 결제 사용",
"use_escrow_hint": "활성화하면 모든 결제에 에스크로가 적용됩니다.",
"escrow_test_site_cd": "에스크로 테스트 사이트 코드",
"escrow_test_site_cd_hint": "KCP 에스크로 테스트용 site_cd (기본값: T0007)",
"section_escrow_notify": "에스크로 공통통보 URL",
"escrow_common_notify_hint": "NHN KCP 가맹점 어드민에서 이 URL을 공통통보 URL로 등록해야 구매확인/취소가 자동으로 처리됩니다.",
"escrow_common_notify_copied": "URL이 클립보드에 복사되었습니다."
},
"easy_pay": {
"section_title": "간편결제"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Concerns;
use Illuminate\Cookie\CookieJar;
use Symfony\Component\HttpFoundation\Cookie;
trait IssuesReceiptCookie
{
public const RECEIPT_COOKIE_NAME = 'nhnkcp_receipt_token';
private const RECEIPT_COOKIE_TTL_MINUTES = 5;
protected function queueReceiptCookie(string $orderNumber): void
{
$expiresTs = time() + (self::RECEIPT_COOKIE_TTL_MINUTES * 60);
$signature = $this->signReceiptToken($orderNumber, $expiresTs);
$tokenValue = $orderNumber.'|'.$expiresTs.'|'.$signature;
/** @var CookieJar $jar */
$jar = app(CookieJar::class);
$jar->queue(new Cookie(
name: self::RECEIPT_COOKIE_NAME,
value: $tokenValue,
expire: $expiresTs,
path: '/',
domain: null,
secure: request()->isSecure(),
httpOnly: true,
raw: false,
sameSite: Cookie::SAMESITE_LAX,
));
}
protected function verifyReceiptCookie(?string $cookieValue, string $expectedOrderNumber): bool
{
if (! is_string($cookieValue) || $cookieValue === '') {
return false;
}
$parts = explode('|', $cookieValue);
if (count($parts) !== 3) {
return false;
}
[$orderNumber, $expiresTs, $signature] = $parts;
if ($orderNumber !== $expectedOrderNumber) {
return false;
}
if (! ctype_digit($expiresTs) || (int) $expiresTs < time()) {
return false;
}
if (! ctype_xdigit($signature) || strlen($signature) !== 64) {
return false;
}
return hash_equals($this->signReceiptToken($orderNumber, (int) $expiresTs), $signature);
}
private function signReceiptToken(string $orderNumber, int $expiresTs): string
{
return hash_hmac('sha256', $orderNumber.'|'.$expiresTs, (string) config('app.key', ''));
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Concerns;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
/**
* Plugin 측 replay attack 방어 — 동일 PG 거래 ID 의 중복 콜백을 멱등 처리.
*
* `g7_ecommerce_order_payments.transaction_id` 컬럼은 DB unique 제약이 없으므로
* 동일 transaction_id 로 콜백이 두 번 도착하면 두 번 `completePayment()` 가
* 실행되어 중복 처리될 위험이 있다 (예: 결제완료 알림 중복, 마일리지 중복 적립).
*
* 본 트레이트는 콜백 진입 시점에 OrderPayment 를 조회하여 이미 `paid` 상태이면
* 멱등 분기로 조기 리턴하도록 한다. modules 영역의 schema 는 변경하지 않고
* plugin scope 내에서만 처리한다.
*/
trait PreventsReplayCallback
{
/**
* 동일 transaction_id 가 이미 paid 상태로 저장되었는지 확인.
*
* @param string|null $transactionId PG 거래 ID (NHN KCP: tno)
* @return bool true 면 중복 콜백 — 멱등 응답으로 처리해야 함
*/
protected function wasAlreadyPaid(?string $transactionId): bool
{
if ($transactionId === null || $transactionId === '') {
return false;
}
return OrderPayment::query()
->where('transaction_id', $transactionId)
->where('payment_status', PaymentStatusEnum::PAID)
->exists();
}
/**
* Replay 감지를 로깅. 통일된 로그 형식으로 운영 모니터링 용이성 확보.
*
* @param string $tid PG 거래 ID
* @param string|null $moid 주문번호 (있을 경우)
* @param string $context 콜백 종류 (authCallback / vbankNotify 등)
*/
protected function logReplayDetected(string $tid, ?string $moid, string $context): void
{
Log::info('NHN KCP: replay detected — already paid, returning idempotent response', [
'tid' => $tid,
'moid' => $moid,
'context' => $context,
]);
}
}
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Concerns;
use InvalidArgumentException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderAddress;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
trait RecordsPaymentWindowClosure
{
private const KCP_PROVIDER = 'nhnkcp';
protected function expectedPaymentPrice(Order $order): int
{
// 결제 청구액 SSoT = 결제 통화(order_currency) 환산액. base(total_due_amount) 직접 비교 시
// base≠결제 통화에서 PG 청구 통화와 단위가 어긋난다(buildPgPaymentData 와 동일 기준).
return app(CurrencyConversionService::class)->resolveOrderPaymentChargeAmount($order);
}
protected function resolveExpectedPaymentPriceOrNull(Order $order, string $context, array $logContext = []): ?int
{
try {
return $this->expectedPaymentPrice($order);
} catch (InvalidArgumentException $e) {
$this->logInvalidPaymentCurrency($order, $e, $context, $logContext);
return null;
}
}
protected function logInvalidPaymentCurrency(
Order $order,
InvalidArgumentException $e,
string $context,
array $logContext = [],
): void {
Log::error('NHN KCP: payment currency is not chargeable', array_merge([
'context' => $context,
'order_id' => $order->id,
'order_number' => $order->order_number,
'currency' => $order->currency,
'currency_snapshot' => $order->currency_snapshot,
'error' => $e->getMessage(),
], $logContext));
}
protected function requestMatchesOrderBuyer(Request $request, Order $order): bool
{
/** @var OrderAddress|null $address */
$address = $order->shippingAddress;
if (! $address) {
return true;
}
$expectedEmail = strtolower(trim((string) $address->orderer_email));
if ($expectedEmail !== '') {
$receivedEmail = strtolower(trim((string) $request->input('buyer_email', '')));
if ($receivedEmail === '' || $receivedEmail !== $expectedEmail) {
return false;
}
}
$expectedPhone = $this->digitsOnly((string) $address->orderer_phone);
if ($expectedPhone !== '') {
$receivedPhone = $this->digitsOnly((string) $request->input('buyer_phone', ''));
if ($receivedPhone === '' || $receivedPhone !== $expectedPhone) {
return false;
}
}
return true;
}
protected function markPaymentWindowClosed(
OrderProcessingService $orderService,
Order $order,
string $failureCode,
string $failureMessage,
?string $cancelMessage = null,
): Order {
if (! $order->order_status->isBeforePayment()) {
return $order;
}
$failedOrder = $orderService->failPayment($order, $failureCode, $failureMessage);
$cancelledOrder = $orderService->recordPaymentCancellation(
$failedOrder,
$failureCode,
$cancelMessage ?: $failureMessage,
);
return $this->markKcpPaymentFailureRecord(
$cancelledOrder,
$failureCode,
$cancelMessage ?: $failureMessage,
'window_closed',
PaymentStatusEnum::CANCELLED,
);
}
protected function markKcpPaymentFailureRecord(
Order $order,
string $failureCode,
string $failureMessage,
string $failureStage,
PaymentStatusEnum $paymentStatus = PaymentStatusEnum::FAILED,
): Order {
/** @var OrderPayment|null $payment */
$payment = $order->payment;
if (! $payment || ! $payment->exists) {
return $order;
}
$now = now()->toIso8601String();
$paymentMeta = $payment->payment_meta ?? [];
$history = $paymentMeta['failure_history'] ?? [];
$history = is_array($history) ? $history : [];
$history[] = [
'code' => $failureCode,
'message' => $failureMessage,
'stage' => $failureStage,
'failed_at' => $now,
];
$paymentMeta['failure_history'] = array_slice($history, -5);
$paymentMeta['failure_source'] = self::KCP_PROVIDER;
$paymentMeta['failure_code'] = $failureCode;
$paymentMeta['failure_message'] = $failureMessage;
$paymentMeta['failure_stage'] = $failureStage;
$paymentMeta['failed_at'] = $now;
$payment->update([
'pg_provider' => self::KCP_PROVIDER,
'payment_status' => $paymentStatus->value,
'payment_meta' => $paymentMeta,
]);
return $order->fresh('payment') ?? $order;
}
protected function restoreRetryableKcpOrder(Order $order, ?int $amount = null): bool
{
/** @var OrderPayment|null $payment */
$payment = $order->payment;
if (! $payment || ! $payment->exists) {
return false;
}
if ($amount !== null) {
$expectedAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'retry_restore', [
'received_amount' => $amount,
]);
if ($expectedAmount === null || $amount !== $expectedAmount) {
return false;
}
}
if (! $this->isRetryableKcpFailure($order, $payment)) {
return false;
}
DB::transaction(function () use ($order, $payment) {
$now = now()->toIso8601String();
$orderMeta = $order->order_meta ?? [];
$paymentMeta = $payment->payment_meta ?? [];
$previousFailure = array_filter([
'code' => $orderMeta['payment_failure_code'] ?? $paymentMeta['failure_code'] ?? null,
'message' => $orderMeta['payment_failure_message'] ?? $paymentMeta['failure_message'] ?? null,
'failed_at' => $orderMeta['payment_failed_at'] ?? $paymentMeta['failed_at'] ?? null,
'stage' => $paymentMeta['failure_stage'] ?? null,
], fn ($value) => $value !== null && $value !== '');
if ($previousFailure !== []) {
$retryFailures = $orderMeta['nhnkcp_retry_failures'] ?? [];
$retryFailures = is_array($retryFailures) ? $retryFailures : [];
$retryFailures[] = $previousFailure;
$orderMeta['nhnkcp_retry_failures'] = array_slice($retryFailures, -5);
}
unset(
$orderMeta['payment_failure_code'],
$orderMeta['payment_failure_message'],
$orderMeta['payment_failed_at'],
$paymentMeta['failure_code'],
$paymentMeta['failure_message'],
$paymentMeta['failure_source'],
$paymentMeta['failure_stage'],
$paymentMeta['failed_at'],
);
$orderMeta['nhnkcp_retry_count'] = (int) ($orderMeta['nhnkcp_retry_count'] ?? 0) + 1;
$orderMeta['nhnkcp_retry_started_at'] = $now;
$paymentMeta['retry_count'] = (int) ($paymentMeta['retry_count'] ?? 0) + 1;
$paymentMeta['retry_started_at'] = $now;
$order->update([
'order_status' => OrderStatusEnum::PENDING_ORDER,
'order_meta' => $orderMeta,
]);
$payment->update([
'payment_status' => PaymentStatusEnum::READY->value,
'payment_meta' => $paymentMeta,
'paid_at' => null,
'cancelled_at' => null,
'transaction_id' => null,
'card_approval_number' => null,
]);
});
Log::info('KCP: retryable failed order restored', [
'ordr_idxx' => $order->order_number,
'payment_id' => $payment->id,
]);
return true;
}
private function isRetryableKcpFailure(Order $order, OrderPayment $payment): bool
{
$paymentMeta = $payment->payment_meta ?? [];
$failureCode = (string) ($paymentMeta['failure_code'] ?? $order->order_meta['payment_failure_code'] ?? '');
return $order->order_status === OrderStatusEnum::CANCELLED
&& $payment->pg_provider === self::KCP_PROVIDER
&& in_array($payment->payment_status, [PaymentStatusEnum::FAILED, PaymentStatusEnum::CANCELLED], true)
&& ($paymentMeta['failure_source'] ?? null) === self::KCP_PROVIDER
&& $failureCode !== 'AMOUNT_MISMATCH'
&& blank($payment->transaction_id)
&& blank($payment->card_approval_number)
&& $payment->paid_at === null;
}
protected function digitsOnly(string $value): string
{
return preg_replace('/[^0-9]/', '', $value) ?? '';
}
}
@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Concerns;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
trait ResolvesEasyPayDisplay
{
private const EASY_PAY_METHODS = [
'nhnkcp_payco' => [
'provider' => 'payco',
'label' => ['ko' => 'PAYCO (페이코)', 'en' => 'PAYCO'],
],
'nhnkcp_naverpay' => [
'provider' => 'naverpay',
'label' => ['ko' => '네이버페이', 'en' => 'NaverPay'],
],
'nhnkcp_naverpay_point' => [
'provider' => 'naverpay_point',
'label' => ['ko' => '네이버페이 포인트결제', 'en' => 'NaverPay Point'],
],
'nhnkcp_kakaopay' => [
'provider' => 'kakaopay',
'label' => ['ko' => '카카오페이', 'en' => 'KakaoPay'],
],
'nhnkcp_applepay' => [
'provider' => 'applepay',
'label' => ['ko' => '애플페이', 'en' => 'Apple Pay'],
],
];
/**
* @return array{method: string, provider: string, label: array{ko: string, en: string}}|array{}
*/
protected function resolveEasyPayMeta(array $payload): array
{
$method = strtolower(trim((string) ($payload['nhnkcp_easy_pay_method'] ?? $payload['param_opt_1'] ?? '')));
if (! isset(self::EASY_PAY_METHODS[$method])) {
return [];
}
$definition = self::EASY_PAY_METHODS[$method];
return [
'method' => $method,
'provider' => $definition['provider'],
'label' => $definition['label'],
];
}
/**
* @return array<string, mixed>
*/
protected function decodePaymentMeta(mixed $paymentMeta): array
{
if (is_array($paymentMeta)) {
return $paymentMeta;
}
if (! is_string($paymentMeta) || trim($paymentMeta) === '') {
return [];
}
$decoded = json_decode($paymentMeta, true);
return is_array($decoded) ? $decoded : [];
}
/**
* @return array{
* payment_method_label: string|null,
* payment_method_display_label: string|null,
* selected_payment_method: string|null,
* embedded_pg_provider: string|null,
* embedded_pg_provider_label: string|null
* }
*/
protected function resolvePaymentDisplay(object $payment): array
{
$paymentMeta = $this->decodePaymentMeta($payment->payment_meta ?? null);
$selectedPaymentMethod = $this->stringOrNull($paymentMeta['nhnkcp_easy_pay_method'] ?? null);
$provider = $this->stringOrNull(
$payment->embedded_pg_provider
?? $paymentMeta['nhnkcp_easy_pay_provider']
?? $paymentMeta['embedded_pg_provider']
?? null
);
if ($provider === null && $selectedPaymentMethod !== null) {
$provider = $this->stringOrNull(self::EASY_PAY_METHODS[$selectedPaymentMethod]['provider'] ?? null);
}
$baseLabel = $this->paymentMethodLabel($this->stringOrNull($payment->payment_method ?? null));
$embeddedLabel = $this->embeddedPgProviderLabel($provider, $paymentMeta);
return [
'payment_method_label' => $baseLabel,
'payment_method_display_label' => $this->paymentMethodDisplayLabel($baseLabel, $embeddedLabel),
'selected_payment_method' => $selectedPaymentMethod,
'embedded_pg_provider' => $provider,
'embedded_pg_provider_label' => $embeddedLabel,
];
}
protected function paymentMethodLabel(?string $method): ?string
{
if ($method === null || $method === '') {
return null;
}
return PaymentMethodEnum::tryFrom($method)?->label() ?? $method;
}
protected function paymentMethodDisplayLabel(?string $baseLabel, ?string $embeddedLabel): ?string
{
if ($baseLabel === null || $baseLabel === '') {
return $embeddedLabel;
}
if ($embeddedLabel === null || $embeddedLabel === '') {
return $baseLabel;
}
return $embeddedLabel . ' (' . $baseLabel . ')';
}
/**
* @param array<string, mixed> $paymentMeta
*/
protected function embeddedPgProviderLabel(?string $provider, array $paymentMeta = []): ?string
{
$metaLabel = $this->localizedLabel($paymentMeta['nhnkcp_easy_pay_label'] ?? null)
?? $this->localizedLabel($paymentMeta['embedded_pg_provider_label'] ?? null);
if ($metaLabel !== null) {
return $metaLabel;
}
if ($provider === null || $provider === '') {
return null;
}
foreach (self::EASY_PAY_METHODS as $definition) {
if (($definition['provider'] ?? null) === $provider) {
return $this->localizedLabel($definition['label'] ?? null);
}
}
return null;
}
private function localizedLabel(mixed $label): ?string
{
if (is_string($label) && trim($label) !== '') {
return $label;
}
if (! is_array($label)) {
return null;
}
$locale = app()->getLocale();
return $this->stringOrNull($label[$locale] ?? null)
?? $this->stringOrNull($label['ko'] ?? null)
?? $this->stringOrNull($label['en'] ?? null);
}
private function stringOrNull(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$value = trim($value);
return $value === '' ? null : $value;
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Concerns;
trait SanitizesPgResponse
{
/**
* PG 응답 원문 대신 운영에 필요한 필드만 남긴다.
*
* 카드번호, 구매자 연락처, 가상계좌번호처럼 PG 응답에 섞일 수 있는 민감 필드는
* whitelist 에 넣지 않는 방식으로 차단한다. 기존 관리자 조회 호환을 위해
* payment_meta.pg_raw_response 키는 유지하되 내용은 sanitized subset 이다.
*/
protected function sanitizePgResponse(array $response, array $allowedKeys): array
{
$allowed = array_flip($allowedKeys);
$sanitized = [];
foreach ($response as $key => $value) {
if (! isset($allowed[$key])) {
continue;
}
$sanitized[$key] = is_scalar($value) || $value === null
? $value
: json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
return $sanitized;
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Concerns;
use Illuminate\Http\Response;
/**
* KCP 공통통보 webhook 표준 응답 헬퍼 (그누보드5 settle_kcp_common.php 동일 패턴)
*
* KCP 는 응답 body 의 <input name="result"> 값을 파싱한다.
* - result="0000" → 통보 성공 인정 (재시도 차단)
* - 그 외 → 재통보 (최대 10회, 0/5/5/10/20/40/80/160/320/640분)
*
* vbank-notify / escrow-common-notify 등 KCP 서버 발신 webhook 모두 동일 형식 사용.
*/
trait SendsKcpNotifyResponse
{
/**
* KCP 표준 통보 응답 (HTML form 으로 result 코드 전달)
*
* @param string $resultCode 통보 처리 결과 ("0000" 성공 / 그 외 재통보 유도)
* @return Response 200 + KCP 표준 HTML body
*/
protected function kcpNotifyResponse(string $resultCode = '0000'): Response
{
$safeCode = htmlspecialchars($resultCode, ENT_QUOTES, 'UTF-8');
$html = '<html><body><form><input type="hidden" name="result" value="'.$safeCode.'"></form></body></html>';
return response($html, 200)->header('Content-Type', 'text/html; charset=utf-8');
}
/**
* 재통보 유도 응답 (DB 일시적 오류 등 — KCP 가 재시도)
*
* @return Response 200 + result="9999" HTML
*/
protected function kcpNotifyRetry(): Response
{
return $this->kcpNotifyResponse('9999');
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Concerns;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
trait SerializesPaymentCallbacks
{
protected function acquireOrderCallbackLock(string $context, string $orderNumber): mixed
{
$key = $this->callbackLockKey($context, $orderNumber);
$lock = Cache::lock($key, $this->callbackLockTtlSeconds());
try {
$lock->block($this->callbackLockWaitSeconds());
} catch (LockTimeoutException $e) {
Log::warning('NHN KCP: callback lock timeout', [
'context' => $context,
'order_number' => $orderNumber,
'lock_key' => $key,
]);
throw $e;
}
return $lock;
}
protected function releaseOrderCallbackLock(mixed $lock): void
{
if (is_object($lock) && method_exists($lock, 'release')) {
$lock->release();
}
}
protected function callbackLockKey(string $context, string $orderNumber): string
{
return 'sirsoft-pay_nhnkcp:callback:'
. $context
. ':'
. sha1(trim($orderNumber));
}
protected function callbackLockTtlSeconds(): int
{
return 30;
}
protected function callbackLockWaitSeconds(): int
{
return 10;
}
}
@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Controllers;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Plugins\Sirsoft\PayNhnkcp\Concerns\SanitizesPgResponse;
use Plugins\Sirsoft\PayNhnkcp\Services\NhnKcpApiService;
/**
* KCP 에스크로 배송등록 관리자 컨트롤러
*
* 에스크로 결제 후 상품 발송 시 KCP에 운송장번호를 등록합니다.
* CLI mod_type=STE1 방식으로 배송정보를 전달합니다.
*/
class AdminEscrowDeliveryController extends AdminBaseController
{
use SanitizesPgResponse;
private const ESCROW_DELIVERY_RESPONSE_KEYS = [
'res_cd',
'res_msg',
'tno',
'deli_numb',
'deli_corp',
];
/** KCP 에스크로 택배사 코드표 */
private const COURIER_CODES = [
'04' => 'CJ대한통운',
'05' => '한진택배',
'06' => '로젠택배',
'08' => '롯데택배',
'09' => '우체국택배',
'11' => '경동택배',
'13' => '일양로지스',
'14' => '합동택배',
'20' => '드림택배',
'23' => '천일택배',
'26' => '건영택배',
'40' => '기타',
];
public function __construct(
private readonly NhnKcpApiService $apiService,
) {
parent::__construct();
}
/**
* 배송등록 폼 데이터 반환 (주소 자동완성 및 기등록 배송 이력 포함)
*/
/**
* 에스크로 배송 등록 폼 초기 데이터 조회
*
* 어드민 에스크로 배송 등록 화면에서 주문 정보 + 기본 배송지/수령인을 미리 채울 수 있도록 반환.
*
* @param string $orderNumber 주문번호
* @return JsonResponse 폼 초기 데이터 또는 404
*/
public function formData(string $orderNumber): JsonResponse
{
$payment = $this->findEscrowPayment($orderNumber);
if (! $payment) {
return ResponseHelper::success('messages.success', null);
}
$address = DB::table('ecommerce_order_addresses as a')
->join('ecommerce_orders as o', 'o.id', '=', 'a.order_id')
->where('o.order_number', $orderNumber)
->where('a.address_type', 'shipping')
->select([
'a.recipient_name',
'a.recipient_phone',
'a.zipcode',
'a.address',
'a.address_detail',
])
->first();
$meta = $payment->payment_meta ? json_decode($payment->payment_meta, true) : [];
$escrowDelivery = $meta['escrow_delivery'] ?? null;
return ResponseHelper::success('messages.success', [
'has_escrow_payment' => true,
'tno' => $payment->transaction_id,
'courier_codes' => self::COURIER_CODES,
'prefill' => [
'recv_name' => $address?->recipient_name ?? '',
'recv_tel' => $address?->recipient_phone ?? '',
'recv_post' => $address?->zipcode ?? '',
'recv_addr' => trim(($address?->address ?? '') . ' ' . ($address?->address_detail ?? '')),
],
'registered_delivery' => $escrowDelivery,
]);
}
/**
* KCP에 배송정보 등록 (CLI mod_type=STE1)
*/
/**
* 에스크로 배송 등록 API 호출
*
* KCP 에스크로 API 로 배송 정보(택배사/송장번호/수령인 등) 등록. 등록 완료 시 구매확정 안내 자동 발송.
*
* @param Request $request 배송 정보 폼
* @param string $orderNumber 주문번호
* @return JsonResponse 등록 결과
*/
public function register(Request $request, string $orderNumber): JsonResponse
{
$deliNumb = trim((string) $request->input('deli_numb', ''));
$deliCorp = trim((string) $request->input('deli_corp', ''));
if ($deliNumb === '') {
return ResponseHelper::error('messages.failed', 422, ['deli_numb' => ['운송장번호를 입력해주세요.']]);
}
if ($deliCorp === '' || ! array_key_exists($deliCorp, self::COURIER_CODES)) {
return ResponseHelper::error('messages.failed', 422, ['deli_corp' => ['택배사를 선택해주세요.']]);
}
$payment = $this->findEscrowPayment($orderNumber);
if (! $payment) {
return ResponseHelper::error('messages.failed', 404, null);
}
Log::info('KCP: escrow delivery register requested', [
'order_number' => $orderNumber,
'tno' => $payment->transaction_id,
'deli_numb' => $deliNumb,
'deli_corp' => $deliCorp,
]);
try {
$pgResponse = $this->apiService->registerEscrowDelivery(
tno: $payment->transaction_id,
ordrIdxx: $orderNumber,
deliNumb: $deliNumb,
deliCorp: $deliCorp,
);
$courierName = self::COURIER_CODES[$deliCorp];
$meta = $payment->payment_meta ? json_decode($payment->payment_meta, true) : [];
$meta['escrow_delivery'] = [
'registered_at' => now()->toDateTimeString(),
'deli_numb' => $deliNumb,
'deli_corp' => $deliCorp,
'courier_name' => $courierName,
'pg_response_sanitized' => true,
'pg_response' => $this->sanitizePgResponse($pgResponse, self::ESCROW_DELIVERY_RESPONSE_KEYS),
];
DB::table('ecommerce_order_payments')
->where('id', $payment->id)
->update([
'payment_meta' => json_encode($meta, JSON_UNESCAPED_UNICODE),
'updated_at' => now(),
]);
Log::info('KCP: escrow delivery registered', [
'order_number' => $orderNumber,
'tno' => $payment->transaction_id,
'deli_numb' => $deliNumb,
'courier_name' => $courierName,
]);
return ResponseHelper::success('messages.success', [
'res_cd' => $pgResponse['res_cd'] ?? '0000',
'deli_numb' => $deliNumb,
'courier_name' => $courierName,
]);
} catch (\Exception $e) {
Log::error('KCP: escrow delivery register exception', [
'order_number' => $orderNumber,
'error' => $e->getMessage(),
]);
return ResponseHelper::error('messages.failed', 500, [
'message' => [$e->getMessage()],
]);
}
}
private function findEscrowPayment(string $orderNumber): ?object
{
return DB::table('ecommerce_order_payments as p')
->join('ecommerce_orders as o', 'o.id', '=', 'p.order_id')
->where('o.order_number', $orderNumber)
->where('p.pg_provider', 'nhnkcp')
->where('p.is_escrow', true)
->whereNotNull('p.transaction_id')
->select([
'p.id',
'p.transaction_id',
'p.paid_amount_local',
'p.payment_meta',
])
->first();
}
}
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Controllers;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Services\PluginSettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Plugins\Sirsoft\PayNhnkcp\Concerns\ResolvesEasyPayDisplay;
class AdminOrderListController extends AdminBaseController
{
use ResolvesEasyPayDisplay;
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
private bool $isTest;
public function __construct(PluginSettingsService $pluginSettingsService)
{
parent::__construct();
$settings = $pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
$this->isTest = $settings['is_test_mode'] ?? true;
}
/**
* NHN KCP 테스트 모드 주문 맵 반환
*
* 현재 플러그인이 테스트 모드이면 최근 6개월 nhnkcp 전체 주문을 테스트로 간주한다.
* 라이브 모드이면 payment_meta.is_test_mode = true 인 주문만 반환한다.
*
* @return JsonResponse 테스트 모드 주문 맵 { "order_number": true, ... }
*/
public function testModeMap(): JsonResponse
{
$query = DB::table('ecommerce_orders as o')
->join('ecommerce_order_payments as p', 'p.order_id', '=', 'o.id')
->where('p.pg_provider', 'nhnkcp')
->where('p.created_at', '>=', now()->subMonths(6));
if ($this->isTest) {
$orderNumbers = $query->pluck('o.order_number');
$map = array_fill_keys($orderNumbers->all(), true);
} else {
$rows = $query
->whereNotNull('p.payment_meta')
->select(['o.order_number', 'p.payment_meta'])
->get();
$map = [];
foreach ($rows as $row) {
$meta = json_decode($row->payment_meta, true) ?? [];
if ($meta['is_test_mode'] ?? false) {
$map[$row->order_number] = true;
}
}
}
return ResponseHelper::success('messages.success', $map);
}
/**
* NHN KCP 간편결제 표시 맵 반환
*
* 관리자 주문목록은 코어 OrderPaymentResource 의 기본 payment_method_label 을 사용하므로,
* 플러그인 데이터소스로 간편결제 원 결제수단 표시명을 보강한다.
*
* @return JsonResponse 주문번호별 표시 맵
*/
public function easyPayDisplayMap(): JsonResponse
{
$rows = DB::table('ecommerce_orders as o')
->join('ecommerce_order_payments as p', 'p.order_id', '=', 'o.id')
->where('p.pg_provider', 'nhnkcp')
->where('p.created_at', '>=', now()->subMonths(6))
->select([
'o.order_number',
'p.payment_method',
'p.embedded_pg_provider',
'p.payment_meta',
])
->get();
$map = [];
foreach ($rows as $row) {
$display = $this->resolvePaymentDisplay($row);
if (($display['embedded_pg_provider_label'] ?? null) === null) {
continue;
}
$map[$row->order_number] = [
'payment_method_label' => $display['payment_method_label'],
'payment_method_display_label' => $display['payment_method_display_label'],
'embedded_pg_provider' => $display['embedded_pg_provider'],
'embedded_pg_provider_label' => $display['embedded_pg_provider_label'],
];
}
return ResponseHelper::success('messages.success', $map);
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Controllers;
use App\Helpers\ResponseHelper;
use App\Services\PluginSettingsService;
use Illuminate\Http\JsonResponse;
class AdminSettingsStatusController
{
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
public function __construct(private readonly PluginSettingsService $settingsService) {}
/**
* 테스트 모드 활성 여부만 반환한다.
*
* @return JsonResponse
*/
public function testMode(): JsonResponse
{
return ResponseHelper::success('messages.success', [
'is_test_mode' => (bool) $this->settingsService->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true),
]);
}
}
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Controllers;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Services\PluginSettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\RefundStatusEnum;
use Plugins\Sirsoft\PayNhnkcp\Concerns\ResolvesEasyPayDisplay;
/**
* NHN KCP 거래 정보 관리자 컨트롤러
*
* KCP는 CLI 바이너리 방식으로 승인이 이루어지므로 웹 API 실시간 조회가 불가합니다.
* DB에 저장된 거래 정보(payment_meta)를 반환합니다.
*/
class AdminTransactionController extends AdminBaseController
{
use ResolvesEasyPayDisplay;
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
public function __construct(
private readonly PluginSettingsService $pluginSettingsService,
) {
parent::__construct();
}
/**
* GET /api/plugins/sirsoft-pay_nhnkcp/admin/orders/{orderNumber}/transaction-status
*/
/**
* 주문번호로 KCP 거래 조회
*
* 어드민 주문 상세에서 거래 조회 버튼 클릭 시 사용. ecommerce_order_payments
* 의 transaction_id (KCP tno) 를 찾아 KCP 단건 거래 조회 API 호출.
*
* @param string $orderNumber 주문번호
* @return JsonResponse 거래 정보 또는 매핑 없을 시 null
*/
public function queryByOrder(string $orderNumber): JsonResponse
{
$payment = DB::table('ecommerce_order_payments as p')
->join('ecommerce_orders as o', 'o.id', '=', 'p.order_id')
->where('o.order_number', $orderNumber)
->where('p.pg_provider', 'nhnkcp')
->whereNotNull('p.transaction_id')
->where('p.transaction_id', '!=', '')
->select([
'p.order_id',
'p.transaction_id',
'p.payment_meta',
'p.payment_method',
'p.embedded_pg_provider',
'p.payment_status',
'p.cancelled_amount',
'p.cancelled_at',
'p.cancel_history',
'p.currency',
])
->first();
if (! $payment) {
return ResponseHelper::success('messages.success', null);
}
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
$isTest = (bool) ($settings['is_test_mode'] ?? true);
$meta = $this->decodePaymentMeta($payment->payment_meta ?? null);
$rawResponse = $meta['pg_raw_response'] ?? [];
$display = $this->resolvePaymentDisplay($payment);
$paymentStatus = is_string($payment->payment_status ?? null)
? PaymentStatusEnum::tryFrom($payment->payment_status)
: null;
$refund = $this->latestRefundForOrder((int) $payment->order_id);
$refundStatus = is_string($refund->refund_status ?? null)
? RefundStatusEnum::tryFrom($refund->refund_status)
: null;
$cancelledAmount = (float) ($payment->cancelled_amount ?? 0);
$refundAmount = $refund ? (float) ($refund->refund_amount ?? 0) : 0.0;
return ResponseHelper::success('messages.success', [
'tno' => $payment->transaction_id,
'app_no' => $rawResponse['app_no'] ?? $meta['app_no'] ?? null,
'use_pay_method' => $meta['use_pay_method'] ?? $rawResponse['use_pay_method'] ?? null,
'app_time' => $meta['app_time'] ?? $rawResponse['app_time'] ?? null,
'res_cd' => $meta['res_cd'] ?? $rawResponse['res_cd'] ?? '0000',
'card_name' => $rawResponse['card_name'] ?? $meta['card_name'] ?? $rawResponse['bank_name'] ?? $meta['bank_name'] ?? null,
'account' => $meta['account'] ?? null,
'bank_name' => $rawResponse['bank_name'] ?? $meta['bank_name'] ?? null,
'_is_test_mode' => $isTest,
'payment_status' => $paymentStatus?->value ?? $payment->payment_status,
'payment_status_label' => $paymentStatus?->label(),
'payment_status_variant' => $paymentStatus?->variant(),
'cancelled_amount' => $cancelledAmount,
'cancelled_amount_formatted' => $this->formatKrwAmount($cancelledAmount),
'cancelled_at' => $payment->cancelled_at,
'cancel_history' => $this->decodeJsonArray($payment->cancel_history ?? null),
'refund_number' => $refund->refund_number ?? null,
'refund_status' => $refundStatus?->value ?? ($refund->refund_status ?? null),
'refund_status_label' => $refundStatus?->label(),
'refund_status_variant' => $refundStatus?->variant(),
'refund_amount' => $refundAmount,
'refund_amount_formatted' => $this->formatKrwAmount($refundAmount),
'refunded_at' => $refund->refunded_at ?? null,
'refund_pg_transaction_id' => $refund->pg_transaction_id ?? null,
'_base_pay_method_label' => $display['payment_method_label'],
'_embedded_pg_provider' => $display['embedded_pg_provider'],
'_embedded_pg_provider_label' => $display['embedded_pg_provider_label'],
'_pay_method_label' => $display['payment_method_display_label'],
'payment_method_display_label' => $display['payment_method_display_label'],
]);
}
private function latestRefundForOrder(int $orderId): ?object
{
return DB::table('ecommerce_order_refunds')
->where('order_id', $orderId)
->orderByDesc('id')
->select([
'refund_number',
'refund_status',
'refund_amount',
'pg_transaction_id',
'refunded_at',
])
->first();
}
private function formatKrwAmount(float $amount): string
{
return number_format((int) round($amount)).'원';
}
/**
* @return array<int|string, mixed>
*/
private function decodeJsonArray(mixed $value): array
{
if (is_array($value)) {
return $value;
}
if (! is_string($value) || trim($value) === '') {
return [];
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
}
@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Controllers;
use App\Extension\HookManager;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Plugins\Sirsoft\PayNhnkcp\Concerns\SendsKcpNotifyResponse;
use Plugins\Sirsoft\PayNhnkcp\Http\Requests\EscrowCommonNotifyRequest;
/**
* KCP 에스크로 공통통보 컨트롤러
*
* POST /plugins/sirsoft-pay_nhnkcp/payment/escrow-common-notify
* KCP 가맹점 어드민에서 공통통보 URL로 등록해야 합니다.
*
* tx_cd 별 처리 (그누보드5 settle_kcp_common.php 참고):
* TX02 + cl_status=2 → 구매확인
* TX02 + cl_status=8 → 구매취소
* TX02 + cl_status=3 → 구매취소 확인
* TX03 → 배송시작 통보
*
* 응답: <form><input name="result" value="0000"> HTML (SendsKcpNotifyResponse trait)
* IP 가드: RestrictKcpIp 미들웨어 (routes/web.php)
*/
class EscrowCommonNotifyController
{
use SendsKcpNotifyResponse;
public function __construct(
private readonly OrderProcessingService $orderService,
) {}
/**
* KCP 에스크로 통보 처리 (공통 webhook)
*
* @param EscrowCommonNotifyRequest $request 검증된 통보 페이로드 (order_no, tx_cd, cl_status 등)
* @return Response 200 + KCP 표준 result HTML
*/
public function handle(EscrowCommonNotifyRequest $request): Response
{
$validated = $request->validated();
$txCd = (string) $validated['tx_cd'];
$tno = (string) ($validated['tno'] ?? '');
$orderNo = (string) $validated['order_no'];
$clStatus = (string) ($validated['cl_status'] ?? '');
Log::info('KCP: escrow common notify received', [
'tx_cd' => $txCd,
'tno' => $tno,
'order_no' => $orderNo,
'cl_status' => $clStatus,
]);
try {
$order = $this->orderService->findByOrderNumber($orderNo);
if (! $order) {
Log::error('KCP: escrow common notify - order not found', ['order_no' => $orderNo]);
// 영구 실패 — result=0000 으로 재통보 차단
return $this->kcpNotifyResponse();
}
$contextError = $this->validateEscrowNotifyContext($order, $validated, $tno);
if ($contextError !== null) {
Log::warning('KCP: escrow common notify context mismatch — retry requested', [
'reason' => $contextError,
'tx_cd' => $txCd,
'tno' => $tno,
'order_no' => $orderNo,
]);
return $this->kcpNotifyRetry();
}
$fingerprint = $this->escrowNotifyFingerprint($validated);
if ($this->wasEscrowNotifyAlreadyProcessed($order, $fingerprint)) {
Log::info('KCP: escrow common notify replay ignored', [
'tx_cd' => $txCd,
'tno' => $tno,
'order_no' => $orderNo,
'cl_status' => $clStatus,
]);
return $this->kcpNotifyResponse();
}
match ($txCd) {
'TX02' => match ($clStatus) {
'2' => HookManager::doAction('sirsoft-pay_nhnkcp.escrow.purchase_confirmed', $order, $validated),
'8' => HookManager::doAction('sirsoft-pay_nhnkcp.escrow.purchase_cancelled', $order, $validated),
'3' => HookManager::doAction('sirsoft-pay_nhnkcp.escrow.denial_confirmed', $order, $validated),
default => Log::info('KCP: escrow TX02 unknown cl_status', ['cl_status' => $clStatus, 'order_no' => $orderNo]),
},
'TX03' => HookManager::doAction('sirsoft-pay_nhnkcp.escrow.delivery_started', $order, $validated),
default => Log::info('KCP: escrow common notify - unhandled tx_cd', ['tx_cd' => $txCd, 'order_no' => $orderNo]),
};
$this->recordEscrowNotifyProcessed($order, $fingerprint);
Log::info('KCP: escrow common notify processed', [
'tx_cd' => $txCd,
'order_no' => $orderNo,
'cl_status' => $clStatus,
]);
} catch (\Exception $e) {
Log::error('KCP: escrow common notify failed', [
'tx_cd' => $txCd,
'order_no' => $orderNo,
'error' => $e->getMessage(),
]);
// 일시적 실패 (DB 등) — result != 0000 으로 KCP 재통보 유도
return $this->kcpNotifyRetry();
}
return $this->kcpNotifyResponse();
}
/**
* 에스크로 공통통보가 실제 결제 건과 같은 거래인지 확인한다.
*
* 현재 이 컨트롤러는 hook dispatch 만 하지만, 향후 listener 가 주문 상태를 바꿀 수 있으므로
* 주문번호 단독 신뢰를 피하고 저장된 tno/site_cd/escrow 플래그를 먼저 확인한다.
*/
private function validateEscrowNotifyContext(Order $order, array $validated, string $tno): ?string
{
$payment = $order->payment;
if (! $payment) {
return 'payment_not_found';
}
if ($payment->pg_provider !== 'nhnkcp') {
return 'pg_provider_mismatch';
}
if (! (bool) $payment->is_escrow) {
return 'not_escrow_payment';
}
$storedTno = trim((string) ($payment->transaction_id ?? ''));
if ($storedTno === '' || $tno === '' || ! hash_equals($storedTno, $tno)) {
return 'tno_mismatch';
}
$meta = $payment->payment_meta ?? [];
$storedSiteCd = trim((string) ($meta['site_cd'] ?? ''));
$receivedSiteCd = trim((string) ($validated['site_cd'] ?? ''));
if ($storedSiteCd !== '' && $receivedSiteCd !== '' && ! hash_equals($storedSiteCd, $receivedSiteCd)) {
return 'site_cd_mismatch';
}
return null;
}
private function escrowNotifyFingerprint(array $validated): string
{
$keys = [
'site_cd',
'tno',
'order_no',
'tx_cd',
'tx_tm',
'cl_status',
'st_cd',
'can_msg',
'waybill_no',
'waybill_corp',
];
$payload = [];
foreach ($keys as $key) {
$payload[$key] = (string) ($validated[$key] ?? '');
}
return hash('sha256', json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
private function wasEscrowNotifyAlreadyProcessed(Order $order, string $fingerprint): bool
{
$payment = $order->payment;
$meta = $payment?->payment_meta ?? [];
$processed = $meta['escrow_common_notify_fingerprints'] ?? [];
$processed = is_array($processed) ? $processed : [];
return in_array($fingerprint, $processed, true);
}
private function recordEscrowNotifyProcessed(Order $order, string $fingerprint): void
{
$payment = $order->payment;
if (! $payment) {
return;
}
$meta = $payment->payment_meta ?? [];
$processed = $meta['escrow_common_notify_fingerprints'] ?? [];
$processed = is_array($processed) ? $processed : [];
$processed[] = $fingerprint;
$meta['escrow_common_notify_fingerprints'] = array_values(array_unique(array_slice($processed, -20)));
$payment->update(['payment_meta' => $meta]);
}
}
@@ -0,0 +1,444 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Http;
/**
* NHN KCP 플러그인 시스템 점검 컨트롤러
*
* 관리자 설정 페이지에서 호출되어:
* - PC 결제 (Standard Pay CLI 바이너리) 실행 환경
* - 모바일 결제 (SmartPhone Pay SOAP 호출) 실행 환경
* 두 가지 사전조건을 진단하고, 자동 복구 가능한 항목(예: chmod +x)은
* 즉시 수정한 뒤 결과를 반환한다.
*
* 자동 복구 불가능한 항목(php-soap 미설치, exec() disabled 등)은
* "사용자 조치 안내" 메시지(remediation)를 함께 반환한다.
*/
class HealthCheckController
{
private const STATUS_OK = 'ok'; // 정상
private const STATUS_FIXED = 'fixed'; // 자동 복구됨
private const STATUS_WARNING = 'warning'; // 동작 가능하나 권장 상태 아님
private const STATUS_ERROR = 'error'; // 사용자 조치 필요
private string $binDir;
public function __construct()
{
$this->binDir = dirname(__DIR__, 2) . '/bin';
}
/**
* GET /api/plugins/sirsoft-pay_nhnkcp/admin/health
*
* @return JsonResponse{success: true, data: {summary: array, checks: array}}
*/
public function check(): JsonResponse
{
// 현재 OS/아키텍처에 해당하는 CLI 바이너리만 체크 — NhnKcpApiService::executeCli() 와 동일 기준
[$cliFilename, $cliArch, $cliRequireExec] = $this->resolveCliForCurrentOs();
$checks = [
$this->checkExecFunction(),
$this->checkBinDirectory(),
$this->checkCliBinary($cliFilename, $cliArch, $cliRequireExec),
$this->checkPubKey(),
$this->checkSoapExtension(),
$this->checkWsdlFile(),
];
$summary = array_merge(
$this->summarize($checks, $cliFilename),
['cancel_server_ip' => $this->resolveCancelServerIp()]
);
return response()->json([
'success' => true,
'data' => [
'summary' => $summary,
'checks' => $checks,
],
]);
}
/**
* 현재 PHP 런타임의 OS/아키텍처에 맞는 CLI 바이너리 정보 반환
*
* @return array{0: string, 1: string, 2: bool} [filename, arch label, requireExec]
*/
private function resolveCliForCurrentOs(): array
{
// Windows
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
// Windows 는 ACL 기반이라 ELF 의 +x 비트 검사를 적용하지 않음
return ['pp_cli_exe.exe', 'Windows', false];
}
// Linux 32-bit (PHP_INT_MAX == 2^31-1)
if (PHP_INT_MAX === 2147483647) {
return ['pp_cli', 'Linux 32-bit', true];
}
// Linux 64-bit (기본)
return ['pp_cli_x64', 'Linux 64-bit', true];
}
/**
* 상태 통계: 카테고리별로 PC/모바일 결제 가능 여부 판단
*
* @param array<int, array<string, mixed>> $checks
* @param string $cliFilename 현재 OS 의 CLI 바이너리 파일명 (cli_id 계산용)
*/
private function summarize(array $checks, string $cliFilename): array
{
$byId = [];
foreach ($checks as $c) {
$byId[$c['id']] = $c;
}
$execOk = ! $this->isErroneous($byId['exec_function'] ?? null);
// 현재 OS 의 CLI 바이너리 ID 동적 계산 (checkCliBinary 와 동일 규칙)
$cliId = 'cli_' . preg_replace('/[^a-z0-9_]/i', '_', strtolower(pathinfo($cliFilename, PATHINFO_FILENAME)));
$cliOk = ! $this->isErroneous($byId[$cliId] ?? null);
$pubKeyOk = ! $this->isErroneous($byId['pub_key'] ?? null);
$soapOk = ! $this->isErroneous($byId['soap_extension'] ?? null);
$wsdlOk = ! $this->isErroneous($byId['wsdl_file'] ?? null);
// PC 표준결제: CLI 바이너리로 enc_data 복호화 필수
$pcReady = $execOk && $cliOk && $pubKeyOk;
// 모바일 카드/일반결제: KCP 가 콜백에 enc_data 를 동봉 → 서버가 CLI 로 복호화 필요.
// SOAP 만으로는 부족. PC 와 동일한 CLI 요건 + 모바일 전용 SOAP/WSDL 모두 필요.
$mobileCardReady = $execOk && $cliOk && $pubKeyOk && $soapOk && $wsdlOk;
// 모바일 가상계좌 전용: KCP 가 평문 필드(bankname/account/depositor)로 콜백 → CLI 불필요.
// SOAP/WSDL 만 OK 여도 가상계좌는 동작 가능.
$mobileVbankOnlyReady = $soapOk && $wsdlOk;
$errorCount = 0;
$warningCount = 0;
$fixedCount = 0;
foreach ($checks as $c) {
match ($c['status']) {
self::STATUS_ERROR => $errorCount++,
self::STATUS_WARNING => $warningCount++,
self::STATUS_FIXED => $fixedCount++,
default => null,
};
}
return [
'pc_payment_ready' => $pcReady,
// 'mobile_payment_ready' 는 카드/일반결제 기준(가장 흔한 케이스). 가상계좌는 별도 필드.
'mobile_payment_ready' => $mobileCardReady,
'mobile_vbank_only_ready' => $mobileVbankOnlyReady,
'error_count' => $errorCount,
'warning_count' => $warningCount,
'fixed_count' => $fixedCount,
];
}
private function isErroneous(?array $check): bool
{
return ($check['status'] ?? self::STATUS_ERROR) === self::STATUS_ERROR;
}
/**
* KCP 상점관리자 결제서버 IP 설정에 등록할 서버 IP 후보를 반환한다.
*
* 실제 취소 요청은 서버에서 KCP로 나가므로 공인 outbound IP가 가장 정확하다.
* 외부 조회가 실패하면 PHP 서버 변수/호스트명에서 확인 가능한 IP를 fallback으로 제공한다.
*
* @return array{address: string|null, source: string, source_label: string}
*/
private function resolveCancelServerIp(): array
{
$publicIp = $this->detectPublicOutboundIp();
if ($publicIp !== null) {
return [
'address' => $publicIp,
'source' => 'public_outbound',
'source_label' => '공인 송신 IP',
];
}
$serverIp = $this->detectServerVariableIp();
if ($serverIp !== null) {
return [
'address' => $serverIp,
'source' => 'server_variable',
'source_label' => '서버 IP 후보',
];
}
return [
'address' => null,
'source' => 'unavailable',
'source_label' => '확인 필요',
];
}
private function detectPublicOutboundIp(): ?string
{
try {
$response = Http::timeout(2)
->acceptJson()
->get('https://api.ipify.org', ['format' => 'json']);
if (! $response->ok()) {
return null;
}
$ip = trim((string) ($response->json('ip') ?? ''));
return $this->normalizeIp($ip);
} catch (\Throwable) {
return null;
}
}
private function detectServerVariableIp(): ?string
{
$candidates = [
(string) request()->server('SERVER_ADDR', ''),
(string) ($_SERVER['SERVER_ADDR'] ?? ''),
(string) request()->server('LOCAL_ADDR', ''),
(string) ($_SERVER['LOCAL_ADDR'] ?? ''),
gethostbyname((string) gethostname()),
];
foreach ($candidates as $candidate) {
$ip = $this->normalizeIp($candidate);
if ($ip !== null) {
return $ip;
}
}
return null;
}
private function normalizeIp(?string $ip): ?string
{
$ip = trim((string) $ip);
if ($ip === '') {
return null;
}
return filter_var($ip, FILTER_VALIDATE_IP) !== false ? $ip : null;
}
/**
* PHP `exec()` 함수 활성화 여부 — disable_functions 에서 차단되면 CLI 호출 불가
*/
private function checkExecFunction(): array
{
$disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
$isDisabled = in_array('exec', $disabled, true) || ! function_exists('exec');
if ($isDisabled) {
return [
'id' => 'exec_function',
'category' => 'pc',
'label' => 'PHP exec() 함수',
'status' => self::STATUS_ERROR,
'detail' => 'php.ini 의 disable_functions 에서 exec 가 차단되어 있습니다.',
'remediation' => "php.ini 에서 disable_functions 목록에서 'exec' 를 제거한 뒤 PHP-FPM(또는 Apache) 을 재시작하세요.\n예: sudo sed -i 's/disable_functions = .*exec.*/disable_functions =/' /etc/php/8.x/fpm/php.ini && sudo systemctl restart php8.x-fpm",
];
}
return [
'id' => 'exec_function',
'category' => 'pc',
'label' => 'PHP exec() 함수',
'status' => self::STATUS_OK,
'detail' => 'CLI 호출 가능',
];
}
/**
* bin 디렉토리 존재 여부만 검사 — 쓰기 권한은 CLI 바이너리 단위로 처리
*/
private function checkBinDirectory(): array
{
if (! is_dir($this->binDir)) {
return [
'id' => 'bin_directory',
'category' => 'pc',
'label' => 'CLI 바이너리 디렉토리',
'status' => self::STATUS_ERROR,
'detail' => "bin 디렉토리가 없습니다: {$this->binDir}",
'remediation' => '플러그인을 재설치하거나, KCP 에서 제공한 CLI 바이너리를 bin/ 에 복사하세요.',
];
}
return [
'id' => 'bin_directory',
'category' => 'pc',
'label' => 'CLI 바이너리 디렉토리',
'status' => self::STATUS_OK,
'detail' => '디렉토리 존재',
];
}
/**
* CLI 바이너리: 존재 + (Linux는) 실행 권한 — 권한 없으면 자동 chmod +x 시도
*/
private function checkCliBinary(string $filename, string $arch, bool $requireExec = true): array
{
$path = $this->binDir . '/' . $filename;
$id = 'cli_' . preg_replace('/[^a-z0-9_]/i', '_', strtolower(pathinfo($filename, PATHINFO_FILENAME)));
$label = "CLI 바이너리 ({$arch}) — {$filename}";
if (! file_exists($path)) {
return [
'id' => $id,
'category' => 'pc',
'label' => $label,
'status' => self::STATUS_ERROR,
'detail' => "파일이 없습니다: {$path}",
'remediation' => "현재 OS({$arch})에 해당하는 KCP CLI 바이너리를 bin/ 에 복사하세요. (테스트 환경은 플러그인 기본 제공)",
];
}
if (! $requireExec) {
return [
'id' => $id,
'category' => 'pc',
'label' => $label,
'status' => self::STATUS_OK,
'detail' => 'Windows 바이너리 — 실행 권한 검사 생략',
];
}
if (is_executable($path)) {
return [
'id' => $id,
'category' => 'pc',
'label' => $label,
'status' => self::STATUS_OK,
'detail' => '실행 권한 OK (' . substr(sprintf('%o', fileperms($path)), -4) . ')',
];
}
// 자동 복구: chmod +x 시도
$beforeMode = substr(sprintf('%o', fileperms($path)), -4);
$chmodOk = @chmod($path, 0755);
if ($chmodOk && is_executable($path)) {
return [
'id' => $id,
'category' => 'pc',
'label' => $label,
'status' => self::STATUS_FIXED,
'detail' => "실행 권한 자동 복구됨: {$beforeMode} → 0755",
];
}
return [
'id' => $id,
'category' => 'pc',
'label' => $label,
'status' => self::STATUS_ERROR,
'detail' => "실행 권한 없음 ({$beforeMode}) — 자동 복구 실패",
'remediation' => "이 파일에 실행 권한을 부여하세요:\n chmod 755 {$path}\n파일 소유자가 아니면 앞에 sudo 를 붙이세요.",
];
}
/**
* pub.key 파일 존재 — KCP CLI 가 결제 응답 복호화에 사용
*/
private function checkPubKey(): array
{
$path = $this->binDir . '/pub.key';
if (! file_exists($path)) {
return [
'id' => 'pub_key',
'category' => 'pc',
'label' => 'KCP 공개키 (pub.key)',
'status' => self::STATUS_ERROR,
'detail' => "pub.key 파일이 없습니다: {$path}",
'remediation' => 'KCP 에서 제공받은 pub.key 파일을 bin/ 에 복사하세요. (테스트 환경은 플러그인 기본 제공)',
];
}
return [
'id' => 'pub_key',
'category' => 'pc',
'label' => 'KCP 공개키 (pub.key)',
'status' => self::STATUS_OK,
'detail' => '파일 존재',
];
}
/**
* PHP SOAP 확장 — 모바일 SmartPhone Pay 의 approval_key 획득에 필수
*/
private function checkSoapExtension(): array
{
if (extension_loaded('soap') && class_exists(\SoapClient::class)) {
return [
'id' => 'soap_extension',
'category' => 'mobile',
'label' => 'PHP SOAP 확장',
'status' => self::STATUS_OK,
'detail' => 'SoapClient 사용 가능',
];
}
return [
'id' => 'soap_extension',
'category' => 'mobile',
'label' => 'PHP SOAP 확장',
'status' => self::STATUS_ERROR,
'detail' => '모바일 결제 (SmartPhone Pay) 가 동작하지 않습니다.',
'remediation' => "PHP SOAP 확장을 설치하고 PHP-FPM 을 재시작하세요:\n"
. "Ubuntu/Debian: sudo apt install php8.x-soap && sudo systemctl restart php8.x-fpm\n"
. "RHEL/CentOS: sudo yum install php-soap && sudo systemctl restart php-fpm\n"
. "확인: php -m | grep soap",
];
}
/**
* WSDL 파일 — bin/ 에 KCPPaymentService.wsdl, real_KCPPaymentService.wsdl 필요
*/
private function checkWsdlFile(): array
{
$wsdlFiles = ['KCPPaymentService.wsdl', 'real_KCPPaymentService.wsdl'];
$missing = [];
foreach ($wsdlFiles as $f) {
if (! file_exists($this->binDir . '/' . $f)) {
$missing[] = $f;
}
}
if (! empty($missing)) {
return [
'id' => 'wsdl_file',
'category' => 'mobile',
'label' => 'WSDL 파일',
'status' => self::STATUS_ERROR,
'detail' => '누락: ' . implode(', ', $missing),
'remediation' => 'KCP 에서 제공받은 WSDL 파일을 bin/ 에 복사하세요. (테스트/라이브 별로 두 개)',
];
}
return [
'id' => 'wsdl_file',
'category' => 'mobile',
'label' => 'WSDL 파일',
'status' => self::STATUS_OK,
'detail' => '테스트/라이브 WSDL 모두 존재',
];
}
}
@@ -0,0 +1,286 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Plugins\Sirsoft\PayNhnkcp\Concerns\RecordsPaymentWindowClosure;
use Plugins\Sirsoft\PayNhnkcp\Exceptions\NhnKcpApiException;
use Plugins\Sirsoft\PayNhnkcp\Services\KcpSoapService;
/**
* KCP SmartPhone Pay 승인키 컨트롤러
*
* PC Standard Pay(iframe)와 달리 모바일은 서버에서 SOAP으로 approval_key 를
* 먼저 획득하고, 브라우저가 pay_url 로 form POST(페이지 전환)한다.
*/
class MobileApprovalController
{
use RecordsPaymentWindowClosure;
/** 결제수단 → KCP 모바일 pay_method 코드 */
private const MOBILE_PAY_METHOD_MAP = [
'card' => 'CARD',
'bank_transfer' => 'BANK',
'virtual_account' => 'VCNT',
'mobile' => 'MOBX',
'bank' => 'BANK',
'vbank' => 'VCNT',
'phone' => 'MOBX',
];
/** KCP 모바일 pay_method → ActionResult (form hidden field) */
private const ACTION_RESULT_MAP = [
'CARD' => 'card',
'BANK' => 'acnt',
'MOBX' => 'mobx',
'VCNT' => 'vcnt',
];
/** 간편결제 direct 파라미터 기본값 (매 결제 시 초기화) */
private const EASY_PAY_DIRECT_DEFAULTS = [
'payco_direct' => '',
'naverpay_direct' => 'A',
'kakaopay_direct' => 'A',
'applepay_direct' => 'A',
];
/** 간편결제별 direct 파라미터 override (기본값 위에 덮어씀) */
private const EASY_PAY_DIRECT_FIELDS = [
'nhnkcp_payco' => ['payco_direct' => 'Y'],
'nhnkcp_naverpay' => ['naverpay_direct' => 'Y'],
'nhnkcp_naverpay_point' => ['naverpay_direct' => 'Y', 'naverpay_point_direct' => 'Y'],
'nhnkcp_kakaopay' => ['kakaopay_direct' => 'Y'],
'nhnkcp_applepay' => ['applepay_direct' => 'Y'],
];
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
public function __construct(
private readonly KcpSoapService $soapService,
private readonly OrderProcessingService $orderService,
) {}
/**
* KCP 모바일 결제 승인키 획득
*
* POST /api/plugins/sirsoft-pay_nhnkcp/mobile/approval-key
*
* @return JsonResponse{ success: true, data: { pay_url, fields } }
*/
/**
* 모바일 결제 승인키 발급 (SOAP)
*
* KCP 모바일 결제창 진입 전 SOAP 으로 승인키 + payUrl 발급.
*
* @param Request $request 주문/결제 메타
* @return JsonResponse approval_key + pay_url
*/
public function getApprovalKey(Request $request): JsonResponse
{
$validated = $request->validate([
'order_number' => ['required', 'string', 'max:40'],
'amount' => ['required', 'integer', 'min:100'],
'good_name' => ['required', 'string', 'max:100'],
'pay_method' => ['required', 'string'],
'buyr_name' => ['nullable', 'string', 'max:50'],
'buyr_mail' => ['nullable', 'string', 'email', 'max:100'],
'buyr_tel1' => ['nullable', 'string', 'max:20'],
'ret_url' => ['required', 'string', 'url'],
'currency' => ['nullable', 'string', 'size:3'],
]);
$payMethodKey = strtolower($validated['pay_method']);
$isEasyPay = str_starts_with($payMethodKey, 'nhnkcp_');
$mobilePayMethod = $isEasyPay
? 'CARD'
: (self::MOBILE_PAY_METHOD_MAP[$payMethodKey] ?? 'CARD');
$settings = plugin_settings(self::PLUGIN_IDENTIFIER) ?? [];
$useEscrow = (bool) ($settings['use_escrow'] ?? false) && ! $isEasyPay;
try {
$order = $this->orderService->findByOrderNumber($validated['order_number']);
if (! $order) {
return response()->json([
'success' => false,
'error' => 'Order not found.',
], 404);
}
if ((int) $order->user_id !== (int) ($request->user()?->id ?? 0)) {
return response()->json([
'success' => false,
'error' => 'You cannot request a NHN KCP approval key for this order.',
], 403);
}
$expectedAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'mobile_approval_key', [
'order_number' => $validated['order_number'],
'requested_amount' => $validated['amount'],
'requested_currency' => $validated['currency'] ?? null,
'ip' => $request->ip(),
]);
if ($expectedAmount === null) {
return response()->json([
'success' => false,
'error' => 'Payment currency is not chargeable.',
], 422);
}
if ((int) $validated['amount'] !== $expectedAmount) {
return response()->json([
'success' => false,
'error' => 'Payment amount does not match the order amount.',
], 422);
}
if (! $this->isKrwPayment($order, $validated['currency'] ?? null)) {
return response()->json([
'success' => false,
'error' => 'NHN KCP supports KRW payments only.',
], 422);
}
if (! $order->order_status->isBeforePayment()) {
$restored = $this->restoreRetryableKcpOrder($order, (int) $validated['amount']);
if (! $restored) {
return response()->json([
'success' => false,
'error' => 'Order is not retryable for NHN KCP payment.',
], 409);
}
}
$result = $this->soapService->getApprovalKey(
orderNumber: $validated['order_number'],
goodName: $validated['good_name'],
amount: (int) $validated['amount'],
payMethod: $mobilePayMethod,
retUrl: $validated['ret_url'],
payMethodKey: $payMethodKey,
escrow: $useEscrow,
);
$siteCd = $useEscrow
? $this->soapService->getEscrowSiteCd()
: $this->soapService->getSiteCd($payMethodKey);
$fields = [
'req_tx' => 'pay',
'site_cd' => $siteCd,
'ordr_idxx' => $validated['order_number'],
'pay_method' => $mobilePayMethod,
'good_mny' => (string) $validated['amount'],
'good_name' => $validated['good_name'],
'buyr_name' => $validated['buyr_name'] ?? '',
'buyr_mail' => $validated['buyr_mail'] ?? '',
'buyr_tel1' => $validated['buyr_tel1'] ?? '',
'Ret_URL' => $validated['ret_url'],
'ActionResult' => self::ACTION_RESULT_MAP[$mobilePayMethod] ?? 'card',
'escw_used' => $useEscrow ? 'Y' : 'N',
'quotaopt' => '12',
'currency' => '410',
'approval_key' => $result['approval_key'],
...$this->buildTaxFields($order, (int) $validated['amount']),
];
// 가상계좌 전용 파라미터
if ($mobilePayMethod === 'VCNT') {
$settings = plugin_settings(self::PLUGIN_IDENTIFIER) ?? [];
$fields['vcnt_expire_term'] = (string) ((int) ($settings['vbank_expire_days'] ?? 3));
$fields['disp_tax_yn'] = 'N';
}
if ($isEasyPay) {
// GNU5 규격: 모든 direct 파라미터를 기본값으로 초기화 후 선택된 수단만 Y로 덮어씀
$fields = [...$fields, ...self::EASY_PAY_DIRECT_DEFAULTS];
if (isset(self::EASY_PAY_DIRECT_FIELDS[$payMethodKey])) {
$fields = [...$fields, ...self::EASY_PAY_DIRECT_FIELDS[$payMethodKey]];
}
$fields = [...$fields, ...$this->buildEasyPayReturnFields($payMethodKey)];
}
return response()->json([
'success' => true,
'data' => [
'pay_url' => $result['pay_url'],
'fields' => $fields,
],
]);
} catch (NhnKcpApiException $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
], 422);
}
}
/**
* 주문의 과세/비과세 분할 필드 반환 (복합과세 사이트 코드 계약 시 필요)
*
* 비과세 금액이 있는 경우에만 comm_tax_mny / comm_vat_mny / comm_free_mny 추가.
* 전액 과세라면 good_mny 만으로 KCP가 내부적으로 세금 계산.
*
* @return array<string, string>
*/
private function buildTaxFields(Order $order, int $paymentAmount): array
{
$taxFreeAmt = (int) round((float) ($order->total_tax_free_amount ?? 0));
if ($taxFreeAmt <= 0) {
return [];
}
$paymentAmount = max(0, $paymentAmount);
$taxFreeAmt = min($taxFreeAmt, $paymentAmount);
$taxablePaymentAmt = max(0, $paymentAmount - $taxFreeAmt);
$taxTotalAmt = (int) round((float) ($order->total_tax_amount ?? 0));
$vatAmt = (int) round((float) ($order->total_vat_amount ?? 0));
if ($taxablePaymentAmt > 0 && $taxTotalAmt > 0 && $vatAmt > 0) {
$vatAmt = min($taxablePaymentAmt, (int) round($taxablePaymentAmt * ($vatAmt / $taxTotalAmt)));
} else {
$vatAmt = 0;
}
$supplyAmt = $taxablePaymentAmt - $vatAmt; // 공급가액 (VAT 제외)
return [
'tax_flag' => 'TG03',
'comm_tax_mny' => (string) $supplyAmt,
'comm_vat_mny' => (string) $vatAmt,
'comm_free_mny' => (string) $taxFreeAmt,
];
}
/**
* @return array<string, string>
*/
private function buildEasyPayReturnFields(string $payMethodKey): array
{
if (! isset(self::EASY_PAY_DIRECT_FIELDS[$payMethodKey])) {
return [];
}
return [
'param_opt_1' => $payMethodKey,
'nhnkcp_easy_pay_method' => $payMethodKey,
];
}
private function isKrwPayment(Order $order, ?string $requestedCurrency): bool
{
return $this->normalizeCurrency($requestedCurrency) === 'KRW'
&& $this->normalizeCurrency((string) ($order->currency ?? 'KRW')) === 'KRW';
}
private function normalizeCurrency(?string $currency): string
{
$normalized = strtoupper(trim((string) $currency));
return $normalized !== '' ? $normalized : 'KRW';
}
}
@@ -0,0 +1,957 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Controllers;
use App\Extension\HookManager;
use App\Services\PluginSettingsService;
use Carbon\Carbon;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException;
use Modules\Sirsoft\Ecommerce\Helpers\DeviceDetector;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Plugins\Sirsoft\PayNhnkcp\Concerns\PreventsReplayCallback;
use Plugins\Sirsoft\PayNhnkcp\Concerns\IssuesReceiptCookie;
use Plugins\Sirsoft\PayNhnkcp\Concerns\RecordsPaymentWindowClosure;
use Plugins\Sirsoft\PayNhnkcp\Concerns\ResolvesEasyPayDisplay;
use Plugins\Sirsoft\PayNhnkcp\Concerns\SanitizesPgResponse;
use Plugins\Sirsoft\PayNhnkcp\Concerns\SerializesPaymentCallbacks;
use Plugins\Sirsoft\PayNhnkcp\Concerns\SendsKcpNotifyResponse;
use Plugins\Sirsoft\PayNhnkcp\Http\Requests\AuthCallbackRequest;
use Plugins\Sirsoft\PayNhnkcp\Http\Requests\VbankNotifyRequest;
use Plugins\Sirsoft\PayNhnkcp\Services\NhnKcpApiService;
/**
* KCP 결제 콜백 컨트롤러
*
* KCP Standard Pay는 브라우저 POST 콜백 방식입니다:
* 1단계: 브라우저가 POST 콜백으로 enc_data, enc_info 등 전달 → authCallback()
* 2단계: 서버가 KCP CLI 바이너리로 최종 승인 확인 → NhnKcpApiService::approvePayment()
*/
class PaymentCallbackController
{
use PreventsReplayCallback;
use IssuesReceiptCookie;
use RecordsPaymentWindowClosure;
use ResolvesEasyPayDisplay;
use SanitizesPgResponse;
use SerializesPaymentCallbacks;
use SendsKcpNotifyResponse;
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
private const SUCCESS_RES_CD = '0000';
// 사용자가 결제창을 직접 닫은 취소 코드 — 조용히 체크아웃으로 복귀
private const CANCEL_RES_CODES = ['3001', '3000', '7777', ''];
// KCP 공통통보 tx_cd — 그누보드5 settle_kcp_common.php 참고
private const KCP_TX_CD_VBANK_DEPOSIT = 'TX00';
// KCP 가상계좌 입금통보 op_cd
private const KCP_OP_CD_DEPOSIT_COMPLETE = '50';
private const KCP_OP_CD_DEPOSIT_RESEND = '01';
private const KCP_OP_CD_DEPOSIT_CANCEL = '13';
private const AUTH_RESPONSE_KEYS = [
'res_cd',
'res_msg',
'tno',
'ordr_idxx',
'good_mny',
'use_pay_method',
'app_time',
'app_no',
'card_cd',
'card_name',
'quota',
'noinf',
'escw_yn',
'bank_code',
'bank_name',
'cash_yn',
'receipt_no',
];
private const VBANK_NOTIFY_RESPONSE_KEYS = [
'site_cd',
'tno',
'order_no',
'tx_cd',
'tx_tm',
'ipgm_mnyx',
'bank_code',
'op_cd',
'noti_id',
];
private const VBANK_ISSUE_RESPONSE_KEYS = [
'res_cd',
'res_msg',
'tno',
'bankname',
'bank_name',
'bankcode',
'bank_code',
'va_date',
'vnbank_expire_date',
'app_time',
'use_pay_method',
];
public function __construct(
private readonly OrderProcessingService $orderService,
private readonly PluginSettingsService $pluginSettingsService,
private readonly NhnKcpApiService $apiService,
) {}
/**
* KCP 결제 승인 콜백
*
* POST /plugins/sirsoft-pay_nhnkcp/payment/callback
* (CSRF 제외 - KCP가 브라우저 통해 POST 전달)
*/
/**
* KCP 결제 승인 콜백
*
* 1단계: KCP Standard Pay 가 결제창 완료 후 ReturnURL 로 POST 콜백.
* 2단계: enc_data + enc_info 로 서버 승인 요청. 결제 수단별로 가상계좌(계좌발급)/
* 카드(즉시완료) 분기. 사용자 취소 / 인증 실패 시 에러 query 로 체크아웃 복귀.
*
* @param AuthCallbackRequest $request 검증된 콜백 페이로드
* @return RedirectResponse 성공/실패 URL 로 리다이렉트
*/
public function authCallback(AuthCallbackRequest $request): RedirectResponse
{
$validated = $request->validated();
$resCd = (string) ($validated['res_cd'] ?? '');
$resMsg = $this->decodeKcpMessage($validated['res_msg'] ?? '');
$encData = $validated['enc_data'] ?? ''; // 모바일 취소 시 미포함
$encInfo = $validated['enc_info'] ?? ''; // 모바일 취소 시 미포함
$ordrIdxx = $validated['ordr_idxx'];
$goodMny = isset($validated['good_mny']) ? (int) $validated['good_mny'] : 0;
$custIp = $request->ip() ?? '127.0.0.1';
Log::info('KCP: authCallback received', [
'ordr_idxx' => $ordrIdxx,
'res_cd' => $resCd,
'good_mny' => $goodMny,
'has_enc_data' => ! empty($encData),
'has_enc_info' => ! empty($encInfo),
'post_keys' => array_keys($request->all()),
]);
// 1단계: KCP 브라우저 결과 코드 확인
if ($resCd !== self::SUCCESS_RES_CD) {
$isCancelled = in_array($resCd, self::CANCEL_RES_CODES, true);
Log::info('KCP: payment result non-success', [
'ordr_idxx' => $ordrIdxx,
'res_cd' => $resCd,
'res_msg' => $resMsg,
'is_cancelled' => $isCancelled,
]);
// authCallback is a browser-return endpoint without PG-server signature/IP proof.
// Do not mutate order state from a non-success browser result alone; the
// authenticated close-report path records real window closures.
// 사용자 취소는 오류 없이 체크아웃으로 복귀
if ($isCancelled) {
return redirect($this->resolveFailUrl());
}
return redirect($this->resolveFailUrl([
'error' => $resCd,
'message' => $resMsg,
'orderId' => $ordrIdxx,
]));
}
// Approve 성공 후 후속 처리 실패 시 PG 측 자동 cancel 을 위한 추적 변수.
// catch 블록에서 tno 가 set 되어 있으면 KCP 측 승인이 이미 발생한 상태이므로
// cancelPayment 를 호출해 PG 잔존 승인을 해제한다 (사용자 환불 보장).
$order = null;
$approvedTno = null;
$approvedAmtForCancel = 0;
$callbackLock = null;
try {
// 2단계: 주문 조회
$order = $this->orderService->findByOrderNumber($ordrIdxx);
if (! $order) {
Log::error('KCP: order not found', ['ordr_idxx' => $ordrIdxx]);
return redirect($this->resolveFailUrl(['error' => 'order_not_found', 'orderId' => $ordrIdxx]));
}
$callbackLock = $this->acquireOrderCallbackLock('authCallback', $ordrIdxx);
$order = $order->fresh('payment') ?? $order;
if ($order->order_status === OrderStatusEnum::CANCELLED) {
$restored = $this->restoreRetryableKcpOrder($order, $goodMny > 0 ? $goodMny : null);
if (! $restored) {
Log::warning('KCP: cancelled order is not retryable', ['ordr_idxx' => $ordrIdxx]);
return redirect($this->resolveFailUrl([
'error' => 'order_not_retryable',
'orderId' => $ordrIdxx,
]));
}
$order = $order->fresh('payment') ?? $order;
}
if (($order->payment?->isPaid() ?? false) || $order->order_status === OrderStatusEnum::PAYMENT_COMPLETE) {
Log::info('KCP: authCallback already paid order, skipping CLI approval', [
'ordr_idxx' => $ordrIdxx,
'transaction_id' => $order->payment?->transaction_id,
]);
$this->queueReceiptCookie($ordrIdxx);
return redirect($this->resolveSuccessUrl($ordrIdxx));
}
$expectedAmount = $this->resolveExpectedPaymentPriceOrNull($order, 'auth_callback', [
'ordr_idxx' => $ordrIdxx,
'good_mny' => $goodMny,
]);
if ($expectedAmount === null) {
return redirect($this->resolveFailUrl([
'error' => 'invalid_payment_currency',
'orderId' => $ordrIdxx,
]));
}
if (! $this->isKrwOrder($order)) {
Log::warning('KCP: unsupported order currency', [
'ordr_idxx' => $ordrIdxx,
'currency' => $this->orderCurrency($order),
]);
return redirect($this->resolveFailUrl(['error' => 'currency_not_supported', 'orderId' => $ordrIdxx]));
}
if ($goodMny > 0 && $goodMny !== $expectedAmount) {
Log::warning('KCP: browser callback amount mismatch before approval', [
'ordr_idxx' => $ordrIdxx,
'expected' => $expectedAmount,
'actual' => $goodMny,
]);
$failedOrder = $this->orderService->failPayment($order, 'AMOUNT_MISMATCH', 'KCP callback amount mismatch');
$this->markKcpPaymentFailureRecord(
$failedOrder,
'AMOUNT_MISMATCH',
'KCP callback amount mismatch',
'amount_mismatch',
);
return redirect($this->resolveFailUrl(['error' => 'amount_mismatch', 'orderId' => $ordrIdxx]));
}
// 가상계좌: 계좌 발급 완료 처리 (실제 입금은 vbankNotify에서 처리)
// KCP 콜백의 use_pay_method=VCNT 또는 주문의 payment_method=vbank 로 감지
$isVbank = ($validated['use_pay_method'] ?? '') === 'VCNT'
|| in_array($order->payment?->payment_method?->value, ['vbank', 'virtual_account'], true);
if ($isVbank) {
return $this->handleVbankIssued($validated, $order, $encData, $encInfo, $ordrIdxx, $custIp, $request);
}
HookManager::doAction('sirsoft-pay_nhnkcp.payment.before_confirm', $order, $validated);
// 3단계: KCP CLI로 최종 승인 확인
$pgResponse = $this->apiService->approvePayment($encData, $encInfo, $ordrIdxx, $custIp);
HookManager::doAction('sirsoft-pay_nhnkcp.payment.after_confirm', $order, $pgResponse);
$pgResCd = $pgResponse['res_cd'] ?? '';
if ($pgResCd !== self::SUCCESS_RES_CD) {
Log::warning('KCP: CLI approval failed', [
'ordr_idxx' => $ordrIdxx,
'res_cd' => $pgResCd,
'res_msg' => $pgResponse['res_msg'] ?? '',
]);
$failedOrder = $this->orderService->failPayment($order, $pgResCd, $pgResponse['res_msg'] ?? '');
$this->markKcpPaymentFailureRecord(
$failedOrder,
$pgResCd,
$pgResponse['res_msg'] ?? '',
'approval_failed',
);
return redirect($this->resolveFailUrl([
'error' => $pgResCd,
'message' => $pgResponse['res_msg'] ?? '',
'orderId' => $ordrIdxx,
]));
}
$tno = $pgResponse['tno'] ?? ($validated['tno'] ?? '');
// Replay 가드: 동일 tno 가 이미 paid 상태면 중복 처리하지 않고 성공 페이지로 복귀
if ($this->wasAlreadyPaid($tno)) {
$this->logReplayDetected($tno, $ordrIdxx, 'authCallback (card)');
$this->queueReceiptCookie($ordrIdxx);
return redirect($this->resolveSuccessUrl($ordrIdxx));
}
// KCP는 CLI 응답에 good_mny가 없는 경우가 많으므로 주문 금액으로 검증.
// 결제 청구액 SSoT = 결제 통화(order_currency) 환산액 (마일리지/예치금 차감 후 실청구액) —
// 클라이언트 결제 요청액(buildPgPaymentData)·코어 최종 승인 검증과 동일 기준.
// base≠결제 통화(예: base JPY, 결제 KRW)에서도 PG 청구 통화와 단위가 일치한다.
$approvedAmt = $goodMny > 0
? $goodMny
: $expectedAmount;
// PG 측 승인이 확정된 시점 — 후속 처리 실패 시 cancel 필요. catch 에서 참조.
$approvedTno = $tno;
$approvedAmtForCancel = $approvedAmt;
$isEscrow = ($pgResponse['escw_yn'] ?? '') === 'Y';
$easyPayMeta = $this->resolveEasyPayMeta($validated);
// 4단계: 주문 완료 처리
$this->orderService->completePayment($order, [
'transaction_id' => $tno,
'card_approval_number' => $pgResponse['app_no'] ?? null,
'card_number_masked' => $pgResponse['card_no'] ?? $pgResponse['account'] ?? null,
'card_name' => $pgResponse['card_name'] ?? $pgResponse['bank_name'] ?? null,
'card_installment_months' => (int) ($pgResponse['quota'] ?? 0),
'is_interest_free' => false,
'embedded_pg_provider' => $easyPayMeta['provider'] ?? null,
'receipt_url' => null,
'payment_meta' => [
'res_cd' => $pgResCd,
...$this->currentCredentialMeta(),
...($easyPayMeta === [] ? [] : [
'nhnkcp_easy_pay_method' => $easyPayMeta['method'],
'nhnkcp_easy_pay_provider' => $easyPayMeta['provider'],
'nhnkcp_easy_pay_label' => $easyPayMeta['label'],
]),
'use_pay_method' => $validated['use_pay_method'] ?? $pgResponse['use_pay_method'] ?? null,
'app_time' => $pgResponse['app_time'] ?? null,
'bank_name' => $pgResponse['bank_name'] ?? null,
'vnbank_expire_date' => $pgResponse['vnbank_expire_date'] ?? null,
'escw_yn' => $pgResponse['escw_yn'] ?? null,
'pg_response_sanitized' => true,
'pg_raw_response' => $this->sanitizePgResponse($pgResponse, self::AUTH_RESPONSE_KEYS),
],
'payment_device' => DeviceDetector::detect($request),
], $approvedAmt);
// 에스크로 결제인 경우 is_escrow 플래그 저장
if ($isEscrow) {
$order->payment()->update(['is_escrow' => true]);
}
// KCP가 실제로 결제를 처리했으므로 pg_provider를 nhnkcp로 보정
// (기본 PG가 타 PG일 때 주문이 해당 PG provider로 생성되는 경우 대비)
$order->payment()->update(['pg_provider' => 'nhnkcp']);
$this->queueReceiptCookie($ordrIdxx);
return redirect($this->resolveSuccessUrl($ordrIdxx));
} catch (LockTimeoutException $e) {
Log::warning('KCP: authCallback skipped because callback lock is busy', [
'ordr_idxx' => $ordrIdxx,
]);
$latestOrder = $this->orderService->findByOrderNumber($ordrIdxx);
if (($latestOrder?->payment?->isPaid() ?? false)
|| $latestOrder?->order_status === OrderStatusEnum::PAYMENT_COMPLETE) {
$this->queueReceiptCookie($ordrIdxx);
return redirect($this->resolveSuccessUrl($ordrIdxx));
}
return redirect($this->resolveFailUrl([
'error' => 'callback_locked',
'orderId' => $ordrIdxx,
]));
} catch (PaymentAmountMismatchException $e) {
Log::error('KCP: amount mismatch', [
'ordr_idxx' => $ordrIdxx,
'expected' => $e->getExpectedAmount(),
'actual' => $e->getActualAmount(),
]);
// Approve 가 이미 KCP 측에서 발생했으면 자동 취소로 PG 잔존 승인 해제
$this->autoCancelIfApproved($approvedTno, $ordrIdxx, $approvedAmtForCancel, 'amount_mismatch');
if ($order instanceof Order) {
$failedOrder = $this->orderService->failPayment($order, 'AMOUNT_MISMATCH', $e->getMessage());
$this->markKcpPaymentFailureRecord(
$failedOrder,
'AMOUNT_MISMATCH',
$e->getMessage(),
'amount_mismatch',
);
}
return redirect($this->resolveFailUrl(['error' => 'amount_mismatch', 'orderId' => $ordrIdxx]));
} catch (\Exception $e) {
Log::error('KCP: confirm exception', [
'ordr_idxx' => $ordrIdxx,
'error' => $e->getMessage(),
]);
$this->autoCancelIfApproved($approvedTno, $ordrIdxx, $approvedAmtForCancel, 'confirm_failed');
if ($order instanceof Order) {
$failedOrder = $this->orderService->failPayment($order, 'CONFIRM_FAILED', $e->getMessage());
$this->markKcpPaymentFailureRecord(
$failedOrder,
'CONFIRM_FAILED',
$e->getMessage(),
'confirm_failed',
);
}
return redirect($this->resolveFailUrl([
'error' => 'confirm_failed',
'message' => $e->getMessage(),
'orderId' => $ordrIdxx,
]));
} finally {
$this->releaseOrderCallbackLock($callbackLock);
}
}
/**
* KCP 가상계좌 입금 통보 처리
*
* POST /plugins/sirsoft-pay_nhnkcp/payment/vbank-notify
* (KCP 서버 → 우리 서버, CSRF 제외)
*
* KCP 공식 webhook 페이로드 (그누보드5 settle_kcp_common.php 참고):
* tx_cd=TX00 (가상계좌 입금) + op_cd 50/01/13 + ipgm_mnyx + order_no + noti_id
*
* 응답: <form><input name="result" value="0000"> HTML 을 KCP 가 파싱.
* result="0000" 이면 통보 성공, 그 외이면 재통보 (최대 10회).
*
* @param VbankNotifyRequest $request 검증된 입금통보 페이로드
* @return Response 200 + KCP 표준 result HTML
*/
public function vbankNotify(VbankNotifyRequest $request): Response
{
$validated = $request->validated();
$txCd = (string) ($validated['tx_cd'] ?? self::KCP_TX_CD_VBANK_DEPOSIT);
$tno = $validated['tno'];
$orderNo = $validated['order_no'];
$opCd = (string) ($validated['op_cd'] ?? '');
$ipgmMny = (int) ($validated['ipgm_mnyx'] ?? 0);
$notiId = $validated['noti_id'] ?? null;
$callbackLock = null;
// tx_cd 가 TX00 가 아니면 본 endpoint 가 처리하지 않음.
// (KCP 가 단일 webhook URL 에 모든 tx_cd 를 보낼 수도 있어 안전하게 무시 + result=0000)
if ($txCd !== self::KCP_TX_CD_VBANK_DEPOSIT) {
Log::info('KCP: vbank-notify ignored non-TX00', [
'tno' => $tno, 'order_no' => $orderNo, 'tx_cd' => $txCd, 'noti_id' => $notiId,
]);
return $this->kcpNotifyResponse();
}
// op_cd 분기 (그누보드5 settle_kcp_common.php 와 동일 정책):
// 13 (망취소) → 별도 처리 (입금완료 안 함, 결제 취소 정책 미정 — 로깅만)
// 그 외 (50/01/18/…) → 입금완료 처리 (KCP testadmin 모의입금은 op_cd=18 사용 확인됨)
// KCP 공식 문서가 50/01/13 만 언급하지만 실제 KCP testadmin 발신은 18 등 추가 코드를
// 사용하므로 그누보드5 의 op_cd 무관 입금처리 정책을 따른다 (망취소만 분리).
if ($opCd === self::KCP_OP_CD_DEPOSIT_CANCEL) {
Log::warning('KCP: vbank deposit cancelled (op_cd=13)', [
'tno' => $tno, 'order_no' => $orderNo, 'noti_id' => $notiId, 'ipgm_mnyx' => $ipgmMny,
]);
return $this->kcpNotifyResponse();
}
try {
$order = $this->orderService->findByOrderNumber($orderNo);
if (! $order) {
Log::error('KCP: vbank notify - order not found', [
'order_no' => $orderNo, 'tno' => $tno, 'noti_id' => $notiId,
]);
// 영구 실패 — 재시도 의미 없음. result=0000 으로 KCP 재통보 차단.
return $this->kcpNotifyResponse();
}
$callbackLock = $this->acquireOrderCallbackLock('vbankNotify', $orderNo);
$order = $order->fresh('payment') ?? $order;
$contextError = $this->validateVbankNotifyContext($order, $validated, $tno, $ipgmMny);
if ($contextError !== null) {
Log::warning('KCP: vbank notify context mismatch — retry requested', [
'reason' => $contextError,
'tno' => $tno,
'order_no' => $orderNo,
'noti_id' => $notiId,
]);
return $this->kcpNotifyRetry();
}
// 멱등성 — 이미 결제완료 상태면 재처리 없이 0000 응답 (op_cd=01 재전송 대응)
if ($order->payment?->isPaid() ?? false) {
Log::info('KCP: vbank-notify already paid (idempotent)', [
'tno' => $tno, 'order_no' => $orderNo, 'noti_id' => $notiId, 'op_cd' => $opCd,
]);
return $this->kcpNotifyResponse();
}
$this->orderService->completePayment($order, [
'transaction_id' => $tno,
'payment_meta' => [
'tx_cd' => $txCd,
'op_cd' => $opCd,
'noti_id' => $notiId,
...$this->currentCredentialMeta((string) ($validated['site_cd'] ?? '')),
'bank_code' => $validated['bank_code'] ?? null,
'ipgm_time' => $validated['tx_tm'] ?? null,
'pg_response_sanitized' => true,
'pg_raw_response' => $this->sanitizePgResponse($validated, self::VBANK_NOTIFY_RESPONSE_KEYS),
],
], $ipgmMny);
$order->payment()->update(['pg_provider' => 'nhnkcp']);
Log::info('KCP: vbank deposit confirmed', [
'tno' => $tno, 'order_no' => $orderNo, 'ipgm_mnyx' => $ipgmMny,
'op_cd' => $opCd, 'noti_id' => $notiId,
]);
return $this->kcpNotifyResponse();
} catch (LockTimeoutException $e) {
Log::warning('KCP: vbank notify lock timeout — retry requested', [
'tno' => $tno,
'order_no' => $orderNo,
'noti_id' => $notiId,
]);
return $this->kcpNotifyRetry();
} catch (\Exception $e) {
Log::error('KCP: vbank notify failed', [
'tno' => $tno, 'order_no' => $orderNo, 'noti_id' => $notiId,
'error' => $e->getMessage(),
]);
// 일시적 실패 (DB 등) — result != 0000 으로 KCP 재통보 유도
return $this->kcpNotifyRetry();
} finally {
$this->releaseOrderCallbackLock($callbackLock);
}
}
/**
* 가상계좌 발급 처리
*
* authCallback 에서 use_pay_method=VCNT 일 때 호출.
*
* PC Standard Pay: enc_data/enc_info 가 동봉되어 CLI 로 복호화하여 계좌 정보를 얻음.
* Mobile SmartPhone Pay: enc_data 없이 평문 필드(bankname/account/depositor/va_date)가
* 콜백 POST 에 직접 포함되므로 CLI 호출을 건너뛰고 요청에서 직접 읽음.
*
* 발급 성공 = res_cd == 0000 AND bankname/account 가 모두 채워진 경우만.
* 그 외 (CLI 9502 / 예외 / 평문 필드 결락) 는 발급 실패로 판정해 failPayment + fail URL.
*/
private function handleVbankIssued(
array $validated,
Order $order,
string $encData,
string $encInfo,
string $ordrIdxx,
string $custIp,
Request $request,
): RedirectResponse {
$tno = $validated['tno'] ?? '';
$pgResponse = [];
$isMobile = $encData === '' || $encInfo === '';
if ($isMobile) {
// 모바일: 콜백 POST 의 평문 필드를 그대로 응답으로 취급
// KCP 변종 키 모두 대응 (bankname|bank_name, depositor|account_holder, va_date|vnbank_expire_date)
$pgResponse = [
'res_cd' => $validated['res_cd'] ?? self::SUCCESS_RES_CD,
'res_msg' => $validated['res_msg'] ?? '',
'tno' => $tno,
'bankname' => $request->input('bankname') ?? $request->input('bank_name'),
'account' => $request->input('account'),
'depositor' => $request->input('depositor') ?? $request->input('account_holder'),
'va_date' => $request->input('va_date') ?? $request->input('vnbank_expire_date'),
];
} else {
// PC: CLI 호출. 호출 자체 실패(권한 누락 / pub.key 부재 등)는 발급 여부 미확정이므로 안전하게 실패 처리.
try {
$pgResponse = $this->apiService->approvePayment($encData, $encInfo, $ordrIdxx, $custIp);
} catch (\Exception $e) {
Log::error('KCP: vbank CLI exception — treating as issuance failure', [
'ordr_idxx' => $ordrIdxx,
'error' => $e->getMessage(),
]);
$failedOrder = $this->orderService->failPayment($order, 'cli_exception', $e->getMessage());
$this->markKcpPaymentFailureRecord(
$failedOrder,
'cli_exception',
$e->getMessage(),
'vbank_cli_exception',
);
return redirect($this->resolveFailUrl([
'error' => 'cli_exception',
'message' => $e->getMessage(),
'orderId' => $ordrIdxx,
]));
}
}
$pgResCd = $pgResponse['res_cd'] ?? '';
$bankname = $pgResponse['bankname'] ?? null;
$account = $pgResponse['account'] ?? null;
// 발급 성공 판정 — 핵심 필드(은행명/계좌번호) 모두 채워진 경우만 성공.
// KCP 표준결제창은 결제수단별로 정상 res_cd 가 다르다 (card=0000, vbank=V000).
// res_cd 단일 비교 대신 발급 데이터 존재 여부로 판정해 응답 변종에 견고하게 대응.
// - V000 + bankname/account 존재 → 정상 발급 (success)
// - 0000 + bankname/account 존재 → 정상 발급 (success)
// - 9502 + bankname/account 부재 → 발급 실패 (fail)
$hasIssuanceData = ($bankname !== null && $bankname !== '')
&& ($account !== null && $account !== '');
if (! $hasIssuanceData) {
$resMsg = $this->decodeKcpMessage($pgResponse['res_msg'] ?? '');
$effectiveCode = $pgResCd !== '' && $pgResCd !== self::SUCCESS_RES_CD
? $pgResCd
: 'vbank_issuance_incomplete';
$effectiveMsg = $resMsg !== '' ? $resMsg : '가상계좌 발급 정보 누락';
Log::warning('KCP: vbank issuance failed', [
'ordr_idxx' => $ordrIdxx,
'res_cd' => $pgResCd,
'res_msg' => $resMsg,
'has_bankname' => $bankname !== null && $bankname !== '',
'has_account' => $account !== null && $account !== '',
'is_mobile' => $isMobile,
]);
$failedOrder = $this->orderService->failPayment($order, $effectiveCode, $effectiveMsg);
$this->markKcpPaymentFailureRecord(
$failedOrder,
$effectiveCode,
$effectiveMsg,
'vbank_issuance_failed',
);
return redirect($this->resolveFailUrl([
'error' => $effectiveCode,
'message' => $effectiveMsg,
'orderId' => $ordrIdxx,
]));
}
if ($isMobile) {
Log::info('KCP: vbank account issued via mobile callback (no CLI)', [
'ordr_idxx' => $ordrIdxx,
'tno' => $tno,
'bankname' => $bankname,
'account' => $account,
'va_date' => $pgResponse['va_date'] ?? null,
]);
} else {
$tno = $pgResponse['tno'] ?? $tno;
Log::info('KCP: vbank account issued via CLI', [
'ordr_idxx' => $ordrIdxx,
'tno' => $tno,
'bankname' => $bankname,
'account' => $account,
]);
}
// 가상계좌 발급 정보를 OrderPayment vbank 전용 컬럼에 저장 (PENDING_PAYMENT 상태 유지).
// 저장 실패 시도 부분 저장으로 사용자에게 잘못된 정보가 노출되지 않도록 failPayment 처리.
try {
$expireRaw = $pgResponse['va_date'] ?? null;
$vbankDueAt = null;
if ($expireRaw) {
// KCP va_date: YYYYMMDDHHMMSS (14자리) 또는 YYYYMMDD (8자리)
try {
$vbankDueAt = strlen($expireRaw) <= 8
? Carbon::createFromFormat('Ymd', $expireRaw)->endOfDay()
: Carbon::createFromFormat('YmdHis', $expireRaw);
} catch (\Exception) {
$vbankDueAt = null;
}
}
$order->payment()->update(array_filter([
'transaction_id' => $tno ?: null,
'vbank_name' => $bankname,
'vbank_number' => $account,
'vbank_holder' => $pgResponse['depositor'] ?? null,
'vbank_due_at' => $vbankDueAt,
'vbank_issued_at' => now(),
'payment_device' => $isMobile ? 'mobile' : 'pc',
'payment_meta' => [
'result_code' => $pgResponse['res_cd'] ?? null,
'tno' => $tno ?: null,
...$this->currentCredentialMeta(),
'bankname' => $bankname,
'bankcode' => $pgResponse['bankcode'] ?? $pgResponse['bank_code'] ?? null,
'va_date' => $pgResponse['va_date'] ?? $pgResponse['vnbank_expire_date'] ?? null,
'app_time' => $pgResponse['app_time'] ?? null,
'pg_response_sanitized' => true,
'pg_raw_response' => $this->sanitizePgResponse($pgResponse, self::VBANK_ISSUE_RESPONSE_KEYS),
],
], fn ($v) => $v !== null));
} catch (\Exception $e) {
Log::error('KCP: failed to save vbank info to OrderPayment', [
'ordr_idxx' => $ordrIdxx,
'error' => $e->getMessage(),
]);
$failedOrder = $this->orderService->failPayment($order, 'vbank_save_failed', $e->getMessage());
$this->markKcpPaymentFailureRecord(
$failedOrder,
'vbank_save_failed',
$e->getMessage(),
'vbank_save_failed',
);
return redirect($this->resolveFailUrl([
'error' => 'vbank_save_failed',
'message' => $e->getMessage(),
'orderId' => $ordrIdxx,
]));
}
$this->queueReceiptCookie($ordrIdxx);
return redirect($this->resolveSuccessUrl($ordrIdxx));
}
/**
* KCP webhook 이 주장한 값이 결제창에서 발급받아 저장한 값과 일치하는지 확인한다.
*
* KCP 입금통보에는 별도 서명 필드가 없으므로, 주문번호만 믿지 않고 저장된
* tno/site_cd/가상계좌/금액을 모두 대조해 위조 통보를 차단한다.
*/
private function validateVbankNotifyContext(Order $order, array $validated, string $tno, int $amount): ?string
{
$payment = $order->payment;
if (! $payment) {
return 'payment_not_found';
}
if (! in_array($payment->payment_method?->value, ['vbank', 'virtual_account'], true)) {
return 'payment_method_not_vbank';
}
$meta = $payment->payment_meta ?? [];
$storedTno = trim((string) ($payment->transaction_id ?: ($meta['tno'] ?? '')));
if ($storedTno === '' || ! hash_equals($storedTno, $tno)) {
return 'tno_mismatch';
}
$storedSiteCd = trim((string) ($meta['site_cd'] ?? ''));
$expectedSiteCd = $storedSiteCd !== '' ? $storedSiteCd : trim($this->apiService->getSiteCd());
$receivedSiteCd = trim((string) ($validated['site_cd'] ?? ''));
if ($expectedSiteCd === '' || $receivedSiteCd === '' || ! hash_equals($expectedSiteCd, $receivedSiteCd)) {
return 'site_cd_mismatch';
}
$storedAccount = trim((string) ($payment->vbank_number ?? ''));
$receivedAccount = trim((string) ($validated['account'] ?? ''));
if ($storedAccount === '' || $receivedAccount === '' || ! hash_equals($storedAccount, $receivedAccount)) {
return 'account_mismatch';
}
$expectedAmount = $this->expectedVbankNotifyAmount($order);
if ($expectedAmount === null) {
return 'invalid_payment_currency';
}
if ($amount !== $expectedAmount) {
return 'amount_mismatch';
}
return null;
}
private function expectedVbankNotifyAmount(Order $order): ?int
{
$payment = $order->payment;
$paidAmount = (int) round((float) ($payment?->paid_amount_local ?? 0));
if (($payment?->isPaid() ?? false) && $paidAmount > 0) {
return $paidAmount;
}
return $this->resolveExpectedPaymentPriceOrNull($order, 'vbank_notify', [
'transaction_id' => $payment?->transaction_id,
]);
}
/**
* 결제 당시 KCP 상점 설정을 payment_meta 에 남겨 이후 환불/통보 검증에 사용한다.
*
* site_key 같은 비밀값은 저장하지 않고, 공개 식별자인 site_cd 와 test/live 모드만 저장한다.
*
* @return array{site_cd: string, is_test_mode: bool}
*/
private function currentCredentialMeta(string $siteCd = ''): array
{
return [
'site_cd' => $siteCd !== '' ? $siteCd : $this->apiService->getSiteCd(),
'is_test_mode' => $this->apiService->isTestMode(),
];
}
private function resolveSuccessUrl(string $orderId): string
{
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
$urlTemplate = $settings['redirect_success_url'] ?? '/shop/orders/{orderId}/complete';
return $this->absolutize(str_replace('{orderId}', $orderId, $urlTemplate));
}
/**
* Approve 가 이미 발생한 후 후속 처리 실패 시 PG 측 cancel 을 시도.
*
* KCP CLI 승인이 완료되었으나 우리 서버에서 completePayment 가 실패한 경우
* 사용자 카드/계좌는 PG 측에서 승인 상태로 잔존 → 환불 불가 위험. 본 메서드는
* cancelPayment API 로 PG 잔존 승인을 즉시 해제해 사용자 보호를 확보한다.
*
* cancel 자체가 실패해도 사용자 응답 흐름은 막지 않음 — 로깅만 수행하고
* 운영자가 KCP 가맹점 관리자에서 수동 처리하도록 신호.
*/
private function autoCancelIfApproved(
?string $tno,
string $ordrIdxx,
int $cancelAmt,
string $reason,
): void {
if ($tno === null || $tno === '' || $cancelAmt <= 0) {
return;
}
try {
$result = $this->apiService->cancelPayment(
$tno,
$ordrIdxx,
$cancelAmt,
'auto-cancel: '.$reason,
);
Log::warning('KCP: auto-cancel after post-approve failure', [
'tno' => $tno,
'ordr_idxx' => $ordrIdxx,
'amount' => $cancelAmt,
'reason' => $reason,
'res_cd' => $result['res_cd'] ?? null,
'res_msg' => $result['res_msg'] ?? null,
]);
} catch (\Throwable $e) {
Log::error('KCP: auto-cancel FAILED — manual reconciliation required', [
'tno' => $tno,
'ordr_idxx' => $ordrIdxx,
'amount' => $cancelAmt,
'reason' => $reason,
'error' => $e->getMessage(),
]);
}
}
private function resolveFailUrl(array $queryParams = []): string
{
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
$baseUrl = $this->absolutize($settings['redirect_fail_url'] ?? '/shop/checkout');
if (empty($queryParams)) {
return $baseUrl;
}
// error 코드와 message 가 모두 있으면 message 앞에 '[코드] ' 자동 prefix.
// 체크아웃 페이지에서 사용자가 오류 종류를 즉시 식별할 수 있도록 한다.
// 예: error=9502, message=연동 모듈 호출 오류 → message=[9502] 연동 모듈 호출 오류
if (! empty($queryParams['error']) && ! empty($queryParams['message'])) {
$queryParams['message'] = sprintf('[%s] %s', $queryParams['error'], $queryParams['message']);
}
$query = http_build_query(array_filter($queryParams));
$separator = str_contains($baseUrl, '?') ? '&' : '?';
return $baseUrl.$separator.$query;
}
/**
* 상대 경로면 APP_URL 기준으로 절대 URL 화.
*
* PG가 브라우저 POST 로 콜백을 보내는 동안 Apache 가 ProxyPreserveHost Off 등
* 으로 Host 헤더를 localhost 로 바꿔서 PHP 에 전달하는 경우, Laravel 의
* redirect('/path') 가 http://localhost/path 를 생성해버린다. config('app.url')
* (.env 의 APP_URL)을 명시적 base 로 사용하여 도메인을 보존한다.
*/
private function absolutize(string $url): string
{
if (preg_match('#^https?://#i', $url) === 1) {
return $url;
}
$base = rtrim((string) config('app.url'), '/');
$path = $url === '' ? '/' : ($url[0] === '/' ? $url : '/'.$url);
return $base.$path;
}
private function isKrwOrder(Order $order): bool
{
return $this->orderCurrency($order) === 'KRW';
}
private function orderCurrency(Order $order): string
{
$currency = strtoupper(trim((string) ($order->currency ?? 'KRW')));
return $currency !== '' ? $currency : 'KRW';
}
/**
* KCP 모바일 게이트웨이는 EUC-KR(CP949) 인코딩으로 res_msg를 POST.
* 유효한 UTF-8이 아니면 CP949로 간주하고 변환한다.
*/
private function decodeKcpMessage(string $msg): string
{
if ($msg === '' || mb_check_encoding($msg, 'UTF-8')) {
return $msg;
}
$converted = mb_convert_encoding($msg, 'UTF-8', 'CP949');
return $converted !== false ? $converted : $msg;
}
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Controllers;
use App\Helpers\ResponseHelper;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\RateLimiter;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Plugins\Sirsoft\PayNhnkcp\Concerns\RecordsPaymentWindowClosure;
use Plugins\Sirsoft\PayNhnkcp\Concerns\SerializesPaymentCallbacks;
use Plugins\Sirsoft\PayNhnkcp\Http\Requests\PaymentCloseReportRequest;
class PaymentCloseReportController
{
use RecordsPaymentWindowClosure;
use SerializesPaymentCallbacks;
private const FAILURE_CODE = 'USER_CANCEL';
private const FAILURE_MESSAGE = '사용자가 NHN KCP 결제창을 닫았습니다.';
public function __construct(
private readonly OrderProcessingService $orderService,
) {}
/**
* PC 결제창 닫힘 보고를 검증하고 결제 실패/취소 이력을 기록합니다.
*
* @param PaymentCloseReportRequest $request 결제창 닫힘 보고 요청
* @return JsonResponse 닫힘 보고 처리 결과
*/
public function store(PaymentCloseReportRequest $request): JsonResponse
{
$validated = $request->validated();
$oid = $validated['oid'];
$price = (int) $validated['price'];
$rateLimitKey = $this->rateLimitKey($request, $oid);
if (RateLimiter::tooManyAttempts($rateLimitKey, 20)) {
return ResponseHelper::error('messages.failed', 429, [
'message' => ['Too many NHN KCP payment close reports. Please try again later.'],
]);
}
RateLimiter::hit($rateLimitKey, 60);
$callbackLock = null;
try {
// PC close-report 는 PC authCallback 과 경쟁할 수 있으므로 같은 주문 락을 공유한다.
// authCallback 이 먼저 결제완료를 확정했다면 아래 최신 주문 조회에서 close-report 를 무시한다.
$callbackLock = $this->acquireOrderCallbackLock('authCallback', $oid);
$order = $this->orderService->findByOrderNumber($oid);
if (! $order) {
return ResponseHelper::error('messages.failed', 404, [
'message' => ['Order not found.'],
]);
}
if (! $order->order_status->isBeforePayment()) {
return ResponseHelper::success('messages.success', [
'status' => 'ignored',
'reason' => 'order_not_payable',
]);
}
// 결제 성공 콜백과 결제창 닫힘 보고가 경쟁할 때, 카드 주문은 승인 직전까지
// order_status=PENDING_ORDER 라 위 가드를 통과한다. payment_status 가 이미 PAID 면 결제가
// 성공한 것이므로 실패 처리하지 않는다(failPayment 가 옵션을 취소로 덮어 주문/옵션 상태가
// 어긋나는 race 차단). failPayment 자체에도 동일 가드가 있으나 여기서 차단해 불필요한
// 결제취소 이력 기록까지 미연에 방지한다.
if ($order->payment?->isPaid()) {
return ResponseHelper::success('messages.success', [
'status' => 'ignored',
'reason' => 'payment_already_paid',
]);
}
if (! $this->requestMatchesOrderBuyer($request, $order)) {
return ResponseHelper::error('messages.failed', 403, [
'message' => ['Order buyer verification failed.'],
]);
}
$expectedPrice = $this->resolveExpectedPaymentPriceOrNull($order, 'close_report', [
'oid' => $oid,
'received_price' => $price,
'ip' => $request->ip(),
]);
if ($expectedPrice === null) {
return ResponseHelper::error('messages.failed', 422, [
'message' => ['Payment currency is not chargeable.'],
]);
}
if ($price !== $expectedPrice) {
return ResponseHelper::error('messages.failed', 422, [
'message' => ['Payment amount does not match the order amount.'],
]);
}
$closeReason = trim((string) ($validated['reason'] ?? ''));
$this->markPaymentWindowClosed(
$this->orderService,
$order,
self::FAILURE_CODE,
self::FAILURE_MESSAGE,
$closeReason !== '' ? $closeReason : self::FAILURE_MESSAGE,
);
return ResponseHelper::success('messages.success', [
'status' => 'recorded',
]);
} catch (LockTimeoutException) {
return ResponseHelper::success('messages.success', [
'status' => 'ignored',
'reason' => 'callback_in_progress',
]);
} finally {
$this->releaseOrderCallbackLock($callbackLock);
}
}
private function rateLimitKey(PaymentCloseReportRequest $request, string $oid): string
{
return 'sirsoft-pay_nhnkcp:payment-close-report:'.sha1($request->ip().'|'.$oid);
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Controllers;
use App\Helpers\ResponseHelper;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\RateLimiter;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Plugins\Sirsoft\PayNhnkcp\Concerns\RecordsPaymentWindowClosure;
use Plugins\Sirsoft\PayNhnkcp\Http\Requests\PaymentRetryRequest;
class PaymentRetryController
{
use RecordsPaymentWindowClosure;
public function __construct(
private readonly OrderProcessingService $orderService,
) {}
/**
* 같은 주문번호로 NHN KCP 결제를 다시 열기 전 실패 주문을 결제대기 상태로 복구합니다.
*
* @param PaymentRetryRequest $request 결제 재시도 준비 요청
* @return JsonResponse 재시도 준비 처리 결과
*/
public function store(PaymentRetryRequest $request): JsonResponse
{
$validated = $request->validated();
$oid = $validated['oid'];
$price = (int) $validated['price'];
$rateLimitKey = $this->rateLimitKey($request, $oid);
if (RateLimiter::tooManyAttempts($rateLimitKey, 20)) {
return ResponseHelper::error('messages.failed', 429, [
'message' => ['Too many NHN KCP payment retry requests. Please try again later.'],
]);
}
RateLimiter::hit($rateLimitKey, 60);
$order = $this->orderService->findByOrderNumber($oid);
if (! $order) {
return ResponseHelper::error('messages.failed', 404, [
'message' => ['Order not found.'],
]);
}
if (! $this->requestMatchesOrderBuyer($request, $order)) {
return ResponseHelper::error('messages.failed', 403, [
'message' => ['Order buyer verification failed.'],
]);
}
$expectedPrice = $this->resolveExpectedPaymentPriceOrNull($order, 'payment_retry', [
'oid' => $oid,
'received_price' => $price,
'ip' => $request->ip(),
]);
if ($expectedPrice === null) {
return ResponseHelper::error('messages.failed', 422, [
'message' => ['Payment currency is not chargeable.'],
]);
}
if ($price !== $expectedPrice) {
return ResponseHelper::error('messages.failed', 422, [
'message' => ['Payment amount does not match the order amount.'],
]);
}
if ($order->order_status->isBeforePayment()) {
return ResponseHelper::success('messages.success', [
'status' => 'ready',
]);
}
if ($this->restoreRetryableKcpOrder($order, $price)) {
return ResponseHelper::success('messages.success', [
'status' => 'restored',
]);
}
return ResponseHelper::error('messages.failed', 409, [
'message' => ['Order is not retryable for NHN KCP payment.'],
]);
}
private function rateLimitKey(PaymentRetryRequest $request, string $oid): string
{
return 'sirsoft-pay_nhnkcp:payment-retry:' . sha1($request->ip() . '|' . $oid);
}
}
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Controllers;
use App\Services\PluginSettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Ecommerce\Services\GuestOrderAuthService;
use Plugins\Sirsoft\PayNhnkcp\Concerns\IssuesReceiptCookie;
use Plugins\Sirsoft\PayNhnkcp\Concerns\ResolvesEasyPayDisplay;
use Plugins\Sirsoft\PayNhnkcp\Services\NhnKcpApiService;
class UserReceiptController
{
use IssuesReceiptCookie;
use ResolvesEasyPayDisplay;
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
// 카드/계좌이체 영수증 URL (cmd= 로 끝나므로 cmd 값을 바로 붙임)
private const BILL_URL_TEST = 'https://testadmin8.kcp.co.kr/assist/bill.BillActionNew.do?cmd=';
private const BILL_URL_LIVE = 'https://admin8.kcp.co.kr/assist/bill.BillActionNew.do?cmd=';
// 현금영수증 URL (term_id=PGNW 로 끝나므로 site_cd 값을 바로 붙임)
private const CASH_URL_TEST = 'https://testadmin8.kcp.co.kr/Modules/Service/Cash/Cash_Bill_Common_View.jsp?term_id=PGNW';
private const CASH_URL_LIVE = 'https://admin.kcp.co.kr/Modules/Service/Cash/Cash_Bill_Common_View.jsp?term_id=PGNW';
public function __construct(
private readonly PluginSettingsService $pluginSettingsService,
private readonly NhnKcpApiService $apiService,
private readonly GuestOrderAuthService $guestOrderAuthService,
) {}
/**
* GET /api/plugins/sirsoft-pay_nhnkcp/user/orders/{orderNumber}/receipt
*/
/**
* 사용자 마이페이지 KCP 영수증 정보 조회
*
* 결제 영수증 URL(신용카드)과 현금영수증 URL(현금/계좌이체)을 반환한다.
* receipt_url 이 비어있으면 transaction_id (tno) 로 KCP 영수증 URL 을 동적 생성.
*
* @param Request $request 회원 또는 비회원 영수증 요청
* @param string $orderNumber 주문번호
* @return JsonResponse receipt_url / cash_receipt_url 또는 404
*/
public function show(Request $request, string $orderNumber): JsonResponse
{
$user = $request->user();
$query = DB::table('ecommerce_order_payments as p')
->join('ecommerce_orders as o', 'o.id', '=', 'p.order_id')
->where('o.order_number', $orderNumber)
->where('p.pg_provider', 'nhnkcp')
->whereNotNull('p.transaction_id');
if ($user) {
$query->where('o.user_id', $user->id);
} else {
$order = $this->guestOrderAuthService->verifyToken($request->header('X-Guest-Order-Token'), $orderNumber);
if ($order) {
$query->whereNull('o.user_id')->where('o.id', $order->id);
} elseif ($this->verifyReceiptCookie($request->cookie(self::RECEIPT_COOKIE_NAME), $orderNumber)) {
$query->whereNull('o.user_id')
->where('o.id', function ($sub) use ($orderNumber) {
$sub->select('id')->from('ecommerce_orders')->where('order_number', $orderNumber);
});
} else {
return response()->json(['error' => 'Not found'], 404);
}
}
$payment = $query
->select([
'p.transaction_id',
'p.payment_meta',
'p.payment_method',
'p.embedded_pg_provider',
'p.payment_device',
'p.paid_amount_local',
'o.order_number',
])
->first();
if (! $payment || ! $payment->transaction_id) {
return response()->json(['error' => 'Not found'], 404);
}
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
$isTest = (bool) ($settings['is_test_mode'] ?? true);
$billBaseUrl = $isTest ? self::BILL_URL_TEST : self::BILL_URL_LIVE;
$cashBaseUrl = $isTest ? self::CASH_URL_TEST : self::CASH_URL_LIVE;
$tno = $payment->transaction_id;
$orderNo = $payment->order_number;
$tradeMony = (int) round((float) ($payment->paid_amount_local ?? 0));
$method = $payment->payment_method ?? '';
$display = $this->resolvePaymentDisplay($payment);
// 결제수단 분류 — KCP 영수증 cmd 는 결제수단(휴대폰결제 vs 그 외) 으로만 분기.
// 디바이스(PC/모바일) 와 무관 — 모바일 디바이스에서 카드결제해도 card_bill 로 가야 함.
// 그누보드5 settle_kcp_common.php 패턴과 일치.
$isPhonePayment = $method === 'phone';
$isCashMethod = in_array($method, ['bank', 'vbank'], true);
// 휴대폰결제 → mcash_bill, 그 외 (card / bank / vbank) → card_bill
$billCmd = $isPhonePayment ? 'mcash_bill' : 'card_bill';
$receiptUrl = $billBaseUrl . $billCmd
. '&tno=' . urlencode($tno)
. '&order_no=' . urlencode($orderNo)
. '&trade_mony=' . $tradeMony;
// 현금영수증 URL (계좌이체 · 가상계좌만 해당)
$cashReceiptUrl = null;
if ($isCashMethod) {
$meta = $this->decodePaymentMeta($payment->payment_meta ?? null);
$pgRaw = $meta['pg_raw_response'] ?? $meta;
$authNo = $pgRaw['app_no'] ?? $pgRaw['receipt_no'] ?? $tno;
$siteCd = $this->apiService->getSiteCd();
$cashReceiptUrl = $cashBaseUrl . $siteCd
. '&orderid=' . urlencode($orderNo)
. '&bill_yn=Y'
. '&authno=' . urlencode((string) $authNo);
}
return response()->json([
'receipt_url' => $receiptUrl,
'cash_receipt_url' => $cashReceiptUrl,
'is_test_mode' => $isTest,
'payment_method_label' => $display['payment_method_label'],
'payment_method_display_label' => $display['payment_method_display_label'],
'selected_payment_method' => $display['selected_payment_method'],
'embedded_pg_provider' => $display['embedded_pg_provider'],
'embedded_pg_provider_label' => $display['embedded_pg_provider_label'],
]);
}
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Controllers;
use App\Services\PluginSettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
/**
* 테스트 모드 가상계좌 모의입금처리 정보 제공
*
* 조건: 테스트 모드 ON + 가상계좌 결제 + 입금대기 상태
*/
class UserVbankMockDepositController
{
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
private const KCP_TEST_MOCK_URL = 'https://testadmin.kcp.co.kr/Modules/Noti/TEST_Vcnt_Noti.jsp';
private const VBANK_NOTIFY_PATH = '/plugins/sirsoft-pay_nhnkcp/payment/vbank-notify';
public function __construct(
private readonly PluginSettingsService $pluginSettingsService,
private readonly OrderProcessingService $orderService,
) {}
/**
* 테스트 모드 가상계좌 모의입금 처리 화면 데이터
*
* 테스트 환경에서 가상계좌 입금을 시뮬레이션할 수 있도록 폼 데이터 반환.
*
* @param Request $request 인증된 사용자 요청
* @param string $orderNumber 주문번호
* @return JsonResponse 가상계좌 폼 데이터 또는 404
*/
public function show(Request $request, string $orderNumber): JsonResponse
{
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
if (! ($settings['is_test_mode'] ?? true)) {
return response()->json(['available' => false]);
}
$order = $this->orderService->findByOrderNumber($orderNumber);
if (! $order) {
return response()->json(['available' => false]);
}
$user = $request->user();
if (! $user) {
return response()->json(['available' => false]);
}
$isAdmin = method_exists($user, 'isAdmin') && $user->isAdmin();
$isOwner = (int) $order->user_id === (int) $user->id;
if (! $isAdmin && ! $isOwner) {
return response()->json(['available' => false]);
}
$payment = $order->payment;
if (! $payment) {
return response()->json(['available' => false]);
}
$isVbank = in_array($payment->payment_method?->value, ['vbank', 'virtual_account'], true);
// 가상계좌 계좌번호가 발급됐고 아직 입금완료가 아닌 상태
// (handleVbankIssued는 payment_status를 ready로 유지하므로 isPaid 역확인)
if (! $isVbank || ! $payment->vbank_number || $payment->isPaid()) {
return response()->json(['available' => false]);
}
// transaction_id(tno)가 없으면 payment_meta에서 fallback
$meta = $payment->payment_meta ?? [];
$tradeNo = $payment->transaction_id
?? (is_array($meta) ? ($meta['tno'] ?? '') : '');
return response()->json([
'available' => true,
'trade_no' => $tradeNo,
'account_no' => $payment->vbank_number,
'notify_url' => url(self::VBANK_NOTIFY_PATH),
'mock_url' => self::KCP_TEST_MOCK_URL,
'is_admin_view' => $isAdmin,
]);
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Exceptions;
use RuntimeException;
/**
* NHN KCP PG API 호출 실패 예외
*
* KCP CLI / Standard Pay API 호출 단계에서 발생하는 모든 실패 (CLI 비정상 종료,
* 응답 res_cd 비정상, 응답 파싱 실패 등) 를 단일 도메인 예외로 통합한다.
*
* 베이스 \Exception / \RuntimeException 직접 throw 대신 본 클래스를 사용해
* 외부 소비자가 NHN KCP 도메인 오류만 선택적으로 catch 할 수 있도록 한다.
*/
class NhnKcpApiException extends RuntimeException
{
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Http\Middleware;
use App\Services\PluginSettingsService;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
/**
* KCP webhook 발신 IP 화이트리스트 가드 (그누보드5 settle_kcp_common.php 참고)
*
* 적용 라우트: vbank-notify / escrow-common-notify
* (결제창 콜백 authCallback 은 브라우저 POST 라 IP 검증 안 함)
*
* 테스트 모드 (is_test_mode=true): 우회 — 개발 환경에서 KCP testadmin 외부 발신 IP
* 에서 오는 모의입금 webhook 또는 PO 의 직접 테스트 시도를 허용 (그누보드5
* settle_kcp_common.php 의 $default['de_card_test'] 분기와 동일 정책).
* 운영 모드 (is_test_mode=false): 화이트리스트 외 요청 403 차단.
*/
class RestrictKcpIp
{
/**
* KCP 공식 발신 IP 목록 — 그누보드5 settle_kcp_common.php 의 화이트리스트와 동일
*/
private const KCP_NOTIFY_IPS = [
'203.238.36.58',
'203.238.36.160',
'203.238.36.161',
'203.238.36.173',
'203.238.36.178',
// 판교 IDC IP (2019-04-03 추가)
'103.215.144.173',
'103.215.144.174',
'103.215.145.30',
'210.122.72.173',
];
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
public function __construct(
private readonly PluginSettingsService $pluginSettingsService,
) {}
/**
* 요청 IP 검증 — 운영 모드 시 KCP 공식 발신 IP 외 모든 요청 403 차단
*
* @param Request $request 유입 요청
* @param Closure $next 다음 미들웨어
* @return Response 통과 시 다음 미들웨어 응답, 차단 시 403
*/
public function handle(Request $request, Closure $next): Response
{
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
// 테스트 모드는 명시적으로 켜진 경우에만 우회한다.
// 설정 누락/오설정은 운영 모드처럼 fail-closed 로 처리해 webhook 무인증 노출을 막는다.
if (array_key_exists('is_test_mode', $settings) && (bool) $settings['is_test_mode']) {
return $next($request);
}
$clientIp = $request->ip() ?? '';
if (! in_array($clientIp, self::KCP_NOTIFY_IPS, true)) {
Log::warning('KCP: webhook from unauthorized IP — blocked', [
'ip' => $clientIp,
'url' => $request->fullUrl(),
'user_agent' => $request->userAgent(),
]);
abort(403, 'Forbidden');
}
return $next($request);
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Http\Requests;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Support\Facades\Log;
/**
* KCP 결제 승인 콜백 요청 검증
*
* POST /plugins/sirsoft-pay_nhnkcp/payment/callback
*
* KCP가 브라우저를 통해 POST 방식으로 전달하는 파라미터입니다.
*/
class AuthCallbackRequest extends FormRequest
{
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
/**
* 콜백 요청 인가 — 외부 PG 호출이라 항상 true
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* KCP 콜백 페이로드 검증 규칙
*
* @return array<string, mixed> Laravel 검증 규칙
*/
public function rules(): array
{
return [
'res_cd' => ['present', 'nullable', 'string'],
'res_msg' => ['nullable', 'string'],
'enc_data' => ['nullable', 'string'],
'enc_info' => ['nullable', 'string'],
'tno' => ['nullable', 'string'],
'ordr_idxx' => ['required', 'string'],
'good_mny' => ['nullable', 'numeric', 'min:1'],
'use_pay_method' => ['nullable', 'string'],
'param_opt_1' => ['nullable', 'string', 'max:50'],
'nhnkcp_easy_pay_method' => ['nullable', 'string', 'max:50'],
// 모바일 SmartPhone Pay 가상계좌 콜백 (enc_data 없이 평문 전달)
// KCP가 보내는 변종 키 모두 허용 — handleVbankIssued 에서 우선순위로 처리
'bankname' => ['nullable', 'string'],
'bank_name' => ['nullable', 'string'],
'account' => ['nullable', 'string'],
'depositor' => ['nullable', 'string'],
'account_holder' => ['nullable', 'string'],
'va_date' => ['nullable', 'string'],
'vnbank_expire_date' => ['nullable', 'string'],
];
}
protected function failedValidation(Validator $validator): void
{
Log::warning('KCP: authCallback validation failed', [
'errors' => $validator->errors()->toArray(),
'input' => array_keys($this->all()),
]);
$settings = plugin_settings(self::PLUGIN_IDENTIFIER);
$baseUrl = $settings['redirect_fail_url'] ?? '/shop/checkout';
$separator = str_contains($baseUrl, '?') ? '&' : '?';
throw new HttpResponseException(
redirect($baseUrl . $separator . http_build_query(['error' => 'invalid_params']))
);
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* KCP 에스크로 공통 통보 요청 검증
*
* KCP가 구매확인(TX02) 또는 배송시작(TX03) 이벤트 발생 시
* 등록된 공통통보 URL로 POST 요청을 보냅니다.
*/
class EscrowCommonNotifyRequest extends FormRequest
{
/**
* FormRequest 인가 — 외부 PG 웹훅이라 항상 true
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* KCP 에스크로 통보 페이로드 검증 규칙 (KCP 공식 webhook 명세)
*
* KCP 결제창 콜백과 달리 통보 webhook 은 order_no 키를 사용한다
* (그누보드5 settle_kcp_common.php 와 동일). ordr_idxx 사용 시 422 차단.
*
* @return array<string, mixed> Laravel 검증 규칙
*/
public function rules(): array
{
return [
'site_cd' => ['nullable', 'string'],
'tno' => ['nullable', 'string'],
'order_no' => ['required', 'string'],
'tx_cd' => ['required', 'string'],
'tx_tm' => ['nullable', 'string'],
'cl_status' => ['nullable', 'string'],
'st_cd' => ['nullable', 'string'],
'can_msg' => ['nullable', 'string'],
'waybill_no' => ['nullable', 'string'],
'waybill_corp' => ['nullable', 'string'],
];
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class PaymentCloseReportRequest extends FormRequest
{
/**
* 결제창 닫힘 보고 요청을 허용합니다.
*
* @return bool 요청 허용 여부
*/
public function authorize(): bool
{
return true;
}
/**
* 결제창 닫힘 보고 요청 검증 규칙을 반환합니다.
*
* @return array<string, array<int, string>> 필드별 검증 규칙
*/
public function rules(): array
{
return [
'oid' => ['required', 'string', 'max:40'],
'price' => ['required', 'integer', 'min:1'],
'buyer_email' => ['nullable', 'string', 'max:255'],
'buyer_phone' => ['nullable', 'string', 'max:30'],
'payment_method' => ['nullable', 'string', 'max:50'],
'reason' => ['nullable', 'string', 'max:160'],
];
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class PaymentRetryRequest extends FormRequest
{
/**
* 결제 재시도 준비 요청을 허용합니다.
*
* @return bool 요청 허용 여부
*/
public function authorize(): bool
{
return true;
}
/**
* 결제 재시도 준비 요청 검증 규칙을 반환합니다.
*
* @return array<string, array<int, string>> 필드별 검증 규칙
*/
public function rules(): array
{
return [
'oid' => ['required', 'string', 'max:40'],
'price' => ['required', 'integer', 'min:1'],
'buyer_email' => ['nullable', 'string', 'max:255'],
'buyer_phone' => ['nullable', 'string', 'max:30'],
'payment_method' => ['nullable', 'string', 'max:50'],
];
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* KCP 가상계좌 입금 통보 요청 검증
*
* POST /plugins/sirsoft-pay_nhnkcp/payment/vbank-notify
*
* KCP 서버가 직접 호출하는 입금 확인 웹훅입니다.
*/
class VbankNotifyRequest extends FormRequest
{
/**
* FormRequest 인가 — 외부 PG 웹훅이라 항상 true
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* KCP 공통통보 페이로드 검증 규칙 (KCP 공식 webhook 명세)
*
* 키 체계는 KCP 결제창 콜백(authCallback) 과 다르므로 주의 — 결제창은 ordr_idxx/good_mny/res_cd
* 를 쓰지만, 통보 webhook 은 order_no/ipgm_mnyx/op_cd 를 쓴다.
*
* tx_cd 분기 (그누보드5 settle_kcp_common.php 참고):
* TX00 = 가상계좌 입금 통보 (본 endpoint 가 처리)
* TX01 = 가상계좌 환불 통보
* TX02 = 구매확인/구매취소 통보
* TX03 = 배송시작 통보
*
* 본 endpoint 는 TX00 만 처리하지만 KCP 가 단일 URL 로 모든 tx_cd 를 보낼 수 있으므로
* tx_cd 자체는 nullable 로 받고 컨트롤러에서 분기.
*
* @return array<string, mixed> Laravel 검증 규칙
*/
public function rules(): array
{
return [
'site_cd' => ['nullable', 'string'],
'tno' => ['required', 'string'],
'order_no' => ['required', 'string'],
'tx_cd' => ['nullable', 'string'],
'tx_tm' => ['nullable', 'string'],
'ipgm_name' => ['nullable', 'string'],
'remitter' => ['nullable', 'string'],
'ipgm_mnyx' => ['nullable', 'numeric'],
'bank_code' => ['nullable', 'string'],
'account' => ['nullable', 'string'],
'op_cd' => ['nullable', 'string'],
'noti_id' => ['nullable', 'string'],
];
}
}
@@ -0,0 +1,332 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Listeners;
use App\Contracts\Extension\HookListenerInterface;
/**
* 이커머스 결제수단 설정 화면에서 NHN KCP 간편결제를 PG 선택 불필요 항목으로 표시한다.
*/
class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterface
{
private const TARGET_LAYOUT = 'admin_ecommerce_settings';
private const TEST_MODE_DATA_SOURCE_ID = 'nhnkcp_test_mode_status';
private const TEST_MODE_NOTICE_ID = 'payment_test_mode_order_settings_notice';
private const TEST_MODE_NOTICE_ROW_ID = 'nhnkcp_test_mode_order_settings_notice';
private const TEST_MODE_CONDITION = 'nhnkcp_test_mode_status.data?.is_test_mode === true';
private const CORE_NO_PG_METHODS = ['point', 'deposit', 'free', 'dbank'];
private const NHNKCP_NO_PG_METHODS = [
'nhnkcp_payco',
'nhnkcp_naverpay',
'nhnkcp_naverpay_point',
'nhnkcp_kakaopay',
'nhnkcp_applepay',
];
/**
* @return array<string, array<string, mixed>>
*/
public static function getSubscribedHooks(): array
{
return [
'core.layout_extension.after_apply' => [
'method' => 'markEasyPayMethodsAsPgNotRequired',
'type' => 'filter',
'priority' => 30,
],
];
}
/**
* @param mixed ...$args
*/
public function handle(...$args): void {}
/**
* @param array<string, mixed> $layout
* @param int $templateId
* @return array<string, mixed>
*/
public function markEasyPayMethodsAsPgNotRequired(array $layout, int $templateId): array
{
if (($layout['layout_name'] ?? '') !== self::TARGET_LAYOUT) {
return $layout;
}
$layout = $this->replaceNoPgMethodExpressions($layout);
return $this->ensureTestModeWarning($layout);
}
/**
* @param array<string, mixed> $node
* @return array<string, mixed>
*/
private function replaceNoPgMethodExpressions(array $node): array
{
foreach ($node as $key => $value) {
if (is_array($value)) {
$node[$key] = $this->replaceNoPgMethodExpressions($value);
continue;
}
if (is_string($value)) {
$node[$key] = $this->addNhnKcpMethodsToNoPgArray($value);
}
}
return $node;
}
private function addNhnKcpMethodsToNoPgArray(string $expression): string
{
return preg_replace_callback(
'/\[((?:\'[^\']+\'\s*,?\s*)+)\]\.includes\(\$method\.id\)/',
function (array $matches): string {
preg_match_all("/'([^']+)'/", $matches[1], $tokenMatches);
$ids = $tokenMatches[1] ?? [];
foreach (self::CORE_NO_PG_METHODS as $coreId) {
if (! in_array($coreId, $ids, true)) {
return $matches[0];
}
}
foreach (self::NHNKCP_NO_PG_METHODS as $methodId) {
if (! in_array($methodId, $ids, true)) {
$ids[] = $methodId;
}
}
return "['" . implode("','", $ids) . "'].includes(\$method.id)";
},
$expression
) ?? $expression;
}
/**
* @param array<string, mixed> $layout
* @return array<string, mixed>
*/
private function ensureTestModeWarning(array $layout): array
{
$layout = $this->ensureTestModeDataSource($layout);
return $this->insertTestModeNotice($layout);
}
/**
* @param array<string, mixed> $layout
* @return array<string, mixed>
*/
private function ensureTestModeDataSource(array $layout): array
{
$dataSources = is_array($layout['data_sources'] ?? null) ? $layout['data_sources'] : [];
foreach ($dataSources as $source) {
if (is_array($source) && ($source['id'] ?? null) === self::TEST_MODE_DATA_SOURCE_ID) {
$layout['data_sources'] = $dataSources;
return $layout;
}
}
$dataSources[] = [
'id' => self::TEST_MODE_DATA_SOURCE_ID,
'label_key' => '$t:sirsoft-pay_nhnkcp.editor.data_source.nhnkcp_test_mode_status',
'type' => 'api',
'endpoint' => '/api/plugins/sirsoft-pay_nhnkcp/admin/settings/test-mode-status',
'method' => 'GET',
'auto_fetch' => true,
'auth_required' => true,
];
$layout['data_sources'] = $dataSources;
return $layout;
}
/**
* @param array<string, mixed> $node
* @return array<string, mixed>
*/
private function insertTestModeNotice(array $node): array
{
if (($node['id'] ?? null) === 'tab_content_order_settings' && is_array($node['children'] ?? null)) {
$noticeIndex = $this->findChildIndexById($node['children'], self::TEST_MODE_NOTICE_ID);
if ($noticeIndex === null) {
$insertAt = $this->findChildIndexById($node['children'], 'payment_methods_card') ?? count($node['children']);
array_splice($node['children'], $insertAt, 0, [$this->testModeNoticeContainerNode()]);
$noticeIndex = $insertAt;
}
if (is_array($node['children'][$noticeIndex] ?? null)) {
$notice = $node['children'][$noticeIndex];
$notice['if'] = $this->mergeTestModeCondition((string) ($notice['if'] ?? ''));
$node['children'][$noticeIndex] = $this->appendTestModeNoticeRow($notice);
}
return $node;
}
foreach ($node as $key => $value) {
if (is_array($value)) {
$node[$key] = $this->insertTestModeNotice($value);
}
}
return $node;
}
/**
* @param array<int, mixed> $children
*/
private function findChildIndexById(array $children, string $id): ?int
{
foreach ($children as $index => $child) {
if (is_array($child) && ($child['id'] ?? null) === $id) {
return $index;
}
}
return null;
}
/**
* @param array<int, mixed> $children
*/
private function hasChildWithId(array $children, string $id): bool
{
return $this->findChildIndexById($children, $id) !== null;
}
private function mergeTestModeCondition(string $condition): string
{
if (str_contains($condition, self::TEST_MODE_CONDITION)) {
return $condition !== '' ? $condition : '{{' . self::TEST_MODE_CONDITION . '}}';
}
$inner = trim($condition);
if (str_starts_with($inner, '{{') && str_ends_with($inner, '}}')) {
$inner = trim(substr($inner, 2, -2));
}
if ($inner === '') {
return '{{' . self::TEST_MODE_CONDITION . '}}';
}
return '{{' . $inner . ' || ' . self::TEST_MODE_CONDITION . '}}';
}
/**
* @param array<string, mixed> $notice
* @return array<string, mixed>
*/
private function appendTestModeNoticeRow(array $notice): array
{
$children = is_array($notice['children'] ?? null) ? $notice['children'] : [];
if (! $this->hasChildWithId($children, self::TEST_MODE_NOTICE_ROW_ID)) {
$children[] = $this->testModeNoticeRowNode();
}
$notice['children'] = $children;
return $notice;
}
/**
* @return array<string, mixed>
*/
private function testModeNoticeContainerNode(): array
{
return [
'id' => self::TEST_MODE_NOTICE_ID,
'type' => 'basic',
'name' => 'Div',
'if' => '{{' . self::TEST_MODE_CONDITION . '}}',
'props' => [
'className' => 'mb-4 space-y-3 rounded-lg border border-orange-200 bg-orange-50 p-4 dark:border-orange-700 dark:bg-orange-900/20',
],
'children' => [
$this->testModeNoticeRowNode(),
],
];
}
/**
* @return array<string, mixed>
*/
private function testModeNoticeRowNode(): array
{
return [
'id' => self::TEST_MODE_NOTICE_ROW_ID,
'type' => 'basic',
'name' => 'Div',
'if' => '{{' . self::TEST_MODE_CONDITION . '}}',
'props' => [
'className' => 'flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between',
],
'children' => [
[
'type' => 'basic',
'name' => 'Div',
'props' => [
'className' => 'min-w-0',
],
'children' => [
[
'type' => 'basic',
'name' => 'Div',
'props' => [
'className' => 'text-sm font-medium text-orange-950 dark:text-orange-50',
],
'text' => '$t:sirsoft-pay_nhnkcp.admin.test_mode_settings_warning_plugin',
],
[
'type' => 'basic',
'name' => 'P',
'props' => [
'className' => 'mt-0.5 text-sm leading-6 text-orange-800 dark:text-orange-200',
],
'text' => '$t:sirsoft-pay_nhnkcp.admin.test_mode_settings_warning_body',
],
],
],
[
'type' => 'basic',
'name' => 'Button',
'props' => [
'type' => 'button',
'className' => 'btn btn-outline btn-sm w-full flex-shrink-0 border-orange-300 text-orange-900 hover:bg-orange-100 sm:w-auto dark:border-orange-600 dark:text-orange-100 dark:hover:bg-orange-900/40',
],
'actions' => [
[
'type' => 'click',
'handler' => 'navigate',
'params' => [
'path' => '/admin/plugins/sirsoft-pay_nhnkcp/settings',
],
],
],
'children' => [
[
'type' => 'basic',
'name' => 'Span',
'text' => '$t:sirsoft-pay_nhnkcp.admin.test_mode_settings_warning_action',
],
],
],
],
];
}
}
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Listeners;
use App\ActivityLog\Traits\ResolvesActivityLogType;
use App\Contracts\Extension\HookListenerInterface;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
/**
* NHN KCP PG 결제 취소 활동 로그 리스너.
*
* 코어의 sirsoft-ecommerce.payment.refund 필터 훅을 priority 20 으로 구독해
* PaymentRefundListener (priority 10) 가 KCP cancel API 호출에 성공한 직후 실행.
*
* 코어가 이미 기록하는 order.cancel (주문 단위 취소 의도) 와 별도로,
* "PG 가 실제로 취소를 확인했다" 는 별도 이벤트를 활동 로그에 보존한다.
*/
class CancelActivityLogListener implements HookListenerInterface
{
use ResolvesActivityLogType;
private const PG_PROVIDER_ID = 'nhnkcp';
/**
* 구독할 훅 매핑 반환
*
* @return array<string, array<string, mixed>> 훅 구독 설정
*/
public static function getSubscribedHooks(): array
{
return [
'sirsoft-ecommerce.payment.refund' => [
'method' => 'logCancelConfirmed',
'type' => 'filter',
'priority' => 20,
],
];
}
/**
* 기본 핸들러 (미사용)
*
* @param mixed ...$args 훅 인수
*/
public function handle(...$args): void {}
/**
* PG 가 결제 취소를 확인한 직후 활동 로그를 기록한다.
*
* @param array $result 이전 필터 누적 결과
* @param Order $order 대상 주문
* @param OrderPayment $payment 대상 결제 정보
* @param float $refundAmount 환불 금액
* @param string|null $reason 취소 사유
* @return array 결과 통과 (mutation 없음)
*/
public function logCancelConfirmed(
array $result,
Order $order,
OrderPayment $payment,
float $refundAmount,
?string $reason = null,
): array {
if ($payment->pg_provider !== self::PG_PROVIDER_ID) {
return $result;
}
if (empty($result['success'])) {
return $result;
}
$this->logActivity('payment.cancel', [
'loggable' => $order,
'description_key' => 'sirsoft-pay_nhnkcp::activity_log.description.payment_cancel',
'description_params' => [
'order_number' => $order->order_number,
'refund_amount' => number_format((int) $refundAmount),
],
'properties' => [
'tno' => $payment->transaction_id,
'cancel_tno' => $result['transaction_id'] ?? null,
'refund_amount' => $refundAmount,
'reason' => $reason,
],
]);
return $result;
}
}
@@ -0,0 +1,338 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Listeners;
use App\Contracts\Extension\HookListenerInterface;
class EnsureAdminOrderDetailPaymentQueryLayoutListener implements HookListenerInterface
{
private const TARGET_LAYOUT = 'admin_ecommerce_order_detail';
private const NOTICE_ID = 'nhnkcp_test_mode_notice';
private const HEADER_TARGET_IDS = [
'sticky_header_container',
'page_header_section',
'page_header',
'order_detail_wrapper',
];
private const BODY_TARGET_IDS = [
'tab_content_area',
'order_detail_wrapper',
];
/**
* @return array<string, array<string, mixed>>
*/
public static function getSubscribedHooks(): array
{
return [
'core.layout_extension.after_apply' => [
'method' => 'ensurePaymentQueryLayout',
'type' => 'filter',
'priority' => 66,
],
];
}
/**
* @param mixed ...$args
*/
public function handle(...$args): void {}
/**
* @param array<string, mixed> $layout
* @param int $templateId
* @return array<string, mixed>
*/
public function ensurePaymentQueryLayout(array $layout, int $templateId): array
{
if (($layout['layout_name'] ?? '') !== self::TARGET_LAYOUT) {
return $layout;
}
$extension = $this->orderPaymentQueryExtension();
$layout = $this->ensureDataSources($layout, $extension['data_sources'] ?? []);
$this->ensureInjections($layout, $extension['injections'] ?? []);
return $layout;
}
/**
* @param array<string, mixed> $layout
* @param array<int, mixed> $extensionDataSources
* @return array<string, mixed>
*/
private function ensureDataSources(array $layout, array $extensionDataSources): array
{
$dataSources = isset($layout['data_sources']) && is_array($layout['data_sources'])
? $layout['data_sources']
: [];
$existingIds = [];
foreach ($dataSources as $dataSource) {
if (is_array($dataSource) && is_string($dataSource['id'] ?? null)) {
$existingIds[$dataSource['id']] = true;
}
}
foreach ($extensionDataSources as $dataSource) {
if (! is_array($dataSource) || ! is_string($dataSource['id'] ?? null)) {
continue;
}
if (isset($existingIds[$dataSource['id']])) {
continue;
}
$dataSources[] = $dataSource;
$existingIds[$dataSource['id']] = true;
}
$layout['data_sources'] = $dataSources;
return $layout;
}
/**
* @param array<string, mixed> $layout
* @param array<int, mixed> $injections
*/
private function ensureInjections(array &$layout, array $injections): void
{
foreach ($injections as $injection) {
if (! is_array($injection) || ! isset($injection['components']) || ! is_array($injection['components'])) {
continue;
}
$position = is_string($injection['position'] ?? null) ? $injection['position'] : 'append_child';
$components = $this->normalizeComponents($injection['components']);
$targetIds = $this->isHeaderInjection($injection) ? self::HEADER_TARGET_IDS : self::BODY_TARGET_IDS;
if ($this->appendComponentsToFirstAvailableTarget($layout, $targetIds, $components, $position)) {
continue;
}
if (! isset($layout['slots']['content']) || ! is_array($layout['slots']['content'])) {
$layout['slots']['content'] = [];
}
$this->mergeMissingComponents($layout['slots']['content'], $components, $position);
}
}
/**
* @param array<string, mixed> $injection
*/
private function isHeaderInjection(array $injection): bool
{
return in_array($injection['target_id'] ?? null, self::HEADER_TARGET_IDS, true);
}
/**
* @param array<string, mixed> $node
* @param array<int, string> $targetIds
* @param array<int, mixed> $components
*/
private function appendComponentsToFirstAvailableTarget(
array &$node,
array $targetIds,
array $components,
string $position
): bool {
foreach ($targetIds as $targetId) {
if ($this->appendComponentsToTargetId($node, $targetId, $components, $position)) {
return true;
}
}
return false;
}
/**
* @param array<string, mixed> $node
* @param array<int, mixed> $components
*/
private function appendComponentsToTargetId(
array &$node,
string $targetId,
array $components,
string $position
): bool {
if (($node['id'] ?? null) === $targetId) {
if (! isset($node['children']) || ! is_array($node['children'])) {
$node['children'] = [];
}
$this->mergeMissingComponents($node['children'], $components, $position);
return true;
}
foreach ($node as &$value) {
if (is_array($value) && $this->appendComponentsToTargetId($value, $targetId, $components, $position)) {
return true;
}
}
unset($value);
return false;
}
/**
* @param array<int, mixed> $target
* @param array<int, mixed> $components
*/
private function mergeMissingComponents(array &$target, array $components, string $position): void
{
$missing = [];
foreach ($components as $component) {
if (! is_array($component) || $this->containsComponent($target, $component)) {
continue;
}
$missing[] = $component;
}
if ($missing === []) {
return;
}
if (str_starts_with($position, 'prepend')) {
$target = array_merge($missing, $target);
return;
}
array_push($target, ...$missing);
}
/**
* @param array<int, mixed> $components
* @return array<int, mixed>
*/
private function normalizeComponents(array $components): array
{
return array_map(function ($component) {
if (
is_array($component)
&& ! isset($component['id'])
&& $this->containsString($component, 'sirsoft-pay_nhnkcp.admin.test_mode_order_notice')
) {
$component['id'] = self::NOTICE_ID;
}
return $component;
}, $components);
}
/**
* @param array<string|int, mixed> $node
* @param array<string, mixed> $component
*/
private function containsComponent(array $node, array $component): bool
{
if (is_string($component['id'] ?? null) && $this->containsNodeId($node, $component['id'])) {
return true;
}
foreach ($this->componentSignatures($component) as $signature) {
if ($this->containsString($node, $signature)) {
return true;
}
}
$targetJson = json_encode($node, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$componentJson = json_encode($component, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return is_string($targetJson)
&& is_string($componentJson)
&& $componentJson !== ''
&& str_contains($targetJson, $componentJson);
}
/**
* @param array<string, mixed> $component
* @return array<int, string>
*/
private function componentSignatures(array $component): array
{
$json = json_encode($component, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (! is_string($json)) {
return [];
}
$signatures = [];
foreach ([
'sirsoft-pay_nhnkcp.admin.test_mode_order_notice',
'sirsoft-pay_nhnkcp.admin.order_info_title',
'sirsoft-pay_nhnkcp.admin.escrow_delivery_title',
] as $candidate) {
if (str_contains($json, $candidate)) {
$signatures[] = $candidate;
}
}
return $signatures;
}
/**
* @param array<string|int, mixed> $node
*/
private function containsNodeId(array $node, string $id): bool
{
if (($node['id'] ?? null) === $id) {
return true;
}
foreach ($node as $value) {
if (is_array($value) && $this->containsNodeId($value, $id)) {
return true;
}
}
return false;
}
/**
* @param array<string|int, mixed> $node
*/
private function containsString(array $node, string $needle): bool
{
foreach ($node as $value) {
if (is_array($value) && $this->containsString($value, $needle)) {
return true;
}
if (is_string($value) && str_contains($value, $needle)) {
return true;
}
}
return false;
}
/**
* @return array<string, mixed>
*/
private function orderPaymentQueryExtension(): array
{
$path = dirname(__DIR__, 2).'/resources/extensions/admin_order_payment_query.json';
$contents = is_file($path) ? file_get_contents($path) : false;
if (! is_string($contents)) {
return [];
}
$decoded = json_decode($contents, true);
return is_array($decoded) ? $decoded : [];
}
}
@@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Listeners;
use App\Contracts\Extension\HookListenerInterface;
class EnsureAdminOrderListTestBadgeLayoutListener implements HookListenerInterface
{
private const TARGET_LAYOUT = 'admin_ecommerce_order_list';
private const ORDER_GRID_ID = 'order_datagrid';
private const DATA_SOURCE_ID = 'nhnkcp_test_map';
private const EASY_PAY_DISPLAY_DATA_SOURCE_ID = 'nhnkcp_easy_pay_display_map';
private const BADGE_CONDITION = 'nhnkcp_test_map.data?.[row.order_number] === true';
private const EASY_PAY_DISPLAY_EXPRESSION = 'nhnkcp_easy_pay_display_map.data?.[row.order_number]?.payment_method_display_label ?? row.payment?.payment_method_label';
/**
* @return array<string, array<string, mixed>>
*/
public static function getSubscribedHooks(): array
{
return [
'core.layout_extension.after_apply' => [
'method' => 'ensureTestBadgeLayout',
'type' => 'filter',
'priority' => 60,
],
];
}
/**
* @param mixed ...$args
*/
public function handle(...$args): void {}
/**
* @param array<string, mixed> $layout
* @param int $templateId
* @return array<string, mixed>
*/
public function ensureTestBadgeLayout(array $layout, int $templateId): array
{
if (($layout['layout_name'] ?? '') !== self::TARGET_LAYOUT) {
return $layout;
}
$layout = $this->ensureDataSource($layout);
if (isset($layout['components']) && is_array($layout['components'])) {
$this->ensureOrderGridBadge($layout['components']);
}
return $layout;
}
/**
* @param array<string, mixed> $layout
* @return array<string, mixed>
*/
private function ensureDataSource(array $layout): array
{
$dataSources = isset($layout['data_sources']) && is_array($layout['data_sources'])
? $layout['data_sources']
: [];
$existingIds = [];
foreach ($dataSources as $dataSource) {
if (is_array($dataSource) && is_string($dataSource['id'] ?? null)) {
$existingIds[$dataSource['id']] = true;
}
}
if (! isset($existingIds[self::DATA_SOURCE_ID])) {
$dataSources[] = [
'id' => self::DATA_SOURCE_ID,
'label_key' => '$t:sirsoft-pay_nhnkcp.editor.data_source.nhnkcp_test_map',
'type' => 'api',
'endpoint' => '/api/plugins/sirsoft-pay_nhnkcp/admin/orders/test-mode-map',
'method' => 'GET',
'auto_fetch' => true,
'auth_required' => true,
'loading_strategy' => 'progressive',
];
}
if (! isset($existingIds[self::EASY_PAY_DISPLAY_DATA_SOURCE_ID])) {
$dataSources[] = [
'id' => self::EASY_PAY_DISPLAY_DATA_SOURCE_ID,
'label_key' => '$t:sirsoft-pay_nhnkcp.editor.data_source.nhnkcp_easy_pay_display_map',
'type' => 'api',
'endpoint' => '/api/plugins/sirsoft-pay_nhnkcp/admin/orders/easy-pay-display-map',
'method' => 'GET',
'auto_fetch' => true,
'auth_required' => true,
'loading_strategy' => 'progressive',
];
}
$layout['data_sources'] = $dataSources;
return $layout;
}
/**
* @param array<int, mixed> $components
*/
private function ensureOrderGridBadge(array &$components): bool
{
foreach ($components as &$component) {
if (! is_array($component)) {
continue;
}
if (($component['id'] ?? '') === self::ORDER_GRID_ID) {
$this->ensurePaymentMethodColumnBadge($component);
return true;
}
if (isset($component['children']) && is_array($component['children'])) {
if ($this->ensureOrderGridBadge($component['children'])) {
return true;
}
}
}
unset($component);
return false;
}
/**
* @param array<string, mixed> $grid
*/
private function ensurePaymentMethodColumnBadge(array &$grid): void
{
if (! isset($grid['props']['columns']) || ! is_array($grid['props']['columns'])) {
return;
}
foreach ($grid['props']['columns'] as &$column) {
if (! is_array($column) || ($column['field'] ?? null) !== 'payment_method') {
continue;
}
$this->ensurePaymentMethodDisplayExpression($column);
$this->appendBadgeToColumn($column);
return;
}
unset($column);
}
/**
* @param array<string|int, mixed> $node
*/
private function ensurePaymentMethodDisplayExpression(array &$node): bool
{
if (($node['text'] ?? null) === '{{row.payment?.payment_method_label}}') {
$node['text'] = '{{'.self::EASY_PAY_DISPLAY_EXPRESSION.'}}';
return true;
}
foreach ($node as &$value) {
if (is_array($value) && $this->ensurePaymentMethodDisplayExpression($value)) {
return true;
}
}
unset($value);
return false;
}
/**
* @param array<string, mixed> $column
*/
private function appendBadgeToColumn(array &$column): void
{
if ($this->containsNhnKcpBadgeCondition($column)) {
return;
}
if (! isset($column['cellChildren']) || ! is_array($column['cellChildren'])) {
$column['cellChildren'] = [];
}
foreach ($column['cellChildren'] as &$cellChild) {
if (
is_array($cellChild)
&& ($cellChild['name'] ?? null) === 'Div'
&& isset($cellChild['children'])
&& is_array($cellChild['children'])
) {
$cellChild['children'][] = $this->badgeComponent();
return;
}
}
unset($cellChild);
$column['cellChildren'][] = $this->badgeComponent();
}
/**
* @param array<string, mixed> $node
*/
private function containsNhnKcpBadgeCondition(array $node): bool
{
foreach ($node as $value) {
if (is_array($value) && $this->containsNhnKcpBadgeCondition($value)) {
return true;
}
if (is_string($value) && str_contains($value, self::BADGE_CONDITION)) {
return true;
}
}
return false;
}
/**
* @return array<string, mixed>
*/
private function badgeComponent(): array
{
return [
'type' => 'basic',
'name' => 'Span',
'if' => '{{'.self::BADGE_CONDITION.'}}',
'props' => [
'className' => 'text-xs font-medium text-orange-500 dark:text-orange-400 leading-tight',
],
'text' => '($t:sirsoft-pay_nhnkcp.admin.test_mode_badge)',
];
}
}
@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Plugins\Sirsoft\PayNhnkcp\Services\NhnKcpApiService;
class PaymentRefundListener implements HookListenerInterface
{
private const PG_PROVIDER_ID = 'nhnkcp';
/**
* 구독할 훅 매핑 반환
*
* @return array<string, array<string, mixed>> 훅 구독 설정
*/
public static function getSubscribedHooks(): array
{
return [
'sirsoft-ecommerce.payment.refund' => [
'method' => 'processRefund',
'type' => 'filter',
'priority' => 10,
],
];
}
/**
* 기본 핸들러 (미사용 — 개별 메서드에서 처리)
*
* @param mixed ...$args 훅 인수
*/
public function handle(...$args): void {}
/**
* 환불 처리 훅 핸들러 (sirsoft-ecommerce.payment.refund 필터)
*
* KCP 결제 건 (pg_provider=nhnkcp) 만 처리. before_cancel/after_cancel
* 액션 훅을 발행하여 외부 확장 지점 제공.
*
* @param array $result 이전 필터 누적 결과
* @param Order $order 대상 주문
* @param OrderPayment $payment 대상 결제 정보
* @param float $refundAmount 환불 금액
* @param string|null $reason 환불 사유 (없으면 기본 메시지)
* @return array success / error_code / error_message / transaction_id
*/
public function processRefund(
array $result,
Order $order,
OrderPayment $payment,
float $refundAmount,
?string $reason = null,
): array {
if ($payment->pg_provider !== self::PG_PROVIDER_ID) {
return $result;
}
$tno = $payment->transaction_id;
if (! $tno) {
return [
'success' => false,
'error_code' => 'MISSING_TNO',
'error_message' => __('sirsoft-pay_nhnkcp::messages.refund.missing_tid'),
'transaction_id' => null,
];
}
try {
$lock = Cache::lock('nhnkcp:refund:' . $payment->id, 30);
if (! $lock->get()) {
return [
'success' => false,
'error_code' => 'REFUND_IN_PROGRESS',
'error_message' => __('sirsoft-pay_nhnkcp::messages.refund.in_progress'),
'transaction_id' => null,
];
}
try {
$apiService = app(NhnKcpApiService::class);
$this->restorePaymentCredentials($apiService, $payment);
$cancelMsg = $reason ?? __('sirsoft-pay_nhnkcp::messages.refund.default_reason');
$cancelAmt = (int) $refundAmount;
$ordrIdxx = (string) $order->order_number;
// $refundAmount 는 코어가 결제 통화(order_currency)로 환산해 전달한 실환불액이다.
// 누적 취소액도 결제 통화 기준(mc_cancelled_amount[order_currency])으로 맞춰야
// base≠결제통화 주문에서 부분취소 판정·총액이 어긋나 PG 실환불이 잘못 전송되지 않는다.
$paidAmount = (int) $payment->paid_amount_local;
$cumulativeCancelled = (int) round((float) $this->cancelledLocalAmount($payment));
$previousCancelled = max(0, $cumulativeCancelled - $cancelAmt);
$totalAmt = max($cancelAmt, $paidAmount - $previousCancelled);
$isPartial = $previousCancelled > 0 || $cancelAmt < $paidAmount;
$response = $apiService->cancelPayment($tno, $ordrIdxx, $cancelAmt, $cancelMsg, $isPartial, $totalAmt);
Log::info('KCP: refund success', [
'order_id' => $order->id,
'tno' => $tno,
'cancel_amt' => $cancelAmt,
]);
return [
'success' => true,
'error_code' => null,
'error_message' => null,
'transaction_id' => $response['tno'] ?? $tno,
];
} finally {
$lock->release();
}
} catch (\Exception $e) {
Log::error('KCP: refund failed', [
'order_id' => $order->id,
'tno' => $tno,
'cancel_amt' => (int) $refundAmount,
'error' => $e->getMessage(),
]);
return [
'success' => false,
'error_code' => 'PG_API_ERROR',
'error_message' => $e->getMessage(),
'transaction_id' => null,
];
}
}
private function restorePaymentCredentials(NhnKcpApiService $apiService, OrderPayment $payment): void
{
$meta = $payment->payment_meta ?? [];
if (! array_key_exists('is_test_mode', $meta) || empty($meta['site_cd'])) {
return;
}
$apiService->useStoredCredentials((bool) $meta['is_test_mode'], (string) $meta['site_cd']);
}
/**
* 결제 통화(order_currency) 기준 누적 취소액을 반환합니다.
*
* 코어가 결제 통화로 누적한 mc_cancelled_amount[order_currency] 를 우선 사용하고,
* 없으면(레거시 결제) base 누적 cancelled_amount 로 폴백합니다.
*
* @param OrderPayment $payment 결제 레코드
* @return float 결제 통화 기준 누적 취소액
*/
private function cancelledLocalAmount(OrderPayment $payment): float
{
$currency = $payment->currency;
$mc = $payment->mc_cancelled_amount ?? [];
if ($currency !== null && isset($mc[$currency])) {
return (float) $mc[$currency];
}
return (float) $payment->cancelled_amount;
}
}
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Listeners;
use App\Contracts\Extension\HookListenerInterface;
/**
* NHN KCP 간편결제를 이커머스 결제수단 목록에 등록한다.
*
* 각 결제수단 ID는 프론트 requestPaymentHandler 의 KCP_EASY_PAY_DIRECT 매핑과 일치한다.
*/
class RegisterEasyPayMethodsListener implements HookListenerInterface
{
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
private const EASY_PAY_METHODS = [
[
'id' => 'nhnkcp_payco',
'name_key' => 'sirsoft-pay_nhnkcp::payment_methods.payco.name',
'description_key' => 'sirsoft-pay_nhnkcp::payment_methods.payco.description',
'icon' => 'wallet',
],
[
'id' => 'nhnkcp_naverpay',
'name_key' => 'sirsoft-pay_nhnkcp::payment_methods.naverpay.name',
'description_key' => 'sirsoft-pay_nhnkcp::payment_methods.naverpay.description',
'icon' => 'wallet',
],
[
'id' => 'nhnkcp_naverpay_point',
'name_key' => 'sirsoft-pay_nhnkcp::payment_methods.naverpay_point.name',
'description_key' => 'sirsoft-pay_nhnkcp::payment_methods.naverpay_point.description',
'icon' => 'wallet-cards',
],
[
'id' => 'nhnkcp_kakaopay',
'name_key' => 'sirsoft-pay_nhnkcp::payment_methods.kakaopay.name',
'description_key' => 'sirsoft-pay_nhnkcp::payment_methods.kakaopay.description',
'icon' => 'message-circle',
],
[
'id' => 'nhnkcp_applepay',
'name_key' => 'sirsoft-pay_nhnkcp::payment_methods.applepay.name',
'description_key' => 'sirsoft-pay_nhnkcp::payment_methods.applepay.description',
'icon' => 'smartphone',
],
];
/**
* @return array<string, array<string, mixed>>
*/
public static function getSubscribedHooks(): array
{
return [
'sirsoft-ecommerce.settings.filter_available_payment_methods' => [
'method' => 'injectEasyPayMethods',
'type' => 'filter',
'priority' => 30,
],
];
}
/**
* @param mixed ...$args
*/
public function handle(...$args): void {}
/**
* @param array<int, array<string, mixed>> $methods
* @return array<int, array<string, mixed>>
*/
public function injectEasyPayMethods(array $methods): array
{
$easyPayMethods = array_map(
fn (array $method): array => $this->buildEntry(
id: $method['id'],
nameKey: $method['name_key'],
descriptionKey: $method['description_key'],
icon: $method['icon'],
),
self::EASY_PAY_METHODS
);
$easyPayIds = array_column($easyPayMethods, 'id');
$methods = array_values(array_filter(
$methods,
fn (array $method): bool => ! in_array($method['id'] ?? null, $easyPayIds, true)
));
$insertAfter = $this->resolveInsertionIndex($methods);
if ($insertAfter === null) {
return array_merge($methods, $easyPayMethods);
}
return array_merge(
array_slice($methods, 0, $insertAfter + 1),
$easyPayMethods,
array_slice($methods, $insertAfter + 1),
);
}
/**
* @param array<int, array<string, mixed>> $methods
*/
private function resolveInsertionIndex(array $methods): ?int
{
$phoneIndex = null;
foreach ($methods as $index => $method) {
if (($method['id'] ?? null) === 'phone') {
$phoneIndex = $index;
break;
}
}
if ($phoneIndex === null) {
return null;
}
$insertAfter = $phoneIndex;
for ($index = $phoneIndex + 1, $count = count($methods); $index < $count; $index++) {
$id = (string) ($methods[$index]['id'] ?? '');
if (! str_starts_with($id, 'kginicis_') && ! str_starts_with($id, 'nhnkcp_')) {
break;
}
$insertAfter = $index;
}
return $insertAfter;
}
private function buildEntry(string $id, string $nameKey, string $descriptionKey, string $icon): array
{
return [
'id' => $id,
'name' => [
'ko' => __($nameKey, [], 'ko'),
'en' => __($nameKey, [], 'en'),
],
'description' => [
'ko' => __($descriptionKey, [], 'ko'),
'en' => __($descriptionKey, [], 'en'),
],
'icon' => $icon,
'source' => 'plugin:' . self::PLUGIN_IDENTIFIER,
'defaults' => [
'pg_provider' => null,
'is_active' => false,
'min_order_amount' => 0,
'stock_deduction_timing' => 'payment_complete',
],
];
}
}
@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Listeners;
use App\Contracts\Extension\HookListenerInterface;
class RegisterPgProviderListener implements HookListenerInterface
{
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
private const LIVE_SITE_CD_PREFIX = 'SR';
/**
* 구독할 훅 매핑 반환
*
* @return array<string, array<string, mixed>> 훅 구독 설정
*/
public static function getSubscribedHooks(): array
{
return [
'sirsoft-ecommerce.payment.registered_pg_providers' => [
'method' => 'registerProvider',
'type' => 'filter',
'priority' => 10,
],
'sirsoft-ecommerce.payment.get_client_config' => [
'method' => 'getClientConfig',
'type' => 'filter',
'priority' => 10,
],
];
}
/**
* 기본 핸들러 (미사용 — 개별 메서드에서 처리)
*
* @param mixed ...$args 훅 인수
*/
public function handle(...$args): void {}
/**
* PG 제공자 목록에 NHN KCP 등록
*
* @param array $providers 기존 PG 제공자 목록
* @return array NHN KCP 가 추가된 PG 제공자 목록
*/
public function registerProvider(array $providers): array
{
$providers[] = [
'id' => 'nhnkcp',
'name_key' => 'sirsoft-pay_nhnkcp::provider.name',
'name' => localized_label(nameKey: 'sirsoft-pay_nhnkcp::provider.name'),
'icon' => 'credit-card',
'supported_methods' => ['card', 'bank_transfer', 'virtual_account', 'mobile'],
];
return $providers;
}
/**
* PG 클라이언트 설정 제공 (프론트엔드 SDK용)
*
* site_cd, SDK URL, 콜백 URL 을 반환. 테스트 모드는 testpay.kcp.co.kr
* 라이브 모드는 pay.kcp.co.kr 사용.
*
* @param array $config 기존 설정
* @param string $provider PG 제공자 ID
* @return array 클라이언트 설정 또는 입력 그대로 (다른 PG 인 경우)
*/
public function getClientConfig(array $config, string $provider): array
{
if ($provider !== 'nhnkcp') {
return $config;
}
$settings = $this->getPluginSettings();
$isTest = $settings['is_test_mode'] ?? true;
$liveSuffix = $settings['live_site_cd'] ?? '';
$liveSiteCd = str_starts_with($liveSuffix, self::LIVE_SITE_CD_PREFIX)
? $liveSuffix
: self::LIVE_SITE_CD_PREFIX . $liveSuffix;
$siteCd = $isTest
? ($settings['test_site_cd'] ?? 'T0000')
: $liveSiteCd;
$sdkUrl = $isTest
? 'https://testpay.kcp.co.kr/plugin/payplus_web.jsp'
: 'https://pay.kcp.co.kr/plugin/payplus_web.jsp';
$easyPayKeys = [
'PAYCO' => 'easy_pay_payco',
'NAVERPAY' => 'easy_pay_naverpay',
'NAVERPAY POINT' => 'easy_pay_naverpay_point',
'KAKAOPAY' => 'easy_pay_kakaopay',
'APPLEPAY' => 'easy_pay_applepay',
];
$enabledEasyPays = array_values(array_keys(array_filter($easyPayKeys, fn ($key) => (bool) ($settings[$key] ?? false))));
// "타 PG와 사용가능함"이 꺼져 있고 현재 기본 PG가 nhnkcp가 아니면 간편결제 숨김
$allowWithOtherPg = (bool) ($settings['easy_pay_allow_with_other_pg'] ?? false);
if (! $allowWithOtherPg && ! $this->isNhnKcpDefaultPg()) {
$enabledEasyPays = [];
}
// 간편결제 테스트 site_cd: 레거시 코드와 동일하게 S6729 사용
// T0000(표준 테스트)은 간편결제 미지원 → [3101] 오류
$easyPayTestSiteCd = $settings['easy_pay_test_site_cd'] ?? 'S6729';
$easyPayClientId = $isTest ? $easyPayTestSiteCd : $liveSiteCd;
$useEscrow = (bool) ($settings['use_escrow'] ?? false);
$escrowTestSiteCd = trim((string) ($settings['escrow_test_site_cd'] ?? ''));
$escrowClientId = $isTest && $escrowTestSiteCd !== '' ? $escrowTestSiteCd : $siteCd;
return array_merge($config, [
'client_id' => $siteCd,
'easy_pay_client_id' => $easyPayClientId,
'sdk_url' => $sdkUrl,
'callback_urls' => [
'callback' => '/plugins/sirsoft-pay_nhnkcp/payment/callback',
'close_report' => '/plugins/sirsoft-pay_nhnkcp/payment/close-report',
],
'enabled_easy_pays' => $enabledEasyPays,
'vbank_expire_days' => (int) ($settings['vbank_expire_days'] ?? 3),
'use_escrow' => $useEscrow,
'escrow_client_id' => $escrowClientId,
]);
}
private function isNhnKcpDefaultPg(): bool
{
$path = storage_path('app/modules/sirsoft-ecommerce/settings/order_settings.json');
if (! file_exists($path)) {
return false;
}
$data = json_decode(file_get_contents($path), true);
return ($data['default_pg_provider'] ?? '') === 'nhnkcp';
}
private function getPluginSettings(): array
{
return plugin_settings(self::PLUGIN_IDENTIFIER);
}
}
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use App\Contracts\Repositories\LayoutRepositoryInterface;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Enums\LayoutExtensionType;
use App\Enums\LayoutSourceType;
use App\Services\LayoutExtensionService;
use Illuminate\Support\Facades\Log;
class RestoreLayoutExtensionsAfterUpdateListener implements HookListenerInterface
{
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
/**
* @return array<string, array<string, mixed>>
*/
public static function getSubscribedHooks(): array
{
return [
'core.plugins.updated' => [
'method' => 'restoreCurrentExtensionsAfterUpdate',
'type' => 'action',
'priority' => 20,
'sync' => true,
],
];
}
/**
* @param mixed ...$args
*/
public function handle(...$args): void {}
/**
* @param string $identifier
*/
public function restoreCurrentExtensionsAfterUpdate(string $identifier): void
{
if ($identifier !== self::PLUGIN_IDENTIFIER) {
return;
}
$service = app(LayoutExtensionService::class);
foreach ($this->currentOverlayExtensions() as $extensionData) {
$targetLayout = $extensionData['target_layout'] ?? null;
if (! is_string($targetLayout) || $targetLayout === '') {
continue;
}
foreach ($this->templateIdsForLogicalLayout($targetLayout) as $templateId) {
$result = $service->registerExtension(
$extensionData,
LayoutSourceType::Plugin,
self::PLUGIN_IDENTIFIER,
$templateId,
true
);
if ($result !== null) {
$service->invalidateExtensionCache($templateId, $targetLayout, LayoutExtensionType::Overlay);
}
}
}
}
/**
* @return array<int, array<string, mixed>>
*/
private function currentOverlayExtensions(): array
{
$paths = glob($this->extensionsDirectory().'/*.json') ?: [];
$extensions = [];
foreach ($paths as $path) {
$extensionData = $this->readExtensionJson($path);
if ($extensionData === null || ! isset($extensionData['target_layout'])) {
continue;
}
$extensions[] = $extensionData;
}
return $extensions;
}
private function extensionsDirectory(): string
{
return dirname(__DIR__, 2).'/resources/extensions';
}
/**
* @return array<string, mixed>|null
*/
private function readExtensionJson(string $path): ?array
{
$content = file_get_contents($path);
if ($content === false) {
Log::warning('NHN KCP layout extension file could not be read.', [
'path' => $path,
]);
return null;
}
$data = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($data)) {
Log::warning('NHN KCP layout extension JSON parse failed.', [
'path' => $path,
'error' => json_last_error_msg(),
]);
return null;
}
return $data;
}
/**
* @return array<int, int>
*/
private function templateIdsForLogicalLayout(string $layoutName): array
{
$templateRepository = app(TemplateRepositoryInterface::class);
$layoutRepository = app(LayoutRepositoryInterface::class);
$templateIds = [];
foreach ($templateRepository->getAll() as $template) {
$layoutNames = $layoutRepository->getLayoutNamesByTemplateId((int) $template->id);
foreach ($layoutNames as $storedName) {
if (! is_string($storedName) || ! $this->matchesLogicalLayout($storedName, $layoutName)) {
continue;
}
$templateIds[(int) $template->id] = (int) $template->id;
break;
}
}
return array_values($templateIds);
}
private function matchesLogicalLayout(string $storedName, string $layoutName): bool
{
return $storedName === $layoutName || str_ends_with($storedName, '.'.$layoutName);
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Providers;
use App\Extension\BasePluginServiceProvider;
use Illuminate\Cookie\Middleware\EncryptCookies;
class PayNhnkcpServiceProvider extends BasePluginServiceProvider
{
protected string $pluginIdentifier = 'sirsoft-pay_nhnkcp';
public function boot(): void
{
parent::boot();
// PG callback 이 발급하는 영수증 쿠키는 HMAC 으로 자체 무결성 보장하므로
// EncryptCookies 대상에서 제외한다. 암호화 대상이면 callback 응답에서
// 직접 발급한 평문 쿠키가 다음 API 요청에서 복호화 실패로 폐기될 수 있다.
// 값 동기화: IssuesReceiptCookie::RECEIPT_COOKIE_NAME 과 동일해야 함.
EncryptCookies::except(['nhnkcp_receipt_token']);
}
}
@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Services;
use App\Services\PluginSettingsService;
use Illuminate\Support\Facades\Log;
use Plugins\Sirsoft\PayNhnkcp\Exceptions\NhnKcpApiException;
use SoapClient;
use SoapFault;
/**
* KCP SmartPhone Pay SOAP 서비스
*
* 모바일 결제창 호출 전 서버에서 approval_key 와 pay_url 을 획득합니다.
* PC Standard Pay(payplus_web.jsp iframe)와 달리 모바일은 2-step 방식:
* 1) SOAP → approval_key + pay_url
* 2) 브라우저 form submit → pay_url (페이지 전환)
*/
class KcpSoapService
{
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
private const LIVE_SITE_CD_PREFIX = 'SR';
private const WSDL_TEST = 'KCPPaymentService.wsdl';
private const WSDL_LIVE = 'real_KCPPaymentService.wsdl';
private bool $isTest;
private string $siteCd;
private string $escrowSiteCd;
private string $easyPaySiteCd;
private string $binDir;
public function __construct(PluginSettingsService $pluginSettingsService)
{
$settings = $pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
$this->isTest = $settings['is_test_mode'] ?? true;
$liveSuffix = $settings['live_site_cd'] ?? '';
$liveSiteCd = str_starts_with($liveSuffix, self::LIVE_SITE_CD_PREFIX)
? $liveSuffix
: self::LIVE_SITE_CD_PREFIX . $liveSuffix;
$this->siteCd = $this->isTest
? ($settings['test_site_cd'] ?? 'T0000')
: $liveSiteCd;
if ($this->isTest) {
$escrowTestSiteCd = trim((string) ($settings['escrow_test_site_cd'] ?? ''));
$this->escrowSiteCd = $escrowTestSiteCd !== '' ? $escrowTestSiteCd : $this->siteCd;
} else {
$this->escrowSiteCd = $liveSiteCd;
}
$easyPayTestSiteCd = $settings['easy_pay_test_site_cd'] ?? 'S6729';
$this->easyPaySiteCd = $this->isTest ? $easyPayTestSiteCd : $liveSiteCd;
$this->binDir = dirname(__DIR__, 2) . '/bin';
}
/**
* KCP 모바일 결제 승인키 획득 (SOAP)
*
* @param string $orderNumber 주문번호
* @param string $goodName 상품명
* @param int $amount 결제 금액
* @param string $payMethod 결제수단 코드 (CARD/BANK/MOBX/VCNT)
* @param string $retUrl 결제 결과 콜백 URL
* @param string $payMethodKey 원본 결제수단 키 (nhnkcp_payco 등, site_cd 분기용)
* @param bool $escrow 에스크로 여부
* @return array{approval_key: string, pay_url: string}
*
* @throws NhnKcpApiException
*/
public function getApprovalKey(
string $orderNumber,
string $goodName,
int $amount,
string $payMethod,
string $retUrl,
string $payMethodKey = '',
bool $escrow = false,
): array {
$wsdlFile = $this->binDir . DIRECTORY_SEPARATOR . ($this->isTest ? self::WSDL_TEST : self::WSDL_LIVE);
if (! file_exists($wsdlFile)) {
throw new NhnKcpApiException(__('sirsoft-pay_nhnkcp::messages.errors.wsdl_missing', ['file' => $wsdlFile]));
}
$siteCd = $escrow ? $this->escrowSiteCd : $this->resolveSiteCd($payMethodKey);
try {
$soapClient = new SoapClient($wsdlFile, [
'trace' => false,
'exceptions' => true,
'connection_timeout' => 15,
'encoding' => 'UTF-8',
]);
$req = new \stdClass();
$req->accessCredentialType = (object) [
'accessLicense' => '',
'signature' => '',
'timestamp' => '',
];
$req->baseRequestType = (object) [
'detailLevel' => '0',
'requestApp' => 'WEB',
'requestID' => $orderNumber,
'userAgent' => request()->userAgent() ?? '',
'version' => '0.1',
];
$req->escrow = $escrow;
$req->orderID = $orderNumber;
$req->paymentAmount = (string) $amount;
$req->paymentMethod = $payMethod;
$req->productName = $goodName;
$req->returnUrl = $retUrl;
$req->siteCode = $siteCd;
$param = new \stdClass();
$param->req = $req;
$response = $soapClient->__soapCall('approve', [$param], [
'uri' => 'http://webservice.act.webpay.service.kcp.kr',
'soapaction' => '',
]);
$resCode = $response->return->baseResponseType->error->code ?? '9999';
$resMsg = (string) ($response->return->baseResponseType->error->message ?? '연동 오류');
if ($resCode !== '0000') {
Log::warning('[nhnkcp] mobile SOAP approval error', [
'order' => $orderNumber,
'res_cd' => $resCode,
'res_msg' => $resMsg,
'site_cd' => $siteCd,
]);
throw new NhnKcpApiException(__('sirsoft-pay_nhnkcp::messages.errors.approval_key_error', ['code' => $resCode, 'message' => $resMsg]));
}
return [
'approval_key' => (string) ($response->return->approvalKey ?? ''),
'pay_url' => (string) ($response->return->payUrl ?? ''),
];
} catch (SoapFault $e) {
Log::error('[nhnkcp] SOAP fault', [
'order' => $orderNumber,
'error' => $e->getMessage(),
]);
throw new NhnKcpApiException(__('sirsoft-pay_nhnkcp::messages.errors.soap_error', ['message' => $e->getMessage()]), 0, $e);
}
}
/**
* 결제수단 키에 따른 site_cd 반환
*
* 레거시 settle_kcp.inc.php 규칙: PAYCO만 간편결제 전용 site_cd(테스트: S6729) 사용,
* NaverPay/KakaoPay/ApplePay 는 일반 site_cd(테스트: T0000) 사용.
*
* @param string $payMethodKey 결제수단 키 (nhnkcp_payco / nhnkcp_naverpay 등)
* @return string KCP site_cd
*/
public function getSiteCd(string $payMethodKey = ''): string
{
return $this->resolveSiteCd($payMethodKey);
}
/**
* 에스크로 전용 site_cd 반환
*
* @return string 에스크로 전용 KCP site_cd
*/
public function getEscrowSiteCd(): string
{
return $this->escrowSiteCd;
}
private function resolveSiteCd(string $payMethodKey): string
{
// PAYCO만 전용 간편결제 site_cd 사용 (레거시 settle_kcp.inc.php 동일)
if ($payMethodKey === 'nhnkcp_payco') {
return $this->easyPaySiteCd;
}
return $this->siteCd;
}
}
@@ -0,0 +1,619 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Services;
use App\Extension\HookManager;
use App\Services\PluginSettingsService;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Http;
use Plugins\Sirsoft\PayNhnkcp\Exceptions\NhnKcpApiException;
class NhnKcpApiService
{
private const PLUGIN_IDENTIFIER = 'sirsoft-pay_nhnkcp';
private const PA_URL_TEST = 'testpaygw.kcp.co.kr';
private const PA_URL_LIVE = 'paygw.kcp.co.kr';
private const PA_PORT = '8090';
private const TX_APPROVE = '00100000';
private const TX_CANCEL = '00200000';
private const JS_URL_TEST = 'https://testpay.kcp.co.kr/plugin/payplus_web.jsp';
private const JS_URL_LIVE = 'https://pay.kcp.co.kr/plugin/payplus_web.jsp';
private const API_URL_TEST = 'https://stgapi.kcp.co.kr';
private const API_URL_LIVE = 'https://api.kcp.co.kr';
private const LIVE_SITE_CD_PREFIX = 'SR';
/**
* KCP CLI log_level — '1' (오류만).
*
* '3' (verbose) 은 TX_ENDED 라인에 PG 응답 페이로드 전체(card_no 평문 등) 를
* 기록하므로 PCI DSS 위반 위험. LOG_DISABLED_PATH 와 함께 이중 안전장치.
*/
private const LOG_LEVEL = '1';
/**
* KCP CLI log_path — 의도적으로 존재하지 않는 경로.
*
* gnuboard5 의 settle_kcp.inc.php 가 검증된 패턴 (`$g_conf_log_dir = '/home100/kcp'`).
* CLI 가 디렉토리 생성을 시도하지만 실패하므로 로그 작성 단계가 silent skip 된다.
* 결과:
* - bin/log/YYYYMM/*.log 자체가 생성되지 않음
* - 카드번호 / 사업자번호 등 결제 메타가 디스크에 잔존하지 않음 (PCI DSS 3.4)
* - 비표준 배포(DocumentRoot=프로젝트 루트)에서 직접 다운로드 위험 자체가 부재
*
* 디버깅 필요 시 임시로 실제 경로를 지정해 활성화 가능하지만, 그 경우 결제
* 메타 평문 잔존을 운영자가 책임져야 함 (PCI 운영자 동의 필요).
*/
private const LOG_DISABLED_PATH = '/home100/kcp';
private bool $isTest;
private string $siteCd;
private string $escrowSiteCd;
private string $siteKey;
private string $binDir;
public function __construct(
private readonly PluginSettingsService $pluginSettingsService,
)
{
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
$this->applyCredentials($settings, (bool) ($settings['is_test_mode'] ?? true));
$this->binDir = dirname(__DIR__, 2) . '/bin';
}
/**
* 활성 site_cd 반환 (테스트/라이브 자동 분기)
*
* @return string KCP site_cd
*/
public function getSiteCd(): string
{
return $this->siteCd;
}
/**
* 현재 서비스 인스턴스가 테스트 모드인지 반환한다.
*
* @return bool 테스트 모드 여부
*/
public function isTestMode(): bool
{
return $this->isTest;
}
/**
* 결제 당시 저장된 KCP 상점 코드/모드를 환불·조회 호출 전에 복원한다.
*
* 사이트 키는 DB 결제 메타에 저장하지 않고 현재 플러그인 설정의 해당 모드 키를 사용한다.
*
* @param bool $isTestMode 결제 당시 테스트 모드 여부
* @param string $siteCd 결제 당시 KCP site_cd
* @return void
*/
public function useStoredCredentials(bool $isTestMode, string $siteCd): void
{
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
$this->applyCredentials($settings, $isTestMode, $siteCd);
}
/**
* Standard Pay JS SDK URL 반환 (테스트/라이브 자동 분기)
*
* @return string SDK URL
*/
public function getJsUrl(): string
{
return $this->isTest ? self::JS_URL_TEST : self::JS_URL_LIVE;
}
/**
* KCP 거래 조회 (HTTP API 방식)
*
* 결제 승인/취소 hot path 는 KCP CLI 를 사용하지만, 거래 상태 조회처럼
* 부수효과가 없는 요청은 HTTP API 로 제공한다.
*
* @param string $tno KCP 거래번호
* @param string $ordrIdxx 주문번호
* @return array KCP 거래 조회 응답
*/
public function getTransaction(string $tno, string $ordrIdxx): array
{
$this->assertSafeCliValue($tno, 'tno');
$this->assertSafeCliValue($ordrIdxx, 'ordr_idxx');
$timestamp = now()->format('YmdHis');
$signature = hash_hmac(
'sha256',
implode('|', [$this->siteCd, $tno, $ordrIdxx, $timestamp]),
$this->siteKey
);
$url = $this->apiBaseUrl() . '/v1/payment/trade/' . rawurlencode($tno);
$response = Http::withBasicAuth($this->siteCd, $this->siteKey)
->withHeaders([
'X-Kcp-Site-Code' => $this->siteCd,
'X-Kcp-Timestamp' => $timestamp,
'X-Kcp-Signature' => $signature,
])
->asJson()
->post($url, [
'tno' => $tno,
'ordr_idxx' => $ordrIdxx,
]);
if ($response->failed()) {
throw new NhnKcpApiException('KCP transaction query HTTP ' . $response->status());
}
$json = $response->json();
return is_array($json) ? $json : [];
}
/**
* KCP 결제 승인 (CLI 방식)
*
* Standard Pay 결제창 완료 후 받은 enc_data / enc_info 로 KCP CLI 를 통해
* 서버 승인 요청. 응답 res_cd = '0000' 이면 성공.
*
* @param string $encData KCP 암호화 결제 데이터
* @param string $encInfo KCP 암호화 결제 정보
* @param string $ordrIdxx 주문번호
* @param string $custIp 고객 IP
* @return array 파싱된 KCP 응답 (res_cd, res_msg, tno, app_no, card_no, quota 등)
*/
public function approvePayment(string $encData, string $encInfo, string $ordrIdxx, string $custIp): array
{
return $this->executeCli(
txCd: self::TX_APPROVE,
ordrIdxx: $ordrIdxx,
encData: $encData,
encInfo: $encInfo,
custIp: $custIp,
);
}
/**
* KCP 결제 취소 (CLI 방식)
*
* @param string $tno KCP 원거래 거래번호
* @param string $ordrIdxx 주문번호
* @param int $cancelAmt 취소 금액
* @param string $cancelMsg 취소 사유
* @param bool $isPartial 부분 취소 여부
* @param int $totalAmt 현재 부분취소 가능 금액 (KCP rem_mny)
* @return array 파싱된 KCP 응답
*/
public function cancelPayment(
string $tno,
string $ordrIdxx,
int $cancelAmt,
string $cancelMsg,
bool $isPartial = false,
int $totalAmt = 0,
): array {
$modType = $isPartial ? 'RN07' : 'STSC';
$fields = [
'tno' => $tno,
'mod_type' => $modType,
'mod_desc' => $cancelMsg,
];
if ($isPartial && $totalAmt > 0) {
// KCP rem_mny는 취소 후 잔액이 아니라 현재 부분취소 가능한 금액이다.
// 첫 부분취소는 원거래금액, 이후에는 이전 부분취소 후 남은 잔액을 전달한다.
$fields['rem_mny'] = (string) $totalAmt;
$fields['mod_mny'] = (string) $cancelAmt;
}
$modxData = $this->buildModData($fields);
// 훅: 결제 취소 전 (본인인증 등 확장 지점)
HookManager::doAction('sirsoft-pay_nhnkcp.payment.before_cancel', $tno, $ordrIdxx, $cancelAmt, $cancelMsg);
$result = $this->executeCli(
txCd: self::TX_CANCEL,
ordrIdxx: $ordrIdxx,
encData: '',
encInfo: '',
custIp: '127.0.0.1',
modxData: $modxData,
);
if (($result['res_cd'] ?? '') !== '0000') {
Log::error('KCP CLI cancel failed', [
'res_cd' => $result['res_cd'] ?? '',
'res_msg' => $result['res_msg'] ?? '',
'tno' => $tno,
]);
throw new NhnKcpApiException($result['res_msg'] ?? 'KCP cancel failed');
}
// 훅: 결제 취소 완료 후 (외부 소비자 후처리 확장 지점)
HookManager::doAction('sirsoft-pay_nhnkcp.payment.after_cancel', $tno, $result);
return $result;
}
/**
* KCP 에스크로 배송 등록 (CLI 방식, mod_type=STE1)
*
* 에스크로 결제 후 상품을 발송할 때 KCP에 운송장번호를 등록합니다.
* 에스크로 테스트 결제는 별도 설정값이 있으면 그 site_cd를, 없으면 일반 테스트 site_cd를 사용합니다.
*
* @param string $tno KCP 에스크로 거래번호
* @param string $ordrIdxx 주문번호
* @param string $deliNumb 운송장번호
* @param string $deliCorp 택배사코드 (KCP 코드: '04'=CJ대한통운, '05'=한진택배 등)
* @return array 파싱된 KCP 응답
*/
public function registerEscrowDelivery(
string $tno,
string $ordrIdxx,
string $deliNumb,
string $deliCorp,
): array {
$modxData = $this->buildModData([
'tno' => $tno,
'mod_type' => 'STE1',
'deli_numb' => $deliNumb,
'deli_corp' => $deliCorp,
]);
$result = $this->executeCli(
txCd: self::TX_CANCEL,
ordrIdxx: $ordrIdxx,
encData: '',
encInfo: '',
custIp: '127.0.0.1',
modxData: $modxData,
siteCdOverride: $this->escrowSiteCd,
);
if (($result['res_cd'] ?? '') !== '0000') {
Log::error('KCP CLI escrow delivery register failed', [
'res_cd' => $result['res_cd'] ?? '',
'res_msg' => $result['res_msg'] ?? '',
'tno' => $tno,
]);
throw new NhnKcpApiException($result['res_msg'] ?? 'KCP escrow delivery registration failed');
}
return $result;
}
private function executeCli(
string $txCd,
string $ordrIdxx,
string $encData,
string $encInfo,
string $custIp,
string $modxData = '',
string $siteCdOverride = '',
): array {
$paUrl = $this->isTest ? self::PA_URL_TEST : self::PA_URL_LIVE;
$siteCd = $siteCdOverride !== '' ? $siteCdOverride : $this->siteCd;
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
$resData = $this->executeCliWindows($txCd, $ordrIdxx, $encData, $encInfo, $custIp, $paUrl, $modxData, $siteCd);
} else {
$resData = $this->executeCliLinux($txCd, $ordrIdxx, $encData, $encInfo, $custIp, $paUrl, $modxData, $siteCd);
}
if ($resData === '') {
$resData = 'res_cd=9502' . chr(31) . 'res_msg=연동 모듈 호출 오류';
}
// KCP Windows CLI는 CP949(EUC-KR)로 한글을 출력 — MySQL UTF-8 저장을 위해 변환
if (! mb_check_encoding($resData, 'UTF-8')) {
$converted = mb_convert_encoding($resData, 'UTF-8', 'CP949');
if ($converted !== false) {
$resData = $converted;
}
}
parse_str(str_replace(chr(31), '&', $resData), $result);
Log::debug('KCP CLI response parsed', [
'tx_cd' => $txCd,
'ordr_idxx' => $ordrIdxx,
'res_cd' => $result['res_cd'] ?? null,
]);
return $result;
}
private function executeCliWindows(
string $txCd,
string $ordrIdxx,
string $encData,
string $encInfo,
string $custIp,
string $paUrl,
string $modxData,
string $siteCd = '',
): string {
$siteCd = $siteCd !== '' ? $siteCd : $this->siteCd;
$keyPath = str_replace('/', DIRECTORY_SEPARATOR, $this->binDir . '/pub.key');
$binPath = str_replace('/', DIRECTORY_SEPARATOR, $this->binDir . '/pp_cli_exe.exe');
// 실행 권한 자가 복구: plugin:update 후 _bundled 의 0664 권한이 활성 디렉토리에 그대로
// 회귀해 9502 ("연동 모듈 호출 오류") 가 발생하던 회귀를 결제 hot path 에서 차단.
// Windows 에서는 chmod 자체는 no-op 에 가깝지만, is_file 가드는 의미 있음.
$this->ensureCliExecutable($binPath);
$planData = 'payx_data=' . ($modxData !== '' ? 'mod_data=' . $modxData : '');
// Shell injection 방어: CLI args 의 각 값을 사전 검증 후 안전한 값만 사용.
// 위험 문자(`"`, 제어문자, 개행) 가 포함되면 KCP CLI 인터페이스가 깨지고 cmd.exe
// 인수 파싱이 조작될 수 있어 NhnKcpApiException 으로 즉시 거부.
$this->assertSafeCliValue($siteCd, 'site_cd');
$this->assertSafeCliValue($this->siteKey, 'site_key');
$this->assertSafeCliValue($txCd, 'tx_cd');
$this->assertSafeCliValue($paUrl, 'pa_url');
$this->assertSafeCliValue($ordrIdxx, 'ordr_idxx');
$this->assertSafeCliValue($encData, 'enc_data');
$this->assertSafeCliValue($encInfo, 'enc_info');
$this->assertSafeCliValue($custIp, 'cust_ip');
$this->assertSafeCliValue($keyPath, 'key_path');
$this->assertSafeCliValue($planData, 'plan_data', allowKcpFieldSeparator: true);
$args = 'site_cd=' . $siteCd . ','
. 'site_key=' . $this->siteKey . ','
. 'tx_cd=' . $txCd . ','
. 'pa_url=' . $paUrl . ','
. 'pa_port=' . self::PA_PORT . ','
. 'ordr_idxx=' . $ordrIdxx . ','
. 'enc_data=' . $encData . ','
. 'enc_info=' . $encInfo . ','
. 'trace_no=,'
. 'cust_ip=' . $custIp . ','
. 'key_path=' . $keyPath . ','
. 'log_path=' . self::LOG_DISABLED_PATH . ','
. 'log_level=' . self::LOG_LEVEL . ','
. 'plan_data=' . $planData;
// escapeshellarg 로 binPath / args 각각을 안전하게 quoting (Windows 는 `"` 제거 + 큰따옴표 래핑).
$command = escapeshellarg($binPath) . ' ' . escapeshellarg($args);
Log::debug('KCP CLI command prepared (Windows)', [
'bin' => basename($binPath),
'tx_cd' => $txCd,
'ordr_idxx' => $ordrIdxx,
'site_cd' => $siteCd,
]);
exec($command, $output, $returnCode);
Log::debug('KCP CLI exec result', [
'return_code' => $returnCode,
'output_count' => count($output),
]);
// Windows 코드페이지 변경 메시지('Active code page: ...') 등 비-KCP 라인 제거
$kcpLines = array_filter($output, static fn (string $line) => str_contains($line, 'res_cd='));
if (empty($kcpLines)) {
return '';
}
return (string) array_values($kcpLines)[0];
}
private function executeCliLinux(
string $txCd,
string $ordrIdxx,
string $encData,
string $encInfo,
string $custIp,
string $paUrl,
string $modxData,
string $siteCd = '',
): string {
$siteCd = $siteCd !== '' ? $siteCd : $this->siteCd;
$binExe = PHP_INT_MAX === 2147483647
? $this->binDir . '/pp_cli'
: $this->binDir . '/pp_cli_x64';
// 실행 권한 자가 복구: plugin:update 후 _bundled 의 0664 권한이 활성 디렉토리에 그대로
// 회귀해 exec() 가 빈 결과를 반환 → 9502 ("연동 모듈 호출 오류") 가 발생하던 회귀를
// 결제 hot path 에서 차단. HealthCheckController 의 admin UI 진입 시점 복구와는 별개로
// 사용자 결제 시점 안전망 역할.
$this->ensureCliExecutable($binExe);
$modxArg = $modxData !== '' ? 'mod_data=' . $modxData : '';
// CLI args 사전 검증 — Linux 도 별도 sanitization 적용해 OS 간 동일한 가드.
$this->assertSafeCliValue($siteCd, 'site_cd');
$this->assertSafeCliValue($this->siteKey, 'site_key');
$this->assertSafeCliValue($txCd, 'tx_cd');
$this->assertSafeCliValue($paUrl, 'pa_url');
$this->assertSafeCliValue($ordrIdxx, 'ordr_idxx');
$this->assertSafeCliValue($encData, 'enc_data');
$this->assertSafeCliValue($encInfo, 'enc_info');
$this->assertSafeCliValue($custIp, 'cust_ip');
$this->assertSafeCliValue($modxArg, 'modx_data', allowKcpFieldSeparator: true);
$args = 'home=' . $this->binDir . ','
. 'site_cd=' . $siteCd . ','
. 'site_key=' . $this->siteKey . ','
. 'tx_cd=' . $txCd . ','
. 'pa_url=' . $paUrl . ','
. 'pa_port=' . self::PA_PORT . ','
. 'ordr_idxx=' . $ordrIdxx . ','
. 'enc_data=' . $encData . ','
. 'enc_info=' . $encInfo . ','
. 'trace_no=,'
. 'cust_ip=' . $custIp . ','
. 'modx_data=' . $modxArg . ','
. 'log_path=' . self::LOG_DISABLED_PATH . ','
. 'log_level=' . self::LOG_LEVEL . ','
. 'opt=';
$command = $binExe . ' ' . escapeshellarg('-h') . ' ' . escapeshellarg($args);
return (string) exec($command);
}
/**
* KCP CLI 바이너리의 실행 권한 자가 복구.
*
* plugin:update 가 _bundled 의 0664 권한을 활성 디렉토리로 그대로 복사해
* 실행 권한이 사라지면 exec() 가 빈 결과를 반환 → executeCli() 가 res_cd=9502
* fallback 으로 떨어진다. 결제 진입 직전 호출하여 권한이 부족하면 chmod 0755
* 로 복구, stat 캐시 무효화 후 재검증한다.
*
* 복구 실패 시 (PHP-FPM 이 파일 소유자가 아닌 sudo 환경 등) NhnKcpApiException
* 으로 fail-fast — exec() 가 9502 로 끝나기 전에 운영자가 원인 (sudo chmod 필요)
* 을 명확히 알 수 있다.
*
* @param string $binPath KCP CLI 바이너리 절대 경로
* @return void
*
* @throws NhnKcpApiException 바이너리 누락 또는 실행 권한 복구 실패 시
*/
private function ensureCliExecutable(string $binPath): void
{
if (! is_file($binPath)) {
throw new NhnKcpApiException(__('sirsoft-pay_nhnkcp::messages.errors.cli_binary_missing', [
'path' => $binPath,
]));
}
clearstatcache(true, $binPath);
if (is_executable($binPath)) {
return;
}
$beforeMode = substr(sprintf('%o', fileperms($binPath)), -4);
$chmodOk = @chmod($binPath, 0755);
clearstatcache(true, $binPath);
if ($chmodOk && is_executable($binPath)) {
Log::info('KCP: CLI 바이너리 실행 권한 자가 복구', [
'path' => $binPath,
'before' => $beforeMode,
'after' => '0755',
]);
return;
}
Log::error('KCP: CLI 실행 권한 자가 복구 실패 — sudo chmod 필요', [
'path' => $binPath,
'mode' => $beforeMode,
'owner' => fileowner($binPath),
'php_uid' => function_exists('posix_geteuid') ? posix_geteuid() : null,
]);
throw new NhnKcpApiException(
__('sirsoft-pay_nhnkcp::messages.errors.cli_binary_not_executable', ['path' => $binPath])
);
}
/**
* CLI args 의 단일 값에 shell injection 위험 문자가 없는지 검증.
*
* 거부 대상:
* - 큰따옴표 (`"`) — Windows cmd.exe 의 args quoting 깨짐
* - 제어문자 (개행 / 캐리지리턴 / NUL / TAB) — argv 분리/명령 종료 위험
* - 백틱 (`` ` ``) — 일부 셸에서 명령 치환
*
* KCP CLI 의 정상 입력 (alphanumeric, base64, IP, URL, Windows path 등) 은
* 모두 통과하며, 비정상 페이로드만 차단한다.
*
* @param string $value 검증할 값
* @param string $key 필드 이름 (예외 메시지용)
*
* @throws NhnKcpApiException 위험 문자 발견 시
*/
private function assertSafeCliValue(string $value, string $key, bool $allowKcpFieldSeparator = false): void
{
// 큰따옴표 / 백틱 — 명시적 위험
if (preg_match('/["`]/', $value) === 1) {
throw new NhnKcpApiException(
"KCP CLI rejected unsafe value for {$key} (contains quote/backtick)."
);
}
// 제어문자 (NUL / LF / CR / TAB 등 0x00-0x1F + 0x7F).
// KCP CLI mod_data 자체에는 필드 구분자 chr(31)가 필요하므로 내부에서
// buildModData()로 개별 값 검증을 마친 조립 문자열에 한해 chr(31)만 허용한다.
$controlPattern = $allowKcpFieldSeparator ? '/[\x00-\x1E\x7F]/' : '/[\x00-\x1F\x7F]/';
if (preg_match($controlPattern, $value) === 1) {
throw new NhnKcpApiException(
"KCP CLI rejected unsafe value for {$key} (contains control character)."
);
}
}
/**
* KCP CLI mod_data 문자열을 안전하게 조립한다.
*
* 개별 value 에 KCP 필드 구분자(chr(31))나 쉘 인자 위험 문자가 들어가면 먼저
* 거부한 뒤, 내부 구분자만 포함한 mod_data 를 만든다.
*
* @param array<string, string|int> $fields
*/
private function buildModData(array $fields): string
{
$parts = [];
foreach ($fields as $key => $value) {
$value = (string) $value;
$this->assertSafeCliValue($key, 'mod_data_key');
$this->assertSafeCliValue($value, $key);
$parts[] = $key . '=' . $value;
}
return implode(chr(31), $parts) . chr(31);
}
private function apiBaseUrl(): string
{
return $this->isTest ? self::API_URL_TEST : self::API_URL_LIVE;
}
/**
* 플러그인 설정과 선택 모드 기준으로 KCP 인증 정보를 적용한다.
*/
private function applyCredentials(array $settings, bool $isTestMode, string $siteCdOverride = ''): void
{
$this->isTest = $isTestMode;
if ($this->isTest) {
$this->siteCd = $siteCdOverride !== '' ? $siteCdOverride : ($settings['test_site_cd'] ?? 'T0000');
$escrowTestSiteCd = trim((string) ($settings['escrow_test_site_cd'] ?? ''));
$this->escrowSiteCd = $escrowTestSiteCd !== '' ? $escrowTestSiteCd : $this->siteCd;
$this->siteKey = $settings['test_site_key'] ?? '';
return;
}
$liveSiteCd = $siteCdOverride !== '' ? $siteCdOverride : ($settings['live_site_cd'] ?? '');
$this->siteCd = $this->buildLiveSiteCd($liveSiteCd);
$this->escrowSiteCd = $this->siteCd;
$this->siteKey = $settings['live_site_key'] ?? '';
}
private function buildLiveSiteCd(string $suffix): string
{
return str_starts_with($suffix, self::LIVE_SITE_CD_PREFIX) ? $suffix : self::LIVE_SITE_CD_PREFIX . $suffix;
}
}
@@ -0,0 +1,82 @@
<?php
use Illuminate\Support\Facades\Route;
use Plugins\Sirsoft\PayNhnkcp\Controllers\AdminEscrowDeliveryController;
use Plugins\Sirsoft\PayNhnkcp\Controllers\AdminOrderListController;
use Plugins\Sirsoft\PayNhnkcp\Controllers\AdminSettingsStatusController;
use Plugins\Sirsoft\PayNhnkcp\Controllers\AdminTransactionController;
use Plugins\Sirsoft\PayNhnkcp\Controllers\HealthCheckController;
use Plugins\Sirsoft\PayNhnkcp\Controllers\MobileApprovalController;
use Plugins\Sirsoft\PayNhnkcp\Controllers\PaymentCloseReportController;
use Plugins\Sirsoft\PayNhnkcp\Controllers\PaymentRetryController;
use Plugins\Sirsoft\PayNhnkcp\Controllers\UserReceiptController;
use Plugins\Sirsoft\PayNhnkcp\Controllers\UserVbankMockDepositController;
/*
|--------------------------------------------------------------------------
| NHN KCP Plugin API Routes
|--------------------------------------------------------------------------
|
| 프리픽스: /api/plugins/sirsoft-pay_nhnkcp (PluginRouteServiceProvider 자동 적용)
| 미들웨어: api (PluginRouteServiceProvider 자동 적용)
|
*/
// PC 결제창 닫힘 보고 — 주문 컨텍스트 검증 후 결제 실패/취소 이력 기록
Route::post('/payment/close-report', [PaymentCloseReportController::class, 'store'])
->name('payment.close-report');
// 결제 재시도 준비 — 이전 실패/취소 주문을 검증 후 같은 주문번호로 재결제 가능 상태로 복구
Route::post('/payment/retry', [PaymentRetryController::class, 'store'])
->name('payment.retry');
Route::get('/user/orders/{orderNumber}/receipt', [UserReceiptController::class, 'show'])
->middleware('optional.sanctum')
->name('user.orders.receipt');
Route::middleware(['auth:sanctum'])->group(function () {
// 테스트 모드 가상계좌 모의입금 정보 (입금대기 상태일 때만 데이터 반환)
Route::get('/user/orders/{orderNumber}/vbank-mock-deposit-info', [UserVbankMockDepositController::class, 'show'])
->name('user.orders.vbank-mock-deposit-info');
// 모바일 결제 승인키 획득 (KCP SmartPhone Pay SOAP)
Route::post('/mobile/approval-key', [MobileApprovalController::class, 'getApprovalKey'])
->name('mobile.approval-key');
});
Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->group(function () {
// 가상계좌 입금통보 URL 조회 (관리자 설정 페이지 표시용)
Route::get('/vbank-notify-url', fn () => response()->json([
'success' => true,
'data' => [
'url' => url('/plugins/sirsoft-pay_nhnkcp/payment/vbank-notify'),
'escrow_common_notify_url' => url('/plugins/sirsoft-pay_nhnkcp/payment/escrow-common-notify'),
],
]))->name('vbank.notify.url');
Route::get('/settings/test-mode-status', [AdminSettingsStatusController::class, 'testMode'])
->middleware('permission:admin,sirsoft-ecommerce.settings.read')
->name('settings.test-mode-status');
// 테스트 모드 주문 맵 (관리자 주문목록 배지 표시용)
Route::get('/orders/test-mode-map', [AdminOrderListController::class, 'testModeMap'])
->name('orders.test-mode-map');
// 간편결제 원 결제수단 표시 맵 (관리자 주문목록 보강용)
Route::get('/orders/easy-pay-display-map', [AdminOrderListController::class, 'easyPayDisplayMap'])
->name('orders.easy-pay-display-map');
// 주문번호로 거래 정보 조회 (레이아웃 확장 자동 로드용)
Route::get('/orders/{orderNumber}/transaction-status', [AdminTransactionController::class, 'queryByOrder'])
->name('orders.transaction-status');
// 에스크로 배송 등록
Route::get('/orders/{orderNumber}/escrow-delivery', [AdminEscrowDeliveryController::class, 'formData'])
->name('orders.escrow-delivery.form');
Route::post('/orders/{orderNumber}/escrow-delivery', [AdminEscrowDeliveryController::class, 'register'])
->name('orders.escrow-delivery.register');
// 시스템 점검 (PC/모바일 결제 사전조건 진단 + 자동 chmod +x 복구)
Route::get('/health', [HealthCheckController::class, 'check'])
->name('health');
});
@@ -0,0 +1,40 @@
<?php
use Illuminate\Support\Facades\Route;
use Plugins\Sirsoft\PayNhnkcp\Controllers\EscrowCommonNotifyController;
use Plugins\Sirsoft\PayNhnkcp\Controllers\PaymentCallbackController;
use Plugins\Sirsoft\PayNhnkcp\Http\Middleware\RestrictKcpIp;
/*
|--------------------------------------------------------------------------
| NHN KCP Plugin Web Routes
|--------------------------------------------------------------------------
|
| 프리픽스: /plugins/sirsoft-pay_nhnkcp (PluginRouteServiceProvider 자동 적용)
| 미들웨어: web (PluginRouteServiceProvider 자동 적용)
|
| KCP Standard Pay는 브라우저 POST 콜백 방식이므로 CSRF 미들웨어를 제외합니다.
|
| IP 검증:
| - authCallback: 브라우저 POST (사용자 IP) → IP 검증 안 함
| - vbank-notify, escrow-common-notify: KCP 서버 직접 POST → RestrictKcpIp 가드
| (테스트 모드 시 우회. 그누보드5 settle_kcp_common.php 동일 패턴)
|
*/
Route::withoutMiddleware([\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class])->group(function () {
// 결제 승인 콜백 (KCP → 브라우저 POST)
Route::post('/payment/callback', [PaymentCallbackController::class, 'authCallback'])
->name('payment.callback');
// KCP 서버 발신 webhook 그룹 (IP 화이트리스트 가드)
Route::middleware(RestrictKcpIp::class)->group(function () {
// 가상계좌 입금 통보 (KCP 서버 → 우리 서버 POST)
Route::post('/payment/vbank-notify', [PaymentCallbackController::class, 'vbankNotify'])
->name('payment.vbank-notify');
// 에스크로 공통통보 (KCP 서버 → 우리 서버 POST: TX02 구매확인/취소, TX03 배송시작)
Route::post('/payment/escrow-common-notify', [EscrowCommonNotifyController::class, 'handle'])
->name('payment.escrow-common-notify');
});
});
@@ -0,0 +1,141 @@
<?php
namespace Plugins\Sirsoft\PayNhnkcp\Tests\Feature\Concerns;
use App\Models\User;
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 Plugins\Sirsoft\PayNhnkcp\Concerns\PreventsReplayCallback;
use Plugins\Sirsoft\PayNhnkcp\Tests\PluginTestCase;
/**
* Phase 3 PoC (Feature) — NHN KCP PreventsReplayCallback trait 동작 검증
*
* trait 의 wasAlreadyPaid() 가 다음 시나리오에서 올바르게 분기:
* 1. transaction_id null/empty → false (early return)
* 2. paid 상태 + 동일 tno → true (replay detected)
* 3. ready 상태만 존재 → false
* 4. paid 상태이지만 다른 tno → false
* 5. failed 상태 + 동일 tno → false
*/
class PreventsReplayCallbackTest extends PluginTestCase
{
private object $traitSubject;
protected function setUp(): void
{
parent::setUp();
$this->traitSubject = new class {
use PreventsReplayCallback {
wasAlreadyPaid as public;
}
};
}
private function seedPayment(string $tno, PaymentStatusEnum $status, int $amount = 50000): void
{
$user = User::factory()->create();
$order = OrderFactory::new()->create([
'user_id' => $user->id,
'order_number' => 'ORD-RC-' . random_int(10000, 99999),
'order_status' => $status === PaymentStatusEnum::PAID
? OrderStatusEnum::PAYMENT_COMPLETE
: OrderStatusEnum::PENDING_ORDER,
'subtotal_amount' => $amount,
'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' => $amount,
'total_due_amount' => $amount,
'total_points_used_amount' => 0,
'total_deposit_used_amount' => 0,
'total_paid_amount' => $status === PaymentStatusEnum::PAID ? $amount : 0,
]);
OrderPaymentFactory::new()->create([
'order_id' => $order->id,
'payment_status' => $status,
'payment_method' => PaymentMethodEnum::CARD,
'pg_provider' => 'nhnkcp',
'paid_amount_local' => $status === PaymentStatusEnum::PAID ? $amount : 0,
'paid_at' => $status === PaymentStatusEnum::PAID ? now() : null,
'transaction_id' => $tno,
]);
}
/**
* @scenario context=paymentCallback_card, threat=replay, callback_state=first_arrival
* @effects replay_first_arrival_proceeds_normally
*/
public function test_null_transaction_id_returns_false(): void
{
$this->assertFalse($this->traitSubject->wasAlreadyPaid(null));
}
/**
* @scenario context=paymentCallback_card, threat=replay, callback_state=first_arrival
* @effects replay_first_arrival_proceeds_normally
*/
public function test_empty_transaction_id_returns_false(): void
{
$this->assertFalse($this->traitSubject->wasAlreadyPaid(''));
}
/**
* @scenario context=paymentCallback_card, threat=replay, callback_state=second_arrival_same_tno
* @effects replay_detected_on_second_arrival
*/
public function test_paid_payment_with_same_tno_returns_true(): void
{
$tno = 'TNO_PAID_001';
$this->seedPayment($tno, PaymentStatusEnum::PAID);
$this->assertTrue($this->traitSubject->wasAlreadyPaid($tno));
}
/**
* @scenario context=paymentCallback_card, threat=replay, callback_state=first_arrival
* @effects replay_first_arrival_proceeds_normally
*/
public function test_ready_status_payment_returns_false(): void
{
$tno = 'TNO_READY_001';
$this->seedPayment($tno, PaymentStatusEnum::READY);
$this->assertFalse($this->traitSubject->wasAlreadyPaid($tno));
}
/**
* @scenario context=paymentCallback_card, threat=replay, callback_state=first_arrival
* @effects replay_first_arrival_proceeds_normally
*/
public function test_paid_with_different_tno_returns_false(): void
{
$this->seedPayment('TNO_OTHER_001', PaymentStatusEnum::PAID);
$this->assertFalse($this->traitSubject->wasAlreadyPaid('TNO_QUERY_001'));
}
/**
* @scenario context=paymentCallback_card, threat=replay, callback_state=first_arrival
* @effects replay_first_arrival_proceeds_normally
*/
public function test_failed_payment_with_same_tno_returns_false(): void
{
$tno = 'TNO_FAILED_001';
$this->seedPayment($tno, PaymentStatusEnum::FAILED);
$this->assertFalse($this->traitSubject->wasAlreadyPaid($tno));
}
}
@@ -0,0 +1,74 @@
<?php
namespace Plugins\Sirsoft\PayNhnkcp\Tests\Feature\Concerns;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Plugins\Sirsoft\PayNhnkcp\Concerns\SerializesPaymentCallbacks;
use Plugins\Sirsoft\PayNhnkcp\Tests\PluginTestCase;
class SerializesPaymentCallbacksTest extends PluginTestCase
{
private object $traitSubject;
protected function setUp(): void
{
parent::setUp();
$this->traitSubject = new class {
use SerializesPaymentCallbacks {
acquireOrderCallbackLock as public;
releaseOrderCallbackLock as public;
callbackLockKey as public;
}
protected function callbackLockWaitSeconds(): int
{
return 0;
}
protected function callbackLockTtlSeconds(): int
{
return 5;
}
};
}
public function test_same_context_and_order_are_serialized(): void
{
$orderNumber = 'ORD-KCP-LOCK-' . random_int(10000, 99999);
$firstLock = $this->traitSubject->acquireOrderCallbackLock('authCallback', $orderNumber);
try {
$this->expectException(LockTimeoutException::class);
$this->traitSubject->acquireOrderCallbackLock('authCallback', $orderNumber);
} finally {
$this->traitSubject->releaseOrderCallbackLock($firstLock);
}
}
public function test_released_lock_allows_next_callback(): void
{
$orderNumber = 'ORD-KCP-LOCK-' . random_int(10000, 99999);
$firstLock = $this->traitSubject->acquireOrderCallbackLock('authCallback', $orderNumber);
$this->traitSubject->releaseOrderCallbackLock($firstLock);
$secondLock = $this->traitSubject->acquireOrderCallbackLock('authCallback', $orderNumber);
$this->assertNotNull($secondLock);
$this->traitSubject->releaseOrderCallbackLock($secondLock);
}
public function test_lock_key_is_scoped_by_context_and_order(): void
{
$this->assertNotSame(
$this->traitSubject->callbackLockKey('authCallback', 'ORD-KCP-LOCK-A'),
$this->traitSubject->callbackLockKey('vbankNotify', 'ORD-KCP-LOCK-A'),
);
$this->assertNotSame(
$this->traitSubject->callbackLockKey('authCallback', 'ORD-KCP-LOCK-A'),
$this->traitSubject->callbackLockKey('authCallback', 'ORD-KCP-LOCK-B'),
);
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayNhnkcp\Tests\Feature\Controllers;
use App\Models\User;
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\PayNhnkcp\Services\NhnKcpApiService;
use Plugins\Sirsoft\PayNhnkcp\Tests\PluginTestCase;
class AdminEscrowDeliveryControllerTest extends PluginTestCase
{
private function createEscrowOrder(string $tno = 'KCP_ESCROW_TNO_DELIVERY'): Order
{
$user = User::factory()->create();
$order = OrderFactory::new()->create([
'user_id' => $user->id,
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'subtotal_amount' => 30000,
'total_amount' => 30000,
'total_due_amount' => 0,
'total_paid_amount' => 30000,
'paid_at' => now(),
]);
OrderPaymentFactory::new()->create([
'order_id' => $order->id,
'payment_status' => PaymentStatusEnum::PAID,
'payment_method' => PaymentMethodEnum::CARD,
'pg_provider' => 'nhnkcp',
'transaction_id' => $tno,
'paid_amount_local' => 30000,
'paid_at' => now(),
'is_escrow' => true,
'payment_meta' => [
'site_cd' => 'T0000',
'is_test_mode' => true,
'escw_yn' => 'Y',
],
]);
return $order;
}
private function mockEscrowDeliveryApi(array $response): void
{
$mock = $this->createMock(NhnKcpApiService::class);
$mock->method('registerEscrowDelivery')->willReturn($response);
$this->app->instance(NhnKcpApiService::class, $mock);
}
public function test_register_stores_sanitized_pg_response_only(): void
{
$admin = $this->createAdminUser(['sirsoft-ecommerce.orders.update']);
$order = $this->createEscrowOrder();
$this->mockEscrowDeliveryApi([
'res_cd' => '0000',
'res_msg' => '정상처리',
'tno' => 'KCP_ESCROW_TNO_DELIVERY',
'deli_numb' => '1234567890',
'deli_corp' => '04',
'recv_name' => '수령자',
'recv_tel' => '01012345678',
'recv_addr' => '서울시 테스트구',
'unexpected_sensitive' => 'store-me-not',
]);
$response = $this->actingAs($admin)
->postJson("/api/plugins/sirsoft-pay_nhnkcp/admin/orders/{$order->order_number}/escrow-delivery", [
'deli_numb' => '1234567890',
'deli_corp' => '04',
]);
$response->assertOk()->assertJsonPath('success', true);
$payment = $order->payment;
$payment->refresh();
$deliveryMeta = $payment->payment_meta['escrow_delivery'] ?? [];
$pgResponse = $deliveryMeta['pg_response'] ?? [];
$this->assertTrue($deliveryMeta['pg_response_sanitized'] ?? false);
$this->assertSame('0000', $pgResponse['res_cd'] ?? null);
$this->assertSame('KCP_ESCROW_TNO_DELIVERY', $pgResponse['tno'] ?? null);
$this->assertArrayNotHasKey('recv_name', $pgResponse);
$this->assertArrayNotHasKey('recv_tel', $pgResponse);
$this->assertArrayNotHasKey('recv_addr', $pgResponse);
$this->assertArrayNotHasKey('unexpected_sensitive', $pgResponse);
}
}

Some files were not shown because too many files have changed in this diff Show More