Revert "공개 버전 출시를 위한 나이스페이먼츠·NHN KCP 결제 플러그인 번들 제거"
This reverts commit bac963b011f7b180c0d64e4f050131b5f05b85c1.
This commit is contained in:
@@ -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>
|
||||
BIN
Binary file not shown.
Binary file not shown.
BIN
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
@@ -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 연동) — 카드/계좌이체/가상계좌/휴대폰 결제 지원',
|
||||
];
|
||||
+3179
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',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
+295
@@ -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}}" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+548
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+313
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+38
@@ -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 기기에 모바일결제만 가능합니다.');
|
||||
});
|
||||
});
|
||||
+87
@@ -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();
|
||||
});
|
||||
});
|
||||
+226
@@ -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');
|
||||
});
|
||||
});
|
||||
+42
@@ -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();
|
||||
});
|
||||
});
|
||||
+225
@@ -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({});
|
||||
});
|
||||
});
|
||||
+153
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
+44
@@ -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('카카오페이결제수단카카오페이(신용카드,일시불)');
|
||||
});
|
||||
});
|
||||
+158
@@ -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, '"')}">`)
|
||||
.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;
|
||||
}
|
||||
}
|
||||
+210
@@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
+94
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
+332
@@ -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;
|
||||
}
|
||||
}
|
||||
+338
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
+243
@@ -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);
|
||||
}
|
||||
}
|
||||
+154
@@ -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');
|
||||
});
|
||||
});
|
||||
+141
@@ -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));
|
||||
}
|
||||
}
|
||||
+74
@@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
+95
@@ -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
Reference in New Issue
Block a user