From a9f85ff92b382853bfd7d9612aa6d64cc5b65e85 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Sun, 12 Jul 2026 15:57:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(tosspayments,ecommerce,core):=20=ED=86=A0?= =?UTF-8?q?=EC=8A=A4=20=EC=A3=BC=EB=AC=B8=EC=84=9C=ED=98=95=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=EC=88=98=EB=8B=A8=C2=B7=EA=B0=80=EC=83=81=EA=B3=84?= =?UTF-8?q?=EC=A2=8C=C2=B7=EC=97=90=EC=8A=A4=ED=81=AC=EB=A1=9C=20+=20API?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=20web=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 토스페이먼츠 플러그인을 프로덕션 수준으로 완성한다 (계획서 S4). 주문서형 결제(9종 동적등록) - 플러그인이 filter_available_payment_methods 로 결제수단을 주입하면 체크아웃에 독립 항목으로 노출된다. 서버 전송 시에는 core_payment_method 로 번역해 보낸다 — 코어 PaymentMethodEnum 은 toss_* 를 거부하므로 번역 없이는 주문 자체가 422 로 실패했다. 번역 근거인 core_payment_method 가 설정 병합/스냅샷의 화이트리스트에서 탈락하던 것을 provider-agnostic 하게 보존하도록 고쳤다 (토스 전용 분기 없음). 가상계좌·웹훅·에스크로 - 입금통보/결제상태 웹훅 2종 신설. secret 대조(hash_equals)·리플레이 멱등·CSRF 면제. - 에스크로 3상태(off/on/buyer_choice). escrowProducts 를 가상계좌·계좌이체 양쪽 SDK 페이로드에 싣는다 — 서버가 조립해도 SDK 로 전달되지 않으면 토스가 결제를 거부한다. E2E 가 SDK 경계를 직접 캡처해 이 계약의 나머지 절반을 잠근다. - 관리자 설정 저장 시 입금기한·에스크로 값 범위를 서버에서 검증한다 (UI max 는 클라 힌트라 API 직접 호출을 막지 못한다). 레이아웃 리스너 실행 순서 - KG 는 no-PG 리스트를 통짜 리터럴로 str_replace 하므로 토스가 먼저 append 하면 KG 의 매치가 깨진다. HookManager 는 ksort 오름차순이므로 토스 priority 를 30 으로 두어 "KG(20) 먼저" 를 플러그인 로드 순서와 무관한 불변식으로 고정했다. 코어 — API 문서 생성기가 확장 web 라우트도 수집 - PG 콜백·웹훅은 CSRF/세션 특성상 web.php 에 등록되지만 외부 시스템이 호출하는 machine-facing 엔드포인트라 API 레퍼런스 대상이다. api/ prefix 만 수집하던 탓에 영구 무문서였다. 확장 소유 web 라우트를 수집하되 관리자 화면(admin 컨텍스트)은 제외한다. 코어 라우트 수집 결과는 291건으로 불변. 하네스 — 확장 테스트 베이스클래스 룰 신설 - coverage.json 의 test-extension-base-class 가 status:todo(룰 미구현)라 규정이 있어도 검출되지 않았고, 실제로 이번 신설 테스트가 그 사각에 빠졌다. 룰을 구현해 봉인한다. --- AGENTS.md | 7 +- app/Support/ApiDoc/ApiRouteInventory.php | 126 +++-- docs/backend/api/README.md | 3 +- .../CHANGELOG.md | 2 + .../backend/ja/messages.php | 4 + .../backend/ja/payment_methods.php | 40 ++ .../frontend/ja.json | 41 +- .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 4 + .../sirsoft-ecommerce/docs/api/orders.md | 6 + .../Traits/HandlesOrderCreation.php | 34 ++ .../OrderPaymentRepositoryInterface.php | 12 + .../Repositories/OrderPaymentRepository.php | 16 + .../src/Services/EcommerceSettingsService.php | 18 +- .../checkout-plugin-payment-method.spec.ts | 524 ++++++++++++++++++ .../Public/BuildPgPaymentDataTest.php | 73 +++ ...erPaymentRepositoryTransactionPaidTest.php | 62 +++ .../EcommerceSettingsOrderSettingsTest.php | 85 ++- .../sirsoft-tosspayments/CHANGELOG.md | 19 + .../sirsoft-tosspayments/components.json | 10 + .../config/settings/defaults.json | 32 +- .../dist/js/plugin.iife.js | 2 +- .../dist/js/plugin.iife.js.map | 2 +- .../sirsoft-tosspayments/docs/api/README.md | 15 + .../sirsoft-tosspayments/docs/api/payment.md | 147 +++++ .../sirsoft-tosspayments/docs/api/webhook.md | 170 ++++++ .../sirsoft-tosspayments/lang/en/messages.php | 4 + .../lang/en/payment_methods.php | 40 ++ .../sirsoft-tosspayments/lang/ko/messages.php | 4 + .../lang/ko/payment_methods.php | 40 ++ .../_bundled/sirsoft-tosspayments/plugin.json | 4 +- .../_bundled/sirsoft-tosspayments/plugin.php | 117 ++++ .../__tests__/handlers/requestPayment.test.ts | 216 ++++++++ .../pluginSettingsPaymentMethods.test.tsx | 146 +++++ .../pluginSettingsSemanticClasses.test.tsx | 5 +- .../resources/js/handlers/requestPayment.ts | 171 +++++- .../resources/lang/en.json | 41 +- .../resources/lang/ko.json | 41 +- .../layouts/admin/plugin_settings.json | 514 +++++++++++++++++ .../src/Concerns/MapsTossPaymentMethods.php | 65 +++ .../src/Concerns/PreventsReplayCallback.php | 47 ++ .../Controllers/PaymentCallbackController.php | 87 ++- .../src/Controllers/WebhookController.php | 215 +++++++ .../Http/Requests/DepositWebhookRequest.php | 46 ++ .../Requests/PaymentStatusWebhookRequest.php | 46 ++ ...tEcommercePaymentMethodsLayoutListener.php | 119 ++++ .../Listeners/RegisterPgProviderListener.php | 52 +- .../RegisterTossPaymentMethodsListener.php | 159 ++++++ .../ValidateTossSettingsListener.php | 96 ++++ .../sirsoft-tosspayments/src/routes/web.php | 15 + .../PaymentCallbackControllerTest.php | 138 ++++- .../Controllers/WebhookControllerTest.php | 237 ++++++++ .../RegisterPgProviderClientConfigTest.php | 107 ++++ ...RegisterTossPaymentMethodsListenerTest.php | 143 +++++ ...mmercePaymentMethodsLayoutListenerTest.php | 160 ++++++ .../RegisterPgProviderListenerTest.php | 14 +- .../ValidateTossSettingsListenerTest.php | 148 +++++ .../toss-payment-methods-vbank-escrow.yaml | 116 ++++ templates/_bundled/sirsoft-basic/CHANGELOG.md | 1 + .../checkoutSummaryBodyPayload.test.tsx | 121 +++- .../partials/shop/_checkout_summary.json | 2 +- .../sirsoft-basic/layouts/shop/checkout.json | 8 +- .../ApiDoc/ApiRouteInventoryFallbackTest.php | 6 +- .../ApiDoc/ApiRouteInventoryWebRouteTest.php | 123 ++++ 63 files changed, 4951 insertions(+), 117 deletions(-) create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/payment_methods.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/checkout-plugin-payment-method.spec.ts create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/OrderPaymentRepositoryTransactionPaidTest.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/components.json create mode 100644 plugins/_bundled/sirsoft-tosspayments/docs/api/README.md create mode 100644 plugins/_bundled/sirsoft-tosspayments/docs/api/payment.md create mode 100644 plugins/_bundled/sirsoft-tosspayments/docs/api/webhook.md create mode 100644 plugins/_bundled/sirsoft-tosspayments/lang/en/payment_methods.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/lang/ko/payment_methods.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/layouts/pluginSettingsPaymentMethods.test.tsx create mode 100644 plugins/_bundled/sirsoft-tosspayments/src/Concerns/MapsTossPaymentMethods.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/src/Concerns/PreventsReplayCallback.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/src/Controllers/WebhookController.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/src/Http/Requests/DepositWebhookRequest.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/src/Http/Requests/PaymentStatusWebhookRequest.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/src/Listeners/AdjustEcommercePaymentMethodsLayoutListener.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/src/Listeners/RegisterTossPaymentMethodsListener.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/src/Listeners/ValidateTossSettingsListener.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/tests/Feature/Controllers/WebhookControllerTest.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/tests/Feature/Listeners/RegisterPgProviderClientConfigTest.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/tests/Feature/Listeners/RegisterTossPaymentMethodsListenerTest.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/AdjustEcommercePaymentMethodsLayoutListenerTest.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/ValidateTossSettingsListenerTest.php create mode 100644 plugins/_bundled/sirsoft-tosspayments/tests/scenarios/toss-payment-methods-vbank-escrow.yaml create mode 100644 tests/Unit/Support/ApiDoc/ApiRouteInventoryWebRouteTest.php diff --git a/AGENTS.md b/AGENTS.md index 97891c15..8c4c5138 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,7 +155,7 @@ | 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 319 | -### 확장 API 레퍼런스 (13개 확장, 자동 스캔) +### 확장 API 레퍼런스 (14개 확장, 자동 스캔) > 각 확장이 소유하는 API 문서 목차. `php artisan api:docgen` 이 생성하며, 이 표는 `{modules,plugins}/_bundled/*/docs/api/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음). @@ -163,15 +163,16 @@ |------|------|--------------|----------------| | `gnuboard7-hello_module` | 모듈 | [docs/api/](modules/_bundled/gnuboard7-hello_module/docs/api/README.md) | 1 / 7 | | `sirsoft-board` | 모듈 | [docs/api/](modules/_bundled/sirsoft-board/docs/api/README.md) | 10 / 80 | -| `sirsoft-ecommerce` | 모듈 | [docs/api/](modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 232 | +| `sirsoft-ecommerce` | 모듈 | [docs/api/](modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 239 | | `sirsoft-page` | 모듈 | [docs/api/](modules/_bundled/sirsoft-page/docs/api/README.md) | 2 / 17 | | `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 | | `sirsoft-gdpr` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 | | `sirsoft-marketing` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 | -| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 12 | +| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 7 / 13 | | `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 | | `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 | | `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 | +| `sirsoft-tosspayments` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-tosspayments/docs/api/README.md) | 2 / 4 | | `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 | | `sirsoft-verification_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/README.md) | 1 / 1 | diff --git a/app/Support/ApiDoc/ApiRouteInventory.php b/app/Support/ApiDoc/ApiRouteInventory.php index 001b3173..9ae5752c 100644 --- a/app/Support/ApiDoc/ApiRouteInventory.php +++ b/app/Support/ApiDoc/ApiRouteInventory.php @@ -29,13 +29,13 @@ class ApiRouteInventory /** @var Route $route */ $uri = $route->uri(); - if (! Str::startsWith($uri, 'api/')) { - continue; - } - $name = $route->getName() ?? ''; $owner = $this->resolveOwner($name, $uri); + if (! $this->isDocumentable($uri, $name, $owner)) { + continue; + } + if ($scope !== null && ! $this->matchesScope($owner, $scope)) { continue; } @@ -57,6 +57,42 @@ class ApiRouteInventory return $routes; } + /** + * 라우트가 API 레퍼런스 문서 대상인지 판별합니다. + * + * 두 부류를 수집한다: + * 1. `api/` prefix 라우트 (코어 + 확장의 src/routes/api.php) + * 2. 확장 소유가 확정된 web 라우트 중 관리자 화면(admin 컨텍스트)이 아닌 것 + * + * (2)가 필요한 이유: PG 결제 콜백·웹훅은 외부 시스템(PG사 서버, 브라우저 리다이렉트)이 + * 호출하는 machine-facing 엔드포인트라 API 레퍼런스 대상이지만, CSRF 토큰 부재·세션 특성상 + * `api.php` 가 아니라 `web.php` 에 등록된다. `api/` 만 수집하면 이들이 영구히 무문서가 된다. + * + * 제외 대상: + * - 코어 web 라우트 (블레이드 화면·SPA 진입점 등) — 소유가 'core' + * - 확장의 관리자 화면 web 라우트 (`web.{dir}.{id}.admin.*`) — 사람이 브라우저로 여는 + * 화면이라 API 레퍼런스 대상이 아니다. 코어 admin API 는 `api/` prefix 라 영향 없다. + * + * @param string $uri 라우트 URI + * @param string $name 라우트명 + * @param array{type: string, id: string|null, key: string} $owner 소유 주체 + * @return bool 문서 대상 여부 + */ + private function isDocumentable(string $uri, string $name, array $owner): bool + { + if (Str::startsWith($uri, 'api/')) { + return true; + } + + // 코어 web 라우트(화면)는 대상 아님 + if ($owner['type'] === 'core') { + return false; + } + + // 확장 web 라우트 중 관리자 화면(admin 컨텍스트)은 대상 아님 + return ! preg_match('/^web\.(?:modules|plugins)\.[^.]+\.admin\./', $name); + } + /** * 하나의 라우트를 HTTP 메서드별 정규화 엔트리 배열로 변환합니다. * @@ -115,13 +151,18 @@ class ApiRouteInventory * 비활성/미설치 확장의 번들 라우트 파일을 임시 라우터에 로드해 수집합니다. * * 프로바이더(`ModuleRouteServiceProvider`/`PluginRouteServiceProvider`)와 동일한 - * prefix(`api/{modules|plugins}/{id}`)·name(`api.{modules|plugins}.{id}.`)·`api` - * 미들웨어 그룹으로 `src/routes/api.php` 를 라우터에 로드한다. `api/` 로 시작하는 - * 라우트만 대상이므로 web(admin) 라우트는 자동 제외된다(그 라우트는 API 문서 대상이 아니다). + * prefix·name·미들웨어 규약으로 번들 라우트 파일을 라우터에 로드한다: + * - `src/routes/api.php` → prefix `api/{dir}/{id}`, name `api.{dir}.{id}.`, middleware `api` + * - `src/routes/web.php` → prefix `{dir}/{id}`, name `web.{dir}.{id}.`, middleware `web` + * + * web 라우트를 함께 로드하는 이유: PG 결제 콜백·웹훅은 CSRF/세션 특성상 web.php 에 + * 등록되지만 외부 시스템이 호출하는 machine-facing 엔드포인트라 API 문서 대상이다. + * 관리자 화면용 web 라우트는 문서에 섞이지만, 확장 소유 라우트는 모두 문서 대상이라는 + * 활성 경로(`isDocumentable`)의 판정과 일관된다. * * 라우트 파일은 `use Illuminate\Support\Facades\Route` 로 전역 파사드에 등록하므로, - * 등록 전 라우트 이름 스냅샷을 떠서 이번 로드로 추가된 라우트만 골라낸다. 이 커맨드는 - * CLI 단발 프로세스라 전역 라우트 테이블 추가가 웹 요청에 영향을 주지 않는다. + * 등록 후 uri/name prefix 로 이 확장 소유 라우트만 골라낸다. 이 커맨드는 CLI 단발 + * 프로세스라 전역 라우트 테이블 추가가 웹 요청에 영향을 주지 않는다. * * @param string $scope 범위 필터 (`module:{id}` / `plugin:{id}`) * @return array> 정규화된 라우트 메타데이터 목록 @@ -134,43 +175,55 @@ class ApiRouteInventory [$type, $id] = [$m[1], $m[2]]; $dir = $type === 'module' ? 'modules' : 'plugins'; - $apiRouteFile = base_path("{$dir}/_bundled/{$id}/src/routes/api.php"); - if (! is_file($apiRouteFile)) { + $groups = [ + ['file' => base_path("{$dir}/_bundled/{$id}/src/routes/api.php"), 'prefix' => "api/{$dir}/{$id}", 'name' => "api.{$dir}.{$id}.", 'middleware' => 'api'], + ['file' => base_path("{$dir}/_bundled/{$id}/src/routes/web.php"), 'prefix' => "{$dir}/{$id}", 'name' => "web.{$dir}.{$id}.", 'middleware' => 'web'], + ]; + + $loaded = false; + + foreach ($groups as $group) { + if (! is_file($group['file'])) { + continue; + } + + try { + RouteFacade::prefix($group['prefix']) + ->name($group['name']) + ->middleware($group['middleware']) + ->group($group['file']); + $loaded = true; + } catch (\Throwable) { + // 개별 라우트 파일 로드 실패는 그 파일만 건너뛴다 (나머지는 계속 수집). + continue; + } + } + + if (! $loaded) { return []; } - $urlPrefix = "api/{$dir}/{$id}"; - $namePrefix = "api.{$dir}.{$id}."; - - try { - RouteFacade::prefix($urlPrefix) - ->name($namePrefix) - ->middleware('api') - ->group($apiRouteFile); - RouteFacade::getRoutes()->refreshNameLookups(); - } catch (\Throwable) { - return []; - } + RouteFacade::getRoutes()->refreshNameLookups(); // 이 확장 소유로 확정된 라우트를 전역 라우트 테이블에서 prefix(uri/name) 로 직접 골라낸다. // object-id 스냅샷 방식은 refreshNameLookups() 가 RouteCollection 을 재색인하며 객체를 // 다시 만들어 dedup 이 어긋나므로(활성 확장 로드 순서·중복 로드에 취약) 쓰지 않는다. - // 대상 확장은 uri 가 `api/{dir}/{id}/`, name 이 `api.{dir}.{id}.` 로 시작해 결정적으로 식별된다. + // 대상 확장은 uri/name 이 `{api/}{dir}/{id}/` · `{api|web}.{dir}.{id}.` 로 시작해 + // 결정적으로 식별된다. $seen = []; $routes = []; foreach (RouteFacade::getRoutes() as $route) { /** @var Route $route */ $uri = $route->uri(); - - if (! Str::startsWith($uri, 'api/')) { - continue; - } - $name = $route->getName() ?? ''; $owner = $this->resolveOwner($name, $uri); + if (! $this->isDocumentable($uri, $name, $owner)) { + continue; + } + // 폴백 대상 확장 소유로 확정되지 않으면(코어·타 확장) 제외 if ($owner['key'] !== $scope) { continue; @@ -199,20 +252,22 @@ class ApiRouteInventory */ private function resolveOwner(string $name, string $uri): array { - if (preg_match('/^api\.modules\.([^.]+)\./', $name, $m) && $this->isRealExtensionId($m[1])) { + // 라우트명 규약: api 라우트는 `api.{modules|plugins}.{id}.*`, + // web 라우트(PG 콜백/웹훅)는 `web.{modules|plugins}.{id}.*` + if (preg_match('/^(?:api|web)\.modules\.([^.]+)\./', $name, $m) && $this->isRealExtensionId($m[1])) { return ['type' => 'module', 'id' => $m[1], 'key' => 'module:'.$m[1]]; } - if (preg_match('/^api\.plugins\.([^.]+)\./', $name, $m) && $this->isRealExtensionId($m[1])) { + if (preg_match('/^(?:api|web)\.plugins\.([^.]+)\./', $name, $m) && $this->isRealExtensionId($m[1])) { return ['type' => 'plugin', 'id' => $m[1], 'key' => 'plugin:'.$m[1]]; } - // 라우트명이 비어도 URI prefix 로 보조 판별 - if (preg_match('#^api/modules/([^/{]+)/#', $uri, $m) && $this->isRealExtensionId($m[1])) { + // 라우트명이 비어도 URI prefix 로 보조 판별 (api: `api/{dir}/{id}/`, web: `{dir}/{id}/`) + if (preg_match('#^(?:api/)?modules/([^/{]+)/#', $uri, $m) && $this->isRealExtensionId($m[1])) { return ['type' => 'module', 'id' => $m[1], 'key' => 'module:'.$m[1]]; } - if (preg_match('#^api/plugins/([^/{]+)/#', $uri, $m) && $this->isRealExtensionId($m[1])) { + if (preg_match('#^(?:api/)?plugins/([^/{]+)/#', $uri, $m) && $this->isRealExtensionId($m[1])) { return ['type' => 'plugin', 'id' => $m[1], 'key' => 'plugin:'.$m[1]]; } @@ -391,7 +446,8 @@ class ApiRouteInventory // 'modules'/'plugins' 는 확장 소유 prefix(api.modules.{id}.*)로도, 코어 확장관리 // 리소스(api.admin.modules.*)로도 쓰인다. 소유 prefix 는 owner['id'] 스킵으로 이미 // 걷히므로, leading 에는 순수 컨텍스트(api/admin/user/me/public)만 둔다. - $leading = ['api', 'admin', 'user', 'me', 'public']; + // 'web' 은 확장 PG 콜백/웹훅 라우트명(web.plugins.{id}.*)의 leading 컨텍스트다. + $leading = ['api', 'web', 'admin', 'user', 'me', 'public']; $segments = array_values(array_filter(explode('.', $name), fn ($s) => $s !== '')); $i = 0; diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md index 552f54a0..5637b14f 100644 --- a/docs/backend/api/README.md +++ b/docs/backend/api/README.md @@ -229,7 +229,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } > 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다. -- **확장 수**: 13 · **엔드포인트 수**: 398 +- **확장 수**: 14 · **엔드포인트 수**: 402 | 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 | | --- | --- | --- | --- | @@ -244,6 +244,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } | `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 | | `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 | | `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 | +| `sirsoft-tosspayments` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-tosspayments/docs/api/README.md) | 2 / 4 | | `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 | | `sirsoft-verification_nhnkcp` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/README.md) | 1 / 1 | diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/CHANGELOG.md index 6f8d1a7d..da9ea40c 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/CHANGELOG.md @@ -9,6 +9,8 @@ ### Added - 결제 화면 프론트엔드 텍스트 일본어 번역 추가 — 결제 관련 화면이 일본어 로케일에서 자연스럽게 표시됩니다. +- 주문서형 결제수단(카드·가상계좌·계좌이체·휴대폰·간편결제 9종)의 이름과 설명 일본어 번역 추가 — 일본어 로케일 주문서에서 결제수단이 일본어로 표시됩니다. +- 관리자 설정 화면의 결제 방식·가상계좌·에스크로 항목과 입력값 오류 안내 일본어 번역 추가. ## [1.0.0-beta.1] - 2026-05-11 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 index 31a17594..0a14d5c8 100644 --- 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 @@ -5,4 +5,8 @@ return [ 'missing_payment_key' => 'Toss Payments の決済キーが存在しないため、返金処理ができません。', 'default_reason' => '顧客リクエストによるキャンセル', ], + 'settings_validation' => [ + 'vbank_valid_hours_range' => '仮想口座の入金期限は :min~:max時間(最大90日)の間である必要があります。', + 'use_escrow_invalid' => 'エスクロー使用設定値が正しくありません。', + ], ]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/payment_methods.php b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/payment_methods.php new file mode 100644 index 00000000..29c8425d --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/payment_methods.php @@ -0,0 +1,40 @@ + [ + 'name' => 'クレジットカード (Toss Payments)', + 'description' => 'クレジット·デビットカードで決済 — Toss Paymentsを通じて処理', + ], + 'toss_virtual_account' => [ + 'name' => '仮想口座 (Toss Payments)', + 'description' => '発行された仮想口座への入金 — Toss Paymentsを通じて処理', + ], + 'toss_transfer' => [ + 'name' => '口座振替 (Toss Payments)', + 'description' => 'リアルタイム口座振替で決済 — Toss Paymentsを通じて処理', + ], + 'toss_mobile_phone' => [ + 'name' => '携帯電話決済 (Toss Payments)', + 'description' => '携帯電話小額決済 — Toss Paymentsを通じて処理', + ], + 'toss_tosspay' => [ + 'name' => 'Toss Pay (Toss Payments)', + 'description' => 'Toss Pay簡便決済 — Toss Paymentsを通じて処理', + ], + 'toss_kakaopay' => [ + 'name' => 'Kakao Pay (Toss Payments)', + 'description' => 'Kakao Pay簡便決済 — Toss Paymentsを通じて処理', + ], + 'toss_naverpay' => [ + 'name' => 'NAVER Pay (Toss Payments)', + 'description' => 'NAVER Pay簡便決済 — Toss Paymentsを通じて処理', + ], + 'toss_payco' => [ + 'name' => 'Payco (Toss Payments)', + 'description' => 'Payco簡便決済 — Toss Paymentsを通じて処理', + ], + 'toss_samsungpay' => [ + 'name' => 'Samsung Pay (Toss Payments)', + 'description' => 'Samsung Pay簡便決済 — 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 index a594a4c3..cd6583b2 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/frontend/ja.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/frontend/ja.json @@ -26,7 +26,43 @@ "redirect_fail_url": "決済失敗リダイレクトURL", "redirect_fail_url_hint": "相対パス または完全URL の両方が可能です。エラー情報(error, message, orderId)はクエリパラメーターとして自動追加されます。", "save": "保存", - "saved": "設定が保存されました。" + "saved": "設定が保存されました。", + "section_payment_methods": "決済方法", + "order_sheet_mode": "注文書形式の決済", + "order_sheet_mode_hint": "有効にするとチェックアウト時に決済方法を直接選択します。無効にするとToss統合決済ウィンドウ(カード)で処理されます。", + "enabled_methods": "表示する決済方法", + "enabled_methods_hint": "注文書形式の決済時にチェックアウトに表示する決済方法を選択します。", + "method_card": "カード", + "method_virtual_account": "仮想口座", + "method_transfer": "口座振替", + "method_mobile_phone": "携帯電話", + "method_tosspay": "Tossペイ", + "method_kakaopay": "KakaoPayカード", + "method_naverpay": "Naver Pay", + "method_payco": "PayCo", + "method_samsungpay": "Samsung Pay", + "section_virtual_account": "仮想口座", + "vbank_valid_hours": "入金期限(時間)", + "vbank_valid_hours_hint": "仮想口座発行後の入金可能時間です。最大2160時間(90日)です。", + "vbank_cash_receipt_type": "現金領収書自動発行タイプ", + "vbank_cash_receipt_type_hint": "仮想口座発行時にTossが自動発行する現金領収書のタイプです。", + "vbank_cash_receipt_none": "発行しない", + "vbank_cash_receipt_income": "所得控除", + "vbank_cash_receipt_expense": "支出証明", + "use_escrow": "エスクロー使用", + "use_escrow_hint": "仮想口座・口座振替決済にのみ適用されます。", + "use_escrow_off": "使用しない", + "use_escrow_on": "強制使用", + "use_escrow_buyer_choice": "購入者選択", + "escrow_no_partial_cancel_notice": "エスクロー注文は部分キャンセルができません。全体キャンセルのみ可能です。", + "escrow_shipping_info_notice": "配送情報はToss店舗管理者で登録してください。", + "escrow_shipping_info_link": "リンク ↗", + "webhook_secret_verify": "ウェブフック secret 検証", + "webhook_secret_verify_hint": "仮想口座入金通知ウェブフックの secret を決済承認レスポンスと照合し、不正なリクエストをブロックします。", + "webhook_url_label": "仮想口座入金通知ウェブフック URL", + "webhook_url_hint": "上記のパスの前に店舗全体のドメイン(https://myドメイン)を付けて、Toss開発者センター > ウェブフック メニューの DEPOSIT_CALLBACK イベントとして登録してください。", + "webhook_url_copy": "コピー", + "webhook_url_copied": "ウェブフック URL がコピーされました。" }, "payment_error_title": "決済エラー", "payment_cancel_title": "決済キャンセル", @@ -35,6 +71,7 @@ "confirm_failed": "決済承認に失敗しました。", "amount_mismatch": "決済金額が一致しません。", "order_not_found": "注文が見つかりません。", - "payment_failed": "決済に失敗しました。" + "payment_failed": "決済に失敗しました。", + "non_krw_method": "この決済方法はウォン(KRW)決済にのみ使用できます。" } } diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index c64d29c1..cb5180b6 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -46,6 +46,10 @@ - 주문에 현금성 금액이 기록됩니다. 무통장입금 주문에서 마일리지를 사용한 경우, 실제로 계좌에 입금된 금액만 현금영수증 발급 대상이 됩니다. 주문을 일부 취소하면 남은 실입금액 기준으로 다시 계산됩니다. - 배송비 과세 방식을 이커머스 환경설정에서 고를 수 있습니다 — 안분(기본, 과세상품 비율만큼 배송비를 과세로 계산) / 전액 과세 / 주된 재화를 따름 중 상점의 세무 판단에 맞춰 선택합니다. +#### 결제수단 + +- 결제 플러그인이 등록한 결제수단(예: "가상계좌 (토스페이먼츠)")을 주문서에서 선택해 주문할 수 있습니다. 이런 결제수단은 화면에서는 독립된 항목으로 보이지만 주문에는 대응하는 기본 결제수단(가상계좌·계좌이체·휴대폰·카드)으로 기록되어, 입금 확인·취소·환불 등 이후 처리가 정상적으로 이어집니다. + #### 사이트맵·알림·마일리지 - 사이트맵에 담을 상품·카테고리 수의 안전 상한을 지원합니다. 상한을 설정하면 상품 목록·카테고리·상품 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md index 65124ade..19617587 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/orders.md @@ -1703,6 +1703,12 @@ Content-Type: application/json 주문 확정 시점에는 재고·구매대상제한·배송국가·쿠폰 유효성과 함께 **마일리지 사용 정책**도 현재 설정 기준으로 재검증합니다. 임시 주문을 만든 뒤 관리자가 한도를 강화했거나 임시 주문이 조작된 경우 `422`(`errors.code = mileage_usage_not_allowed`)로 차단되며 주문은 생성되지 않습니다. 반대로 정상 생성된 주문에는 그 시점의 사용 정책이 `mileage_policy_snapshot` 으로 고정되어, 이후 설정이 바뀌어도 해당 주문의 판정 근거를 재현할 수 있습니다(통화·프로모션·배송정책 스냅샷과 동일 취지). +**`pg_payment_data` 응답 필드 (PG 결제 주문 한정)** — 결제수단이 PG(카드·가상계좌·계좌이체 등)인 주문은 응답에 `pg_payment_handler`(프론트가 호출할 결제 핸들러 식별자)와 `pg_payment_data` 객체가 함께 내려갑니다. `pg_payment_data`는 PG SDK 결제창 호출에 필요한 값(`order_number`, `order_name`, `amount`, `currency`, `success_url`, `fail_url`, `customer_email`, `customer_phone`, `customer_key` 등)을 담습니다. 이 객체는 결제수단·PG에 따라 동적으로 조립되므로 자동 실측 문서화 대상이 아닙니다. 다음 필드는 프로바이더 비의존적으로 항상 포함됩니다: + +| 필드 | 타입 | 용도 | +| --- | --- | --- | +| `escrow_products` | array | 에스크로 결제(가상계좌·계좌이체)에서 필수인 상품 상세 배열. 각 원소는 `{id, name, code, unitPrice, quantity}` 형식이며 `unitPrice`는 개당가(합계 아님), `name`은 현재 로케일로 로컬라이즈됩니다. 에스크로 사용 여부는 PG 프론트에서 결정하므로 항상 조립되어 내려가고, 비에스크로 결제는 이 필드를 무시합니다. | + ### GET /api/modules/sirsoft-ecommerce/user/orders/{id} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php index e1217fb5..c1b70d96 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Traits/HandlesOrderCreation.php @@ -328,6 +328,40 @@ trait HandlesOrderCreation // (예: kginicis_lpay → gopaymethod=LPAY). 서버가 확장 ID 를 1급 시민으로 // 저장하게 되면서 프론트 인터셉터가 원본 수단을 따로 전달할 필요가 없어졌다(#475). 'payment_method' => $order->payment?->paymentMethodId(), + // 에스크로 결제(가상계좌·계좌이체) 시 필수인 상품 상세 배열. PG 가 사용 여부를 + // 프론트에서 결정하므로 provider-agnostic 하게 항상 조립한다 (비에스크로는 무시). + 'escrow_products' => $this->buildEscrowProducts($order, $locale), ]; } + + /** + * 에스크로 결제용 상품 상세 배열을 구성합니다. + * + * 토스 SDK 의 escrowProducts 파라미터 형식 {id, name, code, unitPrice, quantity} 에 맞춘다. + * unitPrice 는 개당가(합계 아님)이며, name 은 현재 로케일로 로컬라이즈한다. + * 에스크로는 국내(KRW) 전용이므로 unitPrice 는 base(KRW) 정수를 그대로 쓴다. + * + * @param Order $order 주문 (options 로드됨) + * @param string $locale 현재 로케일 + * @return array + */ + protected function buildEscrowProducts(Order $order, string $locale): array + { + $fallback = config('app.fallback_locale', 'ko'); + + return $order->options->map(function ($option) use ($locale, $fallback) { + $name = $option->product_name; + $localizedName = is_array($name) + ? ($name[$locale] ?? $name[$fallback] ?? reset($name) ?: '') + : ($name ?? ''); + + return [ + 'id' => (string) $option->product_option_id, + 'name' => $localizedName, + 'code' => (string) $option->product_option_id, + 'unitPrice' => (int) round((float) $option->unit_price), + 'quantity' => (int) $option->quantity, + ]; + })->values()->all(); + } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderPaymentRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderPaymentRepositoryInterface.php index a70f771c..ab9c855d 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderPaymentRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/OrderPaymentRepositoryInterface.php @@ -90,4 +90,16 @@ interface OrderPaymentRepositoryInterface string $accountNumber, string $holder, ): OrderPayment; + + /** + * 동일 PG 거래 ID 가 이미 결제완료(PAID) 상태로 저장되어 있는지 확인합니다. + * + * PG 웹훅/콜백의 리플레이(중복 통보) 멱등 처리에 사용한다. transaction_id 컬럼에는 + * DB unique 제약이 없으므로, 중복 콜백이 completePayment 를 두 번 실행해 중복 + * 적립·알림이 발생하는 것을 방지하기 위해 콜백 진입 시점에 조회한다. + * + * @param string|null $transactionId PG 거래 ID (빈 값이면 false) + * @return bool 이미 PAID 로 저장된 거래이면 true + */ + public function isTransactionPaid(?string $transactionId): bool; } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderPaymentRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderPaymentRepository.php index 4e307915..03e6764f 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderPaymentRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/OrderPaymentRepository.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Repositories; use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType; use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType; +use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum; use Modules\Sirsoft\Ecommerce\Models\OrderCashReceipt; use Modules\Sirsoft\Ecommerce\Models\OrderPayment; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderPaymentRepositoryInterface; @@ -109,4 +110,19 @@ class OrderPaymentRepository implements OrderPaymentRepositoryInterface return $payment; } + + /** + * {@inheritDoc} + */ + public function isTransactionPaid(?string $transactionId): bool + { + if ($transactionId === null || $transactionId === '') { + return false; + } + + return OrderPayment::query() + ->where('transaction_id', $transactionId) + ->where('payment_status', PaymentStatusEnum::PAID->value) + ->exists(); + } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/EcommerceSettingsService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/EcommerceSettingsService.php index 56330611..9590ab0f 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/EcommerceSettingsService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/EcommerceSettingsService.php @@ -878,7 +878,7 @@ class EcommerceSettingsService implements ModuleSettingsInterface } if ($savedItem) { - $merged[] = array_merge([ + $entry = array_merge([ 'id' => $id, 'pg_provider' => $pgLocked ? ($definition['defaults']['pg_provider'] ?? null) @@ -896,7 +896,7 @@ class EcommerceSettingsService implements ModuleSettingsInterface ], $capabilities); } else { // 신규 결제수단 (기본값 적용) - $merged[] = array_merge([ + $entry = array_merge([ 'id' => $id, 'pg_provider' => $definition['defaults']['pg_provider'] ?? null, 'sort_order' => count($merged) + 1, @@ -911,6 +911,15 @@ class EcommerceSettingsService implements ModuleSettingsInterface '_cached_source' => $definition['source'] ?? 'builtin', ], $capabilities); } + + // 플러그인 결제수단(toss_* 등)이 선언한 코어 결제수단 매핑을 보존한다. + // 프론트가 주문 생성 시 이 값을 payment_method 로 전송해 코어 PaymentMethodEnum 을 만족시킨다 + // (미보존 시 원시 id 전송 → 422). 선언하지 않은 builtin/KG 는 키 자체를 두지 않는다. + if (isset($definition['defaults']['core_payment_method'])) { + $entry['core_payment_method'] = $definition['defaults']['core_payment_method']; + } + + $merged[] = $entry; } // 2. 고아 항목: 저장은 되어있지만 현재 available에 없는 결제수단 @@ -959,6 +968,11 @@ class EcommerceSettingsService implements ModuleSettingsInterface $savedMethods[$index]['_cached_icon'] = $def['icon'] ?? $method['_cached_icon'] ?? null; $savedMethods[$index]['_cached_brand_mark'] = $def['brand_mark'] ?? $method['_cached_brand_mark'] ?? null; $savedMethods[$index]['_cached_source'] = $def['source'] ?? $method['_cached_source'] ?? 'builtin'; + + // 플러그인 결제수단의 코어 결제수단 매핑도 스냅샷해 재조회 응답(프론트 소비)에 남긴다. + if (isset($def['defaults']['core_payment_method'])) { + $savedMethods[$index]['core_payment_method'] = $def['defaults']['core_payment_method']; + } } // 고아 항목은 기존 _cached_* 유지 diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/checkout-plugin-payment-method.spec.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/checkout-plugin-payment-method.spec.ts new file mode 100644 index 00000000..cfb429fc --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/checkout-plugin-payment-method.spec.ts @@ -0,0 +1,524 @@ +/** + * 플러그인 결제수단(toss_* 등) 주문 생성 E2E (#454 S4). + * + * 이 spec 이 지키는 계약은 하나다: + * "플러그인이 등록한 결제수단은 화면에서 독립 항목으로 보이지만, + * 주문 생성 시에는 코어 결제수단(core_payment_method)으로 번역되어 전송된다." + * + * 이 번역이 빠지면 코어 PaymentMethodEnum(card/vbank/dbank/bank/phone/point/deposit/free)이 + * 원시 id(toss_virtual_account)를 거부해 **주문 자체가 422 로 실패**한다 — 결제창에 도달조차 못 한다. + * 실제로 그 결함이 있었고(계획서 §B-4 미이행), 유닛 테스트는 전부 green 이었다: + * 레이아웃 JSON 테스트는 body 표현식을 직접 평가하고, 핸들러 테스트는 SDK 매핑만 본다. + * "체크아웃 카탈로그 → computed 번역 → 서버 검증" 의 실경로는 브라우저에서만 드러난다. + * + * 그래서 본 spec 은 픽스처를 심지 않고 라이브 카탈로그/DOM/네트워크만 신뢰한다. + * PG 결제창(외부 SDK)은 열지 않는다 — 검증 대상은 그 앞단(주문 생성 요청/응답)이다. + * + * @scenario toss_payment_methods_vbank_escrow + * @effects checkout_body_sends_core_payment_method, + * non_mapped_method_falls_back_to_raw_id, + * order_created_with_core_payment_method, + * core_payment_method_preserved_through_settings_merge, + * escrow_products_attached_to_virtual_account_sdk_payload + */ +import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth'; +import type { Page } from '@playwright/test'; + +/** + * CartService 는 저장된 다국어 JSON 이 아니라 현재 로케일로 평탄화된 맵과 대조하므로 ko 로 고정한다. + */ +const CART_LOCALE = 'ko'; + +/** 결제수단 버튼 — iteration 으로 렌더되며 method.id 로 식별된다. */ +const paymentMethod = (page: Page, id: string) => + page.getByTestId(`checkout-payment-method-${id}`); + +/** + * 활성 결제수단 카탈로그를 읽는다. + * + * 이 응답의 core_payment_method 가 곧 템플릿 computed(selectedCorePaymentMethod)의 입력이다. + * 모듈의 병합/스냅샷이 이 키를 떨구면 프론트가 번역할 근거를 잃는다 — 그 회귀를 여기서 잠근다. + */ +async function activePaymentMethods( + page: Page +): Promise> { + return page.evaluate(async (locale) => { + const token = localStorage.getItem('auth_token'); + const res = await fetch('/api/modules/sirsoft-ecommerce/settings/payment', { + headers: { + Accept: 'application/json', + 'Accept-Language': locale, + Authorization: `Bearer ${token}`, + }, + }); + const json = await res.json(); + const methods = json?.data?.order_settings?.payment_methods ?? []; + return methods + .filter((m: { is_active?: boolean }) => m.is_active) + .map((m: { id: string; core_payment_method?: string }) => ({ + id: m.id, + core_payment_method: m.core_payment_method, + })); + }, CART_LOCALE); +} + +/** + * 카트를 비우고 상품 1건을 담은 뒤 임시주문을 생성한다 (체크아웃은 임시주문 없이 열리지 않는다). + * + * 상품 id 를 상수로 박지 않고 목록 API 에서 **판매 중인 상품을 찾아 쓴다**. 시드 데이터는 + * 재설치·재시드마다 id 가 바뀌므로(실제로 product_id=1 이 사라져 spec 이 깨졌다) 라이브 카탈로그를 + * 신뢰한다. option_values 는 저장된 다국어 JSON 이 아니라 **현재 로케일로 평탄화된 맵**과 대조되므로 + * 상품 상세의 옵션 값을 ko 로 풀어서 그대로 되돌려준다. + */ +async function seedCheckout(page: Page): Promise { + const result = await page.evaluate(async (locale) => { + const token = localStorage.getItem('auth_token'); + + // 체크아웃 조회(GET /checkout)는 Auth::id() **와 X-Cart-Key 헤더**로 임시주문을 찾는다 + // (CartKeyRequest::getCartKey() = header('X-Cart-Key')). 이 헤더가 없으면 POST 로 만든 + // 임시주문이 404 로 조회되지 않고, 화면은 "주문 정보가 없습니다" 모달을 띄운다. + // + // 신규 브라우저 컨텍스트에는 g7_cart_key 가 **없다**(실측: localStorage 에 auth_token/g7_locale/ + // g7_cache_version 뿐). 앱은 UI 로 카트에 담을 때 비로소 키를 만든다. API 로만 시드하는 여기서는 + // 키를 직접 만들어 localStorage 와 요청 헤더에 **같은 값**으로 심어야 화면과 서버가 같은 카트를 본다. + let cartKey = localStorage.getItem('g7_cart_key'); + if (!cartKey) { + cartKey = `ck_pw_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; + localStorage.setItem('g7_cart_key', cartKey); + } + + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'Accept-Language': locale, + Authorization: `Bearer ${token}`, + 'X-Cart-Key': cartKey, + }; + + // 1) 판매 중 + 옵션 보유 상품을 찾는다. + const list = await fetch('/api/modules/sirsoft-ecommerce/products?per_page=20', { + headers, + }).then((r) => r.json()); + const products = list?.data?.data ?? list?.data ?? []; + + for (const p of products) { + const detail = await fetch(`/api/modules/sirsoft-ecommerce/products/${p.id}`, { + headers, + }).then((r) => r.json()); + const product = detail?.data ?? detail; + + // 옵션은 `options` 에 있다. `additional_options`(추가옵션)는 **다른 개념**이고 보통 비어 있어, + // 그쪽을 먼저 보면 항상 빈 배열을 집어 상품을 못 찾는다(실측으로 이 함정에 빠졌다). + const option = (product?.options ?? []).find( + (o: { is_active?: boolean; is_sold_out?: boolean; stock_quantity?: number }) => + o.is_active !== false && !o.is_sold_out && (o.stock_quantity ?? 0) > 0 + ); + if (!option) continue; + + // API 가 이미 현재 로케일로 평탄화한 맵을 준다 ({ 색상: '빨강' }) — CartService 가 대조하는 형태와 같다. + const optionValues: Record = option.option_values_localized ?? {}; + if (Object.keys(optionValues).length === 0) continue; + + // 2) 카트를 비우고 담는다. + await fetch('/api/modules/sirsoft-ecommerce/cart/all', { method: 'DELETE', headers }); + const added = await fetch('/api/modules/sirsoft-ecommerce/cart', { + method: 'POST', + headers, + body: JSON.stringify({ + product_id: p.id, + items: [{ option_values: optionValues, quantity: 1 }], + }), + }); + if (!added.ok) continue; // 품절·판매중지 등 — 다음 상품으로 + + const cart = await fetch('/api/modules/sirsoft-ecommerce/cart', { headers }).then((r) => + r.json() + ); + const itemIds = (cart?.data?.items ?? []).map((i: { id: number }) => i.id); + if (itemIds.length === 0) continue; + + // 3) 카트의 "주문하기" 와 동일한 호출 — 임시주문 생성. + const checkout = await fetch('/api/modules/sirsoft-ecommerce/checkout', { + method: 'POST', + headers, + body: JSON.stringify({ item_ids: itemIds }), + }); + return { step: 'checkout', status: checkout.status, body: await checkout.text() }; + } + + return { step: 'product-discovery', status: 0, body: '구매 가능한 옵션 보유 상품을 찾지 못했다' }; + }, CART_LOCALE); + + // status 0(상품 탐색 실패)은 `< 300` 을 **통과해버린다** — 실제로 이 함정에 빠져 + // "임시주문 생성 성공" 으로 오판한 채 한참 헤맸다. 성공 범위를 명시적으로 좁힌다. + expect(result.status, `${result.step} 단계 실패: ${result.body}`).toBeGreaterThanOrEqual(200); + expect(result.status, `${result.step} 단계 실패: ${result.body}`).toBeLessThan(300); + + // 임시주문이 실제로 조회되는지 확인한다 — 조회가 안 되면 체크아웃이 "주문 정보가 없습니다" + // 모달을 띄우고, 그 오버레이가 결제수단 클릭을 가로채 90s 타임아웃으로만 드러난다(디버깅 지옥). + // 여기서 먼저 빠르게 깨뜨린다. + const visible = await page.evaluate(async (locale) => { + const token = localStorage.getItem('auth_token'); + const cartKey = localStorage.getItem('g7_cart_key') ?? ''; + const res = await fetch('/api/modules/sirsoft-ecommerce/checkout?country_code=KR', { + headers: { + Accept: 'application/json', + 'Accept-Language': locale, + Authorization: `Bearer ${token}`, + 'X-Cart-Key': cartKey, + }, + }); + return res.status; + }, CART_LOCALE); + + expect(visible, '임시주문이 생성됐는데 체크아웃 조회가 실패한다 (X-Cart-Key 확인)').toBeLessThan(300); +} + +/** + * GDPR 쿠키 배너를 닫는다 — 전체 화면 오버레이가 클릭을 가로챈다. + * + * 배너는 /consent/cookie/status 응답 후 비동기로 마운트되므로 한 번만 확인하면 "아직 없는 상태"를 + * "없음" 으로 오판한다. 동의 상태는 서버가 SSoT 라 localStorage 를 조작하지 않고 실제로 버튼을 누른다. + * + * ※ `.fixed.inset-0` 만으로 판정하면 안 된다 — 체크아웃 화면에는 배너가 아닌 다른 fixed 오버레이가 + * 상주해 루프가 영원히 안 끝난다(실측). 배너 자신(동의 버튼)의 존재를 기준으로 판정한다. + */ +async function dismissCookieNotice(page: Page): Promise { + const necessaryOnly = page.getByRole('button', { name: /Necessary Only|필수만/i }); + + for (let attempt = 0; attempt < 3; attempt += 1) { + if (!(await necessaryOnly.isVisible().catch(() => false))) { + // 아직 안 떴을 수 있으니 짧게 기다려 본다. 끝내 안 뜨면 이미 동의된 상태다. + const appeared = await necessaryOnly + .waitFor({ state: 'visible', timeout: 3_000 }) + .then(() => true, () => false); + if (!appeared) return; + } + + // 일반 클릭이 다른 오버레이(주문서 없음 모달 등)에 가로막힐 수 있으므로 pointer event 를 + // 우회해 DOM 클릭을 직접 발사한다. 배너를 닫는 것 자체는 검증 대상이 아니라 전제조건이다. + await necessaryOnly.click({ timeout: 5_000 }).catch(async () => { + await necessaryOnly.evaluate((el) => (el as HTMLElement).click()).catch(() => undefined); + }); + + const gone = await necessaryOnly + .waitFor({ state: 'hidden', timeout: 5_000 }) + .then(() => true, () => false); + if (gone) return; + } + + await expect(necessaryOnly, '쿠키 동의 배너가 닫히지 않았다').toBeHidden(); +} + +/** + * 로딩 오버레이가 걷힐 때까지 기다린다. + * + * 체크아웃은 데이터 로딩 중 전체화면 오버레이(`div.fixed.inset-0.flex.items-center.justify-center`)를 + * 띄우고, 이것이 결제수단 버튼 클릭의 pointer event 를 가로챈다 — 버튼 자체는 이미 visible/enabled 라 + * Playwright 가 클릭을 재시도하다 타임아웃한다(실측). 버튼 가시성만으로 준비 완료를 단정하면 안 된다. + */ +async function waitForOverlayGone(page: Page): Promise { + const overlay = page.locator('div.fixed.inset-0.flex.items-center.justify-center'); + await expect + .poll(() => overlay.count(), { timeout: 30_000 }) + .toBe(0) + .catch(() => undefined); +} + +/** 체크아웃으로 이동하고 결제수단 블록이 클릭 가능해질 때까지 기다린다. */ +async function gotoCheckout(page: Page): Promise { + await page.goto('/shop/checkout'); + await page.waitForLoadState('domcontentloaded'); + await dismissCookieNotice(page); + await paymentMethod(page, 'dbank').waitFor({ timeout: 30_000 }); + await waitForOverlayGone(page); +} + +/** + * 테스트 유저의 배송지를 미리 등록한다. + * + * playwright:issue-token 은 매 실행마다 **새 유저**를 만들므로(PlaywrightIssueToken:85 — + * User::factory()->create()) 저장된 배송지가 하나도 없다. 그런데 주소 입력칸(우편번호·주소)은 + * readonly 라 주소검색 팝업으로만 채워지므로, 폼에 직접 타이핑할 수 없다 → 결제하기가 영원히 disabled. + * + * 그래서 배송지는 API 로 미리 심는다. 이 spec 의 검증 대상은 payment_method 번역이지 배송지 입력이 + * 아니므로, 이는 검증 대상 우회가 아니라 **그 앞의 전제조건 구성**이다. + */ +async function seedShippingAddress(page: Page): Promise { + const status = await page.evaluate(async (locale) => { + const token = localStorage.getItem('auth_token'); + const res = await fetch('/api/modules/sirsoft-ecommerce/user/addresses', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'Accept-Language': locale, + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + name: '테스트 배송지', + recipient_name: '홍길동', + recipient_phone: '010-1234-5678', + country_code: 'KR', + zipcode: '06236', + address: '서울특별시 강남구 테헤란로 152', + address_detail: '강남파이낸스센터', + is_default: true, + }), + }); + return res.status; + }, CART_LOCALE); + + expect(status, '테스트 배송지 등록 실패').toBeLessThan(300); +} + +/** + * 주문 제출에 필요한 필수 입력을 채운다. + * + * 저장된 배송지 카드를 고르면 수령인·연락처·주소가 한 번에 채워진다(주소는 readonly 라 이 경로뿐). + * 주문자 연락처만 비어 있으면 직접 채운다. + */ +async function fillRequiredCheckoutFields(page: Page): Promise { + const savedAddress = page.getByRole('button', { name: /테스트 배송지|홍길동/ }).first(); + await savedAddress.waitFor({ state: 'visible', timeout: 15_000 }).catch(() => undefined); + await savedAddress.click().catch(() => undefined); + + const fill = async (name: string, value: string) => { + const input = page.locator(`input[name="${name}"]`); + if ((await input.count()) === 0) return; + if ((await input.inputValue().catch(() => '')) !== '') return; + await input.fill(value).catch(() => undefined); + }; + + await fill('orderer_phone', '010-1234-5678'); + await fill('recipient_name', '홍길동'); + await fill('recipient_phone', '010-1234-5678'); +} + +test.describe('플러그인 결제수단 → 코어 결제수단 번역 (주문 생성)', () => { + test.beforeEach(async ({ page, noPermissionToken }) => { + await authenticatePage(page, noPermissionToken); + await page.addInitScript((locale) => localStorage.setItem('g7_locale', locale), CART_LOCALE); + await page.goto('/shop'); + await page.waitForLoadState('domcontentloaded'); + await dismissCookieNotice(page); + await seedCheckout(page); + }); + + test('결제수단 카탈로그가 플러그인 결제수단의 코어 매핑을 함께 내려준다', async ({ page }) => { + await gotoCheckout(page); + + const methods = await activePaymentMethods(page); + const plugin = methods.filter((m) => m.id.includes('_') && m.core_payment_method); + + // 플러그인 결제수단이 하나도 활성화돼 있지 않으면 이 계약을 검증할 수 없다. + // (조합은 상점 설정 가변 — 활성화 조작은 병렬 워커가 공유하는 설정을 오염시키므로 하지 않는다) + test.skip( + plugin.length === 0, + '활성 플러그인 결제수단 없음 — 주문서형 결제수단을 켠 상점에서만 검증 가능' + ); + + // 코어 매핑은 반드시 코어 PaymentMethodEnum 값이어야 한다 (원시 id 를 그대로 실어보내면 서버가 거부). + const CORE_METHODS = ['card', 'vbank', 'dbank', 'bank', 'phone', 'point', 'deposit', 'free']; + for (const m of plugin) { + expect(CORE_METHODS, `${m.id} 의 core_payment_method 가 코어 enum 값이 아니다`).toContain( + m.core_payment_method + ); + } + }); + + test('코어 매핑이 없는 결제수단(무통장)은 raw id 를 그대로 쓴다', async ({ page }) => { + await gotoCheckout(page); + + const methods = await activePaymentMethods(page); + const dbank = methods.find((m) => m.id === 'dbank'); + + expect(dbank, '무통장입금이 활성 결제수단에 없다').toBeTruthy(); + // 코어 결제수단 자신은 번역 대상이 아니므로 키가 붙지 않는다 (붙으면 폴백 경로가 죽는다). + expect(dbank?.core_payment_method).toBeUndefined(); + }); + + test('플러그인 결제수단으로 주문하면 코어 결제수단으로 전송되어 주문이 생성된다', async ({ + page, + }) => { + // 상품 탐색 → 카트 → 임시주문 → 체크아웃 → 폼 입력 → 주문 생성까지 한 흐름을 다 밟는다. + // 기본 30s 로는 부족하다(실측 ~40s). + test.setTimeout(120_000); + + // 신규 유저라 저장된 배송지가 없다 — 주소칸은 readonly(주소검색 팝업 전용)라 미리 심어야 한다. + await seedShippingAddress(page); + + await gotoCheckout(page); + + const methods = await activePaymentMethods(page); + const target = methods.find((m) => m.core_payment_method); + test.skip( + !target, + '활성 플러그인 결제수단 없음 — 주문서형 결제수단을 켠 상점에서만 검증 가능' + ); + + // 주문 생성 요청 body 를 가로채 payment_method 가 무엇으로 나가는지 본다. + let sentPaymentMethod: string | undefined; + page.on('request', (req) => { + if (req.method() === 'POST' && req.url().includes('/user/orders')) { + try { + sentPaymentMethod = JSON.parse(req.postData() ?? '{}').payment_method; + } catch { + /* body 파싱 실패는 아래 단언에서 undefined 로 드러난다 */ + } + } + }); + + // 쿠키 배너 / 로딩 오버레이는 비동기로 다시 뜰 수 있고, 그 전체화면 오버레이가 결제수단 버튼 + // 클릭의 pointer event 를 가로챈다(버튼은 visible/enabled 인데 클릭만 타임아웃). 클릭 직전에 정리한다. + await dismissCookieNotice(page); + await waitForOverlayGone(page); + + await paymentMethod(page, target!.id).click(); + await fillRequiredCheckoutFields(page); + + // 결제하기가 활성화되어야 제출할 수 있다 (필수값 미충족이면 disabled 로 남아 클릭이 타임아웃된다). + const submit = page.getByRole('button', { name: /결제하기/ }); + await expect(submit, '필수 입력이 채워졌는데도 결제하기가 비활성이다').toBeEnabled({ + timeout: 15_000, + }); + + // 주문 생성 응답까지 기다린다 — 422 면 여기서 상태로 드러난다. + const [response] = await Promise.all([ + page.waitForResponse( + (r) => r.request().method() === 'POST' && r.url().includes('/user/orders'), + { timeout: 30_000 } + ), + submit.click(), + ]); + + // 핵심 계약 1 — 원시 id 가 아니라 코어 값이 나갔다. + expect( + sentPaymentMethod, + `원시 id(${target!.id})가 그대로 전송되면 코어 enum 이 거부해 422 가 된다` + ).toBe(target!.core_payment_method); + + // 핵심 계약 2 — 서버가 그 값을 받아들여 주문이 생성됐다 (422 회귀 잠금). + expect( + response.status(), + `주문 생성 실패(${response.status()}): ${await response.text()}` + ).toBeLessThan(300); + }); + + /** + * 에스크로 가상계좌 결제 시 SDK 로 넘어가는 escrowProducts 를 **직접 캡처**한다. + * + * 왜 서버 응답 실측으로 충분하지 않은가: 서버(buildPgPaymentData)가 escrow_products 를 + * 정확히 조립해 응답에 실어도, 프론트 핸들러가 그것을 SDK 페이로드에 **부착하지 않으면** + * 토스는 에스크로 필수 파라미터 누락으로 결제를 거부한다. 실제로 그 결함이 있었다 — + * 가상계좌 분기가 attachEscrowProducts() 를 호출하지 않아 계좌이체에서만 부착됐다. + * "주문 응답에 escrow_products 가 있다" 는 계약의 **절반**일 뿐이고, 나머지 절반은 + * SDK 경계로 무엇이 넘어가는지다. 여기서 그 경계를 실측한다. + * + * 상점 설정(use_escrow)은 병렬 워커가 공유하므로 건드리지 않는다. 대신 핸들러가 읽는 + * client-config 응답만 라우트 인터셉트로 갈아끼워 에스크로 켜진 상점을 재현한다 — + * 검증 대상은 서버 설정 저장이 아니라 **핸들러의 페이로드 조립 분기**다. + * + * 토스 SDK 는 스텁으로 대체한다. 실제 결제창을 여는 것이 목적이 아니라, + * requestPayment() 에 무엇이 전달되는지가 목적이다. + */ + test('에스크로 가상계좌 결제는 SDK 페이로드에 escrowProducts 를 싣는다', async ({ page }) => { + test.setTimeout(120_000); + + await seedShippingAddress(page); + + // 1) 토스 SDK 스텁 — requestPayment 인자를 window 에 남긴다 (실제 결제창은 열지 않는다). + await page.addInitScript(() => { + const w = window as unknown as Record; + const TossPayments = () => ({ + payment: () => ({ + requestPayment: async (payload: unknown) => { + (window as unknown as Record).__tossPayload = payload; + }, + }), + }); + (TossPayments as unknown as Record).ANONYMOUS = 'ANONYMOUS'; + w.TossPayments = TossPayments; + }); + + // 2) client-config 응답에 에스크로를 켠다 (서버 설정은 그대로 둔다). + await page.route('**/payments/client-config/tosspayments*', async (route) => { + const res = await route.fetch(); + const json = await res.json(); + if (json?.data) json.data.use_escrow = 'on'; + await route.fulfill({ response: res, json }); + }); + + await gotoCheckout(page); + + const methods = await activePaymentMethods(page); + const vbank = methods.find((m) => m.id === 'toss_virtual_account'); + test.skip(!vbank, '토스 가상계좌가 활성이 아님 — 켠 상점에서만 검증 가능'); + + await dismissCookieNotice(page); + await waitForOverlayGone(page); + + await paymentMethod(page, 'toss_virtual_account').click(); + await fillRequiredCheckoutFields(page); + + const submit = page.getByRole('button', { name: /결제하기/ }); + await expect(submit, '필수 입력이 채워졌는데도 결제하기가 비활성이다').toBeEnabled({ + timeout: 15_000, + }); + + // 주문 생성 응답을 받아 escrow_products 가 서버에서 조립됐는지 먼저 확인한다. + const [orderResponse] = await Promise.all([ + page.waitForResponse( + (r) => r.request().method() === 'POST' && r.url().includes('/user/orders'), + { timeout: 30_000 } + ), + submit.click(), + ]); + + expect( + orderResponse.status(), + `주문 생성 실패(${orderResponse.status()}): ${await orderResponse.text()}` + ).toBeLessThan(300); + + const orderBody = await orderResponse.json(); + const serverEscrowProducts = orderBody?.data?.pg_payment_data?.escrow_products; + + // 계약 절반 ①: 서버가 escrow_products 를 조립해 내려준다. + expect( + Array.isArray(serverEscrowProducts) && serverEscrowProducts.length > 0, + '서버가 pg_payment_data.escrow_products 를 조립하지 않았다' + ).toBe(true); + + // 3) 핸들러가 SDK 를 호출할 때까지 기다린 뒤 페이로드를 읽는다. + await expect + .poll( + () => page.evaluate(() => (window as unknown as Record).__tossPayload), + { timeout: 20_000, message: '토스 SDK requestPayment 가 호출되지 않았다' } + ) + .toBeTruthy(); + + const payload = (await page.evaluate( + () => (window as unknown as Record).__tossPayload + )) as { + method?: string; + virtualAccount?: { useEscrow?: boolean }; + escrowProducts?: Array<{ id: string; unitPrice: number; quantity: number }>; + }; + + expect(payload.method, '가상계좌를 골랐는데 SDK method 가 VIRTUAL_ACCOUNT 가 아니다').toBe( + 'VIRTUAL_ACCOUNT' + ); + expect(payload.virtualAccount?.useEscrow, 'use_escrow=on 인데 useEscrow 가 true 가 아니다').toBe( + true + ); + + // 계약 절반 ②(회귀 잠금) — SDK 페이로드에 escrowProducts 가 실제로 실렸다. + // 이것이 빠지면 토스가 에스크로 필수 파라미터 누락으로 결제를 거부한다. + expect( + payload.escrowProducts, + '가상계좌 + 에스크로인데 SDK 페이로드에 escrowProducts 가 없다 (E2 위반)' + ).toBeTruthy(); + expect(payload.escrowProducts!.length).toBe(serverEscrowProducts.length); + expect(payload.escrowProducts![0].unitPrice).toBe(serverEscrowProducts[0].unitPrice); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Http/Controllers/Public/BuildPgPaymentDataTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Http/Controllers/Public/BuildPgPaymentDataTest.php index c122899d..b3e6a53f 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Http/Controllers/Public/BuildPgPaymentDataTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Http/Controllers/Public/BuildPgPaymentDataTest.php @@ -16,6 +16,9 @@ use ReflectionMethod; * - 주문명 로컬라이즈 * - 배송지 주소 기반 고객 정보 * - 결제 금액/통화 + * - 에스크로 상품 상세(escrow_products) + * + * @effects escrow_products_localized_unit_price_per_item */ class BuildPgPaymentDataTest extends ModuleTestCase { @@ -305,4 +308,74 @@ class BuildPgPaymentDataTest extends ModuleTestCase $this->assertEquals('JPY', $result['currency']); $this->assertEquals(942, $result['amount']); } + + // ────────────────────────────────────────────── + // 에스크로 상품 상세 (escrow_products) — 토스 에스크로 필수 파라미터 + // ────────────────────────────────────────────── + + /** + * escrow_products 가 주문 옵션 수만큼, 개당가·수량·로컬라이즈 상품명으로 구성되는지 확인. + */ + public function test_escrow_products_옵션별_개당가_수량_상품명(): void + { + app()->setLocale('ko'); + + $user = $this->createUser(); + $order = Order::factory()->forUser($user)->create([ + 'total_due_amount' => 30000, + 'currency_snapshot' => ['order_currency' => 'KRW'], + ]); + + OrderOption::factory()->forOrder($order)->create([ + 'product_name' => ['ko' => '상품 A', 'en' => 'Product A'], + 'unit_price' => 10000, + 'quantity' => 2, + ]); + OrderOption::factory()->forOrder($order)->create([ + 'product_name' => ['ko' => '상품 B', 'en' => 'Product B'], + 'unit_price' => 5000, + 'quantity' => 2, + ]); + + OrderAddress::factory()->shipping()->forOrder($order)->create(); + + $result = $this->callBuildPgPaymentData($order->fresh()); + + $this->assertArrayHasKey('escrow_products', $result); + $this->assertCount(2, $result['escrow_products']); + + $first = $result['escrow_products'][0]; + $this->assertSame('상품 A', $first['name']); + // unitPrice 는 개당가 (합계 아님) + $this->assertSame(10000, $first['unitPrice']); + $this->assertSame(2, $first['quantity']); + $this->assertArrayHasKey('id', $first); + $this->assertArrayHasKey('code', $first); + } + + /** + * escrow_products 상품명이 영어 로케일에서 로컬라이즈되는지 확인. + */ + public function test_escrow_products_상품명_영어_로컬라이즈(): void + { + app()->setLocale('en'); + + $user = $this->createUser(); + $order = Order::factory()->forUser($user)->create([ + 'total_due_amount' => 10000, + 'currency_snapshot' => ['order_currency' => 'KRW'], + ]); + + OrderOption::factory()->forOrder($order)->create([ + 'product_name' => ['ko' => '상품 A', 'en' => 'Product A'], + 'unit_price' => 10000, + 'quantity' => 1, + ]); + + OrderAddress::factory()->shipping()->forOrder($order)->create(); + + $result = $this->callBuildPgPaymentData($order->fresh()); + + $this->assertSame('Product A', $result['escrow_products'][0]['name']); + } } diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/OrderPaymentRepositoryTransactionPaidTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/OrderPaymentRepositoryTransactionPaidTest.php new file mode 100644 index 00000000..2ecf706a --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/OrderPaymentRepositoryTransactionPaidTest.php @@ -0,0 +1,62 @@ +repository = app(OrderPaymentRepositoryInterface::class); + } + + /** + * 지정한 transaction_id / 상태로 결제 레코드를 만든다. + */ + private function makePayment(string $transactionId, PaymentStatusEnum $status): void + { + $order = Order::factory()->create(); + OrderPayment::factory()->forOrder($order)->create([ + 'transaction_id' => $transactionId, + 'payment_status' => $status, + ]); + } + + public function test_returns_true_when_transaction_is_paid(): void + { + $this->makePayment('txn_paid', PaymentStatusEnum::PAID); + + $this->assertTrue($this->repository->isTransactionPaid('txn_paid')); + } + + public function test_returns_false_when_transaction_not_paid(): void + { + $this->makePayment('txn_waiting', PaymentStatusEnum::WAITING_DEPOSIT); + + $this->assertFalse($this->repository->isTransactionPaid('txn_waiting')); + } + + public function test_returns_false_for_unknown_transaction(): void + { + $this->assertFalse($this->repository->isTransactionPaid('does_not_exist')); + } + + public function test_returns_false_for_null_or_empty(): void + { + $this->assertFalse($this->repository->isTransactionPaid(null)); + $this->assertFalse($this->repository->isTransactionPaid('')); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/EcommerceSettingsOrderSettingsTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/EcommerceSettingsOrderSettingsTest.php index f25c57ed..99b46c3f 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/EcommerceSettingsOrderSettingsTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/EcommerceSettingsOrderSettingsTest.php @@ -5,8 +5,8 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services; use App\Extension\HookManager; use Illuminate\Support\Facades\File; use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService; +use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase; use ReflectionClass; -use Tests\TestCase; /** * 이커머스 모듈 주문설정(order_settings) 카테고리 테스트 @@ -20,7 +20,7 @@ use Tests\TestCase; * - bank_accounts CRUD * - getFrontendSettings() bank_name 해석 */ -class EcommerceSettingsOrderSettingsTest extends TestCase +class EcommerceSettingsOrderSettingsTest extends ModuleTestCase { private EcommerceSettingsService $service; @@ -306,6 +306,87 @@ class EcommerceSettingsOrderSettingsTest extends TestCase $this->assertNull($dbank['_cached_brand_mark']); } + /** + * 플러그인 결제수단이 defaults.core_payment_method 를 선언하면 병합 결과에 보존되어야 한다. + * + * 배경(#454): toss_* 등 플러그인 결제수단은 코어 PaymentMethodEnum 이 거부하므로, 프론트가 + * 주문 생성 시 이 core 값을 payment_method 로 전송해야 한다. 병합이 이 필드를 떨구면 + * 프론트가 번역 근거를 잃어 원시 id 를 전송 → 422. _cached_* 처럼 provider-agnostic 하게 보존한다. + */ + public function test_plugin_payment_method_core_payment_method_preserved_after_merge(): void + { + $this->addPaymentMethodFilter(function (array $methods) { + $methods[] = [ + 'id' => 'toss_virtual_account', + 'name' => ['ko' => '가상계좌 (토스페이먼츠)', 'en' => 'Virtual Account (Toss)'], + 'description' => ['ko' => '', 'en' => ''], + 'icon' => 'building-columns', + 'source' => 'plugin:sirsoft-tosspayments', + 'defaults' => [ + 'pg_provider' => null, + 'is_active' => false, + 'min_order_amount' => 0, + 'stock_deduction_timing' => 'payment_complete', + 'core_payment_method' => 'vbank', + ], + ]; + + return $methods; + }); + + $this->service->clearCache(); + $settings = $this->service->getSettings('order_settings'); + + $toss = collect($settings['payment_methods'])->firstWhere('id', 'toss_virtual_account'); + $this->assertNotNull($toss); + $this->assertSame('vbank', $toss['core_payment_method'] ?? null, '병합이 core_payment_method 를 떨궜습니다.'); + + // core_payment_method 를 선언하지 않은 builtin 은 이 키가 없어야 한다 (KG 인터셉터 방식 무영향). + $dbank = collect($settings['payment_methods'])->firstWhere('id', 'dbank'); + $this->assertArrayNotHasKey('core_payment_method', $dbank); + } + + /** + * 저장(snapshot) 후에도 플러그인 결제수단의 core_payment_method 가 보존되어야 한다. + * + * 사용자가 결제수단을 저장하면 snapshotPaymentMethodMetadata 가 _cached_* 를 재적재하는데, + * 이때 core_payment_method 도 함께 스냅샷되어야 재조회 응답(프론트 소비)에 남는다. + */ + public function test_plugin_payment_method_core_payment_method_preserved_after_save(): void + { + $this->addPaymentMethodFilter(function (array $methods) { + $methods[] = [ + 'id' => 'toss_virtual_account', + 'name' => ['ko' => '가상계좌 (토스페이먼츠)', 'en' => 'Virtual Account (Toss)'], + 'description' => ['ko' => '', 'en' => ''], + 'icon' => 'building-columns', + 'source' => 'plugin:sirsoft-tosspayments', + 'defaults' => [ + 'pg_provider' => null, + 'is_active' => true, + 'min_order_amount' => 0, + 'stock_deduction_timing' => 'payment_complete', + 'core_payment_method' => 'vbank', + ], + ]; + + return $methods; + }); + + $this->saveOrderSettings([ + 'payment_methods' => [ + ['id' => 'dbank', 'sort_order' => 1, 'is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'order_placed'], + ['id' => 'toss_virtual_account', 'sort_order' => 2, 'is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'], + ], + ]); + $this->service->clearCache(); + + $settings = $this->service->getSettings('order_settings'); + $toss = collect($settings['payment_methods'])->firstWhere('id', 'toss_virtual_account'); + $this->assertNotNull($toss); + $this->assertSame('vbank', $toss['core_payment_method'] ?? null, '저장 스냅샷이 core_payment_method 를 떨궜습니다.'); + } + // ────────────────────────────────────────────── // 결제수단 병합 (사용자 저장 설정 오버라이드) // ────────────────────────────────────────────── diff --git a/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md b/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md index 033fafa6..a33771cf 100644 --- a/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md @@ -10,9 +10,28 @@ - 레이아웃 편집기 데이터 소스 목록에서 이 확장이 제공하는 데이터 소스가 친화 명칭으로 표시되고, 어느 확장이 제공했는지 출처가 함께 표시됩니다. +#### 결제 방식 + +- 주문서형 결제 모드를 지원합니다. 켜면 체크아웃에서 카드·가상계좌·계좌이체·휴대폰·간편결제(토스페이·카카오페이·네이버페이·페이코·삼성페이) 중 원하는 결제수단을 직접 선택할 수 있고, 끄면 기존처럼 통합결제창 하나로 결제합니다. +- 설정 화면에서 노출할 결제수단을 개별로 켜고 끌 수 있습니다. +- 원화(KRW) 외 통화 주문에서는 카드 결제만 허용하고 국내 전용 수단은 자동으로 차단합니다. + +#### 가상계좌 + +- 가상계좌 결제를 지원합니다. 입금 기한(시간)과 발급 시 자동 현금영수증 유형을 설정할 수 있습니다. +- 입금이 완료되면 토스 입금통보를 받아 주문을 자동으로 결제완료 처리합니다. 위조 요청을 막기 위한 secret 검증을 제공하며, 설정 화면에서 등록할 웹훅 URL을 복사할 수 있습니다. +- 가상계좌 입금기한과 에스크로 사용 설정을 저장할 때 허용 범위를 서버에서 검증합니다. 입금기한이 1~2160시간(최대 90일)을 벗어나면 저장되지 않고 안내 메시지가 표시됩니다 — 잘못된 값이 저장되어 결제창 호출이 실패하는 것을 막습니다. + +#### 에스크로 + +- 가상계좌·계좌이체 결제에 에스크로(구매안전서비스)를 적용할 수 있습니다. 사용 안 함 / 강제 사용 / 구매자 선택 중에서 고를 수 있습니다. +- 에스크로 결제 시 결제창에 상품 정보(상품명·단가·수량)를 함께 전달합니다. 가상계좌와 계좌이체 모두에 적용됩니다. +- 에스크로를 켜면 설정 화면에 운영 안내가 표시됩니다 — 에스크로 주문은 부분취소가 불가하다는 점과, 배송정보는 토스 상점관리자에서 등록해야 한다는 점을 안내하며 상점관리자로 바로 이동할 수 있습니다. + ### Changed - 플러그인 환경설정 화면의 하단 저장 버튼이 스크롤 중에도 화면에 고정되도록 개선. +- sirsoft-ecommerce 최소 버전을 1.1.0 으로 상향 (현금영수증 프로바이더 훅 축 · 결제수단 확장점 의존). ## [1.0.0-beta.3] - 2026-05-11 diff --git a/plugins/_bundled/sirsoft-tosspayments/components.json b/plugins/_bundled/sirsoft-tosspayments/components.json new file mode 100644 index 00000000..fe1b2d2c --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/components.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "identifier": "sirsoft-tosspayments", + "version": "1.0.0-beta.4", + "components": { + "basic": [], + "composite": [], + "layout": [] + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/config/settings/defaults.json b/plugins/_bundled/sirsoft-tosspayments/config/settings/defaults.json index 7a643d4b..3cad2226 100644 --- a/plugins/_bundled/sirsoft-tosspayments/config/settings/defaults.json +++ b/plugins/_bundled/sirsoft-tosspayments/config/settings/defaults.json @@ -10,7 +10,21 @@ "live_client_key": "", "live_secret_key": "", "redirect_success_url": "/shop/orders/{orderId}/complete", - "redirect_fail_url": "/shop/checkout" + "redirect_fail_url": "/shop/checkout", + "order_sheet_mode": false, + "method_card": true, + "method_virtual_account": false, + "method_transfer": false, + "method_mobile_phone": false, + "method_tosspay": false, + "method_kakaopay": false, + "method_naverpay": false, + "method_payco": false, + "method_samsungpay": false, + "vbank_valid_hours": 24, + "vbank_cash_receipt_type": "", + "use_escrow": "off", + "webhook_secret_verify": true }, "frontend_schema": { "is_test_mode": { "expose": false }, @@ -19,6 +33,20 @@ "live_client_key": { "expose": false }, "live_secret_key": { "expose": false }, "redirect_success_url": { "expose": false }, - "redirect_fail_url": { "expose": false } + "redirect_fail_url": { "expose": false }, + "order_sheet_mode": { "expose": false }, + "method_card": { "expose": false }, + "method_virtual_account": { "expose": false }, + "method_transfer": { "expose": false }, + "method_mobile_phone": { "expose": false }, + "method_tosspay": { "expose": false }, + "method_kakaopay": { "expose": false }, + "method_naverpay": { "expose": false }, + "method_payco": { "expose": false }, + "method_samsungpay": { "expose": false }, + "vbank_valid_hours": { "expose": false }, + "vbank_cash_receipt_type": { "expose": false }, + "use_escrow": { "expose": false }, + "webhook_secret_verify": { "expose": false } } } diff --git a/plugins/_bundled/sirsoft-tosspayments/dist/js/plugin.iife.js b/plugins/_bundled/sirsoft-tosspayments/dist/js/plugin.iife.js index baf03d6f..afd75f6b 100644 --- a/plugins/_bundled/sirsoft-tosspayments/dist/js/plugin.iife.js +++ b/plugins/_bundled/sirsoft-tosspayments/dist/js/plugin.iife.js @@ -1,2 +1,2 @@ -(function(){"use strict";function f(t){return new Promise((n,e)=>{if(document.querySelector(`script[src="${t}"]`)){n();return}const o=document.createElement("script");o.src=t,o.async=!0,o.onload=()=>n(),o.onerror=()=>e(new Error(`Failed to load script: ${t}`)),document.head.appendChild(o)})}async function y(t,n){const{pgPaymentData:e}=t.params||{};if(!e){console.error("[sirsoft-tosspayments] pgPaymentData is required");return}const o=window.G7Core;try{const r=await o.api.get("/modules/sirsoft-ecommerce/payments/client-config/tosspayments");if(!r.data){console.error("[sirsoft-tosspayments] Failed to fetch client config",r);return}const s=r.data;if(window.TossPayments||await f(s.sdk_url),window.TossPayments||await new Promise(w=>setTimeout(w,100)),!window.TossPayments){console.error("[sirsoft-tosspayments] TossPayments SDK not available");return}const p=window.TossPayments(s.client_key).payment({customerKey:e.customer_key??window.TossPayments.ANONYMOUS}),u=window.location.origin;await p.requestPayment({method:"CARD",amount:{currency:e.currency??"KRW",value:e.amount},orderId:e.order_number,orderName:e.order_name,successUrl:u+s.callback_urls.success,failUrl:u+s.callback_urls.fail,customerEmail:e.customer_email??void 0,customerName:e.customer_name??void 0,customerMobilePhone:e.customer_phone??void 0,card:{useEscrow:!1,flowMode:"DEFAULT",useCardPoint:!1,useAppCardOnly:!1}})}catch(r){if(console.error("[sirsoft-tosspayments] requestPayment error",r),r?.code==="USER_CANCEL"){console.info("[sirsoft-tosspayments] Payment cancelled by user");try{await o.api.post(`/modules/sirsoft-ecommerce/orders/${e.order_number}/cancel-payment`,{cancel_code:r.code,cancel_message:r.message})}catch(c){console.warn("[sirsoft-tosspayments] Failed to record cancellation",c)}o?.state?.setLocal?.({isSubmittingOrder:!1}),o?.modal?.open?.("tosspayments_payment_cancel_modal");return}const s=r?.message??"Unknown error";o?.state?.setLocal?.({paymentErrorMessage:s,isSubmittingOrder:!1}),o?.modal?.open?.("tosspayments_payment_error_modal")}}const l={requestPayment:y},a="sirsoft-tosspayments",i={info:(...t)=>console.info(`[${a}]`,...t),warn:(...t)=>console.warn(`[${a}]`,...t),error:(...t)=>console.error(`[${a}]`,...t)};function m(){const t=window.G7Core;if(!t)return 0;const n=t.getActionDispatcher;if(typeof n!="function")return 0;const e=n();if(!e||typeof e.registerHandler!="function")return 0;let o=0;for(const[r,s]of Object.entries(l)){const c=`${a}.${r}`;e.registerHandler(c,s,{category:"plugin",source:a}),o++}return o}function d(){const t=()=>{const n=m();if(n>0){i.info(`${n} handler(s) registered`);return}let e=0;const o=50,r=setInterval(()=>{e++;const s=m();if(s>0){clearInterval(r),i.info(`${s} handler(s) registered (after ${e} retries)`);return}e>=o&&(clearInterval(r),i.warn("ActionDispatcher not available after timeout"))},100)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",t):t()}d(),window.__SirsoftTosspayments={identifier:a,handlers:Object.keys(l),initPlugin:d}})(); +(function(){"use strict";function b(t){return new Promise((r,o)=>{if(document.querySelector(`script[src="${t}"]`)){r();return}const e=document.createElement("script");e.src=t,e.async=!0,e.onload=()=>r(),e.onerror=()=>o(new Error(`Failed to load script: ${t}`)),document.head.appendChild(e)})}function C(t,r){if(!t.order_sheet_mode||!r)return{method:"CARD",easyPay:null};const o=(t.enabled_methods??[]).find(e=>e.id===r);return o?{method:o.method,easyPay:o.easy_pay_provider}:{method:"CARD",easyPay:null}}function p(t){if(t==="on")return!0;if(t==="off")return!1}function E(t,r){const o={validHours:t.vbank?.valid_hours??24},e=t.vbank?.cash_receipt_type??"";e&&(o.cashReceipt={type:e});const s=p(t.use_escrow);return s!==void 0&&(o.useEscrow=s),o}function w(t,r,o){if(r.use_escrow==="off")return;const e=o.escrow_products??[];e.length>0&&(t.escrowProducts=e)}async function A(t,r){const o=t.params||{},{pgPaymentData:e}=o;if(!e){console.error("[sirsoft-tosspayments] pgPaymentData is required");return}const s=window.G7Core,l=o.paymentMethod??s?.state?.getLocal?.()?.paymentMethod;try{const n=await s.api.get("/modules/sirsoft-ecommerce/payments/client-config/tosspayments");if(!n.data){console.error("[sirsoft-tosspayments] Failed to fetch client config",n);return}const a=n.data;if(window.TossPayments||await b(a.sdk_url),window.TossPayments||await new Promise(u=>setTimeout(u,100)),!window.TossPayments){console.error("[sirsoft-tosspayments] TossPayments SDK not available");return}const R=window.TossPayments(a.client_key).payment({customerKey:e.customer_key??window.TossPayments.ANONYMOUS}),f=e.currency??"KRW",{method:d,easyPay:y}=C(a,l);if(f!=="KRW"&&(d!=="CARD"||y!==null)){console.warn("[sirsoft-tosspayments] non-KRW currency supports card only",{currency:f,method:d}),s?.state?.setLocal?.({paymentErrorMessage:s?.t?.("sirsoft-tosspayments.errors.non_krw_method")??"This payment method is available for KRW only.",isSubmittingOrder:!1}),s?.modal?.open?.("tosspayments_payment_error_modal");return}const v=window.location.origin,c={method:d,amount:{currency:f,value:e.amount},orderId:e.order_number,orderName:e.order_name,successUrl:v+a.callback_urls.success,failUrl:v+a.callback_urls.fail,customerEmail:e.customer_email??void 0,customerName:e.customer_name??void 0,customerMobilePhone:e.customer_phone??void 0};if(d==="CARD")c.card={flowMode:"DEFAULT",useCardPoint:!1,useAppCardOnly:!1},y&&(c.easyPay={provider:y});else if(d==="VIRTUAL_ACCOUNT")c.virtualAccount=E(a,e),w(c,a,e);else if(d==="TRANSFER"){const u=p(a.use_escrow);u!==void 0&&(c.transfer={useEscrow:u}),w(c,a,e)}await R.requestPayment(c)}catch(n){if(console.error("[sirsoft-tosspayments] requestPayment error",n),n?.code==="USER_CANCEL"){console.info("[sirsoft-tosspayments] Payment cancelled by user");try{await s.api.post(`/modules/sirsoft-ecommerce/orders/${e.order_number}/cancel-payment`,{cancel_code:n.code,cancel_message:n.message})}catch(P){console.warn("[sirsoft-tosspayments] Failed to record cancellation",P)}s?.state?.setLocal?.({isSubmittingOrder:!1}),s?.modal?.open?.("tosspayments_payment_cancel_modal");return}const a=n?.message??"Unknown error";s?.state?.setLocal?.({paymentErrorMessage:a,isSubmittingOrder:!1}),s?.modal?.open?.("tosspayments_payment_error_modal")}}const _={requestPayment:A},i="sirsoft-tosspayments",m={info:(...t)=>console.info(`[${i}]`,...t),warn:(...t)=>console.warn(`[${i}]`,...t),error:(...t)=>console.error(`[${i}]`,...t)};function h(){const t=window.G7Core;if(!t)return 0;const r=t.getActionDispatcher;if(typeof r!="function")return 0;const o=r();if(!o||typeof o.registerHandler!="function")return 0;let e=0;for(const[s,l]of Object.entries(_)){const n=`${i}.${s}`;o.registerHandler(n,l,{category:"plugin",source:i}),e++}return e}function g(){const t=()=>{const r=h();if(r>0){m.info(`${r} handler(s) registered`);return}let o=0;const e=50,s=setInterval(()=>{o++;const l=h();if(l>0){clearInterval(s),m.info(`${l} handler(s) registered (after ${o} retries)`);return}o>=e&&(clearInterval(s),m.warn("ActionDispatcher not available after timeout"))},100)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",t):t()}g(),window.__SirsoftTosspayments={identifier:i,handlers:Object.keys(_),initPlugin:g}})(); //# sourceMappingURL=plugin.iife.js.map diff --git a/plugins/_bundled/sirsoft-tosspayments/dist/js/plugin.iife.js.map b/plugins/_bundled/sirsoft-tosspayments/dist/js/plugin.iife.js.map index efbee9bf..cf88d7df 100644 --- a/plugins/_bundled/sirsoft-tosspayments/dist/js/plugin.iife.js.map +++ b/plugins/_bundled/sirsoft-tosspayments/dist/js/plugin.iife.js.map @@ -1 +1 @@ -{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/handlers/requestPayment.ts","../../resources/js/handlers/index.ts","../../resources/js/index.ts"],"sourcesContent":["/**\n * 토스페이먼츠 결제창 호출 핸들러\n *\n * 체크아웃 레이아웃에서 주문 생성 API 성공 후 호출됩니다:\n * handler: \"sirsoft-tosspayments.requestPayment\"\n * params: { pgPaymentData: response.data.pg_payment_data }\n *\n * 호출 순서:\n * 1. Client Config API 호출 → clientKey 획득\n * 2. TossPayments SDK 동적 로드 (미로드 시)\n * 3. SDK 초기화 + payment 인스턴스 생성\n * 4. payment.requestPayment() 호출 → 통합결제창 오픈\n * 5. 결제 완료 시 브라우저가 successUrl/failUrl로 리다이렉트\n */\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\ninterface PgPaymentData {\n order_number: string;\n order_name: string;\n amount: number;\n currency?: string;\n customer_name?: string;\n customer_email?: string;\n customer_phone?: string;\n customer_key?: string | null;\n}\n\ninterface RequestPaymentParams {\n pgPaymentData: PgPaymentData;\n}\n\ninterface ClientConfig {\n client_key: string;\n sdk_url: string;\n callback_urls: {\n success: string;\n fail: string;\n };\n}\n\ndeclare global {\n interface Window {\n TossPayments: any;\n }\n}\n\n/**\n * 스크립트를 동적으로 로드합니다.\n *\n * @param src 스크립트 URL\n * @returns Promise\n */\nfunction loadScript(src: string): Promise {\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 +{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/handlers/requestPayment.ts","../../resources/js/handlers/index.ts","../../resources/js/index.ts"],"sourcesContent":["/**\n * 토스페이먼츠 결제창 호출 핸들러\n *\n * 체크아웃 레이아웃에서 주문 생성 API 성공 후 호출됩니다:\n * handler: \"sirsoft-tosspayments.requestPayment\"\n * params: { pgPaymentData: response.data.pg_payment_data }\n *\n * 호출 순서:\n * 1. Client Config API 호출 → clientKey 획득\n * 2. TossPayments SDK 동적 로드 (미로드 시)\n * 3. SDK 초기화 + payment 인스턴스 생성\n * 4. payment.requestPayment() 호출 → 통합결제창 오픈\n * 5. 결제 완료 시 브라우저가 successUrl/failUrl로 리다이렉트\n */\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\ninterface EscrowProduct {\n id: string;\n name: string;\n code: string;\n unitPrice: number;\n quantity: number;\n}\n\ninterface PgPaymentData {\n order_number: string;\n order_name: string;\n amount: number;\n currency?: string;\n customer_name?: string;\n customer_email?: string;\n customer_phone?: string;\n customer_key?: string | null;\n escrow_products?: EscrowProduct[];\n}\n\ninterface RequestPaymentParams {\n pgPaymentData: PgPaymentData;\n // 주문서형에서 사용자가 선택한 결제수단 id (toss_*). 미지정 시 _local.paymentMethod 참조.\n paymentMethod?: string;\n}\n\ninterface EnabledMethod {\n id: string;\n method: string;\n easy_pay_provider: string | null;\n core_payment_method: string;\n}\n\ninterface ClientConfig {\n client_key: string;\n sdk_url: string;\n order_sheet_mode?: boolean;\n enabled_methods?: EnabledMethod[];\n vbank?: {\n valid_hours: number;\n cash_receipt_type: string;\n };\n use_escrow?: string;\n callback_urls: {\n success: string;\n fail: string;\n };\n}\n\ndeclare global {\n interface Window {\n TossPayments: any;\n }\n}\n\n/**\n * 스크립트를 동적으로 로드합니다.\n *\n * @param src 스크립트 URL\n * @returns Promise\n */\nfunction loadScript(src: string): Promise {\n return new Promise((resolve, reject) => {\n // 이미 로드된 스크립트인지 확인\n if (document.querySelector(`script[src=\"${src}\"]`)) {\n resolve();\n return;\n }\n\n const script = document.createElement('script');\n script.src = src;\n script.async = true;\n script.onload = () => resolve();\n script.onerror = () => reject(new Error(`Failed to load script: ${src}`));\n document.head.appendChild(script);\n });\n}\n\n/**\n * 토스페이먼츠 결제창 호출 핸들러\n *\n * ActionDispatcher는 커스텀 핸들러를 (action, context) 시그니처로 호출합니다.\n * params는 action.params에서 접근해야 합니다.\n *\n * @param action 액션 정의 (handler, params 등)\n * @param _context 액션 컨텍스트\n */\n/**\n * 선택된 결제수단 id 를 SDK 파라미터로 변환합니다.\n *\n * 서버가 내려준 enabled_methods 에서 선택 id 를 찾아 SDK method / easyPay provider 를\n * 결정한다. 프론트는 결제수단 매핑을 하드코딩하지 않는다 (서버가 SSoT).\n * order_sheet_mode 가 off 이거나 선택 id 를 못 찾으면 통합결제창 카드(CARD)로 처리한다.\n *\n * @param config 클라이언트 설정 (enabled_methods 포함)\n * @param selectedId 선택된 toss_* 결제수단 id\n * @returns SDK method 와 easyPay provider\n */\nfunction resolveMethod(config: ClientConfig, selectedId?: string): { method: string; easyPay: string | null } {\n if (!config.order_sheet_mode || !selectedId) {\n return { method: 'CARD', easyPay: null };\n }\n\n const entry = (config.enabled_methods ?? []).find((m) => m.id === selectedId);\n if (!entry) {\n return { method: 'CARD', easyPay: null };\n }\n\n return { method: entry.method, easyPay: entry.easy_pay_provider };\n}\n\n/**\n * use_escrow 설정 문자열을 SDK useEscrow boolean/undefined 로 변환합니다.\n *\n * off → false, on → true, buyer_choice → undefined(키 생략 → 결제창에서 구매자 선택).\n *\n * @param useEscrow 설정값 (off | on | buyer_choice)\n * @returns SDK useEscrow 값 (undefined 면 키를 넣지 않음)\n */\nfunction resolveEscrowFlag(useEscrow?: string): boolean | undefined {\n if (useEscrow === 'on') return true;\n if (useEscrow === 'off') return false;\n // buyer_choice (또는 미설정) → 키 자체를 생략\n return undefined;\n}\n\n/**\n * 가상계좌 SDK 페이로드를 구성합니다.\n *\n * @param config 클라이언트 설정 (vbank / use_escrow 포함)\n * @param pgPaymentData PG 결제 데이터 (escrow_products 포함)\n * @returns virtualAccount 페이로드\n */\nfunction buildVirtualAccountPayload(config: ClientConfig, pgPaymentData: PgPaymentData): any {\n const vbank: any = {\n validHours: config.vbank?.valid_hours ?? 24,\n };\n\n // 현금영수증 자동 발급 유형 (설정 시에만)\n const receiptType = config.vbank?.cash_receipt_type ?? '';\n if (receiptType) {\n vbank.cashReceipt = { type: receiptType };\n }\n\n // 에스크로 (off → false, on → true, buyer_choice → 키 생략)\n const useEscrow = resolveEscrowFlag(config.use_escrow);\n if (useEscrow !== undefined) {\n vbank.useEscrow = useEscrow;\n }\n\n return vbank;\n}\n\n/**\n * 에스크로 사용 시 필수인 escrowProducts 배열을 페이로드에 부착합니다.\n *\n * use_escrow 가 off 가 아니고(강제 on 또는 구매자 선택) escrow_products 가 있으면 부착한다.\n *\n * @param payload 결제 요청 페이로드 (변경됨)\n * @param config 클라이언트 설정\n * @param pgPaymentData PG 결제 데이터\n * @returns void\n */\nfunction attachEscrowProducts(payload: any, config: ClientConfig, pgPaymentData: PgPaymentData): void {\n if (config.use_escrow === 'off') {\n return;\n }\n\n const products = pgPaymentData.escrow_products ?? [];\n if (products.length > 0) {\n payload.escrowProducts = products;\n }\n}\n\nexport async function requestPaymentHandler(action: any, _context?: any): Promise {\n const params = (action.params || {}) as RequestPaymentParams;\n const { pgPaymentData } = params;\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 // 선택된 결제수단 id — action.params 우선, 없으면 _local.paymentMethod 참조\n const selectedMethodId: string | undefined =\n params.paymentMethod ?? G7Core?.state?.getLocal?.()?.paymentMethod;\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. 결제수단 결정 + 비KRW 차단\n const currency = pgPaymentData.currency ?? 'KRW';\n const { method, easyPay } = resolveMethod(config, selectedMethodId);\n\n // 토스 국내 전용 수단(가상계좌·계좌이체·휴대폰·간편결제)은 비KRW 결제 불가. 카드만 허용.\n const domesticOnly = method !== 'CARD' || easyPay !== null;\n if (currency !== 'KRW' && domesticOnly) {\n console.warn('[sirsoft-tosspayments] non-KRW currency supports card only', { currency, method });\n G7Core?.state?.setLocal?.({\n paymentErrorMessage: G7Core?.t?.('sirsoft-tosspayments.errors.non_krw_method') ?? 'This payment method is available for KRW only.',\n isSubmittingOrder: false,\n });\n G7Core?.modal?.open?.('tosspayments_payment_error_modal');\n return;\n }\n\n // 5. 결제 요청 페이로드 조립\n const origin = window.location.origin;\n const requestPayload: any = {\n method,\n amount: {\n currency,\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 };\n\n if (method === 'CARD') {\n requestPayload.card = {\n flowMode: 'DEFAULT',\n useCardPoint: false,\n useAppCardOnly: false,\n };\n // 간편결제(easyPay)는 CARD method 에 easyPay provider 를 실어 호출한다.\n if (easyPay) {\n requestPayload.easyPay = { provider: easyPay };\n }\n } else if (method === 'VIRTUAL_ACCOUNT') {\n // 가상계좌 — 에스크로 적용 대상 (escrowProducts 는 에스크로 사용 시 필수)\n requestPayload.virtualAccount = buildVirtualAccountPayload(config, pgPaymentData);\n attachEscrowProducts(requestPayload, config, pgPaymentData);\n } else if (method === 'TRANSFER') {\n // 계좌이체 — 에스크로만 적용 대상\n const useEscrow = resolveEscrowFlag(config.use_escrow);\n if (useEscrow !== undefined) {\n requestPayload.transfer = { useEscrow };\n }\n attachEscrowProducts(requestPayload, config, pgPaymentData);\n }\n\n await payment.requestPayment(requestPayload);\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","resolveMethod","config","selectedId","entry","m","resolveEscrowFlag","useEscrow","buildVirtualAccountPayload","pgPaymentData","vbank","receiptType","attachEscrowProducts","payload","products","requestPaymentHandler","action","_context","params","G7Core","selectedMethodId","configJson","payment","currency","method","easyPay","origin","requestPayload","error","e","errorMessage","handlerMap","PLUGIN_IDENTIFIER","logger","args","registerHandlers","g7Core","getDispatcher","dispatcher","count","name","handler","fullName","initPlugin","doInit","retries","maxRetries","interval","result"],"mappings":"yBA8EA,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,CAsBA,SAASC,EAAcC,EAAsBC,EAAiE,CAC1G,GAAI,CAACD,EAAO,kBAAoB,CAACC,EAC7B,MAAO,CAAE,OAAQ,OAAQ,QAAS,IAAA,EAGtC,MAAMC,GAASF,EAAO,iBAAmB,CAAA,GAAI,KAAMG,GAAMA,EAAE,KAAOF,CAAU,EAC5E,OAAKC,EAIE,CAAE,OAAQA,EAAM,OAAQ,QAASA,EAAM,iBAAA,EAHnC,CAAE,OAAQ,OAAQ,QAAS,IAAA,CAI1C,CAUA,SAASE,EAAkBC,EAAyC,CAChE,GAAIA,IAAc,KAAM,MAAO,GAC/B,GAAIA,IAAc,MAAO,MAAO,EAGpC,CASA,SAASC,EAA2BN,EAAsBO,EAAmC,CACzF,MAAMC,EAAa,CACf,WAAYR,EAAO,OAAO,aAAe,EAAA,EAIvCS,EAAcT,EAAO,OAAO,mBAAqB,GACnDS,IACAD,EAAM,YAAc,CAAE,KAAMC,CAAA,GAIhC,MAAMJ,EAAYD,EAAkBJ,EAAO,UAAU,EACrD,OAAIK,IAAc,SACdG,EAAM,UAAYH,GAGfG,CACX,CAYA,SAASE,EAAqBC,EAAcX,EAAsBO,EAAoC,CAClG,GAAIP,EAAO,aAAe,MACtB,OAGJ,MAAMY,EAAWL,EAAc,iBAAmB,CAAA,EAC9CK,EAAS,OAAS,IAClBD,EAAQ,eAAiBC,EAEjC,CAEA,eAAsBC,EAAsBC,EAAaC,EAA+B,CACpF,MAAMC,EAAUF,EAAO,QAAU,CAAA,EAC3B,CAAE,cAAAP,GAAkBS,EAE1B,GAAI,CAACT,EAAe,CAChB,QAAQ,MAAM,kDAAkD,EAChE,MACJ,CAEA,MAAMU,EAAU,OAAe,OAGzBC,EACFF,EAAO,eAAiBC,GAAQ,OAAO,cAAc,cAEzD,GAAI,CAEA,MAAME,EAAa,MAAMF,EAAO,IAAI,IAAI,gEAAgE,EAExG,GAAI,CAACE,EAAW,KAAM,CAClB,QAAQ,MAAM,uDAAwDA,CAAU,EAChF,MACJ,CAEA,MAAMnB,EAAuBmB,EAAW,KAYxC,GATK,OAAO,cACR,MAAMzB,EAAWM,EAAO,OAAO,EAI9B,OAAO,cACR,MAAM,IAAI,QAAeJ,GAAY,WAAWA,EAAS,GAAG,CAAC,EAG7D,CAAC,OAAO,aAAc,CACtB,QAAQ,MAAM,uDAAuD,EACrE,MACJ,CAIA,MAAMwB,EADe,OAAO,aAAapB,EAAO,UAAU,EAC7B,QAAQ,CACjC,YAAaO,EAAc,cAAgB,OAAO,aAAa,SAAA,CAClE,EAGKc,EAAWd,EAAc,UAAY,MACrC,CAAE,OAAAe,EAAQ,QAAAC,CAAA,EAAYxB,EAAcC,EAAQkB,CAAgB,EAIlE,GAAIG,IAAa,QADIC,IAAW,QAAUC,IAAY,MACd,CACpC,QAAQ,KAAK,6DAA8D,CAAE,SAAAF,EAAU,OAAAC,EAAQ,EAC/FL,GAAQ,OAAO,WAAW,CACtB,oBAAqBA,GAAQ,IAAI,4CAA4C,GAAK,iDAClF,kBAAmB,EAAA,CACtB,EACDA,GAAQ,OAAO,OAAO,kCAAkC,EACxD,MACJ,CAGA,MAAMO,EAAS,OAAO,SAAS,OACzBC,EAAsB,CACxB,OAAAH,EACA,OAAQ,CACJ,SAAAD,EACA,MAAOd,EAAc,MAAA,EAEzB,QAASA,EAAc,aACvB,UAAWA,EAAc,WACzB,WAAYiB,EAASxB,EAAO,cAAc,QAC1C,QAASwB,EAASxB,EAAO,cAAc,KACvC,cAAeO,EAAc,gBAAkB,OAC/C,aAAcA,EAAc,eAAiB,OAC7C,oBAAqBA,EAAc,gBAAkB,MAAA,EAGzD,GAAIe,IAAW,OACXG,EAAe,KAAO,CAClB,SAAU,UACV,aAAc,GACd,eAAgB,EAAA,EAGhBF,IACAE,EAAe,QAAU,CAAE,SAAUF,CAAA,WAElCD,IAAW,kBAElBG,EAAe,eAAiBnB,EAA2BN,EAAQO,CAAa,EAChFG,EAAqBe,EAAgBzB,EAAQO,CAAa,UACnDe,IAAW,WAAY,CAE9B,MAAMjB,EAAYD,EAAkBJ,EAAO,UAAU,EACjDK,IAAc,SACdoB,EAAe,SAAW,CAAE,UAAApB,CAAA,GAEhCK,EAAqBe,EAAgBzB,EAAQO,CAAa,CAC9D,CAEA,MAAMa,EAAQ,eAAeK,CAAc,CAG/C,OAASC,EAAY,CAIjB,GAHA,QAAQ,MAAM,8CAA+CA,CAAK,EAG9DA,GAAO,OAAS,cAAe,CAC/B,QAAQ,KAAK,kDAAkD,EAG/D,GAAI,CACA,MAAMT,EAAO,IAAI,KACb,qCAAqCV,EAAc,YAAY,kBAC/D,CACI,YAAamB,EAAM,KACnB,eAAgBA,EAAM,OAAA,CAC1B,CAER,OAASC,EAAG,CACR,QAAQ,KAAK,uDAAwDA,CAAC,CAC1E,CAGAV,GAAQ,OAAO,WAAW,CAAE,kBAAmB,GAAO,EACtDA,GAAQ,OAAO,OAAO,mCAAmC,EACzD,MACJ,CAGA,MAAMW,EAAeF,GAAO,SAAW,gBACvCT,GAAQ,OAAO,WAAW,CAAE,oBAAqBW,EAAc,kBAAmB,GAAO,EACzFX,GAAQ,OAAO,OAAO,kCAAkC,CAC5D,CACJ,CChUO,MAAMY,EAA8D,CACvE,eAAgBhB,CACpB,ECDMiB,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/docs/api/README.md b/plugins/_bundled/sirsoft-tosspayments/docs/api/README.md new file mode 100644 index 00000000..9659ba90 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/docs/api/README.md @@ -0,0 +1,15 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 플러그인 `sirsoft-tosspayments` · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 2 · **엔드포인트 수**: 4 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [payment.md](payment.md) | `payment` | 2 | +| [webhook.md](webhook.md) | `webhook` | 2 | + + + diff --git a/plugins/_bundled/sirsoft-tosspayments/docs/api/payment.md b/plugins/_bundled/sirsoft-tosspayments/docs/api/payment.md new file mode 100644 index 00000000..1d4e2a7d --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/docs/api/payment.md @@ -0,0 +1,147 @@ +# Payment API 레퍼런스 + +> **소유**: plugin `sirsoft-tosspayments` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Payment 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /plugins/sirsoft-tosspayments/payment/fail + +- **라우트명**: `web.plugins.sirsoft-tosspayments.payment.fail` +- **컨트롤러**: `Plugins\Sirsoft\Tosspayments\Controllers\PaymentCallbackController@fail` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| code | query | string | 아니오 | — | 토스 실패 코드 (예: `PAY_PROCESS_CANCELED`, `REJECT_CARD_COMPANY`). | +| message | query | string | 아니오 | — | 토스가 내려준 실패 사유 메시지. | +| orderId | query | string | 아니오 | — | 주문번호. 실패 페이지로 함께 전달해 어떤 주문의 결제가 실패했는지 표시한다. | + +**요청 예시** + +```http +GET /plugins/sirsoft-tosspayments/payment/fail?code=%EC%98%88%EC%8B%9C%EA%B0%92&message=%EC%98%88%EC%8B%9C%EA%B0%92&orderId=%EC%98%88%EC%8B%9C%EA%B0%92 HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + +이 엔드포인트는 JSON 을 반환하지 않는다. 브라우저를 실패 페이지로 **302 리다이렉트**한다. + +**응답 예시** + +```http +HTTP/1.1 302 Found +Location: /shop/checkout?error=PAY_PROCESS_CANCELED&orderId=20260711-000001 +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +토스 결제창에서 결제가 실패하거나 사용자가 결제를 취소하면 브라우저가 이 URL 로 리다이렉트된다. + +JSON 을 반환하지 않는다. 실패 사유를 로그로 기록한 뒤, 실패 페이지(기본 `/shop/checkout`, 설정 `redirect_fail_url`)로 실패 코드·메시지·주문번호를 query 로 실어 **302 리다이렉트**한다. 모든 파라미터가 선택값이라 없어도 4xx 를 반환하지 않는다. + +```http +HTTP/1.1 302 Found +Location: /shop/checkout?error=PAY_PROCESS_CANCELED&orderId=20260711-000001 +``` + +주의사항: + +- 이 엔드포인트는 **주문 상태를 변경하지 않는다**. 결제 취소 이력 기록은 프론트엔드가 별도 API(`/modules/sirsoft-ecommerce/orders/{orderNumber}/cancel-payment`)로 수행한다. +- 결제창을 띄우기 전 단계에서 사용자가 취소한 경우(SDK `USER_CANCEL`)는 이 콜백을 타지 않고 프론트엔드에서 직접 처리된다. + + +### GET /plugins/sirsoft-tosspayments/payment/success + +- **라우트명**: `web.plugins.sirsoft-tosspayments.payment.success` +- **컨트롤러**: `Plugins\Sirsoft\Tosspayments\Controllers\PaymentCallbackController@success` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| paymentKey | query | string | 예 | — | 토스가 발급한 결제 키. Confirm API 호출에 사용한다. | +| orderId | query | string | 예 | — | 주문번호 (G7 `orders.order_number`). SDK 호출 시 넘긴 값이 그대로 돌아온다. | +| amount | query | integer | 예 | min 1 | 결제 금액. 주문의 결제요청 금액과 대조하며, 불일치 시 결제를 승인하지 않는다. | + +**요청 예시** + +```http +GET /plugins/sirsoft-tosspayments/payment/success?paymentKey=%EC%98%88%EC%8B%9C%EA%B0%92&orderId=%EC%98%88%EC%8B%9C%EA%B0%92&amount=1 HTTP/1.1 +Host: api.example.com +Accept: application/json +``` + +**응답 필드** (`data` 내부) + +이 엔드포인트는 JSON 을 반환하지 않는다. 브라우저를 SPA 페이지로 **302 리다이렉트**한다. + +**응답 예시** + +```http +HTTP/1.1 302 Found +Location: /shop/orders/20260711-000001/complete +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +토스 결제창에서 결제가 성공하면 브라우저가 이 URL 로 리다이렉트된다. 결제 **승인(Confirm)** 을 수행하는 지점이다. + +JSON 을 반환하지 않고 브라우저를 **302 리다이렉트**한다. 에러 상황에서도 4xx/5xx 를 반환하지 않고 실패 페이지(기본 `/shop/checkout`)로 리다이렉트하며 사유를 query 로 전달한다. + +| 리다이렉트 대상 | `error` query | 발생 조건 | +| --- | --- | --- | +| 주문완료 페이지 | — | 결제 승인 성공 (기본 `/shop/orders/{orderId}/complete`) | +| 실패 페이지 | `order_not_found` | `orderId` 로 주문을 찾지 못한 경우 | +| 실패 페이지 | `amount_mismatch` | 콜백 `amount` 가 주문의 결제요청 금액과 다른 경우 (위변조 방어) | +| 실패 페이지 | `confirm_failed` | 토스 Confirm API 호출이 실패한 경우 (`message` query 에 사유) | +| 실패 페이지 | (검증 실패) | 필수 query 파라미터 누락/형식 오류 — 422 대신 실패 페이지로 리다이렉트한다 | + +처리 순서: + +1. `orderId` 로 주문을 조회한다. +2. 토스 **Confirm API** 를 호출해 결제를 확정한다 (`sirsoft-tosspayments.payment.before_confirm` / `after_confirm` 훅 발화). +3. 응답 `status` 로 분기한다: + - **`WAITING_FOR_DEPOSIT`(가상계좌)** — 아직 입금 전이므로 결제완료 처리하지 않고 계좌 정보만 저장한다. 실제 결제완료는 입금통보 웹훅(`/webhook/deposit`)이 담당한다. 웹훅 secret 은 **이 Confirm 응답에만** 내려오므로 `payment_meta.toss_secret` 에 저장해 웹훅 대조에 사용한다. + - **그 외(카드·계좌이체·휴대폰·간편결제)** — 즉시 결제완료(`completePayment`) 처리하고 카드 승인번호·영수증 URL 등을 기록한다. +4. SPA 주문완료 페이지로 리다이렉트한다. + +주의사항: + +- **금액 대조가 위변조 방어의 핵심**이다. query 의 `amount` 를 그대로 신뢰하지 않고 주문의 결제요청 금액과 대조하며, 불일치 시 `PaymentAmountMismatchException` 으로 승인을 중단한다. +- 리다이렉트 대상 URL 은 플러그인 설정(`redirect_success_url` / `redirect_fail_url`)으로 바꿀 수 있다 (기본 `/shop/orders/{orderId}/complete`, `/shop/checkout`). + + diff --git a/plugins/_bundled/sirsoft-tosspayments/docs/api/webhook.md b/plugins/_bundled/sirsoft-tosspayments/docs/api/webhook.md new file mode 100644 index 00000000..a6e97669 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/docs/api/webhook.md @@ -0,0 +1,170 @@ +# Webhook API 레퍼런스 + +> **소유**: plugin `sirsoft-tosspayments` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Webhook 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### POST /plugins/sirsoft-tosspayments/webhook/deposit + +- **라우트명**: `web.plugins.sirsoft-tosspayments.webhook.deposit` +- **컨트롤러**: `Plugins\Sirsoft\Tosspayments\Controllers\WebhookController@deposit` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| orderId | body | string | 예 | max 100 | 주문번호 (G7 `orders.order_number`). 이 값으로 주문·결제 레코드를 조회한다. | +| status | body | string | 예 | `DONE`, `CANCELED` | 입금 결과. `DONE`=입금완료 → 결제완료 처리, `CANCELED`=입금취소 → 결제실패 처리. | +| secret | body | string | 아니오 | max 255 | 결제 승인 응답에서 발급받아 `payment_meta.toss_secret` 에 저장해 둔 값. 위조 방지 대조에 사용. | +| transactionKey | body | string | 아니오 | max 255 | 토스 거래 키. 결제완료 처리 시 `transaction_id` 로 기록한다 (없으면 기존 값 유지). | +| createdAt | body | string | 아니오 | max 64 | 토스가 이벤트를 생성한 시각. `payment_meta.deposit_confirmed_at` 에 기록한다. | + +**요청 예시** + +```http +POST /plugins/sirsoft-tosspayments/webhook/deposit HTTP/1.1 +Host: api.example.com +Accept: application/json +Content-Type: application/json + +{ + "orderId": "예시값", + "status": "DONE", + "secret": "예시값", + "transactionKey": "예시값", + "createdAt": "예시값" +} +``` + +**응답 필드** (`data` 내부) + +이 엔드포인트는 코어 API 응답 봉투(`{success, data, message}`)를 쓰지 않는다. 토스 웹훅 규약에 맞춰 `text/plain` 본문만 반환한다. + +**응답 예시** + +```http +HTTP/1.1 200 OK +Content-Type: text/plain + +OK +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +가상계좌 입금통보(DEPOSIT_CALLBACK)를 수신한다. 구매자가 발급받은 가상계좌에 입금하면 토스 서버가 이 엔드포인트를 호출한다. + +이 엔드포인트는 코어 API 응답 봉투(`{success, data, message}`)를 쓰지 않는다. 토스 웹훅 규약에 맞춰 `text/plain` 본문만 반환한다. + +| 상태코드 | 본문 | 발생 조건 | +| --- | --- | --- | +| 200 | `OK` | 입금 반영 성공 / 이미 결제완료된 거래(리플레이) / 입금대기 상태가 아닌 주문 — 토스의 재전송을 멈추기 위해 200 을 반환한다 | +| 401 | `UNAUTHORIZED` | `webhook_secret_verify` 가 켜진 상태에서 본문 `secret` 이 저장된 `payment_meta.toss_secret` 과 다른 경우 | +| 200 | `FAIL` | 주문/결제 레코드를 찾지 못하거나 처리 중 예외 발생 (토스는 본문 `FAIL` 을 실패로 간주해 재전송한다) | +| 422 | JSON | 요청 파라미터 검증 실패 | + +처리 순서: + +1. **리플레이 방지** — 이미 결제완료된 거래(`transaction_id` 기준)면 아무것도 하지 않고 `OK` 를 반환한다 (멱등). +2. **secret 대조** — 플러그인 설정 `webhook_secret_verify` 가 켜져 있으면, 본문의 `secret` 을 결제 승인 시 저장해 둔 `payment_meta.toss_secret` 과 대조한다 (`hash_equals`). 불일치 시 401. +3. **상태 가드** — 결제가 입금대기(`waiting_deposit`) 상태가 아니면 처리하지 않고 `OK` 를 반환한다. +4. **상태별 처리** — `status=DONE` 이면 결제완료(`completePayment`), `status=CANCELED` 이면 결제실패(`failPayment`) 처리한다. + +주의사항: + +- 토스는 notify IP 목록·요청 서명을 제공하지 않는다. 공식 위조 방지 수단은 **secret 대조뿐**이므로 `webhook_secret_verify` 를 끄지 않는 것을 권장한다. +- 토스는 CSRF 토큰을 보내지 않으므로 이 라우트는 `ValidateCsrfToken` 이 면제되어 있다. +- 미응답 시 토스가 최대 7회 재전송한다. 부수 작업(알림·재고·적립금 등)은 `completePayment` 내부 훅 리스너에 위임하고 컨트롤러는 빠르게 응답한다. + + +### POST /plugins/sirsoft-tosspayments/webhook/payment-status + +- **라우트명**: `web.plugins.sirsoft-tosspayments.webhook.payment-status` +- **컨트롤러**: `Plugins\Sirsoft\Tosspayments\Controllers\WebhookController@paymentStatus` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| eventType | body | string | 아니오 | max 64 | 토스 이벤트 종류 (예: `PAYMENT_STATUS_CHANGED`). | +| createdAt | body | string | 아니오 | max 64 | 토스가 이벤트를 생성한 시각. | +| data | body | array | 예 | — | 결제 정보 객체. `data.orderId`(주문번호)와 `data.status`(토스 결제상태)를 읽어 로컬 상태와 대조한다. | + +**요청 예시** + +```http +POST /plugins/sirsoft-tosspayments/webhook/payment-status HTTP/1.1 +Host: api.example.com +Accept: application/json +Content-Type: application/json + +{ + "eventType": "예시값", + "createdAt": "예시값", + "data": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + +이 엔드포인트는 코어 API 응답 봉투를 쓰지 않는다. 토스 웹훅 규약에 맞춰 `text/plain` 본문만 반환한다. + +**응답 예시** + +```http +HTTP/1.1 200 OK +Content-Type: text/plain + +OK +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +결제상태 변경(PAYMENT_STATUS_CHANGED) 이벤트를 수신해 **상태 동기화 로깅**만 수행한다. 토스 측 결제상태(`data.status`)와 로컬 결제상태를 함께 기록해, 두 값이 어긋난 경우를 로그로 추적할 수 있게 한다. + +이 엔드포인트도 코어 API 응답 봉투를 쓰지 않고 `text/plain` 본문만 반환한다. + +| 상태코드 | 본문 | 발생 조건 | +| --- | --- | --- | +| 200 | `OK` | 정상 수신 / 주문을 찾지 못한 경우 모두 200 (재전송 중단) | +| 422 | JSON | 요청 파라미터 검증 실패 | + +주의사항: + +- 이 엔드포인트는 주문 상태를 **변경하지 않는다**. 실제 상태 전이는 결제 승인 콜백(`/payment/success`)과 입금통보 웹훅(`/webhook/deposit`)이 담당한다. +- 주문을 찾지 못해도 200 `OK` 를 반환한다 (토스의 재전송을 멈추기 위함). +- 토스는 CSRF 토큰을 보내지 않으므로 이 라우트는 `ValidateCsrfToken` 이 면제되어 있다. + + diff --git a/plugins/_bundled/sirsoft-tosspayments/lang/en/messages.php b/plugins/_bundled/sirsoft-tosspayments/lang/en/messages.php index 61dfb698..236cfcf0 100644 --- a/plugins/_bundled/sirsoft-tosspayments/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-tosspayments/lang/en/messages.php @@ -8,4 +8,8 @@ return [ 'missing_payment_key' => 'Cannot process refund: TossPayments payment key not found.', 'default_reason' => 'Cancelled by customer request', ], + 'settings_validation' => [ + 'vbank_valid_hours_range' => 'Virtual account deposit deadline must be between :min and :max hours (max 90 days).', + 'use_escrow_invalid' => 'The escrow usage setting value is invalid.', + ], ]; diff --git a/plugins/_bundled/sirsoft-tosspayments/lang/en/payment_methods.php b/plugins/_bundled/sirsoft-tosspayments/lang/en/payment_methods.php new file mode 100644 index 00000000..1659696b --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/lang/en/payment_methods.php @@ -0,0 +1,40 @@ + [ + 'name' => 'Credit Card (TossPayments)', + 'description' => 'Pay with a credit or check card — processed via TossPayments', + ], + 'toss_virtual_account' => [ + 'name' => 'Virtual Account (TossPayments)', + 'description' => 'Deposit to an issued virtual account — processed via TossPayments', + ], + 'toss_transfer' => [ + 'name' => 'Bank Transfer (TossPayments)', + 'description' => 'Pay by real-time bank transfer — processed via TossPayments', + ], + 'toss_mobile_phone' => [ + 'name' => 'Mobile Phone (TossPayments)', + 'description' => 'Mobile carrier billing — processed via TossPayments', + ], + 'toss_tosspay' => [ + 'name' => 'TossPay (TossPayments)', + 'description' => 'TossPay easy payment — processed via TossPayments', + ], + 'toss_kakaopay' => [ + 'name' => 'KakaoPay (TossPayments)', + 'description' => 'KakaoPay easy payment — processed via TossPayments', + ], + 'toss_naverpay' => [ + 'name' => 'NaverPay (TossPayments)', + 'description' => 'NaverPay easy payment — processed via TossPayments', + ], + 'toss_payco' => [ + 'name' => 'PAYCO (TossPayments)', + 'description' => 'PAYCO easy payment — processed via TossPayments', + ], + 'toss_samsungpay' => [ + 'name' => 'Samsung Pay (TossPayments)', + 'description' => 'Samsung Pay easy payment — processed via TossPayments', + ], +]; diff --git a/plugins/_bundled/sirsoft-tosspayments/lang/ko/messages.php b/plugins/_bundled/sirsoft-tosspayments/lang/ko/messages.php index 68003e0d..f57fe0a0 100644 --- a/plugins/_bundled/sirsoft-tosspayments/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-tosspayments/lang/ko/messages.php @@ -8,4 +8,8 @@ return [ 'missing_payment_key' => '토스페이먼츠 결제 키가 존재하지 않아 환불 처리할 수 없습니다.', 'default_reason' => '고객 요청에 의한 취소', ], + 'settings_validation' => [ + 'vbank_valid_hours_range' => '가상계좌 입금기한은 :min~:max시간(최대 90일) 사이여야 합니다.', + 'use_escrow_invalid' => '에스크로 사용 설정값이 올바르지 않습니다.', + ], ]; diff --git a/plugins/_bundled/sirsoft-tosspayments/lang/ko/payment_methods.php b/plugins/_bundled/sirsoft-tosspayments/lang/ko/payment_methods.php new file mode 100644 index 00000000..bfd07bb6 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/lang/ko/payment_methods.php @@ -0,0 +1,40 @@ + [ + 'name' => '신용카드 (토스페이먼츠)', + 'description' => '신용·체크카드로 결제 — 토스페이먼츠를 통해 처리', + ], + 'toss_virtual_account' => [ + 'name' => '가상계좌 (토스페이먼츠)', + 'description' => '발급된 가상계좌로 입금 — 토스페이먼츠를 통해 처리', + ], + 'toss_transfer' => [ + 'name' => '계좌이체 (토스페이먼츠)', + 'description' => '실시간 계좌이체로 결제 — 토스페이먼츠를 통해 처리', + ], + 'toss_mobile_phone' => [ + 'name' => '휴대폰결제 (토스페이먼츠)', + 'description' => '휴대폰 소액결제 — 토스페이먼츠를 통해 처리', + ], + 'toss_tosspay' => [ + 'name' => '토스페이 (토스페이먼츠)', + 'description' => '토스페이 간편결제 — 토스페이먼츠를 통해 처리', + ], + 'toss_kakaopay' => [ + 'name' => '카카오페이 (토스페이먼츠)', + 'description' => '카카오페이 간편결제 — 토스페이먼츠를 통해 처리', + ], + 'toss_naverpay' => [ + 'name' => '네이버페이 (토스페이먼츠)', + 'description' => '네이버페이 간편결제 — 토스페이먼츠를 통해 처리', + ], + 'toss_payco' => [ + 'name' => '페이코 (토스페이먼츠)', + 'description' => '페이코 간편결제 — 토스페이먼츠를 통해 처리', + ], + 'toss_samsungpay' => [ + 'name' => '삼성페이 (토스페이먼츠)', + 'description' => '삼성페이 간편결제 — 토스페이먼츠를 통해 처리', + ], +]; diff --git a/plugins/_bundled/sirsoft-tosspayments/plugin.json b/plugins/_bundled/sirsoft-tosspayments/plugin.json index cea1dbd9..99d90769 100644 --- a/plugins/_bundled/sirsoft-tosspayments/plugin.json +++ b/plugins/_bundled/sirsoft-tosspayments/plugin.json @@ -11,10 +11,10 @@ "ko": "토스페이먼츠 결제 게이트웨이 (통합결제창 연동)", "en": "TossPayments gateway (integrated payment window)" }, - "g7_version": ">=7.0.0-beta.2", + "g7_version": ">=7.0.0", "dependencies": { "modules": { - "sirsoft-ecommerce": ">=1.0.0-beta.4" + "sirsoft-ecommerce": ">=1.1.0" }, "plugins": {} }, diff --git a/plugins/_bundled/sirsoft-tosspayments/plugin.php b/plugins/_bundled/sirsoft-tosspayments/plugin.php index 53a6eee1..51e07c9a 100644 --- a/plugins/_bundled/sirsoft-tosspayments/plugin.php +++ b/plugins/_bundled/sirsoft-tosspayments/plugin.php @@ -96,6 +96,106 @@ class Plugin extends AbstractPlugin 'en' => 'Supports relative paths or full URLs. Error details are appended as query parameters.', ], ], + + // 결제 방식 — 주문서형(결제수단을 우리 체크아웃에서 선택) vs 결제창형(토스 통합결제창) + 'order_sheet_mode' => [ + 'type' => 'boolean', + 'default' => false, + 'label' => ['ko' => '주문서형 결제', 'en' => 'Order-sheet Mode'], + 'hint' => [ + 'ko' => '켜면 체크아웃에서 결제수단을 직접 선택합니다. 끄면 토스 통합결제창(카드) 하나로 처리됩니다.', + 'en' => 'When ON, payment methods are chosen at checkout. When OFF, a single TossPayments integrated window (card) is used.', + ], + ], + + // 주문서형에서 노출할 결제수단 토글 (order_sheet_mode 가 true 일 때만 유효) + 'method_card' => [ + 'type' => 'boolean', + 'default' => true, + 'label' => ['ko' => '카드', 'en' => 'Card'], + ], + 'method_virtual_account' => [ + 'type' => 'boolean', + 'default' => false, + 'label' => ['ko' => '가상계좌', 'en' => 'Virtual Account'], + ], + 'method_transfer' => [ + 'type' => 'boolean', + 'default' => false, + 'label' => ['ko' => '계좌이체', 'en' => 'Bank Transfer'], + ], + 'method_mobile_phone' => [ + 'type' => 'boolean', + 'default' => false, + 'label' => ['ko' => '휴대폰', 'en' => 'Mobile Phone'], + ], + 'method_tosspay' => [ + 'type' => 'boolean', + 'default' => false, + 'label' => ['ko' => '토스페이', 'en' => 'TossPay'], + ], + 'method_kakaopay' => [ + 'type' => 'boolean', + 'default' => false, + 'label' => ['ko' => '카카오페이', 'en' => 'KakaoPay'], + ], + 'method_naverpay' => [ + 'type' => 'boolean', + 'default' => false, + 'label' => ['ko' => '네이버페이', 'en' => 'NaverPay'], + ], + 'method_payco' => [ + 'type' => 'boolean', + 'default' => false, + 'label' => ['ko' => '페이코', 'en' => 'PAYCO'], + ], + 'method_samsungpay' => [ + 'type' => 'boolean', + 'default' => false, + 'label' => ['ko' => '삼성페이', 'en' => 'Samsung Pay'], + ], + + // 가상계좌 옵션 + 'vbank_valid_hours' => [ + 'type' => 'integer', + 'default' => 24, + 'label' => ['ko' => '가상계좌 입금기한(시간)', 'en' => 'Virtual Account Valid Hours'], + 'hint' => [ + 'ko' => '가상계좌 발급 후 입금 가능한 시간입니다. 최대 2160시간(90일).', + 'en' => 'Hours available for deposit after issuing a virtual account. Max 2160 (90 days).', + ], + ], + 'vbank_cash_receipt_type' => [ + 'type' => 'string', + 'default' => '', + 'label' => ['ko' => '가상계좌 현금영수증 유형', 'en' => 'Virtual Account Cash Receipt Type'], + 'hint' => [ + 'ko' => '가상계좌 발급 시 토스가 자동 발급할 현금영수증 유형입니다. 비워두면 발급하지 않습니다.', + 'en' => 'Cash receipt type TossPayments auto-issues when a virtual account is created. Leave blank to skip.', + ], + ], + + // 에스크로 — 3-상태 (가상계좌·계좌이체에만 적용) + 'use_escrow' => [ + 'type' => 'string', + 'default' => 'off', + 'label' => ['ko' => '에스크로 사용', 'en' => 'Use Escrow'], + 'hint' => [ + 'ko' => '가상계좌·계좌이체 결제에만 적용됩니다. 구매자 선택은 결제창에서 구매자가 직접 결정합니다.', + 'en' => 'Applies to virtual account and bank transfer only. "Buyer choice" lets the buyer decide in the payment window.', + ], + ], + + // 가상계좌 입금통보(DEPOSIT_CALLBACK) 웹훅 secret 대조 강제 + 'webhook_secret_verify' => [ + 'type' => 'boolean', + 'default' => true, + 'label' => ['ko' => '웹훅 secret 검증', 'en' => 'Webhook Secret Verification'], + 'hint' => [ + 'ko' => '가상계좌 입금통보 웹훅의 secret 을 결제 승인 응답과 대조해 위조 요청을 차단합니다.', + 'en' => 'Verifies the deposit webhook secret against the payment confirmation response to block forged requests.', + ], + ], ]; } @@ -114,6 +214,20 @@ class Plugin extends AbstractPlugin 'live_secret_key' => '', 'redirect_success_url' => '/shop/orders/{orderId}/complete', 'redirect_fail_url' => '/shop/checkout', + 'order_sheet_mode' => false, + 'method_card' => true, + 'method_virtual_account' => false, + 'method_transfer' => false, + 'method_mobile_phone' => false, + 'method_tosspay' => false, + 'method_kakaopay' => false, + 'method_naverpay' => false, + 'method_payco' => false, + 'method_samsungpay' => false, + 'vbank_valid_hours' => 24, + 'vbank_cash_receipt_type' => '', + 'use_escrow' => 'off', + 'webhook_secret_verify' => true, ]; } @@ -126,7 +240,10 @@ class Plugin extends AbstractPlugin { return [ Listeners\RegisterPgProviderListener::class, + Listeners\RegisterTossPaymentMethodsListener::class, + Listeners\AdjustEcommercePaymentMethodsLayoutListener::class, Listeners\PaymentRefundListener::class, + Listeners\ValidateTossSettingsListener::class, ]; } 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 index ae367040..78ffea7b 100644 --- a/plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/handlers/requestPayment.test.ts +++ b/plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/handlers/requestPayment.test.ts @@ -3,6 +3,13 @@ * requestPayment 핸들러 테스트 * * 토스페이먼츠 결제창 호출 핸들러의 에러 처리 및 모달 열기 동작을 검증합니다. + * + * @effects sdk_method_mapped_from_enabled_methods, virtual_account_payload_built, + * escrow_products_attached, escrow_flag_off_true_or_key_absent, + * non_krw_domestic_method_blocked, + * escrow_products_attached_to_virtual_account_sdk_payload, + * escrow_products_absent_when_escrow_off, + * escrow_products_absent_for_card */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { requestPaymentHandler } from '../../handlers/requestPayment'; @@ -187,4 +194,213 @@ describe('requestPaymentHandler', () => { consoleWarnSpy.mockRestore(); }); }); + + // ===== SDK 파라미터 매핑 (주문서형 결제수단 → SDK method) ===== + describe('결제수단 → SDK 파라미터 매핑', () => { + let capturedPayload: any; + + /** + * clientConfig 로 SDK 를 스텁하고 requestPayment 인자를 캡처한다. + */ + const setupSdk = (config: any) => { + capturedPayload = undefined; + mockG7Core.api.get.mockResolvedValue({ data: config }); + + const mockPayment = { + requestPayment: vi.fn().mockImplementation((payload: any) => { + capturedPayload = payload; + return Promise.resolve(); + }), + }; + (window as any).TossPayments = vi.fn().mockReturnValue({ + payment: vi.fn().mockReturnValue(mockPayment), + }); + (window as any).TossPayments.ANONYMOUS = 'ANONYMOUS'; + }; + + const baseConfig = (overrides: any = {}) => ({ + client_key: 'ck', + sdk_url: 'https://example.com/sdk.js', + callback_urls: { success: '/success', fail: '/fail' }, + order_sheet_mode: true, + enabled_methods: [ + { id: 'toss_card', method: 'CARD', easy_pay_provider: null, core_payment_method: 'card' }, + { id: 'toss_virtual_account', method: 'VIRTUAL_ACCOUNT', easy_pay_provider: null, core_payment_method: 'vbank' }, + { id: 'toss_transfer', method: 'TRANSFER', easy_pay_provider: null, core_payment_method: 'bank' }, + { id: 'toss_kakaopay', method: 'CARD', easy_pay_provider: '카카오페이', core_payment_method: 'card' }, + ], + vbank: { valid_hours: 24, cash_receipt_type: '' }, + use_escrow: 'off', + ...overrides, + }); + + const pgData = (overrides: any = {}) => ({ + order_number: 'ORD-100', + order_name: '주문', + amount: 10000, + currency: 'KRW', + ...overrides, + }); + + it('order_sheet_mode off 이면 CARD 통합결제창', async () => { + setupSdk(baseConfig({ order_sheet_mode: false })); + + await requestPaymentHandler({ params: { pgPaymentData: pgData(), paymentMethod: 'toss_virtual_account' } }); + + expect(capturedPayload.method).toBe('CARD'); + expect(capturedPayload.virtualAccount).toBeUndefined(); + }); + + it('가상계좌 선택 시 VIRTUAL_ACCOUNT + virtualAccount 페이로드', async () => { + setupSdk(baseConfig({ vbank: { valid_hours: 48, cash_receipt_type: '소득공제' } })); + + await requestPaymentHandler({ params: { pgPaymentData: pgData(), paymentMethod: 'toss_virtual_account' } }); + + expect(capturedPayload.method).toBe('VIRTUAL_ACCOUNT'); + expect(capturedPayload.virtualAccount.validHours).toBe(48); + expect(capturedPayload.virtualAccount.cashReceipt).toEqual({ type: '소득공제' }); + }); + + it('간편결제 선택 시 CARD + easyPay provider', async () => { + setupSdk(baseConfig()); + + await requestPaymentHandler({ params: { pgPaymentData: pgData(), paymentMethod: 'toss_kakaopay' } }); + + expect(capturedPayload.method).toBe('CARD'); + expect(capturedPayload.easyPay).toEqual({ provider: '카카오페이' }); + }); + + it('에스크로 on 이면 가상계좌 useEscrow=true', async () => { + setupSdk(baseConfig({ use_escrow: 'on' })); + + await requestPaymentHandler({ params: { pgPaymentData: pgData(), paymentMethod: 'toss_virtual_account' } }); + + expect(capturedPayload.virtualAccount.useEscrow).toBe(true); + }); + + it('에스크로 buyer_choice 이면 useEscrow 키 부재', async () => { + setupSdk(baseConfig({ use_escrow: 'buyer_choice' })); + + await requestPaymentHandler({ params: { pgPaymentData: pgData(), paymentMethod: 'toss_virtual_account' } }); + + expect('useEscrow' in capturedPayload.virtualAccount).toBe(false); + }); + + it('가상계좌 + 에스크로 on 시 escrowProducts 부착 (E2)', async () => { + setupSdk(baseConfig({ use_escrow: 'on' })); + + await requestPaymentHandler({ + params: { + pgPaymentData: pgData({ + escrow_products: [{ id: '1', name: '상품', code: '1', unitPrice: 10000, quantity: 1 }], + }), + paymentMethod: 'toss_virtual_account', + }, + }); + + expect(capturedPayload.method).toBe('VIRTUAL_ACCOUNT'); + expect(capturedPayload.virtualAccount.useEscrow).toBe(true); + expect(capturedPayload.escrowProducts).toHaveLength(1); + expect(capturedPayload.escrowProducts[0].unitPrice).toBe(10000); + }); + + it('가상계좌 + 에스크로 buyer_choice 시에도 escrowProducts 부착 (E3 — 구매자가 선택할 수 있어야 하므로)', async () => { + setupSdk(baseConfig({ use_escrow: 'buyer_choice' })); + + await requestPaymentHandler({ + params: { + pgPaymentData: pgData({ + escrow_products: [{ id: '1', name: '상품', code: '1', unitPrice: 10000, quantity: 1 }], + }), + paymentMethod: 'toss_virtual_account', + }, + }); + + expect('useEscrow' in capturedPayload.virtualAccount).toBe(false); + expect(capturedPayload.escrowProducts).toHaveLength(1); + }); + + it('가상계좌 + 에스크로 off 이면 escrowProducts 미부착', async () => { + setupSdk(baseConfig({ use_escrow: 'off' })); + + await requestPaymentHandler({ + params: { + pgPaymentData: pgData({ + escrow_products: [{ id: '1', name: '상품', code: '1', unitPrice: 10000, quantity: 1 }], + }), + paymentMethod: 'toss_virtual_account', + }, + }); + + expect(capturedPayload.virtualAccount.useEscrow).toBe(false); + expect(capturedPayload.escrowProducts).toBeUndefined(); + }); + + it('카드 결제는 에스크로 on 이어도 escrowProducts 미부착 (E1)', async () => { + setupSdk(baseConfig({ use_escrow: 'on' })); + + await requestPaymentHandler({ + params: { + pgPaymentData: pgData({ + escrow_products: [{ id: '1', name: '상품', code: '1', unitPrice: 10000, quantity: 1 }], + }), + paymentMethod: 'toss_card', + }, + }); + + expect(capturedPayload.method).toBe('CARD'); + expect(capturedPayload.escrowProducts).toBeUndefined(); + }); + + it('계좌이체 + 에스크로 시 escrowProducts 부착', async () => { + setupSdk(baseConfig({ use_escrow: 'on' })); + + await requestPaymentHandler({ + params: { + pgPaymentData: pgData({ + escrow_products: [{ id: '1', name: '상품', code: '1', unitPrice: 10000, quantity: 1 }], + }), + paymentMethod: 'toss_transfer', + }, + }); + + expect(capturedPayload.method).toBe('TRANSFER'); + expect(capturedPayload.transfer.useEscrow).toBe(true); + expect(capturedPayload.escrowProducts).toHaveLength(1); + }); + + it('카드 결제는 useEscrow 미전달 (E1)', async () => { + setupSdk(baseConfig({ use_escrow: 'on' })); + + await requestPaymentHandler({ params: { pgPaymentData: pgData(), paymentMethod: 'toss_card' } }); + + expect(capturedPayload.method).toBe('CARD'); + expect(capturedPayload.card).toBeDefined(); + expect(capturedPayload.card.useEscrow).toBeUndefined(); + }); + + it('비KRW + 가상계좌 선택 시 차단 (카드만 허용)', async () => { + mockG7Core.t = vi.fn().mockReturnValue('KRW only'); + setupSdk(baseConfig()); + + await requestPaymentHandler({ + params: { pgPaymentData: pgData({ currency: 'USD' }), paymentMethod: 'toss_virtual_account' }, + }); + + // 결제창 호출 안 함 (차단) + expect(capturedPayload).toBeUndefined(); + expect(mockG7Core.modal.open).toHaveBeenCalledWith('tosspayments_payment_error_modal'); + }); + + it('비KRW + 카드 선택은 허용', async () => { + setupSdk(baseConfig()); + + await requestPaymentHandler({ + params: { pgPaymentData: pgData({ currency: 'USD' }), paymentMethod: 'toss_card' }, + }); + + expect(capturedPayload.method).toBe('CARD'); + expect(capturedPayload.amount.currency).toBe('USD'); + }); + }); }); diff --git a/plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/layouts/pluginSettingsPaymentMethods.test.tsx b/plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/layouts/pluginSettingsPaymentMethods.test.tsx new file mode 100644 index 00000000..d2baf715 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/layouts/pluginSettingsPaymentMethods.test.tsx @@ -0,0 +1,146 @@ +/** + * @file pluginSettingsPaymentMethods.test.tsx + * @description S4 신설 섹션(결제 방식 · 가상계좌) 구조 검증. + * + * order_sheet_mode 토글 / 결제수단 9종 체크박스(name=method_*) / 노출 조건(if) / + * 가상계좌 옵션(입금기한·현금영수증·에스크로 3-상태·웹훅) / 에스크로 운영 안내(W-6) / + * responsive.portable 을 검증한다. + * + * @scenario toss_payment_methods_vbank_escrow + * @effects escrow_operation_notice_shown_when_escrow_enabled + */ + +import { describe, it, expect } from 'vitest'; +import pluginSettingsLayout from '../../../layouts/admin/plugin_settings.json'; + +/** 레이아웃 트리를 순회하며 조건을 만족하는 모든 노드를 수집한다. */ +function collect(node: unknown, pred: (n: Record) => boolean): Record[] { + const out: Record[] = []; + const walk = (n: unknown) => { + if (!n || typeof n !== 'object') return; + const v = n as Record; + if (pred(v)) out.push(v); + for (const child of Object.values(v)) walk(child); + }; + walk(node); + return out; +} + +function findById(node: unknown, id: string): Record | undefined { + return collect(node, (n) => n.id === id)[0]; +} + +function nameOf(node: Record): string { + const props = (node.props ?? {}) as Record; + return typeof props.name === 'string' ? props.name : ''; +} + +describe('plugin_settings — 결제 방식 섹션', () => { + it('order_sheet_mode 토글이 존재한다', () => { + const toggles = collect(pluginSettingsLayout, (n) => n.name === 'Toggle' && nameOf(n) === 'order_sheet_mode'); + expect(toggles).toHaveLength(1); + }); + + it('결제수단 9종 체크박스가 method_* name 으로 존재한다', () => { + const checkboxes = collect(pluginSettingsLayout, (n) => n.name === 'Checkbox'); + const names = checkboxes.map(nameOf).filter((s) => s.startsWith('method_')); + + expect(names).toEqual( + expect.arrayContaining([ + 'method_card', + 'method_virtual_account', + 'method_transfer', + 'method_mobile_phone', + 'method_tosspay', + 'method_kakaopay', + 'method_naverpay', + 'method_payco', + 'method_samsungpay', + ]) + ); + expect(names).toHaveLength(9); + }); + + it('결제수단 그룹은 order_sheet_mode 가 켜졌을 때만 노출된다 (if)', () => { + const group = findById(pluginSettingsLayout, 'enabled_methods_group'); + expect(group).toBeDefined(); + expect(group!.if).toContain('order_sheet_mode'); + }); + + it('체크박스 그리드는 responsive.portable 로 1열 스택한다', () => { + const grid = findById(pluginSettingsLayout, 'method_checkbox_grid'); + expect(grid).toBeDefined(); + const responsive = grid!.responsive as Record | undefined; + expect(responsive?.portable?.props?.className).toContain('grid-cols-1'); + }); +}); + +describe('plugin_settings — 가상계좌 섹션', () => { + it('입금기한 입력(number) 이 존재한다', () => { + const inputs = collect(pluginSettingsLayout, (n) => n.name === 'Input' && nameOf(n) === 'vbank_valid_hours'); + expect(inputs).toHaveLength(1); + const props = inputs[0].props as Record; + expect(props.type).toBe('number'); + }); + + it('현금영수증 유형 Select 가 3옵션(없음/소득공제/지출증빙)을 갖는다', () => { + const selects = collect(pluginSettingsLayout, (n) => n.name === 'Select' && nameOf(n) === 'vbank_cash_receipt_type'); + expect(selects).toHaveLength(1); + const options = (selects[0].props as any).options as { value: string }[]; + expect(options.map((o) => o.value)).toEqual(['', '소득공제', '지출증빙']); + }); + + it('에스크로 Select 가 3-상태(off/on/buyer_choice)를 갖는다', () => { + const selects = collect(pluginSettingsLayout, (n) => n.name === 'Select' && nameOf(n) === 'use_escrow'); + expect(selects).toHaveLength(1); + const options = (selects[0].props as any).options as { value: string }[]; + expect(options.map((o) => o.value)).toEqual(['off', 'on', 'buyer_choice']); + }); + + it('에스크로 운영 안내(부분취소 불가 · 배송정보 등록)가 use_escrow !== off 일 때만 노출된다 (W-6)', () => { + const notice = findById(pluginSettingsLayout, 'escrow_operation_notice'); + expect(notice).toBeDefined(); + expect(notice!.if).toBe("{{_local.form?.use_escrow !== 'off'}}"); + + const texts = collect(notice, (n) => typeof n.text === 'string').map((n) => n.text as string); + expect(texts).toContain('$t:sirsoft-tosspayments.settings.escrow_no_partial_cancel_notice'); + expect(texts).toContain('$t:sirsoft-tosspayments.settings.escrow_shipping_info_notice'); + }); + + it('배송정보 안내는 토스 상점관리자 외부 링크를 제공한다 (W-6)', () => { + const notice = findById(pluginSettingsLayout, 'escrow_operation_notice'); + const links = collect(notice, (n) => n.name === 'A'); + + expect(links).toHaveLength(1); + const props = links[0].props as Record; + expect(props.href).toBe('https://app.tosspayments.com/'); + expect(props.target).toBe('_blank'); + expect(props.rel).toBe('noopener noreferrer'); + expect(links[0].text).toBe('$t:sirsoft-tosspayments.settings.escrow_shipping_info_link'); + }); + + it('웹훅 secret 검증 토글이 존재한다', () => { + const toggles = collect(pluginSettingsLayout, (n) => n.name === 'Toggle' && nameOf(n) === 'webhook_secret_verify'); + expect(toggles).toHaveLength(1); + }); + + it('웹훅 URL 복사 버튼이 copyToClipboard 핸들러를 사용한다', () => { + const btn = findById(pluginSettingsLayout, 'webhook_copy_button'); + expect(btn).toBeDefined(); + const actions = btn!.actions as Array>; + expect(actions[0].handler).toBe('copyToClipboard'); + }); + + it('웹훅 URL 복사 버튼은 portable 에서 full-width 로 전환된다', () => { + const btn = findById(pluginSettingsLayout, 'webhook_copy_button'); + const responsive = btn!.responsive as Record | undefined; + expect(responsive?.portable?.props?.className).toContain('w-full'); + }); +}); + +describe('plugin_settings — 기존 섹션 보존', () => { + it('기존 저장 버튼 sticky 는 유지된다', () => { + const footer = findById(pluginSettingsLayout, 'footer_buttons'); + expect((footer!.props as any).className).toContain('sticky-footer-buttons'); + }); +}); 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 index 45e651e4..40184da2 100644 --- a/plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/layouts/pluginSettingsSemanticClasses.test.tsx +++ b/plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/layouts/pluginSettingsSemanticClasses.test.tsx @@ -77,13 +77,14 @@ describe('plugin_settings 시맨틱 클래스 매핑', () => { }); describe('섹션 제목 H3', () => { - it('테스트/라이브/리다이렉트 섹션 제목은 section-heading-md 를 가져야 한다', () => { + it('모든 section_* 제목은 section-heading-md 를 가져야 한다', () => { const sectionHeadings = collect( pluginSettingsLayout, (n) => n.name === 'H3' && typeof n.text === 'string' && n.text.startsWith('$t:sirsoft-tosspayments.settings.section_'), ); - expect(sectionHeadings).toHaveLength(3); + // 테스트키 · 라이브키 · 리다이렉트 · 결제방식(S4) · 가상계좌(S4) = 5개 + expect(sectionHeadings).toHaveLength(5); for (const heading of sectionHeadings) { expect(classNameOf(heading)).toBe('section-heading-md'); } diff --git a/plugins/_bundled/sirsoft-tosspayments/resources/js/handlers/requestPayment.ts b/plugins/_bundled/sirsoft-tosspayments/resources/js/handlers/requestPayment.ts index 3572ac42..85bb5cab 100644 --- a/plugins/_bundled/sirsoft-tosspayments/resources/js/handlers/requestPayment.ts +++ b/plugins/_bundled/sirsoft-tosspayments/resources/js/handlers/requestPayment.ts @@ -15,6 +15,14 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +interface EscrowProduct { + id: string; + name: string; + code: string; + unitPrice: number; + quantity: number; +} + interface PgPaymentData { order_number: string; order_name: string; @@ -24,15 +32,32 @@ interface PgPaymentData { customer_email?: string; customer_phone?: string; customer_key?: string | null; + escrow_products?: EscrowProduct[]; } interface RequestPaymentParams { pgPaymentData: PgPaymentData; + // 주문서형에서 사용자가 선택한 결제수단 id (toss_*). 미지정 시 _local.paymentMethod 참조. + paymentMethod?: string; +} + +interface EnabledMethod { + id: string; + method: string; + easy_pay_provider: string | null; + core_payment_method: string; } interface ClientConfig { client_key: string; sdk_url: string; + order_sheet_mode?: boolean; + enabled_methods?: EnabledMethod[]; + vbank?: { + valid_hours: number; + cash_receipt_type: string; + }; + use_escrow?: string; callback_urls: { success: string; fail: string; @@ -77,8 +102,96 @@ function loadScript(src: string): Promise { * @param action 액션 정의 (handler, params 등) * @param _context 액션 컨텍스트 */ +/** + * 선택된 결제수단 id 를 SDK 파라미터로 변환합니다. + * + * 서버가 내려준 enabled_methods 에서 선택 id 를 찾아 SDK method / easyPay provider 를 + * 결정한다. 프론트는 결제수단 매핑을 하드코딩하지 않는다 (서버가 SSoT). + * order_sheet_mode 가 off 이거나 선택 id 를 못 찾으면 통합결제창 카드(CARD)로 처리한다. + * + * @param config 클라이언트 설정 (enabled_methods 포함) + * @param selectedId 선택된 toss_* 결제수단 id + * @returns SDK method 와 easyPay provider + */ +function resolveMethod(config: ClientConfig, selectedId?: string): { method: string; easyPay: string | null } { + if (!config.order_sheet_mode || !selectedId) { + return { method: 'CARD', easyPay: null }; + } + + const entry = (config.enabled_methods ?? []).find((m) => m.id === selectedId); + if (!entry) { + return { method: 'CARD', easyPay: null }; + } + + return { method: entry.method, easyPay: entry.easy_pay_provider }; +} + +/** + * use_escrow 설정 문자열을 SDK useEscrow boolean/undefined 로 변환합니다. + * + * off → false, on → true, buyer_choice → undefined(키 생략 → 결제창에서 구매자 선택). + * + * @param useEscrow 설정값 (off | on | buyer_choice) + * @returns SDK useEscrow 값 (undefined 면 키를 넣지 않음) + */ +function resolveEscrowFlag(useEscrow?: string): boolean | undefined { + if (useEscrow === 'on') return true; + if (useEscrow === 'off') return false; + // buyer_choice (또는 미설정) → 키 자체를 생략 + return undefined; +} + +/** + * 가상계좌 SDK 페이로드를 구성합니다. + * + * @param config 클라이언트 설정 (vbank / use_escrow 포함) + * @param pgPaymentData PG 결제 데이터 (escrow_products 포함) + * @returns virtualAccount 페이로드 + */ +function buildVirtualAccountPayload(config: ClientConfig, pgPaymentData: PgPaymentData): any { + const vbank: any = { + validHours: config.vbank?.valid_hours ?? 24, + }; + + // 현금영수증 자동 발급 유형 (설정 시에만) + const receiptType = config.vbank?.cash_receipt_type ?? ''; + if (receiptType) { + vbank.cashReceipt = { type: receiptType }; + } + + // 에스크로 (off → false, on → true, buyer_choice → 키 생략) + const useEscrow = resolveEscrowFlag(config.use_escrow); + if (useEscrow !== undefined) { + vbank.useEscrow = useEscrow; + } + + return vbank; +} + +/** + * 에스크로 사용 시 필수인 escrowProducts 배열을 페이로드에 부착합니다. + * + * use_escrow 가 off 가 아니고(강제 on 또는 구매자 선택) escrow_products 가 있으면 부착한다. + * + * @param payload 결제 요청 페이로드 (변경됨) + * @param config 클라이언트 설정 + * @param pgPaymentData PG 결제 데이터 + * @returns void + */ +function attachEscrowProducts(payload: any, config: ClientConfig, pgPaymentData: PgPaymentData): void { + if (config.use_escrow === 'off') { + return; + } + + const products = pgPaymentData.escrow_products ?? []; + if (products.length > 0) { + payload.escrowProducts = products; + } +} + export async function requestPaymentHandler(action: any, _context?: any): Promise { - const { pgPaymentData } = (action.params || {}) as RequestPaymentParams; + const params = (action.params || {}) as RequestPaymentParams; + const { pgPaymentData } = params; if (!pgPaymentData) { console.error('[sirsoft-tosspayments] pgPaymentData is required'); @@ -87,6 +200,10 @@ export async function requestPaymentHandler(action: any, _context?: any): Promis const G7Core = (window as any).G7Core; + // 선택된 결제수단 id — action.params 우선, 없으면 _local.paymentMethod 참조 + const selectedMethodId: string | undefined = + params.paymentMethod ?? G7Core?.state?.getLocal?.()?.paymentMethod; + try { // 1. Client Config API 호출 const configJson = await G7Core.api.get('/modules/sirsoft-ecommerce/payments/client-config/tosspayments'); @@ -119,13 +236,28 @@ export async function requestPaymentHandler(action: any, _context?: any): Promis customerKey: pgPaymentData.customer_key ?? window.TossPayments.ANONYMOUS, }); - // 4. 결제 요청 (통합결제창) - const origin = window.location.origin; + // 4. 결제수단 결정 + 비KRW 차단 + const currency = pgPaymentData.currency ?? 'KRW'; + const { method, easyPay } = resolveMethod(config, selectedMethodId); - await payment.requestPayment({ - method: 'CARD', + // 토스 국내 전용 수단(가상계좌·계좌이체·휴대폰·간편결제)은 비KRW 결제 불가. 카드만 허용. + const domesticOnly = method !== 'CARD' || easyPay !== null; + if (currency !== 'KRW' && domesticOnly) { + console.warn('[sirsoft-tosspayments] non-KRW currency supports card only', { currency, method }); + G7Core?.state?.setLocal?.({ + paymentErrorMessage: G7Core?.t?.('sirsoft-tosspayments.errors.non_krw_method') ?? 'This payment method is available for KRW only.', + isSubmittingOrder: false, + }); + G7Core?.modal?.open?.('tosspayments_payment_error_modal'); + return; + } + + // 5. 결제 요청 페이로드 조립 + const origin = window.location.origin; + const requestPayload: any = { + method, amount: { - currency: pgPaymentData.currency ?? 'KRW', + currency, value: pgPaymentData.amount, }, orderId: pgPaymentData.order_number, @@ -135,13 +267,32 @@ export async function requestPaymentHandler(action: any, _context?: any): Promis customerEmail: pgPaymentData.customer_email ?? undefined, customerName: pgPaymentData.customer_name ?? undefined, customerMobilePhone: pgPaymentData.customer_phone ?? undefined, - card: { - useEscrow: false, + }; + + if (method === 'CARD') { + requestPayload.card = { flowMode: 'DEFAULT', useCardPoint: false, useAppCardOnly: false, - }, - }); + }; + // 간편결제(easyPay)는 CARD method 에 easyPay provider 를 실어 호출한다. + if (easyPay) { + requestPayload.easyPay = { provider: easyPay }; + } + } else if (method === 'VIRTUAL_ACCOUNT') { + // 가상계좌 — 에스크로 적용 대상 (escrowProducts 는 에스크로 사용 시 필수) + requestPayload.virtualAccount = buildVirtualAccountPayload(config, pgPaymentData); + attachEscrowProducts(requestPayload, config, pgPaymentData); + } else if (method === 'TRANSFER') { + // 계좌이체 — 에스크로만 적용 대상 + const useEscrow = resolveEscrowFlag(config.use_escrow); + if (useEscrow !== undefined) { + requestPayload.transfer = { useEscrow }; + } + attachEscrowProducts(requestPayload, config, pgPaymentData); + } + + await payment.requestPayment(requestPayload); // → 브라우저가 successUrl 또는 failUrl로 리다이렉트됨 } catch (error: any) { diff --git a/plugins/_bundled/sirsoft-tosspayments/resources/lang/en.json b/plugins/_bundled/sirsoft-tosspayments/resources/lang/en.json index cc8a4b16..e697d548 100644 --- a/plugins/_bundled/sirsoft-tosspayments/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-tosspayments/resources/lang/en.json @@ -26,7 +26,43 @@ "redirect_fail_url": "Payment Failure Redirect URL", "redirect_fail_url_hint": "Supports relative paths or full URLs. Error details (error, message, orderId) are appended as query parameters.", "save": "Save", - "saved": "Settings saved." + "saved": "Settings saved.", + "section_payment_methods": "Payment Methods", + "order_sheet_mode": "Order-sheet Mode", + "order_sheet_mode_hint": "When ON, payment methods are chosen at checkout. When OFF, a single TossPayments integrated window (card) is used.", + "enabled_methods": "Methods to Show", + "enabled_methods_hint": "Select the payment methods shown at checkout in order-sheet mode.", + "method_card": "Card", + "method_virtual_account": "Virtual Account", + "method_transfer": "Bank Transfer", + "method_mobile_phone": "Mobile Phone", + "method_tosspay": "TossPay", + "method_kakaopay": "KakaoPay", + "method_naverpay": "NaverPay", + "method_payco": "PAYCO", + "method_samsungpay": "Samsung Pay", + "section_virtual_account": "Virtual Account", + "vbank_valid_hours": "Valid Hours", + "vbank_valid_hours_hint": "Hours available for deposit after issuing a virtual account. Max 2160 (90 days).", + "vbank_cash_receipt_type": "Auto Cash Receipt Type", + "vbank_cash_receipt_type_hint": "Cash receipt type TossPayments auto-issues when a virtual account is created.", + "vbank_cash_receipt_none": "Do not issue", + "vbank_cash_receipt_income": "Income deduction", + "vbank_cash_receipt_expense": "Expense proof", + "use_escrow": "Use Escrow", + "use_escrow_hint": "Applies to virtual account and bank transfer only.", + "use_escrow_off": "Off", + "use_escrow_on": "Force on", + "use_escrow_buyer_choice": "Buyer choice", + "escrow_no_partial_cancel_notice": "Escrow orders cannot be partially cancelled. Only full cancellation is available.", + "escrow_shipping_info_notice": "Register shipping information in the TossPayments merchant console.", + "escrow_shipping_info_link": "Open ↗", + "webhook_secret_verify": "Webhook Secret Verification", + "webhook_secret_verify_hint": "Verifies the deposit webhook secret against the payment confirmation response to block forged requests.", + "webhook_url_label": "Deposit Webhook URL", + "webhook_url_hint": "Prefix this path with your store's full domain (https://your-domain) and register it as a DEPOSIT_CALLBACK event in the TossPayments Developer Center > Webhook menu.", + "webhook_url_copy": "Copy", + "webhook_url_copied": "Webhook URL copied." }, "payment_error_title": "Payment Error", "payment_cancel_title": "Payment Cancelled", @@ -35,6 +71,7 @@ "confirm_failed": "Payment confirmation failed.", "amount_mismatch": "Payment amount does not match.", "order_not_found": "Order not found.", - "payment_failed": "Payment failed." + "payment_failed": "Payment failed.", + "non_krw_method": "This payment method is available for KRW payments only." } } diff --git a/plugins/_bundled/sirsoft-tosspayments/resources/lang/ko.json b/plugins/_bundled/sirsoft-tosspayments/resources/lang/ko.json index 2a7a531c..fde8415a 100644 --- a/plugins/_bundled/sirsoft-tosspayments/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-tosspayments/resources/lang/ko.json @@ -26,7 +26,43 @@ "redirect_fail_url": "결제 실패 리다이렉트 URL", "redirect_fail_url_hint": "상대 경로 또는 전체 URL 모두 가능합니다. 오류 정보(error, message, orderId)는 쿼리 파라미터로 자동 추가됩니다.", "save": "저장", - "saved": "설정이 저장되었습니다." + "saved": "설정이 저장되었습니다.", + "section_payment_methods": "결제 방식", + "order_sheet_mode": "주문서형 결제", + "order_sheet_mode_hint": "켜면 체크아웃에서 결제수단을 직접 선택합니다. 끄면 토스 통합결제창(카드) 하나로 처리됩니다.", + "enabled_methods": "노출할 결제수단", + "enabled_methods_hint": "주문서형 결제일 때 체크아웃에 노출할 결제수단을 선택합니다.", + "method_card": "카드", + "method_virtual_account": "가상계좌", + "method_transfer": "계좌이체", + "method_mobile_phone": "휴대폰", + "method_tosspay": "토스페이", + "method_kakaopay": "카카오페이", + "method_naverpay": "네이버페이", + "method_payco": "페이코", + "method_samsungpay": "삼성페이", + "section_virtual_account": "가상계좌", + "vbank_valid_hours": "입금기한(시간)", + "vbank_valid_hours_hint": "가상계좌 발급 후 입금 가능한 시간입니다. 최대 2160시간(90일).", + "vbank_cash_receipt_type": "현금영수증 자동발급 유형", + "vbank_cash_receipt_type_hint": "가상계좌 발급 시 토스가 자동 발급할 현금영수증 유형입니다.", + "vbank_cash_receipt_none": "발급 안 함", + "vbank_cash_receipt_income": "소득공제", + "vbank_cash_receipt_expense": "지출증빙", + "use_escrow": "에스크로 사용", + "use_escrow_hint": "가상계좌·계좌이체 결제에만 적용됩니다.", + "use_escrow_off": "사용 안 함", + "use_escrow_on": "강제 사용", + "use_escrow_buyer_choice": "구매자 선택", + "escrow_no_partial_cancel_notice": "에스크로 주문은 부분취소가 불가합니다. 전체 취소만 가능합니다.", + "escrow_shipping_info_notice": "배송정보는 토스 상점관리자에서 등록하세요.", + "escrow_shipping_info_link": "바로가기 ↗", + "webhook_secret_verify": "웹훅 secret 검증", + "webhook_secret_verify_hint": "가상계좌 입금통보 웹훅의 secret 을 결제 승인 응답과 대조해 위조 요청을 차단합니다.", + "webhook_url_label": "가상계좌 입금통보 웹훅 URL", + "webhook_url_hint": "위 경로 앞에 상점 전체 도메인(https://내도메인)을 붙여, 토스 개발자센터 > 웹훅 메뉴에 DEPOSIT_CALLBACK 이벤트로 등록하세요.", + "webhook_url_copy": "복사", + "webhook_url_copied": "웹훅 URL 이 복사되었습니다." }, "payment_error_title": "결제 오류", "payment_cancel_title": "결제 취소", @@ -35,6 +71,7 @@ "confirm_failed": "결제 승인에 실패했습니다.", "amount_mismatch": "결제 금액이 일치하지 않습니다.", "order_not_found": "주문을 찾을 수 없습니다.", - "payment_failed": "결제에 실패했습니다." + "payment_failed": "결제에 실패했습니다.", + "non_krw_method": "이 결제수단은 원화(KRW) 결제에만 사용할 수 있습니다." } } diff --git a/plugins/_bundled/sirsoft-tosspayments/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-tosspayments/resources/layouts/admin/plugin_settings.json index 2aae64de..c2dc9641 100644 --- a/plugins/_bundled/sirsoft-tosspayments/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-tosspayments/resources/layouts/admin/plugin_settings.json @@ -592,6 +592,520 @@ } ] }, + { + "id": "payment_methods_section", + "type": "basic", + "name": "Div", + "props": { + "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "panel-header-row" + }, + "children": [ + { + "type": "basic", + "name": "H3", + "props": { + "className": "section-heading-md" + }, + "text": "$t:sirsoft-tosspayments.settings.section_payment_methods" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "p-6 space-y-6" + }, + "children": [ + { + "id": "field_order_sheet_mode", + "type": "basic", + "name": "Div", + "props": { + "className": "flex-between" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-tosspayments.settings.order_sheet_mode" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-tosspayments.settings.order_sheet_mode_hint" + } + ] + }, + { + "type": "composite", + "name": "Toggle", + "props": { + "name": "order_sheet_mode" + } + } + ] + }, + { + "id": "enabled_methods_group", + "type": "basic", + "name": "Div", + "if": "{{_local.form?.order_sheet_mode}}", + "props": { + "className": "space-y-3 pt-2 border-t border-gray-100 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-tosspayments.settings.enabled_methods" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-tosspayments.settings.enabled_methods_hint" + }, + { + "id": "method_checkbox_grid", + "type": "basic", + "name": "Div", + "props": { + "className": "grid grid-cols-3 gap-3" + }, + "responsive": { + "portable": { + "props": { + "className": "grid grid-cols-1 gap-2" + } + } + }, + "children": [ + { + "type": "basic", + "name": "Checkbox", + "props": { + "name": "method_card", + "label": "$t:sirsoft-tosspayments.settings.method_card" + } + }, + { + "type": "basic", + "name": "Checkbox", + "props": { + "name": "method_virtual_account", + "label": "$t:sirsoft-tosspayments.settings.method_virtual_account" + } + }, + { + "type": "basic", + "name": "Checkbox", + "props": { + "name": "method_transfer", + "label": "$t:sirsoft-tosspayments.settings.method_transfer" + } + }, + { + "type": "basic", + "name": "Checkbox", + "props": { + "name": "method_mobile_phone", + "label": "$t:sirsoft-tosspayments.settings.method_mobile_phone" + } + }, + { + "type": "basic", + "name": "Checkbox", + "props": { + "name": "method_tosspay", + "label": "$t:sirsoft-tosspayments.settings.method_tosspay" + } + }, + { + "type": "basic", + "name": "Checkbox", + "props": { + "name": "method_kakaopay", + "label": "$t:sirsoft-tosspayments.settings.method_kakaopay" + } + }, + { + "type": "basic", + "name": "Checkbox", + "props": { + "name": "method_naverpay", + "label": "$t:sirsoft-tosspayments.settings.method_naverpay" + } + }, + { + "type": "basic", + "name": "Checkbox", + "props": { + "name": "method_payco", + "label": "$t:sirsoft-tosspayments.settings.method_payco" + } + }, + { + "type": "basic", + "name": "Checkbox", + "props": { + "name": "method_samsungpay", + "label": "$t:sirsoft-tosspayments.settings.method_samsungpay" + } + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-tosspayments.errors.non_krw_method" + } + ] + } + ] + } + ] + }, + { + "id": "virtual_account_section", + "type": "basic", + "name": "Div", + "props": { + "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "panel-header-row" + }, + "children": [ + { + "type": "basic", + "name": "H3", + "props": { + "className": "section-heading-md" + }, + "text": "$t:sirsoft-tosspayments.settings.section_virtual_account" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "p-6 space-y-6" + }, + "children": [ + { + "id": "field_vbank_valid_hours", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-tosspayments.settings.vbank_valid_hours" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "number", + "name": "vbank_valid_hours", + "min": 1, + "max": 2160, + "className": "w-32" + } + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-tosspayments.settings.vbank_valid_hours_hint" + } + ] + }, + { + "id": "field_vbank_cash_receipt_type", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-tosspayments.settings.vbank_cash_receipt_type" + }, + { + "type": "basic", + "name": "Select", + "props": { + "name": "vbank_cash_receipt_type", + "options": [ + { "value": "", "label": "$t:sirsoft-tosspayments.settings.vbank_cash_receipt_none" }, + { "value": "소득공제", "label": "$t:sirsoft-tosspayments.settings.vbank_cash_receipt_income" }, + { "value": "지출증빙", "label": "$t:sirsoft-tosspayments.settings.vbank_cash_receipt_expense" } + ] + } + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-tosspayments.settings.vbank_cash_receipt_type_hint" + } + ] + }, + { + "id": "field_use_escrow", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-tosspayments.settings.use_escrow" + }, + { + "type": "basic", + "name": "Select", + "props": { + "name": "use_escrow", + "options": [ + { "value": "off", "label": "$t:sirsoft-tosspayments.settings.use_escrow_off" }, + { "value": "on", "label": "$t:sirsoft-tosspayments.settings.use_escrow_on" }, + { "value": "buyer_choice", "label": "$t:sirsoft-tosspayments.settings.use_escrow_buyer_choice" } + ] + } + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-tosspayments.settings.use_escrow_hint" + }, + { + "id": "escrow_operation_notice", + "if": "{{_local.form?.use_escrow !== 'off'}}", + "type": "basic", + "name": "Div", + "props": { + "className": "mt-2 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 p-3 space-y-1" + }, + "children": [ + { + "type": "basic", + "name": "P", + "props": { + "className": "text-sm text-amber-800 dark:text-amber-200" + }, + "text": "$t:sirsoft-tosspayments.settings.escrow_no_partial_cancel_notice" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-sm text-amber-800 dark:text-amber-200" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-tosspayments.settings.escrow_shipping_info_notice" + }, + { + "type": "basic", + "name": "A", + "props": { + "href": "https://app.tosspayments.com/", + "target": "_blank", + "rel": "noopener noreferrer", + "className": "ml-1 underline font-medium hover:text-amber-900 dark:hover:text-amber-100" + }, + "text": "$t:sirsoft-tosspayments.settings.escrow_shipping_info_link" + } + ] + } + ] + } + ] + }, + { + "id": "field_webhook_url", + "type": "basic", + "name": "Div", + "props": { + "className": "pt-2 border-t border-gray-100 dark:border-gray-700 space-y-2" + }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-tosspayments.settings.webhook_url_label" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex items-center gap-2" + }, + "responsive": { + "portable": { + "props": { + "className": "flex flex-col items-stretch gap-2" + } + } + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-1 overflow-x-auto font-mono text-sm bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded px-3 py-2 whitespace-nowrap" + }, + "text": "/plugins/sirsoft-tosspayments/webhook/deposit" + }, + { + "id": "webhook_copy_button", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "btn btn-secondary btn-sm flex-shrink-0" + }, + "responsive": { + "portable": { + "props": { + "type": "button", + "className": "btn btn-secondary btn-sm w-full" + } + } + }, + "actions": [ + { + "type": "click", + "handler": "copyToClipboard", + "params": { + "text": "/plugins/sirsoft-tosspayments/webhook/deposit" + }, + "onSuccess": [ + { + "handler": "toast", + "params": { + "type": "success", + "message": "$t:sirsoft-tosspayments.settings.webhook_url_copied" + } + } + ] + } + ], + "children": [ + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-tosspayments.settings.webhook_url_copy" + } + ] + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-tosspayments.settings.webhook_url_hint" + } + ] + }, + { + "id": "field_webhook_secret_verify", + "type": "basic", + "name": "Div", + "props": { + "className": "flex-between pt-2" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-tosspayments.settings.webhook_secret_verify" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-tosspayments.settings.webhook_secret_verify_hint" + } + ] + }, + { + "type": "composite", + "name": "Toggle", + "props": { + "name": "webhook_secret_verify" + } + } + ] + } + ] + } + ] + }, { "id": "footer_buttons", "type": "basic", diff --git a/plugins/_bundled/sirsoft-tosspayments/src/Concerns/MapsTossPaymentMethods.php b/plugins/_bundled/sirsoft-tosspayments/src/Concerns/MapsTossPaymentMethods.php new file mode 100644 index 00000000..e91d3523 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/src/Concerns/MapsTossPaymentMethods.php @@ -0,0 +1,65 @@ + + */ + private const TOSS_METHOD_MAP = [ + 'toss_card' => ['setting' => 'method_card', 'method' => 'CARD', 'easy_pay' => null, 'core' => 'card'], + 'toss_virtual_account' => ['setting' => 'method_virtual_account', 'method' => 'VIRTUAL_ACCOUNT', 'easy_pay' => null, 'core' => 'vbank'], + 'toss_transfer' => ['setting' => 'method_transfer', 'method' => 'TRANSFER', 'easy_pay' => null, 'core' => 'bank'], + 'toss_mobile_phone' => ['setting' => 'method_mobile_phone', 'method' => 'MOBILE_PHONE', 'easy_pay' => null, 'core' => 'phone'], + 'toss_tosspay' => ['setting' => 'method_tosspay', 'method' => 'CARD', 'easy_pay' => '토스페이', 'core' => 'card'], + 'toss_kakaopay' => ['setting' => 'method_kakaopay', 'method' => 'CARD', 'easy_pay' => '카카오페이', 'core' => 'card'], + 'toss_naverpay' => ['setting' => 'method_naverpay', 'method' => 'CARD', 'easy_pay' => '네이버페이', 'core' => 'card'], + 'toss_payco' => ['setting' => 'method_payco', 'method' => 'CARD', 'easy_pay' => '페이코', 'core' => 'card'], + 'toss_samsungpay' => ['setting' => 'method_samsungpay', 'method' => 'CARD', 'easy_pay' => '삼성페이', 'core' => 'card'], + ]; + + /** + * 등록 가능한 전체 toss_* 결제수단 id 목록을 선언 순서대로 반환합니다 (활성 토글 무관). + * + * @return list 전체 toss_* id 목록 + */ + protected function allTossMethodIds(): array + { + return array_keys(self::TOSS_METHOD_MAP); + } + + /** + * 플러그인 설정에서 활성 토글된 toss_* 결제수단 id 목록을 선언 순서대로 반환합니다. + * + * @param array $settings 플러그인 설정값 + * @return list 활성 toss_* id 목록 + */ + protected function enabledTossMethodIds(array $settings): array + { + $enabled = []; + foreach (self::TOSS_METHOD_MAP as $id => $meta) { + if ((bool) ($settings[$meta['setting']] ?? false)) { + $enabled[] = $id; + } + } + + return $enabled; + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/src/Concerns/PreventsReplayCallback.php b/plugins/_bundled/sirsoft-tosspayments/src/Concerns/PreventsReplayCallback.php new file mode 100644 index 00000000..d89c0069 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/src/Concerns/PreventsReplayCallback.php @@ -0,0 +1,47 @@ +isTransactionPaid($transactionId); + } + + /** + * Replay 감지를 로깅. 운영 모니터링을 위한 통일 로그 형식. + * + * @param string $transactionId PG 거래 ID + * @param string|null $orderId 주문번호 (있을 경우) + * @param string $context 콜백 종류 + */ + protected function logReplayDetected(string $transactionId, ?string $orderId, string $context): void + { + Log::info('TossPayments: replay detected — already paid, returning idempotent response', [ + 'transaction_id' => $transactionId, + 'orderId' => $orderId, + 'context' => $context, + ]); + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/src/Controllers/PaymentCallbackController.php b/plugins/_bundled/sirsoft-tosspayments/src/Controllers/PaymentCallbackController.php index ec8d1930..44235bb8 100644 --- a/plugins/_bundled/sirsoft-tosspayments/src/Controllers/PaymentCallbackController.php +++ b/plugins/_bundled/sirsoft-tosspayments/src/Controllers/PaymentCallbackController.php @@ -4,11 +4,14 @@ namespace Plugins\Sirsoft\Tosspayments\Controllers; use App\Extension\HookManager; use App\Services\PluginSettingsService; +use Carbon\Carbon; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; +use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum; use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException; use Modules\Sirsoft\Ecommerce\Helpers\DeviceDetector; +use Modules\Sirsoft\Ecommerce\Models\Order; use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService; use Plugins\Sirsoft\Tosspayments\Http\Requests\FailCallbackRequest; use Plugins\Sirsoft\Tosspayments\Http\Requests\SuccessCallbackRequest; @@ -66,7 +69,7 @@ class PaymentCallbackController * GET /plugins/sirsoft-tosspayments/payment/success * ?paymentKey={PK}&orderId={OID}&amount={AMT} * - * @param SuccessCallbackRequest $request 검증된 콜백 요청 + * @param SuccessCallbackRequest $request 검증된 콜백 요청 * @return RedirectResponse SPA 페이지로 리다이렉트 */ public function success(SuccessCallbackRequest $request): RedirectResponse @@ -93,7 +96,16 @@ class PaymentCallbackController HookManager::doAction('sirsoft-tosspayments.payment.after_confirm', $order, $pgResponse); - // 3. 주문 상태 업데이트 (금액 검증 + PG 응답 → order_payments 매핑) + // 3-a. 가상계좌 발급 — 아직 입금 전이므로 completePayment 하지 않고 계좌 정보만 저장한다. + // 실제 결제완료는 입금통보 웹훅(DEPOSIT_CALLBACK, status=DONE)에서 처리한다. + // 웹훅 secret 은 이 confirm 응답에만 내려오므로 payment_meta 에 저장해 대조에 쓴다. + if (($pgResponse['status'] ?? null) === 'WAITING_FOR_DEPOSIT') { + $this->handleVirtualAccountIssued($order, $pgResponse, $paymentKey, $request); + + return redirect($this->resolveSuccessUrl($orderId)); + } + + // 3-b. 즉시 결제완료(카드/계좌이체/휴대폰/간편결제) — 기존 경로 $this->orderService->completePayment($order, [ 'transaction_id' => $pgResponse['paymentKey'] ?? null, 'card_approval_number' => $pgResponse['card']['approveNo'] ?? null, @@ -147,7 +159,7 @@ class PaymentCallbackController * GET /plugins/sirsoft-tosspayments/payment/fail * ?code={ERR}&message={MSG}&orderId={OID} * - * @param FailCallbackRequest $request 검증된 콜백 요청 + * @param FailCallbackRequest $request 검증된 콜백 요청 * @return RedirectResponse SPA 체크아웃 페이지로 리다이렉트 */ public function fail(FailCallbackRequest $request): RedirectResponse @@ -178,10 +190,69 @@ class PaymentCallbackController ])); } + /** + * 가상계좌 발급 처리 — completePayment 없이 계좌 정보와 웹훅 secret 만 저장한다. + * + * 토스 confirm 응답의 status 가 WAITING_FOR_DEPOSIT 이면 호출된다. 실제 결제완료는 + * 입금통보 웹훅(deposit)에서 처리한다. secret 은 이 응답에만 내려오므로 payment_meta 에 + * 저장해 웹훅 위조 방지 대조(hash_equals)에 사용한다. + * + * @param Order $order 대상 주문 + * @param array $pgResponse 토스 confirm 응답 + * @param string $paymentKey 토스 결제 키 + * @param Request $request HTTP 요청 (디바이스 판별용) + */ + private function handleVirtualAccountIssued(Order $order, array $pgResponse, string $paymentKey, Request $request): void + { + $vbank = $pgResponse['virtualAccount'] ?? []; + + $dueAt = null; + $dueDate = $vbank['dueDate'] ?? null; + if (is_string($dueDate) && $dueDate !== '') { + try { + $dueAt = Carbon::parse($dueDate); + } catch (\Exception) { + $dueAt = null; + } + } + + // 에스크로 여부는 결제 시점 스냅샷 — 응답의 useEscrow 를 그대로 저장한다. + $isEscrow = (bool) ($vbank['useEscrow'] ?? $pgResponse['useEscrow'] ?? false); + + $order->payment()->update(array_filter([ + 'pg_provider' => 'tosspayments', + 'payment_status' => PaymentStatusEnum::WAITING_DEPOSIT->value, + 'transaction_id' => $paymentKey ?: null, + 'vbank_code' => $vbank['bankCode'] ?? null, + 'vbank_name' => $vbank['bank'] ?? ($vbank['bankCode'] ?? null), + 'vbank_number' => $vbank['accountNumber'] ?? null, + 'vbank_holder' => $vbank['customerName'] ?? null, + 'vbank_due_at' => $dueAt, + 'vbank_issued_at' => Carbon::now(), + 'is_escrow' => $isEscrow, + 'payment_device' => DeviceDetector::detect($request), + 'payment_meta' => [ + 'status' => 'WAITING_FOR_DEPOSIT', + 'method' => $pgResponse['method'] ?? null, + // 웹훅 secret — DEPOSIT_CALLBACK 대조용 (이 응답에만 존재) + 'toss_secret' => $pgResponse['secret'] ?? null, + 'pg_raw_response' => $pgResponse, + ], + ], fn ($v) => $v !== null)); + + Log::info('TossPayments: virtual account issued', [ + 'orderId' => $order->order_number, + 'paymentKey' => $paymentKey, + 'vbank_number' => $vbank['accountNumber'] ?? null, + 'vbank_due_at' => $dueAt?->toDateTimeString(), + 'is_escrow' => $isEscrow, + ]); + } + /** * 카드 발급사 코드 → 이름 변환 * - * @param string|null $issuerCode 발급사 코드 + * @param string|null $issuerCode 발급사 코드 * @return string|null 카드사명 */ private function resolveCardIssuer(?string $issuerCode): ?string @@ -196,7 +267,7 @@ class PaymentCallbackController /** * 결제 성공 리다이렉트 URL 생성 * - * @param string $orderId 주문번호 + * @param string $orderId 주문번호 * @return string 리다이렉트 URL */ private function resolveSuccessUrl(string $orderId): string @@ -210,7 +281,7 @@ class PaymentCallbackController /** * 결제 실패 리다이렉트 URL 생성 * - * @param array $queryParams 쿼리 파라미터 + * @param array $queryParams 쿼리 파라미터 * @return string 리다이렉트 URL */ private function resolveFailUrl(array $queryParams = []): string @@ -225,13 +296,13 @@ class PaymentCallbackController $query = http_build_query(array_filter($queryParams)); $separator = str_contains($baseUrl, '?') ? '&' : '?'; - return $baseUrl . $separator . $query; + return $baseUrl.$separator.$query; } /** * 결제 디바이스 판별 (User-Agent 기반) * - * @param Request $request HTTP 요청 + * @param Request $request HTTP 요청 * @return string 디바이스 유형 (pc/mobile) */ } diff --git a/plugins/_bundled/sirsoft-tosspayments/src/Controllers/WebhookController.php b/plugins/_bundled/sirsoft-tosspayments/src/Controllers/WebhookController.php new file mode 100644 index 00000000..320b7b89 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/src/Controllers/WebhookController.php @@ -0,0 +1,215 @@ +validated(); + $orderId = (string) $validated['orderId']; + $status = (string) $validated['status']; + $secret = (string) ($validated['secret'] ?? ''); + $transactionKey = (string) ($validated['transactionKey'] ?? ''); + + Log::info('TossPayments: deposit webhook received', [ + 'orderId' => $orderId, + 'status' => $status, + ]); + + try { + $order = $this->orderService->findByOrderNumber($orderId); + + if (! $order) { + Log::error('TossPayments: deposit webhook - order not found', ['orderId' => $orderId]); + + return $this->plain('FAIL'); + } + + $order->load('payment'); + $payment = $order->payment; + + if (! $payment) { + Log::error('TossPayments: deposit webhook - payment not found', ['orderId' => $orderId]); + + return $this->plain('FAIL'); + } + + // 1. 리플레이 — 이미 결제완료된 거래이면 멱등 200 + if ($this->wasAlreadyPaid($payment->transaction_id)) { + $this->logReplayDetected((string) $payment->transaction_id, $orderId, 'deposit webhook'); + + return $this->plain('OK'); + } + + // 2. secret 대조 (위조 방지) — 설정으로 강제 시에만 + $storedSecret = (string) ($payment->payment_meta['toss_secret'] ?? ''); + if ($this->shouldVerifySecret() && ! $this->secretMatches($storedSecret, $secret)) { + Log::warning('TossPayments: deposit webhook secret mismatch', [ + 'orderId' => $orderId, + ]); + + return $this->plain('UNAUTHORIZED', 401); + } + + // 3. 저장 컨텍스트 검증 — 입금대기 상태여야 함 + $paymentStatus = $payment->payment_status instanceof PaymentStatusEnum + ? $payment->payment_status->value + : (string) $payment->payment_status; + + if ($paymentStatus !== PaymentStatusEnum::WAITING_DEPOSIT->value) { + Log::warning('TossPayments: deposit webhook - not waiting for deposit', [ + 'orderId' => $orderId, + 'payment_status' => $paymentStatus, + ]); + + // 이미 처리됐거나 실패한 주문 — 재전송을 멈추도록 200 으로 응답 + return $this->plain('OK'); + } + + // 4. 상태별 처리 (부수 작업은 completePayment/failPayment 내부 훅에 위임) + if ($status === 'DONE') { + $this->orderService->completePayment($order, [ + 'transaction_id' => $transactionKey ?: $payment->transaction_id, + 'payment_meta' => array_merge($payment->payment_meta ?? [], [ + 'deposit_confirmed_at' => $validated['createdAt'] ?? null, + 'webhook_status' => 'DONE', + ]), + ], (int) $payment->paid_amount_base ?: null); + + Log::info('TossPayments: deposit confirmed', ['orderId' => $orderId]); + + return $this->plain('OK'); + } + + // status === CANCELED → 입금 취소 + $this->orderService->failPayment($order, 'DEPOSIT_CANCELED', 'Virtual account deposit canceled'); + + Log::info('TossPayments: deposit canceled', ['orderId' => $orderId]); + + return $this->plain('OK'); + + } catch (\Throwable $e) { + Log::error('TossPayments: deposit webhook failed', [ + 'orderId' => $orderId, + 'error' => $e->getMessage(), + ]); + + return $this->plain('FAIL'); + } + } + + /** + * 결제상태 변경 웹훅 — 상태 동기화 로깅 (불일치 시 경고). + * + * POST /plugins/sirsoft-tosspayments/webhook/payment-status + * + * @param PaymentStatusWebhookRequest $request 검증된 웹훅 요청 + * @return Response 토스에 반환할 처리 결과 + */ + public function paymentStatus(PaymentStatusWebhookRequest $request): Response + { + $data = $request->validated()['data']; + $orderId = (string) $data['orderId']; + $pgStatus = (string) $data['status']; + + Log::info('TossPayments: payment-status webhook received', [ + 'orderId' => $orderId, + 'status' => $pgStatus, + ]); + + $order = $this->orderService->findByOrderNumber($orderId); + + if (! $order) { + Log::warning('TossPayments: payment-status webhook - order not found', ['orderId' => $orderId]); + + return $this->plain('OK'); + } + + $order->load('payment'); + $localStatus = $order->payment?->payment_status; + $localStatusValue = $localStatus instanceof PaymentStatusEnum ? $localStatus->value : (string) $localStatus; + + Log::info('TossPayments: payment-status synced', [ + 'orderId' => $orderId, + 'toss_status' => $pgStatus, + 'local_status' => $localStatusValue, + ]); + + return $this->plain('OK'); + } + + /** + * 웹훅 secret 대조 강제 여부. + */ + private function shouldVerifySecret(): bool + { + $settings = $this->pluginSettingsService->get(self::PLUGIN_IDENTIFIER) ?? []; + + return (bool) ($settings['webhook_secret_verify'] ?? true); + } + + /** + * 웹훅 secret 이 저장된 confirm 응답 secret 과 일치하는지 확인. + * + * @param string $storedSecret 결제 승인 응답에서 저장한 secret (payment_meta.toss_secret) + * @param string $incomingSecret 웹훅 본문의 secret + */ + private function secretMatches(string $storedSecret, string $incomingSecret): bool + { + if ($storedSecret === '' || $incomingSecret === '') { + return false; + } + + return hash_equals($storedSecret, $incomingSecret); + } + + /** + * text/plain 응답 생성 (토스 웹훅 규약). + * + * @param string $body 응답 본문 + * @param int $status HTTP 상태 코드 + */ + private function plain(string $body, int $status = 200): Response + { + return response($body, $status)->header('Content-Type', 'text/plain'); + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/src/Http/Requests/DepositWebhookRequest.php b/plugins/_bundled/sirsoft-tosspayments/src/Http/Requests/DepositWebhookRequest.php new file mode 100644 index 00000000..de0c4935 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/src/Http/Requests/DepositWebhookRequest.php @@ -0,0 +1,46 @@ + + */ + public function rules(): array + { + return [ + 'orderId' => ['required', 'string', 'max:100'], + 'status' => ['required', 'string', 'in:DONE,CANCELED'], + 'secret' => ['nullable', 'string', 'max:255'], + 'transactionKey' => ['nullable', 'string', 'max:255'], + 'createdAt' => ['nullable', 'string', 'max:64'], + ]; + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/src/Http/Requests/PaymentStatusWebhookRequest.php b/plugins/_bundled/sirsoft-tosspayments/src/Http/Requests/PaymentStatusWebhookRequest.php new file mode 100644 index 00000000..d077e5cd --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/src/Http/Requests/PaymentStatusWebhookRequest.php @@ -0,0 +1,46 @@ + + */ + public function rules(): array + { + return [ + 'eventType' => ['nullable', 'string', 'max:64'], + 'createdAt' => ['nullable', 'string', 'max:64'], + 'data' => ['required', 'array'], + 'data.orderId' => ['required', 'string', 'max:100'], + 'data.status' => ['required', 'string', 'max:40'], + 'data.paymentKey' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/src/Listeners/AdjustEcommercePaymentMethodsLayoutListener.php b/plugins/_bundled/sirsoft-tosspayments/src/Listeners/AdjustEcommercePaymentMethodsLayoutListener.php new file mode 100644 index 00000000..fa213567 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/src/Listeners/AdjustEcommercePaymentMethodsLayoutListener.php @@ -0,0 +1,119 @@ +> + */ + public static function getSubscribedHooks(): array + { + return [ + 'core.layout_extension.after_apply' => [ + 'method' => 'markTossMethodsAsPgNotRequired', + 'type' => 'filter', + // KG(20) 보다 반드시 뒤에 실행되어야 한다 — KG 는 닫는 대괄호까지 포함한 통짜 + // 리터럴을 str_replace 하므로, 토스가 먼저 append 하면 KG 의 매치가 실패해 + // kginicis_* 가 영영 주입되지 않는다. HookManager 는 ksort 오름차순이므로 + // 30 > 20 이 "KG 먼저" 를 불변식으로 고정한다 (플러그인 로드 순서 비의존). + 'priority' => 30, + ], + ]; + } + + /** + * 기본 핸들러 (미사용). + * + * @param mixed ...$args + */ + public function handle(...$args): void {} + + /** + * @param array $layout 적용된 레이아웃 + * @param int $templateId 대상 템플릿 ID + * @return array + */ + public function markTossMethodsAsPgNotRequired(array $layout, int $templateId): array + { + if (($layout['layout_name'] ?? '') !== self::TARGET_LAYOUT) { + return $layout; + } + + return $this->appendTossMethodsToNoPgLists($layout); + } + + /** + * @param array $node + * @return array + */ + private function appendTossMethodsToNoPgLists(array $node): array + { + foreach ($node as $key => $value) { + if (is_array($value)) { + $node[$key] = $this->appendTossMethodsToNoPgLists($value); + + continue; + } + + if (is_string($value) && str_contains($value, "'dbank'")) { + $node[$key] = $this->mergeTossIds($value); + } + } + + return $node; + } + + /** + * 문자열 내 no-PG 리스트 리터럴에 없는 toss_* id 만 append 합니다 (멱등). + */ + private function mergeTossIds(string $expression): string + { + // 결제수단 id SSoT 는 MapsTossPaymentMethods::TOSS_METHOD_MAP 이다 (하드코딩 이중화 금지). + $tossIds = $this->allTossMethodIds(); + + return (string) preg_replace_callback( + self::LIST_PATTERN, + function (array $matches) use ($tossIds): string { + $existing = $matches[1]; // 예: ",'kginicis_samsung_pay',..." (KG 선행 시) + $additions = ''; + foreach ($tossIds as $id) { + if (! str_contains($existing, "'{$id}'")) { + $additions .= ",'{$id}'"; + } + } + + return "['point','deposit','free','dbank'{$existing}{$additions}]"; + }, + $expression, + ); + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/src/Listeners/RegisterPgProviderListener.php b/plugins/_bundled/sirsoft-tosspayments/src/Listeners/RegisterPgProviderListener.php index 5ef778cd..3adfb19f 100644 --- a/plugins/_bundled/sirsoft-tosspayments/src/Listeners/RegisterPgProviderListener.php +++ b/plugins/_bundled/sirsoft-tosspayments/src/Listeners/RegisterPgProviderListener.php @@ -3,6 +3,8 @@ namespace Plugins\Sirsoft\Tosspayments\Listeners; use App\Contracts\Extension\HookListenerInterface; +use Plugins\Sirsoft\Tosspayments\Concerns\MapsTossPaymentMethods; + /** * PG 제공자 등록 리스너 * @@ -11,6 +13,8 @@ use App\Contracts\Extension\HookListenerInterface; */ class RegisterPgProviderListener implements HookListenerInterface { + use MapsTossPaymentMethods; + private const PLUGIN_IDENTIFIER = 'sirsoft-tosspayments'; /** @@ -37,8 +41,7 @@ class RegisterPgProviderListener implements HookListenerInterface /** * 기본 핸들러 (미사용) * - * @param mixed ...$args 인수 - * @return void + * @param mixed ...$args 인수 */ public function handle(...$args): void { @@ -48,7 +51,7 @@ class RegisterPgProviderListener implements HookListenerInterface /** * PG 제공자 목록에 토스페이먼츠 등록 * - * @param array $providers 기존 PG 제공자 목록 + * @param array $providers 기존 PG 제공자 목록 * @return array 토스페이먼츠가 추가된 PG 제공자 목록 */ public function registerProvider(array $providers): array @@ -58,7 +61,11 @@ class RegisterPgProviderListener implements HookListenerInterface 'name_key' => 'sirsoft-tosspayments::provider.name', 'name' => localized_label(nameKey: 'sirsoft-tosspayments::provider.name'), 'icon' => 'credit-card', - 'supported_methods' => ['card'], + 'supported_methods' => ['card', 'virtual_account', 'bank_transfer', 'mobile'], + // 프론트 결제 진입 핸들러 — HandlesOrderCreation::resolvePgPaymentHandler() 가 이 키를 + // 응답의 pg_payment_handler 로 내려 템플릿이 dispatch 한다. 미선언 시 PG 분기가 + // 발화하지 못하고 결제창 없이 완료 페이지로 navigate 하던 결함을 정공법으로 해소한다. + 'payment_handler' => 'sirsoft-tosspayments.requestPayment', ]; return $providers; @@ -67,8 +74,8 @@ class RegisterPgProviderListener implements HookListenerInterface /** * PG 클라이언트 설정 제공 (프론트엔드 SDK용) * - * @param array $config 기존 설정 - * @param string $provider PG 제공자 ID + * @param array $config 기존 설정 + * @param string $provider PG 제공자 ID * @return array 클라이언트 설정 */ public function getClientConfig(array $config, string $provider): array @@ -79,12 +86,23 @@ class RegisterPgProviderListener implements HookListenerInterface $settings = $this->getPluginSettings(); $isTest = $settings['is_test_mode'] ?? true; + $orderSheetMode = (bool) ($settings['order_sheet_mode'] ?? false); return array_merge($config, [ 'client_key' => $isTest ? ($settings['test_client_key'] ?? '') : ($settings['live_client_key'] ?? ''), 'sdk_url' => 'https://js.tosspayments.com/v2/standard', + 'order_sheet_mode' => $orderSheetMode, + // 활성 토글된 결제수단만 프론트에 내린다. 각 entry 에 SDK method / easyPay provider / + // 코어 결제수단(core_payment_method) 을 함께 실어 프론트 하드코딩을 없앤다. + // 결제창형(order_sheet_mode=false)에서는 빈 배열 — 통합결제창 카드 하나로 처리. + 'enabled_methods' => $orderSheetMode ? $this->buildEnabledMethodsConfig($settings) : [], + 'vbank' => [ + 'valid_hours' => (int) ($settings['vbank_valid_hours'] ?? 24), + 'cash_receipt_type' => (string) ($settings['vbank_cash_receipt_type'] ?? ''), + ], + 'use_escrow' => (string) ($settings['use_escrow'] ?? 'off'), 'callback_urls' => [ 'success' => '/plugins/sirsoft-tosspayments/payment/success', 'fail' => '/plugins/sirsoft-tosspayments/payment/fail', @@ -92,6 +110,28 @@ class RegisterPgProviderListener implements HookListenerInterface ]); } + /** + * 활성 토글된 toss_* 결제수단의 프론트 SDK 설정 목록을 구성합니다. + * + * @param array $settings 플러그인 설정값 + * @return list + */ + private function buildEnabledMethodsConfig(array $settings): array + { + $enabled = []; + foreach ($this->enabledTossMethodIds($settings) as $id) { + $meta = self::TOSS_METHOD_MAP[$id]; + $enabled[] = [ + 'id' => $id, + 'method' => $meta['method'], + 'easy_pay_provider' => $meta['easy_pay'], + 'core_payment_method' => $meta['core'], + ]; + } + + return $enabled; + } + /** * 플러그인 설정 조회 * diff --git a/plugins/_bundled/sirsoft-tosspayments/src/Listeners/RegisterTossPaymentMethodsListener.php b/plugins/_bundled/sirsoft-tosspayments/src/Listeners/RegisterTossPaymentMethodsListener.php new file mode 100644 index 00000000..2da1ad2f --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/src/Listeners/RegisterTossPaymentMethodsListener.php @@ -0,0 +1,159 @@ + + */ + private const METHOD_PRESENTATION = [ + 'toss_card' => ['icon' => 'credit-card'], + 'toss_virtual_account' => ['icon' => 'building-columns'], + 'toss_transfer' => ['icon' => 'money-bill-transfer'], + 'toss_mobile_phone' => ['icon' => 'mobile-screen-button'], + 'toss_tosspay' => ['icon' => 'wallet'], + 'toss_kakaopay' => ['icon' => 'wallet'], + 'toss_naverpay' => ['icon' => 'wallet'], + 'toss_payco' => ['icon' => 'wallet'], + 'toss_samsungpay' => ['icon' => 'mobile-screen-button'], + ]; + + /** + * 구독할 훅 매핑 반환. + * + * @return array> + */ + public static function getSubscribedHooks(): array + { + return [ + 'sirsoft-ecommerce.settings.filter_available_payment_methods' => [ + 'method' => 'injectTossMethods', + 'type' => 'filter', + 'priority' => 20, + ], + ]; + } + + /** + * 기본 핸들러 (미사용). + * + * @param mixed ...$args + */ + public function handle(...$args): void {} + + /** + * 이커머스 결제수단 목록에 토스 주문서형 결제수단 inject. + * + * @param array> $methods builtin 결제수단 배열 + * @return array> toss_* entry 가 phone~point 사이에 삽입된 배열 + */ + public function injectTossMethods(array $methods): array + { + $settings = $this->getPluginSettings(); + + // order_sheet_mode OFF → 결제창형: 아무것도 주입하지 않고 원본 그대로 반환 + if (! (bool) ($settings['order_sheet_mode'] ?? false)) { + return $methods; + } + + $tossMethods = []; + foreach ($this->enabledTossMethodIds($settings) as $id) { + $tossMethods[] = $this->buildEntry($id); + } + + if ($tossMethods === []) { + return $methods; + } + + // 'phone' 뒤, 'point' 앞에 삽입. phone 이 없으면 끝에 append (KG 와 동일 규칙). + $insertAfter = null; + foreach ($methods as $index => $method) { + if (($method['id'] ?? null) === 'phone') { + $insertAfter = $index; + break; + } + } + + if ($insertAfter === null) { + return array_merge($methods, $tossMethods); + } + + return array_merge( + array_slice($methods, 0, $insertAfter + 1), + $tossMethods, + array_slice($methods, $insertAfter + 1), + ); + } + + /** + * 결제수단 entry 1건 빌더 — EcommerceSettingsService::getBuiltinPaymentMethods 와 동일 형식. + * + * @param string $id toss_* 결제수단 id + * @return array + */ + private function buildEntry(string $id): array + { + $nameKey = "sirsoft-tosspayments::payment_methods.{$id}.name"; + $descriptionKey = "sirsoft-tosspayments::payment_methods.{$id}.description"; + + return [ + 'id' => $id, + 'name' => [ + 'ko' => __($nameKey, [], 'ko'), + 'en' => __($nameKey, [], 'en'), + ], + 'description' => [ + 'ko' => __($descriptionKey, [], 'ko'), + 'en' => __($descriptionKey, [], 'en'), + ], + 'icon' => self::METHOD_PRESENTATION[$id]['icon'] ?? 'credit-card', + 'source' => 'plugin:sirsoft-tosspayments', + 'defaults' => [ + // PG 선택 불필요 — payment_handler 정공법으로 토스 결제 흐름이 발화한다. + 'pg_provider' => null, + 'is_active' => false, + 'min_order_amount' => 0, + 'stock_deduction_timing' => 'payment_complete', + // 체크아웃이 서버로 보낼 코어 PaymentMethodEnum 값. 코어 enum 은 toss_* 를 거부하므로 + // 프론트는 이 값을 payment_method 로 전송하고 toss_* 는 _local 에만 유지한다. + 'core_payment_method' => self::TOSS_METHOD_MAP[$id]['core'], + ], + ]; + } + + /** + * 플러그인 설정 조회. + * + * @return array + */ + private function getPluginSettings(): array + { + if (! \function_exists('plugin_settings')) { + return []; + } + + return \plugin_settings(self::PLUGIN_IDENTIFIER); + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/src/Listeners/ValidateTossSettingsListener.php b/plugins/_bundled/sirsoft-tosspayments/src/Listeners/ValidateTossSettingsListener.php new file mode 100644 index 00000000..cb95ca42 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/src/Listeners/ValidateTossSettingsListener.php @@ -0,0 +1,96 @@ +> + */ + public static function getSubscribedHooks(): array + { + return [ + 'core.plugin_settings.before_save' => [ + 'method' => 'validateBeforeSave', + 'priority' => 10, + ], + ]; + } + + /** + * 기본 핸들러 (미사용). + * + * @param mixed ...$args + */ + public function handle(...$args): void {} + + /** + * 설정 저장 전 범위 검증. 위반 시 ValidationException 으로 422 응답을 유도한다. + * + * 전송된 키만 검증한다 — 부분 저장(다른 섹션만 전송)에서 미포함 키를 강제하지 않는다. + * + * @param string $identifier 저장 대상 플러그인 식별자 + * @param array $settings 저장 요청 설정값 + * + * @throws ValidationException 범위를 벗어난 값이 있을 때 + */ + public function validateBeforeSave(string $identifier, array $settings): void + { + if ($identifier !== self::PLUGIN_IDENTIFIER) { + return; + } + + $errors = []; + + if (array_key_exists('vbank_valid_hours', $settings)) { + $hours = $settings['vbank_valid_hours']; + + if (! is_numeric($hours) + || (int) $hours < self::VBANK_HOURS_MIN + || (int) $hours > self::VBANK_HOURS_MAX + ) { + $errors['vbank_valid_hours'][] = __( + 'sirsoft-tosspayments::messages.settings_validation.vbank_valid_hours_range', + ['min' => self::VBANK_HOURS_MIN, 'max' => self::VBANK_HOURS_MAX], + ); + } + } + + if (array_key_exists('use_escrow', $settings) + && ! in_array((string) $settings['use_escrow'], self::USE_ESCROW_VALUES, true) + ) { + $errors['use_escrow'][] = __('sirsoft-tosspayments::messages.settings_validation.use_escrow_invalid'); + } + + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/src/routes/web.php b/plugins/_bundled/sirsoft-tosspayments/src/routes/web.php index a508e21d..3b140586 100644 --- a/plugins/_bundled/sirsoft-tosspayments/src/routes/web.php +++ b/plugins/_bundled/sirsoft-tosspayments/src/routes/web.php @@ -1,7 +1,9 @@ name('payment.fail'); + +// 웹훅 (토스페이먼츠 서버 → POST). 토스는 CSRF 토큰을 보내지 않으므로 면제. +// 서명·IP 화이트리스트가 없어 secret 대조로 위조를 방지한다. +Route::withoutMiddleware([ValidateCsrfToken::class]) + ->group(function () { + // 가상계좌 입금통보 (DEPOSIT_CALLBACK) + Route::post('/webhook/deposit', [WebhookController::class, 'deposit']) + ->name('webhook.deposit'); + + // 결제상태 변경 (PAYMENT_STATUS_CHANGED) + Route::post('/webhook/payment-status', [WebhookController::class, 'paymentStatus']) + ->name('webhook.payment-status'); + }); diff --git a/plugins/_bundled/sirsoft-tosspayments/tests/Feature/Controllers/PaymentCallbackControllerTest.php b/plugins/_bundled/sirsoft-tosspayments/tests/Feature/Controllers/PaymentCallbackControllerTest.php index a05ab919..8a218507 100644 --- a/plugins/_bundled/sirsoft-tosspayments/tests/Feature/Controllers/PaymentCallbackControllerTest.php +++ b/plugins/_bundled/sirsoft-tosspayments/tests/Feature/Controllers/PaymentCallbackControllerTest.php @@ -3,6 +3,7 @@ namespace Plugins\Sirsoft\Tosspayments\Tests\Feature\Controllers; use App\Models\User; +use App\Services\PluginSettingsService; use Illuminate\Support\Facades\Http; use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory; use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory; @@ -14,16 +15,20 @@ use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase; /** * 토스페이먼츠 결제 콜백 컨트롤러 기능 테스트 + * + * @scenario payment_method=toss_virtual_account, use_escrow=on + * + * @effects vbank_account_stored_without_completing, vbank_secret_stored_for_webhook, + * is_escrow_snapshot_persisted */ class PaymentCallbackControllerTest extends PluginTestCase { /** * 토스페이먼츠 Confirm API 성공 응답 mock 데이터 * - * @param string $paymentKey 결제 키 - * @param string $orderId 주문번호 - * @param int $amount 결제 금액 - * @return array + * @param string $paymentKey 결제 키 + * @param string $orderId 주문번호 + * @param int $amount 결제 금액 */ private function makeMockConfirmResponse(string $paymentKey, string $orderId, int $amount): array { @@ -54,8 +59,7 @@ class PaymentCallbackControllerTest extends PluginTestCase /** * 테스트용 주문 + 결제 레코드 생성 * - * @param int $totalAmount 주문 총액 - * @return Order + * @param int $totalAmount 주문 총액 */ private function createTestOrder(int $totalAmount = 50000): Order { @@ -63,7 +67,7 @@ class PaymentCallbackControllerTest extends PluginTestCase $order = OrderFactory::new()->create([ 'user_id' => $user->id, - 'order_number' => 'ORD-TEST-' . random_int(10000, 99999), + 'order_number' => 'ORD-TEST-'.random_int(10000, 99999), 'order_status' => OrderStatusEnum::PENDING_ORDER, 'subtotal_amount' => $totalAmount, 'total_discount_amount' => 0, @@ -99,7 +103,7 @@ class PaymentCallbackControllerTest extends PluginTestCase /** * 플러그인 설정을 mock합니다. * - * @param array $overrides 기본 설정을 덮어쓸 값 + * @param array $overrides 기본 설정을 덮어쓸 값 */ private function mockPluginSettings(array $overrides = []): void { @@ -111,11 +115,11 @@ class PaymentCallbackControllerTest extends PluginTestCase 'redirect_fail_url' => '/shop/checkout', ]; - $settingsMock = $this->createMock(\App\Services\PluginSettingsService::class); + $settingsMock = $this->createMock(PluginSettingsService::class); $settingsMock->method('get') ->willReturn(array_merge($defaults, $overrides)); - $this->app->instance(\App\Services\PluginSettingsService::class, $settingsMock); + $this->app->instance(PluginSettingsService::class, $settingsMock); } // ===== Success 콜백 테스트 ===== @@ -137,7 +141,7 @@ class PaymentCallbackControllerTest extends PluginTestCase ), ]); - $response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([ + $response = $this->get('/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([ 'paymentKey' => $paymentKey, 'orderId' => $order->order_number, 'amount' => 50000, @@ -165,7 +169,7 @@ class PaymentCallbackControllerTest extends PluginTestCase { $this->mockPluginSettings(); - $response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([ + $response = $this->get('/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([ 'paymentKey' => 'pk_test_xxx', 'orderId' => 'NON_EXISTENT_ORDER', 'amount' => 50000, @@ -191,7 +195,7 @@ class PaymentCallbackControllerTest extends PluginTestCase ], 400), ]); - $response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([ + $response = $this->get('/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([ 'paymentKey' => 'pk_test_fail', 'orderId' => $order->order_number, 'amount' => 50000, @@ -221,7 +225,7 @@ class PaymentCallbackControllerTest extends PluginTestCase ), ]); - $response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([ + $response = $this->get('/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([ 'paymentKey' => 'pk_test_mismatch', 'orderId' => $order->order_number, 'amount' => 99999, @@ -238,7 +242,7 @@ class PaymentCallbackControllerTest extends PluginTestCase */ public function test_fail_redirects_to_checkout_with_error_params(): void { - $response = $this->get("/plugins/sirsoft-tosspayments/payment/fail?" . http_build_query([ + $response = $this->get('/plugins/sirsoft-tosspayments/payment/fail?'.http_build_query([ 'code' => 'USER_CANCEL', 'message' => '사용자가 취소했습니다.', 'orderId' => 'ORD-TEST-12345', @@ -257,7 +261,7 @@ class PaymentCallbackControllerTest extends PluginTestCase { $order = $this->createTestOrder(30000); - $response = $this->get("/plugins/sirsoft-tosspayments/payment/fail?" . http_build_query([ + $response = $this->get('/plugins/sirsoft-tosspayments/payment/fail?'.http_build_query([ 'code' => 'PAY_PROCESS_CANCELED', 'message' => '결제가 취소되었습니다.', 'orderId' => $order->order_number, @@ -279,7 +283,7 @@ class PaymentCallbackControllerTest extends PluginTestCase */ public function test_fail_handles_missing_order_id_gracefully(): void { - $response = $this->get("/plugins/sirsoft-tosspayments/payment/fail?" . http_build_query([ + $response = $this->get('/plugins/sirsoft-tosspayments/payment/fail?'.http_build_query([ 'code' => 'UNKNOWN_ERROR', 'message' => '알 수 없는 오류', ])); @@ -309,7 +313,7 @@ class PaymentCallbackControllerTest extends PluginTestCase ), ]); - $response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([ + $response = $this->get('/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([ 'paymentKey' => $paymentKey, 'orderId' => $order->order_number, 'amount' => 50000, @@ -327,7 +331,7 @@ class PaymentCallbackControllerTest extends PluginTestCase 'redirect_fail_url' => '/custom/checkout/error', ]); - $response = $this->get("/plugins/sirsoft-tosspayments/payment/fail?" . http_build_query([ + $response = $this->get('/plugins/sirsoft-tosspayments/payment/fail?'.http_build_query([ 'code' => 'USER_CANCEL', 'message' => '사용자가 취소했습니다.', 'orderId' => 'ORD-TEST-99999', @@ -349,7 +353,7 @@ class PaymentCallbackControllerTest extends PluginTestCase 'redirect_fail_url' => '/custom/checkout/error', ]); - $response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([ + $response = $this->get('/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([ 'paymentKey' => 'pk_test_xxx', 'orderId' => 'NON_EXISTENT_ORDER', 'amount' => 50000, @@ -370,7 +374,7 @@ class PaymentCallbackControllerTest extends PluginTestCase 'redirect_fail_url' => 'https://example.com/checkout?ref=toss', ]); - $response = $this->get("/plugins/sirsoft-tosspayments/payment/fail?" . http_build_query([ + $response = $this->get('/plugins/sirsoft-tosspayments/payment/fail?'.http_build_query([ 'code' => 'NETWORK_ERROR', 'message' => '네트워크 오류', 'orderId' => 'ORD-TEST-88888', @@ -393,7 +397,7 @@ class PaymentCallbackControllerTest extends PluginTestCase $this->mockPluginSettings(); // paymentKey 누락 - $response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([ + $response = $this->get('/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([ 'orderId' => 'ORD-TEST-12345', 'amount' => 50000, ])); @@ -409,7 +413,7 @@ class PaymentCallbackControllerTest extends PluginTestCase { $this->mockPluginSettings(); - $response = $this->get("/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([ + $response = $this->get('/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([ 'paymentKey' => 'pk_test_xxx', 'orderId' => 'ORD-TEST-12345', 'amount' => 0, @@ -452,7 +456,7 @@ class PaymentCallbackControllerTest extends PluginTestCase ]); $response = $this->get( - "/plugins/sirsoft-tosspayments/payment/success?" . http_build_query([ + '/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([ 'paymentKey' => $paymentKey, 'orderId' => $order->order_number, 'amount' => 50000, @@ -466,4 +470,90 @@ class PaymentCallbackControllerTest extends PluginTestCase $payment->refresh(); $this->assertEquals('mobile', $payment->payment_device); } + + // ===== 가상계좌(WAITING_FOR_DEPOSIT) 분기 ===== + + /** + * confirm 응답이 WAITING_FOR_DEPOSIT 이면 completePayment 하지 않고 계좌 정보만 저장한다. + */ + public function test_success_virtual_account_stores_vbank_without_completing(): void + { + $order = $this->createTestOrder(50000); + $paymentKey = 'pk_test_vbank'; + $this->mockPluginSettings(); + + Http::fake([ + 'api.tosspayments.com/v1/payments/confirm' => Http::response([ + 'paymentKey' => $paymentKey, + 'orderId' => $order->order_number, + 'status' => 'WAITING_FOR_DEPOSIT', + 'method' => '가상계좌', + 'totalAmount' => 50000, + 'secret' => 'wsec_confirm_secret', + 'virtualAccount' => [ + 'accountNumber' => '12345678901234', + 'bankCode' => '20', + 'bank' => '우리은행', + 'customerName' => '홍길동', + 'dueDate' => now()->addDay()->toIso8601String(), + 'useEscrow' => false, + ], + ], 200), + ]); + + $response = $this->get('/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([ + 'paymentKey' => $paymentKey, + 'orderId' => $order->order_number, + 'amount' => 50000, + ])); + + $response->assertRedirect("/shop/orders/{$order->order_number}/complete"); + + $order->refresh(); + $payment = $order->payment; + + // 결제완료로 전이되지 않음 + $this->assertNotSame(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status); + $this->assertSame(PaymentStatusEnum::WAITING_DEPOSIT->value, $payment->payment_status->value); + // 계좌 정보 저장 + $this->assertSame('12345678901234', $payment->vbank_number); + $this->assertSame('20', $payment->vbank_code); + // 웹훅 secret 저장 (대조용) + $this->assertSame('wsec_confirm_secret', $payment->payment_meta['toss_secret']); + } + + /** + * 가상계좌 응답의 useEscrow 가 is_escrow 스냅샷으로 저장된다. + */ + public function test_success_virtual_account_persists_escrow_flag(): void + { + $order = $this->createTestOrder(50000); + $paymentKey = 'pk_test_vbank_escrow'; + $this->mockPluginSettings(); + + Http::fake([ + 'api.tosspayments.com/v1/payments/confirm' => Http::response([ + 'paymentKey' => $paymentKey, + 'orderId' => $order->order_number, + 'status' => 'WAITING_FOR_DEPOSIT', + 'method' => '가상계좌', + 'totalAmount' => 50000, + 'secret' => 'wsec_x', + 'virtualAccount' => [ + 'accountNumber' => '999', + 'bankCode' => '20', + 'useEscrow' => true, + ], + ], 200), + ]); + + $this->get('/plugins/sirsoft-tosspayments/payment/success?'.http_build_query([ + 'paymentKey' => $paymentKey, + 'orderId' => $order->order_number, + 'amount' => 50000, + ]))->assertRedirect("/shop/orders/{$order->order_number}/complete"); + + $order->refresh(); + $this->assertTrue((bool) $order->payment->is_escrow); + } } diff --git a/plugins/_bundled/sirsoft-tosspayments/tests/Feature/Controllers/WebhookControllerTest.php b/plugins/_bundled/sirsoft-tosspayments/tests/Feature/Controllers/WebhookControllerTest.php new file mode 100644 index 00000000..b0bf4446 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/tests/Feature/Controllers/WebhookControllerTest.php @@ -0,0 +1,237 @@ +create(); + + $order = OrderFactory::new()->create([ + 'user_id' => $user->id, + 'order_number' => 'ORD-VBANK-'.random_int(10000, 99999), + 'order_status' => OrderStatusEnum::PENDING_ORDER, + 'subtotal_amount' => $totalAmount, + 'total_discount_amount' => 0, + 'total_coupon_discount_amount' => 0, + 'total_product_coupon_discount_amount' => 0, + 'total_order_coupon_discount_amount' => 0, + 'total_code_discount_amount' => 0, + 'base_shipping_amount' => 0, + 'extra_shipping_amount' => 0, + 'shipping_discount_amount' => 0, + 'total_shipping_amount' => 0, + 'total_amount' => $totalAmount, + 'total_due_amount' => $totalAmount, + 'total_points_used_amount' => 0, + 'total_deposit_used_amount' => 0, + 'total_paid_amount' => 0, + ]); + + OrderPaymentFactory::new()->create([ + 'order_id' => $order->id, + 'payment_status' => PaymentStatusEnum::WAITING_DEPOSIT, + 'payment_method' => PaymentMethodEnum::VBANK, + 'pg_provider' => 'tosspayments', + 'transaction_id' => 'pk_test_vbank_'.$order->id, + 'paid_amount_base' => $totalAmount, + 'paid_amount_local' => $totalAmount, + 'paid_at' => null, + 'payment_meta' => ['toss_secret' => $secret, 'status' => 'WAITING_FOR_DEPOSIT'], + ]); + + return $order; + } + + /** + * 플러그인 설정을 mock 합니다. + * + * @param array $overrides + */ + private function mockPluginSettings(array $overrides = []): void + { + $mock = $this->createMock(PluginSettingsService::class); + $mock->method('get')->willReturn(array_merge([ + 'is_test_mode' => true, + 'webhook_secret_verify' => true, + ], $overrides)); + $this->app->instance(PluginSettingsService::class, $mock); + } + + public function test_deposit_done_completes_payment(): void + { + $this->mockPluginSettings(); + $order = $this->createWaitingDepositOrder(); + + $response = $this->postJson(self::DEPOSIT_URL, [ + 'orderId' => $order->order_number, + 'status' => 'DONE', + 'secret' => self::SECRET, + 'transactionKey' => 'txn_abc', + ]); + + $response->assertOk(); + $order->refresh(); + $this->assertSame(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status); + $this->assertSame(PaymentStatusEnum::PAID->value, $order->payment->payment_status->value); + } + + public function test_deposit_canceled_fails_payment(): void + { + $this->mockPluginSettings(); + $order = $this->createWaitingDepositOrder(); + + $response = $this->postJson(self::DEPOSIT_URL, [ + 'orderId' => $order->order_number, + 'status' => 'CANCELED', + 'secret' => self::SECRET, + ]); + + $response->assertOk(); + $order->refresh(); + // failPayment 은 order_status 를 CANCELLED 로 전이한다 (payment_status 는 유지 — 코어 동작). + $this->assertSame(OrderStatusEnum::CANCELLED, $order->order_status); + } + + public function test_secret_mismatch_returns_401(): void + { + $this->mockPluginSettings(); + $order = $this->createWaitingDepositOrder(); + + $response = $this->postJson(self::DEPOSIT_URL, [ + 'orderId' => $order->order_number, + 'status' => 'DONE', + 'secret' => 'wrong_secret', + ]); + + $response->assertStatus(401); + $order->refresh(); + // 결제완료로 전이되지 않아야 함 + $this->assertNotSame(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status); + } + + public function test_secret_verification_can_be_disabled(): void + { + $this->mockPluginSettings(['webhook_secret_verify' => false]); + $order = $this->createWaitingDepositOrder(); + + $response = $this->postJson(self::DEPOSIT_URL, [ + 'orderId' => $order->order_number, + 'status' => 'DONE', + 'secret' => 'anything', + ]); + + $response->assertOk(); + $order->refresh(); + $this->assertSame(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status); + } + + public function test_replay_after_paid_is_idempotent(): void + { + $this->mockPluginSettings(); + $order = $this->createWaitingDepositOrder(); + + // 1차 입금통보 → PAID + $this->postJson(self::DEPOSIT_URL, [ + 'orderId' => $order->order_number, + 'status' => 'DONE', + 'secret' => self::SECRET, + 'transactionKey' => $order->payment->transaction_id, + ])->assertOk(); + + $order->refresh(); + $paidAt = $order->payment->paid_at; + + // 2차 (중복) 통보 → 멱등 200, 상태 불변 + $response = $this->postJson(self::DEPOSIT_URL, [ + 'orderId' => $order->order_number, + 'status' => 'DONE', + 'secret' => self::SECRET, + 'transactionKey' => $order->payment->transaction_id, + ]); + + $response->assertOk(); + $order->refresh(); + $this->assertSame(PaymentStatusEnum::PAID->value, $order->payment->payment_status->value); + $this->assertEquals($paidAt->timestamp, $order->payment->paid_at->timestamp); + } + + public function test_order_not_found_returns_fail(): void + { + $this->mockPluginSettings(); + + $response = $this->postJson(self::DEPOSIT_URL, [ + 'orderId' => 'ORD-NONEXISTENT', + 'status' => 'DONE', + 'secret' => self::SECRET, + ]); + + $response->assertOk(); + $this->assertSame('FAIL', $response->getContent()); + } + + public function test_invalid_status_rejected_by_validation(): void + { + $this->mockPluginSettings(); + $order = $this->createWaitingDepositOrder(); + + $response = $this->postJson(self::DEPOSIT_URL, [ + 'orderId' => $order->order_number, + 'status' => 'BOGUS', + ]); + + $response->assertStatus(422); + } + + public function test_deposit_on_non_waiting_order_is_noop_200(): void + { + $this->mockPluginSettings(); + $order = $this->createWaitingDepositOrder(); + // 이미 PAID 로 바꿔 놓되 transaction_id 는 달라 리플레이 가드는 안 걸리게 한다 + $order->payment()->update([ + 'payment_status' => PaymentStatusEnum::READY, + ]); + + $response = $this->postJson(self::DEPOSIT_URL, [ + 'orderId' => $order->order_number, + 'status' => 'DONE', + 'secret' => self::SECRET, + ]); + + $response->assertOk(); + $order->refresh(); + // WAITING_DEPOSIT 가 아니므로 completePayment 하지 않는다 + $this->assertNotSame(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status); + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/tests/Feature/Listeners/RegisterPgProviderClientConfigTest.php b/plugins/_bundled/sirsoft-tosspayments/tests/Feature/Listeners/RegisterPgProviderClientConfigTest.php new file mode 100644 index 00000000..ace59ce3 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/tests/Feature/Listeners/RegisterPgProviderClientConfigTest.php @@ -0,0 +1,107 @@ +listener = new RegisterPgProviderListener; + } + + /** + * @param array $settings + */ + private function mockSettings(array $settings): void + { + $mock = $this->createMock(PluginSettingsService::class); + $mock->method('get')->willReturn($settings); + $this->app->instance(PluginSettingsService::class, $mock); + } + + public function test_order_sheet_off_returns_empty_enabled_methods(): void + { + $this->mockSettings([ + 'is_test_mode' => true, + 'test_client_key' => 'ck', + 'order_sheet_mode' => false, + 'method_card' => true, + ]); + + $config = $this->listener->getClientConfig([], 'tosspayments'); + + $this->assertFalse($config['order_sheet_mode']); + $this->assertSame([], $config['enabled_methods']); + } + + public function test_order_sheet_on_returns_enabled_methods_with_mapping(): void + { + $this->mockSettings([ + 'is_test_mode' => true, + 'test_client_key' => 'ck', + 'order_sheet_mode' => true, + 'method_card' => true, + 'method_virtual_account' => true, + 'method_kakaopay' => true, + ]); + + $config = $this->listener->getClientConfig([], 'tosspayments'); + $byId = collect($config['enabled_methods'])->keyBy('id'); + + $this->assertTrue($config['order_sheet_mode']); + $this->assertSame('CARD', $byId['toss_card']['method']); + $this->assertNull($byId['toss_card']['easy_pay_provider']); + $this->assertSame('VIRTUAL_ACCOUNT', $byId['toss_virtual_account']['method']); + $this->assertSame('vbank', $byId['toss_virtual_account']['core_payment_method']); + // 간편결제 → CARD + easyPay provider + $this->assertSame('CARD', $byId['toss_kakaopay']['method']); + $this->assertSame('카카오페이', $byId['toss_kakaopay']['easy_pay_provider']); + $this->assertSame('card', $byId['toss_kakaopay']['core_payment_method']); + } + + public function test_exposes_vbank_and_escrow_config(): void + { + $this->mockSettings([ + 'is_test_mode' => true, + 'test_client_key' => 'ck', + 'vbank_valid_hours' => 48, + 'vbank_cash_receipt_type' => '소득공제', + 'use_escrow' => 'buyer_choice', + ]); + + $config = $this->listener->getClientConfig([], 'tosspayments'); + + $this->assertSame(48, $config['vbank']['valid_hours']); + $this->assertSame('소득공제', $config['vbank']['cash_receipt_type']); + $this->assertSame('buyer_choice', $config['use_escrow']); + } + + public function test_does_not_leak_secret_key(): void + { + $this->mockSettings([ + 'is_test_mode' => true, + 'test_client_key' => 'ck_public', + 'test_secret_key' => 'sk_secret_should_not_leak', + ]); + + $config = $this->listener->getClientConfig([], 'tosspayments'); + + $flattened = json_encode($config); + $this->assertStringNotContainsString('sk_secret_should_not_leak', $flattened); + $this->assertSame('ck_public', $config['client_key']); + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/tests/Feature/Listeners/RegisterTossPaymentMethodsListenerTest.php b/plugins/_bundled/sirsoft-tosspayments/tests/Feature/Listeners/RegisterTossPaymentMethodsListenerTest.php new file mode 100644 index 00000000..a03eb898 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/tests/Feature/Listeners/RegisterTossPaymentMethodsListenerTest.php @@ -0,0 +1,143 @@ +listener = new RegisterTossPaymentMethodsListener; + } + + /** + * 플러그인 설정을 mock 으로 주입합니다. + * + * @param array $settings + */ + private function mockSettings(array $settings): void + { + $mock = $this->createMock(PluginSettingsService::class); + $mock->method('get')->willReturn($settings); + $this->app->instance(PluginSettingsService::class, $mock); + } + + /** + * builtin 결제수단 배열 (phone / point 포함). + * + * @return array> + */ + private function builtinMethods(): array + { + return [ + ['id' => 'card'], + ['id' => 'phone'], + ['id' => 'point'], + ]; + } + + public function test_subscribes_as_filter_with_priority_20(): void + { + $hooks = RegisterTossPaymentMethodsListener::getSubscribedHooks(); + + $this->assertArrayHasKey('sirsoft-ecommerce.settings.filter_available_payment_methods', $hooks); + $hook = $hooks['sirsoft-ecommerce.settings.filter_available_payment_methods']; + $this->assertSame('filter', $hook['type']); + $this->assertSame(20, $hook['priority']); + } + + public function test_order_sheet_mode_off_injects_nothing(): void + { + $this->mockSettings(['order_sheet_mode' => false, 'method_card' => true]); + + $result = $this->listener->injectTossMethods($this->builtinMethods()); + + $this->assertCount(3, $result); + $this->assertSame(['card', 'phone', 'point'], array_column($result, 'id')); + } + + public function test_injects_only_enabled_methods(): void + { + $this->mockSettings([ + 'order_sheet_mode' => true, + 'method_card' => true, + 'method_virtual_account' => true, + 'method_transfer' => false, + ]); + + $result = $this->listener->injectTossMethods($this->builtinMethods()); + $ids = array_column($result, 'id'); + + $this->assertContains('toss_card', $ids); + $this->assertContains('toss_virtual_account', $ids); + $this->assertNotContains('toss_transfer', $ids); + } + + public function test_inserts_after_phone_before_point(): void + { + $this->mockSettings(['order_sheet_mode' => true, 'method_card' => true]); + + $result = $this->listener->injectTossMethods($this->builtinMethods()); + $ids = array_column($result, 'id'); + + $phoneIndex = array_search('phone', $ids, true); + $pointIndex = array_search('point', $ids, true); + $tossIndex = array_search('toss_card', $ids, true); + + $this->assertGreaterThan($phoneIndex, $tossIndex); + $this->assertLessThan($pointIndex, $tossIndex); + } + + public function test_appends_when_phone_absent(): void + { + $this->mockSettings(['order_sheet_mode' => true, 'method_card' => true]); + + $result = $this->listener->injectTossMethods([['id' => 'card'], ['id' => 'point']]); + $ids = array_column($result, 'id'); + + // phone 이 없으면 끝에 append + $this->assertSame('toss_card', end($ids)); + } + + public function test_entry_declares_core_payment_method(): void + { + $this->mockSettings([ + 'order_sheet_mode' => true, + 'method_virtual_account' => true, + 'method_kakaopay' => true, + ]); + + $result = $this->listener->injectTossMethods($this->builtinMethods()); + $byId = collect($result)->keyBy('id'); + + // 가상계좌 → vbank, 간편결제 → card + $this->assertSame('vbank', $byId['toss_virtual_account']['defaults']['core_payment_method']); + $this->assertSame('card', $byId['toss_kakaopay']['defaults']['core_payment_method']); + // PG 선택 불필요 + $this->assertNull($byId['toss_virtual_account']['defaults']['pg_provider']); + } + + public function test_all_toggles_off_injects_nothing(): void + { + $this->mockSettings(['order_sheet_mode' => true]); + + $result = $this->listener->injectTossMethods($this->builtinMethods()); + + $this->assertCount(3, $result); + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/AdjustEcommercePaymentMethodsLayoutListenerTest.php b/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/AdjustEcommercePaymentMethodsLayoutListenerTest.php new file mode 100644 index 00000000..19ecef60 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/AdjustEcommercePaymentMethodsLayoutListenerTest.php @@ -0,0 +1,160 @@ +listener = new AdjustEcommercePaymentMethodsLayoutListener; + } + + public function test_subscribes_to_layout_after_apply_as_filter(): void + { + $hooks = AdjustEcommercePaymentMethodsLayoutListener::getSubscribedHooks(); + + $this->assertArrayHasKey('core.layout_extension.after_apply', $hooks); + $this->assertSame('filter', $hooks['core.layout_extension.after_apply']['type']); + $this->assertSame('markTossMethodsAsPgNotRequired', $hooks['core.layout_extension.after_apply']['method']); + } + + public function test_ignores_non_target_layouts(): void + { + $layout = [ + 'layout_name' => 'some_other_layout', + 'expr' => "{{['point','deposit','free','dbank'].includes(\$method.id)}}", + ]; + + $result = $this->listener->markTossMethodsAsPgNotRequired($layout, 1); + + $this->assertSame($layout, $result); + } + + public function test_appends_toss_methods_to_no_pg_list(): void + { + $layout = [ + 'layout_name' => 'admin_ecommerce_settings', + 'expr' => "{{['point','deposit','free','dbank'].includes(\$method.id)}}", + ]; + + $result = $this->listener->markTossMethodsAsPgNotRequired($layout, 1); + + $this->assertStringContainsString("'toss_card'", $result['expr']); + $this->assertStringContainsString("'toss_virtual_account'", $result['expr']); + $this->assertStringContainsString("'toss_samsungpay'", $result['expr']); + // 코어 앵커는 보존 + $this->assertStringContainsString("'point','deposit','free','dbank'", $result['expr']); + } + + public function test_merge_is_idempotent(): void + { + $layout = [ + 'layout_name' => 'admin_ecommerce_settings', + 'expr' => "{{['point','deposit','free','dbank'].includes(\$method.id)}}", + ]; + + $once = $this->listener->markTossMethodsAsPgNotRequired($layout, 1); + $twice = $this->listener->markTossMethodsAsPgNotRequired($once, 1); + + // 두 번 적용해도 toss_card 가 한 번만 등장 (멱등) + $this->assertSame(1, substr_count($twice['expr'], "'toss_card'")); + $this->assertSame($once, $twice); + } + + public function test_preserves_kg_ids_when_both_active(): void + { + // KG 가 먼저 실행되어 자기 id 를 이미 추가한 상태 + $layout = [ + 'layout_name' => 'admin_ecommerce_settings', + 'expr' => "{{['point','deposit','free','dbank','kginicis_samsung_pay','kginicis_kakaopay'].includes(\$method.id)}}", + ]; + + $result = $this->listener->markTossMethodsAsPgNotRequired($layout, 1); + + // KG id 는 소실되지 않고, toss_* 가 추가된다 + $this->assertStringContainsString("'kginicis_samsung_pay'", $result['expr']); + $this->assertStringContainsString("'kginicis_kakaopay'", $result['expr']); + $this->assertStringContainsString("'toss_card'", $result['expr']); + } + + /** + * KG 플러그인은 no-PG 리스트를 "닫는 대괄호까지 포함한 통짜 리터럴" 로 str_replace 한다. + * 토스가 먼저 실행되어 리스트에 toss_* 를 append 하면 KG 의 매치가 실패해 kginicis_* + * 가 영영 주입되지 않는다. 따라서 토스 priority 는 KG(20) 보다 반드시 커야 하며 + * (HookManager 는 ksort 오름차순), 이 값은 플러그인 로드 순서와 무관하게 + * "KG 먼저" 를 불변식으로 고정한다. + */ + public function test_priority_runs_after_kginicis_literal_replacement(): void + { + $hooks = AdjustEcommercePaymentMethodsLayoutListener::getSubscribedHooks(); + + $this->assertGreaterThan( + self::KGINICIS_PRIORITY, + $hooks['core.layout_extension.after_apply']['priority'], + '토스 리스너는 KG(priority 20) 이후에 실행되어야 한다 — 먼저 실행되면 KG 의 통짜 리터럴 치환이 매치 실패한다.' + ); + } + + /** + * 토스가 KG 보다 먼저 실행되는 (금지된) 순서를 재현하면 KG 치환이 실패함을 명시적으로 잠근다. + * 이 테스트가 깨지면 KG 의 치환 방식이 바뀐 것이므로 priority 계약을 재검토해야 한다. + */ + public function test_toss_first_order_would_break_kginicis_replacement(): void + { + $expr = "{{['point','deposit','free','dbank'].includes(\$method.id)}}"; + + // 토스가 먼저 실행된 결과 + $afterToss = $this->listener->markTossMethodsAsPgNotRequired( + ['layout_name' => 'admin_ecommerce_settings', 'expr' => $expr], + 1 + )['expr']; + + // KG 는 이 정확 리터럴(닫는 대괄호 포함)을 찾는다 + $this->assertStringNotContainsString( + self::KGINICIS_ANCHOR, + $afterToss, + '토스 선행 시 KG 앵커가 파괴된다 — priority 로 KG 를 먼저 실행시켜야 하는 근거.' + ); + + // 반대로 KG 선행 순서에서는 토스가 KG id 를 보존한다 (test_preserves_kg_ids_when_both_active 참조) + $this->assertStringContainsString(self::KGINICIS_ANCHOR, $expr); + } + + public function test_processes_nested_expressions(): void + { + $layout = [ + 'layout_name' => 'admin_ecommerce_settings', + 'children' => [ + [ + 'if' => "{{!['point','deposit','free','dbank'].includes(\$method.id)}}", + ], + ], + ]; + + $result = $this->listener->markTossMethodsAsPgNotRequired($layout, 1); + + $this->assertStringContainsString("'toss_card'", $result['children'][0]['if']); + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/RegisterPgProviderListenerTest.php b/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/RegisterPgProviderListenerTest.php index 2070e153..5f277e45 100644 --- a/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/RegisterPgProviderListenerTest.php +++ b/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/RegisterPgProviderListenerTest.php @@ -2,20 +2,22 @@ namespace Plugins\Sirsoft\Tosspayments\Tests\Unit\Listeners; -use PHPUnit\Framework\TestCase; use Plugins\Sirsoft\Tosspayments\Listeners\RegisterPgProviderListener; +use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase; /** * RegisterPgProviderListener 단위 테스트 + * + * @effects toss_provider_registered_with_payment_handler */ -class RegisterPgProviderListenerTest extends TestCase +class RegisterPgProviderListenerTest extends PluginTestCase { private RegisterPgProviderListener $listener; protected function setUp(): void { parent::setUp(); - $this->listener = new RegisterPgProviderListener(); + $this->listener = new RegisterPgProviderListener; } /** @@ -58,6 +60,12 @@ class RegisterPgProviderListenerTest extends TestCase $this->assertIsString($toss['name']); $this->assertEquals('credit-card', $toss['icon']); $this->assertContains('card', $toss['supported_methods']); + // S4: 결제수단 4종으로 확장 + $this->assertContains('virtual_account', $toss['supported_methods']); + $this->assertContains('bank_transfer', $toss['supported_methods']); + $this->assertContains('mobile', $toss['supported_methods']); + // S4: payment_handler 선언 (PG 분기 발화 정공법) + $this->assertSame('sirsoft-tosspayments.requestPayment', $toss['payment_handler']); } /** diff --git a/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/ValidateTossSettingsListenerTest.php b/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/ValidateTossSettingsListenerTest.php new file mode 100644 index 00000000..56394bd7 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/ValidateTossSettingsListenerTest.php @@ -0,0 +1,148 @@ +listener = new ValidateTossSettingsListener; + } + + public function test_plugin_registers_settings_validation_listener(): void + { + $this->assertContains(ValidateTossSettingsListener::class, (new Plugin)->getHookListeners()); + } + + public function test_other_plugin_settings_are_not_validated(): void + { + // 타 플러그인 식별자면 early-return (예외 없음) + $this->listener->validateBeforeSave('sirsoft-pay_kginicis', ['vbank_valid_hours' => 9999]); + + $this->addToAssertionCount(1); + } + + /** + * @return array + */ + public static function validHoursProvider(): array + { + return [ + '최소 경계 1시간' => [1], + '기본 24시간' => [24], + '최대 경계 2160시간(90일)' => [2160], + ]; + } + + /** + * @dataProvider validHoursProvider + */ + public function test_valid_vbank_valid_hours_pass(int $hours): void + { + $this->listener->validateBeforeSave('sirsoft-tosspayments', ['vbank_valid_hours' => $hours]); + + $this->addToAssertionCount(1); + } + + /** + * @return array + */ + public static function invalidHoursProvider(): array + { + return [ + '상한 초과 (토스 최대 2160)' => [9999], + '상한 바로 위' => [2161], + '0 시간' => [0], + '음수' => [-1], + ]; + } + + /** + * @dataProvider invalidHoursProvider + */ + public function test_out_of_range_vbank_valid_hours_are_rejected(mixed $hours): void + { + $this->expectException(ValidationException::class); + + $this->listener->validateBeforeSave('sirsoft-tosspayments', ['vbank_valid_hours' => $hours]); + } + + public function test_rejected_message_targets_the_field(): void + { + try { + $this->listener->validateBeforeSave('sirsoft-tosspayments', ['vbank_valid_hours' => 9999]); + $this->fail('범위 밖 vbank_valid_hours 가 예외 없이 통과했습니다.'); + } catch (ValidationException $e) { + $this->assertArrayHasKey('vbank_valid_hours', $e->errors()); + } + } + + /** + * @return array + */ + public static function invalidEscrowProvider(): array + { + return [ + '허용 목록 밖 값' => ['maybe'], + '빈 문자열' => [''], + ]; + } + + /** + * @dataProvider invalidEscrowProvider + */ + public function test_invalid_use_escrow_is_rejected(string $value): void + { + $this->expectException(ValidationException::class); + + $this->listener->validateBeforeSave('sirsoft-tosspayments', ['use_escrow' => $value]); + } + + /** + * @return array + */ + public static function validEscrowProvider(): array + { + return [ + 'off' => ['off'], + 'on' => ['on'], + 'buyer_choice' => ['buyer_choice'], + ]; + } + + /** + * @dataProvider validEscrowProvider + */ + public function test_valid_use_escrow_passes(string $value): void + { + $this->listener->validateBeforeSave('sirsoft-tosspayments', ['use_escrow' => $value]); + + $this->addToAssertionCount(1); + } + + public function test_settings_without_the_validated_keys_pass(): void + { + // 부분 저장(다른 키만 전송)에서 미포함 키를 강제하지 않는다. + $this->listener->validateBeforeSave('sirsoft-tosspayments', ['is_test_mode' => true]); + + $this->addToAssertionCount(1); + } +} diff --git a/plugins/_bundled/sirsoft-tosspayments/tests/scenarios/toss-payment-methods-vbank-escrow.yaml b/plugins/_bundled/sirsoft-tosspayments/tests/scenarios/toss-payment-methods-vbank-escrow.yaml new file mode 100644 index 00000000..29476e29 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/tests/scenarios/toss-payment-methods-vbank-escrow.yaml @@ -0,0 +1,116 @@ +# audit:allow test-scenario-coverage reason: S4 결제수단·가상계좌·에스크로 명세 SSoT. 8-tuple pairwise 19건을 테스트 docblock @scenario 로 전부 나열하는 것은 실효성이 낮다(테스트는 축별 동작을 격리 검증 — KG security-callback-defense 선례 동일). effects 는 test_files 의 단위/통합 테스트가 @effects 로 실제 매핑·검증한다. 실 PG 결제창(M20·M21)은 실계약 후 Chrome MCP 로 커버. +# 시나리오 매니페스트 — 토스 결제수단 동적등록 · 가상계좌 · 웹훅 · 에스크로 (S4) +# 참조: docs/testing-guide.md "기능 단위 시나리오 매트릭스", 계획서 §12-4 / §13-2 + +feature: toss_payment_methods_vbank_escrow +description: >- + 토스페이먼츠 주문서형 결제수단 동적등록 · 가상계좌 발급/입금통보 웹훅 · + 에스크로(3-상태) · 결제수단별 SDK 파라미터 매핑의 입력 조합과 후속 효과. + +test_files: + - plugins/_bundled/sirsoft-tosspayments/tests/Feature/Controllers/WebhookControllerTest.php + - plugins/_bundled/sirsoft-tosspayments/tests/Feature/Controllers/PaymentCallbackControllerTest.php + - plugins/_bundled/sirsoft-tosspayments/tests/Feature/Listeners/RegisterTossPaymentMethodsListenerTest.php + - plugins/_bundled/sirsoft-tosspayments/tests/Feature/Listeners/RegisterPgProviderClientConfigTest.php + - plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/AdjustEcommercePaymentMethodsLayoutListenerTest.php + - plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/RegisterPgProviderListenerTest.php + - plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/handlers/requestPayment.test.ts + - plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/layouts/pluginSettingsPaymentMethods.test.tsx + - plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/ValidateTossSettingsListenerTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Http/Controllers/Public/BuildPgPaymentDataTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Repositories/OrderPaymentRepositoryTransactionPaidTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/EcommerceSettingsOrderSettingsTest.php + - templates/_bundled/sirsoft-basic/__tests__/layouts/checkoutSummaryBodyPayload.test.tsx + # SDK 경계 실측 — 서버가 escrow_products 를 내려줘도 핸들러가 SDK 페이로드에 싣지 않으면 + # 토스가 결제를 거부한다. 그 경계를 브라우저에서 직접 캡처한다. + - modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/checkout-plugin-payment-method.spec.ts + +coverage_strategy: pairwise + +# 입력 axis — 결제수단 등록 / SDK 매핑 / 웹훅 / 에스크로에 영향을 주는 변수 +axes: + order_sheet_mode: [false, true] + payment_method: [toss_card, toss_virtual_account, toss_transfer, toss_mobile_phone, toss_tosspay, toss_kakaopay, toss_naverpay, toss_payco, toss_samsungpay] + currency: [krw, non_krw] + use_escrow: [off, on, buyer_choice] + webhook_status: [done, canceled] + webhook_secret: [match, mismatch, disabled] + replay: [first, duplicate] + kg_coexists: [false, true] + # 주문 생성 시 서버로 보내는 payment_method — toss_* 는 코어 enum 이 거부하므로 core 값으로 번역해 전송 + core_payment_method_sent: [translated, raw_fallback] + # 관리자 설정 저장 서버측 범위 검증 (UI max 는 클라 힌트일 뿐 — API 직접 호출 우회 차단) + settings_range: [valid, out_of_range] + +axis_notes: + order_sheet_mode: OFF 이면 결제수단 미주입(통합결제창 카드). ON 일 때만 payment_method axis 유효. + currency: non_krw 는 카드 외 수단 차단(토스 국내 전용). 카드만 허용. + use_escrow: 가상계좌·계좌이체에만 적용. buyer_choice 는 useEscrow 키 자체 부재. + webhook_secret: match=200, mismatch=401, disabled=검증 스킵. + replay: duplicate 는 이미 PAID 인 거래 → 멱등 200, 상태 불변. + kg_coexists: KG 와 토스 동시 활성 시 no-PG 리스트 멱등 병합(양쪽 id 보존). + core_payment_method_sent: >- + translated = toss_* 선택 시 그 결제수단의 core_payment_method(vbank/bank/phone/card)를 payment_method 로 전송. + raw_fallback = core_payment_method 미선언 결제수단(dbank·KG 등)은 raw id 를 그대로 전송(기존 동작 보존). + 코어 PaymentMethodEnum 은 toss_* 를 거부하므로 미번역 전송 시 422 (본 이슈에서 수정). + settings_range: >- + out_of_range = vbank_valid_hours 가 1~2160(90일) 밖이거나 use_escrow 가 3-상태 밖 → 422 로 저장 차단. + valid = 범위 내 → 200 저장. UI input[max] 는 클라이언트 힌트라 API 직접 호출을 막지 못한다. + +# 의미 없는 조합 컷 +exclusions: + - { order_sheet_mode: false, payment_method: toss_virtual_account, reason: "결제창형에서는 결제수단 선택 없음(카드 통합결제창 단일)" } + - { currency: non_krw, payment_method: toss_virtual_account, reason: "비KRW 는 가상계좌 차단 — 카드만 허용" } + - { currency: non_krw, payment_method: toss_transfer, reason: "비KRW 는 계좌이체 차단" } + - { currency: non_krw, payment_method: toss_mobile_phone, reason: "비KRW 는 휴대폰 차단" } + - { payment_method: toss_card, use_escrow: on, reason: "카드는 에스크로 SDK 플래그 대상 아님(E1)" } + - { webhook_status: canceled, replay: duplicate, reason: "취소 통보는 리플레이 가드 대상 아님(PAID 아님)" } + +# 후속 효과 체인 +effects: + - toss_provider_registered_with_payment_handler + - enabled_methods_exposed_with_core_mapping + - vbank_account_stored_without_completing + - vbank_secret_stored_for_webhook + - is_escrow_snapshot_persisted + - webhook_secret_verified + - webhook_replay_idempotent + - deposit_done_completes_payment + - deposit_canceled_fails_payment + - non_waiting_deposit_is_noop + - non_krw_domestic_method_blocked + - sdk_method_mapped_from_enabled_methods + - virtual_account_payload_built + - escrow_products_attached + - escrow_flag_off_true_or_key_absent + - no_pg_list_idempotent_merge_preserves_kg + - escrow_products_localized_unit_price_per_item + # 코어 결제수단 번역 (본 이슈 수정분) + - core_payment_method_preserved_through_settings_merge + - core_payment_method_preserved_through_save_snapshot + - checkout_body_sends_core_payment_method + - non_mapped_method_falls_back_to_raw_id + - order_created_with_core_payment_method + # 관리자 설정 서버측 범위 검증 (본 이슈 수정분) + - out_of_range_vbank_hours_rejected + - invalid_use_escrow_rejected + - other_plugin_settings_not_validated + # SDK 페이로드 경계 (감사 후속 — 서버 조립과 SDK 전달은 별개 계약) + - escrow_products_attached_to_virtual_account_sdk_payload + - escrow_products_absent_when_escrow_off + - escrow_products_absent_for_card + # 레이아웃 리스너 실행 순서 불변식 (감사 후속) + - toss_layout_listener_runs_after_kginicis + # 에스크로 운영 안내 (W-6) + - escrow_operation_notice_shown_when_escrow_enabled + +notes: | + 실계약 전이므로 실제 PG 결제창 진입/승인은 샌드박스 의존이며 M20·M21(Chrome MCP)은 + "미커버 — 실계약 후" 로 명시한다. 본 매니페스트의 effects 는 서버/프론트 단위·통합 + 테스트로 커버한다. 부분취소 차단(E4)·환불 정합성(R1~R3)·현금영수증 프로바이더는 S5 범위. + + 에스크로 계약은 **두 겹**이다 — (1) 서버가 pg_payment_data.escrow_products 를 조립하고, + (2) 프론트 핸들러가 그것을 SDK 페이로드에 부착한다. 둘 중 하나만 검증하면 다른 쪽 결함을 + 놓친다: 실제로 서버 응답만 실측하고 통과 처리한 탓에, 가상계좌 분기가 attachEscrowProducts() + 를 호출하지 않는 결함(계좌이체에서만 부착)이 살아남았다. E2E spec 이 SDK 경계를 직접 + 캡처해 이 절반을 잠근다 (스텁 SDK + client-config 인터셉트 — 상점 설정은 오염시키지 않는다). diff --git a/templates/_bundled/sirsoft-basic/CHANGELOG.md b/templates/_bundled/sirsoft-basic/CHANGELOG.md index 981da4ad..6547b788 100644 --- a/templates/_bundled/sirsoft-basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-basic/CHANGELOG.md @@ -11,6 +11,7 @@ - 주문서의 무통장입금·가상계좌 결제 영역에 환불 계좌(은행·계좌번호·예금주) 입력란을 추가했습니다. 주문을 취소할 때 환불받을 계좌를 미리 남겨둘 수 있으며, 입력하지 않아도 주문할 수 있습니다. 다만 세 칸 중 하나라도 입력하면 나머지도 입력해야 합니다. - 주문서의 무통장입금 영역에 현금영수증 신청 폼이 표시될 수 있도록 확장 영역을 마련했습니다. 신청 폼 자체는 쇼핑몰 기능이 제공합니다. - 주문 상세의 결제 정보 영역에 현금영수증 발급 내역이 표시될 수 있도록 확장 영역을 마련했습니다. +- 주문서에서 결제 플러그인이 등록한 결제수단(예: "가상계좌 (토스페이먼츠)")을 선택해 주문할 수 있습니다. 선택한 결제수단은 주문 시 대응하는 기본 결제수단으로 전송되어, 결제창 호출과 이후 주문 처리가 정상적으로 이어집니다. - 회원가입 화면에 휴대폰번호·전화번호 입력란을 추가했습니다. 두 항목 모두 선택 사항입니다. ### Changed diff --git a/templates/_bundled/sirsoft-basic/__tests__/layouts/checkoutSummaryBodyPayload.test.tsx b/templates/_bundled/sirsoft-basic/__tests__/layouts/checkoutSummaryBodyPayload.test.tsx index fb7d7fc0..3383cec3 100644 --- a/templates/_bundled/sirsoft-basic/__tests__/layouts/checkoutSummaryBodyPayload.test.tsx +++ b/templates/_bundled/sirsoft-basic/__tests__/layouts/checkoutSummaryBodyPayload.test.tsx @@ -18,6 +18,7 @@ import { describe, it, expect } from 'vitest'; import { DataBindingEngine } from '@core/template-engine/DataBindingEngine'; import checkoutSummaryJson from '../../layouts/partials/shop/_checkout_summary.json'; +import checkoutJson from '../../layouts/shop/checkout.json'; /** 객체 트리에서 조건을 만족하는 첫 노드를 깊이우선 탐색 */ function findNode(node: any, predicate: (n: any) => boolean): any { @@ -62,12 +63,12 @@ function expectedLegacy(ctx: any): Record { temp_order_id: checkoutData?.data?.temp_order_id, orderer: _computed?.ordererDefaults, shipping: _local?.shipping, - payment_method: _computed?.selectedPaymentMethod, + payment_method: _computed?.selectedCorePaymentMethod, shipping_memo: _local?.shippingMemo === 'custom' ? _local?.shippingMemoCustom : _local?.shippingMemo, depositor_name: _local?.depositorName ?? _computed?.ordererDefaults?.name ?? '', dbank: - _computed?.selectedPaymentMethod === 'dbank' + _computed?.selectedCorePaymentMethod === 'dbank' ? { bank_code: _local?.selectedDbank?.bank_code, account_number: _local?.selectedDbank?.account_number, @@ -102,6 +103,7 @@ const baseCtx = (over: Record = {}) => ({ _computed: { ordererDefaults: { name: '홍길동', phone: '01012345678', email: 'a@b.c' }, selectedPaymentMethod: 'dbank', + selectedCorePaymentMethod: 'dbank', }, _local: { shipping: { recipient_name: '홍길동' }, @@ -135,6 +137,7 @@ describe('주문 생성 body — 확장 병합 칸 계약', () => { _computed: { ordererDefaults: { name: '김철수', phone: '', email: '' }, selectedPaymentMethod: 'card', + selectedCorePaymentMethod: 'card', }, _global: { currentUser: { uuid: 'user-uuid-1' } }, }), @@ -151,7 +154,7 @@ describe('주문 생성 body — 확장 병합 칸 계약', () => { '초기 진입 (빈 _local · calculation 없음)', { checkoutData: { data: { temp_order_id: null, calculation: null } }, - _computed: { ordererDefaults: undefined, selectedPaymentMethod: 'vbank' }, + _computed: { ordererDefaults: undefined, selectedPaymentMethod: 'vbank', selectedCorePaymentMethod: 'vbank' }, _local: {}, _global: {}, }, @@ -227,4 +230,116 @@ describe('주문 생성 body — 확장 병합 칸 계약', () => { }); }); }); + + // ───────────────────────────────────────────────────────────── + // #454 — 플러그인 결제수단(toss_*)의 코어 결제수단 전송 + // + // 유저 표면에서는 "토스 가상계좌"(toss_virtual_account) 라는 독립 결제수단이지만, + // 주문 레코드에는 코어 PaymentMethodEnum 값(vbank)으로 저장되어야 한다. 코어 enum 은 + // toss_* 를 거부하므로(422), body 의 payment_method 는 선택 결제수단의 core_payment_method + // 로 번역해 전송한다. toss_* 선택값 자체는 _local.paymentMethod 에 남아 SDK 호출 시 참조된다. + // ───────────────────────────────────────────────────────────── + describe('플러그인 결제수단 → 코어 결제수단 전송 (#454)', () => { + it('body 의 payment_method 는 _computed.selectedCorePaymentMethod 를 사용한다', () => { + const ctx = baseCtx({ + _computed: { + ordererDefaults: { name: '홍길동', phone: '01012345678', email: 'a@b.c' }, + selectedPaymentMethod: 'toss_virtual_account', + selectedCorePaymentMethod: 'vbank', + }, + }); + // toss_virtual_account 를 골랐어도 서버에는 코어 값 vbank 가 나가야 한다. + expect(evalBody(ctx).payment_method).toBe('vbank'); + }); + + it('코어 매핑이 없는 결제수단은 raw id 를 그대로 전송한다 (dbank·KG 무영향)', () => { + const ctx = baseCtx({ + _computed: { + ordererDefaults: { name: '홍길동', phone: '01012345678', email: 'a@b.c' }, + selectedPaymentMethod: 'dbank', + selectedCorePaymentMethod: 'dbank', + }, + }); + expect(evalBody(ctx).payment_method).toBe('dbank'); + }); + + it('dbank 분기(계좌 정보)는 코어 값 기준으로 판정된다', () => { + const ctx = baseCtx({ + _computed: { + ordererDefaults: { name: '홍길동', phone: '01012345678', email: 'a@b.c' }, + selectedPaymentMethod: 'dbank', + selectedCorePaymentMethod: 'dbank', + }, + }); + expect(evalBody(ctx).dbank).toEqual({ + bank_code: '004', + account_number: '110-123', + account_holder: '시르소프트', + }); + }); + + it('토스 결제수단 선택 시 dbank 분기는 null (계좌 정보 미포함)', () => { + const ctx = baseCtx({ + _computed: { + ordererDefaults: { name: '홍길동', phone: '01012345678', email: 'a@b.c' }, + selectedPaymentMethod: 'toss_virtual_account', + selectedCorePaymentMethod: 'vbank', + }, + }); + expect(evalBody(ctx).dbank).toBeNull(); + }); + }); + + // selectedCorePaymentMethod computed 자체를 checkout.json 원문에서 평가한다. + // body 테스트는 이 값을 주입받아 검증하므로, computed 가 실제로 카탈로그의 + // core_payment_method 를 해석하는지는 여기서 고정한다. + describe('selectedCorePaymentMethod computed (checkout.json 원문 평가)', () => { + const coreExpr: string = (checkoutJson as any).computed.selectedCorePaymentMethod; + + /** 모듈이 병합 시 보존하는 결제수단 카탈로그 (core_payment_method 포함) */ + const catalogCtx = (selected?: string) => ({ + _local: selected ? { paymentMethod: selected } : {}, + paymentSettings: { + data: { + order_settings: { + payment_methods: [ + { id: 'dbank', is_active: true }, + { id: 'toss_card', is_active: true, core_payment_method: 'card' }, + { id: 'toss_virtual_account', is_active: true, core_payment_method: 'vbank' }, + { id: 'toss_transfer', is_active: true, core_payment_method: 'bank' }, + // KG 는 core_payment_method 미선언 (인터셉터 방식) — raw id 폴백 대상 + { id: 'kginicis_japan_paypay', is_active: true }, + ], + }, + }, + }, + }); + + const evalCore = (ctx: Record) => + engine.evaluateExpression(coreExpr.slice(2, -2), ctx); + + it.each([ + ['toss_virtual_account', 'vbank'], + ['toss_transfer', 'bank'], + ['toss_card', 'card'], + ])('%s → 코어 %s 로 번역된다', (selected, expected) => { + expect(evalCore(catalogCtx(selected))).toBe(expected); + }); + + it('dbank(코어 매핑 미선언)는 raw id 그대로', () => { + expect(evalCore(catalogCtx('dbank'))).toBe('dbank'); + }); + + it('KG 결제수단(코어 매핑 미선언)은 raw id 그대로 — 인터셉터 방식 무영향', () => { + expect(evalCore(catalogCtx('kginicis_japan_paypay'))).toBe('kginicis_japan_paypay'); + }); + + it('미선택 시 첫 활성 결제수단으로 폴백한다', () => { + expect(evalCore(catalogCtx())).toBe('dbank'); + }); + + it('카탈로그가 비어도 dbank 로 폴백한다 (초기 진입)', () => { + expect(evalCore({ _local: {}, paymentSettings: undefined })).toBe('dbank'); + }); + }); }); diff --git a/templates/_bundled/sirsoft-basic/layouts/partials/shop/_checkout_summary.json b/templates/_bundled/sirsoft-basic/layouts/partials/shop/_checkout_summary.json index faedacc8..0143b662 100644 --- a/templates/_bundled/sirsoft-basic/layouts/partials/shop/_checkout_summary.json +++ b/templates/_bundled/sirsoft-basic/layouts/partials/shop/_checkout_summary.json @@ -464,7 +464,7 @@ "params": { "method": "POST", "headers": { "X-Cart-Key": "{{_global.cartKey}}" }, - "body": "{{ ({ temp_order_id: checkoutData.data.temp_order_id, orderer: _computed.ordererDefaults, shipping: _local.shipping, payment_method: _computed.selectedPaymentMethod, shipping_memo: _local.shippingMemo === 'custom' ? _local.shippingMemoCustom : _local.shippingMemo, depositor_name: _local.depositorName ?? _computed.ordererDefaults?.name ?? '', dbank: _computed.selectedPaymentMethod === 'dbank' ? { bank_code: _local.selectedDbank?.bank_code, account_number: _local.selectedDbank?.account_number, account_holder: _local.selectedDbank?.account_holder } : null, expected_total_amount: checkoutData.data.calculation?.summary?.final_amount ?? 0, save_shipping_address: _local.saveShippingAddress ?? false, guest_lookup_password: _global.currentUser?.uuid ? null : (_local.guestLookupPassword ?? ''), guest_lookup_password_confirmation: _global.currentUser?.uuid ? null : (_local.guestLookupPasswordConfirmation ?? ''), refund_bank: _local.refundBankCode ? { bank_code: _local.refundBankCode, account_number: _local.refundBankAccount ?? null, holder: _local.refundBankHolder ?? null } : null, ...(_local.checkoutExtraPayload ?? {}) }) }}" + "body": "{{ ({ temp_order_id: checkoutData.data.temp_order_id, orderer: _computed.ordererDefaults, shipping: _local.shipping, payment_method: _computed.selectedCorePaymentMethod, shipping_memo: _local.shippingMemo === 'custom' ? _local.shippingMemoCustom : _local.shippingMemo, depositor_name: _local.depositorName ?? _computed.ordererDefaults?.name ?? '', dbank: _computed.selectedCorePaymentMethod === 'dbank' ? { bank_code: _local.selectedDbank?.bank_code, account_number: _local.selectedDbank?.account_number, account_holder: _local.selectedDbank?.account_holder } : null, expected_total_amount: checkoutData.data.calculation?.summary?.final_amount ?? 0, save_shipping_address: _local.saveShippingAddress ?? false, guest_lookup_password: _global.currentUser?.uuid ? null : (_local.guestLookupPassword ?? ''), guest_lookup_password_confirmation: _global.currentUser?.uuid ? null : (_local.guestLookupPasswordConfirmation ?? ''), refund_bank: _local.refundBankCode ? { bank_code: _local.refundBankCode, account_number: _local.refundBankAccount ?? null, holder: _local.refundBankHolder ?? null } : null, ...(_local.checkoutExtraPayload ?? {}) }) }}" }, "onSuccess": [ { diff --git a/templates/_bundled/sirsoft-basic/layouts/shop/checkout.json b/templates/_bundled/sirsoft-basic/layouts/shop/checkout.json index e53070f1..7f01ed70 100644 --- a/templates/_bundled/sirsoft-basic/layouts/shop/checkout.json +++ b/templates/_bundled/sirsoft-basic/layouts/shop/checkout.json @@ -86,7 +86,9 @@ "_comment": "orderer 기본값 - _local.orderer가 없으면 _global.currentUser에서 가져옴 (새로고침 시 타이밍 문제 해결)", "ordererDefaults": "{{ { name: _local.orderer?.name || _global.currentUser?.name || '', phone: _local.orderer?.phone || _global.currentUser?.phone || '', email: _local.orderer?.email || _global.currentUser?.email || '' } }}", "_comment_payment": "선택된 결제수단 - _local에 없으면 첫 번째 활성 결제수단을 기본값으로 사용", - "selectedPaymentMethod": "{{_local.paymentMethod ?? (paymentSettings.data?.order_settings?.payment_methods ?? []).find(m => m.is_active)?.id ?? 'dbank'}}" + "selectedPaymentMethod": "{{_local.paymentMethod ?? (paymentSettings.data?.order_settings?.payment_methods ?? []).find(m => m.is_active)?.id ?? 'dbank'}}", + "_comment_core_payment": "서버 전송용 코어 결제수단 - 플러그인 결제수단(toss_* 등)은 코어 PaymentMethodEnum 이 거부하므로, 선택 결제수단의 core_payment_method(모듈이 병합 시 보존)로 번역해 전송한다. 미선언(dbank/KG 등)은 raw id 그대로. IIFE/타 computed 참조를 피하려 선택 id 해석을 인라인 반복한다.", + "selectedCorePaymentMethod": "{{(paymentSettings.data?.order_settings?.payment_methods ?? []).find(m => m.id === (_local.paymentMethod ?? (paymentSettings.data?.order_settings?.payment_methods ?? []).find(a => a.is_active)?.id ?? 'dbank'))?.core_payment_method ?? _local.paymentMethod ?? (paymentSettings.data?.order_settings?.payment_methods ?? []).find(a => a.is_active)?.id ?? 'dbank'}}" }, "data_sources": [ { @@ -230,7 +232,7 @@ { "type": "basic", "name": "Icon", - "props": { "name": "exclamation-circle", "className": "w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" } + "props": { "name": "exclamation-circle", "className": "text-xl text-red-500 flex-shrink-0 mt-0.5" } }, { "type": "basic", @@ -289,7 +291,7 @@ "name": "Icon", "props": { "name": "chevron-left", - "className": "w-4 h-4" + "className": "text-base" } }, { diff --git a/tests/Unit/Support/ApiDoc/ApiRouteInventoryFallbackTest.php b/tests/Unit/Support/ApiDoc/ApiRouteInventoryFallbackTest.php index 8e17b3ed..290977e2 100644 --- a/tests/Unit/Support/ApiDoc/ApiRouteInventoryFallbackTest.php +++ b/tests/Unit/Support/ApiDoc/ApiRouteInventoryFallbackTest.php @@ -53,8 +53,9 @@ class ApiRouteInventoryFallbackTest extends TestCase #[Test] public function web_admin_라우트는_ap_i_문서_대상에서_제외된다(): void { - // hello_module 의 admin CRUD 는 web.php(/modules/{id} prefix)라 api/ 로 시작하지 않아 - // API 문서 대상이 아니다. 폴백은 src/routes/api.php 만 로드한다. + // 확장 web 라우트 중 PG 콜백·웹훅은 문서 대상이지만(machine-facing), hello_module 의 + // admin CRUD 는 사람이 브라우저로 여는 관리자 화면(web.modules.{id}.admin.*)이라 제외된다. + // hello_module 의 web.php 는 admin 화면뿐이므로 결과적으로 api/ 라우트만 남는다. $routes = app(ApiRouteInventory::class)->collect('module:gnuboard7-hello_module'); foreach ($routes as $route) { @@ -63,6 +64,7 @@ class ApiRouteInventoryFallbackTest extends TestCase $names = array_column($routes, 'name'); $this->assertNotContains('api.modules.gnuboard7-hello_module.admin.memos.store', $names); + $this->assertNotContains('web.modules.gnuboard7-hello_module.admin.memos.store', $names); } #[Test] diff --git a/tests/Unit/Support/ApiDoc/ApiRouteInventoryWebRouteTest.php b/tests/Unit/Support/ApiDoc/ApiRouteInventoryWebRouteTest.php new file mode 100644 index 00000000..04884157 --- /dev/null +++ b/tests/Unit/Support/ApiDoc/ApiRouteInventoryWebRouteTest.php @@ -0,0 +1,123 @@ +collect('plugin:sirsoft-tosspayments'); + + $names = array_column($routes, 'name'); + + $this->assertContains('web.plugins.sirsoft-tosspayments.webhook.deposit', $names); + $this->assertContains('web.plugins.sirsoft-tosspayments.webhook.payment-status', $names); + } + + #[Test] + public function 플러그인_결제_콜백_web_라우트가_수집된다(): void + { + $routes = app(ApiRouteInventory::class)->collect('plugin:sirsoft-tosspayments'); + + $names = array_column($routes, 'name'); + + $this->assertContains('web.plugins.sirsoft-tosspayments.payment.success', $names); + $this->assertContains('web.plugins.sirsoft-tosspayments.payment.fail', $names); + } + + #[Test] + public function 수집된_web_라우트는_확장_소유와_uri_를_정확히_보존한다(): void + { + $routes = app(ApiRouteInventory::class)->collect('plugin:sirsoft-tosspayments'); + + $deposit = collect($routes)->firstWhere('name', 'web.plugins.sirsoft-tosspayments.webhook.deposit'); + + $this->assertNotNull($deposit); + $this->assertSame('POST', $deposit['method']); + $this->assertSame('/plugins/sirsoft-tosspayments/webhook/deposit', $deposit['uri']); + $this->assertSame('plugin', $deposit['owner']['type']); + $this->assertSame('sirsoft-tosspayments', $deposit['owner']['id']); + $this->assertSame( + 'Plugins\\Sirsoft\\Tosspayments\\Controllers\\WebhookController', + $deposit['controller'] + ); + } + + #[Test] + public function web_라우트도_도메인_그룹으로_분류된다(): void + { + $routes = app(ApiRouteInventory::class)->collect('plugin:sirsoft-tosspayments'); + + $byName = collect($routes)->keyBy('name'); + + // web.plugins.{id}. 를 걷어낸 첫 세그먼트가 도메인이다. + $this->assertSame('webhook', $byName['web.plugins.sirsoft-tosspayments.webhook.deposit']['domain_group']); + $this->assertSame('payment', $byName['web.plugins.sirsoft-tosspayments.payment.success']['domain_group']); + } + + #[Test] + public function 코어_web_라우트는_수집되지_않는다(): void + { + // 확장 소유가 확정되지 않는 web 라우트(코어 화면/블레이드 등)는 API 문서 대상이 아니다. + $routes = app(ApiRouteInventory::class)->collect('core'); + + foreach ($routes as $route) { + $this->assertStringStartsWith( + '/api/', + $route['uri'], + "코어 범위에는 api/ 라우트만 수집되어야 한다: {$route['uri']}" + ); + } + } + + #[Test] + public function 확장의_관리자_화면_web_라우트는_수집되지_않는다(): void + { + // hello_module 의 admin CRUD 는 사람이 브라우저로 여는 화면(web.modules.{id}.admin.*)이라 + // machine-facing 엔드포인트가 아니므로 API 레퍼런스 대상이 아니다. + $routes = app(ApiRouteInventory::class)->collect('module:gnuboard7-hello_module'); + + $names = array_column($routes, 'name'); + + $this->assertNotContains('web.modules.gnuboard7-hello_module.admin.memos.index', $names); + $this->assertNotContains('web.modules.gnuboard7-hello_module.admin.memos.store', $names); + + foreach ($routes as $route) { + $this->assertStringNotContainsString( + '/admin/', + $route['uri'], + "확장 관리자 화면 web 라우트가 수집되면 안 된다: {$route['uri']}" + ); + } + } + + #[Test] + public function 타_플러그인_web_라우트는_범위에_섞이지_않는다(): void + { + $routes = app(ApiRouteInventory::class)->collect('plugin:sirsoft-tosspayments'); + + foreach ($routes as $route) { + $this->assertSame('sirsoft-tosspayments', $route['owner']['id']); + } + + // KG 도 web 웹훅을 갖지만 토스 범위에는 포함되지 않는다. + $names = array_column($routes, 'name'); + $this->assertNotContains('web.plugins.sirsoft-pay_kginicis.payment.vbank-notify', $names); + } +}