diff --git a/AGENTS.md b/AGENTS.md index 37b3d34b..bd3ef5a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -502,6 +502,19 @@ G7 은 **기본 통화**(상품·쿠폰·배송비 저장 기준), **표시 통 > 상세: [api-resources.md](docs/backend/api-resources.md), [service-repository.md](docs/backend/service-repository.md) +### 확장 결제수단은 자기 능력을 선언한다 + +코어 `PaymentMethodEnum` 은 확장 결제수단 ID(`kginicis_naverpay`, `toss_tosspay` 등)를 모른다. 그래서 능력(PG 필요 여부 / PG 고정 / 환불수단)은 **등록하는 확장이 카탈로그에 선언**하고, 관리자 화면과 서버는 그 선언만 읽는다. 미선언 시 안전 기본값(`needs_pg=true`, `pg_locked=false`, `pg_provider=null`)으로 떨어지는데, 그 조합은 "PG 가 필요한데 어느 PG 인지 모른다" 를 뜻해 화면과 실제 결제 경로가 어긋난다. + +| 금지 | 올바른 사용 | +|--------|---------------| +| entry `defaults` 에 `pg_provider` 만 두고 능력 키 생략 | `needs_pg` 명시 선언 (PG 결제창을 거치는가) | +| 자기 PG 전용 수단인데 `pg_provider: null` | `pg_provider: '{자기 provider id}'` + `pg_locked: true` (PG 제공자 등록 리스너의 id 와 동일해야 배지가 이름을 찾는다) | +| 표시를 고치려고 코어 레이아웃 표현식을 정규식 치환 | 카탈로그 선언만 바꾼다 — 레이아웃이 `pg_locked`/`needs_pg` 로 직접 3분기한다 | +| 선언을 바꾸고 기설치본은 그대로 | 저장된 `order_settings.json` 을 정정하는 업그레이드 스텝 동반 (자기 접두사만, 멱등) | + +레이아웃 치환 방식은 코어가 그 리터럴을 버리는 순간 조용히 사문화된다 — 합성 입력으로만 검증한 테스트는 계속 통과하므로 사문화가 드러나지 않는다. 정적 검사가 능력 선언 누락을 차단한다. + ### Listener 데이터 접근 | 금지 | 올바른 사용 | diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/extension-payment-method.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/extension-payment-method.yaml index c7cb72e0..240b6cc1 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/extension-payment-method.yaml +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/extension-payment-method.yaml @@ -23,6 +23,8 @@ test_files: - plugins/_bundled/sirsoft-pay_nicepayments/tests/Unit/Upgrades/BackfillEasyPayPgProviderTest.php - plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Listeners/RegisterEasyPayMethodsListenerTest.php - plugins/_bundled/sirsoft-pay_kginicis/tests/Unit/Upgrades/BackfillEasyPayPgProviderTest.php + - plugins/_bundled/sirsoft-tosspayments/tests/Feature/Listeners/RegisterTossPaymentMethodsListenerTest.php + - plugins/_bundled/sirsoft-tosspayments/tests/Unit/Upgrade/BackfillTossPgProviderTest.php # 입력 axis — 능력 해석에 영향을 주는 변수 axes: diff --git a/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md b/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md index a292fb3d..a2c01751 100644 --- a/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-tosspayments/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.1] - 2026-08-11 + +### Fixed + +- 관리자 주문설정에서 주문서형 결제수단의 PG사 표시가 저장된 설정과 어긋나던 문제를 수정했습니다. 이미 저장해 둔 주문설정도 업데이트 시 자동으로 정정되며, 활성 여부 등 직접 설정한 값은 그대로 유지됩니다. + ## [1.0.0] - 2026-08-10 ### Added diff --git a/plugins/_bundled/sirsoft-tosspayments/composer.json b/plugins/_bundled/sirsoft-tosspayments/composer.json index 309d3c88..ed455896 100644 --- a/plugins/_bundled/sirsoft-tosspayments/composer.json +++ b/plugins/_bundled/sirsoft-tosspayments/composer.json @@ -1,7 +1,7 @@ { "name": "plugins/sirsoft-tosspayments", "description": "TossPayments PG Plugin for G7 platform", - "version": "1.0.0", + "version": "1.0.1", "type": "library", "authors": [ { diff --git a/plugins/_bundled/sirsoft-tosspayments/package-lock.json b/plugins/_bundled/sirsoft-tosspayments/package-lock.json index 9a18976e..d841b69e 100644 --- a/plugins/_bundled/sirsoft-tosspayments/package-lock.json +++ b/plugins/_bundled/sirsoft-tosspayments/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-tosspayments", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-tosspayments", - "version": "1.0.0", + "version": "1.0.1", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/plugins/_bundled/sirsoft-tosspayments/package.json b/plugins/_bundled/sirsoft-tosspayments/package.json index 44b38612..a502c56a 100644 --- a/plugins/_bundled/sirsoft-tosspayments/package.json +++ b/plugins/_bundled/sirsoft-tosspayments/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-tosspayments", - "version": "1.0.0", + "version": "1.0.1", "type": "module", "private": true, "scripts": { diff --git a/plugins/_bundled/sirsoft-tosspayments/plugin.json b/plugins/_bundled/sirsoft-tosspayments/plugin.json index 3e507907..9d5c7d9e 100644 --- a/plugins/_bundled/sirsoft-tosspayments/plugin.json +++ b/plugins/_bundled/sirsoft-tosspayments/plugin.json @@ -5,7 +5,7 @@ "ko": "토스페이먼츠", "en": "TossPayments" }, - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "description": { "ko": "토스페이먼츠 결제 게이트웨이 (통합결제창 연동)", diff --git a/plugins/_bundled/sirsoft-tosspayments/plugin.php b/plugins/_bundled/sirsoft-tosspayments/plugin.php index 3cddb933..13b4f96c 100644 --- a/plugins/_bundled/sirsoft-tosspayments/plugin.php +++ b/plugins/_bundled/sirsoft-tosspayments/plugin.php @@ -242,7 +242,6 @@ class Plugin extends AbstractPlugin Listeners\RegisterPgProviderListener::class, Listeners\RegisterTossPaymentMethodsListener::class, Listeners\RegisterCashReceiptProviderListener::class, - Listeners\AdjustEcommercePaymentMethodsLayoutListener::class, Listeners\PaymentRefundListener::class, Listeners\ValidateTossSettingsListener::class, Listeners\RestoreLayoutExtensionsAfterUpdateListener::class, diff --git a/plugins/_bundled/sirsoft-tosspayments/src/Concerns/MapsTossPaymentMethods.php b/plugins/_bundled/sirsoft-tosspayments/src/Concerns/MapsTossPaymentMethods.php index e91d3523..e493e74a 100644 --- a/plugins/_bundled/sirsoft-tosspayments/src/Concerns/MapsTossPaymentMethods.php +++ b/plugins/_bundled/sirsoft-tosspayments/src/Concerns/MapsTossPaymentMethods.php @@ -7,10 +7,13 @@ namespace Plugins\Sirsoft\Tosspayments\Concerns; /** * 토스 주문서형 결제수단(toss_*) ↔ SDK method / easyPay provider / 코어 결제수단 매핑 SSoT. * - * 세 리스너가 공유한다: + * 두 리스너가 공유한다: * - RegisterTossPaymentMethodsListener: 활성 토글된 수단만 이커머스 결제수단 목록에 entry 로 주입 * - RegisterPgProviderListener::getClientConfig: enabled_methods 를 프론트 SDK 설정으로 내림 - * - AdjustEcommercePaymentMethodsLayoutListener: 전체 toss_* id 를 no-PG 리스트에 병합 + * + * 관리자 주문설정 화면의 PG 표시는 결제수단 카탈로그의 `pg_locked` / `needs_pg` 선언을 코어 + * 레이아웃이 직접 읽어 3분기(PG 고정 배지 / PG 선택 / PG 불필요)하므로, 과거처럼 레이아웃 + * 표현식의 no-PG 리스트 리터럴을 정규식으로 재작성하는 리스너는 두지 않는다(#475). * * core 값은 체크아웃이 서버로 보낼 코어 PaymentMethodEnum 값이다. toss_* id 는 코어 enum 이 * 거부하므로, 프론트는 이 core 값을 payment_method 로 전송하고 toss_* 선택값은 _local 에만 diff --git a/plugins/_bundled/sirsoft-tosspayments/src/Listeners/AdjustEcommercePaymentMethodsLayoutListener.php b/plugins/_bundled/sirsoft-tosspayments/src/Listeners/AdjustEcommercePaymentMethodsLayoutListener.php deleted file mode 100644 index fa213567..00000000 --- a/plugins/_bundled/sirsoft-tosspayments/src/Listeners/AdjustEcommercePaymentMethodsLayoutListener.php +++ /dev/null @@ -1,119 +0,0 @@ -> - */ - 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/RegisterTossPaymentMethodsListener.php b/plugins/_bundled/sirsoft-tosspayments/src/Listeners/RegisterTossPaymentMethodsListener.php index bdc73f83..174a9ae5 100644 --- a/plugins/_bundled/sirsoft-tosspayments/src/Listeners/RegisterTossPaymentMethodsListener.php +++ b/plugins/_bundled/sirsoft-tosspayments/src/Listeners/RegisterTossPaymentMethodsListener.php @@ -14,8 +14,9 @@ use Plugins\Sirsoft\Tosspayments\Concerns\MapsTossPaymentMethods; * builtin 결제수단 배열의 'phone' 뒤, 'point' 앞에 활성 토글된 toss_* 결제수단을 삽입한다. * * order_sheet_mode 가 false 면 아무것도 주입하지 않는다 — 결제창형에서는 기존 card 하나로 - * 통합결제창이 뜬다. 각 entry 의 defaults.pg_provider 는 null(PG 선택 불필요)이며, - * defaults.core_payment_method 로 체크아웃이 서버에 보낼 코어 PaymentMethodEnum 값을 선언한다. + * 통합결제창이 뜬다. 각 entry 는 토스 결제창으로만 처리되므로 PG 를 자기 자신으로 고정 + * (defaults.pg_provider = 'tosspayments' + pg_locked)하며, defaults.core_payment_method 로 + * 체크아웃이 서버에 보낼 코어 PaymentMethodEnum 값을 선언한다. */ class RegisterTossPaymentMethodsListener implements HookListenerInterface { diff --git a/plugins/_bundled/sirsoft-tosspayments/tests/Feature/Listeners/RegisterTossPaymentMethodsListenerTest.php b/plugins/_bundled/sirsoft-tosspayments/tests/Feature/Listeners/RegisterTossPaymentMethodsListenerTest.php index f57af4a8..9620d245 100644 --- a/plugins/_bundled/sirsoft-tosspayments/tests/Feature/Listeners/RegisterTossPaymentMethodsListenerTest.php +++ b/plugins/_bundled/sirsoft-tosspayments/tests/Feature/Listeners/RegisterTossPaymentMethodsListenerTest.php @@ -3,6 +3,7 @@ namespace Plugins\Sirsoft\Tosspayments\Tests\Feature\Listeners; use App\Services\PluginSettingsService; +use Plugins\Sirsoft\Tosspayments\Listeners\RegisterPgProviderListener; use Plugins\Sirsoft\Tosspayments\Listeners\RegisterTossPaymentMethodsListener; use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase; @@ -244,4 +245,59 @@ class RegisterTossPaymentMethodsListenerTest extends PluginTestCase $this->assertArrayNotHasKey('brand_mark', $byId[$id], "{$id} 는 브랜드 수단이 아니다"); } } + + /** + * 고정한 PG 식별자는 이 플러그인이 등록하는 provider id 와 같아야 한다. + * + * 어긋나면 관리자 화면의 PG 고정 배지가 표시명을 찾지 못해 raw id 를 그대로 노출하고, + * 서버의 결제 진입 핸들러 조회(resolvePgPaymentHandler)도 실패한다. 두 리스너가 문자열을 + * 각자 들고 있으므로 한쪽만 바뀌는 것을 테스트가 막는다. + * + * @effects locked_pg_id_matches_registered_provider + */ + public function test_locked_pg_id_matches_registered_provider_id(): void + { + $this->mockSettings(['order_sheet_mode' => true, 'method_card' => true]); + + $registered = (new RegisterPgProviderListener)->registerProvider([]); + $providerIds = array_column($registered, 'id'); + + $result = $this->listener->injectTossMethods($this->builtinMethods()); + $tossCard = collect($result)->keyBy('id')['toss_card']; + + $this->assertContains($tossCard['defaults']['pg_provider'], $providerIds); + } + + /** + * 다른 PG 플러그인이 먼저 등록한 결제수단의 선언을 덮지 않는다. + * + * 각 플러그인은 자기 수단의 PG 만 고정한다. 훅 체인에서 뒤에 실행되는 플러그인이 앞선 + * 선언을 건드리면 동시 활성 상점에서 한쪽 PG 표시가 조용히 뒤바뀐다. + * + * @scenario kg_coexists=true + * + * @effects other_plugin_methods_untouched_by_toss_injection + */ + public function test_does_not_touch_other_plugin_methods(): void + { + $this->mockSettings(['order_sheet_mode' => true, 'method_card' => true]); + + $kgEntry = [ + 'id' => 'kginicis_naverpay', + 'source' => 'plugin:sirsoft-pay_kginicis', + 'defaults' => ['pg_provider' => 'kginicis', 'pg_locked' => true, 'needs_pg' => true], + ]; + + $result = $this->listener->injectTossMethods([ + ['id' => 'card'], + ['id' => 'phone'], + $kgEntry, + ['id' => 'point'], + ]); + + $byId = collect($result)->keyBy('id'); + + $this->assertSame($kgEntry, $byId['kginicis_naverpay']); + $this->assertSame('tosspayments', $byId['toss_card']['defaults']['pg_provider']); + } } diff --git a/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/AdjustEcommercePaymentMethodsLayoutListenerTest.php b/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/AdjustEcommercePaymentMethodsLayoutListenerTest.php deleted file mode 100644 index 19ecef60..00000000 --- a/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Listeners/AdjustEcommercePaymentMethodsLayoutListenerTest.php +++ /dev/null @@ -1,160 +0,0 @@ -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/Upgrade/BackfillTossPgProviderTest.php b/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Upgrade/BackfillTossPgProviderTest.php new file mode 100644 index 00000000..535048dd --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/tests/Unit/Upgrade/BackfillTossPgProviderTest.php @@ -0,0 +1,183 @@ +settingsPath = storage_path('app/modules/sirsoft-ecommerce/settings/order_settings.json'); + $this->hadOriginalSettings = File::exists($this->settingsPath); + $this->originalSettings = $this->hadOriginalSettings ? File::get($this->settingsPath) : null; + + File::ensureDirectoryExists(dirname($this->settingsPath)); + } + + protected function tearDown(): void + { + if ($this->hadOriginalSettings && $this->originalSettings !== null) { + File::put($this->settingsPath, $this->originalSettings); + } else { + File::delete($this->settingsPath); + } + + parent::tearDown(); + } + + public function test_backfills_pg_declaration_for_toss_methods(): void + { + // 결함 시절의 저장 상태 재현: pg_provider=null, 능력 키 부재 + $this->writeSettings([ + ['id' => 'card', 'pg_provider' => 'tosspayments'], + ['id' => 'toss_tosspay', 'pg_provider' => null, 'is_active' => true], + ['id' => 'toss_naverpay', 'pg_provider' => null, 'is_active' => true], + ]); + + $this->runMigration(); + + $methods = $this->readMethods(); + + foreach (['toss_tosspay', 'toss_naverpay'] as $id) { + $method = $methods[$id]; + $this->assertSame('tosspayments', $method['pg_provider'], "{$id} 의 PG 가 고정되어야 한다"); + $this->assertTrue($method['pg_locked']); + $this->assertTrue($method['needs_pg']); + $this->assertSame('pg', $method['refund_method']); + // 관리자가 설정한 값은 보존한다. + $this->assertTrue($method['is_active']); + } + } + + public function test_does_not_touch_other_plugins_or_builtin_methods(): void + { + // 각 플러그인은 자기 접두사 수단만 백필한다 (다른 PG 의 수단을 건드리면 안 된다). + $this->writeSettings([ + ['id' => 'card', 'pg_provider' => 'tosspayments'], + ['id' => 'dbank', 'pg_provider' => ''], + ['id' => 'kginicis_naverpay', 'pg_provider' => null], + ['id' => 'toss_card', 'pg_provider' => null], + ]); + + $this->runMigration(); + + $methods = $this->readMethods(); + + $this->assertSame('tosspayments', $methods['card']['pg_provider']); + $this->assertArrayNotHasKey('pg_locked', $methods['card']); + + $this->assertSame('', $methods['dbank']['pg_provider']); + $this->assertArrayNotHasKey('pg_locked', $methods['dbank']); + + // 다른 PG 플러그인의 수단은 그 플러그인의 스텝이 처리한다. + $this->assertNull($methods['kginicis_naverpay']['pg_provider']); + $this->assertArrayNotHasKey('pg_locked', $methods['kginicis_naverpay']); + + $this->assertSame('tosspayments', $methods['toss_card']['pg_provider']); + } + + public function test_is_idempotent(): void + { + $this->writeSettings([ + ['id' => 'toss_card', 'pg_provider' => null], + ]); + + $this->runMigration(); + $first = File::get($this->settingsPath); + + $this->runMigration(); + $second = File::get($this->settingsPath); + + $this->assertSame($first, $second, '재실행해도 결과가 달라지지 않아야 한다'); + } + + public function test_skips_when_settings_file_is_missing(): void + { + File::delete($this->settingsPath); + + $this->runMigration(); + + $this->assertFalse(File::exists($this->settingsPath)); + } + + public function test_skips_when_settings_json_is_malformed(): void + { + File::put($this->settingsPath, '{ not valid json'); + + $this->runMigration(); + + // 손상된 파일을 덮어써서 더 망가뜨리지 않는다. + $this->assertSame('{ not valid json', File::get($this->settingsPath)); + } + + /** + * 테스트용 주문설정 파일을 기록합니다. + * + * @param array> $paymentMethods 결제수단 배열 + */ + private function writeSettings(array $paymentMethods): void + { + File::put($this->settingsPath, json_encode([ + 'payment_methods' => $paymentMethods, + ], JSON_THROW_ON_ERROR)); + } + + /** + * 저장 파일의 결제수단을 ID 키 맵으로 읽습니다. + * + * @return array> 결제수단 ID => 설정 + */ + private function readMethods(): array + { + $settings = json_decode(File::get($this->settingsPath), true, flags: JSON_THROW_ON_ERROR); + + $keyed = []; + foreach ($settings['payment_methods'] as $method) { + $keyed[$method['id']] = $method; + } + + return $keyed; + } + + /** + * 백필 마이그레이션을 실행합니다. + */ + private function runMigration(): void + { + (new BackfillTossPgProvider)->run(new UpgradeContext( + fromVersion: '1.0.0', + toVersion: '1.0.1', + currentStep: '1.0.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 index 29476e29..47cb1721 100644 --- 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 @@ -12,8 +12,8 @@ test_files: - 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/tests/Unit/Upgrade/BackfillTossPgProviderTest.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 @@ -48,7 +48,11 @@ axis_notes: use_escrow: 가상계좌·계좌이체에만 적용. buyer_choice 는 useEscrow 키 자체 부재. webhook_secret: match=200, mismatch=401, disabled=검증 스킵. replay: duplicate 는 이미 PAID 인 거래 → 멱등 200, 상태 불변. - kg_coexists: KG 와 토스 동시 활성 시 no-PG 리스트 멱등 병합(양쪽 id 보존). + kg_coexists: >- + KG 와 토스 동시 활성 시 각 플러그인이 자기 결제수단의 PG 를 자기 자신으로 고정 선언하며 + 상대 수단의 선언을 덮지 않는다. (과거에는 레이아웃 표현식의 no-PG 리스트 리터럴을 + 정규식으로 재작성해 표시를 바꿨고 그 병합 순서가 관건이었다 — 지금은 카탈로그 선언이므로 + 순서 의존이 없다.) core_payment_method_sent: >- translated = toss_* 선택 시 그 결제수단의 core_payment_method(vbank/bank/phone/card)를 payment_method 로 전송. raw_fallback = core_payment_method 미선언 결제수단(dbank·KG 등)은 raw id 를 그대로 전송(기존 동작 보존). @@ -83,8 +87,11 @@ effects: - 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 + # 관리자 주문설정의 PG 표시 (카탈로그 능력 선언) + - toss_methods_declare_pg_locked_to_tosspayments + - locked_pg_id_matches_registered_provider + - other_plugin_methods_untouched_by_toss_injection # 코어 결제수단 번역 (본 이슈 수정분) - core_payment_method_preserved_through_settings_merge - core_payment_method_preserved_through_save_snapshot @@ -99,8 +106,6 @@ effects: - 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 diff --git a/plugins/_bundled/sirsoft-tosspayments/upgrades/Upgrade_1_0_1.php b/plugins/_bundled/sirsoft-tosspayments/upgrades/Upgrade_1_0_1.php new file mode 100644 index 00000000..89d0cb30 --- /dev/null +++ b/plugins/_bundled/sirsoft-tosspayments/upgrades/Upgrade_1_0_1.php @@ -0,0 +1,18 @@ +logger->info('[tosspayments] 이커머스 주문설정 파일 없음 — 기본값으로 동작하므로 skip'); + + return; + } + + $settings = json_decode(File::get($path), true); + if (! is_array($settings) || ! is_array($settings['payment_methods'] ?? null)) { + $context->logger->warning('[tosspayments] 주문설정 JSON 형식 비정상 — 결제수단 PG 백필 skip'); + + return; + } + + $updated = 0; + foreach ($settings['payment_methods'] as $index => $method) { + if (! is_array($method)) { + continue; + } + + $id = (string) ($method['id'] ?? ''); + if (! str_starts_with($id, self::METHOD_PREFIX)) { + continue; + } + + $before = [ + $method['pg_provider'] ?? null, + $method['pg_locked'] ?? null, + $method['needs_pg'] ?? null, + $method['refund_method'] ?? null, + ]; + + $method['pg_provider'] = self::PG_PROVIDER_ID; + $method['pg_locked'] = true; + $method['needs_pg'] = true; + $method['refund_method'] = 'pg'; + + $after = [ + $method['pg_provider'], + $method['pg_locked'], + $method['needs_pg'], + $method['refund_method'], + ]; + + if ($before !== $after) { + $updated++; + } + + $settings['payment_methods'][$index] = $method; + } + + if ($updated === 0) { + $context->logger->info('[tosspayments] 결제수단 PG 선언이 이미 최신 — 변경 없음'); + + return; + } + + File::put($path, json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + FilePermissionHelper::inheritOwnershipFromParent($path); + + $context->logger->info('[tosspayments] 주문서형 결제수단 PG 고정 선언 백필 완료', [ + 'pg_provider' => self::PG_PROVIDER_ID, + 'updated_methods' => $updated, + ]); + } +}