diff --git a/README.md b/README.md index 63ae0606..d8b4dc2a 100644 --- a/README.md +++ b/README.md @@ -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** | 사용자 기본 템플릿 일본어 | diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/CHANGELOG.md new file mode 100644 index 00000000..6f8d1a7d --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/CHANGELOG.md @@ -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)의 일본어 번들 언어팩 초기 제공 diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/messages.php new file mode 100644 index 00000000..31a17594 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/messages.php @@ -0,0 +1,8 @@ + [ + 'missing_payment_key' => 'Toss Payments の決済キーが存在しないため、返金処理ができません。', + 'default_reason' => '顧客リクエストによるキャンセル', + ], +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/provider.php b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/provider.php new file mode 100644 index 00000000..a8b64c74 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/provider.php @@ -0,0 +1,6 @@ + 'Toss Payments', + 'description' => '国内PG会社 Toss Payments 決済連動', +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/frontend/ja.json new file mode 100644 index 00000000..a594a4c3 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/frontend/ja.json @@ -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": "決済に失敗しました。" + } +} diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/language-pack.json new file mode 100644 index 00000000..e09efe20 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/language-pack.json @@ -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": "" +} diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/seed/manifest.json b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/seed/manifest.json new file mode 100644 index 00000000..b4656838 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/seed/manifest.json @@ -0,0 +1,4 @@ +{ + "name": "Toss Payments", + "description": "Toss Payments 決済ゲートウェイ (統合決済窓口連携)" +} diff --git a/modules/_bundled/sirsoft-ecommerce/editor-spec.json b/modules/_bundled/sirsoft-ecommerce/editor-spec.json index 071bf224..cad3f219 100644 --- a/modules/_bundled/sirsoft-ecommerce/editor-spec.json +++ b/modules/_bundled/sirsoft-ecommerce/editor-spec.json @@ -3713,6 +3713,15 @@ "virtual_account", "mobile" ] + }, + { + "id": "tosspayments", + "name_key": "sirsoft-tosspayments::provider.name", + "name": "토스페이먼츠", + "icon": "credit-card", + "supported_methods": [ + "card" + ] } ], "abilities": { diff --git a/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md b/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md new file mode 100644 index 00000000..033fafa6 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md @@ -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) diff --git a/plugins/_bundled/sirsoft-tosspayments/LICENSE b/plugins/_bundled/sirsoft-tosspayments/LICENSE new file mode 100644 index 00000000..b939d80e --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/LICENSE @@ -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. diff --git a/plugins/_bundled/sirsoft-tosspayments/composer.json b/plugins/_bundled/sirsoft-tosspayments/composer.json new file mode 100644 index 00000000..fbe37f7e --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/composer.json @@ -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/", "./"] + } + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/composer.lock b/plugins/_bundled/sirsoft-tosspayments/composer.lock new file mode 100644 index 00000000..254377c1 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/composer.lock @@ -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" +} diff --git a/plugins/_bundled/sirsoft-tosspayments/config/settings/defaults.json b/plugins/_bundled/sirsoft-tosspayments/config/settings/defaults.json new file mode 100644 index 00000000..7a643d4b --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/config/settings/defaults.json @@ -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 } + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/dist/js/plugin.iife.js b/plugins/_bundled/sirsoft-tosspayments/dist/js/plugin.iife.js new file mode 100644 index 00000000..baf03d6f --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/dist/js/plugin.iife.js @@ -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 diff --git a/plugins/_bundled/sirsoft-tosspayments/dist/js/plugin.iife.js.map b/plugins/_bundled/sirsoft-tosspayments/dist/js/plugin.iife.js.map new file mode 100644 index 00000000..efbee9bf --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/dist/js/plugin.iife.js.map @@ -0,0 +1 @@ +{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/handlers/requestPayment.ts","../../resources/js/handlers/index.ts","../../resources/js/index.ts"],"sourcesContent":["/**\n * 토스페이먼츠 결제창 호출 핸들러\n *\n * 체크아웃 레이아웃에서 주문 생성 API 성공 후 호출됩니다:\n * handler: \"sirsoft-tosspayments.requestPayment\"\n * params: { pgPaymentData: response.data.pg_payment_data }\n *\n * 호출 순서:\n * 1. Client Config API 호출 → clientKey 획득\n * 2. TossPayments SDK 동적 로드 (미로드 시)\n * 3. SDK 초기화 + payment 인스턴스 생성\n * 4. payment.requestPayment() 호출 → 통합결제창 오픈\n * 5. 결제 완료 시 브라우저가 successUrl/failUrl로 리다이렉트\n */\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\ninterface PgPaymentData {\n order_number: string;\n order_name: string;\n amount: number;\n currency?: string;\n customer_name?: string;\n customer_email?: string;\n customer_phone?: string;\n customer_key?: string | null;\n}\n\ninterface RequestPaymentParams {\n pgPaymentData: PgPaymentData;\n}\n\ninterface ClientConfig {\n client_key: string;\n sdk_url: string;\n callback_urls: {\n success: string;\n fail: string;\n };\n}\n\ndeclare global {\n interface Window {\n TossPayments: any;\n }\n}\n\n/**\n * 스크립트를 동적으로 로드합니다.\n *\n * @param src 스크립트 URL\n * @returns Promise\n */\nfunction loadScript(src: string): Promise {\n return new Promise((resolve, reject) => {\n // 이미 로드된 스크립트인지 확인\n if (document.querySelector(`script[src=\"${src}\"]`)) {\n resolve();\n return;\n }\n\n const script = document.createElement('script');\n script.src = src;\n script.async = true;\n script.onload = () => resolve();\n script.onerror = () => reject(new Error(`Failed to load script: ${src}`));\n document.head.appendChild(script);\n });\n}\n\n/**\n * 토스페이먼츠 결제창 호출 핸들러\n *\n * ActionDispatcher는 커스텀 핸들러를 (action, context) 시그니처로 호출합니다.\n * params는 action.params에서 접근해야 합니다.\n *\n * @param action 액션 정의 (handler, params 등)\n * @param _context 액션 컨텍스트\n */\nexport async function requestPaymentHandler(action: any, _context?: any): Promise {\n const { pgPaymentData } = (action.params || {}) as RequestPaymentParams;\n\n if (!pgPaymentData) {\n console.error('[sirsoft-tosspayments] pgPaymentData is required');\n return;\n }\n\n const G7Core = (window as any).G7Core;\n\n try {\n // 1. Client Config API 호출\n const configJson = await G7Core.api.get('/modules/sirsoft-ecommerce/payments/client-config/tosspayments');\n\n if (!configJson.data) {\n console.error('[sirsoft-tosspayments] Failed to fetch client config', configJson);\n return;\n }\n\n const config: ClientConfig = configJson.data;\n\n // 2. SDK 동적 로드 (미로드 시)\n if (!window.TossPayments) {\n await loadScript(config.sdk_url);\n }\n\n // SDK 로드 대기 (스크립트 로드 후 TossPayments 객체 초기화까지 약간의 시간 소요)\n if (!window.TossPayments) {\n await new Promise((resolve) => setTimeout(resolve, 100));\n }\n\n if (!window.TossPayments) {\n console.error('[sirsoft-tosspayments] TossPayments SDK not available');\n return;\n }\n\n // 3. SDK 초기화\n const tossPayments = window.TossPayments(config.client_key);\n const payment = tossPayments.payment({\n customerKey: pgPaymentData.customer_key ?? window.TossPayments.ANONYMOUS,\n });\n\n // 4. 결제 요청 (통합결제창)\n const origin = window.location.origin;\n\n await payment.requestPayment({\n method: 'CARD',\n amount: {\n currency: pgPaymentData.currency ?? 'KRW',\n value: pgPaymentData.amount,\n },\n orderId: pgPaymentData.order_number,\n orderName: pgPaymentData.order_name,\n successUrl: origin + config.callback_urls.success,\n failUrl: origin + config.callback_urls.fail,\n customerEmail: pgPaymentData.customer_email ?? undefined,\n customerName: pgPaymentData.customer_name ?? undefined,\n customerMobilePhone: pgPaymentData.customer_phone ?? undefined,\n card: {\n useEscrow: false,\n flowMode: 'DEFAULT',\n useCardPoint: false,\n useAppCardOnly: false,\n },\n });\n // → 브라우저가 successUrl 또는 failUrl로 리다이렉트됨\n\n } catch (error: any) {\n console.error('[sirsoft-tosspayments] requestPayment error', error);\n\n // SDK에서 사용자 취소 시 에러가 발생할 수 있음\n if (error?.code === 'USER_CANCEL') {\n console.info('[sirsoft-tosspayments] Payment cancelled by user');\n\n // 1. 결제 취소 이력 기록 API 호출 (PG사 응답값 전달)\n try {\n await G7Core.api.post(\n `/modules/sirsoft-ecommerce/orders/${pgPaymentData.order_number}/cancel-payment`,\n {\n cancel_code: error.code,\n cancel_message: error.message,\n }\n );\n } catch (e) {\n console.warn('[sirsoft-tosspayments] Failed to record cancellation', e);\n }\n\n // 2. 로딩 상태 해제 + 취소 안내 모달 표시\n G7Core?.state?.setLocal?.({ isSubmittingOrder: false });\n G7Core?.modal?.open?.('tosspayments_payment_cancel_modal');\n return;\n }\n\n // 기타 에러 시 모달로 오류 표시 (체크아웃 페이지 유지)\n const errorMessage = error?.message ?? 'Unknown error';\n G7Core?.state?.setLocal?.({ paymentErrorMessage: errorMessage, isSubmittingOrder: false });\n G7Core?.modal?.open?.('tosspayments_payment_error_modal');\n }\n}\n","import { requestPaymentHandler } from './requestPayment';\n\n/**\n * 토스페이먼츠 플러그인 핸들러 맵\n *\n * ActionDispatcher에 등록될 핸들러 목록입니다.\n * 네임스페이스 접두사(sirsoft-tosspayments.)는 index.ts에서 자동으로 추가됩니다.\n */\nexport const handlerMap: Record unknown> = {\n requestPayment: requestPaymentHandler,\n} as const;\n","/**\n * 토스페이먼츠 플러그인 엔트리\n *\n * ActionDispatcher에 핸들러를 등록하여\n * 레이아웃 JSON에서 \"sirsoft-tosspayments.requestPayment\" 형태로 호출할 수 있게 합니다.\n */\n\nimport { handlerMap } from './handlers';\n\nconst PLUGIN_IDENTIFIER = 'sirsoft-tosspayments';\n\n/**\n * 로거 (G7Core 초기화 전에도 동작)\n */\nconst logger = {\n info: (...args: unknown[]) => console.info(`[${PLUGIN_IDENTIFIER}]`, ...args),\n warn: (...args: unknown[]) => console.warn(`[${PLUGIN_IDENTIFIER}]`, ...args),\n error: (...args: unknown[]) => console.error(`[${PLUGIN_IDENTIFIER}]`, ...args),\n};\n\n/**\n * ActionDispatcher에 핸들러를 등록합니다.\n *\n * @returns 등록된 핸들러 수\n */\nfunction registerHandlers(): number {\n const g7Core = (window as Record).G7Core as Record | undefined;\n\n if (!g7Core) {\n return 0;\n }\n\n const getDispatcher = g7Core.getActionDispatcher as (() => Record) | undefined;\n\n if (typeof getDispatcher !== 'function') {\n return 0;\n }\n\n const dispatcher = getDispatcher() as Record | undefined;\n\n if (!dispatcher || typeof dispatcher.registerHandler !== 'function') {\n return 0;\n }\n\n let count = 0;\n for (const [name, handler] of Object.entries(handlerMap)) {\n const fullName = `${PLUGIN_IDENTIFIER}.${name}`;\n dispatcher.registerHandler(fullName, handler, {\n category: 'plugin',\n source: PLUGIN_IDENTIFIER,\n });\n count++;\n }\n\n return count;\n}\n\n/**\n * 플러그인 초기화\n *\n * DOMContentLoaded 후 ActionDispatcher가 준비되면 핸들러를 등록합니다.\n * ActionDispatcher가 아직 준비되지 않았으면 최대 5초간 재시도합니다.\n */\nfunction initPlugin(): void {\n const doInit = () => {\n const count = registerHandlers();\n\n if (count > 0) {\n logger.info(`${count} handler(s) registered`);\n return;\n }\n\n // ActionDispatcher 미준비 시 재시도 (100ms 간격, 최대 50회 = 5초)\n let retries = 0;\n const maxRetries = 50;\n const interval = setInterval(() => {\n retries++;\n const result = registerHandlers();\n\n if (result > 0) {\n clearInterval(interval);\n logger.info(`${result} handler(s) registered (after ${retries} retries)`);\n return;\n }\n\n if (retries >= maxRetries) {\n clearInterval(interval);\n logger.warn('ActionDispatcher not available after timeout');\n }\n }, 100);\n };\n\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', doInit);\n } else {\n doInit();\n }\n}\n\n// 자동 초기화\ninitPlugin();\n\n// 디버그용 글로벌 노출\n(window as Record).__SirsoftTosspayments = {\n identifier: PLUGIN_IDENTIFIER,\n handlers: Object.keys(handlerMap),\n initPlugin,\n};\n"],"names":["loadScript","src","resolve","reject","script","requestPaymentHandler","action","_context","pgPaymentData","G7Core","configJson","config","payment","origin","error","e","errorMessage","handlerMap","PLUGIN_IDENTIFIER","logger","args","registerHandlers","g7Core","getDispatcher","dispatcher","count","name","handler","fullName","initPlugin","doInit","retries","maxRetries","interval","result"],"mappings":"yBAqDA,SAASA,EAAWC,EAA4B,CAC5C,OAAO,IAAI,QAAQ,CAACC,EAASC,IAAW,CAEpC,GAAI,SAAS,cAAc,eAAeF,CAAG,IAAI,EAAG,CAChDC,EAAA,EACA,MACJ,CAEA,MAAME,EAAS,SAAS,cAAc,QAAQ,EAC9CA,EAAO,IAAMH,EACbG,EAAO,MAAQ,GACfA,EAAO,OAAS,IAAMF,EAAA,EACtBE,EAAO,QAAU,IAAMD,EAAO,IAAI,MAAM,0BAA0BF,CAAG,EAAE,CAAC,EACxE,SAAS,KAAK,YAAYG,CAAM,CACpC,CAAC,CACL,CAWA,eAAsBC,EAAsBC,EAAaC,EAA+B,CACpF,KAAM,CAAE,cAAAC,CAAA,EAAmBF,EAAO,QAAU,CAAA,EAE5C,GAAI,CAACE,EAAe,CAChB,QAAQ,MAAM,kDAAkD,EAChE,MACJ,CAEA,MAAMC,EAAU,OAAe,OAE/B,GAAI,CAEA,MAAMC,EAAa,MAAMD,EAAO,IAAI,IAAI,gEAAgE,EAExG,GAAI,CAACC,EAAW,KAAM,CAClB,QAAQ,MAAM,uDAAwDA,CAAU,EAChF,MACJ,CAEA,MAAMC,EAAuBD,EAAW,KAYxC,GATK,OAAO,cACR,MAAMV,EAAWW,EAAO,OAAO,EAI9B,OAAO,cACR,MAAM,IAAI,QAAeT,GAAY,WAAWA,EAAS,GAAG,CAAC,EAG7D,CAAC,OAAO,aAAc,CACtB,QAAQ,MAAM,uDAAuD,EACrE,MACJ,CAIA,MAAMU,EADe,OAAO,aAAaD,EAAO,UAAU,EAC7B,QAAQ,CACjC,YAAaH,EAAc,cAAgB,OAAO,aAAa,SAAA,CAClE,EAGKK,EAAS,OAAO,SAAS,OAE/B,MAAMD,EAAQ,eAAe,CACzB,OAAQ,OACR,OAAQ,CACJ,SAAUJ,EAAc,UAAY,MACpC,MAAOA,EAAc,MAAA,EAEzB,QAASA,EAAc,aACvB,UAAWA,EAAc,WACzB,WAAYK,EAASF,EAAO,cAAc,QAC1C,QAASE,EAASF,EAAO,cAAc,KACvC,cAAeH,EAAc,gBAAkB,OAC/C,aAAcA,EAAc,eAAiB,OAC7C,oBAAqBA,EAAc,gBAAkB,OACrD,KAAM,CACF,UAAW,GACX,SAAU,UACV,aAAc,GACd,eAAgB,EAAA,CACpB,CACH,CAGL,OAASM,EAAY,CAIjB,GAHA,QAAQ,MAAM,8CAA+CA,CAAK,EAG9DA,GAAO,OAAS,cAAe,CAC/B,QAAQ,KAAK,kDAAkD,EAG/D,GAAI,CACA,MAAML,EAAO,IAAI,KACb,qCAAqCD,EAAc,YAAY,kBAC/D,CACI,YAAaM,EAAM,KACnB,eAAgBA,EAAM,OAAA,CAC1B,CAER,OAASC,EAAG,CACR,QAAQ,KAAK,uDAAwDA,CAAC,CAC1E,CAGAN,GAAQ,OAAO,WAAW,CAAE,kBAAmB,GAAO,EACtDA,GAAQ,OAAO,OAAO,mCAAmC,EACzD,MACJ,CAGA,MAAMO,EAAeF,GAAO,SAAW,gBACvCL,GAAQ,OAAO,WAAW,CAAE,oBAAqBO,EAAc,kBAAmB,GAAO,EACzFP,GAAQ,OAAO,OAAO,kCAAkC,CAC5D,CACJ,CCzKO,MAAMQ,EAA8D,CACvE,eAAgBZ,CACpB,ECDMa,EAAoB,uBAKpBC,EAAS,CACX,KAAM,IAAIC,IAAoB,QAAQ,KAAK,IAAIF,CAAiB,IAAK,GAAGE,CAAI,EAC5E,KAAM,IAAIA,IAAoB,QAAQ,KAAK,IAAIF,CAAiB,IAAK,GAAGE,CAAI,EAC5E,MAAO,IAAIA,IAAoB,QAAQ,MAAM,IAAIF,CAAiB,IAAK,GAAGE,CAAI,CAClF,EAOA,SAASC,GAA2B,CAChC,MAAMC,EAAU,OAAmC,OAEnD,GAAI,CAACA,EACD,MAAO,GAGX,MAAMC,EAAgBD,EAAO,oBAE7B,GAAI,OAAOC,GAAkB,WACzB,MAAO,GAGX,MAAMC,EAAaD,EAAA,EAEnB,GAAI,CAACC,GAAc,OAAOA,EAAW,iBAAoB,WACrD,MAAO,GAGX,IAAIC,EAAQ,EACZ,SAAW,CAACC,EAAMC,CAAO,IAAK,OAAO,QAAQV,CAAU,EAAG,CACtD,MAAMW,EAAW,GAAGV,CAAiB,IAAIQ,CAAI,GAC7CF,EAAW,gBAAgBI,EAAUD,EAAS,CAC1C,SAAU,SACV,OAAQT,CAAA,CACX,EACDO,GACJ,CAEA,OAAOA,CACX,CAQA,SAASI,GAAmB,CACxB,MAAMC,EAAS,IAAM,CACjB,MAAML,EAAQJ,EAAA,EAEd,GAAII,EAAQ,EAAG,CACXN,EAAO,KAAK,GAAGM,CAAK,wBAAwB,EAC5C,MACJ,CAGA,IAAIM,EAAU,EACd,MAAMC,EAAa,GACbC,EAAW,YAAY,IAAM,CAC/BF,IACA,MAAMG,EAASb,EAAA,EAEf,GAAIa,EAAS,EAAG,CACZ,cAAcD,CAAQ,EACtBd,EAAO,KAAK,GAAGe,CAAM,iCAAiCH,CAAO,WAAW,EACxE,MACJ,CAEIA,GAAWC,IACX,cAAcC,CAAQ,EACtBd,EAAO,KAAK,8CAA8C,EAElE,EAAG,GAAG,CACV,EAEI,SAAS,aAAe,UACxB,SAAS,iBAAiB,mBAAoBW,CAAM,EAEpDA,EAAA,CAER,CAGAD,EAAA,EAGC,OAAmC,sBAAwB,CACxD,WAAYX,EACZ,SAAU,OAAO,KAAKD,CAAU,EAChC,WAAAY,CACJ"} \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-tosspayments/lang/en/messages.php b/plugins/_bundled/sirsoft-tosspayments/lang/en/messages.php new file mode 100644 index 00000000..61dfb698 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/lang/en/messages.php @@ -0,0 +1,11 @@ + [ + 'missing_payment_key' => 'Cannot process refund: TossPayments payment key not found.', + 'default_reason' => 'Cancelled by customer request', + ], +]; diff --git a/plugins/_bundled/sirsoft-tosspayments/lang/en/provider.php b/plugins/_bundled/sirsoft-tosspayments/lang/en/provider.php new file mode 100644 index 00000000..96f20f04 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/lang/en/provider.php @@ -0,0 +1,12 @@ + 'TossPayments', + 'description' => 'TossPayments domestic payment gateway integration', +]; diff --git a/plugins/_bundled/sirsoft-tosspayments/lang/ko/messages.php b/plugins/_bundled/sirsoft-tosspayments/lang/ko/messages.php new file mode 100644 index 00000000..68003e0d --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/lang/ko/messages.php @@ -0,0 +1,11 @@ + [ + 'missing_payment_key' => '토스페이먼츠 결제 키가 존재하지 않아 환불 처리할 수 없습니다.', + 'default_reason' => '고객 요청에 의한 취소', + ], +]; diff --git a/plugins/_bundled/sirsoft-tosspayments/lang/ko/provider.php b/plugins/_bundled/sirsoft-tosspayments/lang/ko/provider.php new file mode 100644 index 00000000..3c7d0e2c --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/lang/ko/provider.php @@ -0,0 +1,12 @@ + '토스페이먼츠', + 'description' => '국내 PG 사 토스페이먼츠 결제 연동', +]; diff --git a/plugins/_bundled/sirsoft-tosspayments/package-lock.json b/plugins/_bundled/sirsoft-tosspayments/package-lock.json new file mode 100644 index 00000000..07e87c18 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/package-lock.json @@ -0,0 +1,2014 @@ +{ + "name": "@g7/sirsoft-tosspayments", + "version": "0.2.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@g7/sirsoft-tosspayments", + "version": "0.2.2", + "devDependencies": { + "jsdom": "^27.4.0", + "typescript": "^5.3.3", + "vite": "^5.4.14", + "vitest": "^2.1.8" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.8", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.8.tgz", + "integrity": "sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz", + "integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.0.1.tgz", + "integrity": "sha512-bsDKIP6f4ta2DO9t+rAbSSwv4EMESXy5ZIvzQl1afmD6Z1XHkVu9ijcG9QR/qSgQS1dVa+RaQ/MfQ7FIB/Dn1Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz", + "integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.1", + "@csstools/css-calc": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.27", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz", + "integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.0.tgz", + "integrity": "sha512-YiY1OmY6Qhkvmly8vZiD8wZRpW/npGZNg+0Sk8mstxirRHCg6lolHt5tSODCfuNPE/fBsAqRwDJE417x7jDDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.23" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/package.json b/plugins/_bundled/sirsoft-tosspayments/package.json new file mode 100644 index 00000000..75ec38d8 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/package.json @@ -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" + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/plugin.json b/plugins/_bundled/sirsoft-tosspayments/plugin.json new file mode 100644 index 00000000..cea1dbd9 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/plugin.json @@ -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 + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/plugin.php b/plugins/_bundled/sirsoft-tosspayments/plugin.php new file mode 100644 index 00000000..53a6eee1 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/plugin.php @@ -0,0 +1,159 @@ + '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', + ], + ], + ]; + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/resources/extensions/checkout-payment-error.json b/plugins/_bundled/sirsoft-tosspayments/resources/extensions/checkout-payment-error.json new file mode 100644 index 00000000..0fb8432c --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/resources/extensions/checkout-payment-error.json @@ -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 +} diff --git a/plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/handlers/requestPayment.test.ts b/plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/handlers/requestPayment.test.ts new file mode 100644 index 00000000..ae367040 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/handlers/requestPayment.test.ts @@ -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; post: ReturnType }; + state: { setLocal: ReturnType }; + modal: { open: ReturnType }; + }; + + 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(); + }); + }); +}); diff --git a/plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/layouts/pluginSettingsSemanticClasses.test.tsx b/plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/layouts/pluginSettingsSemanticClasses.test.tsx new file mode 100644 index 00000000..45e651e4 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/layouts/pluginSettingsSemanticClasses.test.tsx @@ -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; + +/** 레이아웃 트리를 순회하며 술어를 만족하는 노드를 모두 수집한다. */ +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; + 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); + }); +}); diff --git a/plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/layouts/pluginSettingsSticky.test.tsx b/plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/layouts/pluginSettingsSticky.test.tsx new file mode 100644 index 00000000..c5a8d2be --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/layouts/pluginSettingsSticky.test.tsx @@ -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 | undefined { + if (!node || typeof node !== 'object') { + return undefined; + } + const value = node as Record; + 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 | undefined): string { + const props = (node?.props ?? {}) as Record; + 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'); + }); +}); diff --git a/plugins/_bundled/sirsoft-tosspayments/resources/js/handlers/index.ts b/plugins/_bundled/sirsoft-tosspayments/resources/js/handlers/index.ts new file mode 100644 index 00000000..ea290be2 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/resources/js/handlers/index.ts @@ -0,0 +1,11 @@ +import { requestPaymentHandler } from './requestPayment'; + +/** + * 토스페이먼츠 플러그인 핸들러 맵 + * + * ActionDispatcher에 등록될 핸들러 목록입니다. + * 네임스페이스 접두사(sirsoft-tosspayments.)는 index.ts에서 자동으로 추가됩니다. + */ +export const handlerMap: Record unknown> = { + requestPayment: requestPaymentHandler, +} as const; diff --git a/plugins/_bundled/sirsoft-tosspayments/resources/js/handlers/requestPayment.ts b/plugins/_bundled/sirsoft-tosspayments/resources/js/handlers/requestPayment.ts new file mode 100644 index 00000000..3572ac42 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/resources/js/handlers/requestPayment.ts @@ -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 { + 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 { + 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((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'); + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/resources/js/index.ts b/plugins/_bundled/sirsoft-tosspayments/resources/js/index.ts new file mode 100644 index 00000000..ae8b2da6 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/resources/js/index.ts @@ -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).G7Core as Record | undefined; + + if (!g7Core) { + return 0; + } + + const getDispatcher = g7Core.getActionDispatcher as (() => Record) | undefined; + + if (typeof getDispatcher !== 'function') { + return 0; + } + + const dispatcher = getDispatcher() as Record | 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).__SirsoftTosspayments = { + identifier: PLUGIN_IDENTIFIER, + handlers: Object.keys(handlerMap), + initPlugin, +}; diff --git a/plugins/_bundled/sirsoft-tosspayments/resources/lang/en.json b/plugins/_bundled/sirsoft-tosspayments/resources/lang/en.json new file mode 100644 index 00000000..cc8a4b16 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/resources/lang/en.json @@ -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." + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/resources/lang/ko.json b/plugins/_bundled/sirsoft-tosspayments/resources/lang/ko.json new file mode 100644 index 00000000..2a7a531c --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/resources/lang/ko.json @@ -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": "결제에 실패했습니다." + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-tosspayments/resources/layouts/admin/plugin_settings.json new file mode 100644 index 00000000..2aae64de --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/resources/layouts/admin/plugin_settings.json @@ -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" + } + ] + } + ] + } + ] + } + ] + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/src/Controllers/PaymentCallbackController.php b/plugins/_bundled/sirsoft-tosspayments/src/Controllers/PaymentCallbackController.php new file mode 100644 index 00000000..ec8d1930 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/src/Controllers/PaymentCallbackController.php @@ -0,0 +1,237 @@ + '기업 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) + */ +} diff --git a/plugins/_bundled/sirsoft-tosspayments/src/Http/Requests/FailCallbackRequest.php b/plugins/_bundled/sirsoft-tosspayments/src/Http/Requests/FailCallbackRequest.php new file mode 100644 index 00000000..56df480a --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/src/Http/Requests/FailCallbackRequest.php @@ -0,0 +1,51 @@ +> + */ + 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' => '', + ]); + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/src/Http/Requests/SuccessCallbackRequest.php b/plugins/_bundled/sirsoft-tosspayments/src/Http/Requests/SuccessCallbackRequest.php new file mode 100644 index 00000000..bd5520ef --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/src/Http/Requests/SuccessCallbackRequest.php @@ -0,0 +1,62 @@ +> + */ + 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'])) + ); + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/src/Listeners/PaymentRefundListener.php b/plugins/_bundled/sirsoft-tosspayments/src/Listeners/PaymentRefundListener.php new file mode 100644 index 00000000..e7a72fea --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/src/Listeners/PaymentRefundListener.php @@ -0,0 +1,129 @@ + [ + '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); + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/src/Listeners/RegisterPgProviderListener.php b/plugins/_bundled/sirsoft-tosspayments/src/Listeners/RegisterPgProviderListener.php new file mode 100644 index 00000000..5ef778cd --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/src/Listeners/RegisterPgProviderListener.php @@ -0,0 +1,104 @@ + [ + '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); + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/src/Services/TossPaymentsApiService.php b/plugins/_bundled/sirsoft-tosspayments/src/Services/TossPaymentsApiService.php new file mode 100644 index 00000000..dd7ffadb --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/src/Services/TossPaymentsApiService.php @@ -0,0 +1,119 @@ +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(); + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/src/routes/web.php b/plugins/_bundled/sirsoft-tosspayments/src/routes/web.php new file mode 100644 index 00000000..a508e21d --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/src/routes/web.php @@ -0,0 +1,22 @@ +name('payment.success'); + +// 결제 실패 콜백 (토스페이먼츠 → 브라우저 리다이렉트) +Route::get('/payment/fail', [PaymentCallbackController::class, 'fail']) + ->name('payment.fail'); diff --git a/plugins/_bundled/sirsoft-tosspayments/tests/Feature/Controllers/PaymentCallbackControllerTest.php b/plugins/_bundled/sirsoft-tosspayments/tests/Feature/Controllers/PaymentCallbackControllerTest.php new file mode 100644 index 00000000..a05ab919 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/tests/Feature/Controllers/PaymentCallbackControllerTest.php @@ -0,0 +1,469 @@ + $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); + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-tosspayments/tests/PluginTestCase.php new file mode 100644 index 00000000..04535bad --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/tests/PluginTestCase.php @@ -0,0 +1,286 @@ + $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; + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/PaymentRefundListenerTest.php b/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/PaymentRefundListenerTest.php new file mode 100644 index 00000000..9055da25 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/PaymentRefundListenerTest.php @@ -0,0 +1,103 @@ +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); + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/RegisterPgProviderListenerTest.php b/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/RegisterPgProviderListenerTest.php new file mode 100644 index 00000000..2070e153 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/RegisterPgProviderListenerTest.php @@ -0,0 +1,112 @@ +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); + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Services/TossPaymentsApiServiceTest.php b/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Services/TossPaymentsApiServiceTest.php new file mode 100644 index 00000000..fa2380b5 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Services/TossPaymentsApiServiceTest.php @@ -0,0 +1,98 @@ +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)); + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/tests/scenarios/example_pg_payment.yaml.example b/plugins/_bundled/sirsoft-tosspayments/tests/scenarios/example_pg_payment.yaml.example new file mode 100644 index 00000000..08340239 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/tests/scenarios/example_pg_payment.yaml.example @@ -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) diff --git a/plugins/_bundled/sirsoft-tosspayments/tsconfig.json b/plugins/_bundled/sirsoft-tosspayments/tsconfig.json new file mode 100644 index 00000000..f50d72bd --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/tsconfig.json @@ -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"] +} diff --git a/plugins/_bundled/sirsoft-tosspayments/vite.config.ts b/plugins/_bundled/sirsoft-tosspayments/vite.config.ts new file mode 100644 index 00000000..24a7eb49 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/vite.config.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'), + }, + }, +}); diff --git a/plugins/_bundled/sirsoft-tosspayments/vitest.config.ts b/plugins/_bundled/sirsoft-tosspayments/vitest.config.ts new file mode 100644 index 00000000..b66c6912 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/vitest.config.ts @@ -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'), + }, + }, +});