fix(pay_tosspayments): 사문화된 결제수단 표시 리스너 제거 + 저장설정 PG 고정 백필
토스 결제수단의 PG 고정 선언( 전환)은 develop 의 선행 커밋이 이미 반영했다. 이 커밋은 그 전환에서 남은 뒷정리와, 같은 결함이 다시 나지 않게 하는 장치를 담는다. AdjustEcommercePaymentMethodsLayoutListener 는 코어 레이아웃 표현식의 no-PG 리스트 리터럴을 정규식으로 재작성해 표시를 바꾸던 리스너인데, 에서 코어가 그 리터럴을 버리면서 매치 대상이 사라져 이미 사문화된 상태였다. 단위 테스트가 그 리터럴을 합성 레이아웃에 직접 넣어 검증한 탓에 계속 통과해 사문화가 드러나지 않았다. 리스너와 그 테스트, 시나리오 매니페스트의 관련 effects 를 함께 걷어낸다. 런타임 병합은 정의값으로 자가 치유되지만 저장된 주문설정 파일에는 pg_provider=null 이 그대로 남는다. 자기 접두사 수단만 정정하는 멱등 업그레이드 스텝을 추가했다. 능력 선언 규약이 어디에도 문서화돼 있지 않아 4개 PG 플러그인 중 토스만 어긋난 채 남았으므로, 규약을 명문화하고 미선언을 검출하는 audit 룰을 신설했다(수정 전 코드에 red 확인 후 전수 스캔 green). 1.0.0 이 공개 발행되었으므로 발행 섹션에 누적하지 않고 1.0.1 로 올렸다.
This commit is contained in:
@@ -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 데이터 접근
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-tosspayments",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "토스페이먼츠",
|
||||
"en": "TossPayments"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "토스페이먼츠 결제 게이트웨이 (통합결제창 연동)",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 에만
|
||||
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use Plugins\Sirsoft\Tosspayments\Concerns\MapsTossPaymentMethods;
|
||||
|
||||
/**
|
||||
* 이커머스 결제수단 설정 화면에서 토스 주문서형 결제수단을 "PG 선택 불필요" 항목으로 표시한다.
|
||||
*
|
||||
* admin_ecommerce_settings 레이아웃의 여러 표현식에 등장하는 no-PG 결제수단 리스트 리터럴
|
||||
* (`['point','deposit','free','dbank', ...]`) 에 활성 토글된 toss_* id 를 추가한다.
|
||||
*
|
||||
* KG 플러그인이 같은 리스트를 상수 치환 방식으로 이미 재작성하므로, 두 플러그인이 동시
|
||||
* 활성일 때 서로의 결과를 덮어쓰지 않도록 "현재 리스트에 없는 id 만 append" 하는 멱등
|
||||
* 구현으로 작성한다 (KG 의 통짜 상수 치환을 복제하면 KG id 가 소실된다).
|
||||
*/
|
||||
class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterface
|
||||
{
|
||||
use MapsTossPaymentMethods;
|
||||
|
||||
private const TARGET_LAYOUT = 'admin_ecommerce_settings';
|
||||
|
||||
/**
|
||||
* no-PG 결제수단 리스트 리터럴의 앵커 — 코어가 항상 이 4종을 이 순서로 연다.
|
||||
* KG 가 먼저 실행되면 뒤에 kginicis_* 가 append 되어 있을 수 있으므로, 여는 대괄호부터
|
||||
* 닫는 대괄호까지 통째로 캡처해 그 안에 없는 toss_* id 만 추가한다.
|
||||
*/
|
||||
private const LIST_PATTERN = "/\\['point','deposit','free','dbank'([^\\]]*)\\]/";
|
||||
|
||||
/**
|
||||
* 구독할 훅 매핑 반환.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed> $layout 적용된 레이아웃
|
||||
* @param int $templateId 대상 템플릿 ID
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function markTossMethodsAsPgNotRequired(array $layout, int $templateId): array
|
||||
{
|
||||
if (($layout['layout_name'] ?? '') !== self::TARGET_LAYOUT) {
|
||||
return $layout;
|
||||
}
|
||||
|
||||
return $this->appendTossMethodsToNoPgLists($layout);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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
|
||||
{
|
||||
|
||||
+56
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
-160
@@ -1,160 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Tests\Unit\Listeners;
|
||||
|
||||
use Plugins\Sirsoft\Tosspayments\Listeners\AdjustEcommercePaymentMethodsLayoutListener;
|
||||
use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* AdjustEcommercePaymentMethodsLayoutListener 단위 테스트.
|
||||
*
|
||||
* no-PG 결제수단 리스트 리터럴에 toss_* id 를 멱등하게 병합하는지 검증한다.
|
||||
*
|
||||
* @scenario kg_coexists=true
|
||||
*
|
||||
* @effects no_pg_list_idempotent_merge_preserves_kg,
|
||||
* toss_layout_listener_runs_after_kginicis
|
||||
*/
|
||||
class AdjustEcommercePaymentMethodsLayoutListenerTest extends PluginTestCase
|
||||
{
|
||||
/** KG 플러그인 레이아웃 리스너의 priority (sirsoft-pay_kginicis). */
|
||||
private const KGINICIS_PRIORITY = 20;
|
||||
|
||||
/** KG 가 str_replace 대상으로 삼는 정확 리터럴 (닫는 대괄호 포함). */
|
||||
private const KGINICIS_ANCHOR = "['point','deposit','free','dbank']";
|
||||
|
||||
private AdjustEcommercePaymentMethodsLayoutListener $listener;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Tests\Unit\Upgrade;
|
||||
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Upgrades\Data\Ext\Plugins\SirsoftTosspayments\V1_0_1\Migrations\BackfillTossPgProvider;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 저장된 주문설정의 토스 주문서형 결제수단 PG 고정 백필.
|
||||
*
|
||||
* 주문서형 결제수단(toss_*)은 `pg_provider: null` 로 등록되어 저장 파일에 그대로
|
||||
* 영속화됐고, 관리자 주문설정 화면이 다른 간편결제와 달리 "PG 고정" 배지 대신 빈 PG
|
||||
* 선택 셀렉트를 그렸다. 백필은 자기 접두사 수단만, 멱등하게 현재 선언과 일치시킨다.
|
||||
*
|
||||
* @scenario method_kind=extension, capability_declared=declared, capability=pg_locked
|
||||
*
|
||||
* @effects upgrade_step_backfills_settings_file, saved_null_pg_provider_self_healed
|
||||
*
|
||||
* @group payment
|
||||
* @group upgrade
|
||||
*/
|
||||
class BackfillTossPgProviderTest extends PluginTestCase
|
||||
{
|
||||
private string $settingsPath;
|
||||
|
||||
private bool $hadOriginalSettings = false;
|
||||
|
||||
private ?string $originalSettings = null;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
require_once base_path('plugins/_bundled/sirsoft-tosspayments/upgrades/data/1.0.1/migrations/BackfillTossPgProvider.php');
|
||||
|
||||
$this->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<int, array<string, mixed>> $paymentMethods 결제수단 배열
|
||||
*/
|
||||
private function writeSettings(array $paymentMethods): void
|
||||
{
|
||||
File::put($this->settingsPath, json_encode([
|
||||
'payment_methods' => $paymentMethods,
|
||||
], JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 파일의 결제수단을 ID 키 맵으로 읽습니다.
|
||||
*
|
||||
* @return array<string, array<string, mixed>> 결제수단 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',
|
||||
));
|
||||
}
|
||||
}
|
||||
+10
-5
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\Tosspayments\Upgrades;
|
||||
|
||||
use App\Extension\AbstractUpgradeStep;
|
||||
|
||||
/**
|
||||
* v1.0.1 업그레이드 스텝
|
||||
*
|
||||
* 저장된 이커머스 주문설정의 토스 결제수단 항목에 PG 고정 선언을 백필한다.
|
||||
*
|
||||
* 모든 비즈니스 로직은 data/1.0.1/migrations/ 로 격리(AbstractUpgradeStep 규약).
|
||||
*
|
||||
* @upgrade-path B
|
||||
*/
|
||||
class Upgrade_1_0_1 extends AbstractUpgradeStep {}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Upgrades\Data\Ext\Plugins\SirsoftTosspayments\V1_0_1\Migrations;
|
||||
|
||||
use App\Extension\Helpers\FilePermissionHelper;
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 저장된 이커머스 주문설정의 토스 주문서형 결제수단 항목에 PG 고정 선언을 백필한다.
|
||||
*
|
||||
* 배경:
|
||||
*
|
||||
* 주문서형 결제수단(toss_*)은 과거 `pg_provider: null` 로 등록되어 저장 파일에 그대로
|
||||
* 영속화됐다. 그 결과 관리자 주문설정 화면이 다른 간편결제(KG 이니시스 등)와 달리
|
||||
* "PG 고정" 배지 대신 빈 PG 선택 셀렉트(---)를 그렸다.
|
||||
*
|
||||
* 현재 코드는 결제수단 카탈로그를 병합할 때 능력 선언(pg_provider / pg_locked / needs_pg /
|
||||
* refund_method)을 플러그인 정의에서 가져오므로 런타임 표시는 이미 정상이다(자가 치유).
|
||||
* 본 마이그레이션은 저장 파일 자체를 현재 선언과 일치시켜 설정 화면과 파일 내용이
|
||||
* 어긋나 보이지 않게 한다.
|
||||
*
|
||||
* V-1 안전 격리 (docs/extension/upgrade-step-guide.md §13):
|
||||
* - 파일 시스템 + FilePermissionHelper 만 사용 (이전 버전에도 존재하던 표면)
|
||||
* - Service / Manager / Repository 컨테이너 해석 없음
|
||||
*/
|
||||
final class BackfillTossPgProvider implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 이커머스 모듈의 주문설정 저장 경로.
|
||||
*/
|
||||
private const SETTINGS_PATH = 'app/modules/sirsoft-ecommerce/settings/order_settings.json';
|
||||
|
||||
/**
|
||||
* 이 플러그인이 등록하는 주문서형 결제수단의 ID 접두사.
|
||||
*/
|
||||
private const METHOD_PREFIX = 'toss_';
|
||||
|
||||
/**
|
||||
* 이 플러그인이 제공하는 PG 식별자.
|
||||
*/
|
||||
private const PG_PROVIDER_ID = 'tosspayments';
|
||||
|
||||
/**
|
||||
* 마이그레이션 식별자 (로그용).
|
||||
*
|
||||
* @return string 사람이 읽을 수 있는 짧은 식별자
|
||||
*/
|
||||
public function name(): string
|
||||
{
|
||||
return 'BackfillTossPgProvider';
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 주문설정의 토스 결제수단 항목에 PG 고정 선언을 백필한다. idempotent.
|
||||
*
|
||||
* @param UpgradeContext $context 업그레이드 컨텍스트 (로거 등)
|
||||
*/
|
||||
public function run(UpgradeContext $context): void
|
||||
{
|
||||
$path = storage_path(self::SETTINGS_PATH);
|
||||
|
||||
if (! File::exists($path)) {
|
||||
$context->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user