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:
HeuJung
2026-08-11 09:20:08 +09:00
parent 152e3bfde7
commit 84e37bfa8f
17 changed files with 431 additions and 294 deletions
+13
View File
@@ -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
View File
@@ -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 에만
@@ -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,
);
}
}
@@ -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
{
@@ -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']);
}
}
@@ -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']);
}
}
@@ -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',
));
}
}
@@ -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 {}
@@ -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,
]);
}
}