feat(plugins): 토스페이먼츠 번들 플러그인 복원
7.0.0 정식 출시 커밋에서 PG 3종이 함께 제거되었으나, 그중 토스페이먼츠만 번들로 되돌린다. 삭제 직전 트리를 기준으로 플러그인 40개 파일과 일본어 언어팩 6개 파일을 복원하고, 삭제에 연동되어 변경되었던 README 목록·언어팩 빌드 스크립트 엔트리·이커머스 편집기 샘플의 PG provider 를 함께 되살렸다. 복원된 코드는 삭제 시점에 멈춰 있어 현행 코드베이스와 어긋나는 지점이 있어 함께 정합화했다. - PluginTestCase 가 활성 디렉토리 경로를 참조해 _bundled 단독 실행 시 라우트가 등록되지 않던 문제를 자기 기준 경로로 교체 - SettingsServiceProvider 의 site_url override 로 app.url 이 오염되어 assertRedirect 가 간헐 실패하던 문제를 APP_URL 명시 리셋으로 차단 - 환경설정 레이아웃의 시맨틱 클래스가 한 칸씩 밀려 순환 배치되어 2열 폼이 무너지고 섹션 제목이 에러 색으로 표시되던 결함을 정정 (회귀 테스트 신설) - 편집기 저장 시 silent drop 되던 미표준 root 키 제거, Icon 을 size prop 으로 전환
This commit is contained in:
@@ -390,6 +390,7 @@ cp .env.example .env
|
||||
| **sirsoft-pay_kginicis** | KG이니시스 결제 연동 |
|
||||
| **sirsoft-pay_nicepayments** | 나이스페이먼츠 결제 연동 (통합결제창) |
|
||||
| **sirsoft-pay_nhnkcp** | NHN KCP 결제 연동 (Standard Pay) |
|
||||
| **sirsoft-tosspayments** | 토스페이먼츠 결제 연동 |
|
||||
| **sirsoft-verification_kginicis** | KG이니시스 본인인증 |
|
||||
| **sirsoft-daum_postcode** | 다음 우편번호 검색 |
|
||||
| **sirsoft-marketing** | 마케팅 도구 |
|
||||
@@ -420,6 +421,7 @@ cp .env.example .env
|
||||
| **g7-plugin-sirsoft-pay_kginicis-ja** | KG이니시스 결제 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-pay_nicepayments-ja** | 나이스페이먼츠 결제 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-pay_nhnkcp-ja** | NHN KCP 결제 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-tosspayments-ja** | 토스페이먼츠 플러그인 일본어 |
|
||||
| **g7-plugin-sirsoft-verification_kginicis-ja** | KG이니시스 본인인증 플러그인 일본어 |
|
||||
| **g7-template-sirsoft-admin_basic-ja** | 관리자 기본 템플릿 일본어 |
|
||||
| **g7-template-sirsoft-basic-ja** | 사용자 기본 템플릿 일본어 |
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
이 언어팩의 모든 주요 변경사항을 기록합니다.
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.0-beta.2] - 2026-06-11
|
||||
|
||||
### Added
|
||||
|
||||
- 결제 화면 프론트엔드 텍스트 일본어 번역 추가 — 결제 관련 화면이 일본어 로케일에서 자연스럽게 표시됩니다.
|
||||
|
||||
## [1.0.0-beta.1] - 2026-05-11
|
||||
|
||||
### Added
|
||||
|
||||
- 토스페이먼츠 플러그인(sirsoft-tosspayments)의 일본어 번들 언어팩 초기 제공
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'refund' => [
|
||||
'missing_payment_key' => 'Toss Payments の決済キーが存在しないため、返金処理ができません。',
|
||||
'default_reason' => '顧客リクエストによるキャンセル',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'Toss Payments',
|
||||
'description' => '国内PG会社 Toss Payments 決済連動',
|
||||
];
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"editor": {
|
||||
"data_source": {
|
||||
"settings": "決済設定"
|
||||
}
|
||||
},
|
||||
"plugin_name": "Toss Payments",
|
||||
"plugin_description": "Toss Payments決済ゲートウェイ (統合決済窓連携)",
|
||||
"settings": {
|
||||
"title": "Toss Payments設定",
|
||||
"description": "Toss Payments API キーを設定します。",
|
||||
"test_mode": "テストモード",
|
||||
"test_mode_hint": "テストモードでは実際の決済が発生しません。",
|
||||
"test_client_key": "テストクライアントキー",
|
||||
"test_client_key_hint": "デベロッパーセンター > API 個別連携キーで確認",
|
||||
"test_secret_key": "テストシークレットキー",
|
||||
"test_secret_key_hint": "外部に露出しないようご注意ください。",
|
||||
"live_client_key": "ライブクライアントキー",
|
||||
"live_secret_key": "ライブシークレットキー",
|
||||
"live_secret_key_hint": "外部に露出しないようご注意ください。",
|
||||
"section_test_keys": "テストAPI キー",
|
||||
"section_live_keys": "ライブAPI キー",
|
||||
"section_redirect": "リダイレクト設定",
|
||||
"redirect_success_url": "決済成功リダイレクトURL",
|
||||
"redirect_success_url_hint": "相対パス(/shop/...) または完全URL(https://...) の両方が可能です。{orderId}は実際の注文番号に自動置換されます。",
|
||||
"redirect_fail_url": "決済失敗リダイレクトURL",
|
||||
"redirect_fail_url_hint": "相対パス または完全URL の両方が可能です。エラー情報(error, message, orderId)はクエリパラメーターとして自動追加されます。",
|
||||
"save": "保存",
|
||||
"saved": "設定が保存されました。"
|
||||
},
|
||||
"payment_error_title": "決済エラー",
|
||||
"payment_cancel_title": "決済キャンセル",
|
||||
"payment_cancelled_message": "決済がキャンセルされました。もう一度決済をお試しいただくか、別の決済方法をお選びください。",
|
||||
"errors": {
|
||||
"confirm_failed": "決済承認に失敗しました。",
|
||||
"amount_mismatch": "決済金額が一致しません。",
|
||||
"order_not_found": "注文が見つかりません。",
|
||||
"payment_failed": "決済に失敗しました。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"identifier": "g7-plugin-sirsoft-tosspayments-ja",
|
||||
"namespace": "g7",
|
||||
"vendor": "sirsoft",
|
||||
"name": {
|
||||
"ko": "G7 플러그인 (sirsoft-tosspayments) 일본어 언어팩",
|
||||
"en": "G7 plugin (sirsoft-tosspayments) Japanese language pack",
|
||||
"ja": "G7 プラグイン (sirsoft-tosspayments) 日本語 言語パック"
|
||||
},
|
||||
"description": {
|
||||
"ko": "G7 플러그인 (sirsoft-tosspayments) 일본어 언어팩 (번들)",
|
||||
"en": "G7 plugin (sirsoft-tosspayments) Japanese language pack (bundled)",
|
||||
"ja": "G7 プラグイン (sirsoft-tosspayments) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.0-beta.2",
|
||||
"license": "MIT",
|
||||
"scope": "plugin",
|
||||
"target_identifier": "sirsoft-tosspayments",
|
||||
"locale": "ja",
|
||||
"locale_name": "Japanese",
|
||||
"locale_native_name": "日本語",
|
||||
"text_direction": "ltr",
|
||||
"g7_version": ">=7.0.0-beta.4",
|
||||
"requires": {
|
||||
"target_version": null,
|
||||
"depends_on_core_locale": true
|
||||
},
|
||||
"github_url": "",
|
||||
"github_changelog_url": ""
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "Toss Payments",
|
||||
"description": "Toss Payments 決済ゲートウェイ (統合決済窓口連携)"
|
||||
}
|
||||
@@ -3713,6 +3713,15 @@
|
||||
"virtual_account",
|
||||
"mobile"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tosspayments",
|
||||
"name_key": "sirsoft-tosspayments::provider.name",
|
||||
"name": "토스페이먼츠",
|
||||
"icon": "credit-card",
|
||||
"supported_methods": [
|
||||
"card"
|
||||
]
|
||||
}
|
||||
],
|
||||
"abilities": {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Changelog
|
||||
|
||||
이 프로젝트의 모든 주요 변경사항을 기록합니다.
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.0-beta.4] - 2026-05-28
|
||||
|
||||
### Added
|
||||
|
||||
- 레이아웃 편집기 데이터 소스 목록에서 이 확장이 제공하는 데이터 소스가 친화 명칭으로 표시되고, 어느 확장이 제공했는지 출처가 함께 표시됩니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 플러그인 환경설정 화면의 하단 저장 버튼이 스크롤 중에도 화면에 고정되도록 개선.
|
||||
|
||||
## [1.0.0-beta.3] - 2026-05-11
|
||||
|
||||
### Added
|
||||
|
||||
- 결제 취소 전후에 외부 확장이 본인인증 등 추가 로직을 붙일 수 있는 확장점 제공
|
||||
|
||||
### Changed
|
||||
|
||||
- 관리자 화면 좌우 정렬 컨테이너(섹션 헤더 · 액션바 · 카드 상단부 등) 외형을 표준 시맨틱(.flex-between)으로 정리 — 시각 변경 없음, 향후 톤 조정이 한 곳에서 가능.
|
||||
- sirsoft-ecommerce 의존성을 `>=1.0.0-beta.3` 으로 정비
|
||||
- 플러그인 환경설정 화면의 하단 저장 버튼이 스크롤 중에도 화면에 고정되도록 개선.
|
||||
- 플러그인 환경설정 화면의 폼 라벨 / 보조 설명 / 에러 메시지 시각 시맨틱을 sirsoft-admin_basic 표준 시맨틱과 정합 — 다른 관리자 화면과 같은 결로 통일.
|
||||
- 플러그인 환경설정 화면 곳곳의 텍스트 톤 (보조 설명 · 라벨 · 본문 · 강조 · 작은 보조) 시각 시맨틱을 관리자 표준 시맨틱과 정합 — 같은 결의 글자 톤이 한 곳에서 일괄 조정 가능.
|
||||
- 플러그인 환경설정 화면의 세로 정렬 컨테이너 / 입력 박스를 sirsoft-admin_basic 표준 시맨틱 (.stack / .input) 과 정합 — 표준 간격과 외형이 일관 표시되도록 정리.
|
||||
|
||||
## [1.0.0-beta.2] - 2026-04-20
|
||||
|
||||
### Changed
|
||||
|
||||
- 코어 최소 요구 버전을 7.0.0-beta.2 로 상향
|
||||
- sirsoft-ecommerce 의존성 버전 제약을 실제 릴리스 버전에 맞춰 정비
|
||||
- 플러그인 의존성 관리를 manifest(plugin.json) 기준으로 일원화
|
||||
|
||||
## [1.0.0-beta.1] - 2026-04-01
|
||||
|
||||
### Changed
|
||||
|
||||
- 오픈 베타 릴리즈
|
||||
|
||||
## [0.2.2] - 2026-03-30
|
||||
|
||||
### Changed
|
||||
|
||||
- frontend_schema에서 `is_test_mode`, `redirect_success_url`, `redirect_fail_url`을 `expose: false`로 변경 — 프론트엔드 미참조 설정의 보안 노출 차단
|
||||
|
||||
## [0.2.1] - 2026-03-25
|
||||
|
||||
### Changed
|
||||
|
||||
- app(PluginSettingsService::class) 직접 호출을 plugin_settings() 헬퍼로 교체 (SuccessCallbackRequest, PaymentRefundListener, RegisterPgProviderListener)
|
||||
- 불필요한 PluginSettingsService use문 제거 (3개 파일)
|
||||
|
||||
## [0.2.0] - 2026-03-24
|
||||
|
||||
### Added
|
||||
|
||||
- 환불 리스너 추가 (PaymentRefundListener): 주문 취소 시 토스페이먼츠 환불 API 연동
|
||||
- 다국어 메시지 파일 추가 (ko/en messages.php)
|
||||
|
||||
## [0.1.3] - 2026-03-16
|
||||
|
||||
### Changed
|
||||
|
||||
- 라이선스 프로그램 명칭 정비
|
||||
|
||||
## [0.1.2] - 2026-03-13
|
||||
|
||||
### Added
|
||||
|
||||
- manifest에 license 필드 및 LICENSE 파일 추가
|
||||
|
||||
### Changed
|
||||
|
||||
- 설정 레이아웃 경로를 `resources/layouts/settings.json` → `resources/layouts/admin/plugin_settings.json`으로 이동 (모듈과 동일한 구조 통일)
|
||||
|
||||
## [0.1.1] - 2026-02-24
|
||||
|
||||
### Changed
|
||||
- 버전 체계 조정 (정식 출시 전 0.x 체계로 변경)
|
||||
|
||||
## [0.1.0] - 2026-02-23
|
||||
|
||||
### Added
|
||||
- 토스페이먼츠 결제 플러그인 초기 구현
|
||||
- 카드/계좌이체/가상계좌/무통장입금 결제 지원
|
||||
- 결제 위젯 (TossPayments SDK) 통합
|
||||
- 결제 확인 (confirm) 프로세스
|
||||
- 결제 성공/실패 콜백 처리
|
||||
- 결제 상태 웹훅 처리
|
||||
- 플러그인 설정 UI (API 키, 시크릿 키 관리)
|
||||
- sirsoft-ecommerce 모듈 결제 인터페이스 (PaymentInterface) 연동
|
||||
- 다국어 지원 (ko, en)
|
||||
@@ -0,0 +1,47 @@
|
||||
프로그램 명칭 : 그누보드7용 토스페이먼츠 플러그인 (sirsoft-tosspayments)
|
||||
|
||||
저작자 : (주)에스아이알소프트
|
||||
|
||||
----- 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,20 @@
|
||||
{
|
||||
"name": "plugins/sirsoft-tosspayments",
|
||||
"description": "TossPayments 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\\Tosspayments\\": ["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": "6754a7ed2d766f6ab8147eb4506789cb",
|
||||
"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": "토스페이먼츠 PG 플러그인 환경설정"
|
||||
},
|
||||
"defaults": {
|
||||
"is_test_mode": true,
|
||||
"test_client_key": "",
|
||||
"test_secret_key": "",
|
||||
"live_client_key": "",
|
||||
"live_secret_key": "",
|
||||
"redirect_success_url": "/shop/orders/{orderId}/complete",
|
||||
"redirect_fail_url": "/shop/checkout"
|
||||
},
|
||||
"frontend_schema": {
|
||||
"is_test_mode": { "expose": false },
|
||||
"test_client_key": { "expose": false },
|
||||
"test_secret_key": { "expose": false },
|
||||
"live_client_key": { "expose": false },
|
||||
"live_secret_key": { "expose": false },
|
||||
"redirect_success_url": { "expose": false },
|
||||
"redirect_fail_url": { "expose": false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
(function(){"use strict";function f(t){return new Promise((n,e)=>{if(document.querySelector(`script[src="${t}"]`)){n();return}const o=document.createElement("script");o.src=t,o.async=!0,o.onload=()=>n(),o.onerror=()=>e(new Error(`Failed to load script: ${t}`)),document.head.appendChild(o)})}async function y(t,n){const{pgPaymentData:e}=t.params||{};if(!e){console.error("[sirsoft-tosspayments] pgPaymentData is required");return}const o=window.G7Core;try{const r=await o.api.get("/modules/sirsoft-ecommerce/payments/client-config/tosspayments");if(!r.data){console.error("[sirsoft-tosspayments] Failed to fetch client config",r);return}const s=r.data;if(window.TossPayments||await f(s.sdk_url),window.TossPayments||await new Promise(w=>setTimeout(w,100)),!window.TossPayments){console.error("[sirsoft-tosspayments] TossPayments SDK not available");return}const p=window.TossPayments(s.client_key).payment({customerKey:e.customer_key??window.TossPayments.ANONYMOUS}),u=window.location.origin;await p.requestPayment({method:"CARD",amount:{currency:e.currency??"KRW",value:e.amount},orderId:e.order_number,orderName:e.order_name,successUrl:u+s.callback_urls.success,failUrl:u+s.callback_urls.fail,customerEmail:e.customer_email??void 0,customerName:e.customer_name??void 0,customerMobilePhone:e.customer_phone??void 0,card:{useEscrow:!1,flowMode:"DEFAULT",useCardPoint:!1,useAppCardOnly:!1}})}catch(r){if(console.error("[sirsoft-tosspayments] requestPayment error",r),r?.code==="USER_CANCEL"){console.info("[sirsoft-tosspayments] Payment cancelled by user");try{await o.api.post(`/modules/sirsoft-ecommerce/orders/${e.order_number}/cancel-payment`,{cancel_code:r.code,cancel_message:r.message})}catch(c){console.warn("[sirsoft-tosspayments] Failed to record cancellation",c)}o?.state?.setLocal?.({isSubmittingOrder:!1}),o?.modal?.open?.("tosspayments_payment_cancel_modal");return}const s=r?.message??"Unknown error";o?.state?.setLocal?.({paymentErrorMessage:s,isSubmittingOrder:!1}),o?.modal?.open?.("tosspayments_payment_error_modal")}}const l={requestPayment:y},a="sirsoft-tosspayments",i={info:(...t)=>console.info(`[${a}]`,...t),warn:(...t)=>console.warn(`[${a}]`,...t),error:(...t)=>console.error(`[${a}]`,...t)};function m(){const t=window.G7Core;if(!t)return 0;const n=t.getActionDispatcher;if(typeof n!="function")return 0;const e=n();if(!e||typeof e.registerHandler!="function")return 0;let o=0;for(const[r,s]of Object.entries(l)){const c=`${a}.${r}`;e.registerHandler(c,s,{category:"plugin",source:a}),o++}return o}function d(){const t=()=>{const n=m();if(n>0){i.info(`${n} handler(s) registered`);return}let e=0;const o=50,r=setInterval(()=>{e++;const s=m();if(s>0){clearInterval(r),i.info(`${s} handler(s) registered (after ${e} retries)`);return}e>=o&&(clearInterval(r),i.warn("ActionDispatcher not available after timeout"))},100)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",t):t()}d(),window.__SirsoftTosspayments={identifier:a,handlers:Object.keys(l),initPlugin:d}})();
|
||||
//# sourceMappingURL=plugin.iife.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* TossPayments Plugin Messages (English)
|
||||
*/
|
||||
return [
|
||||
'refund' => [
|
||||
'missing_payment_key' => 'Cannot process refund: TossPayments payment key not found.',
|
||||
'default_reason' => 'Cancelled by customer request',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* TossPayments PG provider metadata (English)
|
||||
*
|
||||
* Label referenced by name_key in `sirsoft-ecommerce.payment.registered_pg_providers` hook payload.
|
||||
* Automatically supplemented by active language packs.
|
||||
*/
|
||||
return [
|
||||
'name' => 'TossPayments',
|
||||
'description' => 'TossPayments domestic payment gateway integration',
|
||||
];
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 플러그인 메시지 (Korean)
|
||||
*/
|
||||
return [
|
||||
'refund' => [
|
||||
'missing_payment_key' => '토스페이먼츠 결제 키가 존재하지 않아 환불 처리할 수 없습니다.',
|
||||
'default_reason' => '고객 요청에 의한 취소',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 PG 프로바이더 메타 (Korean)
|
||||
*
|
||||
* `sirsoft-ecommerce.payment.registered_pg_providers` 훅 페이로드의
|
||||
* name_key 가 가리키는 라벨. 활성 언어팩으로 자동 보강된다.
|
||||
*/
|
||||
return [
|
||||
'name' => '토스페이먼츠',
|
||||
'description' => '국내 PG 사 토스페이먼츠 결제 연동',
|
||||
];
|
||||
+2014
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-tosspayments",
|
||||
"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": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"identifier": "sirsoft-tosspayments",
|
||||
"vendor": "sirsoft",
|
||||
"name": {
|
||||
"ko": "토스페이먼츠",
|
||||
"en": "TossPayments"
|
||||
},
|
||||
"version": "1.0.0-beta.4",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "토스페이먼츠 결제 게이트웨이 (통합결제창 연동)",
|
||||
"en": "TossPayments gateway (integrated payment window)"
|
||||
},
|
||||
"g7_version": ">=7.0.0-beta.2",
|
||||
"dependencies": {
|
||||
"modules": {
|
||||
"sirsoft-ecommerce": ">=1.0.0-beta.4"
|
||||
},
|
||||
"plugins": {}
|
||||
},
|
||||
"github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-tosspayments",
|
||||
"github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-tosspayments/blob/main/CHANGELOG.md",
|
||||
"assets": {
|
||||
"js": {
|
||||
"entry": "resources/js/index.ts",
|
||||
"output": "dist/js/plugin.iife.js"
|
||||
},
|
||||
"handlers": true
|
||||
},
|
||||
"loading": {
|
||||
"strategy": "global",
|
||||
"priority": 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments;
|
||||
|
||||
use App\Extension\AbstractPlugin;
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 PG 플러그인
|
||||
*
|
||||
* 토스페이먼츠 통합결제창(카드/간편결제) 연동을 제공합니다.
|
||||
* sirsoft-ecommerce 모듈 전용 플러그인입니다.
|
||||
*/
|
||||
class Plugin extends AbstractPlugin
|
||||
{
|
||||
/**
|
||||
* 플러그인 메타데이터 반환
|
||||
*
|
||||
* @return array 메타데이터
|
||||
*/
|
||||
public function getMetadata(): array
|
||||
{
|
||||
return [
|
||||
'author' => 'Sirsoft',
|
||||
'license' => 'MIT',
|
||||
'homepage' => 'https://sir.kr',
|
||||
'keywords' => ['payment', 'tosspayments', 'pg', 'card', 'ecommerce'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 환경설정 스키마 반환
|
||||
*
|
||||
* @return array 설정 스키마
|
||||
*/
|
||||
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_client_key' => [
|
||||
'type' => 'string',
|
||||
'default' => '',
|
||||
'label' => ['ko' => '테스트 클라이언트 키', 'en' => 'Test Client Key'],
|
||||
'hint' => [
|
||||
'ko' => '개발자센터 > API 개별 연동 키에서 확인',
|
||||
'en' => 'Found in Developer Center > API Keys',
|
||||
],
|
||||
],
|
||||
'test_secret_key' => [
|
||||
'type' => 'string',
|
||||
'default' => '',
|
||||
'sensitive' => true,
|
||||
'label' => ['ko' => '테스트 시크릿 키', 'en' => 'Test Secret Key'],
|
||||
'hint' => [
|
||||
'ko' => '외부에 노출되지 않도록 주의하세요.',
|
||||
'en' => 'Keep this key secret.',
|
||||
],
|
||||
],
|
||||
'live_client_key' => [
|
||||
'type' => 'string',
|
||||
'default' => '',
|
||||
'label' => ['ko' => '라이브 클라이언트 키', 'en' => 'Live Client Key'],
|
||||
],
|
||||
'live_secret_key' => [
|
||||
'type' => 'string',
|
||||
'default' => '',
|
||||
'sensitive' => true,
|
||||
'label' => ['ko' => '라이브 시크릿 키', 'en' => 'Live Secret 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.',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 설정값 반환
|
||||
*
|
||||
* @return array 기본 설정값
|
||||
*/
|
||||
public function getConfigValues(): array
|
||||
{
|
||||
return [
|
||||
'is_test_mode' => true,
|
||||
'test_client_key' => '',
|
||||
'test_secret_key' => '',
|
||||
'live_client_key' => '',
|
||||
'live_secret_key' => '',
|
||||
'redirect_success_url' => '/shop/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => '/shop/checkout',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 훅 리스너 클래스 반환
|
||||
*
|
||||
* @return array 리스너 클래스 목록
|
||||
*/
|
||||
public function getHookListeners(): array
|
||||
{
|
||||
return [
|
||||
Listeners\RegisterPgProviderListener::class,
|
||||
Listeners\PaymentRefundListener::class,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 훅 정의 반환
|
||||
*
|
||||
* @return array 훅 정의
|
||||
*/
|
||||
public function getHooks(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'name' => 'sirsoft-tosspayments.payment.before_confirm',
|
||||
'type' => 'action',
|
||||
'description' => [
|
||||
'ko' => '토스페이먼츠 결제 승인 API 호출 전',
|
||||
'en' => 'Before TossPayments confirm API call',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'sirsoft-tosspayments.payment.after_confirm',
|
||||
'type' => 'action',
|
||||
'description' => [
|
||||
'ko' => '토스페이먼츠 결제 승인 완료 후',
|
||||
'en' => 'After TossPayments confirm API completed',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"extension_point": "shop_checkout_extensions",
|
||||
"modals": [
|
||||
{
|
||||
"id": "tosspayments_payment_error_modal",
|
||||
"type": "composite",
|
||||
"name": "Modal",
|
||||
"props": {
|
||||
"title": "$t:sirsoft-tosspayments.payment_error_title",
|
||||
"size": "sm"
|
||||
},
|
||||
"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": "{{_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": "setState",
|
||||
"params": {
|
||||
"target": "_local.paymentErrorMessage",
|
||||
"value": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "closeModal",
|
||||
"params": {
|
||||
"id": "tosspayments_payment_error_modal"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
,
|
||||
{
|
||||
"id": "tosspayments_payment_cancel_modal",
|
||||
"type": "composite",
|
||||
"name": "Modal",
|
||||
"props": {
|
||||
"title": "$t:sirsoft-tosspayments.payment_cancel_title",
|
||||
"size": "sm"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "text-center py-4"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "info-circle",
|
||||
"className": "text-yellow-500 text-4xl mb-4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "text-gray-700 dark:text-gray-300"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.payment_cancelled_message"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "closeModal",
|
||||
"params": {
|
||||
"id": "tosspayments_payment_cancel_modal"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"priority": 100
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
// e2e:allow 정식 출시 시 제거된 번들 플러그인의 복원 — 신규 프론트 동작 없음 (결제창 진입은 PG 샌드박스 의존)
|
||||
/**
|
||||
* requestPayment 핸들러 테스트
|
||||
*
|
||||
* 토스페이먼츠 결제창 호출 핸들러의 에러 처리 및 모달 열기 동작을 검증합니다.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { requestPaymentHandler } from '../../handlers/requestPayment';
|
||||
|
||||
const CLIENT_CONFIG_DATA = {
|
||||
data: {
|
||||
client_key: 'test_key',
|
||||
sdk_url: 'https://example.com/sdk.js',
|
||||
callback_urls: { success: '/success', fail: '/fail' },
|
||||
},
|
||||
};
|
||||
|
||||
describe('requestPaymentHandler', () => {
|
||||
let mockG7Core: {
|
||||
api: { get: ReturnType<typeof vi.fn>; post: ReturnType<typeof vi.fn> };
|
||||
state: { setLocal: ReturnType<typeof vi.fn> };
|
||||
modal: { open: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockG7Core = {
|
||||
api: {
|
||||
get: vi.fn().mockResolvedValue(CLIENT_CONFIG_DATA),
|
||||
post: vi.fn().mockResolvedValue({ success: true }),
|
||||
},
|
||||
state: { setLocal: vi.fn() },
|
||||
modal: { open: vi.fn() },
|
||||
};
|
||||
(window as any).G7Core = mockG7Core;
|
||||
(window as any).TossPayments = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as any).G7Core;
|
||||
delete (window as any).TossPayments;
|
||||
});
|
||||
|
||||
it('pgPaymentData가 없으면 조기 반환한다', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await requestPaymentHandler({ params: {} });
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('pgPaymentData is required')
|
||||
);
|
||||
expect(mockG7Core.state.setLocal).not.toHaveBeenCalled();
|
||||
expect(mockG7Core.modal.open).not.toHaveBeenCalled();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('SDK 에러 시 에러 메시지를 setState하고 모달을 연다', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// TossPayments SDK Mock — requestPayment에서 에러 발생
|
||||
const mockPayment = {
|
||||
requestPayment: vi.fn().mockRejectedValue(new Error('Payment failed')),
|
||||
};
|
||||
(window as any).TossPayments = vi.fn().mockReturnValue({
|
||||
payment: vi.fn().mockReturnValue(mockPayment),
|
||||
});
|
||||
(window as any).TossPayments.ANONYMOUS = 'ANONYMOUS';
|
||||
|
||||
await requestPaymentHandler({
|
||||
params: {
|
||||
pgPaymentData: {
|
||||
order_number: 'ORD-001',
|
||||
order_name: 'Test Order',
|
||||
amount: 10000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// setState로 에러 메시지 설정
|
||||
expect(mockG7Core.state.setLocal).toHaveBeenCalledWith({
|
||||
paymentErrorMessage: 'Payment failed',
|
||||
isSubmittingOrder: false,
|
||||
});
|
||||
|
||||
// 모달 열기
|
||||
expect(mockG7Core.modal.open).toHaveBeenCalledWith('tosspayments_payment_error_modal');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('USER_CANCEL 처리', () => {
|
||||
const setupUserCancel = () => {
|
||||
const cancelError = new Error('User cancelled');
|
||||
(cancelError as any).code = 'USER_CANCEL';
|
||||
|
||||
const mockPayment = {
|
||||
requestPayment: vi.fn().mockRejectedValue(cancelError),
|
||||
};
|
||||
(window as any).TossPayments = vi.fn().mockReturnValue({
|
||||
payment: vi.fn().mockReturnValue(mockPayment),
|
||||
});
|
||||
(window as any).TossPayments.ANONYMOUS = 'ANONYMOUS';
|
||||
};
|
||||
|
||||
const callWithCancel = () => {
|
||||
setupUserCancel();
|
||||
|
||||
return requestPaymentHandler({
|
||||
params: {
|
||||
pgPaymentData: {
|
||||
order_number: 'ORD-002',
|
||||
order_name: 'Test Order',
|
||||
amount: 5000,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it('사용자 취소 시 cancel-payment API를 호출한다', async () => {
|
||||
const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await callWithCancel();
|
||||
|
||||
expect(mockG7Core.api.post).toHaveBeenCalledWith(
|
||||
'/modules/sirsoft-ecommerce/orders/ORD-002/cancel-payment',
|
||||
{
|
||||
cancel_code: 'USER_CANCEL',
|
||||
cancel_message: 'User cancelled',
|
||||
}
|
||||
);
|
||||
|
||||
consoleInfoSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('사용자 취소 시 로딩 상태를 해제한다', async () => {
|
||||
const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await callWithCancel();
|
||||
|
||||
expect(mockG7Core.state.setLocal).toHaveBeenCalledWith({
|
||||
isSubmittingOrder: false,
|
||||
});
|
||||
|
||||
consoleInfoSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('사용자 취소 시 취소 안내 모달을 연다', async () => {
|
||||
const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await callWithCancel();
|
||||
|
||||
expect(mockG7Core.modal.open).toHaveBeenCalledWith('tosspayments_payment_cancel_modal');
|
||||
// 에러 모달은 열리지 않아야 함
|
||||
expect(mockG7Core.modal.open).not.toHaveBeenCalledWith('tosspayments_payment_error_modal');
|
||||
|
||||
consoleInfoSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('cancel-payment API 실패 시에도 모달은 정상 표시한다', async () => {
|
||||
const consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
// cancel-payment API만 실패
|
||||
mockG7Core.api.post.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await callWithCancel();
|
||||
|
||||
// API 실패해도 모달과 상태 리셋은 정상 동작
|
||||
expect(mockG7Core.state.setLocal).toHaveBeenCalledWith({
|
||||
isSubmittingOrder: false,
|
||||
});
|
||||
expect(mockG7Core.modal.open).toHaveBeenCalledWith('tosspayments_payment_cancel_modal');
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to record cancellation'),
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
consoleInfoSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* @file pluginSettingsSemanticClasses.test.tsx
|
||||
* @description sirsoft-tosspayments 환경설정 화면의 시맨틱 클래스 매핑 회귀 테스트
|
||||
*
|
||||
* #399 Phase 1 전수 시맨틱화 당시 이 레이아웃의 클래스가 한 칸씩 밀려 순환 배치되었다:
|
||||
* field_* 컨테이너(3열 그리드) → section-heading-md (그리드 아님, 제목 타이포)
|
||||
* 섹션 H3(제목) → text-error-strong (에러 색)
|
||||
* 에러 H3 → text-error-soft
|
||||
* 에러 Li → grid-3col-responsive (리스트 항목에 그리드)
|
||||
*
|
||||
* 결과적으로 field_* 자식의 `lg:col-span-1/2` 가 부모 grid 부재로 무효화되어
|
||||
* 2열 폼 레이아웃이 무너졌다. 본 테스트는 각 노드가 자기 역할에 맞는 시맨틱
|
||||
* 클래스를 갖는지 고정한다.
|
||||
*
|
||||
* 시맨틱 클래스 정의 (templates/_bundled/sirsoft-admin_basic/src/styles/main.css):
|
||||
* .grid-3col-responsive → @apply grid grid-cols-1 lg:grid-cols-3 gap-4 items-start
|
||||
* .section-heading-md → @apply text-base font-semibold text-gray-900 dark:text-white
|
||||
* .text-error-strong → @apply text-sm font-medium text-red-800 dark:text-red-200
|
||||
* .text-error-soft → @apply text-sm text-red-700 dark:text-red-300
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import pluginSettingsLayout from '../../../layouts/admin/plugin_settings.json';
|
||||
|
||||
type Node = Record<string, unknown>;
|
||||
|
||||
/** 레이아웃 트리를 순회하며 술어를 만족하는 노드를 모두 수집한다. */
|
||||
function collect(node: unknown, predicate: (n: Node) => boolean, acc: Node[] = []): Node[] {
|
||||
if (!node || typeof node !== 'object') {
|
||||
return acc;
|
||||
}
|
||||
const value = node as Node;
|
||||
if (predicate(value)) {
|
||||
acc.push(value);
|
||||
}
|
||||
for (const child of Object.values(value)) {
|
||||
collect(child, predicate, acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
function classNameOf(node: Node | undefined): string {
|
||||
const props = (node?.props ?? {}) as Record<string, unknown>;
|
||||
return typeof props.className === 'string' ? props.className : '';
|
||||
}
|
||||
|
||||
/** `lg:col-span-*` 를 쓰는 2열 폼 필드 행 (토글 단독 행인 field_test_mode 는 제외). */
|
||||
const GRID_FIELD_IDS = [
|
||||
'field_test_client_key',
|
||||
'field_test_secret_key',
|
||||
'field_live_client_key',
|
||||
'field_live_secret_key',
|
||||
'field_redirect_success_url',
|
||||
'field_redirect_fail_url',
|
||||
];
|
||||
|
||||
describe('plugin_settings 시맨틱 클래스 매핑', () => {
|
||||
describe('필드 행 컨테이너', () => {
|
||||
it.each(GRID_FIELD_IDS)('%s 는 grid-3col-responsive 를 가져야 한다', (id) => {
|
||||
const [node] = collect(pluginSettingsLayout, (n) => n.id === id);
|
||||
expect(node).toBeDefined();
|
||||
expect(classNameOf(node)).toBe('grid-3col-responsive');
|
||||
});
|
||||
|
||||
it('자식의 lg:col-span-* 가 유효하도록 부모가 그리드 컨테이너여야 한다', () => {
|
||||
for (const id of GRID_FIELD_IDS) {
|
||||
const [parent] = collect(pluginSettingsLayout, (n) => n.id === id);
|
||||
const children = (parent?.children ?? []) as Node[];
|
||||
const spans = children.map(classNameOf).filter((c) => c.includes('col-span'));
|
||||
|
||||
// col-span 을 쓰는 자식이 있다면 부모는 반드시 그리드여야 한다.
|
||||
if (spans.length > 0) {
|
||||
expect(classNameOf(parent)).toBe('grid-3col-responsive');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('섹션 제목 H3', () => {
|
||||
it('테스트/라이브/리다이렉트 섹션 제목은 section-heading-md 를 가져야 한다', () => {
|
||||
const sectionHeadings = collect(
|
||||
pluginSettingsLayout,
|
||||
(n) => n.name === 'H3' && typeof n.text === 'string' && n.text.startsWith('$t:sirsoft-tosspayments.settings.section_'),
|
||||
);
|
||||
|
||||
expect(sectionHeadings).toHaveLength(3);
|
||||
for (const heading of sectionHeadings) {
|
||||
expect(classNameOf(heading)).toBe('section-heading-md');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('검증 에러 영역', () => {
|
||||
it('에러 제목 H3 는 text-error-strong 을 가져야 한다', () => {
|
||||
const [errorHeading] = collect(
|
||||
pluginSettingsLayout,
|
||||
(n) => n.name === 'H3' && n.text === '$t:admin.plugins.settings.validation_error',
|
||||
);
|
||||
|
||||
expect(errorHeading).toBeDefined();
|
||||
expect(classNameOf(errorHeading)).toBe('text-error-strong');
|
||||
});
|
||||
|
||||
it('에러 메시지 Li 는 text-error-soft 를 가져야 한다 (그리드 클래스 금지)', () => {
|
||||
const items = collect(pluginSettingsLayout, (n) => n.name === 'Li');
|
||||
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
for (const item of items) {
|
||||
expect(classNameOf(item)).toBe('text-error-soft');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('타이포그래피 시맨틱 클래스가 그리드 역할로 오용되지 않는다', () => {
|
||||
const misused = collect(
|
||||
pluginSettingsLayout,
|
||||
(n) => n.id !== undefined && /^field_/.test(String(n.id)) && classNameOf(n) === 'section-heading-md',
|
||||
);
|
||||
|
||||
expect(misused).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @file pluginSettingsSticky.test.tsx
|
||||
* @description sirsoft-tosspayments 플러그인 환경설정 화면 하단 저장 버튼 sticky 고정 테스트
|
||||
*
|
||||
* 플러그인 환경설정(plugin_settings.json)의 하단 저장/취소 버튼 영역이
|
||||
* 긴 콘텐츠를 스크롤하는 동안에도 화면 하단에 고정되도록 sticky 클래스가
|
||||
* 적용되어 있는지 검증한다.
|
||||
*
|
||||
* 고정 스타일은 admin 템플릿의 `.sticky-footer-buttons` 시맨틱 클래스가 소유한다
|
||||
* (`@apply sticky bottom-0 z-10 ...`). 레이아웃은 유틸리티 클래스를 나열하지 않고
|
||||
* 이 시맨틱 클래스만 선언한다 (#399 Phase 1.5 전수 시맨틱화).
|
||||
*/
|
||||
|
||||
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 : '';
|
||||
}
|
||||
|
||||
describe('plugin_settings 하단 버튼 sticky 고정', () => {
|
||||
it('footer_buttons 에 sticky-footer-buttons 시맨틱 클래스가 존재해야 한다', () => {
|
||||
const footer = findById(pluginSettingsLayout, 'footer_buttons');
|
||||
expect(footer).toBeDefined();
|
||||
|
||||
const className = classNameOf(footer);
|
||||
expect(className).toContain('sticky-footer-buttons');
|
||||
});
|
||||
|
||||
it('footer_buttons 는 고정 스타일을 유틸리티 클래스로 직접 나열하지 않는다', () => {
|
||||
const className = classNameOf(findById(pluginSettingsLayout, 'footer_buttons'));
|
||||
const utilities = className.split(/\s+/).filter(Boolean);
|
||||
|
||||
expect(utilities).not.toContain('bottom-0');
|
||||
expect(utilities).not.toContain('z-10');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { requestPaymentHandler } from './requestPayment';
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 플러그인 핸들러 맵
|
||||
*
|
||||
* ActionDispatcher에 등록될 핸들러 목록입니다.
|
||||
* 네임스페이스 접두사(sirsoft-tosspayments.)는 index.ts에서 자동으로 추가됩니다.
|
||||
*/
|
||||
export const handlerMap: Record<string, (...args: unknown[]) => unknown> = {
|
||||
requestPayment: requestPaymentHandler,
|
||||
} as const;
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 토스페이먼츠 결제창 호출 핸들러
|
||||
*
|
||||
* 체크아웃 레이아웃에서 주문 생성 API 성공 후 호출됩니다:
|
||||
* handler: "sirsoft-tosspayments.requestPayment"
|
||||
* params: { pgPaymentData: response.data.pg_payment_data }
|
||||
*
|
||||
* 호출 순서:
|
||||
* 1. Client Config API 호출 → clientKey 획득
|
||||
* 2. TossPayments SDK 동적 로드 (미로드 시)
|
||||
* 3. SDK 초기화 + payment 인스턴스 생성
|
||||
* 4. payment.requestPayment() 호출 → 통합결제창 오픈
|
||||
* 5. 결제 완료 시 브라우저가 successUrl/failUrl로 리다이렉트
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
interface PgPaymentData {
|
||||
order_number: string;
|
||||
order_name: string;
|
||||
amount: number;
|
||||
currency?: string;
|
||||
customer_name?: string;
|
||||
customer_email?: string;
|
||||
customer_phone?: string;
|
||||
customer_key?: string | null;
|
||||
}
|
||||
|
||||
interface RequestPaymentParams {
|
||||
pgPaymentData: PgPaymentData;
|
||||
}
|
||||
|
||||
interface ClientConfig {
|
||||
client_key: string;
|
||||
sdk_url: string;
|
||||
callback_urls: {
|
||||
success: string;
|
||||
fail: string;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
TossPayments: any;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 스크립트를 동적으로 로드합니다.
|
||||
*
|
||||
* @param src 스크립트 URL
|
||||
* @returns Promise
|
||||
*/
|
||||
function loadScript(src: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 이미 로드된 스크립트인지 확인
|
||||
if (document.querySelector(`script[src="${src}"]`)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 결제창 호출 핸들러
|
||||
*
|
||||
* ActionDispatcher는 커스텀 핸들러를 (action, context) 시그니처로 호출합니다.
|
||||
* params는 action.params에서 접근해야 합니다.
|
||||
*
|
||||
* @param action 액션 정의 (handler, params 등)
|
||||
* @param _context 액션 컨텍스트
|
||||
*/
|
||||
export async function requestPaymentHandler(action: any, _context?: any): Promise<void> {
|
||||
const { pgPaymentData } = (action.params || {}) as RequestPaymentParams;
|
||||
|
||||
if (!pgPaymentData) {
|
||||
console.error('[sirsoft-tosspayments] pgPaymentData is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const G7Core = (window as any).G7Core;
|
||||
|
||||
try {
|
||||
// 1. Client Config API 호출
|
||||
const configJson = await G7Core.api.get('/modules/sirsoft-ecommerce/payments/client-config/tosspayments');
|
||||
|
||||
if (!configJson.data) {
|
||||
console.error('[sirsoft-tosspayments] Failed to fetch client config', configJson);
|
||||
return;
|
||||
}
|
||||
|
||||
const config: ClientConfig = configJson.data;
|
||||
|
||||
// 2. SDK 동적 로드 (미로드 시)
|
||||
if (!window.TossPayments) {
|
||||
await loadScript(config.sdk_url);
|
||||
}
|
||||
|
||||
// SDK 로드 대기 (스크립트 로드 후 TossPayments 객체 초기화까지 약간의 시간 소요)
|
||||
if (!window.TossPayments) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
if (!window.TossPayments) {
|
||||
console.error('[sirsoft-tosspayments] TossPayments SDK not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. SDK 초기화
|
||||
const tossPayments = window.TossPayments(config.client_key);
|
||||
const payment = tossPayments.payment({
|
||||
customerKey: pgPaymentData.customer_key ?? window.TossPayments.ANONYMOUS,
|
||||
});
|
||||
|
||||
// 4. 결제 요청 (통합결제창)
|
||||
const origin = window.location.origin;
|
||||
|
||||
await payment.requestPayment({
|
||||
method: 'CARD',
|
||||
amount: {
|
||||
currency: pgPaymentData.currency ?? 'KRW',
|
||||
value: pgPaymentData.amount,
|
||||
},
|
||||
orderId: pgPaymentData.order_number,
|
||||
orderName: pgPaymentData.order_name,
|
||||
successUrl: origin + config.callback_urls.success,
|
||||
failUrl: origin + config.callback_urls.fail,
|
||||
customerEmail: pgPaymentData.customer_email ?? undefined,
|
||||
customerName: pgPaymentData.customer_name ?? undefined,
|
||||
customerMobilePhone: pgPaymentData.customer_phone ?? undefined,
|
||||
card: {
|
||||
useEscrow: false,
|
||||
flowMode: 'DEFAULT',
|
||||
useCardPoint: false,
|
||||
useAppCardOnly: false,
|
||||
},
|
||||
});
|
||||
// → 브라우저가 successUrl 또는 failUrl로 리다이렉트됨
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[sirsoft-tosspayments] requestPayment error', error);
|
||||
|
||||
// SDK에서 사용자 취소 시 에러가 발생할 수 있음
|
||||
if (error?.code === 'USER_CANCEL') {
|
||||
console.info('[sirsoft-tosspayments] Payment cancelled by user');
|
||||
|
||||
// 1. 결제 취소 이력 기록 API 호출 (PG사 응답값 전달)
|
||||
try {
|
||||
await G7Core.api.post(
|
||||
`/modules/sirsoft-ecommerce/orders/${pgPaymentData.order_number}/cancel-payment`,
|
||||
{
|
||||
cancel_code: error.code,
|
||||
cancel_message: error.message,
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('[sirsoft-tosspayments] Failed to record cancellation', e);
|
||||
}
|
||||
|
||||
// 2. 로딩 상태 해제 + 취소 안내 모달 표시
|
||||
G7Core?.state?.setLocal?.({ isSubmittingOrder: false });
|
||||
G7Core?.modal?.open?.('tosspayments_payment_cancel_modal');
|
||||
return;
|
||||
}
|
||||
|
||||
// 기타 에러 시 모달로 오류 표시 (체크아웃 페이지 유지)
|
||||
const errorMessage = error?.message ?? 'Unknown error';
|
||||
G7Core?.state?.setLocal?.({ paymentErrorMessage: errorMessage, isSubmittingOrder: false });
|
||||
G7Core?.modal?.open?.('tosspayments_payment_error_modal');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 토스페이먼츠 플러그인 엔트리
|
||||
*
|
||||
* ActionDispatcher에 핸들러를 등록하여
|
||||
* 레이아웃 JSON에서 "sirsoft-tosspayments.requestPayment" 형태로 호출할 수 있게 합니다.
|
||||
*/
|
||||
|
||||
import { handlerMap } from './handlers';
|
||||
|
||||
const PLUGIN_IDENTIFIER = 'sirsoft-tosspayments';
|
||||
|
||||
/**
|
||||
* 로거 (G7Core 초기화 전에도 동작)
|
||||
*/
|
||||
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),
|
||||
};
|
||||
|
||||
/**
|
||||
* ActionDispatcher에 핸들러를 등록합니다.
|
||||
*
|
||||
* @returns 등록된 핸들러 수
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 초기화
|
||||
*
|
||||
* DOMContentLoaded 후 ActionDispatcher가 준비되면 핸들러를 등록합니다.
|
||||
* ActionDispatcher가 아직 준비되지 않았으면 최대 5초간 재시도합니다.
|
||||
*/
|
||||
function initPlugin(): void {
|
||||
const doInit = () => {
|
||||
const count = registerHandlers();
|
||||
|
||||
if (count > 0) {
|
||||
logger.info(`${count} handler(s) registered`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ActionDispatcher 미준비 시 재시도 (100ms 간격, 최대 50회 = 5초)
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 초기화
|
||||
initPlugin();
|
||||
|
||||
// 디버그용 글로벌 노출
|
||||
(window as Record<string, unknown>).__SirsoftTosspayments = {
|
||||
identifier: PLUGIN_IDENTIFIER,
|
||||
handlers: Object.keys(handlerMap),
|
||||
initPlugin,
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"editor": {
|
||||
"data_source": {
|
||||
"settings": "Payment Settings"
|
||||
}
|
||||
},
|
||||
"plugin_name": "TossPayments",
|
||||
"plugin_description": "TossPayments gateway (integrated payment window)",
|
||||
"settings": {
|
||||
"title": "TossPayments Settings",
|
||||
"description": "Configure TossPayments API keys.",
|
||||
"test_mode": "Test Mode",
|
||||
"test_mode_hint": "No real payments occur in test mode.",
|
||||
"test_client_key": "Test Client Key",
|
||||
"test_client_key_hint": "Found in Developer Center > API Keys",
|
||||
"test_secret_key": "Test Secret Key",
|
||||
"test_secret_key_hint": "Keep this key secret.",
|
||||
"live_client_key": "Live Client Key",
|
||||
"live_secret_key": "Live Secret Key",
|
||||
"live_secret_key_hint": "Keep this key secret.",
|
||||
"section_test_keys": "Test API Keys",
|
||||
"section_live_keys": "Live API Keys",
|
||||
"section_redirect": "Redirect Settings",
|
||||
"redirect_success_url": "Payment Success Redirect URL",
|
||||
"redirect_success_url_hint": "Supports relative paths (/shop/...) or full URLs (https://...). {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 (error, message, orderId) are appended as query parameters.",
|
||||
"save": "Save",
|
||||
"saved": "Settings saved."
|
||||
},
|
||||
"payment_error_title": "Payment Error",
|
||||
"payment_cancel_title": "Payment Cancelled",
|
||||
"payment_cancelled_message": "Payment has been cancelled. Please try again or choose a different payment method.",
|
||||
"errors": {
|
||||
"confirm_failed": "Payment confirmation failed.",
|
||||
"amount_mismatch": "Payment amount does not match.",
|
||||
"order_not_found": "Order not found.",
|
||||
"payment_failed": "Payment failed."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"editor": {
|
||||
"data_source": {
|
||||
"settings": "결제 설정"
|
||||
}
|
||||
},
|
||||
"plugin_name": "토스페이먼츠",
|
||||
"plugin_description": "토스페이먼츠 결제 게이트웨이 (통합결제창 연동)",
|
||||
"settings": {
|
||||
"title": "토스페이먼츠 설정",
|
||||
"description": "토스페이먼츠 API 키를 설정합니다.",
|
||||
"test_mode": "테스트 모드",
|
||||
"test_mode_hint": "테스트 모드에서는 실제 결제가 발생하지 않습니다.",
|
||||
"test_client_key": "테스트 클라이언트 키",
|
||||
"test_client_key_hint": "개발자센터 > API 개별 연동 키에서 확인",
|
||||
"test_secret_key": "테스트 시크릿 키",
|
||||
"test_secret_key_hint": "외부에 노출되지 않도록 주의하세요.",
|
||||
"live_client_key": "라이브 클라이언트 키",
|
||||
"live_secret_key": "라이브 시크릿 키",
|
||||
"live_secret_key_hint": "외부에 노출되지 않도록 주의하세요.",
|
||||
"section_test_keys": "테스트 API 키",
|
||||
"section_live_keys": "라이브 API 키",
|
||||
"section_redirect": "리다이렉트 설정",
|
||||
"redirect_success_url": "결제 성공 리다이렉트 URL",
|
||||
"redirect_success_url_hint": "상대 경로(/shop/...) 또는 전체 URL(https://...) 모두 가능합니다. {orderId}는 실제 주문번호로 자동 치환됩니다.",
|
||||
"redirect_fail_url": "결제 실패 리다이렉트 URL",
|
||||
"redirect_fail_url_hint": "상대 경로 또는 전체 URL 모두 가능합니다. 오류 정보(error, message, orderId)는 쿼리 파라미터로 자동 추가됩니다.",
|
||||
"save": "저장",
|
||||
"saved": "설정이 저장되었습니다."
|
||||
},
|
||||
"payment_error_title": "결제 오류",
|
||||
"payment_cancel_title": "결제 취소",
|
||||
"payment_cancelled_message": "결제가 취소되었습니다. 다시 결제를 시도하시거나 다른 결제 수단을 선택해 주세요.",
|
||||
"errors": {
|
||||
"confirm_failed": "결제 승인에 실패했습니다.",
|
||||
"amount_mismatch": "결제 금액이 일치하지 않습니다.",
|
||||
"order_not_found": "주문을 찾을 수 없습니다.",
|
||||
"payment_failed": "결제에 실패했습니다."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"layout_name": "plugin_settings",
|
||||
"permissions": [
|
||||
"core.plugins.update"
|
||||
],
|
||||
"extends": "_admin_base",
|
||||
"meta": {
|
||||
"title": "$t:sirsoft-tosspayments.settings.title",
|
||||
"description": "$t:sirsoft-tosspayments.settings.description"
|
||||
},
|
||||
"data_sources": [
|
||||
{
|
||||
"id": "settings",
|
||||
"label_key": "$t:sirsoft-tosspayments.editor.data_source.settings",
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/plugins/{{route.identifier}}/settings",
|
||||
"method": "GET",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"initLocal": "form",
|
||||
"refetchOnMount": true
|
||||
}
|
||||
],
|
||||
"slots": {
|
||||
"content": [
|
||||
{
|
||||
"id": "plugin_settings_content",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"dataKey": "form",
|
||||
"trackChanges": true,
|
||||
"props": {
|
||||
"className": "admin-page-content-responsive"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "page_header",
|
||||
"type": "composite",
|
||||
"name": "PageHeader",
|
||||
"props": {
|
||||
"title": "$t:sirsoft-tosspayments.settings.title",
|
||||
"description": "$t:sirsoft-tosspayments.settings.description",
|
||||
"backPath": "/admin/plugins",
|
||||
"backLabel": "$t:admin.plugins.back_to_list"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "validation_error",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_local.errors}}",
|
||||
"props": {
|
||||
"className": "alert-danger"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex gap-3"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "triangle-exclamation",
|
||||
"size": "md",
|
||||
"className": "text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "H3",
|
||||
"props": {
|
||||
"className": "text-error-strong"
|
||||
},
|
||||
"text": "$t:admin.plugins.settings.validation_error"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Ul",
|
||||
"if": "{{typeof _local.errors === 'object' && Object.keys(_local.errors).length > 0}}",
|
||||
"props": {
|
||||
"className": "mt-2 list-disc list-inside space-y-1"
|
||||
},
|
||||
"iteration": {
|
||||
"source": "Object.entries(_local.errors ?? {}).flatMap(([field, messages]) => Array.isArray(messages) ? messages : [messages])",
|
||||
"item_var": "message"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Li",
|
||||
"props": {
|
||||
"className": "text-error-soft"
|
||||
},
|
||||
"text": "{{message}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test_mode_card",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "p-6"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "field_test_mode",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex-between"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": {
|
||||
"className": "form-label"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.test_mode"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "form-hint"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.test_mode_hint"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "Toggle",
|
||||
"props": {
|
||||
"name": "is_test_mode"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test_keys_section",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "panel-header-row"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "H3",
|
||||
"props": {
|
||||
"className": "section-heading-md"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.section_test_keys"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "p-6 space-y-6"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "field_test_client_key",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "grid-3col-responsive"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "lg:col-span-1"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": {
|
||||
"className": "form-label"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.test_client_key"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "form-hint"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.test_client_key_hint"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "lg:col-span-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "text",
|
||||
"name": "test_client_key",
|
||||
"placeholder": "test_ck_...",
|
||||
"className": "focus:border-transparent font-mono"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "field_test_secret_key",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "grid-3col-responsive"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "lg:col-span-1"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": {
|
||||
"className": "form-label"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.test_secret_key"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "form-hint"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.test_secret_key_hint"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "lg:col-span-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "password",
|
||||
"name": "test_secret_key",
|
||||
"placeholder": "test_sk_...",
|
||||
"className": "focus:border-transparent font-mono"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "live_keys_section",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "panel-header-row"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "H3",
|
||||
"props": {
|
||||
"className": "section-heading-md"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.section_live_keys"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "p-6 space-y-6"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "field_live_client_key",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "grid-3col-responsive"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "lg:col-span-1"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": {
|
||||
"className": "form-label"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.live_client_key"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "lg:col-span-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "text",
|
||||
"name": "live_client_key",
|
||||
"placeholder": "live_ck_...",
|
||||
"className": "focus:border-transparent font-mono"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "field_live_secret_key",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "grid-3col-responsive"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "lg:col-span-1"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": {
|
||||
"className": "form-label"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.live_secret_key"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "form-hint"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.live_secret_key_hint"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "lg:col-span-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "password",
|
||||
"name": "live_secret_key",
|
||||
"placeholder": "live_sk_...",
|
||||
"className": "focus:border-transparent font-mono"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "redirect_section",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "panel-header-row"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "H3",
|
||||
"props": {
|
||||
"className": "section-heading-md"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.section_redirect"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "p-6 space-y-6"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "field_redirect_success_url",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "grid-3col-responsive"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "lg:col-span-1"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": {
|
||||
"className": "form-label"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.redirect_success_url"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "form-hint"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.redirect_success_url_hint"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "lg:col-span-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "text",
|
||||
"name": "redirect_success_url",
|
||||
"placeholder": "/shop/orders/{orderId}/complete",
|
||||
"className": "focus:border-transparent"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "field_redirect_fail_url",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "grid-3col-responsive"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "lg:col-span-1"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Label",
|
||||
"props": {
|
||||
"className": "form-label"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.redirect_fail_url"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "form-hint"
|
||||
},
|
||||
"text": "$t:sirsoft-tosspayments.settings.redirect_fail_url_hint"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "lg:col-span-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Input",
|
||||
"props": {
|
||||
"type": "text",
|
||||
"name": "redirect_fail_url",
|
||||
"placeholder": "/shop/checkout",
|
||||
"className": "focus:border-transparent"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "footer_buttons",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "sticky-footer-buttons"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "cancel_button",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "btn btn-secondary"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/plugins"
|
||||
}
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:common.cancel"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "save_button",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"disabled": "{{!_local.hasChanges || _local.isSaving}}",
|
||||
"className": "btn btn-primary flex-center gap-1.5"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"isSaving": true,
|
||||
"errors": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/admin/plugins/{{route.identifier}}/settings",
|
||||
"params": {
|
||||
"method": "PUT",
|
||||
"body": "{{_local.form}}"
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"isSaving": false,
|
||||
"hasChanges": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "success",
|
||||
"message": "$t:admin.plugins.settings.save_success"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "settings"
|
||||
}
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "local",
|
||||
"isSaving": false,
|
||||
"errors": "{{$error.errors}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "$t:admin.plugins.settings.save_error"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"if": "{{_local.isSaving}}",
|
||||
"props": {
|
||||
"name": "spinner",
|
||||
"className": "spinner-sm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!_local.isSaving}}",
|
||||
"text": "$t:common.save"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{_local.isSaving}}",
|
||||
"text": "$t:common.saving"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Controllers;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Services\PluginSettingsService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException;
|
||||
use Modules\Sirsoft\Ecommerce\Helpers\DeviceDetector;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
|
||||
use Plugins\Sirsoft\Tosspayments\Http\Requests\FailCallbackRequest;
|
||||
use Plugins\Sirsoft\Tosspayments\Http\Requests\SuccessCallbackRequest;
|
||||
use Plugins\Sirsoft\Tosspayments\Services\TossPaymentsApiService;
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 결제 콜백 컨트롤러
|
||||
*
|
||||
* 토스페이먼츠 결제 완료/실패 후 브라우저 리다이렉트를 처리합니다.
|
||||
* 성공 시 Confirm API를 호출하고, 주문 상태를 업데이트합니다.
|
||||
*/
|
||||
class PaymentCallbackController
|
||||
{
|
||||
private const PLUGIN_IDENTIFIER = 'sirsoft-tosspayments';
|
||||
|
||||
/**
|
||||
* 카드 발급사 코드 → 이름 매핑
|
||||
*/
|
||||
private const CARD_ISSUER_MAP = [
|
||||
'3K' => '기업 BC',
|
||||
'46' => '광주',
|
||||
'71' => '롯데',
|
||||
'30' => '산업',
|
||||
'31' => '비씨',
|
||||
'51' => '삼성',
|
||||
'38' => '새마을금고',
|
||||
'41' => '신한',
|
||||
'62' => '신협',
|
||||
'36' => '씨티',
|
||||
'33' => '우리',
|
||||
'W1' => '우리',
|
||||
'37' => '우체국',
|
||||
'39' => '저축',
|
||||
'35' => '전북',
|
||||
'42' => '제주',
|
||||
'15' => '카카오뱅크',
|
||||
'3A' => '케이뱅크',
|
||||
'24' => '토스뱅크',
|
||||
'21' => '하나',
|
||||
'61' => '현대',
|
||||
'11' => 'KB국민',
|
||||
'91' => 'NH농협',
|
||||
'34' => 'Sh수협',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private OrderProcessingService $orderService,
|
||||
private PluginSettingsService $pluginSettingsService,
|
||||
private TossPaymentsApiService $apiService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 결제 성공 콜백
|
||||
*
|
||||
* GET /plugins/sirsoft-tosspayments/payment/success
|
||||
* ?paymentKey={PK}&orderId={OID}&amount={AMT}
|
||||
*
|
||||
* @param SuccessCallbackRequest $request 검증된 콜백 요청
|
||||
* @return RedirectResponse SPA 페이지로 리다이렉트
|
||||
*/
|
||||
public function success(SuccessCallbackRequest $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$paymentKey = $validated['paymentKey'];
|
||||
$orderId = $validated['orderId'];
|
||||
$pgAmount = $validated['amount'];
|
||||
|
||||
try {
|
||||
// 1. 주문 조회 (주문번호 기반)
|
||||
$order = $this->orderService->findByOrderNumber($orderId);
|
||||
|
||||
if (! $order) {
|
||||
Log::error('TossPayments: order not found', ['orderId' => $orderId]);
|
||||
|
||||
return redirect($this->resolveFailUrl(['error' => 'order_not_found', 'orderId' => $orderId]));
|
||||
}
|
||||
|
||||
// 2. TossPayments Confirm API 호출
|
||||
HookManager::doAction('sirsoft-tosspayments.payment.before_confirm', $order, $paymentKey, $pgAmount);
|
||||
|
||||
$pgResponse = $this->apiService->confirmPayment($paymentKey, $orderId, $pgAmount);
|
||||
|
||||
HookManager::doAction('sirsoft-tosspayments.payment.after_confirm', $order, $pgResponse);
|
||||
|
||||
// 3. 주문 상태 업데이트 (금액 검증 + PG 응답 → order_payments 매핑)
|
||||
$this->orderService->completePayment($order, [
|
||||
'transaction_id' => $pgResponse['paymentKey'] ?? null,
|
||||
'card_approval_number' => $pgResponse['card']['approveNo'] ?? null,
|
||||
'card_number_masked' => $pgResponse['card']['number'] ?? null,
|
||||
'card_name' => $this->resolveCardIssuer($pgResponse['card']['issuerCode'] ?? null),
|
||||
'card_installment_months' => $pgResponse['card']['installmentPlanMonths'] ?? 0,
|
||||
'is_interest_free' => $pgResponse['card']['isInterestFree'] ?? false,
|
||||
'embedded_pg_provider' => $pgResponse['easyPay']['provider'] ?? null,
|
||||
'receipt_url' => $pgResponse['receipt']['url'] ?? null,
|
||||
'payment_meta' => [
|
||||
'method' => $pgResponse['method'] ?? null,
|
||||
'pg_raw_response' => $pgResponse,
|
||||
],
|
||||
'payment_device' => DeviceDetector::detect($request),
|
||||
], $pgAmount);
|
||||
|
||||
// 4. SPA 주문 완료 페이지로 redirect
|
||||
return redirect($this->resolveSuccessUrl($orderId));
|
||||
|
||||
} catch (PaymentAmountMismatchException $e) {
|
||||
Log::error('TossPayments: amount mismatch', [
|
||||
'orderId' => $orderId,
|
||||
'expected' => $e->getExpectedAmount(),
|
||||
'actual' => $e->getActualAmount(),
|
||||
'context' => $e->getContext(),
|
||||
]);
|
||||
|
||||
return redirect($this->resolveFailUrl([
|
||||
'error' => 'amount_mismatch',
|
||||
'orderId' => $orderId,
|
||||
]));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('TossPayments: confirm failed', [
|
||||
'orderId' => $orderId,
|
||||
'error' => $e->getMessage(),
|
||||
'code' => $e->getCode(),
|
||||
]);
|
||||
|
||||
return redirect($this->resolveFailUrl([
|
||||
'error' => 'confirm_failed',
|
||||
'message' => $e->getMessage(),
|
||||
'orderId' => $orderId,
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 결제 실패 콜백
|
||||
*
|
||||
* GET /plugins/sirsoft-tosspayments/payment/fail
|
||||
* ?code={ERR}&message={MSG}&orderId={OID}
|
||||
*
|
||||
* @param FailCallbackRequest $request 검증된 콜백 요청
|
||||
* @return RedirectResponse SPA 체크아웃 페이지로 리다이렉트
|
||||
*/
|
||||
public function fail(FailCallbackRequest $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$code = $validated['code'] ?? 'UNKNOWN';
|
||||
$message = $validated['message'] ?? '';
|
||||
$orderId = $validated['orderId'] ?? null;
|
||||
|
||||
Log::warning('TossPayments: payment failed', [
|
||||
'code' => $code,
|
||||
'message' => $message,
|
||||
'orderId' => $orderId,
|
||||
]);
|
||||
|
||||
if ($orderId) {
|
||||
$order = $this->orderService->findByOrderNumber($orderId);
|
||||
|
||||
if ($order) {
|
||||
$this->orderService->failPayment($order, $code, $message);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect($this->resolveFailUrl([
|
||||
'error' => $code,
|
||||
'message' => $message,
|
||||
'orderId' => $orderId,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 발급사 코드 → 이름 변환
|
||||
*
|
||||
* @param string|null $issuerCode 발급사 코드
|
||||
* @return string|null 카드사명
|
||||
*/
|
||||
private function resolveCardIssuer(?string $issuerCode): ?string
|
||||
{
|
||||
if ($issuerCode === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::CARD_ISSUER_MAP[$issuerCode] ?? $issuerCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 결제 성공 리다이렉트 URL 생성
|
||||
*
|
||||
* @param string $orderId 주문번호
|
||||
* @return string 리다이렉트 URL
|
||||
*/
|
||||
private function resolveSuccessUrl(string $orderId): string
|
||||
{
|
||||
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
|
||||
$urlTemplate = $settings['redirect_success_url'] ?? '/shop/orders/{orderId}/complete';
|
||||
|
||||
return str_replace('{orderId}', $orderId, $urlTemplate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결제 실패 리다이렉트 URL 생성
|
||||
*
|
||||
* @param array $queryParams 쿼리 파라미터
|
||||
* @return string 리다이렉트 URL
|
||||
*/
|
||||
private function resolveFailUrl(array $queryParams = []): string
|
||||
{
|
||||
$settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
|
||||
$baseUrl = $settings['redirect_fail_url'] ?? '/shop/checkout';
|
||||
|
||||
if (empty($queryParams)) {
|
||||
return $baseUrl;
|
||||
}
|
||||
|
||||
$query = http_build_query(array_filter($queryParams));
|
||||
$separator = str_contains($baseUrl, '?') ? '&' : '?';
|
||||
|
||||
return $baseUrl . $separator . $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 결제 디바이스 판별 (User-Agent 기반)
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @return string 디바이스 유형 (pc/mobile)
|
||||
*/
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 결제 실패 콜백 요청 검증
|
||||
*
|
||||
* GET /plugins/sirsoft-tosspayments/payment/fail
|
||||
* ?code={ERR}&message={MSG}&orderId={OID}
|
||||
*/
|
||||
class FailCallbackRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 권한 확인 (PG 콜백은 인증 불필요)
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 규칙 정의
|
||||
*
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'code' => ['nullable', 'string'],
|
||||
'message' => ['nullable', 'string'],
|
||||
'orderId' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 전 기본값 설정
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->mergeIfMissing([
|
||||
'code' => 'UNKNOWN',
|
||||
'message' => '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 결제 성공 콜백 요청 검증
|
||||
*
|
||||
* GET /plugins/sirsoft-tosspayments/payment/success
|
||||
* ?paymentKey={PK}&orderId={OID}&amount={AMT}
|
||||
*/
|
||||
class SuccessCallbackRequest extends FormRequest
|
||||
{
|
||||
private const PLUGIN_IDENTIFIER = 'sirsoft-tosspayments';
|
||||
|
||||
/**
|
||||
* 권한 확인 (PG 콜백은 인증 불필요)
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 규칙 정의
|
||||
*
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'paymentKey' => ['required', 'string'],
|
||||
'orderId' => ['required', 'string'],
|
||||
'amount' => ['required', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 실패 시 결제 실패 페이지로 리다이렉트
|
||||
*
|
||||
* PG 콜백은 브라우저 리다이렉트이므로 422 대신 실패 페이지로 이동합니다.
|
||||
*
|
||||
* @param Validator $validator 검증기 인스턴스
|
||||
* @return void
|
||||
* @throws HttpResponseException
|
||||
*/
|
||||
protected function failedValidation(Validator $validator): void
|
||||
{
|
||||
$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,129 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use Plugins\Sirsoft\Tosspayments\Services\TossPaymentsApiService;
|
||||
|
||||
/**
|
||||
* PG 결제 환불 리스너
|
||||
*
|
||||
* 이커머스 모듈의 결제 환불 훅을 구독하여
|
||||
* 토스페이먼츠 결제 취소 API를 호출합니다.
|
||||
*/
|
||||
class PaymentRefundListener implements HookListenerInterface
|
||||
{
|
||||
private const PG_PROVIDER_ID = 'tosspayments';
|
||||
|
||||
/**
|
||||
* 구독할 훅 매핑 반환
|
||||
*
|
||||
* @return array 훅 구독 설정
|
||||
*/
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'sirsoft-ecommerce.payment.refund' => [
|
||||
'method' => 'processRefund',
|
||||
'type' => 'filter',
|
||||
'priority' => 10,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 핸들러 (미사용)
|
||||
*
|
||||
* @param mixed ...$args 인수
|
||||
*/
|
||||
public function handle(...$args): void
|
||||
{
|
||||
// processRefund에서 처리
|
||||
}
|
||||
|
||||
/**
|
||||
* PG 결제 환불을 처리합니다.
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
|
||||
$paymentKey = $payment->transaction_id;
|
||||
if (! $paymentKey) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error_code' => 'MISSING_PAYMENT_KEY',
|
||||
'error_message' => __('sirsoft-tosspayments::messages.refund.missing_payment_key'),
|
||||
'transaction_id' => null,
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$apiService = $this->getApiService();
|
||||
|
||||
$cancelReason = $reason ?? __('sirsoft-tosspayments::messages.refund.default_reason');
|
||||
// $refundAmount 는 코어가 결제 통화(order_currency)로 환산해 전달한 실환불액이다.
|
||||
$cancelAmount = (int) $refundAmount;
|
||||
|
||||
$response = $apiService->cancelPayment($paymentKey, $cancelReason, $cancelAmount);
|
||||
|
||||
Log::info('토스페이먼츠 결제 취소 성공', [
|
||||
'order_id' => $order->id,
|
||||
'payment_key' => $paymentKey,
|
||||
'cancel_amount' => $cancelAmount,
|
||||
]);
|
||||
|
||||
// 토스 응답에서 취소 트랜잭션 ID 추출
|
||||
$cancels = $response['cancels'] ?? [];
|
||||
$lastCancel = end($cancels);
|
||||
$transactionId = $lastCancel['transactionKey'] ?? $paymentKey;
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
'transaction_id' => $transactionId,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('토스페이먼츠 결제 취소 실패', [
|
||||
'order_id' => $order->id,
|
||||
'payment_key' => $paymentKey,
|
||||
'cancel_amount' => (int) $refundAmount,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error_code' => 'PG_API_ERROR',
|
||||
'error_message' => $e->getMessage(),
|
||||
'transaction_id' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 서비스 인스턴스를 가져옵니다.
|
||||
*/
|
||||
protected function getApiService(): TossPaymentsApiService
|
||||
{
|
||||
return app(TossPaymentsApiService::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
/**
|
||||
* PG 제공자 등록 리스너
|
||||
*
|
||||
* 이커머스 모듈의 PG 제공자 목록 훅과 클라이언트 설정 훅을 구독하여
|
||||
* 토스페이먼츠를 결제 제공자로 등록하고 프론트엔드 SDK 설정을 제공합니다.
|
||||
*/
|
||||
class RegisterPgProviderListener implements HookListenerInterface
|
||||
{
|
||||
private const PLUGIN_IDENTIFIER = 'sirsoft-tosspayments';
|
||||
|
||||
/**
|
||||
* 구독할 훅 매핑 반환
|
||||
*
|
||||
* @return array 훅 구독 설정
|
||||
*/
|
||||
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 인수
|
||||
* @return void
|
||||
*/
|
||||
public function handle(...$args): void
|
||||
{
|
||||
// 개별 메서드에서 처리
|
||||
}
|
||||
|
||||
/**
|
||||
* PG 제공자 목록에 토스페이먼츠 등록
|
||||
*
|
||||
* @param array $providers 기존 PG 제공자 목록
|
||||
* @return array 토스페이먼츠가 추가된 PG 제공자 목록
|
||||
*/
|
||||
public function registerProvider(array $providers): array
|
||||
{
|
||||
$providers[] = [
|
||||
'id' => 'tosspayments',
|
||||
'name_key' => 'sirsoft-tosspayments::provider.name',
|
||||
'name' => localized_label(nameKey: 'sirsoft-tosspayments::provider.name'),
|
||||
'icon' => 'credit-card',
|
||||
'supported_methods' => ['card'],
|
||||
];
|
||||
|
||||
return $providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* PG 클라이언트 설정 제공 (프론트엔드 SDK용)
|
||||
*
|
||||
* @param array $config 기존 설정
|
||||
* @param string $provider PG 제공자 ID
|
||||
* @return array 클라이언트 설정
|
||||
*/
|
||||
public function getClientConfig(array $config, string $provider): array
|
||||
{
|
||||
if ($provider !== 'tosspayments') {
|
||||
return $config;
|
||||
}
|
||||
|
||||
$settings = $this->getPluginSettings();
|
||||
$isTest = $settings['is_test_mode'] ?? true;
|
||||
|
||||
return array_merge($config, [
|
||||
'client_key' => $isTest
|
||||
? ($settings['test_client_key'] ?? '')
|
||||
: ($settings['live_client_key'] ?? ''),
|
||||
'sdk_url' => 'https://js.tosspayments.com/v2/standard',
|
||||
'callback_urls' => [
|
||||
'success' => '/plugins/sirsoft-tosspayments/payment/success',
|
||||
'fail' => '/plugins/sirsoft-tosspayments/payment/fail',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 설정 조회
|
||||
*
|
||||
* @return array 플러그인 설정값
|
||||
*/
|
||||
private function getPluginSettings(): array
|
||||
{
|
||||
return plugin_settings(self::PLUGIN_IDENTIFIER);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Services;
|
||||
|
||||
use App\Services\PluginSettingsService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 API 호출 서비스
|
||||
*
|
||||
* 결제 승인, 취소 등 토스페이먼츠 REST API를 호출합니다.
|
||||
* Secret Key를 Basic 인증 헤더로 전달합니다.
|
||||
*/
|
||||
class TossPaymentsApiService
|
||||
{
|
||||
/**
|
||||
* 토스페이먼츠 API 베이스 URL
|
||||
*/
|
||||
private const BASE_URL = 'https://api.tosspayments.com';
|
||||
|
||||
private const PLUGIN_IDENTIFIER = 'sirsoft-tosspayments';
|
||||
|
||||
private string $secretKey;
|
||||
|
||||
/**
|
||||
* @param PluginSettingsService $pluginSettingsService 플러그인 설정 서비스
|
||||
*/
|
||||
public function __construct(PluginSettingsService $pluginSettingsService)
|
||||
{
|
||||
$settings = $pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? [];
|
||||
$isTest = $settings['is_test_mode'] ?? true;
|
||||
$this->secretKey = $isTest
|
||||
? ($settings['test_secret_key'] ?? '')
|
||||
: ($settings['live_secret_key'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 결제 승인 API 호출
|
||||
*
|
||||
* @param string $paymentKey 토스 결제 키
|
||||
* @param string $orderId 주문번호
|
||||
* @param int $amount 결제 금액
|
||||
* @return array PG 응답 데이터
|
||||
* @throws \Exception API 호출 실패 시
|
||||
*/
|
||||
public function confirmPayment(string $paymentKey, string $orderId, int $amount): array
|
||||
{
|
||||
return $this->request('POST', '/v1/payments/confirm', [
|
||||
'paymentKey' => $paymentKey,
|
||||
'orderId' => $orderId,
|
||||
'amount' => $amount,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결제 취소 API 호출
|
||||
*
|
||||
* @param string $paymentKey 토스 결제 키
|
||||
* @param string $cancelReason 취소 사유
|
||||
* @param int|null $cancelAmount 부분 취소 금액 (null이면 전액 취소)
|
||||
* @return array PG 응답 데이터
|
||||
* @throws \Exception API 호출 실패 시
|
||||
*/
|
||||
public function cancelPayment(string $paymentKey, string $cancelReason, ?int $cancelAmount = null): array
|
||||
{
|
||||
$body = ['cancelReason' => $cancelReason];
|
||||
|
||||
if ($cancelAmount !== null) {
|
||||
$body['cancelAmount'] = $cancelAmount;
|
||||
}
|
||||
|
||||
// 훅: 결제 취소 전 (본인인증 등 확장 지점)
|
||||
\App\Extension\HookManager::doAction('sirsoft-tosspayments.payment.before_cancel', $paymentKey, $cancelReason, $cancelAmount);
|
||||
|
||||
$response = $this->request('POST', "/v1/payments/{$paymentKey}/cancel", $body);
|
||||
|
||||
\App\Extension\HookManager::doAction('sirsoft-tosspayments.payment.after_cancel', $paymentKey, $response);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 API 요청
|
||||
*
|
||||
* @param string $method HTTP 메서드
|
||||
* @param string $path API 경로
|
||||
* @param array $body 요청 본문
|
||||
* @return array 응답 데이터
|
||||
* @throws \Exception API 호출 실패 시
|
||||
*/
|
||||
private function request(string $method, string $path, array $body): array
|
||||
{
|
||||
$authHeader = 'Basic ' . base64_encode($this->secretKey . ':');
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'Authorization' => $authHeader,
|
||||
'Content-Type' => 'application/json',
|
||||
])->{strtolower($method)}(self::BASE_URL . $path, $body);
|
||||
|
||||
if ($response->failed()) {
|
||||
$error = $response->json();
|
||||
|
||||
Log::error('TossPayments API error', [
|
||||
'path' => $path,
|
||||
'status' => $response->status(),
|
||||
'error_code' => $error['code'] ?? 'UNKNOWN',
|
||||
'error_message' => $error['message'] ?? '',
|
||||
]);
|
||||
|
||||
throw new \Exception(
|
||||
$error['message'] ?? 'TossPayments API error',
|
||||
$response->status()
|
||||
);
|
||||
}
|
||||
|
||||
return $response->json();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Plugins\Sirsoft\Tosspayments\Controllers\PaymentCallbackController;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| TossPayments Plugin Web Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 프리픽스: /plugins/sirsoft-tosspayments (PluginRouteServiceProvider 자동 적용)
|
||||
| 미들웨어: web (PluginRouteServiceProvider 자동 적용)
|
||||
|
|
||||
*/
|
||||
|
||||
// 결제 성공 콜백 (토스페이먼츠 → 브라우저 리다이렉트)
|
||||
Route::get('/payment/success', [PaymentCallbackController::class, 'success'])
|
||||
->name('payment.success');
|
||||
|
||||
// 결제 실패 콜백 (토스페이먼츠 → 브라우저 리다이렉트)
|
||||
Route::get('/payment/fail', [PaymentCallbackController::class, 'fail'])
|
||||
->name('payment.fail');
|
||||
+469
@@ -0,0 +1,469 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Tests\Feature\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 결제 콜백 컨트롤러 기능 테스트
|
||||
*/
|
||||
class PaymentCallbackControllerTest extends PluginTestCase
|
||||
{
|
||||
/**
|
||||
* 토스페이먼츠 Confirm API 성공 응답 mock 데이터
|
||||
*
|
||||
* @param string $paymentKey 결제 키
|
||||
* @param string $orderId 주문번호
|
||||
* @param int $amount 결제 금액
|
||||
* @return array
|
||||
*/
|
||||
private function makeMockConfirmResponse(string $paymentKey, string $orderId, int $amount): array
|
||||
{
|
||||
return [
|
||||
'paymentKey' => $paymentKey,
|
||||
'orderId' => $orderId,
|
||||
'status' => 'DONE',
|
||||
'totalAmount' => $amount,
|
||||
'method' => '카드',
|
||||
'approvedAt' => now()->toIso8601String(),
|
||||
'card' => [
|
||||
'issuerCode' => '41',
|
||||
'acquirerCode' => '41',
|
||||
'number' => '4330-****-****-1234',
|
||||
'installmentPlanMonths' => 0,
|
||||
'approveNo' => '12345678',
|
||||
'isInterestFree' => false,
|
||||
'cardType' => '신용',
|
||||
'ownerType' => '개인',
|
||||
],
|
||||
'easyPay' => null,
|
||||
'receipt' => [
|
||||
'url' => 'https://dashboard.tosspayments.com/receipt/test',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트용 주문 + 결제 레코드 생성
|
||||
*
|
||||
* @param int $totalAmount 주문 총액
|
||||
* @return Order
|
||||
*/
|
||||
private function createTestOrder(int $totalAmount = 50000): Order
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$order = OrderFactory::new()->create([
|
||||
'user_id' => $user->id,
|
||||
'order_number' => 'ORD-TEST-' . random_int(10000, 99999),
|
||||
'order_status' => OrderStatusEnum::PENDING_ORDER,
|
||||
'subtotal_amount' => $totalAmount,
|
||||
'total_discount_amount' => 0,
|
||||
'total_coupon_discount_amount' => 0,
|
||||
'total_product_coupon_discount_amount' => 0,
|
||||
'total_order_coupon_discount_amount' => 0,
|
||||
'total_code_discount_amount' => 0,
|
||||
'base_shipping_amount' => 0,
|
||||
'extra_shipping_amount' => 0,
|
||||
'shipping_discount_amount' => 0,
|
||||
'total_shipping_amount' => 0,
|
||||
'total_amount' => $totalAmount,
|
||||
'total_due_amount' => $totalAmount,
|
||||
'total_points_used_amount' => 0,
|
||||
'total_deposit_used_amount' => 0,
|
||||
'total_paid_amount' => 0,
|
||||
]);
|
||||
|
||||
OrderPaymentFactory::new()->create([
|
||||
'order_id' => $order->id,
|
||||
'payment_status' => PaymentStatusEnum::READY,
|
||||
'payment_method' => PaymentMethodEnum::CARD,
|
||||
'pg_provider' => 'tosspayments',
|
||||
'paid_amount_local' => 0,
|
||||
'paid_at' => null,
|
||||
'transaction_id' => null,
|
||||
'card_approval_number' => null,
|
||||
]);
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 설정을 mock합니다.
|
||||
*
|
||||
* @param array $overrides 기본 설정을 덮어쓸 값
|
||||
*/
|
||||
private function mockPluginSettings(array $overrides = []): void
|
||||
{
|
||||
$defaults = [
|
||||
'is_test_mode' => true,
|
||||
'test_secret_key' => 'test_sk_mock_key',
|
||||
'test_client_key' => 'test_ck_mock_key',
|
||||
'redirect_success_url' => '/shop/orders/{orderId}/complete',
|
||||
'redirect_fail_url' => '/shop/checkout',
|
||||
];
|
||||
|
||||
$settingsMock = $this->createMock(\App\Services\PluginSettingsService::class);
|
||||
$settingsMock->method('get')
|
||||
->willReturn(array_merge($defaults, $overrides));
|
||||
|
||||
$this->app->instance(\App\Services\PluginSettingsService::class, $settingsMock);
|
||||
}
|
||||
|
||||
// ===== Success 콜백 테스트 =====
|
||||
|
||||
/**
|
||||
* 결제 성공 시 주문 완료 페이지로 리다이렉트하는지 확인
|
||||
*/
|
||||
public function test_success_redirects_to_complete_page_on_valid_payment(): void
|
||||
{
|
||||
$order = $this->createTestOrder(50000);
|
||||
$paymentKey = 'pk_test_abc123';
|
||||
|
||||
$this->mockPluginSettings();
|
||||
|
||||
Http::fake([
|
||||
'api.tosspayments.com/v1/payments/confirm' => Http::response(
|
||||
$this->makeMockConfirmResponse($paymentKey, $order->order_number, 50000),
|
||||
200
|
||||
),
|
||||
]);
|
||||
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
|
||||
'paymentKey' => $paymentKey,
|
||||
'orderId' => $order->order_number,
|
||||
'amount' => 50000,
|
||||
]));
|
||||
|
||||
$response->assertRedirect("/shop/orders/{$order->order_number}/complete");
|
||||
|
||||
// 주문 상태가 PAYMENT_COMPLETE로 변경되었는지 확인
|
||||
$order->refresh();
|
||||
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
||||
|
||||
// 결제 정보가 업데이트되었는지 확인
|
||||
$payment = $order->payment;
|
||||
$payment->refresh();
|
||||
$this->assertEquals($paymentKey, $payment->transaction_id);
|
||||
$this->assertEquals('12345678', $payment->card_approval_number);
|
||||
$this->assertEquals('4330-****-****-1234', $payment->card_number_masked);
|
||||
$this->assertEquals('신한', $payment->card_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 존재하지 않는 주문번호로 요청 시 체크아웃으로 리다이렉트
|
||||
*/
|
||||
public function test_success_redirects_to_checkout_on_order_not_found(): void
|
||||
{
|
||||
$this->mockPluginSettings();
|
||||
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
|
||||
'paymentKey' => 'pk_test_xxx',
|
||||
'orderId' => 'NON_EXISTENT_ORDER',
|
||||
'amount' => 50000,
|
||||
]));
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertStringContainsString('error=order_not_found', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
/**
|
||||
* TossPayments Confirm API 실패 시 체크아웃으로 리다이렉트
|
||||
*/
|
||||
public function test_success_redirects_to_checkout_on_confirm_api_failure(): void
|
||||
{
|
||||
$order = $this->createTestOrder(50000);
|
||||
|
||||
$this->mockPluginSettings();
|
||||
|
||||
Http::fake([
|
||||
'api.tosspayments.com/v1/payments/confirm' => Http::response([
|
||||
'code' => 'ALREADY_PROCESSED_PAYMENT',
|
||||
'message' => '이미 처리된 결제입니다.',
|
||||
], 400),
|
||||
]);
|
||||
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
|
||||
'paymentKey' => 'pk_test_fail',
|
||||
'orderId' => $order->order_number,
|
||||
'amount' => 50000,
|
||||
]));
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertStringContainsString('error=confirm_failed', $response->headers->get('Location'));
|
||||
|
||||
// 주문 상태가 변경되지 않았는지 확인
|
||||
$order->refresh();
|
||||
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status);
|
||||
}
|
||||
|
||||
/**
|
||||
* PG 금액 불일치 시 체크아웃으로 리다이렉트
|
||||
*/
|
||||
public function test_success_redirects_to_checkout_on_amount_mismatch(): void
|
||||
{
|
||||
$order = $this->createTestOrder(50000);
|
||||
|
||||
$this->mockPluginSettings();
|
||||
|
||||
Http::fake([
|
||||
'api.tosspayments.com/v1/payments/confirm' => Http::response(
|
||||
$this->makeMockConfirmResponse('pk_test_mismatch', $order->order_number, 99999),
|
||||
200
|
||||
),
|
||||
]);
|
||||
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
|
||||
'paymentKey' => 'pk_test_mismatch',
|
||||
'orderId' => $order->order_number,
|
||||
'amount' => 99999,
|
||||
]));
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertStringContainsString('error=amount_mismatch', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
// ===== Fail 콜백 테스트 =====
|
||||
|
||||
/**
|
||||
* 결제 실패 시 체크아웃으로 리다이렉트하는지 확인
|
||||
*/
|
||||
public function test_fail_redirects_to_checkout_with_error_params(): void
|
||||
{
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/fail?" . http_build_query([
|
||||
'code' => 'USER_CANCEL',
|
||||
'message' => '사용자가 취소했습니다.',
|
||||
'orderId' => 'ORD-TEST-12345',
|
||||
]));
|
||||
|
||||
$response->assertRedirect();
|
||||
$location = $response->headers->get('Location');
|
||||
$this->assertStringContainsString('error=USER_CANCEL', $location);
|
||||
$this->assertStringContainsString('orderId=ORD-TEST-12345', $location);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결제 실패 시 주문이 존재하면 failPayment 처리되는지 확인
|
||||
*/
|
||||
public function test_fail_calls_fail_payment_when_order_exists(): void
|
||||
{
|
||||
$order = $this->createTestOrder(30000);
|
||||
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/fail?" . http_build_query([
|
||||
'code' => 'PAY_PROCESS_CANCELED',
|
||||
'message' => '결제가 취소되었습니다.',
|
||||
'orderId' => $order->order_number,
|
||||
]));
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
// 주문 상태가 CANCELLED로 변경되었는지 확인
|
||||
$order->refresh();
|
||||
$this->assertEquals(OrderStatusEnum::CANCELLED, $order->order_status);
|
||||
|
||||
// 주문 메타에 실패 정보가 저장되었는지 확인
|
||||
$meta = $order->order_meta;
|
||||
$this->assertEquals('PAY_PROCESS_CANCELED', $meta['payment_failure_code']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결제 실패 시 orderId 없이도 에러 없이 처리되는지 확인
|
||||
*/
|
||||
public function test_fail_handles_missing_order_id_gracefully(): void
|
||||
{
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/fail?" . http_build_query([
|
||||
'code' => 'UNKNOWN_ERROR',
|
||||
'message' => '알 수 없는 오류',
|
||||
]));
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertStringContainsString('error=UNKNOWN_ERROR', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
// ===== 커스텀 리다이렉트 URL 테스트 =====
|
||||
|
||||
/**
|
||||
* 커스텀 성공 URL 설정 시 해당 URL로 리다이렉트하는지 확인
|
||||
*/
|
||||
public function test_success_redirects_to_custom_success_url(): void
|
||||
{
|
||||
$order = $this->createTestOrder(50000);
|
||||
$paymentKey = 'pk_test_custom';
|
||||
|
||||
$this->mockPluginSettings([
|
||||
'redirect_success_url' => '/custom/payment/{orderId}/done',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'api.tosspayments.com/v1/payments/confirm' => Http::response(
|
||||
$this->makeMockConfirmResponse($paymentKey, $order->order_number, 50000),
|
||||
200
|
||||
),
|
||||
]);
|
||||
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
|
||||
'paymentKey' => $paymentKey,
|
||||
'orderId' => $order->order_number,
|
||||
'amount' => 50000,
|
||||
]));
|
||||
|
||||
$response->assertRedirect("/custom/payment/{$order->order_number}/done");
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 실패 URL 설정 시 해당 URL로 리다이렉트하는지 확인
|
||||
*/
|
||||
public function test_fail_redirects_to_custom_fail_url(): void
|
||||
{
|
||||
$this->mockPluginSettings([
|
||||
'redirect_fail_url' => '/custom/checkout/error',
|
||||
]);
|
||||
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/fail?" . http_build_query([
|
||||
'code' => 'USER_CANCEL',
|
||||
'message' => '사용자가 취소했습니다.',
|
||||
'orderId' => 'ORD-TEST-99999',
|
||||
]));
|
||||
|
||||
$response->assertRedirect();
|
||||
$location = $response->headers->get('Location');
|
||||
$this->assertStringContainsString('/custom/checkout/error?', $location);
|
||||
$this->assertStringContainsString('error=USER_CANCEL', $location);
|
||||
$this->assertStringContainsString('orderId=ORD-TEST-99999', $location);
|
||||
}
|
||||
|
||||
/**
|
||||
* 커스텀 실패 URL 설정 시 성공 콜백 내 오류에서도 해당 URL로 리다이렉트하는지 확인
|
||||
*/
|
||||
public function test_success_redirects_to_custom_fail_url_on_order_not_found(): void
|
||||
{
|
||||
$this->mockPluginSettings([
|
||||
'redirect_fail_url' => '/custom/checkout/error',
|
||||
]);
|
||||
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
|
||||
'paymentKey' => 'pk_test_xxx',
|
||||
'orderId' => 'NON_EXISTENT_ORDER',
|
||||
'amount' => 50000,
|
||||
]));
|
||||
|
||||
$response->assertRedirect();
|
||||
$location = $response->headers->get('Location');
|
||||
$this->assertStringContainsString('/custom/checkout/error?', $location);
|
||||
$this->assertStringContainsString('error=order_not_found', $location);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 URL + 기존 쿼리 파라미터가 있는 실패 URL에서 & 구분자로 연결되는지 확인
|
||||
*/
|
||||
public function test_fail_redirects_to_full_url_with_existing_query_params(): void
|
||||
{
|
||||
$this->mockPluginSettings([
|
||||
'redirect_fail_url' => 'https://example.com/checkout?ref=toss',
|
||||
]);
|
||||
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/fail?" . http_build_query([
|
||||
'code' => 'NETWORK_ERROR',
|
||||
'message' => '네트워크 오류',
|
||||
'orderId' => 'ORD-TEST-88888',
|
||||
]));
|
||||
|
||||
$response->assertRedirect();
|
||||
$location = $response->headers->get('Location');
|
||||
$this->assertStringStartsWith('https://example.com/checkout?ref=toss&', $location);
|
||||
$this->assertStringContainsString('error=NETWORK_ERROR', $location);
|
||||
$this->assertStringContainsString('orderId=ORD-TEST-88888', $location);
|
||||
}
|
||||
|
||||
// ===== FormRequest 검증 테스트 =====
|
||||
|
||||
/**
|
||||
* 성공 콜백에 필수 파라미터 누락 시 실패 페이지로 리다이렉트하는지 확인
|
||||
*/
|
||||
public function test_success_redirects_to_fail_url_on_missing_params(): void
|
||||
{
|
||||
$this->mockPluginSettings();
|
||||
|
||||
// paymentKey 누락
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
|
||||
'orderId' => 'ORD-TEST-12345',
|
||||
'amount' => 50000,
|
||||
]));
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertStringContainsString('error=invalid_params', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 콜백에 금액이 0 이하일 때 실패 페이지로 리다이렉트하는지 확인
|
||||
*/
|
||||
public function test_success_redirects_to_fail_url_on_invalid_amount(): void
|
||||
{
|
||||
$this->mockPluginSettings();
|
||||
|
||||
$response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
|
||||
'paymentKey' => 'pk_test_xxx',
|
||||
'orderId' => 'ORD-TEST-12345',
|
||||
'amount' => 0,
|
||||
]));
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertStringContainsString('error=invalid_params', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 성공 콜백에 파라미터가 모두 없으면 실패 페이지로 리다이렉트하는지 확인
|
||||
*/
|
||||
public function test_success_redirects_to_fail_url_on_empty_params(): void
|
||||
{
|
||||
$this->mockPluginSettings();
|
||||
|
||||
$response = $this->get('/plugins/sirsoft-tosspayments/payment/success');
|
||||
|
||||
$response->assertRedirect();
|
||||
$this->assertStringContainsString('error=invalid_params', $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
// ===== 디바이스 감지 테스트 =====
|
||||
|
||||
/**
|
||||
* 모바일 User-Agent로 요청 시 payment_device가 mobile인지 확인
|
||||
*/
|
||||
public function test_success_detects_mobile_device(): void
|
||||
{
|
||||
$order = $this->createTestOrder(50000);
|
||||
$paymentKey = 'pk_test_mobile';
|
||||
|
||||
$this->mockPluginSettings();
|
||||
|
||||
Http::fake([
|
||||
'api.tosspayments.com/v1/payments/confirm' => Http::response(
|
||||
$this->makeMockConfirmResponse($paymentKey, $order->order_number, 50000),
|
||||
200
|
||||
),
|
||||
]);
|
||||
|
||||
$response = $this->get(
|
||||
"/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([
|
||||
'paymentKey' => $paymentKey,
|
||||
'orderId' => $order->order_number,
|
||||
'amount' => 50000,
|
||||
]),
|
||||
['User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)']
|
||||
);
|
||||
|
||||
$response->assertRedirect("/shop/orders/{$order->order_number}/complete");
|
||||
|
||||
$payment = $order->payment;
|
||||
$payment->refresh();
|
||||
$this->assertEquals('mobile', $payment->payment_device);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 토스페이먼츠 플러그인 테스트 베이스 클래스
|
||||
*
|
||||
* 모든 토스페이먼츠 플러그인 테스트는 이 클래스를 상속받아야 합니다.
|
||||
* 모듈/플러그인 오토로드, ServiceProvider 등록, 마이그레이션, 라우트 등록을 자동으로 처리합니다.
|
||||
*/
|
||||
abstract class PluginTestCase extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 시딩 활성화
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function shouldSeed(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트용 시더 클래스
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function seeder(): string
|
||||
{
|
||||
return \Modules\Sirsoft\Ecommerce\Database\Seeders\TestingSeeder::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* 마이그레이션 경로를 반환합니다.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function migrateFreshUsing(): array
|
||||
{
|
||||
// RefreshDatabase 는 첫 테스트의 migrateFreshUsing 만 적용 — 모든 번들 확장
|
||||
// migrations 를 포함시켜 Plugin suite 전체 실행 시에도 스키마 보장.
|
||||
$paths = ['database/migrations'];
|
||||
foreach (glob(base_path('modules/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) {
|
||||
$paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p);
|
||||
}
|
||||
foreach (glob(base_path('plugins/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) {
|
||||
$paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p);
|
||||
}
|
||||
|
||||
return [
|
||||
'--drop-views' => $this->shouldDropViews(),
|
||||
'--drop-types' => $this->shouldDropTypes(),
|
||||
'--seed' => $this->shouldSeed(),
|
||||
'--seeder' => $this->seeder(),
|
||||
'--path' => $paths,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* HookManager static state 스냅샷 — tearDown 에서 복원하여 테스트 간 훅 격리 보장.
|
||||
*
|
||||
* @var array{hooks: array, filters: array, dispatching: array}|null
|
||||
*/
|
||||
private ?array $hookSnapshot = null;
|
||||
|
||||
/**
|
||||
* 테스트 환경 설정
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// 이커머스 모듈 오토로드 등록
|
||||
$this->registerModuleAutoload();
|
||||
|
||||
// 플러그인 오토로드 등록
|
||||
$this->registerPluginAutoload();
|
||||
|
||||
// 이커머스 모듈 ServiceProvider 등록 (Repository 바인딩)
|
||||
$this->app->register(\Modules\Sirsoft\Ecommerce\Providers\EcommerceServiceProvider::class);
|
||||
|
||||
// 모듈 라우트를 수동으로 등록
|
||||
$this->registerModuleRoutes();
|
||||
|
||||
// 플러그인 라우트를 수동으로 등록
|
||||
$this->registerPluginRoutes();
|
||||
|
||||
// SettingsServiceProvider 가 storage/app/settings/general.json 의 site_url 로
|
||||
// app.url 을 override 하면 Laravel 의 assertRedirect (APP_URL 기반) 와 mismatch.
|
||||
// 테스트 환경에서는 APP_URL 그대로 사용하도록 명시 리셋.
|
||||
\Illuminate\Support\Facades\Config::set('app.url', env('APP_URL', 'http://localhost'));
|
||||
|
||||
// HookManager 상태 스냅샷 (tearDown 에서 복원)
|
||||
$this->snapshotHookManager();
|
||||
}
|
||||
|
||||
/**
|
||||
* tearDown 에 HookManager 상태 복원.
|
||||
*/
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->restoreHookManager();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* HookManager static $hooks / $filters / $dispatching 를 스냅샷.
|
||||
*/
|
||||
private function snapshotHookManager(): void
|
||||
{
|
||||
$ref = new \ReflectionClass(\App\Extension\HookManager::class);
|
||||
$this->hookSnapshot = [
|
||||
'hooks' => $ref->getProperty('hooks')->getValue(),
|
||||
'filters' => $ref->getProperty('filters')->getValue(),
|
||||
'dispatching' => $ref->getProperty('dispatching')->getValue(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 스냅샷 시점으로 HookManager 복원.
|
||||
*/
|
||||
private function restoreHookManager(): void
|
||||
{
|
||||
if ($this->hookSnapshot === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ref = new \ReflectionClass(\App\Extension\HookManager::class);
|
||||
$ref->getProperty('hooks')->setValue(null, $this->hookSnapshot['hooks']);
|
||||
$ref->getProperty('filters')->setValue(null, $this->hookSnapshot['filters']);
|
||||
$ref->getProperty('dispatching')->setValue(null, $this->hookSnapshot['dispatching']);
|
||||
|
||||
$this->hookSnapshot = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이커머스 모듈 오토로드를 등록합니다.
|
||||
*/
|
||||
protected function registerModuleAutoload(): void
|
||||
{
|
||||
$moduleBasePath = base_path('modules/sirsoft-ecommerce/src/');
|
||||
|
||||
spl_autoload_register(function ($class) use ($moduleBasePath) {
|
||||
$prefix = 'Modules\\Sirsoft\\Ecommerce\\';
|
||||
$len = strlen($prefix);
|
||||
|
||||
if (strncmp($prefix, $class, $len) !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relativeClass = substr($class, $len);
|
||||
$file = $moduleBasePath . str_replace('\\', '/', $relativeClass) . '.php';
|
||||
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
}
|
||||
});
|
||||
|
||||
// composer.json files 오토로드 (헬퍼 함수 등록)
|
||||
$helpersFile = $moduleBasePath . 'Helpers/helpers.php';
|
||||
if (file_exists($helpersFile)) {
|
||||
require_once $helpersFile;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 오토로드를 등록합니다.
|
||||
*/
|
||||
protected function registerPluginAutoload(): void
|
||||
{
|
||||
// 활성 디렉토리(plugins/sirsoft-tosspayments)가 아닌 자기 자신 기준 경로 —
|
||||
// _bundled 에서 직접 실행할 때도 소스를 찾도록 한다.
|
||||
$pluginBasePath = dirname(__DIR__) . '/src/';
|
||||
|
||||
spl_autoload_register(function ($class) use ($pluginBasePath) {
|
||||
$prefix = 'Plugins\\Sirsoft\\Tosspayments\\';
|
||||
$len = strlen($prefix);
|
||||
|
||||
if (strncmp($prefix, $class, $len) !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relativeClass = substr($class, $len);
|
||||
$file = $pluginBasePath . str_replace('\\', '/', $relativeClass) . '.php';
|
||||
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 라우트를 등록합니다.
|
||||
*/
|
||||
protected function registerModuleRoutes(): void
|
||||
{
|
||||
$apiRoutesFile = base_path('modules/sirsoft-ecommerce/src/routes/api.php');
|
||||
|
||||
if (file_exists($apiRoutesFile)) {
|
||||
\Illuminate\Support\Facades\Route::prefix('api/modules/sirsoft-ecommerce')
|
||||
->name('api.modules.sirsoft-ecommerce.')
|
||||
->middleware('api')
|
||||
->group($apiRoutesFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 라우트를 등록합니다.
|
||||
*/
|
||||
protected function registerPluginRoutes(): void
|
||||
{
|
||||
$webRoutesFile = dirname(__DIR__) . '/src/routes/web.php';
|
||||
|
||||
if (file_exists($webRoutesFile)) {
|
||||
\Illuminate\Support\Facades\Route::prefix('plugins/sirsoft-tosspayments')
|
||||
->name('plugins.sirsoft-tosspayments.')
|
||||
->middleware('web')
|
||||
->group($webRoutesFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 사용자를 생성합니다.
|
||||
*
|
||||
* @param array $permissions 추가 권한 목록
|
||||
* @return \App\Models\User
|
||||
*/
|
||||
protected function createAdminUser(array $permissions = []): \App\Models\User
|
||||
{
|
||||
$user = \App\Models\User::factory()->create();
|
||||
|
||||
$uniqueRoleIdentifier = 'admin-test-' . $user->id . '-' . time();
|
||||
$userRole = \App\Models\Role::create([
|
||||
'identifier' => $uniqueRoleIdentifier,
|
||||
'name' => ['ko' => '테스트 관리자', 'en' => 'Test Admin'],
|
||||
]);
|
||||
$user->roles()->attach($userRole->id);
|
||||
|
||||
$adminAccessPermission = \App\Models\Permission::firstOrCreate(
|
||||
['identifier' => 'admin.access'],
|
||||
[
|
||||
'name' => ['ko' => '관리자 접근', 'en' => 'Admin Access'],
|
||||
'type' => \App\Enums\PermissionType::Admin,
|
||||
]
|
||||
);
|
||||
$userRole->permissions()->attach($adminAccessPermission->id);
|
||||
|
||||
if (! empty($permissions)) {
|
||||
foreach ($permissions as $permissionIdentifier) {
|
||||
$permission = \App\Models\Permission::firstOrCreate(
|
||||
['identifier' => $permissionIdentifier],
|
||||
[
|
||||
'name' => ['ko' => $permissionIdentifier, 'en' => $permissionIdentifier],
|
||||
'type' => 'admin',
|
||||
]
|
||||
);
|
||||
$userRole->permissions()->syncWithoutDetaching([$permission->id]);
|
||||
}
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일반 사용자를 생성합니다.
|
||||
*
|
||||
* @return \App\Models\User
|
||||
*/
|
||||
protected function createUser(): \App\Models\User
|
||||
{
|
||||
$userRole = \App\Models\Role::where('identifier', 'user')->first();
|
||||
$user = \App\Models\User::factory()->create();
|
||||
|
||||
if ($userRole) {
|
||||
$user->roles()->attach($userRole->id);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Tests\Unit\Listeners;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Plugins\Sirsoft\Tosspayments\Listeners\PaymentRefundListener;
|
||||
|
||||
/**
|
||||
* PaymentRefundListener 단위 테스트
|
||||
*/
|
||||
class PaymentRefundListenerTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* getSubscribedHooks가 올바른 훅 매핑을 반환하는지 확인
|
||||
*/
|
||||
public function test_get_subscribed_hooks_returns_correct_hooks(): void
|
||||
{
|
||||
$hooks = PaymentRefundListener::getSubscribedHooks();
|
||||
|
||||
$this->assertArrayHasKey('sirsoft-ecommerce.payment.refund', $hooks);
|
||||
$this->assertEquals('filter', $hooks['sirsoft-ecommerce.payment.refund']['type']);
|
||||
$this->assertEquals('processRefund', $hooks['sirsoft-ecommerce.payment.refund']['method']);
|
||||
$this->assertEquals(10, $hooks['sirsoft-ecommerce.payment.refund']['priority']);
|
||||
}
|
||||
|
||||
/**
|
||||
* pg_provider가 'tosspayments'가 아닌 결제는 스킵하는지 확인
|
||||
*/
|
||||
public function test_process_refund_skips_non_tosspayments_provider(): void
|
||||
{
|
||||
$listener = new PaymentRefundListener();
|
||||
|
||||
$order = $this->createMock(Order::class);
|
||||
$payment = $this->createStub(OrderPayment::class);
|
||||
$payment->method('__get')->willReturnCallback(function ($key) {
|
||||
return match ($key) {
|
||||
'pg_provider' => 'other_pg',
|
||||
default => null,
|
||||
};
|
||||
});
|
||||
|
||||
$defaultResult = ['success' => false, 'error_code' => null, 'error_message' => null, 'transaction_id' => null];
|
||||
|
||||
$result = $listener->processRefund($defaultResult, $order, $payment, 10000.0);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertNull($result['error_code']);
|
||||
}
|
||||
|
||||
/**
|
||||
* pg_provider가 'sirsoft-tosspayments'(플러그인 식별자)인 경우 스킵되는지 확인
|
||||
* (이전 버그: 플러그인 식별자와 PG provider ID를 혼동하여 항상 스킵됨)
|
||||
*/
|
||||
public function test_process_refund_does_not_match_plugin_identifier(): void
|
||||
{
|
||||
$listener = new PaymentRefundListener();
|
||||
|
||||
$order = $this->createMock(Order::class);
|
||||
$payment = $this->createStub(OrderPayment::class);
|
||||
$payment->method('__get')->willReturnCallback(function ($key) {
|
||||
return match ($key) {
|
||||
'pg_provider' => 'sirsoft-tosspayments',
|
||||
default => null,
|
||||
};
|
||||
});
|
||||
|
||||
$defaultResult = ['success' => false, 'error_code' => null, 'error_message' => null, 'transaction_id' => null];
|
||||
|
||||
$result = $listener->processRefund($defaultResult, $order, $payment, 10000.0);
|
||||
|
||||
// 플러그인 식별자는 PG provider ID가 아니므로 스킵되어야 함
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertNull($result['error_code']);
|
||||
}
|
||||
|
||||
/**
|
||||
* pg_provider가 'tosspayments'일 때 스킵하지 않고 처리에 진입하는지 확인
|
||||
*/
|
||||
public function test_process_refund_enters_processing_for_tosspayments(): void
|
||||
{
|
||||
$listener = new PaymentRefundListener();
|
||||
|
||||
$order = $this->createMock(Order::class);
|
||||
$payment = $this->createStub(OrderPayment::class);
|
||||
$payment->method('__get')->willReturnCallback(function ($key) {
|
||||
return match ($key) {
|
||||
'pg_provider' => 'tosspayments',
|
||||
'transaction_id' => 'test_payment_key',
|
||||
default => null,
|
||||
};
|
||||
});
|
||||
|
||||
$defaultResult = ['success' => false, 'error_code' => null, 'error_message' => null, 'transaction_id' => null];
|
||||
|
||||
// getApiService()에서 app() 호출 시 Laravel 컨테이너 미준비로
|
||||
// BindingResolutionException 발생 → 정상적으로 tosspayments 분기로 진입했다는 증거
|
||||
$this->expectException(\Throwable::class);
|
||||
|
||||
$listener->processRefund($defaultResult, $order, $payment, 10000.0);
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Tests\Unit\Listeners;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Plugins\Sirsoft\Tosspayments\Listeners\RegisterPgProviderListener;
|
||||
|
||||
/**
|
||||
* RegisterPgProviderListener 단위 테스트
|
||||
*/
|
||||
class RegisterPgProviderListenerTest extends TestCase
|
||||
{
|
||||
private RegisterPgProviderListener $listener;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->listener = new RegisterPgProviderListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* getSubscribedHooks가 올바른 훅 매핑을 반환하는지 확인
|
||||
*/
|
||||
public function test_get_subscribed_hooks_returns_correct_hooks(): void
|
||||
{
|
||||
$hooks = RegisterPgProviderListener::getSubscribedHooks();
|
||||
|
||||
$this->assertArrayHasKey('sirsoft-ecommerce.payment.registered_pg_providers', $hooks);
|
||||
$this->assertArrayHasKey('sirsoft-ecommerce.payment.get_client_config', $hooks);
|
||||
|
||||
// filter 타입 확인
|
||||
$this->assertEquals('filter', $hooks['sirsoft-ecommerce.payment.registered_pg_providers']['type']);
|
||||
$this->assertEquals('filter', $hooks['sirsoft-ecommerce.payment.get_client_config']['type']);
|
||||
|
||||
// 메서드명 확인
|
||||
$this->assertEquals('registerProvider', $hooks['sirsoft-ecommerce.payment.registered_pg_providers']['method']);
|
||||
$this->assertEquals('getClientConfig', $hooks['sirsoft-ecommerce.payment.get_client_config']['method']);
|
||||
}
|
||||
|
||||
/**
|
||||
* registerProvider가 토스페이먼츠를 제공자 목록에 추가하는지 확인
|
||||
*/
|
||||
public function test_register_provider_adds_tosspayments(): void
|
||||
{
|
||||
$existingProviders = [
|
||||
['id' => 'other_pg', 'name' => ['ko' => '기타 PG'], 'icon' => 'wallet'],
|
||||
];
|
||||
|
||||
$result = $this->listener->registerProvider($existingProviders);
|
||||
|
||||
$this->assertCount(2, $result);
|
||||
|
||||
$toss = $result[1];
|
||||
$this->assertEquals('tosspayments', $toss['id']);
|
||||
// 7.0.0-beta.4+ : registry payload name_key 계약
|
||||
$this->assertEquals('sirsoft-tosspayments::provider.name', $toss['name_key']);
|
||||
// name 은 활성 locale 로 해석된 문자열 (Container 미초기화 단위 환경에서는 빈 문자열)
|
||||
$this->assertIsString($toss['name']);
|
||||
$this->assertEquals('credit-card', $toss['icon']);
|
||||
$this->assertContains('card', $toss['supported_methods']);
|
||||
}
|
||||
|
||||
/**
|
||||
* registerProvider 페이로드가 name_key (lang key) 를 보유해 활성 언어팩 보강 가능한지 확인.
|
||||
*/
|
||||
public function test_register_provider_carries_name_key_for_lang_pack_resolution(): void
|
||||
{
|
||||
$result = $this->listener->registerProvider([]);
|
||||
$this->assertArrayHasKey('name_key', $result[0]);
|
||||
$this->assertSame('sirsoft-tosspayments::provider.name', $result[0]['name_key']);
|
||||
}
|
||||
|
||||
/**
|
||||
* registerProvider가 기존 제공자를 유지하는지 확인
|
||||
*/
|
||||
public function test_register_provider_preserves_existing_providers(): void
|
||||
{
|
||||
$existingProviders = [
|
||||
['id' => 'pg_a', 'name' => ['ko' => 'PG A']],
|
||||
['id' => 'pg_b', 'name' => ['ko' => 'PG B']],
|
||||
];
|
||||
|
||||
$result = $this->listener->registerProvider($existingProviders);
|
||||
|
||||
$this->assertCount(3, $result);
|
||||
$this->assertEquals('pg_a', $result[0]['id']);
|
||||
$this->assertEquals('pg_b', $result[1]['id']);
|
||||
$this->assertEquals('tosspayments', $result[2]['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* registerProvider가 빈 배열에도 동작하는지 확인
|
||||
*/
|
||||
public function test_register_provider_works_with_empty_array(): void
|
||||
{
|
||||
$result = $this->listener->registerProvider([]);
|
||||
|
||||
$this->assertCount(1, $result);
|
||||
$this->assertEquals('tosspayments', $result[0]['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* getClientConfig가 다른 provider에 대해 원본 config를 반환하는지 확인
|
||||
*/
|
||||
public function test_get_client_config_ignores_other_providers(): void
|
||||
{
|
||||
$config = ['some' => 'value'];
|
||||
$result = $this->listener->getClientConfig($config, 'inicis');
|
||||
|
||||
$this->assertEquals($config, $result);
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Tests\Unit\Services;
|
||||
|
||||
use App\Services\PluginSettingsService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Plugins\Sirsoft\Tosspayments\Services\TossPaymentsApiService;
|
||||
|
||||
/**
|
||||
* TossPaymentsApiService 단위 테스트
|
||||
*/
|
||||
class TossPaymentsApiServiceTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* PluginSettingsService mock을 생성합니다.
|
||||
*
|
||||
* @param array $settings 반환할 설정값
|
||||
* @return PluginSettingsService
|
||||
*/
|
||||
private function mockSettingsService(array $settings): PluginSettingsService
|
||||
{
|
||||
$mock = $this->createMock(PluginSettingsService::class);
|
||||
$mock->method('get')
|
||||
->with('sirsoft-tosspayments')
|
||||
->willReturn($settings);
|
||||
|
||||
return $mock;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트 모드에서 테스트 시크릿 키를 사용하는지 확인
|
||||
*/
|
||||
public function test_constructor_uses_test_secret_key_in_test_mode(): void
|
||||
{
|
||||
$service = new TossPaymentsApiService($this->mockSettingsService([
|
||||
'is_test_mode' => true,
|
||||
'test_secret_key' => 'test_sk_abc123',
|
||||
'live_secret_key' => 'live_sk_xyz789',
|
||||
]));
|
||||
|
||||
$reflection = new \ReflectionClass($service);
|
||||
$prop = $reflection->getProperty('secretKey');
|
||||
$prop->setAccessible(true);
|
||||
|
||||
$this->assertEquals('test_sk_abc123', $prop->getValue($service));
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이브 모드에서 라이브 시크릿 키를 사용하는지 확인
|
||||
*/
|
||||
public function test_constructor_uses_live_secret_key_in_live_mode(): void
|
||||
{
|
||||
$service = new TossPaymentsApiService($this->mockSettingsService([
|
||||
'is_test_mode' => false,
|
||||
'test_secret_key' => 'test_sk_abc123',
|
||||
'live_secret_key' => 'live_sk_xyz789',
|
||||
]));
|
||||
|
||||
$reflection = new \ReflectionClass($service);
|
||||
$prop = $reflection->getProperty('secretKey');
|
||||
$prop->setAccessible(true);
|
||||
|
||||
$this->assertEquals('live_sk_xyz789', $prop->getValue($service));
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정이 비어있을 때 기본값(테스트 모드, 빈 키)으로 초기화되는지 확인
|
||||
*/
|
||||
public function test_constructor_defaults_to_test_mode_with_empty_key(): void
|
||||
{
|
||||
$service = new TossPaymentsApiService($this->mockSettingsService([]));
|
||||
|
||||
$reflection = new \ReflectionClass($service);
|
||||
$prop = $reflection->getProperty('secretKey');
|
||||
$prop->setAccessible(true);
|
||||
|
||||
$this->assertEquals('', $prop->getValue($service));
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 설정이 null일 때 기본값으로 초기화되는지 확인
|
||||
*/
|
||||
public function test_constructor_handles_null_settings_gracefully(): void
|
||||
{
|
||||
$mock = $this->createMock(PluginSettingsService::class);
|
||||
$mock->method('get')
|
||||
->with('sirsoft-tosspayments')
|
||||
->willReturn(null);
|
||||
|
||||
$service = new TossPaymentsApiService($mock);
|
||||
|
||||
$reflection = new \ReflectionClass($service);
|
||||
$prop = $reflection->getProperty('secretKey');
|
||||
$prop->setAccessible(true);
|
||||
|
||||
$this->assertEquals('', $prop->getValue($service));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# 시나리오 매니페스트 예시 (실제 적용 아님 — .yaml.example 확장자)
|
||||
# 실제 매니페스트로 채택할 때 .yaml 로 이름 변경 + test_files 의 실재 경로 매핑
|
||||
#
|
||||
# 참조: docs/testing-guide.md "기능 단위 시나리오 매트릭스"
|
||||
|
||||
feature: order_payment_pg_toss
|
||||
description: 토스 PG 를 통한 주문 결제 흐름 (회원/비회원, 마일리지, 환율, 도서산간 등 모든 도메인 변수 cross product)
|
||||
|
||||
# 매니페스트를 검증하는 테스트 파일 (실제 작성 시 경로 갱신)
|
||||
test_files:
|
||||
- plugins/_bundled/sirsoft-tosspayments/tests/Feature/Payment/TossPgPaymentTest.php
|
||||
- plugins/_bundled/sirsoft-tosspayments/tests/Feature/Payment/WebhookTest.php
|
||||
|
||||
# 축이 많아 full cross product 가 폭발하므로 pairwise 전략
|
||||
coverage_strategy: pairwise
|
||||
|
||||
# 입력 axis — 결제 흐름에 영향을 주는 도메인 변수 전수
|
||||
axes:
|
||||
user_type: [member, guest]
|
||||
mileage_enabled: [true, false]
|
||||
product_options: [single, multi_option, bundle]
|
||||
shipping_region: [domestic, jeju, remote_island]
|
||||
shipping_policy: [free, fixed, range_based, conditional_free]
|
||||
coupon_applied: [none, product, category, all]
|
||||
currency: [krw, usd_with_fx]
|
||||
rounding_unit: [1, 10, 100]
|
||||
|
||||
# 의미 없는 조합 컷 — 사유 명시 필수
|
||||
exclusions:
|
||||
- { user_type: guest, mileage_enabled: true, reason: "비회원은 마일리지 사용 불가" }
|
||||
- { currency: usd_with_fx, rounding_unit: 1, reason: "환율 적용 시 1원 단위 절사 무의미" }
|
||||
|
||||
# 후속 효과 체인 — 결제 후 모든 부수효과
|
||||
effects:
|
||||
- webhook_received
|
||||
- payment_approved
|
||||
- mileage_deducted
|
||||
- notification_sent_to_user
|
||||
- notification_amount_matches_total
|
||||
- vat_calculated
|
||||
- tax_free_amount_split
|
||||
- coupon_marked_used
|
||||
- inventory_decremented
|
||||
- order_status_transitioned_to_paid
|
||||
|
||||
# 보조 흐름 — 취소/환불 등
|
||||
sub_flows:
|
||||
- id: partial_cancel
|
||||
description: 부분 취소 시 할인/배송 정책 재계산
|
||||
effects:
|
||||
- shipping_policy_recalculated
|
||||
- discount_recalculated
|
||||
- mileage_partial_refunded
|
||||
- vat_recalculated
|
||||
|
||||
- id: full_cancel
|
||||
description: 전체 취소 시 모든 부수효과 역방향
|
||||
effects:
|
||||
- payment_voided
|
||||
- mileage_fully_refunded
|
||||
- coupon_unused
|
||||
- inventory_restored
|
||||
- order_status_transitioned_to_cancelled
|
||||
|
||||
notes: |
|
||||
이 매니페스트는 예시이며 실제 채택 시 다음을 갱신하세요:
|
||||
1. test_files 경로
|
||||
2. axes 값 목록 (실제 도메인 enum/policy 와 일치하도록)
|
||||
3. effects 명명 (테스트 docblock @effects 와 1:1 매칭)
|
||||
pairwise 전략 케이스 수: 약 16~20 (full 일 경우 2*2*3*3*4*4*2*3 = 3456)
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["resources/js/*"]
|
||||
}
|
||||
},
|
||||
"include": ["resources/js/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'resources/js/index.ts'),
|
||||
name: 'SirsoftTosspayments',
|
||||
formats: ['iife'],
|
||||
},
|
||||
outDir: 'dist',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: 'js/plugin.iife.js',
|
||||
},
|
||||
},
|
||||
sourcemap: true,
|
||||
minify: 'esbuild',
|
||||
target: 'es2020',
|
||||
chunkSizeWarningLimit: 500,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'resources/js'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['resources/js/**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: ['node_modules/', 'dist/'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'resources/js'),
|
||||
'@core': path.resolve(__dirname, '../../resources/js/core'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user