fix(tosspayments,pay_nicepayments,pay_nhnkcp,pay_kginicis): 토스 전환 누락 보완 + 탭 복귀 브랜드 배지 재주입

토스페이먼츠가 "확장 결제수단 1급화" 전환에서 통째로 빠져 있었다. 토스 결제수단은
그 전환 3일 전에 들어왔고, 전환 커밋은 nicepayments·nhnkcp·kginicis 만 건드렸다.
 가 발견한 두 증상(브랜드 심벌 미노출 / PG 자유 선택)의 원인이 이 하나다.

토스페이먼츠
- 간편결제 5종에 brand_mark 를 선언한다. 같은 브랜드는 어느 PG 를 통하든 같은 배지여야
 하므로 문자·색을 다른 PG 플러그인과 동일 값으로 맞췄다. 카드·가상계좌·계좌이체·휴대폰은
 브랜드가 아니라 결제 형태이므로 키를 두지 않는다.
- defaults 에 pg_provider·pg_locked·needs_pg·refund_method 를 선언한다. 기존에는
 pg_provider 가 null 이라 관리자 화면이 "PG 고정" 배지 대신 선택지가 하나도 없는 PG
 셀렉트를 그렸고(토스 PG 는 자기 결제수단 id 를 supported_methods 로 선언하지 않는다),
 서버 쪽으로도 PG 없는 결제수단으로 오인될 수 있었다.
- 관리자 브랜드 injector 를 신설한다. 관리자 레이아웃은 아이콘 열을 _cached_icon 하나로만
 그리고 _cached_brand_mark 를 읽는 노드가 없어, 각 PG 플러그인이 자기 행의 아이콘을
 배지로 치환하는 구조인데 토스에만 그 파일이 없었다.

4개 PG 플러그인 공통
- 환경설정 탭 전환은 pushState 가 아니라 replaceState 로 URL 만 바꾼다(탭 왕복 1회에
 push 0 / replace 2 실측). pushState·popstate 만 후킹하던 injector 는 다른 탭에 갔다
 돌아오면 행은 다시 렌더되는데 배지만 사라진 채 남았다. replaceState 후킹을 추가한다.

브라우저 실측(Chrome MCP 7대 축)으로 전 항목 확인했다. 탭 복귀 재주입은 수정 전
toss 0 / nicepay 0 / kginicis 0 → 수정 후 5 / 8 / 4 로 복원되는 것을 계측했다.
This commit is contained in:
HeuJung
2026-08-10 17:17:53 +09:00
parent 30ad72a3a1
commit 1b0be674aa
19 changed files with 747 additions and 21 deletions
@@ -0,0 +1,108 @@
/**
* 주문설정 결제수단 — PG 플러그인 브랜드 마크 주입 회귀.
*
* 배경: 관리자 레이아웃은 아이콘 열을 `Icon name={{$method._cached_icon}}` 하나로만 그리고
* `_cached_brand_mark` 를 읽는 노드가 없다. 각 PG 플러그인이 자기 결제수단 행의 아이콘을
* 브랜드 배지로 치환하는 관리자 전용 injector 를 직접 싣는 구조다.
*
* 이 spec 이 고정하는 두 가지:
* 1. 설치된 PG 플러그인의 간편결제 행에 브랜드 배지가 주입된다 (플러그인별 data 속성으로 판정).
* 2. **다른 탭에 갔다 돌아와도** 배지가 재주입된다. 탭 전환은 pushState 가 아니라
* replaceState 로 URL 만 바꾸므로, pushState/popstate 만 후킹하던 구현은 복귀 후
* 배지가 사라진 채로 남았다(행은 정상 렌더되어 화면상 아이콘만 회색으로 되돌아감).
*
* 측정 규율:
* - 결제수단 ID·PG명을 하드코딩하지 않는다. 설치 구성은 환경마다 다르므로 브랜드 배지
* data 속성을 **런타임 카운트**로 판정하고, 배지가 0인 환경(간편결제 플러그인 미설치)은
* skip 이 아니라 "행이 렌더됐는지" 를 먼저 확인해 거짓 통과를 만들지 않는다.
*
* @scenario extension_payment_method method_kind=extension × capability=brand_mark
* @effects admin_shows_brand_mark, admin_rebinds_brand_mark_after_tab_return
*/
import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth';
import type { Page } from '@playwright/test';
const ORDER_SETTINGS_URL = '/admin/ecommerce/settings?tab=order_settings';
/** 설치된 모든 PG 플러그인의 브랜드 배지 총합 + 렌더된 결제수단 행 수. */
async function counts(page: Page): Promise<{ badges: number; rows: number }> {
return page.evaluate(() => ({
badges: document.querySelectorAll('[data-testid], span').length
&& document.querySelectorAll('span[class*="rounded-lg"][aria-hidden="true"]').length,
rows: document.querySelectorAll('[data-testid^="pg-locked-badge-"]').length,
}));
}
/** 플러그인별 브랜드 배지 카운트 (data 속성 접미사로 집계 — 플러그인명 하드코딩 회피). */
async function badgeCountsByPlugin(page: Page): Promise<Record<string, number>> {
return page.evaluate(() => {
const out: Record<string, number> = {};
for (const el of document.querySelectorAll<HTMLElement>('span[aria-hidden="true"]')) {
for (const name of Object.keys(el.dataset)) {
if (!/AdminPaymentBrandMark$/.test(name)) continue;
out[name] = (out[name] ?? 0) + 1;
}
}
return out;
});
}
/** 설정 탭을 라벨 텍스트로 클릭한다 (탭은 role/accessible-name 이 라벨과 일치하지 않는다). */
async function clickTab(page: Page, label: string): Promise<void> {
const clicked = await page.evaluate((text) => {
const el = [...document.querySelectorAll('button, a')].find((e) => e.textContent?.trim() === text);
if (!el) return false;
(el as HTMLElement).click();
return true;
}, label);
if (!clicked) throw new Error(`탭을 찾지 못했다: ${label}`);
}
async function gotoOrderSettings(page: Page, token: string): Promise<void> {
await authenticatePage(page, token);
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto(ORDER_SETTINGS_URL);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await expect.poll(async () => (await counts(page)).rows, { timeout: 30_000 }).toBeGreaterThan(0);
}
test.describe('@sirsoft-ecommerce 주문설정 결제수단 — 브랜드 마크', () => {
test('PG 플러그인의 간편결제 행에 브랜드 배지가 주입된다', async ({ page, settingsToken }) => {
await gotoOrderSettings(page, settingsToken);
await expect
.poll(async () => Object.values(await badgeCountsByPlugin(page)).reduce((a, b) => a + b, 0), { timeout: 20_000 })
.toBeGreaterThan(0);
const byPlugin = await badgeCountsByPlugin(page);
// 간편결제를 제공하는 PG 플러그인이 하나라도 설치돼 있으면 그 플러그인 배지가 잡혀야 한다.
expect(Object.keys(byPlugin).length, `브랜드 배지를 주입한 플러그인이 없다: ${JSON.stringify(byPlugin)}`)
.toBeGreaterThan(0);
});
test('다른 탭에 갔다 돌아와도 브랜드 배지가 재주입된다 (replaceState 회귀)', async ({ page, settingsToken }) => {
await gotoOrderSettings(page, settingsToken);
await expect
.poll(async () => Object.values(await badgeCountsByPlugin(page)).reduce((a, b) => a + b, 0), { timeout: 20_000 })
.toBeGreaterThan(0);
const before = await badgeCountsByPlugin(page);
// 탭 전환은 replaceState 로 URL 만 바꾼다 — 화면 전환을 실제 클릭으로 재현한다.
// 탭은 접근성 이름이 라벨과 정확히 일치하지 않으므로(내부 텍스트 노드 구성) 텍스트로 찾는다.
await clickTab(page, '배송설정');
await expect.poll(async () => (await counts(page)).rows, { timeout: 20_000 }).toBe(0);
await clickTab(page, '주문설정');
await expect.poll(async () => (await counts(page)).rows, { timeout: 20_000 }).toBeGreaterThan(0);
// 복귀 후 배지가 수정 전처럼 0 으로 남지 않아야 한다.
await expect
.poll(async () => Object.values(await badgeCountsByPlugin(page)).reduce((a, b) => a + b, 0), { timeout: 20_000 })
.toBeGreaterThan(0);
const after = await badgeCountsByPlugin(page);
expect(after, `탭 복귀 후 배지가 복원되지 않았다 (before=${JSON.stringify(before)} after=${JSON.stringify(after)})`)
.toEqual(before);
});
});
@@ -22,6 +22,7 @@
### Fixed
- 이커머스 관리자 주문설정에서 다른 탭에 갔다 돌아오면 간편결제 항목의 브랜드 로고가 사라지고 회색 기본 아이콘으로 남던 문제를 수정했습니다. 화면을 새로고침해야만 다시 표시됐습니다.
- 결제 취소 기록의 환불 금액에 원화 단위가 고정 표기되던 문제를 수정했습니다. 해외 결제(엔화 등) 취소도 실제 결제 통화로 표시됩니다.
- 쇼핑몰 주소를 기본값이 아닌 값으로 바꾸거나 주소 없이 운영하는 상점에서, 결제를 마치거나 결제에 실패한 구매자가 존재하지 않는 페이지로 이동하던 문제를 수정했습니다. 이동 주소가 기본 주소로 고정돼 있었기 때문이며, 이제 상점 주소 설정을 따라갑니다. 이미 운영 중인 사이트도 업데이트 시 자동으로 반영되며, 이동 주소를 직접 입력해 두었다면 그 값은 그대로 유지됩니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.)
@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-pay_kginicis",
"version": "1.0.2",
"version": "1.1.0",
"components": {
"basic": [],
"composite": [],
File diff suppressed because one or more lines are too long
@@ -265,6 +265,17 @@ export function installAdminPaymentMethodBrandInjector(): void {
origPush(...args);
window.setTimeout(() => onRouteChange(), 200);
};
// replaceState 도 후킹한다 — 환경설정 화면의 탭 전환은 pushState 가 아니라
// replaceState 로 URL 만 바꾼다(실측: 탭 왕복 1회에 push 0 / replace 2). pushState 만
// 후킹하면 다른 탭에 갔다 돌아왔을 때 onRouteChange 가 발화하지 않아 배지가 사라진 채로
// 남는다(행은 그대로 렌더되므로 화면상 아이콘만 회색으로 되돌아간다).
const origReplace = history.replaceState.bind(history);
history.replaceState = (...args: Parameters<typeof history.replaceState>) => {
origReplace(...args);
window.setTimeout(() => onRouteChange(), 200);
};
window.addEventListener('popstate', () => window.setTimeout(() => onRouteChange(), 200));
}
@@ -12,6 +12,7 @@
### Fixed
- 이커머스 관리자 주문설정에서 다른 탭에 갔다 돌아오면 간편결제 항목의 브랜드 색상 배지가 사라지고 회색 기본 아이콘으로 남던 문제를 수정했습니다. 화면을 새로고침해야만 다시 표시됐습니다.
- 관리자 주문 상세의 거래 조회에서 취소·환불 금액에 원화 단위가 고정 표기되던 문제를 수정했습니다. 이제 주문 시점 기준 통화로 표시됩니다.
- 결제 취소 기록의 환불 금액 표기도 결제 통화를 따르도록 수정했습니다.
- 쇼핑몰 주소를 기본값이 아닌 값으로 바꾸거나 주소 없이 운영하는 상점에서, 결제를 마치거나 결제에 실패한 구매자가 존재하지 않는 페이지로 이동하던 문제를 수정했습니다. 이동 주소가 기본 주소로 고정돼 있었기 때문이며, 이제 상점 주소 설정을 따라갑니다. 이미 운영 중인 사이트도 업데이트 시 자동으로 반영되며, 이동 주소를 직접 입력해 두었다면 그 값은 그대로 유지됩니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.)
@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-pay_nhnkcp",
"version": "1.0.0",
"version": "1.0.1",
"components": {
"basic": [],
"composite": [],
File diff suppressed because one or more lines are too long
@@ -263,6 +263,17 @@ export function installAdminPaymentMethodBrandInjector(): void {
origPush(...args);
window.setTimeout(() => onRouteChange(), 200);
};
// replaceState 도 후킹한다 — 환경설정 화면의 탭 전환은 pushState 가 아니라
// replaceState 로 URL 만 바꾼다(실측: 탭 왕복 1회에 push 0 / replace 2). pushState 만
// 후킹하면 다른 탭에 갔다 돌아왔을 때 onRouteChange 가 발화하지 않아 배지가 사라진 채로
// 남는다(행은 그대로 렌더되므로 화면상 아이콘만 회색으로 되돌아간다).
const origReplace = history.replaceState.bind(history);
history.replaceState = (...args: Parameters<typeof history.replaceState>) => {
origReplace(...args);
window.setTimeout(() => onRouteChange(), 200);
};
window.addEventListener('popstate', () => window.setTimeout(() => onRouteChange(), 200));
}
@@ -12,6 +12,7 @@
### Fixed
- 이커머스 관리자 주문설정에서 다른 탭에 갔다 돌아오면 간편결제 항목의 브랜드 색상 배지가 사라지고 회색 기본 아이콘으로 남던 문제를 수정했습니다. 화면을 새로고침해야만 다시 표시됐습니다.
- 결제 취소 기록과 환불 금액 오류 안내에 원화 단위가 고정 표기되던 문제를 수정했습니다. 이제 실제 결제 통화로 표시됩니다.
- 쇼핑몰 주소를 기본값이 아닌 값으로 바꾸거나 주소 없이 운영하는 상점에서, 결제를 마치거나 결제에 실패한 구매자가 존재하지 않는 페이지로 이동하던 문제를 수정했습니다. 이동 주소가 기본 주소로 고정돼 있었기 때문이며, 이제 상점 주소 설정을 따라갑니다. 이미 운영 중인 사이트도 업데이트 시 자동으로 반영되며, 이동 주소를 직접 입력해 두었다면 그 값은 그대로 유지됩니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.)
- 이 플러그인이 설치된 사이트에서 관리자 주문 목록의 '일부취소' 표시와 적립예정 포인트·마일리지 사용 금액, 주문상품의 추가옵션 요약이 보이지 않던 문제를 수정했습니다. 결제 정보 칸에 테스트결제 표시를 덧붙이는 과정에서 목록 표 구성 전체를 옛 버전으로 되돌려 놓고 있었습니다.
@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-pay_nicepayments",
"version": "1.0.0",
"version": "1.0.1",
"components": {
"basic": [],
"composite": [],
File diff suppressed because one or more lines are too long
@@ -272,6 +272,17 @@ export function installAdminPaymentMethodBrandInjector(): void {
origPush(...args);
window.setTimeout(() => onRouteChange(), 200);
};
// replaceState 도 후킹한다 — 환경설정 화면의 탭 전환은 pushState 가 아니라
// replaceState 로 URL 만 바꾼다(실측: 탭 왕복 1회에 push 0 / replace 2). pushState 만
// 후킹하면 다른 탭에 갔다 돌아왔을 때 onRouteChange 가 발화하지 않아 배지가 사라진 채로
// 남는다(행은 그대로 렌더되므로 화면상 아이콘만 회색으로 되돌아간다).
const origReplace = history.replaceState.bind(history);
history.replaceState = (...args: Parameters<typeof history.replaceState>) => {
origReplace(...args);
window.setTimeout(() => onRouteChange(), 200);
};
window.addEventListener('popstate', () => window.setTimeout(() => onRouteChange(), 200));
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,138 @@
/**
* 관리자 주문설정 브랜드 마크 주입 회귀.
*
* 배경: #475 "확장 결제수단 1급화" 가 nicepayments·nhnkcp·kginicis 에 관리자 브랜드
* injector 를 도입할 때 토스는 전환 대상에서 빠졌다(그 커밋에 tosspayments 파일 0건).
* 관리자 레이아웃은 아이콘 열을 `Icon name={{$method._cached_icon}}` 하나로만 그리므로,
* injector 가 없는 플러그인의 결제수단은 브랜드 배지 없이 회색 기본 아이콘으로 남는다.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
installAdminPaymentMethodBrandInjector,
resetAdminPaymentMethodBrandInjectorForTests,
syncRenderedAdminPaymentMethodBrands,
} from '../adminPaymentMethodBrandInjector';
function desktopItem(testId: string, title: string, description: string): string {
return `
<div class="flex-center border rounded-lg p-3 gap-4" data-test-item="${testId}">
<div data-drag-handle="true"><svg data-drag-icon="true"></svg></div>
<svg data-original-icon="true"></svg>
<div class="flex-1 min-w-0">
<div class="flex-center gap-2">
<span class="font-medium text-gray-900 dark:text-gray-100">${title}</span>
</div>
<span class="text-label-subtle">${description}</span>
</div>
<div class="row-stack">
<span>PG사</span>
<select></select>
</div>
</div>
`;
}
function mobileItem(testId: string, title: string): string {
return `
<div class="excel-card" data-test-item="${testId}">
<div class="excel-card-header">
<div class="flex-center gap-3 flex-1 min-w-0">
<div data-drag-handle="true"><svg data-drag-icon="true"></svg></div>
<svg data-original-icon="true"></svg>
<span class="font-medium text-gray-900 dark:text-gray-100 truncate">${title}</span>
</div>
</div>
<div class="excel-card-body">
<div class="row-stack"><label>PG사</label><select></select></div>
</div>
</div>
`;
}
describe('adminPaymentMethodBrandInjector (tosspayments)', () => {
beforeEach(() => {
document.documentElement.lang = 'ko';
window.history.pushState({}, '', '/admin/ecommerce/settings?tab=order_settings');
document.body.innerHTML = `
${desktopItem('toss-naverpay', '네이버페이 (토스페이먼츠)', '네이버페이 간편결제 — 토스페이먼츠를 통해 처리')}
${mobileItem('toss-kakaopay', '카카오페이 (토스페이먼츠)')}
${desktopItem('nice-naverpay', '네이버페이 (나이스페이먼츠)', '네이버페이로 결제 (나이스페이먼츠)')}
${desktopItem('toss-card', '신용카드 (토스페이먼츠)', '신용·체크카드로 결제 — 토스페이먼츠를 통해 처리')}
`;
});
afterEach(() => {
resetAdminPaymentMethodBrandInjectorForTests();
document.body.innerHTML = '';
});
it('토스 간편결제 행의 기본 아이콘을 브랜드 배지로 바꾼다', () => {
expect(syncRenderedAdminPaymentMethodBrands()).toBe(true);
const naverpay = document.querySelector<HTMLElement>('[data-test-item="toss-naverpay"]');
expect(naverpay?.dataset.tossAdminPaymentMethod).toBe('toss_naverpay');
expect(naverpay?.querySelector('[data-toss-admin-payment-brand-mark="true"]')?.textContent).toBe('N');
// 원래 아이콘은 치환되고, 드래그 핸들 아이콘은 남아야 한다
expect(naverpay?.querySelector('[data-original-icon="true"]')).toBeNull();
expect(naverpay?.querySelector('[data-drag-icon="true"]')).not.toBeNull();
});
it('모바일 카드 뷰에서도 동일하게 주입한다', () => {
expect(syncRenderedAdminPaymentMethodBrands()).toBe(true);
const kakaopay = document.querySelector<HTMLElement>('[data-test-item="toss-kakaopay"]');
expect(kakaopay?.dataset.tossAdminPaymentMethod).toBe('toss_kakaopay');
expect(kakaopay?.querySelector('[data-toss-admin-payment-brand-mark="true"]')?.textContent).toBe('K');
});
it('다른 PG 의 같은 브랜드 행은 건드리지 않는다 (플러그인 간 침범 금지)', () => {
syncRenderedAdminPaymentMethodBrands();
const nice = document.querySelector<HTMLElement>('[data-test-item="nice-naverpay"]');
expect(nice?.dataset.tossAdminPaymentMethod).toBeUndefined();
expect(nice?.querySelector('[data-original-icon="true"]')).not.toBeNull();
});
it('브랜드가 아닌 수단(카드)은 기본 아이콘을 유지한다', () => {
syncRenderedAdminPaymentMethodBrands();
const card = document.querySelector<HTMLElement>('[data-test-item="toss-card"]');
expect(card?.dataset.tossAdminPaymentMethod).toBeUndefined();
expect(card?.querySelector('[data-original-icon="true"]')).not.toBeNull();
});
it('주문설정 화면이 아니면 아무것도 바꾸지 않는다', () => {
window.history.pushState({}, '', '/admin/ecommerce/settings?tab=shipping');
expect(syncRenderedAdminPaymentMethodBrands()).toBe(false);
expect(document.querySelector('[data-toss-admin-payment-brand-mark="true"]')).toBeNull();
});
/**
* 탭 전환 후 복귀 시 배지 재주입 (브라우저 실측으로 발견한 회귀).
*
* 환경설정 화면의 탭 전환은 pushState 가 아니라 **replaceState** 로 URL 만 바꾼다
* (탭 왕복 1회에 push 0 / replace 2 실측). pushState/popstate 만 후킹하던 기존 구현은
* 다른 탭에 갔다 돌아왔을 때 onRouteChange 가 발화하지 않아, 행은 다시 렌더되는데
* 배지만 사라진 채로 남았다 — 사용자에게는 아이콘이 회색으로 되돌아간 것으로 보인다.
*/
it('replaceState 로 탭을 전환했다 돌아와도 배지를 재주입한다', async () => {
installAdminPaymentMethodBrandInjector();
expect(document.querySelectorAll('[data-toss-admin-payment-brand-mark="true"]').length).toBeGreaterThan(0);
// 다른 탭으로 이동 (replaceState) — 화면이 갈아끼워지며 배지가 사라진 상태를 재현
window.history.replaceState({}, '', '/admin/ecommerce/settings?tab=shipping');
document.body.innerHTML = '';
await new Promise((r) => setTimeout(r, 300));
// 주문설정으로 복귀 (replaceState) + 행 재렌더
document.body.innerHTML = desktopItem('toss-naverpay', '네이버페이 (토스페이먼츠)', '네이버페이 간편결제 — 토스페이먼츠를 통해 처리');
window.history.replaceState({}, '', '/admin/ecommerce/settings?tab=order_settings');
await new Promise((r) => setTimeout(r, 600));
expect(
document.querySelectorAll('[data-toss-admin-payment-brand-mark="true"]').length,
'replaceState 복귀 후 배지가 재주입되지 않았다',
).toBeGreaterThan(0);
});
});
@@ -0,0 +1,291 @@
/**
* 관리자 주문설정 결제수단 목록의 브랜드 마크 주입.
*
* 관리자 레이아웃(`_payment_methods_list.json` / `_payment_methods_cards.json`)은 결제수단
* 아이콘 열을 `Icon name={{$method._cached_icon}}` 하나로만 그린다 — 카탈로그의
* `_cached_brand_mark` 를 읽는 노드가 없다(그 값을 렌더하는 곳은 사용자 템플릿 체크아웃뿐).
* 그래서 각 PG 플러그인이 자기 결제수단 행의 아이콘을 브랜드 배지로 치환하는 관리자 전용
* injector 를 직접 싣는다. 토스에만 이 파일이 없어 간편결제가 회색 기본 아이콘으로 보였다.
*
* 구조는 다른 PG 플러그인(nicepayments·kginicis)의 동명 파일과 동일하게 맞춘다 — 행 판별은
* 라벨 텍스트, 치환 대상은 드래그 핸들/컨트롤을 제외한 첫 아이콘, 재적용은 MutationObserver
* + 폴링이다. 배지 색·문자는 같은 브랜드가 어느 PG 를 통하든 같아야 하므로 서버측
* `RegisterTossPaymentMethodsListener::METHOD_PRESENTATION` 의 `brand_mark` 와 값을 맞춘다.
*/
const PLUGIN_ID = 'sirsoft-tosspayments';
const FLAG = '__tossAdminPaymentMethodBrandInjectorInstalled';
const LISTENER_FLAG = '__tossAdminPaymentMethodBrandSyncListenerAttached';
const ADMIN_SETTINGS_RE = /^\/admin\/ecommerce\/settings\/?$/;
const MARK_SELECTOR = '[data-toss-admin-payment-brand-mark="true"]';
const SYNC_RETRY_INTERVAL_MS = 200;
const SYNC_RETRY_ATTEMPTS = 120;
interface AdminPaymentBrandDefinition {
id: string;
labels: string[];
shortLabels: string[];
markLines: string[];
markClassName: string;
}
const ADMIN_PAYMENT_BRAND_DEFINITIONS: AdminPaymentBrandDefinition[] = [
{
id: 'toss_tosspay',
labels: ['토스페이 (토스페이먼츠)', '토스페이 간편결제 — 토스페이먼츠를 통해 처리', 'TossPay (TossPayments)', 'TossPay easy payment — processed via TossPayments'],
shortLabels: [],
markLines: ['T'],
markClassName: 'bg-blue-500 text-white',
},
{
id: 'toss_kakaopay',
labels: ['카카오페이 (토스페이먼츠)', '카카오페이 간편결제 — 토스페이먼츠를 통해 처리', 'KakaoPay (TossPayments)', 'KakaoPay easy payment — processed via TossPayments'],
shortLabels: [],
markLines: ['K'],
markClassName: 'bg-yellow-400 text-gray-950',
},
{
id: 'toss_naverpay',
labels: ['네이버페이 (토스페이먼츠)', '네이버페이 간편결제 — 토스페이먼츠를 통해 처리', 'NaverPay (TossPayments)', 'NaverPay easy payment — processed via TossPayments'],
shortLabels: [],
markLines: ['N'],
markClassName: 'bg-green-500 text-white',
},
{
id: 'toss_payco',
labels: ['페이코 (토스페이먼츠)', '페이코 간편결제 — 토스페이먼츠를 통해 처리', 'PAYCO (TossPayments)', 'PAYCO easy payment — processed via TossPayments'],
shortLabels: [],
markLines: ['P'],
markClassName: 'bg-red-500 text-white',
},
{
id: 'toss_samsungpay',
labels: ['삼성페이 (토스페이먼츠)', '삼성페이 간편결제 — 토스페이먼츠를 통해 처리', 'Samsung Pay (TossPayments)', 'Samsung Pay easy payment — processed via TossPayments'],
shortLabels: [],
markLines: ['S'],
markClassName: 'bg-blue-600 text-white',
},
];
let observer: MutationObserver | null = null;
let retryTimer: number | null = null;
let syncQueued = false;
function windowRecord(): Record<string, unknown> {
return window as unknown as Record<string, unknown>;
}
function isOrderSettingsPage(): boolean {
if (!ADMIN_SETTINGS_RE.test(window.location.pathname)) return false;
const tab = new URLSearchParams(window.location.search).get('tab');
return tab === null || tab === '' || tab === 'order_settings';
}
function classNameOf(element: Element): string {
return element instanceof HTMLElement ? element.className : '';
}
function comparableText(value: string | null | undefined): string {
return (value ?? '')
.replace(/​/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function isPaymentMethodItem(element: HTMLElement): boolean {
const className = classNameOf(element);
return className.includes('excel-card')
|| (className.includes('flex-center') && className.includes('border') && className.includes('gap-4'));
}
function findTitleElement(item: HTMLElement): HTMLElement | null {
return item.querySelector<HTMLElement>('.font-medium');
}
function findDefinitionForItem(item: HTMLElement): AdminPaymentBrandDefinition | null {
const title = comparableText(findTitleElement(item)?.textContent);
const text = comparableText(item.textContent);
return ADMIN_PAYMENT_BRAND_DEFINITIONS.find((definition) => (
definition.shortLabels.some((label) => title === label)
|| definition.labels.some((label) => text.includes(label))
)) ?? null;
}
function findPaymentMethodItems(root: ParentNode): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>('div'))
.filter(isPaymentMethodItem);
}
function findPaymentIcon(item: HTMLElement): Element | null {
const icons = Array.from(item.querySelectorAll<Element>('svg, [data-icon], i'));
return icons.find((element) => (
!element.closest('[data-drag-handle]')
&& !element.closest(MARK_SELECTOR)
&& !element.closest('.row-stack, .excel-card-body, button, select, [role="switch"]')
)) ?? null;
}
function applyBrandMarkContent(mark: HTMLSpanElement, definition: AdminPaymentBrandDefinition): void {
mark.dataset.tossAdminPaymentBrandMark = 'true';
mark.dataset.tossAdminPaymentMethod = definition.id;
mark.setAttribute('aria-hidden', 'true');
mark.className = `inline-flex items-center justify-center rounded-lg font-bold ${definition.markClassName}`;
mark.style.width = '32px';
mark.style.height = '32px';
mark.style.flex = '0 0 32px';
mark.style.lineHeight = '1';
mark.style.fontSize = definition.markLines.join('').length > 2 ? '9px' : '12px';
const expectedText = definition.markLines.join('');
if (mark.textContent !== expectedText) {
mark.textContent = definition.markLines[0] ?? '';
}
}
function createBrandMark(definition: AdminPaymentBrandDefinition): HTMLSpanElement {
const mark = document.createElement('span');
applyBrandMarkContent(mark, definition);
return mark;
}
function syncBrandMark(item: HTMLElement, definition: AdminPaymentBrandDefinition): boolean {
const existing = item.querySelector<HTMLElement>(MARK_SELECTOR);
if (existing instanceof HTMLSpanElement) {
item.dataset.tossAdminPaymentMethod = definition.id;
applyBrandMarkContent(existing, definition);
return true;
}
const mark = createBrandMark(definition);
const icon = findPaymentIcon(item);
if (icon && icon.parentElement) {
item.dataset.tossAdminPaymentMethod = definition.id;
icon.replaceWith(mark);
return true;
}
const title = findTitleElement(item);
if (title && title.parentElement) {
item.dataset.tossAdminPaymentMethod = definition.id;
title.parentElement.insertBefore(mark, title);
return true;
}
return false;
}
export function syncRenderedAdminPaymentMethodBrands(root: ParentNode = document): boolean {
if (typeof window === 'undefined' || typeof document === 'undefined') return false;
if (!isOrderSettingsPage()) return false;
let matched = false;
findPaymentMethodItems(root).forEach((item) => {
const definition = findDefinitionForItem(item);
if (!definition) return;
if (syncBrandMark(item, definition)) {
matched = true;
}
});
return matched;
}
function queueSync(): void {
if (syncQueued) return;
syncQueued = true;
window.setTimeout(() => {
syncQueued = false;
syncRenderedAdminPaymentMethodBrands();
}, 0);
}
function stopRetries(): void {
if (retryTimer === null) return;
window.clearInterval(retryTimer);
retryTimer = null;
}
function startSync(): void {
if (!isOrderSettingsPage()) return;
stopRetries();
syncRenderedAdminPaymentMethodBrands();
let attempts = 0;
retryTimer = window.setInterval(() => {
attempts += 1;
syncRenderedAdminPaymentMethodBrands();
if (attempts >= SYNC_RETRY_ATTEMPTS) {
stopRetries();
}
}, SYNC_RETRY_INTERVAL_MS);
const body = document.body as HTMLElement & Record<string, unknown>;
if (body[LISTENER_FLAG]) return;
body[LISTENER_FLAG] = true;
observer = new MutationObserver(() => queueSync());
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
}
function onRouteChange(): void {
if (isOrderSettingsPage()) {
startSync();
return;
}
stopRetries();
}
export function installAdminPaymentMethodBrandInjector(): void {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
const w = windowRecord();
if (w[FLAG]) return;
w[FLAG] = true;
console.info(`[${PLUGIN_ID}] admin payment method brand sync installed`);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => startSync());
} else {
startSync();
}
const origPush = history.pushState.bind(history);
history.pushState = (...args: Parameters<typeof history.pushState>) => {
origPush(...args);
window.setTimeout(() => onRouteChange(), 200);
};
// replaceState 도 후킹한다 — 환경설정 화면의 탭 전환은 pushState 가 아니라
// replaceState 로 URL 만 바꾼다(실측: 탭 왕복 1회에 push 0 / replace 2). pushState 만
// 후킹하면 다른 탭에 갔다 돌아왔을 때 onRouteChange 가 발화하지 않아 배지가 사라진 채로
// 남는다(행은 그대로 렌더되므로 화면상 아이콘만 회색으로 되돌아간다).
const origReplace = history.replaceState.bind(history);
history.replaceState = (...args: Parameters<typeof history.replaceState>) => {
origReplace(...args);
window.setTimeout(() => onRouteChange(), 200);
};
window.addEventListener('popstate', () => window.setTimeout(() => onRouteChange(), 200));
}
export function resetAdminPaymentMethodBrandInjectorForTests(): void {
observer?.disconnect();
observer = null;
stopRetries();
syncQueued = false;
delete windowRecord()[FLAG];
if (document.body) {
delete ((document.body as HTMLElement & Record<string, unknown>)[LISTENER_FLAG]);
}
}
@@ -6,6 +6,7 @@
*/
import { handlerMap } from './handlers';
import { installAdminPaymentMethodBrandInjector } from './adminPaymentMethodBrandInjector';
const PLUGIN_IDENTIFIER = 'sirsoft-tosspayments';
@@ -62,6 +63,10 @@ function registerHandlers(): number {
* ActionDispatcher가 아직 준비되지 않았으면 최대 5초간 재시도합니다.
*/
function initPlugin(): void {
// 관리자 주문설정의 브랜드 마크 주입 — 핸들러 등록 성공 여부와 무관하게 설치한다
// (관리자 화면은 결제 핸들러를 쓰지 않으므로 ActionDispatcher 준비를 기다릴 이유가 없다).
installAdminPaymentMethodBrandInjector();
const doInit = () => {
const count = registerHandlers();
@@ -23,21 +23,47 @@ class RegisterTossPaymentMethodsListener implements HookListenerInterface
private const PLUGIN_IDENTIFIER = 'sirsoft-tosspayments';
/** RegisterPgProviderListener 가 등록하는 PG 제공자 id 와 동일해야 한다. */
private const PG_PROVIDER_ID = 'tosspayments';
/**
* toss_* id → 아이콘·다국어 키.
* toss_* id → 아이콘·브랜드 마크.
*
* @var array<string, array{icon:string}>
* 간편결제 수단은 brand_mark 로 브랜드 색상 배지를 노출한다. 같은 브랜드는 어느 PG 를
* 통하든 같은 배지로 보여야 하므로 text/class 값을 다른 PG 플러그인과 동일하게 맞춘다
* (naverpay N/녹색, kakaopay K/노랑, samsungpay S/파랑, payco P/빨강). 미선언 시
* 이커머스가 `_cached_brand_mark` 를 null 로 채워 회색 기본 아이콘만 표시된다.
*
* 카드·가상계좌·계좌이체·휴대폰은 브랜드가 아니라 결제 형태이므로 코어 빌트인 수단과
* 동일하게 아이콘만 둔다.
*
* @var array<string, array{icon:string, brand_mark?:array{text:string, class:string}}>
*/
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'],
'toss_tosspay' => [
'icon' => 'wallet',
'brand_mark' => ['text' => 'T', 'class' => 'bg-blue-500 text-white'],
],
'toss_kakaopay' => [
'icon' => 'wallet',
'brand_mark' => ['text' => 'K', 'class' => 'bg-yellow-400 text-gray-950'],
],
'toss_naverpay' => [
'icon' => 'wallet',
'brand_mark' => ['text' => 'N', 'class' => 'bg-green-500 text-white'],
],
'toss_payco' => [
'icon' => 'wallet',
'brand_mark' => ['text' => 'P', 'class' => 'bg-red-500 text-white'],
],
'toss_samsungpay' => [
'icon' => 'mobile-screen-button',
'brand_mark' => ['text' => 'S', 'class' => 'bg-blue-600 text-white'],
],
];
/**
@@ -118,7 +144,7 @@ class RegisterTossPaymentMethodsListener implements HookListenerInterface
$nameKey = "sirsoft-tosspayments::payment_methods.{$id}.name";
$descriptionKey = "sirsoft-tosspayments::payment_methods.{$id}.description";
return [
$entry = [
'id' => $id,
'name' => [
'ko' => __($nameKey, [], 'ko'),
@@ -131,8 +157,15 @@ class RegisterTossPaymentMethodsListener implements HookListenerInterface
'icon' => self::METHOD_PRESENTATION[$id]['icon'] ?? 'credit-card',
'source' => 'plugin:sirsoft-tosspayments',
'defaults' => [
// PG 선택 불필요 — payment_handler 정공법으로 토스 결제 흐름이 발화한다.
'pg_provider' => null,
// 토스 결제수단은 토스 결제창을 통해서만 처리되므로 PG 를 자기 자신으로 고정한다.
// null 로 두면 코어가 PG 없는 결제수단으로 오인해 결제 실패 주문에 관리자 알림이
// 발송되고 임시주문이 삭제되어 재결제가 막힌다(#475). 또한 관리자 화면이 "PG 고정"
// 배지 대신 선택지가 비어 있는 PG 셀렉트를 그린다 — 토스 PG 는 자기 결제수단 id 를
// supported_methods 로 선언하지 않으므로 고를 수 있는 항목이 하나도 없다.
'pg_provider' => self::PG_PROVIDER_ID,
'pg_locked' => true,
'needs_pg' => true,
'refund_method' => 'pg',
'is_active' => false,
'min_order_amount' => 0,
'stock_deduction_timing' => 'payment_complete',
@@ -141,6 +174,15 @@ class RegisterTossPaymentMethodsListener implements HookListenerInterface
'core_payment_method' => self::TOSS_METHOD_MAP[$id]['core'],
],
];
// 브랜드 마크는 간편결제 수단에만 있다. 없는 수단에 null 키를 남기지 않는다 —
// 이커머스 병합부가 `$definition['brand_mark'] ?? null` 로 읽으므로 결과는 같지만,
// 키 유무가 곧 "브랜드 수단인가" 의 선언이라 다른 PG 플러그인과 형식을 맞춘다.
if (isset(self::METHOD_PRESENTATION[$id]['brand_mark'])) {
$entry['brand_mark'] = self::METHOD_PRESENTATION[$id]['brand_mark'];
}
return $entry;
}
/**
@@ -128,8 +128,9 @@ class RegisterTossPaymentMethodsListenerTest extends PluginTestCase
// 가상계좌 → 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']);
// PG 는 토스로 고정된다 (#475 전환). 과거에는 null 이었고 그것이 관리자 화면의
// 빈 PG 셀렉트와 서버측 "PG 없는 결제수단" 오인의 원인이었다.
$this->assertSame('tosspayments', $byId['toss_virtual_account']['defaults']['pg_provider']);
}
public function test_all_toggles_off_injects_nothing(): void
@@ -140,4 +141,107 @@ class RegisterTossPaymentMethodsListenerTest extends PluginTestCase
$this->assertCount(3, $result);
}
/**
* 모든 토스 결제수단은 PG 를 자기 자신으로 고정 선언해야 한다 (#475 전환 누락 회귀).
*
* 배경: #475 가 확장 결제수단에 pg_locked/needs_pg/refund_method 선언을 도입할 때
* 토스는 3일 먼저 들어와 있었음에도 전환 대상에서 빠졌다. 그 결과 pg_provider 가 null 인
* 채로 needs_pg 만 참이 되어, 관리자 화면이 "PG 고정" 배지 대신 **선택지가 하나도 없는**
* PG 셀렉트를 그렸다(토스 PG 는 toss_* id 를 supported_methods 로 선언하지 않는다).
* 서버 쪽으로도 PG 없는 결제수단으로 오인되어 결제 실패 시 임시주문이 삭제될 수 있었다.
*/
public function test_all_methods_declare_pg_locked_to_self(): void
{
$this->mockSettings([
'order_sheet_mode' => true,
'method_card' => true,
'method_virtual_account' => true,
'method_transfer' => true,
'method_mobile_phone' => true,
'method_tosspay' => true,
'method_kakaopay' => true,
'method_naverpay' => true,
'method_payco' => true,
'method_samsungpay' => true,
]);
$injected = collect($this->listener->injectTossMethods($this->builtinMethods()))
->filter(fn ($m) => str_starts_with($m['id'] ?? '', 'toss_'));
$this->assertGreaterThan(0, $injected->count(), '토스 결제수단이 주입되지 않았다');
foreach ($injected as $method) {
$defaults = $method['defaults'] ?? [];
$id = $method['id'];
$this->assertSame('tosspayments', $defaults['pg_provider'] ?? null, "{$id}: PG 미고정");
$this->assertTrue($defaults['pg_locked'] ?? false, "{$id}: pg_locked 미선언 — 관리자가 다른 PG 로 바꿀 수 있게 된다");
$this->assertTrue($defaults['needs_pg'] ?? false, "{$id}: needs_pg 미선언");
$this->assertSame('pg', $defaults['refund_method'] ?? null, "{$id}: refund_method 미선언");
}
}
/**
* 간편결제 수단은 브랜드 마크를 선언해야 한다.
*
* 회귀 배경: 토스 수단만 brand_mark 를 선언하지 않아, 이커머스 병합부가
* `_cached_brand_mark` 를 null 로 채우고 관리자·체크아웃 화면이 브랜드 배지 대신
* 회색 기본 아이콘을 그렸다. 같은 브랜드가 다른 PG 를 통할 때와 다르게 보였다.
*
* 같은 브랜드는 어느 PG 를 통하든 같은 배지여야 하므로 text/class 를 함께 고정한다.
*/
public function test_easy_pay_methods_declare_brand_mark_consistent_with_other_pg(): void
{
$this->mockSettings([
'order_sheet_mode' => true,
'method_tosspay' => true,
'method_kakaopay' => true,
'method_naverpay' => true,
'method_payco' => true,
'method_samsungpay' => true,
]);
$byId = collect($this->listener->injectTossMethods($this->builtinMethods()))->keyBy('id');
// 다른 PG 플러그인(nicepayments·nhnkcp)이 같은 브랜드에 쓰는 값과 동일해야 한다.
$expected = [
'toss_naverpay' => ['text' => 'N', 'class' => 'bg-green-500 text-white'],
'toss_kakaopay' => ['text' => 'K', 'class' => 'bg-yellow-400 text-gray-950'],
'toss_samsungpay' => ['text' => 'S', 'class' => 'bg-blue-600 text-white'],
'toss_payco' => ['text' => 'P', 'class' => 'bg-red-500 text-white'],
'toss_tosspay' => ['text' => 'T', 'class' => 'bg-blue-500 text-white'],
];
foreach ($expected as $id => $mark) {
$this->assertArrayHasKey($id, $byId, "{$id} 가 주입되지 않았다");
$this->assertSame(
$mark,
$byId[$id]['brand_mark'] ?? null,
"{$id} 의 브랜드 마크가 다른 PG 와 다르다 — 같은 브랜드가 PG 마다 달라 보인다"
);
}
}
/**
* 카드·가상계좌·계좌이체·휴대폰은 브랜드가 아니라 결제 형태이므로 마크를 두지 않는다.
* (코어 빌트인 수단과 동일한 표현)
*/
public function test_non_brand_methods_have_no_brand_mark(): void
{
$this->mockSettings([
'order_sheet_mode' => true,
'method_card' => true,
'method_virtual_account' => true,
'method_transfer' => true,
'method_mobile_phone' => true,
]);
$byId = collect($this->listener->injectTossMethods($this->builtinMethods()))->keyBy('id');
foreach (['toss_card', 'toss_virtual_account', 'toss_transfer', 'toss_mobile_phone'] as $id) {
$this->assertArrayHasKey($id, $byId, "{$id} 가 주입되지 않았다");
$this->assertArrayNotHasKey('brand_mark', $byId[$id], "{$id} 는 브랜드 수단이 아니다");
}
}
}